Fix loading wrong filter problem, implement creation and deletion of filters and jails, fix some css mismatches, update the handlers and routes

This commit is contained in:
2025-12-30 01:10:49 +01:00
parent b9d8f1b39a
commit 84a97eaa96
18 changed files with 1735 additions and 421 deletions

View File

@@ -118,14 +118,21 @@ func (ac *AgentConnector) RestartWithMode(ctx context.Context) (string, error) {
return "restart", nil return "restart", nil
} }
func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) { func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
var resp struct { var resp struct {
Config string `json:"config"` Config string `json:"config"`
FilePath string `json:"filePath"`
} }
if err := ac.get(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), &resp); err != nil { if err := ac.get(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), &resp); err != nil {
return "", err return "", "", err
} }
return resp.Config, nil // If agent doesn't return filePath, construct it (agent should handle .local priority)
filePath := resp.FilePath
if filePath == "" {
// Default to .local path (agent should handle .local priority on its side)
filePath = fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail)
}
return resp.Config, filePath, nil
} }
func (ac *AgentConnector) SetFilterConfig(ctx context.Context, jail, content string) error { func (ac *AgentConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
@@ -196,6 +203,14 @@ func (ac *AgentConnector) put(ctx context.Context, endpoint string, payload any,
return ac.do(req, out) return ac.do(req, out)
} }
func (ac *AgentConnector) delete(ctx context.Context, endpoint string, out any) error {
req, err := ac.newRequest(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return err
}
return ac.do(req, out)
}
func (ac *AgentConnector) newRequest(ctx context.Context, method, endpoint string, payload any) (*http.Request, error) { func (ac *AgentConnector) newRequest(ctx context.Context, method, endpoint string, payload any) (*http.Request, error) {
u := *ac.base u := *ac.base
u.Path = path.Join(ac.base.Path, strings.TrimPrefix(endpoint, "/")) u.Path = path.Join(ac.base.Path, strings.TrimPrefix(endpoint, "/"))
@@ -310,14 +325,21 @@ func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, log
} }
// GetJailConfig implements Connector. // GetJailConfig implements Connector.
func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (string, error) { func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
var resp struct { var resp struct {
Config string `json:"config"` Config string `json:"config"`
FilePath string `json:"filePath"`
} }
if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), &resp); err != nil { if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), &resp); err != nil {
return "", err return "", "", err
} }
return resp.Config, nil // If agent doesn't return filePath, construct it (agent should handle .local priority)
filePath := resp.FilePath
if filePath == "" {
// Default to .local path (agent should handle .local priority on its side)
filePath = fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail)
}
return resp.Config, filePath, nil
} }
// SetJailConfig implements Connector. // SetJailConfig implements Connector.
@@ -415,3 +437,31 @@ func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error {
// For now, we'll try calling it and handle the error gracefully // For now, we'll try calling it and handle the error gracefully
return ac.post(ctx, "/v1/jails/ensure-structure", nil, nil) return ac.post(ctx, "/v1/jails/ensure-structure", nil, nil)
} }
// CreateJail implements Connector.
func (ac *AgentConnector) CreateJail(ctx context.Context, jailName, content string) error {
payload := map[string]interface{}{
"name": jailName,
"content": content,
}
return ac.post(ctx, "/v1/jails", payload, nil)
}
// DeleteJail implements Connector.
func (ac *AgentConnector) DeleteJail(ctx context.Context, jailName string) error {
return ac.delete(ctx, fmt.Sprintf("/v1/jails/%s", jailName), nil)
}
// CreateFilter implements Connector.
func (ac *AgentConnector) CreateFilter(ctx context.Context, filterName, content string) error {
payload := map[string]interface{}{
"name": filterName,
"content": content,
}
return ac.post(ctx, "/v1/filters", payload, nil)
}
// DeleteFilter implements Connector.
func (ac *AgentConnector) DeleteFilter(ctx context.Context, filterName string) error {
return ac.delete(ctx, fmt.Sprintf("/v1/filters/%s", filterName), nil)
}

View File

