From 84a97eaa96c5b11414d7e570d1dd0c0e455f3ab8 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Tue, 30 Dec 2025 01:10:49 +0100 Subject: [PATCH] Fix loading wrong filter problem, implement creation and deletion of filters and jails, fix some css mismatches, update the handlers and routes --- internal/fail2ban/connector_agent.go | 66 ++- internal/fail2ban/connector_local.go | 24 +- internal/fail2ban/connector_ssh.go | 653 +++++++++++++++++-------- internal/fail2ban/filter_management.go | 233 +++++++-- internal/fail2ban/jail_management.go | 344 +++++++++---- internal/fail2ban/manager.go | 15 +- internal/locales/de.json | 4 +- internal/locales/de_ch.json | 4 +- internal/locales/en.json | 4 +- internal/locales/es.json | 4 +- internal/locales/fr.json | 4 +- internal/locales/it.json | 4 +- pkg/web/handlers.go | 275 ++++++++--- pkg/web/routes.go | 7 +- pkg/web/static/fail2ban-ui.css | 103 ++++ pkg/web/static/js/filters.js | 114 +++++ pkg/web/static/js/jails.js | 177 ++++++- pkg/web/templates/index.html | 121 ++++- 18 files changed, 1735 insertions(+), 421 deletions(-) diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go index 4d0da36..5ca1b0c 100644 --- a/internal/fail2ban/connector_agent.go +++ b/internal/fail2ban/connector_agent.go @@ -118,14 +118,21 @@ func (ac *AgentConnector) RestartWithMode(ctx context.Context) (string, error) { 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 { - 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 { - 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 { @@ -196,6 +203,14 @@ func (ac *AgentConnector) put(ctx context.Context, endpoint string, payload any, 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) { u := *ac.base 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. -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 { - 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 { - 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. @@ -415,3 +437,31 @@ func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error { // For now, we'll try calling it and handle the error gracefully 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) +} diff --git a/internal/fail2ban/connector_local.go b/internal/fail2ban/connector_local.go index f814fa4..926dfcd 100644 --- a/internal/fail2ban/connector_local.go +++ b/internal/fail2ban/connector_local.go @@ -199,7 +199,7 @@ func (lc *LocalConnector) Restart(ctx context.Context) error { } // 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) } @@ -306,7 +306,7 @@ func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, log } // 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) } @@ -338,6 +338,26 @@ func (lc *LocalConnector) EnsureJailLocalStructure(ctx context.Context) error { 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) { parts := strings.Fields(command) if len(parts) == 0 { diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index 1154976..28c3efb 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -21,45 +21,50 @@ import ( const sshEnsureActionScript = `python3 - <<'PY' import base64 import pathlib +import sys -action_dir = pathlib.Path("/etc/fail2ban/action.d") -action_dir.mkdir(parents=True, exist_ok=True) -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.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) -if not already: - new_lines = [] - inserted = False - for line in lines: - stripped = line.strip() - if stripped.startswith("action") and "ui-custom-action" not in stripped and not inserted: - if not stripped.startswith("#"): - new_lines.append("# " + line) - else: - new_lines.append(line) - new_lines.append("# Custom Fail2Ban action applied by fail2ban-ui") - new_lines.append("action = %(action_mwlg)s") - inserted = True - continue - new_lines.append(line) - if not inserted: - insert_at = None - for idx, value in enumerate(new_lines): - if value.strip().startswith("[DEFAULT]"): - insert_at = idx + 1 - break - if insert_at is None: - new_lines.append("[DEFAULT]") - insert_at = len(new_lines) - new_lines.insert(insert_at, "# Custom Fail2Ban action applied by fail2ban-ui") - new_lines.insert(insert_at + 1, "action = %(action_mwlg)s") - jail_file.write_text("\n".join(new_lines) + "\n") +try: + action_dir = pathlib.Path("/etc/fail2ban/action.d") + action_dir.mkdir(parents=True, exist_ok=True) + 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.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) + if not already: + new_lines = [] + inserted = False + for line in lines: + stripped = line.strip() + if stripped.startswith("action") and "ui-custom-action" not in stripped and not inserted: + if not stripped.startswith("#"): + new_lines.append("# " + line) + else: + new_lines.append(line) + new_lines.append("# Custom Fail2Ban action applied by fail2ban-ui") + new_lines.append("action = %(action_mwlg)s") + inserted = True + continue + new_lines.append(line) + if not inserted: + insert_at = None + for idx, value in enumerate(new_lines): + if value.strip().startswith("[DEFAULT]"): + insert_at = idx + 1 + break + if insert_at is None: + new_lines.append("[DEFAULT]") + insert_at = len(new_lines) + new_lines.insert(insert_at, "# Custom Fail2Ban action applied by fail2ban-ui") + 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` // 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") } 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 } @@ -186,7 +200,7 @@ func (sc *SSHConnector) Restart(ctx context.Context) error { // RestartWithMode implements the detailed restart logic for SSH connectors. func (sc *SSHConnector) RestartWithMode(ctx context.Context) (string, error) { // 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 := sc.checkFail2banHealthyRemote(ctx); err != nil { 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) } -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 - jail = strings.TrimSpace(jail) - if jail == "" { - return "", fmt.Errorf("filter name cannot be empty") + filterName = strings.TrimSpace(filterName) + if filterName == "" { + return "", "", fmt.Errorf("filter name cannot be empty") } + fail2banPath := sc.getFail2banPath(ctx) // Try .local first, then fallback to .conf - localPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail) - confPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail) + localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local") + 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 { - return out, nil + return content, localPath, nil } // Fallback to .conf - out, err = sc.runRemoteCommand(ctx, []string{"cat", confPath}) + content, err = sc.readRemoteFile(ctx, confPath) 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 - jail = strings.TrimSpace(jail) - if jail == "" { + filterName = strings.TrimSpace(filterName) + if filterName == "" { return fmt.Errorf("filter name cannot be empty") } - // Ensure .local file exists (copy from .conf if needed) - localPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail) - confPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail) + fail2banPath := sc.getFail2banPath(ctx) + filterDPath := filepath.Join(fail2banPath, "filter.d") - // Check if .local exists, if not, copy from .conf - checkScript := fmt.Sprintf(` - 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}) + // Ensure directory exists + _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", filterDPath}) 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) } // Write to .local file - cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, content) - _, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) - return err + localPath := filepath.Join(filterDPath, filterName+".local") + if err := sc.writeRemoteFile(ctx, localPath, content); err != nil { + return fmt.Errorf("failed to write filter config: %w", err) + } + + return nil } 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 { 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 if _, container := os.LookupEnv("CONTAINER"); container { args = append(args, @@ -403,6 +420,14 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string { "-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 != "" { args = append(args, "-i", sc.server.SSHKeyPath) } @@ -418,76 +443,182 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string { return args } -// GetAllJails implements Connector. -func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { - // Read jail.local (DEFAULT only) and jail.d files remotely - var allJails []JailInfo +// listRemoteFiles lists files in a remote directory matching a pattern. +// Uses Python to list files, which works better with FACL permissions than find/ls. +func (sc *SSHConnector) listRemoteFiles(ctx context.Context, directory, pattern string) ([]string, error) { + // 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) - jailLocalContent, err := sc.runRemoteCommand(ctx, []string{"cat", "/etc/fail2ban/jail.local"}) - if err == nil { - // 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 - for _, jail := range jails { - if jail.JailName != "DEFAULT" { - allJails = append(allJails, jail) - } + out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", script}) + if err != nil { + return nil, fmt.Errorf("failed to list files in %s: %w", directory, err) + } + + var files []string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line != "" { + files = append(files, line) } } - // Parse jail.d directory - prefer .local over .conf files - // 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}) - processedJails := make(map[string]bool) - if err == nil && jailDLocalList != "" { - for _, file := range strings.Split(jailDLocalList, "\n") { - file = strings.TrimSpace(file) - if file == "" { + return files, nil +} + +// readRemoteFile reads the content of a remote file via SSH. +func (sc *SSHConnector) readRemoteFile(ctx context.Context, filePath string) (string, error) { + content, err := sc.runRemoteCommand(ctx, []string{"cat", filePath}) + if err != nil { + return "", fmt.Errorf("failed to read remote file %s: %w", filePath, err) + } + 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 } - // Skip files that start with . (like .local) - these are invalid - baseName := filepath.Base(file) - if strings.HasPrefix(baseName, ".") { - config.DebugLog("Skipping invalid jail file: %s", file) + + processedFiles[baseName] = true + + // 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 } - content, err := sc.runRemoteCommand(ctx, []string{"cat", file}) - if err == nil { - jails := parseJailConfigContent(content) - for _, jail := range jails { - // Skip jails with empty names - if jail.JailName != "" { - allJails = append(allJails, jail) - processedJails[jail.JailName] = true - } + + jails := parseJailConfigContent(content) + for _, jail := range jails { + if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { + 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" - jailDConfList, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", jailDConfCmd}) - if err == nil && jailDConfList != "" { - for _, file := range strings.Split(jailDConfList, "\n") { - file = strings.TrimSpace(file) - if file == "" { + + // List all .conf files that don't have corresponding .local files + confFiles, err := sc.listRemoteFiles(ctx, jailDPath, ".conf") + if err != nil { + config.DebugLog("Failed to list .conf files in jail.d on server %s: %v", sc.server.Name, err) + } else { + // Process .conf files + for _, filePath := range confFiles { + filename := filepath.Base(filePath) + baseName := strings.TrimSuffix(filename, ".conf") + if baseName == "" || processedFiles[baseName] { continue } - // Extract jail name from filename - baseName := strings.TrimSuffix(filepath.Base(file), ".conf") - // Skip files that start with . (like .conf) - these are invalid - if baseName == "" || strings.HasPrefix(filepath.Base(file), ".") { - config.DebugLog("Skipping invalid jail file: %s", file) + + processedFiles[baseName] = true + + // 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 } - // Only process if we haven't already processed this jail from a .local file - if !processedJails[baseName] { - content, err := sc.runRemoteCommand(ctx, []string{"cat", file}) - if err == nil { - jails := parseJailConfigContent(content) - allJails = append(allJails, jails...) + + jails := parseJailConfigContent(content) + for _, jail := range jails { + if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { + allJails = append(allJails, jail) + processedJails[jail.JailName] = true } } } @@ -498,8 +629,11 @@ func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { // UpdateJailEnabledStates implements Connector. 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 - _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", "/etc/fail2ban/jail.d"}) + _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", jailDPath}) if err != nil { 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 } - localPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jailName) - confPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jailName) + localPath := filepath.Join(jailDPath, jailName+".local") + confPath := filepath.Join(jailDPath, jailName+".conf") // Ensure .local file exists (copy from .conf if needed) ensureScript := fmt.Sprintf(` @@ -591,48 +725,72 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map } // GetFilters implements Connector. +// Discovers all filters from filesystem (mirrors local connector behavior). func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) { - // Use find to list filter files - list, err := sc.runRemoteCommand(ctx, []string{"find", "/etc/fail2ban/filter.d", "-maxdepth", "1", "-type", "f"}) - if err != nil { - return nil, fmt.Errorf("failed to list filters: %w", err) + fail2banPath := sc.getFail2banPath(ctx) + filterDPath := filepath.Join(fail2banPath, "filter.d") + + 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 - seen := make(map[string]bool) // Avoid duplicates - for _, line := range strings.Split(list, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - // Only process .conf files - be strict about the extension - 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") { + + // First pass: collect all .local files (these take precedence) + localFiles, err := sc.listRemoteFiles(ctx, filterDPath, ".local") + if err != nil { + config.DebugLog("Failed to list .local filters on server %s: %v", sc.server.Name, err) + } else { + for _, filePath := range localFiles { + filename := filepath.Base(filePath) + if shouldExclude(filename) { continue } - name := strings.TrimSuffix(filename, ".conf") - if name != "" && !seen[name] { - seen[name] = true - filters = append(filters, name) + baseName := strings.TrimSuffix(filename, ".local") + if baseName == "" || processedFiles[baseName] { + continue } + 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) + return filters, nil } @@ -719,29 +877,30 @@ fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true } // 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 jail = strings.TrimSpace(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 - localPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail) - confPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jail) + localPath := filepath.Join(fail2banPath, "jail.d", jail+".local") + 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 { - return out, nil + return content, localPath, nil } // Fallback to .conf - out, err = sc.runRemoteCommand(ctx, []string{"cat", confPath}) + content, err = sc.readRemoteFile(ctx, confPath) if err != nil { - // If neither exists, return empty jail section - return fmt.Sprintf("[%s]\n", jail), nil + // If neither exists, return empty jail section with .local path (will be created on save) + return fmt.Sprintf("[%s]\n", jail), localPath, nil } - return out, nil + return content, confPath, nil } // 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") } - localPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail) - confPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jail) + fail2banPath := sc.getFail2banPath(ctx) + jailDPath := filepath.Join(fail2banPath, "jail.d") // 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 { return fmt.Errorf("failed to create jail.d directory: %w", err) } // Ensure .local file exists (copy from .conf if needed) - ensureScript := fmt.Sprintf(` - 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 { + if err := sc.ensureRemoteLocalFile(ctx, jailDPath, jail); err != nil { return fmt.Errorf("failed to ensure .local file for jail %s: %w", jail, err) } // Write to .local file - cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, content) - _, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) - return err + localPath := filepath.Join(jailDPath, jail+".local") + if err := sc.writeRemoteFile(ctx, localPath, content); err != nil { + return fmt.Errorf("failed to write jail config: %w", err) + } + + return nil } // 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) out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", checkScript}) 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 } // Read jail.local content content, err := sc.runRemoteCommand(ctx, []string{"cat", jailLocalPath}) 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 sections, defaultContent, err := parseJailSectionsUncommented(content) 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 @@ -1256,14 +1409,14 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err backupPath := jailLocalPath + ".backup." + fmt.Sprintf("%d", time.Now().Unix()) backupScript := fmt.Sprintf("cp %s %s", jailLocalPath, backupPath) 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 ensureDirScript := fmt.Sprintf("mkdir -p %s", jailDPath) 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 @@ -1279,7 +1432,7 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err checkFileScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailFilePath) fileOut, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", checkFileScript}) 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 } @@ -1293,7 +1446,7 @@ JAILEOF if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", writeScript}); err != nil { 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++ } @@ -1309,7 +1462,107 @@ LOCALEOF if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", writeLocalScript}); err != nil { 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 diff --git a/internal/fail2ban/filter_management.go b/internal/fail2ban/filter_management.go index 070f16c..3ed40f1 100644 --- a/internal/fail2ban/filter_management.go +++ b/internal/fail2ban/filter_management.go @@ -22,6 +22,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "sort" "strings" @@ -29,10 +30,11 @@ import ( ) // 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() if err != nil { - return "", err + return "", "", err } 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. -// If .local doesn't exist, it copies from .conf if available. -// Returns error if neither .local nor .conf exists (filters must have base .conf). +// If .local doesn't exist, it copies from .conf if available, or creates an empty file. func ensureFilterLocalFile(filterName string) error { // Validate filter name - must not be empty filterName = strings.TrimSpace(filterName) @@ -80,16 +81,22 @@ func ensureFilterLocalFile(filterName string) error { return nil } - // Neither exists, return error (filters must have base .conf) - return fmt.Errorf("filter .conf file does not exist: %s (filters must have a base .conf file)", confPath) + // Neither exists, create empty .local file + 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. -func readFilterConfigWithFallback(filterName string) (string, error) { +// Returns (content, filePath, error) +func readFilterConfigWithFallback(filterName string) (string, string, error) { // Validate filter name - must not be empty filterName = strings.TrimSpace(filterName) if filterName == "" { - return "", fmt.Errorf("filter name cannot be empty") + return "", "", fmt.Errorf("filter name cannot be empty") } filterDPath := "/etc/fail2ban/filter.d" @@ -99,22 +106,23 @@ func readFilterConfigWithFallback(filterName string) (string, error) { // Try .local first if content, err := os.ReadFile(localPath); err == nil { config.DebugLog("Reading filter config from .local: %s", localPath) - return string(content), nil + return string(content), localPath, nil } // Fallback to .conf if content, err := os.ReadFile(confPath); err == nil { config.DebugLog("Reading filter config from .conf: %s", confPath) - return string(content), nil + return string(content), confPath, nil } - // Neither exists, return error - return "", fmt.Errorf("filter config not found: neither %s nor %s exists", localPath, confPath) + // Neither exists, return error with .local path (will be created on save) + return "", localPath, fmt.Errorf("filter config not found: neither %s nor %s exists", localPath, confPath) } // GetFilterConfigLocal reads a filter configuration from the local filesystem. // 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) } @@ -134,42 +142,205 @@ func SetFilterConfigLocal(jail, newContent string) error { 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) -func GetFiltersLocal() ([]string, error) { - dir := "/etc/fail2ban/filter.d" - entries, err := os.ReadDir(dir) - if err != nil { - return nil, fmt.Errorf("failed to read filter directory: %w", err) +// ValidateFilterName validates a filter name format. +// Returns an error if the name is invalid (empty, contains invalid characters, or is reserved). +func ValidateFilterName(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("filter name cannot be empty") } - 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) - for _, entry := range entries { - if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".local") { - name := strings.TrimSuffix(entry.Name(), ".local") - filterMap[name] = true + 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 + filterMap[baseName] = true } // Second pass: collect .conf files that don't have corresponding .local files - for _, entry := range entries { - if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".conf") { - name := strings.TrimSuffix(entry.Name(), ".conf") - if !filterMap[name] { - filterMap[name] = true - } + 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 + filterMap[baseName] = true } + // Convert map to sorted slice var filters []string for name := range filterMap { filters = append(filters, name) } sort.Strings(filters) + 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 { var cleaned []string for _, line := range logLines { diff --git a/internal/fail2ban/jail_management.go b/internal/fail2ban/jail_management.go index 7efe22d..c6bac74 100644 --- a/internal/fail2ban/jail_management.go +++ b/internal/fail2ban/jail_management.go @@ -64,11 +64,12 @@ func ensureJailLocalFile(jailName string) error { } // 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 jailName = strings.TrimSpace(jailName) if jailName == "" { - return "", fmt.Errorf("jail name cannot be empty") + return "", "", fmt.Errorf("jail name cannot be empty") } jailDPath := "/etc/fail2ban/jail.d" @@ -78,22 +79,254 @@ func readJailConfigWithFallback(jailName string) (string, error) { // Try .local first if content, err := os.ReadFile(localPath); err == nil { config.DebugLog("Reading jail config from .local: %s", localPath) - return string(content), nil + return string(content), localPath, nil } // Fallback to .conf if content, err := os.ReadFile(confPath); err == nil { 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) - 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. // Automatically migrates legacy jails from jail.local to jail.d on first call. +// Now uses DiscoverJailsFromFiles() for file-based discovery. func GetAllJails() ([]JailInfo, error) { // Run migration once if needed migrationOnce.Do(func() { @@ -102,70 +335,12 @@ func GetAllJails() ([]JailInfo, error) { } }) - var jails []JailInfo - - // Parse only DEFAULT section from jail.local (skip other jails) - localPath := "/etc/fail2ban/jail.local" - if _, err := os.Stat(localPath); err == nil { - defaultJails, err := parseJailConfigFileOnlyDefault(localPath) - if err == nil { - jails = append(jails, defaultJails...) - } + // Discover jails from filesystem + jails, err := DiscoverJailsFromFiles() + if err != nil { + return nil, fmt.Errorf("failed to discover jails from files: %w", err) } - // 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 } @@ -697,44 +872,23 @@ func MigrateJailsFromJailLocal() error { 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 // 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 jailName = strings.TrimSpace(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) - content, err := readJailConfigWithFallback(jailName) + content, filePath, err := readJailConfigWithFallback(jailName) if err != nil { 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)) - return content, nil + config.DebugLog("Jail config read successfully, length: %d, file: %s", len(content), filePath) + return content, filePath, nil } // SetJailConfig writes the full jail configuration to /etc/fail2ban/jail.d/{jailName}.local diff --git a/internal/fail2ban/manager.go b/internal/fail2ban/manager.go index 153d297..4acdf83 100644 --- a/internal/fail2ban/manager.go +++ b/internal/fail2ban/manager.go @@ -18,7 +18,7 @@ type Connector interface { UnbanIP(ctx context.Context, jail, ip string) error Reload(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 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) // 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 TestLogpath(ctx context.Context, logpath string) ([]string, 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 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. @@ -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 // before ensureJailLocalStructure() overwrites jail.local if err := MigrateJailsFromJailLocal(); err != nil { - config.DebugLog("Warning: migration check failed (may be normal if no jails to migrate): %v", err) - // Don't fail - continue with ensuring structure + return nil, fmt.Errorf("failed to initialise local fail2ban connector for %s: %w", server.Name, err) } 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 case "ssh": diff --git a/internal/locales/de.json b/internal/locales/de.json index 703d6a3..c9c3791 100644 --- a/internal/locales/de.json +++ b/internal/locales/de.json @@ -196,8 +196,8 @@ "settings.advanced.test_block": "IP sperren", "settings.advanced.test_unblock": "IP entfernen", "settings.save": "Speichern", - "modal.filter_config": "Filter-Konfiguration:", - "modal.filter_config_edit": "Filter bearbeiten", + "modal.filter_config": "Filter / Jail-Konfiguration:", + "modal.filter_config_edit": "Filter / Jail bearbeiten", "modal.cancel": "Abbrechen", "modal.save": "Speichern", "modal.close": "Schließen", diff --git a/internal/locales/de_ch.json b/internal/locales/de_ch.json index 5b1c020..356671b 100644 --- a/internal/locales/de_ch.json +++ b/internal/locales/de_ch.json @@ -196,8 +196,8 @@ "settings.advanced.test_block": "IP sperre", "settings.advanced.test_unblock": "IP freigäh", "settings.save": "Speicherä", - "modal.filter_config": "Filter-Konfiguration:", - "modal.filter_config_edit": "Filter bearbeite", + "modal.filter_config": "Filter / Jail-Konfiguration:", + "modal.filter_config_edit": "Filter / Jail bearbeite", "modal.cancel": "Abbräche", "modal.save": "Speicherä", "modal.close": "Wider Schliesse", diff --git a/internal/locales/en.json b/internal/locales/en.json index 24670b9..20c9186 100644 --- a/internal/locales/en.json +++ b/internal/locales/en.json @@ -196,8 +196,8 @@ "settings.advanced.test_block": "Block IP", "settings.advanced.test_unblock": "Remove IP", "settings.save": "Save", - "modal.filter_config": "Filter Config:", - "modal.filter_config_edit": "Edit Filter", + "modal.filter_config": "Filter / Jail Configuration:", + "modal.filter_config_edit": "Edit Filter / Jail", "modal.cancel": "Cancel", "modal.save": "Save", "modal.close": "Close", diff --git a/internal/locales/es.json b/internal/locales/es.json index d10068f..87cee13 100644 --- a/internal/locales/es.json +++ b/internal/locales/es.json @@ -196,8 +196,8 @@ "settings.advanced.test_block": "Bloquear IP", "settings.advanced.test_unblock": "Eliminar IP", "settings.save": "Guardar", - "modal.filter_config": "Configuración del filtro:", - "modal.filter_config_edit": "Editar filtro", + "modal.filter_config": "Configuración del filtro / Jail:", + "modal.filter_config_edit": "Editar filtro / Jail", "modal.cancel": "Cancelar", "modal.save": "Guardar", "modal.close": "Cerrar", diff --git a/internal/locales/fr.json b/internal/locales/fr.json index 7bbb72d..1eb36d1 100644 --- a/internal/locales/fr.json +++ b/internal/locales/fr.json @@ -196,8 +196,8 @@ "settings.advanced.test_block": "Bloquer l’IP", "settings.advanced.test_unblock": "Retirer l’IP", "settings.save": "Enregistrer", - "modal.filter_config": "Configuration du filtre:", - "modal.filter_config_edit": "Modifier le filtre", + "modal.filter_config": "Configuration du filtre / Jail:", + "modal.filter_config_edit": "Modifier le filtre / Jail", "modal.cancel": "Annuler", "modal.save": "Enregistrer", "modal.close": "Fermer", diff --git a/internal/locales/it.json b/internal/locales/it.json index a1aa45a..a6e281a 100644 --- a/internal/locales/it.json +++ b/internal/locales/it.json @@ -196,8 +196,8 @@ "settings.advanced.test_block": "Blocca IP", "settings.advanced.test_unblock": "Rimuovi IP", "settings.save": "Salva", - "modal.filter_config": "Configurazione del filtro:", - "modal.filter_config_edit": "Modifica filtro", + "modal.filter_config": "Configurazione del filtro / Jail:", + "modal.filter_config_edit": "Modifica filtro / Jail", "modal.cancel": "Annulla", "modal.save": "Salva", "modal.close": "Chiudi", diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index bb82ca5..c0fe503 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -1116,66 +1116,51 @@ func GetJailFilterConfigHandler(c *gin.Context) { config.DebugLog("Connector resolved: %s", conn.Server().Name) var filterCfg string + var filterFilePath string var jailCfg string - var jailCfgLoaded bool + var jailFilePath string var filterErr error - // First, try to load filter config using jail name - config.DebugLog("Loading filter config for jail: %s", jail) - filterCfg, filterErr = conn.GetFilterConfig(c.Request.Context(), jail) - if filterErr != nil { - config.DebugLog("Failed to load filter config with jail name, trying to find filter from jail config: %v", filterErr) - - // Load jail config first to check for custom filter directive - var jailErr error - 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) + // Always load jail config first to determine which filter to load + config.DebugLog("Loading jail config for jail: %s", jail) + var jailErr error + jailCfg, jailFilePath, 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 jail config: " + jailErr.Error()}) + return } - 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 - if !jailCfgLoaded { - config.DebugLog("Loading jail config for jail: %s", jail) - var jailErr error - 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 jail config: " + jailErr.Error()}) - return - } - config.DebugLog("Jail config loaded, length: %d", len(jailCfg)) + // Extract filter name from jail config, or use jail name as fallback + filterName := fail2ban.ExtractFilterFromJailConfig(jailCfg) + if filterName == "" { + // No filter directive found, use jail name as filter name (default behavior) + filterName = jail + config.DebugLog("No filter directive found in jail config, using jail name as filter name: %s", filterName) + } else { + config.DebugLog("Found filter directive in jail config: %s", filterName) + } + + // 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{ - "jail": jail, - "filter": filterCfg, - "jailConfig": jailCfg, + "jail": jail, + "filter": filterCfg, + "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))]) } - // Save filter config + // Save filter config - use original filter name, not the one from the new jail config if req.Filter != "" { - config.DebugLog("Saving filter config for jail: %s", jail) - if err := conn.SetFilterConfig(c.Request.Context(), jail, req.Filter); err != nil { + // Load the original jail config to determine which filter was originally loaded + 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) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save filter config: " + err.Error()}) return } - config.DebugLog("Filter config saved successfully") + config.DebugLog("Filter config saved successfully to filter: %s", originalFilterName) } else { 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) } else { // 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 { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + err.Error()}) return @@ -1679,6 +1696,78 @@ func UpdateJailManagementHandler(c *gin.Context) { 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 func GetSettingsHandler(c *gin.Context) { 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 func ApplyFail2banSettings(jailLocalPath string) error { config.DebugLog("----------------------------") diff --git a/pkg/web/routes.go b/pkg/web/routes.go index f88416c..2f5d1af 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -41,6 +41,8 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) { // Routes for jail management api.GET("/jails/manage", ManageJailsHandler) api.POST("/jails/manage", UpdateJailManagementHandler) + api.POST("/jails", CreateJailHandler) + api.DELETE("/jails/:jail", DeleteJailHandler) // Settings endpoints api.GET("/settings", GetSettingsHandler) @@ -60,9 +62,8 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) { // Filter debugger endpoints api.GET("/filters", ListFiltersHandler) api.POST("/filters/test", TestFilterHandler) - - // TODO: create or generate new filters - // api.POST("/filters/generate", GenerateFilterHandler) + api.POST("/filters", CreateFilterHandler) + api.DELETE("/filters/:filter", DeleteFilterHandler) // Restart endpoint api.POST("/fail2ban/restart", RestartFail2banHandler) diff --git a/pkg/web/static/fail2ban-ui.css b/pkg/web/static/fail2ban-ui.css index 45d96bb..b7bc902 100644 --- a/pkg/web/static/fail2ban-ui.css +++ b/pkg/web/static/fail2ban-ui.css @@ -317,3 +317,106 @@ mark { #advancedMikrotikFields, #advancedPfSenseFields { 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); +} \ No newline at end of file diff --git a/pkg/web/static/js/filters.js b/pkg/web/static/js/filters.js index 04cf256..fa32fd3 100644 --- a/pkg/web/static/js/filters.js +++ b/pkg/web/static/js/filters.js @@ -24,11 +24,13 @@ function loadFilters() { } } select.innerHTML = ''; + const deleteBtn = document.getElementById('deleteFilterBtn'); if (!data.filters || data.filters.length === 0) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = 'No Filters Found'; select.appendChild(opt); + if (deleteBtn) deleteBtn.disabled = true; } else { data.filters.forEach(f => { const opt = document.createElement('option'); @@ -36,6 +38,14 @@ function loadFilters() { opt.textContent = f; 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 => { @@ -122,11 +132,115 @@ function showFilterSection() { document.getElementById('logLinesTextarea').value = ''; testResultsEl.innerHTML = ''; testResultsEl.classList.add('hidden'); + document.getElementById('deleteFilterBtn').disabled = true; return; } loadFilters(); testResultsEl.innerHTML = ''; testResultsEl.classList.add('hidden'); 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); + }); } diff --git a/pkg/web/static/js/jails.js b/pkg/web/static/js/jails.js index 5d680a9..f64202b 100644 --- a/pkg/web/static/js/jails.js +++ b/pkg/web/static/js/jails.js @@ -56,6 +56,22 @@ function openJailConfigModal(jailName) { filterTextArea.value = data.filter || ''; 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 updateLogpathButtonVisibility(); @@ -267,9 +283,17 @@ function openManageJailsModal() { + ' 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"' + ' 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')) + + ' ' + + ' ' + + ' ' + ' ' + '