Simplify the connector and jail.local cunstruction with a unified function for all connectors

This commit is contained in:
2026-02-10 15:50:32 +01:00
parent 8f9399196e
commit 337d199143
4 changed files with 46 additions and 734 deletions

View File

@@ -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, "'", "'\"'\"'")