@@ -199,7 +199,7 @@ func (lc *LocalConnector) Restart(ctx context.Context) error {
} }
// GetFilterConfig implements Connector. // GetFilterConfig implements Connector.
func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) { func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
return GetFilterConfigLocal(jail) return GetFilterConfigLocal(jail)
} }
@@ -306,7 +306,7 @@ func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, log
} }
// GetJailConfig implements Connector. // GetJailConfig implements Connector.
func (lc *LocalConnector) GetJailConfig(ctx context.Context, jail string) (string, error) { func (lc *LocalConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
return GetJailConfig(jail) return GetJailConfig(jail)
} }
@@ -338,6 +338,26 @@ func (lc *LocalConnector) EnsureJailLocalStructure(ctx context.Context) error {
return config.EnsureJailLocalStructure() return config.EnsureJailLocalStructure()
} }
// CreateJail implements Connector.
func (lc *LocalConnector) CreateJail(ctx context.Context, jailName, content string) error {
return CreateJail(jailName, content)
}
// DeleteJail implements Connector.
func (lc *LocalConnector) DeleteJail(ctx context.Context, jailName string) error {
return DeleteJail(jailName)
}
// CreateFilter implements Connector.
func (lc *LocalConnector) CreateFilter(ctx context.Context, filterName, content string) error {
return CreateFilter(filterName, content)
}
// DeleteFilter implements Connector.
func (lc *LocalConnector) DeleteFilter(ctx context.Context, filterName string) error {
return DeleteFilter(filterName)
}
func executeShellCommand(ctx context.Context, command string) (string, error) { func executeShellCommand(ctx context.Context, command string) (string, error) {
parts := strings.Fields(command) parts := strings.Fields(command)
if len(parts) == 0 { if len(parts) == 0 {

View File

@@ -21,45 +21,50 @@ import (
const sshEnsureActionScript = `python3 - <<'PY' const sshEnsureActionScript = `python3 - <<'PY'
import base64 import base64
import pathlib import pathlib
import sys
action_dir = pathlib.Path("/etc/fail2ban/action.d") try:
action_dir.mkdir(parents=True, exist_ok=True) action_dir = pathlib.Path("/etc/fail2ban/action.d")
action_cfg = base64.b64decode("__PAYLOAD__").decode("utf-8") action_dir.mkdir(parents=True, exist_ok=True)
(action_dir / "ui-custom-action.conf").write_text(action_cfg) action_cfg = base64.b64decode("__PAYLOAD__").decode("utf-8")
(action_dir / "ui-custom-action.conf").write_text(action_cfg)
jail_file = pathlib.Path("/etc/fail2ban/jail.local")
if not jail_file.exists(): jail_file = pathlib.Path("/etc/fail2ban/jail.local")
jail_file.write_text("[DEFAULT]\n") if not jail_file.exists():
jail_file.write_text("[DEFAULT]\n")
lines = jail_file.read_text().splitlines()
already = any("Custom Fail2Ban action applied by fail2ban-ui" in line for line in lines) lines = jail_file.read_text().splitlines()
if not already: already = any("Custom Fail2Ban action applied by fail2ban-ui" in line for line in lines)
new_lines = [] if not already:
inserted = False new_lines = []
for line in lines: inserted = False
stripped = line.strip() for line in lines:
if stripped.startswith("action") and "ui-custom-action" not in stripped and not inserted: stripped = line.strip()
if not stripped.startswith("#"): if stripped.startswith("action") and "ui-custom-action" not in stripped and not inserted:
new_lines.append("# " + line) if not stripped.startswith("#"):
else: new_lines.append("# " + line)
new_lines.append(line) else:
new_lines.append("# Custom Fail2Ban action applied by fail2ban-ui") new_lines.append(line)
new_lines.append("action = %(action_mwlg)s") new_lines.append("# Custom Fail2Ban action applied by fail2ban-ui")
inserted = True new_lines.append("action = %(action_mwlg)s")
continue inserted = True
new_lines.append(line) continue
if not inserted: new_lines.append(line)
insert_at = None if not inserted:
for idx, value in enumerate(new_lines): insert_at = None
if value.strip().startswith("[DEFAULT]"): for idx, value in enumerate(new_lines):
insert_at = idx + 1 if value.strip().startswith("[DEFAULT]"):
break insert_at = idx + 1
if insert_at is None: break
new_lines.append("[DEFAULT]") if insert_at is None:
insert_at = len(new_lines) new_lines.append("[DEFAULT]")
new_lines.insert(insert_at, "# Custom Fail2Ban action applied by fail2ban-ui") insert_at = len(new_lines)
new_lines.insert(insert_at + 1, "action = %(action_mwlg)s") new_lines.insert(insert_at, "# Custom Fail2Ban action applied by fail2ban-ui")
jail_file.write_text("\n".join(new_lines) + "\n") new_lines.insert(insert_at + 1, "action = %(action_mwlg)s")
jail_file.write_text("\n".join(new_lines) + "\n")
except Exception as e:
sys.stderr.write(f"Error: {e}\n")
sys.exit(1)
PY` PY`
// SSHConnector connects to a remote Fail2ban instance over SSH. // SSHConnector connects to a remote Fail2ban instance over SSH.
@@ -76,8 +81,17 @@ func NewSSHConnector(server config.Fail2banServer) (Connector, error) {
return nil, fmt.Errorf("sshUser is required for ssh connector") return nil, fmt.Errorf("sshUser is required for ssh connector")
} }
conn := &SSHConnector{server: server} conn := &SSHConnector{server: server}
if err := conn.ensureAction(context.Background()); err != nil {
fmt.Printf("warning: failed to ensure remote fail2ban action for %s: %v\n", server.Name, err) // Use a timeout context to prevent hanging if SSH server isn't ready yet
// The action file can be ensured later when actually needed
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := conn.ensureAction(ctx); err != nil {
// Log warning but don't fail connector creation - action can be ensured later
config.DebugLog("warning: failed to ensure remote fail2ban action for %s during startup (server may not be ready): %v", server.Name, err)
// Don't return error - allow connector to be created even if action setup fails
// The action will be ensured later when UpdateActionFiles is called
} }
return conn, nil return conn, nil
} }
@@ -186,7 +200,7 @@ func (sc *SSHConnector) Restart(ctx context.Context) error {
// RestartWithMode implements the detailed restart logic for SSH connectors. // RestartWithMode implements the detailed restart logic for SSH connectors.
func (sc *SSHConnector) RestartWithMode(ctx context.Context) (string, error) { func (sc *SSHConnector) RestartWithMode(ctx context.Context) (string, error) {
// First, we try systemd restart on the remote host // First, we try systemd restart on the remote host
out, err := sc.runRemoteCommand(ctx, []string{"sudo", "systemctl", "restart", "fail2ban"}) out, err := sc.runRemoteCommand(ctx, []string{"systemctl", "restart", "fail2ban"})
if err == nil { if err == nil {
if err := sc.checkFail2banHealthyRemote(ctx); err != nil { if err := sc.checkFail2banHealthyRemote(ctx); err != nil {
return "restart", fmt.Errorf("remote fail2ban health check after systemd restart failed: %w", err) return "restart", fmt.Errorf("remote fail2ban health check after systemd restart failed: %w", err)
@@ -211,62 +225,59 @@ func (sc *SSHConnector) RestartWithMode(ctx context.Context) (string, error) {
return "restart", fmt.Errorf("failed to restart fail2ban via systemd on remote: %w (output: %s)", err, out) return "restart", fmt.Errorf("failed to restart fail2ban via systemd on remote: %w (output: %s)", err, out)
} }
func (sc *SSHConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) { func (sc *SSHConnector) GetFilterConfig(ctx context.Context, filterName string) (string, string, error) {
// Validate filter name // Validate filter name
jail = strings.TrimSpace(jail) filterName = strings.TrimSpace(filterName)
if jail == "" { if filterName == "" {
return "", fmt.Errorf("filter name cannot be empty") return "", "", fmt.Errorf("filter name cannot be empty")
} }
fail2banPath := sc.getFail2banPath(ctx)
// Try .local first, then fallback to .conf // Try .local first, then fallback to .conf
localPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail) localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local")
confPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail) confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf")
out, err := sc.runRemoteCommand(ctx, []string{"cat", localPath}) content, err := sc.readRemoteFile(ctx, localPath)
if err == nil { if err == nil {
return out, nil return content, localPath, nil
} }
// Fallback to .conf // Fallback to .conf
out, err = sc.runRemoteCommand(ctx, []string{"cat", confPath}) content, err = sc.readRemoteFile(ctx, confPath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read remote filter config (tried .local and .conf): %w", err) return "", "", fmt.Errorf("failed to read remote filter config (tried .local and .conf): %w", err)
} }
return out, nil return content, confPath, nil
} }
func (sc *SSHConnector) SetFilterConfig(ctx context.Context, jail, content string) error { func (sc *SSHConnector) SetFilterConfig(ctx context.Context, filterName, content string) error {
// Validate filter name // Validate filter name
jail = strings.TrimSpace(jail) filterName = strings.TrimSpace(filterName)
if jail == "" { if filterName == "" {
return fmt.Errorf("filter name cannot be empty") return fmt.Errorf("filter name cannot be empty")
} }
// Ensure .local file exists (copy from .conf if needed) fail2banPath := sc.getFail2banPath(ctx)
localPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail) filterDPath := filepath.Join(fail2banPath, "filter.d")
confPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail)
// Check if .local exists, if not, copy from .conf // Ensure directory exists
checkScript := fmt.Sprintf(` _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", filterDPath})
if [ ! -f "%s" ]; then
if [ -f "%s" ]; then
cp "%s" "%s"
else
echo "Error: filter .conf file does not exist: %s" >&2
exit 1
fi
fi
`, localPath, confPath, confPath, localPath, confPath)
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", checkScript})
if err != nil { if err != nil {
return fmt.Errorf("failed to create filter.d directory: %w", err)
}
// Ensure .local file exists (copy from .conf if needed)
if err := sc.ensureRemoteLocalFile(ctx, filterDPath, filterName); err != nil {
return fmt.Errorf("failed to ensure filter .local file: %w", err) return fmt.Errorf("failed to ensure filter .local file: %w", err)
} }
// Write to .local file // Write to .local file
cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, content) localPath := filepath.Join(filterDPath, filterName+".local")
_, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) if err := sc.writeRemoteFile(ctx, localPath, content); err != nil {
return err return fmt.Errorf("failed to write filter config: %w", err)
}
return nil
} }
func (sc *SSHConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) { func (sc *SSHConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
@@ -395,6 +406,12 @@ func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string)
func (sc *SSHConnector) buildSSHArgs(command []string) []string { func (sc *SSHConnector) buildSSHArgs(command []string) []string {
args := []string{"-o", "BatchMode=yes"} args := []string{"-o", "BatchMode=yes"}
// Add connection timeout to prevent hanging
args = append(args,
"-o", "ConnectTimeout=10",
"-o", "ServerAliveInterval=5",
"-o", "ServerAliveCountMax=2",
)
// In containerized environments, disable strict host key checking // In containerized environments, disable strict host key checking
if _, container := os.LookupEnv("CONTAINER"); container { if _, container := os.LookupEnv("CONTAINER"); container {
args = append(args, args = append(args,
@@ -403,6 +420,14 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string {
"-o", "LogLevel=ERROR", "-o", "LogLevel=ERROR",
) )
} }
// Enable SSH connection multiplexing for faster connections
// Use a control socket based on server ID for connection reuse
controlPath := fmt.Sprintf("/tmp/ssh_control_%s_%s", sc.server.ID, strings.ReplaceAll(sc.server.Host, ".", "_"))
args = append(args,
"-o", "ControlMaster=auto",
"-o", fmt.Sprintf("ControlPath=%s", controlPath),
"-o", "ControlPersist=300", // Keep connection alive for 5 minutes
)
if sc.server.SSHKeyPath != "" { if sc.server.SSHKeyPath != "" {
args = append(args, "-i", sc.server.SSHKeyPath) args = append(args, "-i", sc.server.SSHKeyPath)
} }
@@ -418,76 +443,182 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string {
return args return args
} }
// GetAllJails implements Connector. // listRemoteFiles lists files in a remote directory matching a pattern.
func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { // Uses Python to list files, which works better with FACL permissions than find/ls.
// Read jail.local (DEFAULT only) and jail.d files remotely func (sc *SSHConnector) listRemoteFiles(ctx context.Context, directory, pattern string) ([]string, error) {
var allJails []JailInfo // Use Python to list files - works better with FACL permissions
script := fmt.Sprintf(`python3 -c "
import os
import sys
directory = %q
pattern = %q
try:
if os.path.isdir(directory):
files = os.listdir(directory)
for f in files:
if f.endswith(pattern) and not f.startswith('.'):
full_path = os.path.join(directory, f)
if os.path.isfile(full_path):
print(full_path)
except Exception as e:
sys.stderr.write(f'Error listing files: {e}\\n')
sys.exit(1)
"`, directory, pattern)
// Parse jail.local (only DEFAULT section, skip other jails) out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", script})
jailLocalContent, err := sc.runRemoteCommand(ctx, []string{"cat", "/etc/fail2ban/jail.local"}) if err != nil {
if err == nil { return nil, fmt.Errorf("failed to list files in %s: %w", directory, err)
// Filter to only include DEFAULT section jails (though DEFAULT itself isn't returned as a jail) }
jails := parseJailConfigContent(jailLocalContent)
// Filter out DEFAULT section - we only want actual jails var files []string
for _, jail := range jails { for _, line := range strings.Split(out, "\n") {
if jail.JailName != "DEFAULT" { line = strings.TrimSpace(line)
allJails = append(allJails, jail) if line != "" {
} files = append(files, line)
} }
} }
// Parse jail.d directory - prefer .local over .conf files return files, nil
// First get .local files }
jailDLocalCmd := "find /etc/fail2ban/jail.d -maxdepth 1 -name '*.local' -type f 2>/dev/null"
jailDLocalList, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", jailDLocalCmd}) // readRemoteFile reads the content of a remote file via SSH.
processedJails := make(map[string]bool) func (sc *SSHConnector) readRemoteFile(ctx context.Context, filePath string) (string, error) {
if err == nil && jailDLocalList != "" { content, err := sc.runRemoteCommand(ctx, []string{"cat", filePath})
for _, file := range strings.Split(jailDLocalList, "\n") { if err != nil {
file = strings.TrimSpace(file) return "", fmt.Errorf("failed to read remote file %s: %w", filePath, err)
if file == "" { }
return content, nil
}
// writeRemoteFile writes content to a remote file via SSH using a heredoc.
func (sc *SSHConnector) writeRemoteFile(ctx context.Context, filePath, content string) error {
// Escape single quotes for safe use in a single-quoted heredoc
escaped := strings.ReplaceAll(content, "'", "'\"'\"'")
// Use heredoc to write file content
script := fmt.Sprintf(`cat > %s <<'REMOTEEOF'
%s
REMOTEEOF
`, filePath, escaped)
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script})
if err != nil {
return fmt.Errorf("failed to write remote file %s: %w", filePath, err)
}
return nil
}
// ensureRemoteLocalFile ensures that a .local file exists on the remote system.
// If .local doesn't exist, it copies from .conf if available, or creates an empty file.
func (sc *SSHConnector) ensureRemoteLocalFile(ctx context.Context, basePath, name string) error {
localPath := fmt.Sprintf("%s/%s.local", basePath, name)
confPath := fmt.Sprintf("%s/%s.conf", basePath, name)
// Check if .local exists, if not, copy from .conf or create empty file
script := fmt.Sprintf(`
if [ ! -f "%s" ]; then
if [ -f "%s" ]; then
cp "%s" "%s"
else
# Create empty .local file if neither exists
touch "%s"
fi
fi
`, localPath, confPath, confPath, localPath, localPath)
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script})
if err != nil {
return fmt.Errorf("failed to ensure remote .local file %s: %w", localPath, err)
}
return nil
}
// getFail2banPath detects the fail2ban configuration path on the remote system.
// Returns /config/fail2ban for linuxserver images, or /etc/fail2ban for standard installations.
func (sc *SSHConnector) getFail2banPath(ctx context.Context) string {
// Check if /config/fail2ban exists (linuxserver image)
checkScript := `if [ -d "/config/fail2ban" ]; then echo "/config/fail2ban"; elif [ -d "/etc/fail2ban" ]; then echo "/etc/fail2ban"; else echo "/etc/fail2ban"; fi`
out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", checkScript})
if err == nil {
path := strings.TrimSpace(out)
if path != "" {
return path
}
}
// Default to /etc/fail2ban
return "/etc/fail2ban"
}
// GetAllJails implements Connector.
// Discovers all jails from filesystem (mirrors local connector behavior).
func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
fail2banPath := sc.getFail2banPath(ctx)
jailDPath := filepath.Join(fail2banPath, "jail.d")
var allJails []JailInfo
processedFiles := make(map[string]bool) // Track base names to avoid duplicates
processedJails := make(map[string]bool) // Track jail names to avoid duplicates
// List all .local files first
localFiles, err := sc.listRemoteFiles(ctx, jailDPath, ".local")
if err != nil {
config.DebugLog("Failed to list .local files in jail.d on server %s: %v", sc.server.Name, err)
// Continue with .conf files
} else {
// Process .local files
for _, filePath := range localFiles {
filename := filepath.Base(filePath)
baseName := strings.TrimSuffix(filename, ".local")
if baseName == "" || processedFiles[baseName] {
continue continue
} }
// Skip files that start with . (like .local) - these are invalid
baseName := filepath.Base(file) processedFiles[baseName] = true
if strings.HasPrefix(baseName, ".") {
config.DebugLog("Skipping invalid jail file: %s", file) // Read and parse the file
content, err := sc.readRemoteFile(ctx, filePath)
if err != nil {
config.DebugLog("Failed to read jail file %s on server %s: %v", filePath, sc.server.Name, err)
continue continue
} }
content, err := sc.runRemoteCommand(ctx, []string{"cat", file})
if err == nil { jails := parseJailConfigContent(content)
jails := parseJailConfigContent(content) for _, jail := range jails {
for _, jail := range jails { if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] {
// Skip jails with empty names allJails = append(allJails, jail)
if jail.JailName != "" { processedJails[jail.JailName] = true
allJails = append(allJails, jail)
processedJails[jail.JailName] = true
}
} }
} }
} }
} }
// Then get .conf files that don't have corresponding .local files
jailDConfCmd := "find /etc/fail2ban/jail.d -maxdepth 1 -name '*.conf' -type f 2>/dev/null" // List all .conf files that don't have corresponding .local files
jailDConfList, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", jailDConfCmd}) confFiles, err := sc.listRemoteFiles(ctx, jailDPath, ".conf")
if err == nil && jailDConfList != "" { if err != nil {
for _, file := range strings.Split(jailDConfList, "\n") { config.DebugLog("Failed to list .conf files in jail.d on server %s: %v", sc.server.Name, err)
file = strings.TrimSpace(file) } else {
if file == "" { // Process .conf files
for _, filePath := range confFiles {
filename := filepath.Base(filePath)
baseName := strings.TrimSuffix(filename, ".conf")
if baseName == "" || processedFiles[baseName] {
continue continue
} }
// Extract jail name from filename
baseName := strings.TrimSuffix(filepath.Base(file), ".conf") processedFiles[baseName] = true
// Skip files that start with . (like .conf) - these are invalid
if baseName == "" || strings.HasPrefix(filepath.Base(file), ".") { // Read and parse the file
config.DebugLog("Skipping invalid jail file: %s", file) content, err := sc.readRemoteFile(ctx, filePath)
if err != nil {
config.DebugLog("Failed to read jail file %s on server %s: %v", filePath, sc.server.Name, err)
continue continue
} }
// Only process if we haven't already processed this jail from a .local file
if !processedJails[baseName] { jails := parseJailConfigContent(content)
content, err := sc.runRemoteCommand(ctx, []string{"cat", file}) for _, jail := range jails {
if err == nil { if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] {
jails := parseJailConfigContent(content) allJails = append(allJails, jail)
allJails = append(allJails, jails...) processedJails[jail.JailName] = true
} }
} }
} }
@@ -498,8 +629,11 @@ func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
// UpdateJailEnabledStates implements Connector. // UpdateJailEnabledStates implements Connector.
func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error { func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
fail2banPath := sc.getFail2banPath(ctx)
jailDPath := filepath.Join(fail2banPath, "jail.d")
// Ensure jail.d directory exists // Ensure jail.d directory exists
_, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", "/etc/fail2ban/jail.d"}) _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", jailDPath})
if err != nil { if err != nil {
return fmt.Errorf("failed to create jail.d directory: %w", err) return fmt.Errorf("failed to create jail.d directory: %w", err)
} }
@@ -513,8 +647,8 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map
continue continue
} }
localPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jailName) localPath := filepath.Join(jailDPath, jailName+".local")
confPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jailName) confPath := filepath.Join(jailDPath, jailName+".conf")
// Ensure .local file exists (copy from .conf if needed) // Ensure .local file exists (copy from .conf if needed)
ensureScript := fmt.Sprintf(` ensureScript := fmt.Sprintf(`
@@ -591,48 +725,72 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map
} }
// GetFilters implements Connector. // GetFilters implements Connector.
// Discovers all filters from filesystem (mirrors local connector behavior).
func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) { func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) {
// Use find to list filter files fail2banPath := sc.getFail2banPath(ctx)
list, err := sc.runRemoteCommand(ctx, []string{"find", "/etc/fail2ban/filter.d", "-maxdepth", "1", "-type", "f"}) filterDPath := filepath.Join(fail2banPath, "filter.d")
if err != nil {
return nil, fmt.Errorf("failed to list filters: %w", err) filterMap := make(map[string]bool) // Track unique filter names
processedFiles := make(map[string]bool) // Track base names to avoid duplicates
// Helper function to check if file should be excluded
shouldExclude := func(filename string) bool {
if strings.HasSuffix(filename, ".bak") ||
strings.HasSuffix(filename, "~") ||
strings.HasSuffix(filename, ".old") ||
strings.HasSuffix(filename, ".rpmnew") ||
strings.HasSuffix(filename, ".rpmsave") ||
strings.Contains(filename, "README") {
return true
}
return false
} }
// Filter for .conf files and extract names in Go
var filters []string // First pass: collect all .local files (these take precedence)
seen := make(map[string]bool) // Avoid duplicates localFiles, err := sc.listRemoteFiles(ctx, filterDPath, ".local")
for _, line := range strings.Split(list, "\n") { if err != nil {
line = strings.TrimSpace(line) config.DebugLog("Failed to list .local filters on server %s: %v", sc.server.Name, err)
if line == "" { } else {
continue for _, filePath := range localFiles {
} filename := filepath.Base(filePath)
// Only process .conf files - be strict about the extension if shouldExclude(filename) {
if !strings.HasSuffix(line, ".conf") {
continue
}
// Exclude backup files and other non-filter files
if strings.HasSuffix(line, ".conf.bak") ||
strings.HasSuffix(line, ".conf~") ||
strings.HasSuffix(line, ".conf.old") ||
strings.HasSuffix(line, ".conf.rpmnew") ||
strings.HasSuffix(line, ".conf.rpmsave") ||
strings.Contains(line, "README") {
continue
}
parts := strings.Split(line, "/")
if len(parts) > 0 {
filename := parts[len(parts)-1]
// Double-check it ends with .conf
if !strings.HasSuffix(filename, ".conf") {
continue continue
} }
name := strings.TrimSuffix(filename, ".conf") baseName := strings.TrimSuffix(filename, ".local")
if name != "" && !seen[name] { if baseName == "" || processedFiles[baseName] {
seen[name] = true continue
filters = append(filters, name)
} }
processedFiles[baseName] = true
filterMap[baseName] = true
} }
} }
// Second pass: collect .conf files that don't have corresponding .local files
confFiles, err := sc.listRemoteFiles(ctx, filterDPath, ".conf")
if err != nil {
config.DebugLog("Failed to list .conf filters on server %s: %v", sc.server.Name, err)
} else {
for _, filePath := range confFiles {
filename := filepath.Base(filePath)
if shouldExclude(filename) {
continue
}
baseName := strings.TrimSuffix(filename, ".conf")
if baseName == "" || processedFiles[baseName] {
continue
}
processedFiles[baseName] = true
filterMap[baseName] = true
}
}
// Convert map to sorted slice
var filters []string
for name := range filterMap {
filters = append(filters, name)
}
sort.Strings(filters) sort.Strings(filters)
return filters, nil return filters, nil
} }
@@ -719,29 +877,30 @@ fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true
} }
// GetJailConfig implements Connector. // GetJailConfig implements Connector.
func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, error) { func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
// Validate jail name // Validate jail name
jail = strings.TrimSpace(jail) jail = strings.TrimSpace(jail)
if jail == "" { if jail == "" {
return "", fmt.Errorf("jail name cannot be empty") return "", "", fmt.Errorf("jail name cannot be empty")
} }
fail2banPath := sc.getFail2banPath(ctx)
// Try .local first, then fallback to .conf // Try .local first, then fallback to .conf
localPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail) localPath := filepath.Join(fail2banPath, "jail.d", jail+".local")
confPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jail) confPath := filepath.Join(fail2banPath, "jail.d", jail+".conf")
out, err := sc.runRemoteCommand(ctx, []string{"cat", localPath}) content, err := sc.readRemoteFile(ctx, localPath)
if err == nil { if err == nil {
return out, nil return content, localPath, nil
} }
// Fallback to .conf // Fallback to .conf
out, err = sc.runRemoteCommand(ctx, []string{"cat", confPath}) content, err = sc.readRemoteFile(ctx, confPath)
if err != nil { if err != nil {
// If neither exists, return empty jail section // If neither exists, return empty jail section with .local path (will be created on save)
return fmt.Sprintf("[%s]\n", jail), nil return fmt.Sprintf("[%s]\n", jail), localPath, nil
} }
return out, nil return content, confPath, nil
} }
// SetJailConfig implements Connector. // SetJailConfig implements Connector.
@@ -752,34 +911,27 @@ func (sc *SSHConnector) SetJailConfig(ctx context.Context, jail, content string)
return fmt.Errorf("jail name cannot be empty") return fmt.Errorf("jail name cannot be empty")
} }
localPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail) fail2banPath := sc.getFail2banPath(ctx)
confPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jail) jailDPath := filepath.Join(fail2banPath, "jail.d")
// Ensure jail.d directory exists // Ensure jail.d directory exists
_, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", "/etc/fail2ban/jail.d"}) _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", jailDPath})
if err != nil { if err != nil {
return fmt.Errorf("failed to create jail.d directory: %w", err) return fmt.Errorf("failed to create jail.d directory: %w", err)
} }
// Ensure .local file exists (copy from .conf if needed) // Ensure .local file exists (copy from .conf if needed)
ensureScript := fmt.Sprintf(` if err := sc.ensureRemoteLocalFile(ctx, jailDPath, jail); err != nil {
if [ ! -f "%s" ]; then
if [ -f "%s" ]; then
cp "%s" "%s"
else
echo "[%s]" > "%s"
fi
fi
`, localPath, confPath, confPath, localPath, jail, localPath)
if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", ensureScript}); err != nil {
return fmt.Errorf("failed to ensure .local file for jail %s: %w", jail, err) return fmt.Errorf("failed to ensure .local file for jail %s: %w", jail, err)
} }
// Write to .local file // Write to .local file
cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, content) localPath := filepath.Join(jailDPath, jail+".local")
_, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) if err := sc.writeRemoteFile(ctx, localPath, content); err != nil {
return err return fmt.Errorf("failed to write jail config: %w", err)
}
return nil
} }
// TestLogpath implements Connector. // TestLogpath implements Connector.
@@ -1231,19 +1383,20 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err
checkScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailLocalPath) checkScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailLocalPath)
out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", checkScript}) out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", checkScript})
if err != nil || strings.TrimSpace(out) != "exists" { if err != nil || strings.TrimSpace(out) != "exists" {
config.DebugLog("No jails to migrate from jail.local on server %s (file does not exist)", sc.server.Name)
return nil // Nothing to migrate return nil // Nothing to migrate
} }
// Read jail.local content // Read jail.local content
content, err := sc.runRemoteCommand(ctx, []string{"cat", jailLocalPath}) content, err := sc.runRemoteCommand(ctx, []string{"cat", jailLocalPath})
if err != nil { if err != nil {
return fmt.Errorf("failed to read jail.local: %w", err) return fmt.Errorf("failed to read jail.local on server %s: %w", sc.server.Name, err)
} }
// Parse content locally to extract non-commented sections // Parse content locally to extract non-commented sections
sections, defaultContent, err := parseJailSectionsUncommented(content) sections, defaultContent, err := parseJailSectionsUncommented(content)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse jail.local: %w", err) return fmt.Errorf("failed to parse jail.local on server %s: %w", sc.server.Name, err)
} }
// If no non-commented, non-DEFAULT jails found, nothing to migrate // If no non-commented, non-DEFAULT jails found, nothing to migrate
@@ -1256,14 +1409,14 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err
backupPath := jailLocalPath + ".backup." + fmt.Sprintf("%d", time.Now().Unix()) backupPath := jailLocalPath + ".backup." + fmt.Sprintf("%d", time.Now().Unix())
backupScript := fmt.Sprintf("cp %s %s", jailLocalPath, backupPath) backupScript := fmt.Sprintf("cp %s %s", jailLocalPath, backupPath)
if _, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", backupScript}); err != nil { if _, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", backupScript}); err != nil {
return fmt.Errorf("failed to create backup: %w", err) return fmt.Errorf("failed to create backup on server %s: %w", sc.server.Name, err)
} }
config.DebugLog("Created backup of jail.local at %s on remote system", backupPath) config.DebugLog("Created backup of jail.local at %s on server %s", backupPath, sc.server.Name)
// Ensure jail.d directory exists // Ensure jail.d directory exists
ensureDirScript := fmt.Sprintf("mkdir -p %s", jailDPath) ensureDirScript := fmt.Sprintf("mkdir -p %s", jailDPath)
if _, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", ensureDirScript}); err != nil { if _, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", ensureDirScript}); err != nil {
return fmt.Errorf("failed to create jail.d directory: %w", err) return fmt.Errorf("failed to create jail.d directory on server %s: %w", sc.server.Name, err)
} }
// Write each jail to its own .local file // Write each jail to its own .local file
@@ -1279,7 +1432,7 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err
checkFileScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailFilePath) checkFileScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailFilePath)
fileOut, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", checkFileScript}) fileOut, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", checkFileScript})
if err == nil && strings.TrimSpace(fileOut) == "exists" { if err == nil && strings.TrimSpace(fileOut) == "exists" {
config.DebugLog("Skipping migration for jail %s: .local file already exists", jailName) config.DebugLog("Skipping migration for jail %s on server %s: .local file already exists", jailName, sc.server.Name)
continue continue
} }
@@ -1293,7 +1446,7 @@ JAILEOF
if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", writeScript}); err != nil { if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", writeScript}); err != nil {
return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err)
} }
config.DebugLog("Migrated jail %s to %s on remote system", jailName, jailFilePath) config.DebugLog("Migrated jail %s to %s on server %s", jailName, jailFilePath, sc.server.Name)
migratedCount++ migratedCount++
} }
@@ -1309,7 +1462,107 @@ LOCALEOF
if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", writeLocalScript}); err != nil { if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", writeLocalScript}); err != nil {
return fmt.Errorf("failed to rewrite jail.local: %w", err) return fmt.Errorf("failed to rewrite jail.local: %w", err)
} }
config.DebugLog("Migration completed on remote system: moved %d jails to jail.d/", migratedCount) config.DebugLog("Migration completed on server %s: moved %d jails to jail.d/", sc.server.Name, migratedCount)
}
return nil
}
// CreateJail implements Connector.
func (sc *SSHConnector) CreateJail(ctx context.Context, jailName, content string) error {
// Validate jail name
if err := ValidateJailName(jailName); err != nil {
return err
}
fail2banPath := sc.getFail2banPath(ctx)
jailDPath := filepath.Join(fail2banPath, "jail.d")
// Ensure jail.d directory exists
_, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", jailDPath})
if err != nil {
return fmt.Errorf("failed to create jail.d directory: %w", err)
}
// Validate content starts with correct section header
trimmed := strings.TrimSpace(content)
expectedSection := fmt.Sprintf("[%s]", jailName)
if !strings.HasPrefix(trimmed, expectedSection) {
// Prepend the section header if missing
content = expectedSection + "\n" + content
}
// Write the file
localPath := filepath.Join(jailDPath, jailName+".local")
if err := sc.writeRemoteFile(ctx, localPath, content); err != nil {
return fmt.Errorf("failed to create jail file: %w", err)
}
return nil
}
// DeleteJail implements Connector.
func (sc *SSHConnector) DeleteJail(ctx context.Context, jailName string) error {
// Validate jail name
if err := ValidateJailName(jailName); err != nil {
return err
}
fail2banPath := sc.getFail2banPath(ctx)
localPath := filepath.Join(fail2banPath, "jail.d", jailName+".local")
confPath := filepath.Join(fail2banPath, "jail.d", jailName+".conf")
// Delete both .local and .conf files if they exist (rm -f doesn't error if file doesn't exist)
// Use a single command to delete both files
_, err := sc.runRemoteCommand(ctx, []string{"rm", "-f", localPath, confPath})
if err != nil {
return fmt.Errorf("failed to delete jail files %s or %s: %w", localPath, confPath, err)
}
return nil
}
// CreateFilter implements Connector.
func (sc *SSHConnector) CreateFilter(ctx context.Context, filterName, content string) error {
// Validate filter name
if err := ValidateFilterName(filterName); err != nil {
return err
}
fail2banPath := sc.getFail2banPath(ctx)
filterDPath := filepath.Join(fail2banPath, "filter.d")
// Ensure filter.d directory exists
_, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", filterDPath})
if err != nil {
return fmt.Errorf("failed to create filter.d directory: %w", err)
}
// Write the file
localPath := filepath.Join(filterDPath, filterName+".local")
if err := sc.writeRemoteFile(ctx, localPath, content); err != nil {
return fmt.Errorf("failed to create filter file: %w", err)
}
return nil
}
// DeleteFilter implements Connector.
func (sc *SSHConnector) DeleteFilter(ctx context.Context, filterName string) error {
// Validate filter name
if err := ValidateFilterName(filterName); err != nil {
return err
}
fail2banPath := sc.getFail2banPath(ctx)
localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local")
confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf")
// Delete both .local and .conf files if they exist (rm -f doesn't error if file doesn't exist)
// Use a single command to delete both files
_, err := sc.runRemoteCommand(ctx, []string{"rm", "-f", localPath, confPath})
if err != nil {
return fmt.Errorf("failed to delete filter files %s or %s: %w", localPath, confPath, err)
} }
return nil return nil

