Fix remote SSH jail.local pharsing

This commit is contained in:
2025-12-17 12:28:26 +01:00
parent 7fa5a939d0
commit d44f827845

View File

@@ -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('')
actionOverride := `# Custom Fail2Ban action applied by fail2ban-ui
action = %(action_mwlg)s
`
# 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
content := banner + defaultSection + actionMwlgConfig + actionOverride
# 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"]
"""
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))
// 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
}