diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index 410a13c..4f2a09b 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -993,8 +993,8 @@ jail_file = '%s' ignore_ip_str = '%s' banaction_val = '%s' banaction_allports_val = '%s' -default_jail_enable_val = %t -bantime_increment_val = %t +default_jail_enable_val = '%t' +bantime_increment_val = '%t' bantime_val = '%s' findtime_val = '%s' maxretry_val = %d @@ -1082,6 +1082,10 @@ PY`, escapeForShell(jailLocalPath), escapeForShell(ignoreIPStr), escapeForShell( } // EnsureJailLocalStructure implements Connector. +// For SSH connectors we: +// 1. Migrate any legacy jails out of jail.local into jail.d/*.local +// 2. Rebuild /etc/fail2ban/jail.local with a clean, managed structure +// (banner, [DEFAULT] section based on current settings, and action_mwlg/action override). func (sc *SSHConnector) EnsureJailLocalStructure(ctx context.Context) error { jailLocalPath := "/etc/fail2ban/jail.local" settings := config.GetSettings() @@ -1091,6 +1095,7 @@ func (sc *SSHConnector) EnsureJailLocalStructure(ctx context.Context) error { if ignoreIPStr == "" { ignoreIPStr = "127.0.0.1/8 ::1" } + // Set default banaction values if not set banactionVal := settings.Banaction if banactionVal == "" { @@ -1100,170 +1105,63 @@ func (sc *SSHConnector) EnsureJailLocalStructure(ctx context.Context) error { if banactionAllportsVal == "" { banactionAllportsVal = "iptables-allports" } - // Escape values for shell/Python - escapeForShell := func(s string) string { - return strings.ReplaceAll(s, "'", "'\"'\"'") - } - // Build the structure using Python script - ensureScript := fmt.Sprintf(`python3 <<'PY' -import os -import re + // Build the new jail.local content in Go (mirrors local ensureJailLocalStructure) + banner := config.JailLocalBanner() -jail_file = '%s' -ignore_ip_str = '%s' -banaction_val = '%s' -banaction_allports_val = '%s' -banner_content = """%s""" -settings = { - 'bantime_increment': %t, - 'default_jail_enable': %t, - 'ignoreip': ignore_ip_str, - 'bantime': '%s', - 'findtime': '%s', - 'maxretry': %d, - 'destemail': '%s', - 'banaction': banaction_val, - 'banaction_allports': banaction_allports_val -} + defaultSection := fmt.Sprintf(`[DEFAULT] +enabled = %t +bantime.increment = %t +ignoreip = %s +bantime = %s +findtime = %s +maxretry = %d +destemail = %s +banaction = %s +banaction_allports = %s -# Check if file already has our full banner (indicating it's already properly structured) -has_full_banner = False -has_action_mwlg = False -has_action_override = False +`, + settings.DefaultJailEnable, + settings.BantimeIncrement, + ignoreIPStr, + settings.Bantime, + settings.Findtime, + settings.Maxretry, + settings.Destemail, + banactionVal, + banactionAllportsVal, + ) -try: - with open(jail_file, 'r') as f: - content = f.read() - # Check for the complete banner pattern with hash line separators - has_full_banner = '################################################################################' in content and 'Fail2Ban-UI Managed Configuration' in content and 'DO NOT EDIT THIS FILE MANUALLY' in content - has_action_mwlg = 'action_mwlg' in content and 'ui-custom-action' in content - has_action_override = 'action = %%(action_mwlg)s' in content -except FileNotFoundError: - pass + actionMwlgConfig := `# Custom Fail2Ban action using geo-filter for email alerts +action_mwlg = %(action_)s + ui-custom-action[sender="%(sender)s", dest="%(destemail)s", logpath="%(logpath)s", chain="%(chain)s"] -# If already properly structured, just update DEFAULT section -if has_full_banner and has_action_mwlg and has_action_override: - try: - with open(jail_file, 'r') as f: - lines = f.readlines() - except FileNotFoundError: - lines = [] - - # Always add the full banner at the start - output_lines = [] - output_lines.extend(banner_content.splitlines()) - output_lines.append('') - - # Skip everything before [DEFAULT] section (old banner, comments, empty lines) - found_section = False - for line in lines: - stripped = line.strip() - if stripped.startswith('[') and stripped.endswith(']'): - # Found a section - stop skipping and process this line - found_section = True - if not found_section: - # Skip lines before any section (old banner, comments, empty lines) - continue - - # Process lines after we found a section - if stripped.startswith('[') and stripped.endswith(']'): - section_name = stripped.strip('[]') - if section_name == "DEFAULT": - in_default = True - output_lines.append(line) - else: - in_default = False - output_lines.append(line) - elif in_default: - key_updated = False - for key, new_value in [ - ('enabled', 'enabled = ' + str(settings['default_jail_enable'])), - ('bantime.increment', 'bantime.increment = ' + str(settings['bantime_increment'])), - ('ignoreip', 'ignoreip = ' + settings['ignoreip']), - ('bantime', 'bantime = ' + settings['bantime']), - ('findtime', 'findtime = ' + settings['findtime']), - ('maxretry', 'maxretry = ' + str(settings['maxretry'])), - ('destemail', 'destemail = ' + settings['destemail']), - ('banaction', 'banaction = ' + settings['banaction']), - ('banaction_allports', 'banaction_allports = ' + settings['banaction_allports']), - ]: - 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: - output_lines.append(line) - else: - output_lines.append(line) - - # Add missing keys - if in_default: - for key, new_value in [ - ('enabled', 'enabled = ' + str(settings['default_jail_enable'])), - ('bantime.increment', 'bantime.increment = ' + str(settings['bantime_increment'])), - ('ignoreip', 'ignoreip = ' + settings['ignoreip']), - ('bantime', 'bantime = ' + settings['bantime']), - ('findtime', 'findtime = ' + settings['findtime']), - ('maxretry', 'maxretry = ' + str(settings['maxretry'])), - ('destemail', 'destemail = ' + settings['destemail']), - ('banaction', 'banaction = ' + settings['banaction']), - ('banaction_allports', 'banaction_allports = ' + settings['banaction_allports']), - ]: - if key not in keys_updated: - for i, output_line in enumerate(output_lines): - if output_line.strip() == "[DEFAULT]": - output_lines.insert(i + 1, new_value + '\n') - break - - with open(jail_file, 'w') as f: - f.writelines(output_lines) -else: - # Create new structure - banner = banner_content - - default_section = """[DEFAULT] -enabled = """ + str(settings['default_jail_enable']) + """ -bantime.increment = """ + str(settings['bantime_increment']) + """ -ignoreip = """ + settings['ignoreip'] + """ -bantime = """ + settings['bantime'] + """ -findtime = """ + settings['findtime'] + """ -maxretry = """ + str(settings['maxretry']) + """ -destemail = """ + settings['destemail'] + """ -banaction = """ + settings['banaction'] + """ -banaction_allports = """ + settings['banaction_allports'] + """ +` -""" - - action_mwlg_config = """# Custom Fail2Ban action using geo-filter for email alerts -action_mwlg = %%(action_)s - ui-custom-action[sender="%%(sender)s", dest="%%(destemail)s", logpath="%%(logpath)s", chain="%%(chain)s"] + actionOverride := `# Custom Fail2Ban action applied by fail2ban-ui +action = %(action_mwlg)s +` -""" - - action_override = """# Custom Fail2Ban action applied by fail2ban-ui -action = %%(action_mwlg)s -""" - - new_content = banner + default_section + action_mwlg_config + action_override - - with open(jail_file, 'w') as f: - f.write(new_content) -PY`, escapeForShell(jailLocalPath), escapeForShell(ignoreIPStr), escapeForShell(banactionVal), escapeForShell(banactionAllportsVal), escapeForShell(config.JailLocalBanner()), settings.BantimeIncrement, settings.DefaultJailEnable, - escapeForShell(settings.Bantime), escapeForShell(settings.Findtime), settings.Maxretry, escapeForShell(settings.Destemail)) + content := banner + defaultSection + actionMwlgConfig + actionOverride + + // Escape single quotes for safe use in a single-quoted heredoc + escaped := strings.ReplaceAll(content, "'", "'\"'\"'") // IMPORTANT: Run migration FIRST before ensuring structure - // This is because ensureJailLocalStructure may overwrite jail.local, - // which would destroy any jail sections that need to be migrated + // This is because EnsureJailLocalStructure may overwrite jail.local, + // which would destroy any jail sections that need to be migrated. if err := sc.MigrateJailsFromJailLocalRemote(ctx); err != nil { config.DebugLog("Warning: No migration done (may be normal if no jails to migrate): %v", err) // Don't fail - continue with ensuring structure } - // Then ensure the basic structure - _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", ensureScript}) + // Write the rebuilt content via heredoc over SSH + writeScript := fmt.Sprintf(`cat > %s <<'JAILLOCAL' +%s +JAILLOCAL +`, jailLocalPath, escaped) + + _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", writeScript}) return err }