View File

@@ -22,6 +22,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strings" "strings"
@@ -29,10 +30,11 @@ import (
) )
// GetFilterConfig returns the filter configuration using the default connector. // GetFilterConfig returns the filter configuration using the default connector.
func GetFilterConfig(jail string) (string, error) { // Returns (config, filePath, error)
func GetFilterConfig(jail string) (string, string, error) {
conn, err := GetManager().DefaultConnector() conn, err := GetManager().DefaultConnector()
if err != nil { if err != nil {
return "", err return "", "", err
} }
return conn.GetFilterConfig(context.Background(), jail) return conn.GetFilterConfig(context.Background(), jail)
} }
@@ -47,8 +49,7 @@ func SetFilterConfig(jail, newContent string) error {
} }
// ensureFilterLocalFile ensures that a .local file exists for the given filter. // ensureFilterLocalFile ensures that a .local file exists for the given filter.
// If .local doesn't exist, it copies from .conf if available. // If .local doesn't exist, it copies from .conf if available, or creates an empty file.
// Returns error if neither .local nor .conf exists (filters must have base .conf).
func ensureFilterLocalFile(filterName string) error { func ensureFilterLocalFile(filterName string) error {
// Validate filter name - must not be empty // Validate filter name - must not be empty
filterName = strings.TrimSpace(filterName) filterName = strings.TrimSpace(filterName)
@@ -80,16 +81,22 @@ func ensureFilterLocalFile(filterName string) error {
return nil return nil
} }
// Neither exists, return error (filters must have base .conf) // Neither exists, create empty .local file
return fmt.Errorf("filter .conf file does not exist: %s (filters must have a base .conf file)", confPath) config.DebugLog("Neither .local nor .conf exists for filter %s, creating empty .local file", filterName)
if err := os.WriteFile(localPath, []byte(""), 0644); err != nil {
return fmt.Errorf("failed to create empty filter .local file %s: %w", localPath, err)
}
config.DebugLog("Successfully created empty filter .local file: %s", localPath)
return nil
} }
// readFilterConfigWithFallback reads filter config from .local first, then falls back to .conf. // readFilterConfigWithFallback reads filter config from .local first, then falls back to .conf.
func readFilterConfigWithFallback(filterName string) (string, error) { // Returns (content, filePath, error)
func readFilterConfigWithFallback(filterName string) (string, string, error) {
// Validate filter name - must not be empty // Validate filter name - must not be empty
filterName = strings.TrimSpace(filterName) filterName = strings.TrimSpace(filterName)
if filterName == "" { if filterName == "" {
return "", fmt.Errorf("filter name cannot be empty") return "", "", fmt.Errorf("filter name cannot be empty")
} }
filterDPath := "/etc/fail2ban/filter.d" filterDPath := "/etc/fail2ban/filter.d"
@@ -99,22 +106,23 @@ func readFilterConfigWithFallback(filterName string) (string, error) {
// Try .local first // Try .local first
if content, err := os.ReadFile(localPath); err == nil { if content, err := os.ReadFile(localPath); err == nil {
config.DebugLog("Reading filter config from .local: %s", localPath) config.DebugLog("Reading filter config from .local: %s", localPath)
return string(content), nil return string(content), localPath, nil
} }
// Fallback to .conf // Fallback to .conf
if content, err := os.ReadFile(confPath); err == nil { if content, err := os.ReadFile(confPath); err == nil {
config.DebugLog("Reading filter config from .conf: %s", confPath) config.DebugLog("Reading filter config from .conf: %s", confPath)
return string(content), nil return string(content), confPath, nil
} }
// Neither exists, return error // Neither exists, return error with .local path (will be created on save)
return "", fmt.Errorf("filter config not found: neither %s nor %s exists", localPath, confPath) return "", localPath, fmt.Errorf("filter config not found: neither %s nor %s exists", localPath, confPath)
} }
// GetFilterConfigLocal reads a filter configuration from the local filesystem. // GetFilterConfigLocal reads a filter configuration from the local filesystem.
// Prefers .local over .conf files. // Prefers .local over .conf files.
func GetFilterConfigLocal(jail string) (string, error) { // Returns (content, filePath, error)
func GetFilterConfigLocal(jail string) (string, string, error) {
return readFilterConfigWithFallback(jail) return readFilterConfigWithFallback(jail)
} }
@@ -134,42 +142,205 @@ func SetFilterConfigLocal(jail, newContent string) error {
return nil return nil
} }
// GetFiltersLocal returns a list of filter names from /etc/fail2ban/filter.d // ValidateFilterName validates a filter name format.
// Returns unique filter names from both .conf and .local files (prefers .local if both exist) // Returns an error if the name is invalid (empty, contains invalid characters, or is reserved).
func GetFiltersLocal() ([]string, error) { func ValidateFilterName(name string) error {
dir := "/etc/fail2ban/filter.d" name = strings.TrimSpace(name)
entries, err := os.ReadDir(dir) if name == "" {
if err != nil { return fmt.Errorf("filter name cannot be empty")
return nil, fmt.Errorf("failed to read filter directory: %w", err)
} }
filterMap := make(map[string]bool)
// Check for invalid characters (only alphanumeric, dash, underscore allowed)
invalidChars := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
if invalidChars.MatchString(name) {
return fmt.Errorf("filter name '%s' contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed", name)
}
return nil
}
// ListFilterFiles lists all filter files in the specified directory.
// Returns full paths to .local and .conf files.
func ListFilterFiles(directory string) ([]string, error) {
var files []string
entries, err := os.ReadDir(directory)
if err != nil {
return nil, fmt.Errorf("failed to read filter directory %s: %w", directory, err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// Skip hidden files and invalid names
if strings.HasPrefix(name, ".") {
continue
}
// Only include .local and .conf files
if strings.HasSuffix(name, ".local") || strings.HasSuffix(name, ".conf") {
fullPath := filepath.Join(directory, name)
files = append(files, fullPath)
}
}
return files, nil
}
// DiscoverFiltersFromFiles discovers all filters from the filesystem.
// Reads from /etc/fail2ban/filter.d/ directory, preferring .local files over .conf files.
// Returns unique filter names.
func DiscoverFiltersFromFiles() ([]string, error) {
filterDPath := "/etc/fail2ban/filter.d"
// Check if directory exists
if _, err := os.Stat(filterDPath); os.IsNotExist(err) {
// Directory doesn't exist, return empty list
return []string{}, nil
}
// List all filter files
files, err := ListFilterFiles(filterDPath)
if err != nil {
return nil, err
}
filterMap := make(map[string]bool) // Track unique filter names
processedFiles := make(map[string]bool) // Track base names to avoid duplicates
// First pass: collect all .local files (these take precedence) // First pass: collect all .local files (these take precedence)
for _, entry := range entries { for _, filePath := range files {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".local") { if !strings.HasSuffix(filePath, ".local") {
name := strings.TrimSuffix(entry.Name(), ".local") continue
filterMap[name] = true
} }
filename := filepath.Base(filePath)
baseName := strings.TrimSuffix(filename, ".local")
if baseName == "" {
continue
}
// Skip if we've already processed this base name
if processedFiles[baseName] {
continue
}
processedFiles[baseName] = true
filterMap[baseName] = true
} }
// Second pass: collect .conf files that don't have corresponding .local files // Second pass: collect .conf files that don't have corresponding .local files
for _, entry := range entries { for _, filePath := range files {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".conf") { if !strings.HasSuffix(filePath, ".conf") {
name := strings.TrimSuffix(entry.Name(), ".conf") continue
if !filterMap[name] {
filterMap[name] = true
}
} }
filename := filepath.Base(filePath)
baseName := strings.TrimSuffix(filename, ".conf")
if baseName == "" {
continue
}
// Skip if we've already processed a .local file with the same base name
if processedFiles[baseName] {
continue
}
processedFiles[baseName] = true
filterMap[baseName] = true
} }
// Convert map to sorted slice
var filters []string var filters []string
for name := range filterMap { for name := range filterMap {
filters = append(filters, name) filters = append(filters, name)
} }
sort.Strings(filters) sort.Strings(filters)
return filters, nil return filters, nil
} }
// CreateFilter creates a new filter in filter.d/{name}.local.
// If the filter already exists, it will be overwritten.
func CreateFilter(filterName, content string) error {
if err := ValidateFilterName(filterName); err != nil {
return err
}
filterDPath := "/etc/fail2ban/filter.d"
localPath := filepath.Join(filterDPath, filterName+".local")
// Ensure directory exists
if err := os.MkdirAll(filterDPath, 0755); err != nil {
return fmt.Errorf("failed to create filter.d directory: %w", err)
}
// Write the file
if err := os.WriteFile(localPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to create filter file %s: %w", localPath, err)
}
config.DebugLog("Created filter file: %s", localPath)
return nil
}
// DeleteFilter deletes a filter's .local and .conf files from filter.d/ if they exist.
// Both files are deleted to ensure complete removal of the filter configuration.
func DeleteFilter(filterName string) error {
if err := ValidateFilterName(filterName); err != nil {
return err
}
filterDPath := "/etc/fail2ban/filter.d"
localPath := filepath.Join(filterDPath, filterName+".local")
confPath := filepath.Join(filterDPath, filterName+".conf")
var deletedFiles []string
var lastErr error
// Delete .local file if it exists
if _, err := os.Stat(localPath); err == nil {
if err := os.Remove(localPath); err != nil {
lastErr = fmt.Errorf("failed to delete filter file %s: %w", localPath, err)
} else {
deletedFiles = append(deletedFiles, localPath)
config.DebugLog("Deleted filter file: %s", localPath)
}
}
// Delete .conf file if it exists
if _, err := os.Stat(confPath); err == nil {
if err := os.Remove(confPath); err != nil {
lastErr = fmt.Errorf("failed to delete filter file %s: %w", confPath, err)
} else {
deletedFiles = append(deletedFiles, confPath)
config.DebugLog("Deleted filter file: %s", confPath)
}
}
// If no files were deleted and no error occurred, it means neither file existed
if len(deletedFiles) == 0 && lastErr == nil {
return fmt.Errorf("filter file %s or %s does not exist", localPath, confPath)
}
// Return the last error if any occurred
if lastErr != nil {
return lastErr
}
return nil
}
// GetFiltersLocal returns a list of filter names from /etc/fail2ban/filter.d
// Returns unique filter names from both .conf and .local files (prefers .local if both exist)
// This is the canonical implementation - now uses DiscoverFiltersFromFiles()
func GetFiltersLocal() ([]string, error) {
return DiscoverFiltersFromFiles()
}
func normalizeLogLines(logLines []string) []string { func normalizeLogLines(logLines []string) []string {
var cleaned []string var cleaned []string
for _, line := range logLines { for _, line := range logLines {

View File

@@ -64,11 +64,12 @@ func ensureJailLocalFile(jailName string) error {
} }
// readJailConfigWithFallback reads jail config from .local first, then falls back to .conf. // readJailConfigWithFallback reads jail config from .local first, then falls back to .conf.
func readJailConfigWithFallback(jailName string) (string, error) { // Returns (content, filePath, error)
func readJailConfigWithFallback(jailName string) (string, string, error) {
// Validate jail name - must not be empty // Validate jail name - must not be empty
jailName = strings.TrimSpace(jailName) jailName = strings.TrimSpace(jailName)
if jailName == "" { if jailName == "" {
return "", fmt.Errorf("jail name cannot be empty") return "", "", fmt.Errorf("jail name cannot be empty")
} }
jailDPath := "/etc/fail2ban/jail.d" jailDPath := "/etc/fail2ban/jail.d"
@@ -78,22 +79,254 @@ func readJailConfigWithFallback(jailName string) (string, error) {
// Try .local first // Try .local first
if content, err := os.ReadFile(localPath); err == nil { if content, err := os.ReadFile(localPath); err == nil {
config.DebugLog("Reading jail config from .local: %s", localPath) config.DebugLog("Reading jail config from .local: %s", localPath)
return string(content), nil return string(content), localPath, nil
} }
// Fallback to .conf // Fallback to .conf
if content, err := os.ReadFile(confPath); err == nil { if content, err := os.ReadFile(confPath); err == nil {
config.DebugLog("Reading jail config from .conf: %s", confPath) config.DebugLog("Reading jail config from .conf: %s", confPath)
return string(content), nil return string(content), confPath, nil
} }
// Neither exists, return empty section // Neither exists, return empty section with .local path (will be created on save)
config.DebugLog("Neither .local nor .conf exists for jail %s, returning empty section", jailName) config.DebugLog("Neither .local nor .conf exists for jail %s, returning empty section", jailName)
return fmt.Sprintf("[%s]\n", jailName), nil return fmt.Sprintf("[%s]\n", jailName), localPath, nil
}
// ValidateJailName validates a jail name format.
// Returns an error if the name is invalid (empty, contains invalid characters, or is reserved).
func ValidateJailName(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("jail name cannot be empty")
}
// Reserved names that should not be used
reservedNames := map[string]bool{
"DEFAULT": true,
"INCLUDES": true,
}
if reservedNames[strings.ToUpper(name)] {
return fmt.Errorf("jail name '%s' is reserved and cannot be used", name)
}
// Check for invalid characters (only alphanumeric, dash, underscore allowed)
invalidChars := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
if invalidChars.MatchString(name) {
return fmt.Errorf("jail name '%s' contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed", name)
}
return nil
}
// ListJailFiles lists all jail config files in the specified directory.
// Returns full paths to .local and .conf files.
func ListJailFiles(directory string) ([]string, error) {
var files []string
entries, err := os.ReadDir(directory)
if err != nil {
return nil, fmt.Errorf("failed to read jail directory %s: %w", directory, err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// Skip hidden files and invalid names
if strings.HasPrefix(name, ".") {
continue
}
// Only include .local and .conf files
if strings.HasSuffix(name, ".local") || strings.HasSuffix(name, ".conf") {
fullPath := filepath.Join(directory, name)
files = append(files, fullPath)
}
}
return files, nil
}
// DiscoverJailsFromFiles discovers all jails from the filesystem.
// Reads from /etc/fail2ban/jail.d/ directory, preferring .local files over .conf files.
// Returns all jails found (enabled and disabled).
func DiscoverJailsFromFiles() ([]JailInfo, error) {
jailDPath := "/etc/fail2ban/jail.d"
// Check if directory exists
if _, err := os.Stat(jailDPath); os.IsNotExist(err) {
// Directory doesn't exist, return empty list
return []JailInfo{}, nil
}
// List all jail files
files, err := ListJailFiles(jailDPath)
if err != nil {
return nil, err
}
var allJails []JailInfo
processedFiles := make(map[string]bool) // Track base names to avoid duplicates
processedJails := make(map[string]bool) // Track jail names to avoid duplicates
// First pass: process all .local files
for _, filePath := range files {
if !strings.HasSuffix(filePath, ".local") {
continue
}
filename := filepath.Base(filePath)
baseName := strings.TrimSuffix(filename, ".local")
if baseName == "" {
continue
}
// Skip if we've already processed this base name
if processedFiles[baseName] {
continue
}
processedFiles[baseName] = true
// Parse the file
jails, err := parseJailConfigFile(filePath)
if err != nil {
config.DebugLog("Failed to parse jail file %s: %v", filePath, err)
continue
}
// Add jails from this file
for _, jail := range jails {
if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] {
allJails = append(allJails, jail)
processedJails[jail.JailName] = true
}
}
}
// Second pass: process .conf files that don't have corresponding .local files
for _, filePath := range files {
if !strings.HasSuffix(filePath, ".conf") {
continue
}
filename := filepath.Base(filePath)
baseName := strings.TrimSuffix(filename, ".conf")
if baseName == "" {
continue
}
// Skip if we've already processed a .local file with the same base name
if processedFiles[baseName] {
continue
}
processedFiles[baseName] = true
// Parse the file
jails, err := parseJailConfigFile(filePath)
if err != nil {
config.DebugLog("Failed to parse jail file %s: %v", filePath, err)
continue
}
// Add jails from this file
for _, jail := range jails {
if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] {
allJails = append(allJails, jail)
processedJails[jail.JailName] = true
}
}
}
return allJails, nil
}
// CreateJail creates a new jail in jail.d/{name}.local.
// If the jail already exists, it will be overwritten.
func CreateJail(jailName, content string) error {
if err := ValidateJailName(jailName); err != nil {
return err
}
jailDPath := "/etc/fail2ban/jail.d"
localPath := filepath.Join(jailDPath, jailName+".local")
// Ensure directory exists
if err := os.MkdirAll(jailDPath, 0755); err != nil {
return fmt.Errorf("failed to create jail.d directory: %w", err)
}
// Validate content starts with correct section header
trimmed := strings.TrimSpace(content)
expectedSection := fmt.Sprintf("[%s]", jailName)
if !strings.HasPrefix(trimmed, expectedSection) {
// Prepend the section header if missing
content = expectedSection + "\n" + content
}
// Write the file
if err := os.WriteFile(localPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to create jail file %s: %w", localPath, err)
}
config.DebugLog("Created jail file: %s", localPath)
return nil
}
// DeleteJail deletes a jail's .local and .conf files from jail.d/ if they exist.
// Both files are deleted to ensure complete removal of the jail configuration.
func DeleteJail(jailName string) error {
if err := ValidateJailName(jailName); err != nil {
return err
}
jailDPath := "/etc/fail2ban/jail.d"
localPath := filepath.Join(jailDPath, jailName+".local")
confPath := filepath.Join(jailDPath, jailName+".conf")
var deletedFiles []string
var lastErr error
// Delete .local file if it exists
if _, err := os.Stat(localPath); err == nil {
if err := os.Remove(localPath); err != nil {
lastErr = fmt.Errorf("failed to delete jail file %s: %w", localPath, err)
} else {
deletedFiles = append(deletedFiles, localPath)
config.DebugLog("Deleted jail file: %s", localPath)
}
}
// Delete .conf file if it exists
if _, err := os.Stat(confPath); err == nil {
if err := os.Remove(confPath); err != nil {
lastErr = fmt.Errorf("failed to delete jail file %s: %w", confPath, err)
} else {
deletedFiles = append(deletedFiles, confPath)
config.DebugLog("Deleted jail file: %s", confPath)
}
}
// If no files were deleted and no error occurred, it means neither file existed
if len(deletedFiles) == 0 && lastErr == nil {
return fmt.Errorf("jail file %s or %s does not exist", localPath, confPath)
}
// Return the last error if any occurred
if lastErr != nil {
return lastErr
}
return nil
} }
// GetAllJails reads jails from /etc/fail2ban/jail.local (DEFAULT only) and /etc/fail2ban/jail.d directory. // GetAllJails reads jails from /etc/fail2ban/jail.local (DEFAULT only) and /etc/fail2ban/jail.d directory.
// Automatically migrates legacy jails from jail.local to jail.d on first call. // Automatically migrates legacy jails from jail.local to jail.d on first call.
// Now uses DiscoverJailsFromFiles() for file-based discovery.
func GetAllJails() ([]JailInfo, error) { func GetAllJails() ([]JailInfo, error) {
// Run migration once if needed // Run migration once if needed
migrationOnce.Do(func() { migrationOnce.Do(func() {
@@ -102,70 +335,12 @@ func GetAllJails() ([]JailInfo, error) {
} }
}) })
var jails []JailInfo // Discover jails from filesystem
jails, err := DiscoverJailsFromFiles()
// Parse only DEFAULT section from jail.local (skip other jails) if err != nil {
localPath := "/etc/fail2ban/jail.local" return nil, fmt.Errorf("failed to discover jails from files: %w", err)
if _, err := os.Stat(localPath); err == nil {
defaultJails, err := parseJailConfigFileOnlyDefault(localPath)
if err == nil {
jails = append(jails, defaultJails...)
}
} }
// Parse jails from jail.d directory
// Prefer .local files over .conf files (if both exist for same jail, use .local)
jailDPath := "/etc/fail2ban/jail.d"
if _, err := os.Stat(jailDPath); err == nil {
files, err := os.ReadDir(jailDPath)
if err == nil {
// Track which jails we've already processed (from .local files)
processedJails := make(map[string]bool)
// First pass: process all .local files
for _, f := range files {
if !f.IsDir() && filepath.Ext(f.Name()) == ".local" {
jailName := strings.TrimSuffix(f.Name(), ".local")
// Skip files that start with . (like .local) - these are invalid
if jailName == "" || strings.HasPrefix(f.Name(), ".") {
config.DebugLog("Skipping invalid jail file: %s", f.Name())
continue
}
fullPath := filepath.Join(jailDPath, f.Name())
dJails, err := parseJailConfigFile(fullPath)
if err == nil {
for _, jail := range dJails {
// Skip jails with empty names
if jail.JailName != "" {
jails = append(jails, jail)
processedJails[jail.JailName] = true
}
}
}
}
}
// Second pass: process .conf files that don't have corresponding .local files
for _, f := range files {
if !f.IsDir() && filepath.Ext(f.Name()) == ".conf" {
jailName := strings.TrimSuffix(f.Name(), ".conf")
// Skip files that start with . (like .conf) - these are invalid
if jailName == "" || strings.HasPrefix(f.Name(), ".") {
config.DebugLog("Skipping invalid jail file: %s", f.Name())
continue
}
// Only process if we haven't already processed this jail from a .local file
if !processedJails[jailName] {
fullPath := filepath.Join(jailDPath, f.Name())
dJails, err := parseJailConfigFile(fullPath)
if err == nil {
jails = append(jails, dJails...)
}
}
}
}
}
}
return jails, nil return jails, nil
} }
@@ -697,44 +872,23 @@ func MigrateJailsFromJailLocal() error {
return nil return nil
} }
// parseJailConfigFileOnlyDefault parses only the DEFAULT section from a jail config file.
func parseJailConfigFileOnlyDefault(path string) ([]JailInfo, error) {
var jails []JailInfo
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
// We scan through the file but don't return any jails from DEFAULT section
// This function exists to validate the file can be read
for scanner.Scan() {
// Just scan through - we don't need to parse anything for DEFAULT
}
// We don't return DEFAULT as a jail, but we could if needed
// For now, we only return jails from DEFAULT if they're explicitly listed (unlikely)
return jails, scanner.Err()
}
// GetJailConfig reads the full jail configuration from /etc/fail2ban/jail.d/{jailName}.local // GetJailConfig reads the full jail configuration from /etc/fail2ban/jail.d/{jailName}.local
// Falls back to .conf if .local doesn't exist. // Falls back to .conf if .local doesn't exist.
func GetJailConfig(jailName string) (string, error) { func GetJailConfig(jailName string) (string, string, error) {
// Validate jail name // Validate jail name
jailName = strings.TrimSpace(jailName) jailName = strings.TrimSpace(jailName)
if jailName == "" { if jailName == "" {
return "", fmt.Errorf("jail name cannot be empty") return "", "", fmt.Errorf("jail name cannot be empty")
} }
config.DebugLog("GetJailConfig called for jail: %s", jailName) config.DebugLog("GetJailConfig called for jail: %s", jailName)
content, err := readJailConfigWithFallback(jailName) content, filePath, err := readJailConfigWithFallback(jailName)
if err != nil { if err != nil {
config.DebugLog("Failed to read jail config: %v", err) config.DebugLog("Failed to read jail config: %v", err)
return "", fmt.Errorf("failed to read jail config for %s: %w", jailName, err) return "", "", fmt.Errorf("failed to read jail config for %s: %w", jailName, err)
} }
config.DebugLog("Jail config read successfully, length: %d", len(content)) config.DebugLog("Jail config read successfully, length: %d, file: %s", len(content), filePath)
return content, nil return content, filePath, nil
} }
// SetJailConfig writes the full jail configuration to /etc/fail2ban/jail.d/{jailName}.local // SetJailConfig writes the full jail configuration to /etc/fail2ban/jail.d/{jailName}.local

