From 337d199143b0dea64749334277ee4aa0c9ad2641 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Tue, 10 Feb 2026 15:50:32 +0100 Subject: [PATCH] Simplify the connector and jail.local cunstruction with a unified function for all connectors --- internal/config/settings.go | 240 ++++------------------- internal/fail2ban/connector_agent.go | 49 +---- internal/fail2ban/connector_ssh.go | 281 +-------------------------- internal/fail2ban/jail_management.go | 210 +------------------- 4 files changed, 46 insertions(+), 734 deletions(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index ab3428a..0a97ee3 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -863,70 +863,22 @@ func ensureFail2banActionFiles(callbackURL, serverID string) error { } // Ensure jail.local has proper structure (banner, DEFAULT, action_mwlg, action override) - if err := ensureJailLocalStructure(); err != nil { + if err := EnsureJailLocalStructure(); err != nil { return err } return writeFail2banAction(callbackURL, serverID) } -// EnsureJailLocalStructure creates or updates jail.local with proper structure: -// This is exported so connectors can call it. -func EnsureJailLocalStructure() error { - return ensureJailLocalStructure() -} - -// ensureJailLocalStructure creates or updates jail.local with proper structure: -func ensureJailLocalStructure() error { - DebugLog("Running ensureJailLocalStructure()") // entry point - - // Check if /etc/fail2ban directory exists (fail2ban must be installed) - if _, err := os.Stat(filepath.Dir(jailFile)); os.IsNotExist(err) { - return fmt.Errorf("fail2ban is not installed: /etc/fail2ban directory does not exist. Please install fail2ban package first") - } - - // Get current settings +// BuildJailLocalContent builds the complete managed jail.local file content +// from the current settings. This is the single source of truth for the file +// format, shared by all connectors (local, SSH, agent). +func BuildJailLocalContent() string { settings := GetSettings() - // Read existing jail.local content if it exists - var existingContent string - fileExists := false - if content, err := os.ReadFile(jailFile); err == nil { - existingContent = string(content) - fileExists = len(strings.TrimSpace(existingContent)) > 0 - } - - // If jail.local exists but is NOT managed by Fail2ban-UI, - // it belongs to the user; never overwrite it. - hasUIAction := strings.Contains(existingContent, "ui-custom-action") - if fileExists && !hasUIAction { - DebugLog("jail.local exists but is not managed by Fail2ban-UI - skipping overwrite") - return nil - } - - // Check if file already has our full banner; indicating it's already properly structured - hasFullBanner := strings.Contains(existingContent, "################################################################################") && - strings.Contains(existingContent, "Fail2Ban-UI Managed Configuration") && - strings.Contains(existingContent, "DO NOT EDIT THIS FILE MANUALLY") - hasActionMwlg := strings.Contains(existingContent, "action_mwlg") && hasUIAction - hasActionOverride := strings.Contains(existingContent, "action = %(action_mwlg)s") - - // If file is already properly structured, just ensure DEFAULT section is up to date - if hasFullBanner && hasActionMwlg && hasActionOverride { - DebugLog("jail.local already has proper structure, updating DEFAULT section if needed") - // Update DEFAULT section values without changing structure - return updateJailLocalDefaultSection(settings) - } - - // Use the standard banner - banner := jailLocalBanner - - // Build [DEFAULT] section - // Convert IgnoreIPs array to space-separated string ignoreIPStr := strings.Join(settings.IgnoreIPs, " ") if ignoreIPStr == "" { ignoreIPStr = "127.0.0.1/8 ::1" } - // Set default banaction values if not set banaction := settings.Banaction if banaction == "" { banaction = "nftables-multiport" @@ -949,34 +901,52 @@ maxretry = %d banaction = %s banaction_allports = %s chain = %s - -`, settings.DefaultJailEnable, settings.BantimeIncrement, ignoreIPStr, settings.Bantime, settings.Findtime, settings.Maxretry, banaction, banactionAllports, chain) +`, settings.DefaultJailEnable, settings.BantimeIncrement, ignoreIPStr, + settings.Bantime, settings.Findtime, settings.Maxretry, + banaction, banactionAllports, chain) if settings.BantimeRndtime != "" { defaultSection += fmt.Sprintf("bantime.rndtime = %s\n", settings.BantimeRndtime) } defaultSection += "\n" - // Build action_mwlg configuration - // Note: action_mwlg depends on action_ which depends on banaction - // The multi-line format uses indentation for continuation - // ui-custom-action only needs logpath and chain actionMwlgConfig := `# Custom Fail2Ban action for UI callbacks action_mwlg = %(action_)s ui-custom-action[logpath="%(logpath)s", chain="%(chain)s"] ` - - // Build action override (at the end as per user requirements) actionOverride := `# Custom Fail2Ban action applied by fail2ban-ui action = %(action_mwlg)s ` - // Combine all parts - newContent := banner + defaultSection + actionMwlgConfig + actionOverride + return jailLocalBanner + defaultSection + actionMwlgConfig + actionOverride +} - // Write the new content - err := os.WriteFile(jailFile, []byte(newContent), 0644) - if err != nil { +// EnsureJailLocalStructure writes a complete managed jail.local to the local filesystem. +func EnsureJailLocalStructure() error { + DebugLog("Running EnsureJailLocalStructure()") + + // Check if /etc/fail2ban directory exists (fail2ban must be installed) + if _, err := os.Stat(filepath.Dir(jailFile)); os.IsNotExist(err) { + return fmt.Errorf("fail2ban is not installed: /etc/fail2ban directory does not exist. Please install fail2ban package first") + } + + // Read existing jail.local content if it exists + var existingContent string + fileExists := false + if content, err := os.ReadFile(jailFile); err == nil { + existingContent = string(content) + fileExists = len(strings.TrimSpace(existingContent)) > 0 + } + + // If jail.local exists but is NOT managed by Fail2ban-UI, + // it belongs to the user — never overwrite it. + if fileExists && !strings.Contains(existingContent, "ui-custom-action") { + DebugLog("jail.local exists but is not managed by Fail2ban-UI - skipping overwrite") + return nil + } + + // Full rewrite from current settings (self-healing, no stale keys) + if err := os.WriteFile(jailFile, []byte(BuildJailLocalContent()), 0644); err != nil { return fmt.Errorf("failed to write jail.local: %v", err) } @@ -984,144 +954,6 @@ action = %(action_mwlg)s return nil } -// updateJailLocalDefaultSection updates only the [DEFAULT] section values in jail.local -// while preserving the banner, action_mwlg, and action override -func updateJailLocalDefaultSection(settings AppSettings) error { - content, err := os.ReadFile(jailFile) - if err != nil { - return fmt.Errorf("failed to read jail.local: %w", err) - } - - contentStr := string(content) - lines := strings.Split(contentStr, "\n") - var outputLines []string - inDefault := false - defaultUpdated := false - - // Convert IgnoreIPs array to space-separated string - ignoreIPStr := strings.Join(settings.IgnoreIPs, " ") - if ignoreIPStr == "" { - ignoreIPStr = "127.0.0.1/8 ::1" - } - // Set default banaction values if not set - banaction := settings.Banaction - if banaction == "" { - banaction = "nftables-multiport" - } - banactionAllports := settings.BanactionAllports - if banactionAllports == "" { - banactionAllports = "nftables-allports" - } - chain := settings.Chain - if chain == "" { - chain = "INPUT" - } - // Keys to update - keysToUpdate := map[string]string{ - "enabled": fmt.Sprintf("enabled = %t", settings.DefaultJailEnable), - "bantime.increment": fmt.Sprintf("bantime.increment = %t", settings.BantimeIncrement), - "ignoreip": fmt.Sprintf("ignoreip = %s", ignoreIPStr), - "bantime": fmt.Sprintf("bantime = %s", settings.Bantime), - "findtime": fmt.Sprintf("findtime = %s", settings.Findtime), - "maxretry": fmt.Sprintf("maxretry = %d", settings.Maxretry), - "banaction": fmt.Sprintf("banaction = %s", banaction), - "banaction_allports": fmt.Sprintf("banaction_allports = %s", banactionAllports), - "chain": fmt.Sprintf("chain = %s", chain), - } - if settings.BantimeRndtime != "" { - keysToUpdate["bantime.rndtime"] = fmt.Sprintf("bantime.rndtime = %s", settings.BantimeRndtime) - } - defaultKeysOrder := []string{"enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "banaction", "banaction_allports", "chain"} - if settings.BantimeRndtime != "" { - defaultKeysOrder = append(defaultKeysOrder, "bantime.rndtime") - } - keysUpdated := make(map[string]bool) - - // Always add the full banner at the start - outputLines = append(outputLines, strings.Split(strings.TrimRight(jailLocalBanner, "\n"), "\n")...) - - // Skip everything before [DEFAULT] section (old banner, comments, empty lines) - foundSection := false - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { - // Found a section - stop skipping and process this line - foundSection = true - } - if !foundSection { - // Skip lines before any section (old banner, comments, empty lines) - continue - } - - // Process lines after we found a section - if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { - sectionName := strings.Trim(trimmed, "[]") - if sectionName == "DEFAULT" { - inDefault = true - outputLines = append(outputLines, line) - } else { - inDefault = false - outputLines = append(outputLines, line) - } - } else if inDefault { - // Check if this line is a key we need to update - keyUpdated := false - // When user cleared bantime.rndtime, remove the line from config instead of keeping old value - if settings.BantimeRndtime == "" { - if matched, _ := regexp.MatchString(`^\s*bantime\.rndtime\s*=`, trimmed); matched { - keyUpdated = true - defaultUpdated = true - // don't append: line is removed - } - } - if !keyUpdated { - for key, newValue := range keysToUpdate { - keyPattern := "^\\s*" + regexp.QuoteMeta(key) + "\\s*=" - if matched, _ := regexp.MatchString(keyPattern, trimmed); matched { - outputLines = append(outputLines, newValue) - keysUpdated[key] = true - keyUpdated = true - defaultUpdated = true - break - } - } - } - if !keyUpdated { - // Keep the line as-is - outputLines = append(outputLines, line) - } - } else { - // Keep lines outside DEFAULT section - outputLines = append(outputLines, line) - } - } - - // Add any missing keys to the DEFAULT section - if inDefault { - for _, key := range defaultKeysOrder { - if newValue, ok := keysToUpdate[key]; ok && !keysUpdated[key] { - for i, outputLine := range outputLines { - if strings.TrimSpace(outputLine) == "[DEFAULT]" { - outputLines = append(outputLines[:i+1], append([]string{newValue}, outputLines[i+1:]...)...) - defaultUpdated = true - break - } - } - } - } - } - - if defaultUpdated { - newContent := strings.Join(outputLines, "\n") - if err := os.WriteFile(jailFile, []byte(newContent), 0644); err != nil { - return fmt.Errorf("failed to write jail.local: %w", err) - } - DebugLog("Updated DEFAULT section in jail.local") - } - - return nil -} - // writeFail2banAction creates or updates the action file with the AlertCountries. func writeFail2banAction(callbackURL, serverID string) error { DebugLog("Running initial writeFail2banAction()") // entry point diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go index 87522d9..260d09d 100644 --- a/internal/fail2ban/connector_agent.go +++ b/internal/fail2ban/connector_agent.go @@ -410,52 +410,9 @@ func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath // UpdateDefaultSettings implements Connector. func (ac *AgentConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error { - // Check jail.local integrity first - exists, hasUI, chkErr := ac.CheckJailLocalIntegrity(ctx) - if chkErr != nil { - config.DebugLog("Warning: could not check jail.local integrity on agent %s: %v", ac.server.Name, chkErr) - } - if exists && !hasUI { - return fmt.Errorf("jail.local on agent server %s is not managed by Fail2ban-UI - skipping settings update (please migrate your jail.local manually)", ac.server.Name) - } - if !exists { - config.DebugLog("jail.local does not exist on agent server %s - initializing fresh managed file", ac.server.Name) - if err := ac.EnsureJailLocalStructure(ctx); err != nil { - return fmt.Errorf("failed to initialize jail.local on agent server %s: %w", ac.server.Name, err) - } - } - - // Convert IgnoreIPs array to space-separated string - ignoreIPStr := strings.Join(settings.IgnoreIPs, " ") - if ignoreIPStr == "" { - ignoreIPStr = "127.0.0.1/8 ::1" - } - // Set default banaction values if not set - banaction := settings.Banaction - if banaction == "" { - banaction = "nftables-multiport" - } - banactionAllports := settings.BanactionAllports - if banactionAllports == "" { - banactionAllports = "nftables-allports" - } - chain := settings.Chain - if chain == "" { - chain = "INPUT" - } - payload := map[string]interface{}{ - "bantimeIncrement": settings.BantimeIncrement, - "defaultJailEnable": settings.DefaultJailEnable, - "ignoreip": ignoreIPStr, - "bantime": settings.Bantime, - "findtime": settings.Findtime, - "maxretry": settings.Maxretry, - "banaction": banaction, - "banactionAllports": banactionAllports, - "chain": chain, - "bantimeRndtime": settings.BantimeRndtime, - } - return ac.put(ctx, "/v1/jails/default-settings", payload, nil) + // Since the managed jail.local is fully owned by Fail2ban-UI, a complete + // rewrite from current settings is always correct and self-healing. + return ac.EnsureJailLocalStructure(ctx) } // CheckJailLocalIntegrity implements Connector. diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index 083d74e..618398d 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -1553,222 +1553,9 @@ PYEOF // UpdateDefaultSettings implements Connector. func (sc *SSHConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error { - jailLocalPath := "/etc/fail2ban/jail.local" - - // Check jail.local integrity first - exists, hasUI, chkErr := sc.CheckJailLocalIntegrity(ctx) - if chkErr != nil { - config.DebugLog("Warning: could not check jail.local integrity on %s: %v", sc.server.Name, chkErr) - } - - if exists && !hasUI { - // File belongs to the user – never overwrite - return fmt.Errorf("jail.local on server %s is not managed by Fail2ban-UI - skipping settings update (please migrate your jail.local manually)", sc.server.Name) - } - - if !exists { - // File was deleted (e.g. user finished migration) – create a fresh managed file - config.DebugLog("jail.local does not exist on server %s - initializing fresh managed file", sc.server.Name) - if err := sc.EnsureJailLocalStructure(ctx); err != nil { - return fmt.Errorf("failed to initialize jail.local on server %s: %w", sc.server.Name, err) - } - } - - // Read existing file - existingContent, err := sc.runRemoteCommand(ctx, []string{"cat", jailLocalPath}) - if err != nil { - existingContent = "" - } - - // Remove commented lines (lines starting with #) using sed - if existingContent != "" { - // Use sed to remove lines starting with # (but preserve empty lines) - removeCommentsCmd := fmt.Sprintf("sed '/^[[:space:]]*#/d' %s", jailLocalPath) - uncommentedContent, err := sc.runRemoteCommand(ctx, []string{removeCommentsCmd}) - if err == nil { - existingContent = uncommentedContent - } - } - - // Convert IgnoreIPs array to space-separated string - ignoreIPStr := strings.Join(settings.IgnoreIPs, " ") - if ignoreIPStr == "" { - ignoreIPStr = "127.0.0.1/8 ::1" - } - // Set default banaction values if not set - banactionVal := settings.Banaction - if banactionVal == "" { - banactionVal = "nftables-multiport" - } - banactionAllportsVal := settings.BanactionAllports - if banactionAllportsVal == "" { - banactionAllportsVal = "nftables-allports" - } - chainVal := settings.Chain - if chainVal == "" { - chainVal = "INPUT" - } - // Define the keys we want to update - keysToUpdate := map[string]string{ - "enabled": fmt.Sprintf("enabled = %t", settings.DefaultJailEnable), - "bantime.increment": fmt.Sprintf("bantime.increment = %t", settings.BantimeIncrement), - "ignoreip": fmt.Sprintf("ignoreip = %s", ignoreIPStr), - "bantime": fmt.Sprintf("bantime = %s", settings.Bantime), - "findtime": fmt.Sprintf("findtime = %s", settings.Findtime), - "maxretry": fmt.Sprintf("maxretry = %d", settings.Maxretry), - "banaction": fmt.Sprintf("banaction = %s", banactionVal), - "banaction_allports": fmt.Sprintf("banaction_allports = %s", banactionAllportsVal), - "chain": fmt.Sprintf("chain = %s", chainVal), - } - if settings.BantimeRndtime != "" { - keysToUpdate["bantime.rndtime"] = fmt.Sprintf("bantime.rndtime = %s", settings.BantimeRndtime) - } - defaultKeysOrder := []string{"enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "banaction", "banaction_allports", "chain"} - if settings.BantimeRndtime != "" { - defaultKeysOrder = append(defaultKeysOrder, "bantime.rndtime") - } - - // Parse existing content and update only specific keys in DEFAULT section - if existingContent == "" { - // File doesn't exist, create new one with DEFAULT section - defaultLines := []string{"[DEFAULT]"} - for _, key := range defaultKeysOrder { - defaultLines = append(defaultLines, keysToUpdate[key]) - } - defaultLines = append(defaultLines, "") - newContent := strings.Join(defaultLines, "\n") - cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailLocalPath, newContent) - _, err = sc.runRemoteCommand(ctx, []string{cmd}) - return err - } - - // Use Python script to update only specific keys in DEFAULT section - // Preserves banner, action_mwlg, and action override sections - // Escape values for shell/Python - escapeForShell := func(s string) string { - // Escape single quotes for shell - return strings.ReplaceAll(s, "'", "'\"'\"'") - } - - // Convert boolean values to Python boolean literals - defaultJailEnablePython := "False" - if settings.DefaultJailEnable { - defaultJailEnablePython = "True" - } - bantimeIncrementPython := "False" - if settings.BantimeIncrement { - bantimeIncrementPython = "True" - } - - chainValEsc := escapeForShell(chainVal) - bantimeRndtimeEsc := "" - if settings.BantimeRndtime != "" { - bantimeRndtimeEsc = escapeForShell(settings.BantimeRndtime) - } - updateScript := fmt.Sprintf(`python3 <<'PY' -import re - -jail_file = '%s' -ignore_ip_str = '%s' -banaction_val = '%s' -banaction_allports_val = '%s' -default_jail_enable_val = %s -bantime_increment_val = %s -bantime_val = '%s' -findtime_val = '%s' -maxretry_val = %d -chain_val = '%s' -bantime_rndtime_val = '%s' -keys_to_update = { - 'enabled': 'enabled = ' + str(default_jail_enable_val).lower(), - 'bantime.increment': 'bantime.increment = ' + str(bantime_increment_val).lower(), - 'ignoreip': 'ignoreip = ' + ignore_ip_str, - 'bantime': 'bantime = ' + bantime_val, - 'findtime': 'findtime = ' + findtime_val, - 'maxretry': 'maxretry = ' + str(maxretry_val), - 'banaction': 'banaction = ' + banaction_val, - 'banaction_allports': 'banaction_allports = ' + banaction_allports_val, - 'chain': 'chain = ' + chain_val -} -if bantime_rndtime_val: - keys_to_update['bantime.rndtime'] = 'bantime.rndtime = ' + bantime_rndtime_val -keys_order = ['enabled', 'bantime.increment', 'ignoreip', 'bantime', 'findtime', 'maxretry', 'banaction', 'banaction_allports', 'chain'] -if bantime_rndtime_val: - keys_order.append('bantime.rndtime') - -try: - with open(jail_file, 'r') as f: - lines = f.readlines() -except FileNotFoundError: - lines = [] - -output_lines = [] -in_default = False -default_section_found = False -keys_updated = set() - -for line in lines: - stripped = line.strip() - - # Preserve banner lines, action_mwlg lines, and action override lines - is_banner = 'Fail2Ban-UI' in line or 'fail2ban-ui' in line - is_action_mwlg = 'action_mwlg' in stripped - is_action_override = 'action = %%(action_mwlg)s' in stripped - - if stripped.startswith('[') and stripped.endswith(']'): - section_name = stripped.strip('[]') - if section_name == "DEFAULT": - in_default = True - default_section_found = True - output_lines.append(line) - else: - in_default = False - output_lines.append(line) - elif in_default: - # Check if this line is a key we need to update - key_updated = False - # When user cleared bantime.rndtime, remove the line from config instead of keeping old value - if not bantime_rndtime_val and re.match(r'^\s*bantime\.rndtime\s*=', stripped): - key_updated = True - # don't append: line is removed - if not key_updated: - for key, new_value in keys_to_update.items(): - pattern = r'^\s*' + re.escape(key) + r'\s*=' - if re.match(pattern, stripped): - output_lines.append(new_value + '\n') - keys_updated.add(key) - key_updated = True - break - if not key_updated: - # Keep the line as-is (might be action_mwlg or other DEFAULT settings) - output_lines.append(line) - else: - # Keep lines outside DEFAULT section (preserves banner, action_mwlg, action override) - output_lines.append(line) - -# If DEFAULT section wasn't found, create it at the beginning -if not default_section_found: - default_lines = ["[DEFAULT]\n"] - for key in keys_order: - default_lines.append(keys_to_update[key] + "\n") - default_lines.append("\n") - output_lines = default_lines + output_lines -else: - # Add any missing keys to the DEFAULT section - for key in keys_order: - if key not in keys_updated: - # Find the DEFAULT section and insert after it - for i, line in enumerate(output_lines): - if line.strip() == "[DEFAULT]": - output_lines.insert(i + 1, keys_to_update[key] + "\n") - break - -with open(jail_file, 'w') as f: - f.writelines(output_lines) -PY`, escapeForShell(jailLocalPath), escapeForShell(ignoreIPStr), escapeForShell(banactionVal), escapeForShell(banactionAllportsVal), defaultJailEnablePython, bantimeIncrementPython, escapeForShell(settings.Bantime), escapeForShell(settings.Findtime), settings.Maxretry, chainValEsc, bantimeRndtimeEsc) - - _, err = sc.runRemoteCommand(ctx, []string{updateScript}) - return err + // Since the managed jail.local is fully owned by Fail2ban-UI, a complete + // rewrite from current settings is always correct and self-healing. + return sc.EnsureJailLocalStructure(ctx) } // CheckJailLocalIntegrity implements Connector. @@ -1811,66 +1598,8 @@ func (sc *SSHConnector) EnsureJailLocalStructure(ctx context.Context) error { } } - // Build the managed jail.local content - settings := config.GetSettings() - - ignoreIPStr := strings.Join(settings.IgnoreIPs, " ") - if ignoreIPStr == "" { - ignoreIPStr = "127.0.0.1/8 ::1" - } - banactionVal := settings.Banaction - if banactionVal == "" { - banactionVal = "nftables-multiport" - } - banactionAllportsVal := settings.BanactionAllports - if banactionAllportsVal == "" { - banactionAllportsVal = "nftables-allports" - } - chainVal := settings.Chain - if chainVal == "" { - chainVal = "INPUT" - } - - banner := config.JailLocalBanner() - - defaultSection := fmt.Sprintf(`[DEFAULT] -enabled = %t -bantime.increment = %t -ignoreip = %s -bantime = %s -findtime = %s -maxretry = %d -banaction = %s -banaction_allports = %s -chain = %s - -`, - settings.DefaultJailEnable, - settings.BantimeIncrement, - ignoreIPStr, - settings.Bantime, - settings.Findtime, - settings.Maxretry, - banactionVal, - banactionAllportsVal, - chainVal, - ) - if settings.BantimeRndtime != "" { - defaultSection += fmt.Sprintf("bantime.rndtime = %s\n", settings.BantimeRndtime) - } - defaultSection += "\n" - - actionMwlgConfig := `# Custom Fail2Ban action for UI callbacks -action_mwlg = %(action_)s - ui-custom-action[logpath="%(logpath)s", chain="%(chain)s"] - -` - - actionOverride := `# Custom Fail2Ban action applied by fail2ban-ui -action = %(action_mwlg)s -` - - content := banner + defaultSection + actionMwlgConfig + actionOverride + // Build content using the shared helper (single source of truth) + content := config.BuildJailLocalContent() // Escape single quotes for safe use in a single-quoted heredoc escaped := strings.ReplaceAll(content, "'", "'\"'\"'") diff --git a/internal/fail2ban/jail_management.go b/internal/fail2ban/jail_management.go index 3a18645..d38d0ab 100644 --- a/internal/fail2ban/jail_management.go +++ b/internal/fail2ban/jail_management.go @@ -1064,214 +1064,8 @@ func ExtractFilterFromJailConfig(jailContent string) string { return "" } -// UpdateDefaultSettingsLocal updates specific keys in the [DEFAULT] section of /etc/fail2ban/jail.local -// with the provided settings, preserving all other content including the ui-custom-action section. -// Removes commented lines (starting with #) before applying updates. +// UpdateDefaultSettingsLocal rewrites /etc/fail2ban/jail.local with the current settings. func UpdateDefaultSettingsLocal(settings config.AppSettings) error { config.DebugLog("UpdateDefaultSettingsLocal called") - localPath := "/etc/fail2ban/jail.local" - - // Check jail.local integrity first - var existingContent string - fileExists := false - if content, err := os.ReadFile(localPath); err == nil { - existingContent = string(content) - fileExists = len(strings.TrimSpace(existingContent)) > 0 - } else if !os.IsNotExist(err) { - return fmt.Errorf("failed to read jail.local: %w", err) - } - - hasUIAction := strings.Contains(existingContent, "ui-custom-action") - - if fileExists && !hasUIAction { - // File belongs to the user – never overwrite - return fmt.Errorf("jail.local is not managed by Fail2ban-UI - skipping settings update (please migrate your jail.local manually)") - } - - if !fileExists { - // File was deleted (e.g. user finished migration); create a fresh managed file - config.DebugLog("jail.local does not exist - initializing fresh managed file") - if err := config.EnsureJailLocalStructure(); err != nil { - return fmt.Errorf("failed to initialize jail.local: %w", err) - } - // Re-read the freshly created file - if content, err := os.ReadFile(localPath); err == nil { - existingContent = string(content) - } - } - - // Remove commented lines (lines starting with #) but preserve: - // - Banner lines (containing "Fail2Ban-UI" or "fail2ban-ui") - // - action_mwlg and action override lines - lines := strings.Split(existingContent, "\n") - var uncommentedLines []string - for _, line := range lines { - trimmed := strings.TrimSpace(line) - // Keep empty lines, banner lines, action_mwlg lines, action override lines, and lines that don't start with # - isBanner := strings.Contains(line, "Fail2Ban-UI") || strings.Contains(line, "fail2ban-ui") - isActionMwlg := strings.Contains(trimmed, "action_mwlg") - isActionOverride := strings.Contains(trimmed, "action = %(action_mwlg)s") - if trimmed == "" || !strings.HasPrefix(trimmed, "#") || isBanner || isActionMwlg || isActionOverride { - uncommentedLines = append(uncommentedLines, line) - } - } - existingContent = strings.Join(uncommentedLines, "\n") - - // Convert IgnoreIPs array to space-separated string - ignoreIPStr := strings.Join(settings.IgnoreIPs, " ") - if ignoreIPStr == "" { - ignoreIPStr = "127.0.0.1/8 ::1" - } - // Set default banaction values if not set - banaction := settings.Banaction - if banaction == "" { - banaction = "nftables-multiport" - } - banactionAllports := settings.BanactionAllports - if banactionAllports == "" { - banactionAllports = "nftables-allports" - } - chain := settings.Chain - if chain == "" { - chain = "INPUT" - } - // Define the keys we want to update - keysToUpdate := map[string]string{ - "enabled": fmt.Sprintf("enabled = %t", settings.DefaultJailEnable), - "bantime.increment": fmt.Sprintf("bantime.increment = %t", settings.BantimeIncrement), - "ignoreip": fmt.Sprintf("ignoreip = %s", ignoreIPStr), - "bantime": fmt.Sprintf("bantime = %s", settings.Bantime), - "findtime": fmt.Sprintf("findtime = %s", settings.Findtime), - "maxretry": fmt.Sprintf("maxretry = %d", settings.Maxretry), - "banaction": fmt.Sprintf("banaction = %s", banaction), - "banaction_allports": fmt.Sprintf("banaction_allports = %s", banactionAllports), - "chain": fmt.Sprintf("chain = %s", chain), - } - if settings.BantimeRndtime != "" { - keysToUpdate["bantime.rndtime"] = fmt.Sprintf("bantime.rndtime = %s", settings.BantimeRndtime) - } - defaultKeysOrder := []string{"enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "banaction", "banaction_allports", "chain"} - if settings.BantimeRndtime != "" { - defaultKeysOrder = append(defaultKeysOrder, "bantime.rndtime") - } - - // Track which keys we've updated - keysUpdated := make(map[string]bool) - - // Parse existing content and update only specific keys in DEFAULT section - if existingContent == "" { - // File doesn't exist, create new one with banner and DEFAULT section - var newLines []string - newLines = append(newLines, strings.Split(strings.TrimRight(config.JailLocalBanner(), "\n"), "\n")...) - newLines = append(newLines, "[DEFAULT]") - for _, key := range defaultKeysOrder { - newLines = append(newLines, keysToUpdate[key]) - } - newLines = append(newLines, "") - newContent := strings.Join(newLines, "\n") - if err := os.WriteFile(localPath, []byte(newContent), 0644); err != nil { - return fmt.Errorf("failed to write jail.local: %w", err) - } - config.DebugLog("Created new jail.local with banner and DEFAULT section") - return nil - } - - // Parse and update only specific keys in DEFAULT section - lines = strings.Split(existingContent, "\n") - var outputLines []string - inDefault := false - defaultSectionFound := false - - // Always add the full banner at the start - outputLines = append(outputLines, strings.Split(strings.TrimRight(config.JailLocalBanner(), "\n"), "\n")...) - - // Skip everything before [DEFAULT] section (old banner, comments, empty lines) - foundSection := false - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { - // Found a section - stop skipping and process this line - foundSection = true - } - if !foundSection { - // Skip lines before any section (old banner, comments, empty lines) - continue - } - - // Process lines after we found a section - if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { - sectionName := strings.Trim(trimmed, "[]") - if sectionName == "DEFAULT" { - // Start of DEFAULT section - inDefault = true - defaultSectionFound = true - outputLines = append(outputLines, line) - } else { - // Other section - stop DEFAULT mode - inDefault = false - outputLines = append(outputLines, line) - } - } else if inDefault { - // We're in DEFAULT section - check if this line is a key we need to update - keyUpdated := false - // When user cleared bantime.rndtime, remove the line from config instead of keeping old value - if settings.BantimeRndtime == "" { - if matched, _ := regexp.MatchString(`^\s*bantime\.rndtime\s*=`, trimmed); matched { - keyUpdated = true - // don't append: line is removed - } - } - if !keyUpdated { - for key, newValue := range keysToUpdate { - // Check if this line contains the key (with or without spaces around =) - keyPattern := "^\\s*" + regexp.QuoteMeta(key) + "\\s*=" - if matched, _ := regexp.MatchString(keyPattern, trimmed); matched { - outputLines = append(outputLines, newValue) - keysUpdated[key] = true - keyUpdated = true - break - } - } - } - if !keyUpdated { - // Keep the line as-is (might be other DEFAULT settings or action_mwlg) - outputLines = append(outputLines, line) - } - } else { - // Keep lines outside DEFAULT section (preserves ui-custom-action and other content) - outputLines = append(outputLines, line) - } - } - - // If DEFAULT section wasn't found, create it at the beginning - if !defaultSectionFound { - defaultLines := []string{"[DEFAULT]"} - for _, key := range defaultKeysOrder { - defaultLines = append(defaultLines, keysToUpdate[key]) - } - defaultLines = append(defaultLines, "") - outputLines = append(defaultLines, outputLines...) - } else { - // Add any missing keys to the DEFAULT section - for _, key := range defaultKeysOrder { - if !keysUpdated[key] { - // Find the DEFAULT section and insert after it - for i, line := range outputLines { - if strings.TrimSpace(line) == "[DEFAULT]" { - // Insert after [DEFAULT] header - outputLines = append(outputLines[:i+1], append([]string{keysToUpdate[key]}, outputLines[i+1:]...)...) - break - } - } - } - } - } - - newContent := strings.Join(outputLines, "\n") - if err := os.WriteFile(localPath, []byte(newContent), 0644); err != nil { - return fmt.Errorf("failed to write jail.local: %w", err) - } - - config.DebugLog("Updated specific keys in DEFAULT section of jail.local") - return nil + return config.EnsureJailLocalStructure() }