View File

@@ -18,7 +18,7 @@ type Connector interface {
UnbanIP(ctx context.Context, jail, ip string) error UnbanIP(ctx context.Context, jail, ip string) error
Reload(ctx context.Context) error Reload(ctx context.Context) error
Restart(ctx context.Context) error Restart(ctx context.Context) error
GetFilterConfig(ctx context.Context, jail string) (string, error) GetFilterConfig(ctx context.Context, jail string) (string, string, error) // Returns (config, filePath, error)
SetFilterConfig(ctx context.Context, jail, content string) error SetFilterConfig(ctx context.Context, jail, content string) error
FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error)
@@ -31,7 +31,7 @@ type Connector interface {
TestFilter(ctx context.Context, filterName string, logLines []string) (output string, filterPath string, err error) TestFilter(ctx context.Context, filterName string, logLines []string) (output string, filterPath string, err error)
// Jail configuration operations // Jail configuration operations
GetJailConfig(ctx context.Context, jail string) (string, error) GetJailConfig(ctx context.Context, jail string) (string, string, error) // Returns (config, filePath, error)
SetJailConfig(ctx context.Context, jail, content string) error SetJailConfig(ctx context.Context, jail, content string) error
TestLogpath(ctx context.Context, logpath string) ([]string, error) TestLogpath(ctx context.Context, logpath string) ([]string, error)
TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error)
@@ -41,6 +41,12 @@ type Connector interface {
// Jail local structure management // Jail local structure management
EnsureJailLocalStructure(ctx context.Context) error EnsureJailLocalStructure(ctx context.Context) error
// Jail and filter creation/deletion
CreateJail(ctx context.Context, jailName, content string) error
DeleteJail(ctx context.Context, jailName string) error
CreateFilter(ctx context.Context, filterName, content string) error
DeleteFilter(ctx context.Context, filterName string) error
} }
// Manager orchestrates all connectors for configured Fail2ban servers. // Manager orchestrates all connectors for configured Fail2ban servers.
@@ -173,12 +179,11 @@ func newConnectorForServer(server config.Fail2banServer) (Connector, error) {
// This ensures any legacy jails in jail.local are migrated to jail.d/*.local // This ensures any legacy jails in jail.local are migrated to jail.d/*.local
// before ensureJailLocalStructure() overwrites jail.local // before ensureJailLocalStructure() overwrites jail.local
if err := MigrateJailsFromJailLocal(); err != nil { if err := MigrateJailsFromJailLocal(); err != nil {
config.DebugLog("Warning: migration check failed (may be normal if no jails to migrate): %v", err) return nil, fmt.Errorf("failed to initialise local fail2ban connector for %s: %w", server.Name, err)
// Don't fail - continue with ensuring structure
} }
if err := config.EnsureLocalFail2banAction(server); err != nil { if err := config.EnsureLocalFail2banAction(server); err != nil {
fmt.Printf("warning: failed to ensure local fail2ban action: %v\n", err) return nil, fmt.Errorf("failed to ensure local fail2ban action for %s: %w", server.Name, err)
} }
return NewLocalConnector(server), nil return NewLocalConnector(server), nil
case "ssh": case "ssh":

View File

@@ -196,8 +196,8 @@
"settings.advanced.test_block": "IP sperren", "settings.advanced.test_block": "IP sperren",
"settings.advanced.test_unblock": "IP entfernen", "settings.advanced.test_unblock": "IP entfernen",
"settings.save": "Speichern", "settings.save": "Speichern",
"modal.filter_config": "Filter-Konfiguration:", "modal.filter_config": "Filter / Jail-Konfiguration:",
"modal.filter_config_edit": "Filter bearbeiten", "modal.filter_config_edit": "Filter / Jail bearbeiten",
"modal.cancel": "Abbrechen", "modal.cancel": "Abbrechen",
"modal.save": "Speichern", "modal.save": "Speichern",
"modal.close": "Schließen", "modal.close": "Schließen",

View File

@@ -196,8 +196,8 @@
"settings.advanced.test_block": "IP sperre", "settings.advanced.test_block": "IP sperre",
"settings.advanced.test_unblock": "IP freigäh", "settings.advanced.test_unblock": "IP freigäh",
"settings.save": "Speicherä", "settings.save": "Speicherä",
"modal.filter_config": "Filter-Konfiguration:", "modal.filter_config": "Filter / Jail-Konfiguration:",
"modal.filter_config_edit": "Filter bearbeite", "modal.filter_config_edit": "Filter / Jail bearbeite",
"modal.cancel": "Abbräche", "modal.cancel": "Abbräche",
"modal.save": "Speicherä", "modal.save": "Speicherä",
"modal.close": "Wider Schliesse", "modal.close": "Wider Schliesse",

View File

@@ -196,8 +196,8 @@
"settings.advanced.test_block": "Block IP", "settings.advanced.test_block": "Block IP",
"settings.advanced.test_unblock": "Remove IP", "settings.advanced.test_unblock": "Remove IP",
"settings.save": "Save", "settings.save": "Save",
"modal.filter_config": "Filter Config:", "modal.filter_config": "Filter / Jail Configuration:",
"modal.filter_config_edit": "Edit Filter", "modal.filter_config_edit": "Edit Filter / Jail",
"modal.cancel": "Cancel", "modal.cancel": "Cancel",
"modal.save": "Save", "modal.save": "Save",
"modal.close": "Close", "modal.close": "Close",

View File

@@ -196,8 +196,8 @@
"settings.advanced.test_block": "Bloquear IP", "settings.advanced.test_block": "Bloquear IP",
"settings.advanced.test_unblock": "Eliminar IP", "settings.advanced.test_unblock": "Eliminar IP",
"settings.save": "Guardar", "settings.save": "Guardar",
"modal.filter_config": "Configuración del filtro:", "modal.filter_config": "Configuración del filtro / Jail:",
"modal.filter_config_edit": "Editar filtro", "modal.filter_config_edit": "Editar filtro / Jail",
"modal.cancel": "Cancelar", "modal.cancel": "Cancelar",
"modal.save": "Guardar", "modal.save": "Guardar",
"modal.close": "Cerrar", "modal.close": "Cerrar",

View File

@@ -196,8 +196,8 @@
"settings.advanced.test_block": "Bloquer lIP", "settings.advanced.test_block": "Bloquer lIP",
"settings.advanced.test_unblock": "Retirer lIP", "settings.advanced.test_unblock": "Retirer lIP",
"settings.save": "Enregistrer", "settings.save": "Enregistrer",
"modal.filter_config": "Configuration du filtre:", "modal.filter_config": "Configuration du filtre / Jail:",
"modal.filter_config_edit": "Modifier le filtre", "modal.filter_config_edit": "Modifier le filtre / Jail",
"modal.cancel": "Annuler", "modal.cancel": "Annuler",
"modal.save": "Enregistrer", "modal.save": "Enregistrer",
"modal.close": "Fermer", "modal.close": "Fermer",

View File

@@ -196,8 +196,8 @@
"settings.advanced.test_block": "Blocca IP", "settings.advanced.test_block": "Blocca IP",
"settings.advanced.test_unblock": "Rimuovi IP", "settings.advanced.test_unblock": "Rimuovi IP",
"settings.save": "Salva", "settings.save": "Salva",
"modal.filter_config": "Configurazione del filtro:", "modal.filter_config": "Configurazione del filtro / Jail:",
"modal.filter_config_edit": "Modifica filtro", "modal.filter_config_edit": "Modifica filtro / Jail",
"modal.cancel": "Annulla", "modal.cancel": "Annulla",
"modal.save": "Salva", "modal.save": "Salva",
"modal.close": "Chiudi", "modal.close": "Chiudi",

View File

@@ -1116,66 +1116,51 @@ func GetJailFilterConfigHandler(c *gin.Context) {
config.DebugLog("Connector resolved: %s", conn.Server().Name) config.DebugLog("Connector resolved: %s", conn.Server().Name)
var filterCfg string var filterCfg string
var filterFilePath string
var jailCfg string var jailCfg string
var jailCfgLoaded bool var jailFilePath string
var filterErr error var filterErr error
// First, try to load filter config using jail name // Always load jail config first to determine which filter to load
config.DebugLog("Loading filter config for jail: %s", jail) config.DebugLog("Loading jail config for jail: %s", jail)
filterCfg, filterErr = conn.GetFilterConfig(c.Request.Context(), jail) var jailErr error
if filterErr != nil { jailCfg, jailFilePath, jailErr = conn.GetJailConfig(c.Request.Context(), jail)
config.DebugLog("Failed to load filter config with jail name, trying to find filter from jail config: %v", filterErr) if jailErr != nil {
config.DebugLog("Failed to load jail config: %v", jailErr)
// Load jail config first to check for custom filter directive c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + jailErr.Error()})
var jailErr error return
jailCfg, jailErr = conn.GetJailConfig(c.Request.Context(), jail)
if jailErr != nil {
config.DebugLog("Failed to load jail config: %v", jailErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load filter config: " + filterErr.Error() + ". Also failed to load jail config: " + jailErr.Error()})
return
}
jailCfgLoaded = true
config.DebugLog("Jail config loaded, length: %d", len(jailCfg))
// Extract filter name from jail config
filterName := fail2ban.ExtractFilterFromJailConfig(jailCfg)
if filterName == "" {
config.DebugLog("No filter directive found in jail config")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load filter config: " + filterErr.Error() + ". No filter directive found in jail config."})
return
}
config.DebugLog("Found filter directive in jail config: %s, trying to load that filter", filterName)
// Try loading the filter specified in jail config
filterCfg, filterErr = conn.GetFilterConfig(c.Request.Context(), filterName)
if filterErr != nil {
config.DebugLog("Failed to load filter config for %s: %v", filterName, filterErr)
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Failed to load filter config. Tried '%s' (jail name) and '%s' (from jail config), both failed. Last error: %v", jail, filterName, filterErr),
})
return
}
config.DebugLog("Successfully loaded filter config for %s (from jail config directive)", filterName)
} }
config.DebugLog("Filter config loaded, length: %d", len(filterCfg)) config.DebugLog("Jail config loaded, length: %d, file: %s", len(jailCfg), jailFilePath)
// Load jail config if not already loaded // Extract filter name from jail config, or use jail name as fallback
if !jailCfgLoaded { filterName := fail2ban.ExtractFilterFromJailConfig(jailCfg)
config.DebugLog("Loading jail config for jail: %s", jail) if filterName == "" {
var jailErr error // No filter directive found, use jail name as filter name (default behavior)
jailCfg, jailErr = conn.GetJailConfig(c.Request.Context(), jail) filterName = jail
if jailErr != nil { config.DebugLog("No filter directive found in jail config, using jail name as filter name: %s", filterName)
config.DebugLog("Failed to load jail config: %v", jailErr) } else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + jailErr.Error()}) config.DebugLog("Found filter directive in jail config: %s", filterName)
return }
}
config.DebugLog("Jail config loaded, length: %d", len(jailCfg)) // Load filter config using the determined filter name
config.DebugLog("Loading filter config for filter: %s", filterName)
filterCfg, filterFilePath, filterErr = conn.GetFilterConfig(c.Request.Context(), filterName)
if filterErr != nil {
config.DebugLog("Failed to load filter config for %s: %v", filterName, filterErr)
// Don't fail completely - allow editing even if filter doesn't exist yet
config.DebugLog("Continuing without filter config (filter may not exist yet)")
filterCfg = ""
filterFilePath = ""
} else {
config.DebugLog("Filter config loaded, length: %d, file: %s", len(filterCfg), filterFilePath)
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"jail": jail, "jail": jail,
"filter": filterCfg, "filter": filterCfg,
"jailConfig": jailCfg, "filterFilePath": filterFilePath,
"jailConfig": jailCfg,
"jailFilePath": jailFilePath,
}) })
} }
@@ -1219,15 +1204,47 @@ func SetJailFilterConfigHandler(c *gin.Context) {
config.DebugLog("Jail preview (first 100 chars): %s", req.Jail[:min(100, len(req.Jail))]) config.DebugLog("Jail preview (first 100 chars): %s", req.Jail[:min(100, len(req.Jail))])
} }
// Save filter config // Save filter config - use original filter name, not the one from the new jail config
if req.Filter != "" { if req.Filter != "" {
config.DebugLog("Saving filter config for jail: %s", jail) // Load the original jail config to determine which filter was originally loaded
if err := conn.SetFilterConfig(c.Request.Context(), jail, req.Filter); err != nil { originalJailCfg, _, err := conn.GetJailConfig(c.Request.Context(), jail)
if err != nil {
config.DebugLog("Failed to load original jail config to determine filter name: %v", err)
// Fallback: extract from new jail config
originalJailCfg = req.Jail
}
// Extract the ORIGINAL filter name (the one that was loaded when the modal opened)
originalFilterName := fail2ban.ExtractFilterFromJailConfig(originalJailCfg)
if originalFilterName == "" {
// No filter directive found in original config, use jail name as filter name (default behavior)
originalFilterName = jail
config.DebugLog("No filter directive found in original jail config, using jail name as filter name: %s", originalFilterName)
} else {
config.DebugLog("Found original filter directive in jail config: %s", originalFilterName)
}
// Extract the NEW filter name from the updated jail config
newFilterName := fail2ban.ExtractFilterFromJailConfig(req.Jail)
if newFilterName == "" {
newFilterName = jail
}
// If the filter name changed, save to the ORIGINAL filter name (not the new one)
// This prevents overwriting a different filter with the old filter's content
if originalFilterName != newFilterName {
config.DebugLog("Filter name changed from %s to %s, saving filter to original name: %s", originalFilterName, newFilterName, originalFilterName)
} else {
config.DebugLog("Filter name unchanged: %s", originalFilterName)
}
config.DebugLog("Saving filter config for filter: %s", originalFilterName)
if err := conn.SetFilterConfig(c.Request.Context(), originalFilterName, req.Filter); err != nil {
config.DebugLog("Failed to save filter config: %v", err) config.DebugLog("Failed to save filter config: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save filter config: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save filter config: " + err.Error()})
return return
} }
config.DebugLog("Filter config saved successfully") config.DebugLog("Filter config saved successfully to filter: %s", originalFilterName)
} else { } else {
config.DebugLog("No filter config provided, skipping") config.DebugLog("No filter config provided, skipping")
} }
@@ -1307,7 +1324,7 @@ func TestLogpathHandler(c *gin.Context) {
config.DebugLog("Using logpath from request body: %s", originalLogpath) config.DebugLog("Using logpath from request body: %s", originalLogpath)
} else { } else {
// Fall back to reading from saved jail config // Fall back to reading from saved jail config
jailCfg, err := conn.GetJailConfig(c.Request.Context(), jail) jailCfg, _, err := conn.GetJailConfig(c.Request.Context(), jail)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + err.Error()})
return return
@@ -1679,6 +1696,78 @@ func UpdateJailManagementHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Jail settings updated and fail2ban reloaded successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Jail settings updated and fail2ban reloaded successfully"})
} }
// CreateJailHandler creates a new jail.
func CreateJailHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("CreateJailHandler called (handlers.go)")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var req struct {
JailName string `json:"jailName" binding:"required"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
return
}
// Validate jail name
if err := fail2ban.ValidateJailName(req.JailName); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// If no content provided, create minimal jail config
if req.Content == "" {
req.Content = fmt.Sprintf("[%s]\nenabled = false\n", req.JailName)
}
// Create the jail
if err := conn.CreateJail(c.Request.Context(), req.JailName, req.Content); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create jail: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Jail '%s' created successfully", req.JailName)})
}
// DeleteJailHandler deletes a jail.
func DeleteJailHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("DeleteJailHandler called (handlers.go)")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
jailName := c.Param("jail")
if jailName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Jail name is required"})
return
}
// Validate jail name
if err := fail2ban.ValidateJailName(jailName); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Delete the jail
if err := conn.DeleteJail(c.Request.Context(), jailName); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete jail: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Jail '%s' deleted successfully", jailName)})
}
// GetSettingsHandler returns the entire AppSettings struct as JSON // GetSettingsHandler returns the entire AppSettings struct as JSON
func GetSettingsHandler(c *gin.Context) { func GetSettingsHandler(c *gin.Context) {
config.DebugLog("----------------------------") config.DebugLog("----------------------------")
@@ -1871,6 +1960,78 @@ func TestFilterHandler(c *gin.Context) {
}) })
} }
// CreateFilterHandler creates a new filter.
func CreateFilterHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("CreateFilterHandler called (handlers.go)")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var req struct {
FilterName string `json:"filterName" binding:"required"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
return
}
// Validate filter name
if err := fail2ban.ValidateFilterName(req.FilterName); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// If no content provided, create empty filter
if req.Content == "" {
req.Content = fmt.Sprintf("# Filter: %s\n", req.FilterName)
}
// Create the filter
if err := conn.CreateFilter(c.Request.Context(), req.FilterName, req.Content); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create filter: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Filter '%s' created successfully", req.FilterName)})
}
// DeleteFilterHandler deletes a filter.
func DeleteFilterHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("DeleteFilterHandler called (handlers.go)")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
filterName := c.Param("filter")
if filterName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Filter name is required"})
return
}
// Validate filter name
if err := fail2ban.ValidateFilterName(filterName); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Delete the filter
if err := conn.DeleteFilter(c.Request.Context(), filterName); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete filter: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Filter '%s' deleted successfully", filterName)})
}
// ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON // ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON
func ApplyFail2banSettings(jailLocalPath string) error { func ApplyFail2banSettings(jailLocalPath string) error {
config.DebugLog("----------------------------") config.DebugLog("----------------------------")

View File

@@ -41,6 +41,8 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) {
// Routes for jail management // Routes for jail management
api.GET("/jails/manage", ManageJailsHandler) api.GET("/jails/manage", ManageJailsHandler)
api.POST("/jails/manage", UpdateJailManagementHandler) api.POST("/jails/manage", UpdateJailManagementHandler)
api.POST("/jails", CreateJailHandler)
api.DELETE("/jails/:jail", DeleteJailHandler)
// Settings endpoints // Settings endpoints
api.GET("/settings", GetSettingsHandler) api.GET("/settings", GetSettingsHandler)
@@ -60,9 +62,8 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) {
// Filter debugger endpoints // Filter debugger endpoints
api.GET("/filters", ListFiltersHandler) api.GET("/filters", ListFiltersHandler)
api.POST("/filters/test", TestFilterHandler) api.POST("/filters/test", TestFilterHandler)
api.POST("/filters", CreateFilterHandler)
// TODO: create or generate new filters api.DELETE("/filters/:filter", DeleteFilterHandler)
// api.POST("/filters/generate", GenerateFilterHandler)
// Restart endpoint // Restart endpoint
api.POST("/fail2ban/restart", RestartFail2banHandler) api.POST("/fail2ban/restart", RestartFail2banHandler)

View File

@@ -317,3 +317,106 @@ mark {
#advancedMikrotikFields, #advancedPfSenseFields { #advancedMikrotikFields, #advancedPfSenseFields {
padding: 10px; padding: 10px;
} }
/* Additional Tailwind color classes for buttons */
.bg-red-500 {
--tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1));
}
.bg-red-600 {
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
}
.hover\:bg-red-600:hover {
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
}
.bg-purple-600 {
--tw-bg-opacity: 1;
background-color: rgb(147 51 234 / var(--tw-bg-opacity, 1));
}
.bg-purple-700 {
--tw-bg-opacity: 1;
background-color: rgb(126 34 206 / var(--tw-bg-opacity, 1));
}
.hover\:bg-purple-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(126 34 206 / var(--tw-bg-opacity, 1));
}
/* Ensure Font Awesome icons are visible */
.fas, .far, .fab, .fal, .fad {
font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands", "Font Awesome 6 Pro";
font-weight: 900;
display: inline-block;
font-style: normal;
font-variant: normal;
text-rendering: auto;
line-height: 1;
}
.fas::before {
font-weight: 900;
}
/* Button icon spacing */
button .fas, button .far, button .fab {
margin-right: 0.25rem;
}
button .fas:only-child, button .far:only-child, button .fab:only-child {
margin-right: 0;
}
/* Additional utility classes that might be missing */
/* Support for top-1/2 and -translate-y-1/2 (with escaped slash) */
.top-1\/2 {
top: 50%;
}
.-translate-y-1\/2 {
--tw-translate-y: -50%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
/* Alternative class name without escape (for compatibility) */
.top-1-2 {
top: 50%;
}
.-translate-y-1-2 {
--tw-translate-y: -50%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.gap-2 {
gap: 0.5rem;
}
/* Ensure buttons with icons display properly */
button.inline-flex, .inline-flex {
display: inline-flex;
align-items: center;
}
button .fas, button .far, button .fab {
display: inline-block;
width: 1em;
text-align: center;
}
/* Ensure delete button is visible */
button.bg-red-500, button.bg-red-600 {
color: white;
border: none;
cursor: pointer;
}
button.bg-red-500:hover, button.bg-red-600:hover {
background-color: rgb(220 38 38);
}

View File

@@ -24,11 +24,13 @@ function loadFilters() {
} }
} }
select.innerHTML = ''; select.innerHTML = '';
const deleteBtn = document.getElementById('deleteFilterBtn');
if (!data.filters || data.filters.length === 0) { if (!data.filters || data.filters.length === 0) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = ''; opt.value = '';
opt.textContent = 'No Filters Found'; opt.textContent = 'No Filters Found';
select.appendChild(opt); select.appendChild(opt);
if (deleteBtn) deleteBtn.disabled = true;
} else { } else {
data.filters.forEach(f => { data.filters.forEach(f => {
const opt = document.createElement('option'); const opt = document.createElement('option');
@@ -36,6 +38,14 @@ function loadFilters() {
opt.textContent = f; opt.textContent = f;
select.appendChild(opt); select.appendChild(opt);
}); });
// Add change listener if not already added
if (!select.hasAttribute('data-listener-added')) {
select.setAttribute('data-listener-added', 'true');
select.addEventListener('change', function() {
if (deleteBtn) deleteBtn.disabled = !select.value;
});
}
if (deleteBtn) deleteBtn.disabled = !select.value;
} }
}) })
.catch(err => { .catch(err => {
@@ -122,11 +132,115 @@ function showFilterSection() {
document.getElementById('logLinesTextarea').value = ''; document.getElementById('logLinesTextarea').value = '';
testResultsEl.innerHTML = ''; testResultsEl.innerHTML = '';
testResultsEl.classList.add('hidden'); testResultsEl.classList.add('hidden');
document.getElementById('deleteFilterBtn').disabled = true;
return; return;
} }
loadFilters(); loadFilters();
testResultsEl.innerHTML = ''; testResultsEl.innerHTML = '';
testResultsEl.classList.add('hidden'); testResultsEl.classList.add('hidden');
document.getElementById('logLinesTextarea').value = ''; document.getElementById('logLinesTextarea').value = '';
// Add change listener to enable/disable delete button
const filterSelect = document.getElementById('filterSelect');
const deleteBtn = document.getElementById('deleteFilterBtn');
filterSelect.addEventListener('change', function() {
deleteBtn.disabled = !filterSelect.value;
});
}
function openCreateFilterModal() {
document.getElementById('newFilterName').value = '';
document.getElementById('newFilterContent').value = '';
openModal('createFilterModal');
}
function createFilter() {
const filterName = document.getElementById('newFilterName').value.trim();
const content = document.getElementById('newFilterContent').value.trim();
if (!filterName) {
showToast('Filter name is required', 'error');
return;
}
showLoading(true);
fetch(withServerParam('/api/filters'), {
method: 'POST',
headers: serverHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
filterName: filterName,
content: content
})
})
.then(function(res) {
if (!res.ok) {
return res.json().then(function(data) {
throw new Error(data.error || 'Server returned ' + res.status);
});
}
return res.json();
})
.then(function(data) {
if (data.error) {
showToast('Error creating filter: ' + data.error, 'error');
return;
}
closeModal('createFilterModal');
showToast(data.message || 'Filter created successfully', 'success');
// Reload filters
loadFilters();
})
.catch(function(err) {
console.error('Error creating filter:', err);
showToast('Error creating filter: ' + (err.message || err), 'error');
})
.finally(function() {
showLoading(false);
});
}
function deleteFilter() {
const filterName = document.getElementById('filterSelect').value;
if (!filterName) {
showToast('Please select a filter to delete', 'info');
return;
}
if (!confirm('Are you sure you want to delete the filter "' + escapeHtml(filterName) + '"? This action cannot be undone.')) {
return;
}
showLoading(true);
fetch(withServerParam('/api/filters/' + encodeURIComponent(filterName)), {
method: 'DELETE',
headers: serverHeaders()
})
.then(function(res) {
if (!res.ok) {
return res.json().then(function(data) {
throw new Error(data.error || 'Server returned ' + res.status);
});
}
return res.json();
})
.then(function(data) {
if (data.error) {
showToast('Error deleting filter: ' + data.error, 'error');
return;
}
showToast(data.message || 'Filter deleted successfully', 'success');
// Reload filters
loadFilters();
// Clear test results
document.getElementById('testResults').innerHTML = '';
document.getElementById('testResults').classList.add('hidden');
document.getElementById('logLinesTextarea').value = '';
})
.catch(function(err) {
console.error('Error deleting filter:', err);
showToast('Error deleting filter: ' + (err.message || err), 'error');
})
.finally(function() {
showLoading(false);
});
} }

View File

@@ -56,6 +56,22 @@ function openJailConfigModal(jailName) {
filterTextArea.value = data.filter || ''; filterTextArea.value = data.filter || '';
jailTextArea.value = data.jailConfig || ''; jailTextArea.value = data.jailConfig || '';
// Display file paths if available
var filterFilePathEl = document.getElementById('filterFilePath');
var jailFilePathEl = document.getElementById('jailFilePath');
if (filterFilePathEl && data.filterFilePath) {
filterFilePathEl.textContent = data.filterFilePath;
filterFilePathEl.style.display = 'block';
} else if (filterFilePathEl) {
filterFilePathEl.style.display = 'none';
}
if (jailFilePathEl && data.jailFilePath) {
jailFilePathEl.textContent = data.jailFilePath;
jailFilePathEl.style.display = 'block';
} else if (jailFilePathEl) {
jailFilePathEl.style.display = 'none';
}
// Check if logpath is set in jail config and show test button // Check if logpath is set in jail config and show test button
updateLogpathButtonVisibility(); updateLogpathButtonVisibility();
@@ -267,9 +283,17 @@ function openManageJailsModal() {
+ ' onclick="openJailConfigModal(\'' + jsEscapedJailName + '\')"' + ' onclick="openJailConfigModal(\'' + jsEscapedJailName + '\')"'
+ ' class="text-xs px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors whitespace-nowrap"' + ' class="text-xs px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors whitespace-nowrap"'
+ ' data-i18n="modal.filter_config_edit"' + ' data-i18n="modal.filter_config_edit"'
+ ' title="' + escapeHtml(t('modal.filter_config_edit', 'Edit Filter')) + '"' + ' title="' + escapeHtml(t('modal.filter_config_edit', 'Edit Filter / Jail')) + '"'
+ ' >' + ' >'
+ escapeHtml(t('modal.filter_config_edit', 'Edit Filter')) + escapeHtml(t('modal.filter_config_edit', 'Edit Filter / Jail'))
+ ' </button>'
+ ' <button'
+ ' type="button"'
+ ' onclick="deleteJail(\'' + jsEscapedJailName + '\')"'
+ ' class="text-xs px-3 py-1.5 bg-red-500 text-white rounded hover:bg-red-600 transition-colors whitespace-nowrap"'
+ ' title="' + escapeHtml(t('modal.delete_jail', 'Delete Jail')) + '"'
+ ' >'
+ ' <i class="fas fa-trash"></i>'
+ ' </button>' + ' </button>'
+ ' <label class="inline-flex relative items-center cursor-pointer">' + ' <label class="inline-flex relative items-center cursor-pointer">'
+ ' <input' + ' <input'
@@ -428,3 +452,152 @@ function saveManageJailsSingle(checkbox) {
}); });
} }
function openCreateJailModal() {
document.getElementById('newJailName').value = '';
document.getElementById('newJailContent').value = '';
const filterSelect = document.getElementById('newJailFilter');
if (filterSelect) {
filterSelect.value = '';
}
// Load filters into dropdown
showLoading(true);
fetch(withServerParam('/api/filters'), {
headers: serverHeaders()
})
.then(res => res.json())
.then(data => {
if (filterSelect) {
filterSelect.innerHTML = '<option value="">-- Select a filter --</option>';
if (data.filters && data.filters.length > 0) {
data.filters.forEach(filter => {
const opt = document.createElement('option');
opt.value = filter;
opt.textContent = filter;
filterSelect.appendChild(opt);
});
}
}
openModal('createJailModal');
})
.catch(err => {
console.error('Error loading filters:', err);
openModal('createJailModal');
})
.finally(() => showLoading(false));
}
function updateJailConfigFromFilter() {
const filterSelect = document.getElementById('newJailFilter');
const jailNameInput = document.getElementById('newJailName');
const contentTextarea = document.getElementById('newJailContent');
if (!filterSelect || !contentTextarea) return;
const selectedFilter = filterSelect.value;
if (!selectedFilter) {
return;
}
// Auto-fill jail name if empty
if (jailNameInput && !jailNameInput.value.trim()) {
jailNameInput.value = selectedFilter;
}
// Auto-populate jail config
const jailName = (jailNameInput && jailNameInput.value.trim()) || selectedFilter;
const config = `[${jailName}]
enabled = false
filter = ${selectedFilter}
logpath = /var/log/auth.log
maxretry = 5
bantime = 3600
findtime = 600`;
contentTextarea.value = config;
}
function createJail() {
const jailName = document.getElementById('newJailName').value.trim();
const content = document.getElementById('newJailContent').value.trim();
if (!jailName) {
showToast('Jail name is required', 'error');
return;
}
showLoading(true);
fetch(withServerParam('/api/jails'), {
method: 'POST',
headers: serverHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
jailName: jailName,
content: content
})
})
.then(function(res) {
if (!res.ok) {
return res.json().then(function(data) {
throw new Error(data.error || 'Server returned ' + res.status);
});
}
return res.json();
})
.then(function(data) {
if (data.error) {
showToast('Error creating jail: ' + data.error, 'error');
return;
}
closeModal('createJailModal');
showToast(data.message || 'Jail created successfully', 'success');
// Reload the manage jails modal
openManageJailsModal();
})
.catch(function(err) {
console.error('Error creating jail:', err);
showToast('Error creating jail: ' + (err.message || err), 'error');
})
.finally(function() {
showLoading(false);
});
}
function deleteJail(jailName) {
if (!confirm('Are you sure you want to delete the jail "' + escapeHtml(jailName) + '"? This action cannot be undone.')) {
return;
}
showLoading(true);
fetch(withServerParam('/api/jails/' + encodeURIComponent(jailName)), {
method: 'DELETE',
headers: serverHeaders()
})
.then(function(res) {
if (!res.ok) {
return res.json().then(function(data) {
throw new Error(data.error || 'Server returned ' + res.status);
});
}
return res.json();
})
.then(function(data) {
if (data.error) {
showToast('Error deleting jail: ' + data.error, 'error');
return;
}
showToast(data.message || 'Jail deleted successfully', 'success');
// Reload the manage jails modal
openManageJailsModal();
// Refresh dashboard
refreshData({ silent: true });
})
.catch(function(err) {
console.error('Error deleting jail:', err);
showToast('Error deleting jail: ' + (err.message || err), 'error');
})
.finally(function() {
showLoading(false);
});
}

View File

@@ -157,10 +157,16 @@
<div id="filterNotice" class="hidden mb-4 text-sm text-yellow-700 bg-yellow-100 border border-yellow-200 rounded px-4 py-3"></div> <div id="filterNotice" class="hidden mb-4 text-sm text-yellow-700 bg-yellow-100 border border-yellow-200 rounded px-4 py-3"></div>
<div class="bg-white rounded-lg shadow p-6 mb-6"> <div class="bg-white rounded-lg shadow p-6 mb-6">
<!-- Dropdown of available jail/filters --> <div class="mb-4 flex justify-between items-end">
<div class="mb-4"> <div class="flex-1 mr-4">
<label for="filterSelect" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="filter_debug.select_filter">Select a Filter</label> <label for="filterSelect" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="filter_debug.select_filter">Select a Filter</label>
<select id="filterSelect" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"></select> <div class="flex gap-2">
<select id="filterSelect" class="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"></select>
<button type="button" onclick="deleteFilter()" id="deleteFilterBtn" class="px-3 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" disabled title="Delete selected filter">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div> </div>
<!-- Textarea for log lines to test --> <!-- Textarea for log lines to test -->
@@ -811,7 +817,10 @@
<div class="mt-4 space-y-4"> <div class="mt-4 space-y-4">
<!-- Filter Configuration --> <!-- Filter Configuration -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.filter_config_label">Filter Configuration</label> <div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-gray-700" data-i18n="modal.filter_config_label">Filter Configuration</label>
<span id="filterFilePath" class="text-xs text-gray-500 font-mono" style="display: none;"></span>
</div>
<div class="relative" style="position: relative;"> <div class="relative" style="position: relative;">
<textarea id="filterConfigTextarea" <textarea id="filterConfigTextarea"
class="w-full border border-gray-700 rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-96 font-mono text-sm bg-gray-900 text-white resize-none overflow-auto" class="w-full border border-gray-700 rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-96 font-mono text-sm bg-gray-900 text-white resize-none overflow-auto"
@@ -841,7 +850,10 @@
<!-- Jail Configuration --> <!-- Jail Configuration -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.jail_config_label">Jail Configuration</label> <div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-gray-700" data-i18n="modal.jail_config_label">Jail Configuration</label>
<span id="jailFilePath" class="text-xs text-gray-500 font-mono" style="display: none;"></span>
</div>
<div class="relative" style="position: relative;"> <div class="relative" style="position: relative;">
<textarea id="jailConfigTextarea" <textarea id="jailConfigTextarea"
class="w-full border border-gray-700 rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm bg-gray-900 text-white resize-none overflow-auto" class="w-full border border-gray-700 rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm bg-gray-900 text-white resize-none overflow-auto"
@@ -904,6 +916,16 @@
</button> </button>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<div class="mb-4 flex justify-end gap-2">
<button type="button" onclick="openCreateFilterModal()" class="inline-flex items-center gap-2 px-4 py-2 bg-gray-600 text-white text-sm font-medium rounded-md hover:bg-gray-700 transition-colors">
<i class="fas fa-plus"></i>
<span data-i18n="modal.create_filter">Create New Filter</span>
</button>
<button type="button" onclick="openCreateJailModal()" class="inline-flex items-center gap-2 px-4 py-2 bg-gray-600 text-white text-sm font-medium rounded-md hover:bg-gray-700 transition-colors">
<i class="fas fa-plus"></i>
<span data-i18n="modal.create_jail">Create New Jail</span>
</button>
</div>
<!-- Dynamically filled list of jails with toggle switches --> <!-- Dynamically filled list of jails with toggle switches -->
<div id="jailsList" class="divide-y divide-gray-200"></div> <div id="jailsList" class="divide-y divide-gray-200"></div>
</div> </div>
@@ -917,6 +939,93 @@
</div> </div>
</div> </div>
<!-- Create Jail Modal -->
<div id="createJailModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
<div class="relative z-10 w-full rounded-lg bg-white text-left shadow-xl transition-all" style="max-width: 600px;">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<div class="flex items-center justify-between">
<h3 class="text-lg leading-6 font-medium text-gray-900" data-i18n="modal.create_jail_title">Create New Jail</h3>
<button type="button" onclick="closeModal('createJailModal')" class="text-gray-400 hover:text-gray-600 focus:outline-none focus:text-gray-600" aria-label="Close">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="mt-4">
<div class="mb-4">
<label for="newJailName" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.jail_name">Jail Name</label>
<input type="text" id="newJailName" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="e.g., sshd" />
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.jail_name_hint">Only alphanumeric characters, dashes, and underscores are allowed.</p>
</div>
<div class="mb-4">
<label for="newJailFilter" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.jail_filter">Filter (optional)</label>
<select id="newJailFilter" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" onchange="updateJailConfigFromFilter()">
<option value="">-- Select a filter --</option>
</select>
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.jail_filter_hint">Selecting a filter will auto-populate the jail configuration.</p>
</div>
<div class="mb-4">
<label for="newJailContent" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.jail_config">Jail Configuration</label>
<textarea id="newJailContent" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 h-40 font-mono text-sm" placeholder="[jailname]&#10;enabled = false&#10;port = ssh&#10;filter = sshd&#10;logpath = /var/log/auth.log"></textarea>
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.jail_config_hint">Jail configuration will be auto-populated when you select a filter.</p>
</div>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" onclick="createJail()" class="mt-3 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" data-i18n="modal.create">Create</button>
<button type="button" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" onclick="closeModal('createJailModal')" data-i18n="modal.cancel">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Create Filter Modal -->
<div id="createFilterModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
<div class="relative z-10 w-full rounded-lg bg-white text-left shadow-xl transition-all" style="max-width: 600px;">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<div class="flex items-center justify-between">
<h3 class="text-lg leading-6 font-medium text-gray-900" data-i18n="modal.create_filter_title">Create New Filter</h3>
<button type="button" onclick="closeModal('createFilterModal')" class="text-gray-400 hover:text-gray-600 focus:outline-none focus:text-gray-600" aria-label="Close">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="mt-4">
<div class="mb-4">
<label for="newFilterName" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.filter_name">Filter Name</label>
<input type="text" id="newFilterName" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="e.g., myfilter" />
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.filter_name_hint">Only alphanumeric characters, dashes, and underscores are allowed.</p>
</div>
<div class="mb-4">
<label for="newFilterContent" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.filter_config">Filter Configuration (optional)</label>
<textarea id="newFilterContent" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 h-40 font-mono text-sm" placeholder="# Filter: myfilter&#10;[Definition]&#10;failregex = ^.*Failed login.*$"></textarea>
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.filter_config_hint">If left empty, an empty filter file will be created.</p>
</div>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" onclick="createFilter()" class="mt-3 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" data-i18n="modal.create">Create</button>
<button type="button" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" onclick="closeModal('createFilterModal')" data-i18n="modal.cancel">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Server Manager Modal --> <!-- Server Manager Modal -->
<div id="serverManagerModal" class="hidden fixed inset-0 z-50 overflow-y-auto"> <div id="serverManagerModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6"> <div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">