From 13704df994853643a8c420373e5912e0b5286e53 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Thu, 4 Dec 2025 19:42:43 +0100 Subject: [PATCH] restructure jail.local default config functions, make banactions configurable --- internal/config/settings.go | 373 ++++++++++++++++++--------- internal/fail2ban/connector_agent.go | 37 +++ internal/fail2ban/connector_local.go | 20 +- internal/fail2ban/connector_ssh.go | 327 +++++++++++++++++++++++ internal/fail2ban/jail_management.go | 155 +++++++++++ internal/fail2ban/manager.go | 6 + internal/locales/de.json | 3 +- internal/locales/de_ch.json | 3 +- internal/locales/en.json | 3 +- internal/locales/es.json | 3 +- internal/locales/fr.json | 3 +- internal/locales/it.json | 3 +- internal/storage/storage.go | 38 ++- pkg/web/handlers.go | 85 +++++- pkg/web/templates/index.html | 204 ++++++++++++++- 15 files changed, 1105 insertions(+), 158 deletions(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index 50caf9c..d459c4d 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -24,7 +24,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "os" "path/filepath" "regexp" @@ -61,12 +60,14 @@ type AppSettings struct { Servers []Fail2banServer `json:"servers"` // Fail2Ban [DEFAULT] section values from jail.local - BantimeIncrement bool `json:"bantimeIncrement"` - IgnoreIP string `json:"ignoreip"` - Bantime string `json:"bantime"` - Findtime string `json:"findtime"` - Maxretry int `json:"maxretry"` - Destemail string `json:"destemail"` + BantimeIncrement bool `json:"bantimeIncrement"` + IgnoreIPs []string `json:"ignoreips"` // Changed from string to []string for individual IP management + Bantime string `json:"bantime"` + Findtime string `json:"findtime"` + Maxretry int `json:"maxretry"` + Destemail string `json:"destemail"` + Banaction string `json:"banaction"` // Default banning action + BanactionAllports string `json:"banactionAllports"` // Allports banning action //Sender string `json:"sender"` } @@ -332,7 +333,12 @@ func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) { currentSettings.CallbackURL = rec.CallbackURL currentSettings.RestartNeeded = rec.RestartNeeded currentSettings.BantimeIncrement = rec.BantimeIncrement - currentSettings.IgnoreIP = rec.IgnoreIP + // Convert IgnoreIP string to array (backward compatibility) + if rec.IgnoreIP != "" { + currentSettings.IgnoreIPs = strings.Fields(rec.IgnoreIP) + } else { + currentSettings.IgnoreIPs = []string{} + } currentSettings.Bantime = rec.Bantime currentSettings.Findtime = rec.Findtime currentSettings.Maxretry = rec.MaxRetry @@ -409,24 +415,27 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) { } return storage.AppSettingsRecord{ - Language: currentSettings.Language, - Port: currentSettings.Port, - Debug: currentSettings.Debug, - CallbackURL: currentSettings.CallbackURL, - RestartNeeded: currentSettings.RestartNeeded, - AlertCountriesJSON: string(countryBytes), - SMTPHost: currentSettings.SMTP.Host, - SMTPPort: currentSettings.SMTP.Port, - SMTPUsername: currentSettings.SMTP.Username, - SMTPPassword: currentSettings.SMTP.Password, - SMTPFrom: currentSettings.SMTP.From, - SMTPUseTLS: currentSettings.SMTP.UseTLS, - BantimeIncrement: currentSettings.BantimeIncrement, - IgnoreIP: currentSettings.IgnoreIP, + Language: currentSettings.Language, + Port: currentSettings.Port, + Debug: currentSettings.Debug, + CallbackURL: currentSettings.CallbackURL, + RestartNeeded: currentSettings.RestartNeeded, + AlertCountriesJSON: string(countryBytes), + SMTPHost: currentSettings.SMTP.Host, + SMTPPort: currentSettings.SMTP.Port, + SMTPUsername: currentSettings.SMTP.Username, + SMTPPassword: currentSettings.SMTP.Password, + SMTPFrom: currentSettings.SMTP.From, + SMTPUseTLS: currentSettings.SMTP.UseTLS, + BantimeIncrement: currentSettings.BantimeIncrement, + // Convert IgnoreIPs array to space-separated string for storage + IgnoreIP: strings.Join(currentSettings.IgnoreIPs, " "), Bantime: currentSettings.Bantime, Findtime: currentSettings.Findtime, MaxRetry: currentSettings.Maxretry, DestEmail: currentSettings.Destemail, + Banaction: currentSettings.Banaction, + BanactionAllports: currentSettings.BanactionAllports, AdvancedActionsJSON: string(advancedBytes), }, nil } @@ -538,8 +547,14 @@ func setDefaultsLocked() { if !currentSettings.SMTP.UseTLS { currentSettings.SMTP.UseTLS = true } - if currentSettings.IgnoreIP == "" { - currentSettings.IgnoreIP = "127.0.0.1/8 ::1" + if len(currentSettings.IgnoreIPs) == 0 { + currentSettings.IgnoreIPs = []string{"127.0.0.1/8", "::1"} + } + if currentSettings.Banaction == "" { + currentSettings.Banaction = "iptables-multiport" + } + if currentSettings.BanactionAllports == "" { + currentSettings.BanactionAllports = "iptables-allports" } if (currentSettings.AdvancedActions == AdvancedActionsConfig{}) { @@ -586,7 +601,18 @@ func initializeFromJailFile() error { } } if val, ok := settings["ignoreip"]; ok { - currentSettings.IgnoreIP = val + // Convert space-separated string to array + if val != "" { + currentSettings.IgnoreIPs = strings.Fields(val) + } else { + currentSettings.IgnoreIPs = []string{} + } + } + if val, ok := settings["banaction"]; ok { + currentSettings.Banaction = val + } + if val, ok := settings["banaction_allports"]; ok { + currentSettings.BanactionAllports = val } if val, ok := settings["destemail"]; ok { currentSettings.Destemail = val @@ -695,124 +721,203 @@ func ensureFail2banActionFiles(callbackURL, serverID string) error { return nil } - if err := setupGeoCustomAction(); err != nil { - return err - } - if err := ensureJailDConfig(); err != nil { + // Ensure jail.local has proper structure (banner, DEFAULT, action_mwlg, action override) + if err := ensureJailLocalStructure(); err != nil { return err } return writeFail2banAction(callbackURL, serverID) } -// setupGeoCustomAction checks and replaces the default action in jail.local with our from fail2ban-UI -func setupGeoCustomAction() error { - DebugLog("Running initial setupGeoCustomAction()") // entry point - if err := os.MkdirAll(filepath.Dir(jailFile), 0o755); err != nil { - return fmt.Errorf("failed to ensure jail.local directory: %w", err) - } - - file, err := os.Open(jailFile) - if os.IsNotExist(err) { - if _, statErr := os.Stat(defaultJailFile); os.IsNotExist(statErr) { - return nil - } - if copyErr := copyFile(defaultJailFile, jailFile); copyErr != nil { - return fmt.Errorf("failed to copy default jail.conf to jail.local: %w", copyErr) - } - file, err = os.Open(jailFile) - } - if err != nil { - return err - } - defer file.Close() - - var lines []string - actionPattern := regexp.MustCompile(`^\s*action\s*=\s*%(.*?)\s*$`) - alreadyModified := false - actionFound := false - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - - // Check if we already modified the file (prevent duplicate modifications) - if strings.Contains(line, "# Custom Fail2Ban action applied") { - alreadyModified = true - } - - // Look for an existing action definition - if actionPattern.MatchString(line) && !alreadyModified { - actionFound = true - - // Comment out the existing action line - lines = append(lines, "# "+line) - - // Add our replacement action with a comment marker - lines = append(lines, "# Custom Fail2Ban action applied by fail2ban-ui") - lines = append(lines, "action = %(action_mwlg)s") - continue - } - - // Store the original line - lines = append(lines, line) - } - - // If no action was found, no need to modify the file - if !actionFound || alreadyModified { - return nil - } - - // Write back the modified lines - output := strings.Join(lines, "\n") - return os.WriteFile(jailFile, []byte(output), 0644) +// EnsureJailLocalStructure creates or updates jail.local with proper structure: +// This is exported so connectors can call it. +func EnsureJailLocalStructure() error { + return ensureJailLocalStructure() } -// copyFile copies a file from src to dst. If the destination file does not exist, it will be created. -func copyFile(src, dst string) error { - sourceFile, err := os.Open(src) - if err != nil { - return err - } - defer sourceFile.Close() +// ensureJailLocalStructure creates or updates jail.local with proper structure: +// 1. Banner at top warning users not to edit manually +// 2. [DEFAULT] section with current UI settings +// 3. action_mwlg configuration +// 4. action = %(action_mwlg)s at the end +func ensureJailLocalStructure() error { + DebugLog("Running ensureJailLocalStructure()") // entry point - destFile, err := os.Create(dst) - if err != nil { - return err - } - defer destFile.Close() - - _, err = io.Copy(destFile, sourceFile) - return err -} - -// ensureJailDConfig checks if the jail.d file exists and creates it if necessary -func ensureJailDConfig() error { - DebugLog("Running initial ensureJailDConfig()") // entry point - // Check if the file already exists - if _, err := os.Stat(jailDFile); err == nil { - // File already exists, do nothing - DebugLog("Custom jail.d configuration already exists.") - return nil + // 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") } - if err := os.MkdirAll(filepath.Dir(jailDFile), 0o755); err != nil { - return fmt.Errorf("failed to ensure jail.d directory: %v", err) + // Get current settings + settings := GetSettings() + + // Read existing jail.local content if it exists + var existingContent string + if content, err := os.ReadFile(jailFile); err == nil { + existingContent = string(content) } - // Define the content for the custom jail.d configuration - jailDConfig := `[DEFAULT] -# Custom Fail2Ban action using geo-filter for email alerts + // Check if file already has our banner (indicating it's already structured) + hasBanner := strings.Contains(existingContent, "Fail2Ban-UI") || strings.Contains(existingContent, "fail2ban-ui") + hasActionMwlg := strings.Contains(existingContent, "action_mwlg") && strings.Contains(existingContent, "ui-custom-action") + hasActionOverride := strings.Contains(existingContent, "action = %(action_mwlg)s") + // If file is already properly structured, just ensure DEFAULT section is up to date + if hasBanner && hasActionMwlg && hasActionOverride { + DebugLog("jail.local already has proper structure, updating DEFAULT section if needed") + // Update DEFAULT section values without changing structure + return updateJailLocalDefaultSection(settings) + } + + // Build the banner + banner := `################################################################################ +# Fail2Ban-UI Managed Configuration +# +# WARNING: This file is automatically managed by Fail2Ban-UI. +# DO NOT EDIT THIS FILE MANUALLY - your changes will be overwritten. +# +# This file overrides settings from /etc/fail2ban/jail.conf +# Custom jail configurations should be placed in /etc/fail2ban/jail.d/ +################################################################################ + +` + + // 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 = "iptables-multiport" + } + banactionAllports := settings.BanactionAllports + if banactionAllports == "" { + banactionAllports = "iptables-allports" + } + defaultSection := fmt.Sprintf(`[DEFAULT] +bantime.increment = %t +ignoreip = %s +bantime = %s +findtime = %s +maxretry = %d +destemail = %s +banaction = %s +banaction_allports = %s + +`, settings.BantimeIncrement, ignoreIPStr, settings.Bantime, settings.Findtime, settings.Maxretry, settings.Destemail, banaction, banactionAllports) + + // Build action_mwlg configuration + // Note: action_mwlg depends on action_ which depends on banaction (now defined above) + // The multi-line format uses indentation for continuation + 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"] + ` - // Write the new configuration file - err := os.WriteFile(jailDFile, []byte(jailDConfig), 0644) + + // 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 + + // Write the new content + err := os.WriteFile(jailFile, []byte(newContent), 0644) if err != nil { - return fmt.Errorf("failed to write jail.d config: %v", err) + return fmt.Errorf("failed to write jail.local: %v", err) + } + + DebugLog("Created/updated jail.local with proper structure") + 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) + } + + lines := strings.Split(string(content), "\n") + var outputLines []string + inDefault := false + defaultUpdated := false + + // Keys to update + keysToUpdate := map[string]string{ + "bantime.increment": fmt.Sprintf("bantime.increment = %t", settings.BantimeIncrement), + "ignoreip": fmt.Sprintf("ignoreip = %s", strings.Join(settings.IgnoreIPs, " ")), + "bantime": fmt.Sprintf("bantime = %s", settings.Bantime), + "findtime": fmt.Sprintf("findtime = %s", settings.Findtime), + "maxretry": fmt.Sprintf("maxretry = %d", settings.Maxretry), + "destemail": fmt.Sprintf("destemail = %s", settings.Destemail), + } + keysUpdated := make(map[string]bool) + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + 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 + 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, newValue := range keysToUpdate { + if !keysUpdated[key] { + // Find the DEFAULT section and insert after it + 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") } - DebugLog("Created custom jail.d configuration at: %v", jailDFile) return nil } @@ -820,8 +925,10 @@ action_mwlg = %(action_)s func writeFail2banAction(callbackURL, serverID string) error { DebugLog("Running initial writeFail2banAction()") // entry point DebugLog("----------------------------") - if err := os.MkdirAll(filepath.Dir(actionFile), 0o755); err != nil { - return fmt.Errorf("failed to ensure action.d directory: %w", err) + + // Check if /etc/fail2ban/action.d directory exists (fail2ban must be installed) + if _, err := os.Stat(filepath.Dir(actionFile)); os.IsNotExist(err) { + return fmt.Errorf("fail2ban is not installed: /etc/fail2ban/action.d directory does not exist. Please install fail2ban package first") } actionConfig := BuildFail2banActionConfig(callbackURL, serverID) @@ -1185,8 +1292,20 @@ func UpdateSettings(new AppSettings) (AppSettings, error) { old := currentSettings // If certain fields change, we mark reload needed + // Compare IgnoreIPs arrays + ignoreIPsChanged := false + if len(old.IgnoreIPs) != len(new.IgnoreIPs) { + ignoreIPsChanged = true + } else { + for i := range old.IgnoreIPs { + if old.IgnoreIPs[i] != new.IgnoreIPs[i] { + ignoreIPsChanged = true + break + } + } + } restartTriggered := old.BantimeIncrement != new.BantimeIncrement || - old.IgnoreIP != new.IgnoreIP || + ignoreIPsChanged || old.Bantime != new.Bantime || old.Findtime != new.Findtime || old.Maxretry != new.Maxretry diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go index a4a41a0..b09b77f 100644 --- a/internal/fail2ban/connector_agent.go +++ b/internal/fail2ban/connector_agent.go @@ -320,3 +320,40 @@ func (ac *AgentConnector) TestLogpath(ctx context.Context, logpath string) ([]st } return resp.Files, nil } + +// UpdateDefaultSettings implements Connector. +func (ac *AgentConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error { + // 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 = "iptables-multiport" + } + banactionAllports := settings.BanactionAllports + if banactionAllports == "" { + banactionAllports = "iptables-allports" + } + payload := map[string]interface{}{ + "bantimeIncrement": settings.BantimeIncrement, + "ignoreip": ignoreIPStr, + "bantime": settings.Bantime, + "findtime": settings.Findtime, + "maxretry": settings.Maxretry, + "destemail": settings.Destemail, + "banaction": banaction, + "banactionAllports": banactionAllports, + } + return ac.put(ctx, "/v1/jails/default-settings", payload, nil) +} + +// EnsureJailLocalStructure implements Connector. +func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error { + // Call agent API endpoint to ensure jail.local structure + // If the endpoint doesn't exist, we'll need to implement it on the agent side + // For now, we'll try calling it and handle the error gracefully + return ac.post(ctx, "/v1/jails/ensure-structure", nil, nil) +} diff --git a/internal/fail2ban/connector_local.go b/internal/fail2ban/connector_local.go index 60359bf..e05d4fe 100644 --- a/internal/fail2ban/connector_local.go +++ b/internal/fail2ban/connector_local.go @@ -142,8 +142,14 @@ func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error { // Reload implements Connector. func (lc *LocalConnector) Reload(ctx context.Context) error { - if _, err := lc.runFail2banClient(ctx, "reload"); err != nil { - return fmt.Errorf("fail2ban reload error: %w", err) + out, err := lc.runFail2banClient(ctx, "reload") + if err != nil { + // Include the output in the error message for better debugging + return fmt.Errorf("fail2ban reload error: %w (output: %s)", err, strings.TrimSpace(out)) + } + // Check if output indicates success (fail2ban-client returns "OK" on success) + if strings.TrimSpace(out) != "OK" && strings.TrimSpace(out) != "" { + config.DebugLog("fail2ban reload output: %s", out) } return nil } @@ -267,6 +273,16 @@ func (lc *LocalConnector) TestLogpath(ctx context.Context, logpath string) ([]st return TestLogpath(logpath) } +// UpdateDefaultSettings implements Connector. +func (lc *LocalConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error { + return UpdateDefaultSettingsLocal(settings) +} + +// EnsureJailLocalStructure implements Connector. +func (lc *LocalConnector) EnsureJailLocalStructure(ctx context.Context) error { + return config.EnsureJailLocalStructure() +} + 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 264f8f0..d42b132 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -591,6 +591,333 @@ fi return matches, nil } +// UpdateDefaultSettings implements Connector. +func (sc *SSHConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error { + jailLocalPath := "/etc/fail2ban/jail.local" + + // Read existing file if it exists + existingContent, err := sc.runRemoteCommand(ctx, []string{"cat", jailLocalPath}) + if err != nil { + // File doesn't exist, create new one + 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{"bash", "-lc", 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 = "iptables-multiport" + } + banactionAllportsVal := settings.BanactionAllports + if banactionAllportsVal == "" { + banactionAllportsVal = "iptables-allports" + } + // Define the keys we want to update + keysToUpdate := map[string]string{ + "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), + "destemail": fmt.Sprintf("destemail = %s", settings.Destemail), + "banaction": fmt.Sprintf("banaction = %s", banactionVal), + "banaction_allports": fmt.Sprintf("banaction_allports = %s", banactionAllportsVal), + } + + // 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 []string{"bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "destemail", "banaction", "banaction_allports"} { + 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{"bash", "-lc", 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, "'", "'\"'\"'") + } + + updateScript := fmt.Sprintf(`python3 <<'PY' +import re + +jail_file = '%s' +ignore_ip_str = '%s' +banaction_val = '%s' +banaction_allports_val = '%s' +bantime_increment_val = %t +keys_to_update = { + 'bantime.increment': 'bantime.increment = ' + str(bantime_increment_val), + 'ignoreip': 'ignoreip = ' + ignore_ip_str, + 'bantime': 'bantime = %s', + 'findtime': 'findtime = %s', + 'maxretry': 'maxretry = %d', + 'destemail': 'destemail = %s', + 'banaction': 'banaction = ' + banaction_val, + 'banaction_allports': 'banaction_allports = ' + banaction_allports_val +} + +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 + 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 ["bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "destemail"]: + 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 ["bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "destemail"]: + 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), settings.BantimeIncrement, escapeForShell(settings.Bantime), escapeForShell(settings.Findtime), settings.Maxretry, escapeForShell(settings.Destemail)) + + _, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", updateScript}) + return err +} + +// EnsureJailLocalStructure implements Connector. +func (sc *SSHConnector) EnsureJailLocalStructure(ctx context.Context) error { + jailLocalPath := "/etc/fail2ban/jail.local" + settings := config.GetSettings() + + // 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 = "iptables-multiport" + } + banactionAllportsVal := settings.BanactionAllports + 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 + +jail_file = '%s' +ignore_ip_str = '%s' +banaction_val = '%s' +banaction_allports_val = '%s' +settings = { + 'bantime_increment': %t, + 'ignoreip': ignore_ip_str, + 'bantime': '%s', + 'findtime': '%s', + 'maxretry': %d, + 'destemail': '%s', + 'banaction': banaction_val, + 'banaction_allports': banaction_allports_val +} + +# Check if file already has our banner +has_banner = False +has_action_mwlg = False +has_action_override = False + +try: + with open(jail_file, 'r') as f: + content = f.read() + has_banner = 'Fail2Ban-UI' in content or 'fail2ban-ui' 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 + +# If already properly structured, just update DEFAULT section +if has_banner and has_action_mwlg and has_action_override: + try: + with open(jail_file, 'r') as f: + lines = f.readlines() + except FileNotFoundError: + lines = [] + + output_lines = [] + in_default = False + keys_updated = set() + + for line in lines: + stripped = line.strip() + + 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 [ + ('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 [ + ('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']), + ]: + 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 = """################################################################################ +# Fail2Ban-UI Managed Configuration +# +# WARNING: This file is automatically managed by Fail2Ban-UI. +# DO NOT EDIT THIS FILE MANUALLY - your changes will be overwritten. +# +# This file overrides settings from /etc/fail2ban/jail.conf +# Custom jail configurations should be placed in /etc/fail2ban/jail.d/ +################################################################################ + +""" + + default_section = """[DEFAULT] +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), settings.BantimeIncrement, + escapeForShell(settings.Bantime), escapeForShell(settings.Findtime), settings.Maxretry, escapeForShell(settings.Destemail)) + + _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", ensureScript}) + return err +} + // parseJailConfigContent parses jail configuration content and returns JailInfo slice. func parseJailConfigContent(content string) []JailInfo { var jails []JailInfo diff --git a/internal/fail2ban/jail_management.go b/internal/fail2ban/jail_management.go index 7d3acaa..d836663 100644 --- a/internal/fail2ban/jail_management.go +++ b/internal/fail2ban/jail_management.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "sync" @@ -562,3 +563,157 @@ 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. +func UpdateDefaultSettingsLocal(settings config.AppSettings) error { + config.DebugLog("UpdateDefaultSettingsLocal called") + localPath := "/etc/fail2ban/jail.local" + + // Read existing file if it exists + var existingContent string + if content, err := os.ReadFile(localPath); err == nil { + existingContent = string(content) + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to read jail.local: %w", err) + } + + // 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 = "iptables-multiport" + } + banactionAllports := settings.BanactionAllports + if banactionAllports == "" { + banactionAllports = "iptables-allports" + } + // Define the keys we want to update + keysToUpdate := map[string]string{ + "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), + "destemail": fmt.Sprintf("destemail = %s", settings.Destemail), + "banaction": fmt.Sprintf("banaction = %s", banaction), + "banaction_allports": fmt.Sprintf("banaction_allports = %s", banactionAllports), + } + + // 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 DEFAULT section + defaultLines := []string{"[DEFAULT]"} + for _, key := range []string{"bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "destemail"} { + defaultLines = append(defaultLines, keysToUpdate[key]) + } + defaultLines = append(defaultLines, "") + newContent := strings.Join(defaultLines, "\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 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 + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + 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 + 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 []string{"bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "destemail"} { + defaultLines = append(defaultLines, keysToUpdate[key]) + } + defaultLines = append(defaultLines, "") + outputLines = append(defaultLines, outputLines...) + } else { + // Add any missing keys to the DEFAULT section + for _, key := range []string{"bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "destemail"} { + 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 +} diff --git a/internal/fail2ban/manager.go b/internal/fail2ban/manager.go index d767be8..a00978e 100644 --- a/internal/fail2ban/manager.go +++ b/internal/fail2ban/manager.go @@ -34,6 +34,12 @@ type Connector interface { GetJailConfig(ctx context.Context, jail string) (string, error) SetJailConfig(ctx context.Context, jail, content string) error TestLogpath(ctx context.Context, logpath string) ([]string, error) + + // Default settings operations + UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error + + // Jail local structure management + EnsureJailLocalStructure(ctx context.Context) error } // Manager orchestrates all connectors for configured Fail2ban servers. diff --git a/internal/locales/de.json b/internal/locales/de.json index 23dd884..a9a5057 100644 --- a/internal/locales/de.json +++ b/internal/locales/de.json @@ -119,7 +119,8 @@ "settings.smtp_sender_placeholder": "noreply@swissmakers.ch", "settings.smtp_tls": "TLS verwenden (empfohlen)", "settings.send_test_email": "Test-E-Mail senden", - "settings.fail2ban": "Fail2Ban-Konfiguration", + "settings.fail2ban": "Globale Standard-Fail2Ban-Konfigurationen", + "settings.fail2ban.description": "Diese Einstellungen werden auf allen aktivierten Fail2Ban-Servern angewendet und in deren jail.local [DEFAULT]-Abschnitt gespeichert.", "settings.enable_bantime_increment": "Bantime-Inkrement aktivieren", "settings.default_bantime": "Standard-Bantime", "settings.default_bantime_placeholder": "z.B. 48h", diff --git a/internal/locales/de_ch.json b/internal/locales/de_ch.json index f980bf5..b076b3e 100644 --- a/internal/locales/de_ch.json +++ b/internal/locales/de_ch.json @@ -119,7 +119,8 @@ "settings.smtp_sender_placeholder": "noreply@swissmakers.ch", "settings.smtp_tls": "TLS bruuche (empfohlen)", "settings.send_test_email": "Test-Email schicke", - "settings.fail2ban": "Fail2Ban-Konfiguration", + "settings.fail2ban": "Globale Standard-Fail2Ban-Konfiguratione", + "settings.fail2ban.description": "Die Einstellige werde uf alli aktivierte Fail2Ban-Server aagwändet und i däre jail.local [DEFAULT]-Abschnitt gspeicheret.", "settings.enable_bantime_increment": "Bantime-Inkrement aktivierä", "settings.default_bantime": "Standard-Bantime", "settings.default_bantime_placeholder": "z.B. 48h", diff --git a/internal/locales/en.json b/internal/locales/en.json index 6cad13e..7e9404e 100644 --- a/internal/locales/en.json +++ b/internal/locales/en.json @@ -119,7 +119,8 @@ "settings.smtp_sender_placeholder": "noreply@swissmakers.ch", "settings.smtp_tls": "Use TLS (Recommended)", "settings.send_test_email": "Send Test Email", - "settings.fail2ban": "Fail2Ban Configuration", + "settings.fail2ban": "Global Default Fail2Ban Configurations", + "settings.fail2ban.description": "These settings will be applied to all enabled Fail2Ban servers and stored in their jail.local [DEFAULT] section.", "settings.enable_bantime_increment": "Enable Bantime Increment", "settings.default_bantime": "Default Bantime", "settings.default_bantime_placeholder": "e.g., 48h", diff --git a/internal/locales/es.json b/internal/locales/es.json index 4c7b7e0..0ed77cc 100644 --- a/internal/locales/es.json +++ b/internal/locales/es.json @@ -119,7 +119,8 @@ "settings.smtp_sender_placeholder": "noreply@swissmakers.ch", "settings.smtp_tls": "Usar TLS (recomendado)", "settings.send_test_email": "Enviar correo de prueba", - "settings.fail2ban": "Configuración de Fail2Ban", + "settings.fail2ban": "Configuraciones Globales Predeterminadas de Fail2Ban", + "settings.fail2ban.description": "Estas configuraciones se aplicarán a todos los servidores Fail2Ban habilitados y se almacenarán en su sección [DEFAULT] de jail.local.", "settings.enable_bantime_increment": "Habilitar incremento de Bantime", "settings.default_bantime": "Bantime por defecto", "settings.default_bantime_placeholder": "p.ej., 48h", diff --git a/internal/locales/fr.json b/internal/locales/fr.json index 505565e..a7db4a2 100644 --- a/internal/locales/fr.json +++ b/internal/locales/fr.json @@ -119,7 +119,8 @@ "settings.smtp_sender_placeholder": "noreply@swissmakers.ch", "settings.smtp_tls": "Utiliser TLS (recommandé)", "settings.send_test_email": "Envoyer un email de test", - "settings.fail2ban": "Configuration Fail2Ban", + "settings.fail2ban": "Configurations Globales par Défaut de Fail2Ban", + "settings.fail2ban.description": "Ces paramètres seront appliqués à tous les serveurs Fail2Ban activés et stockés dans leur section [DEFAULT] de jail.local.", "settings.enable_bantime_increment": "Activer l'incrémentation du Bantime", "settings.default_bantime": "Bantime par défaut", "settings.default_bantime_placeholder": "par exemple, 48h", diff --git a/internal/locales/it.json b/internal/locales/it.json index feaa724..5313012 100644 --- a/internal/locales/it.json +++ b/internal/locales/it.json @@ -119,7 +119,8 @@ "settings.smtp_sender_placeholder": "noreply@swissmakers.ch", "settings.smtp_tls": "Usa TLS (raccomandato)", "settings.send_test_email": "Invia email di test", - "settings.fail2ban": "Configurazione Fail2Ban", + "settings.fail2ban": "Configurazioni Globali Predefinite di Fail2Ban", + "settings.fail2ban.description": "Queste impostazioni verranno applicate a tutti i server Fail2Ban abilitati e memorizzate nella loro sezione [DEFAULT] di jail.local.", "settings.enable_bantime_increment": "Abilita incremento del Bantime", "settings.default_bantime": "Bantime predefinito", "settings.default_bantime_placeholder": "es. 48h", diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 10fdc2e..a3647c1 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -61,11 +61,13 @@ type AppSettingsRecord struct { SMTPFrom string SMTPUseTLS bool BantimeIncrement bool - IgnoreIP string + IgnoreIP string // Stored as space-separated string, converted to array in AppSettings Bantime string Findtime string MaxRetry int DestEmail string + Banaction string + BanactionAllports string AdvancedActionsJSON string } @@ -168,17 +170,17 @@ func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) { } row := db.QueryRowContext(ctx, ` -SELECT language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, ignore_ip, bantime, findtime, maxretry, destemail, advanced_actions +SELECT language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions FROM app_settings WHERE id = 1`) var ( - lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, advancedActions sql.NullString - port, smtpPort, maxretry sql.NullInt64 - debug, restartNeeded, smtpTLS, bantimeInc sql.NullInt64 + lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, banaction, banactionAllports, advancedActions sql.NullString + port, smtpPort, maxretry sql.NullInt64 + debug, restartNeeded, smtpTLS, bantimeInc sql.NullInt64 ) - err := row.Scan(&lang, &port, &debug, &callback, &restartNeeded, &alerts, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &ignoreIP, &bantime, &findtime, &maxretry, &destemail, &advancedActions) + err := row.Scan(&lang, &port, &debug, &callback, &restartNeeded, &alerts, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &ignoreIP, &bantime, &findtime, &maxretry, &destemail, &banaction, &banactionAllports, &advancedActions) if errors.Is(err, sql.ErrNoRows) { return AppSettingsRecord{}, false, nil } @@ -205,6 +207,8 @@ WHERE id = 1`) Findtime: stringFromNull(findtime), MaxRetry: intFromNull(maxretry), DestEmail: stringFromNull(destemail), + Banaction: stringFromNull(banaction), + BanactionAllports: stringFromNull(banactionAllports), AdvancedActionsJSON: stringFromNull(advancedActions), } @@ -217,9 +221,9 @@ func SaveAppSettings(ctx context.Context, rec AppSettingsRecord) error { } _, err := db.ExecContext(ctx, ` INSERT INTO app_settings ( - id, language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, ignore_ip, bantime, findtime, maxretry, destemail, advanced_actions + id, language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions ) VALUES ( - 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) ON CONFLICT(id) DO UPDATE SET language = excluded.language, port = excluded.port, @@ -239,6 +243,8 @@ INSERT INTO app_settings ( findtime = excluded.findtime, maxretry = excluded.maxretry, destemail = excluded.destemail, + banaction = excluded.banaction, + banaction_allports = excluded.banaction_allports, advanced_actions = excluded.advanced_actions `, rec.Language, rec.Port, @@ -258,6 +264,8 @@ INSERT INTO app_settings ( rec.Findtime, rec.MaxRetry, rec.DestEmail, + rec.Banaction, + rec.BanactionAllports, rec.AdvancedActionsJSON, ) return err @@ -772,6 +780,8 @@ CREATE TABLE IF NOT EXISTS app_settings ( findtime TEXT, maxretry INTEGER, destemail TEXT, + banaction TEXT, + banaction_allports TEXT, advanced_actions TEXT ); @@ -841,6 +851,18 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status); } } + // Backfill banaction columns for existing databases that predate them. + if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN banaction TEXT`); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + return err + } + } + if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN banaction_allports TEXT`); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + return err + } + } + if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN advanced_actions TEXT`); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { return err diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 0fcf4ba..1598a8f 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -433,6 +433,19 @@ func UpsertServerHandler(c *gin.Context) { } } + // Ensure jail.local structure is properly initialized for newly enabled/added servers + if justEnabled || !wasEnabled { + conn, err := fail2ban.GetManager().Connector(server.ID) + if err == nil { + if err := conn.EnsureJailLocalStructure(c.Request.Context()); err != nil { + config.DebugLog("Warning: failed to ensure jail.local structure for server %s: %v", server.Name, err) + // Don't fail the request, just log the warning + } else { + config.DebugLog("Successfully ensured jail.local structure for server %s", server.Name) + } + } + } + c.JSON(http.StatusOK, gin.H{"server": server}) } @@ -833,6 +846,19 @@ func min(a, b int) int { return b } +// equalStringSlices compares two string slices for equality +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + // TestLogpathHandler tests a logpath and returns matching files func TestLogpathHandler(c *gin.Context) { config.DebugLog("----------------------------") @@ -979,7 +1005,7 @@ func AdvancedActionsTestHandler(c *gin.Context) { // UpdateJailManagementHandler updates the enabled state for each jail. // Expected JSON format: { "JailName1": true, "JailName2": false, ... } -// After updating, the Fail2ban service is restarted. +// After updating, fail2ban is reloaded to apply the changes. func UpdateJailManagementHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("UpdateJailManagementHandler called (handlers.go)") // entry point @@ -998,11 +1024,17 @@ func UpdateJailManagementHandler(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update jail settings: " + err.Error()}) return } - if err := config.MarkRestartNeeded(conn.Server().ID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + // Reload fail2ban to apply the changes (reload is sufficient for jail enable/disable) + if err := conn.Reload(c.Request.Context()); err != nil { + config.DebugLog("Warning: failed to reload fail2ban after updating jail settings: %v", err) + // Still return success but warn about reload failure + c.JSON(http.StatusOK, gin.H{ + "message": "Jail settings updated successfully, but fail2ban reload failed", + "warning": "Please reload fail2ban manually: " + err.Error(), + }) return } - c.JSON(http.StatusOK, gin.H{"message": "Jail settings updated successfully"}) + c.JSON(http.StatusOK, gin.H{"message": "Jail settings updated and fail2ban reloaded successfully"}) } // GetSettingsHandler returns the entire AppSettings struct as JSON @@ -1078,6 +1110,49 @@ func UpdateSettingsHandler(c *gin.Context) { } } + // Check if Fail2Ban DEFAULT settings changed and push to all enabled servers + // Compare IgnoreIPs arrays + ignoreIPsChanged := !equalStringSlices(oldSettings.IgnoreIPs, newSettings.IgnoreIPs) + defaultSettingsChanged := oldSettings.BantimeIncrement != newSettings.BantimeIncrement || + ignoreIPsChanged || + oldSettings.Bantime != newSettings.Bantime || + oldSettings.Findtime != newSettings.Findtime || + oldSettings.Maxretry != newSettings.Maxretry || + oldSettings.Destemail != newSettings.Destemail || + oldSettings.Banaction != newSettings.Banaction || + oldSettings.BanactionAllports != newSettings.BanactionAllports + + if defaultSettingsChanged { + config.DebugLog("Fail2Ban DEFAULT settings changed, pushing to all enabled servers") + connectors := fail2ban.GetManager().Connectors() + var errors []string + for _, conn := range connectors { + server := conn.Server() + config.DebugLog("Updating DEFAULT settings on server: %s (type: %s)", server.Name, server.Type) + if err := conn.UpdateDefaultSettings(c.Request.Context(), newSettings); err != nil { + errorMsg := fmt.Sprintf("Failed to update DEFAULT settings on %s: %v", server.Name, err) + config.DebugLog("Error: %s", errorMsg) + errors = append(errors, errorMsg) + } else { + config.DebugLog("Successfully updated DEFAULT settings on %s", server.Name) + // Mark server as needing restart + if err := config.MarkRestartNeeded(server.ID); err != nil { + config.DebugLog("Warning: failed to mark restart needed for %s: %v", server.Name, err) + } + } + } + if len(errors) > 0 { + config.DebugLog("Some servers failed to update DEFAULT settings: %v", errors) + // Don't fail the request, but include warnings in response + c.JSON(http.StatusOK, gin.H{ + "message": "Settings updated", + "restartNeeded": newSettings.RestartNeeded, + "warnings": errors, + }) + return + } + } + c.JSON(http.StatusOK, gin.H{ "message": "Settings updated", "restartNeeded": newSettings.RestartNeeded, @@ -1154,7 +1229,7 @@ func ApplyFail2banSettings(jailLocalPath string) error { newLines := []string{ "[DEFAULT]", fmt.Sprintf("bantime.increment = %t", s.BantimeIncrement), - fmt.Sprintf("ignoreip = %s", s.IgnoreIP), + fmt.Sprintf("ignoreip = %s", strings.Join(s.IgnoreIPs, " ")), fmt.Sprintf("bantime = %s", s.Bantime), fmt.Sprintf("findtime = %s", s.Findtime), fmt.Sprintf("maxretry = %d", s.Maxretry), diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index f0ed523..181a67a 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -569,36 +569,127 @@
-

Fail2Ban Configuration

+

Global Default Fail2Ban Configurations

+

These settings will be applied to all enabled Fail2Ban servers and stored in their jail.local [DEFAULT] section.

+ -
- - +
+
+ + +
+

If set to true, the bantime will be calculated using the formula: bantime = findtime * (number of failures / maxretry) * (1 + bantime.rndtime).

+
+

The number of seconds that a host is banned. Time format: 1h = 1 hour, 1d = 1 day, 1w = 1 week, 1m = 1 month, 1y = 1 year.

+ + +
+ +

Default banning action (e.g. iptables-multiport, iptables-allports, firewallcmd-multiport, etc). It is used to define action_* variables.

+ +
+ + +
+ +

Banning action for all ports (e.g. iptables-allports, firewallcmd-allports, etc). Used when a jail needs to ban all ports instead of specific ones.

+ +
+
+

A host is banned if it has generated 'maxretry' failures during the last 'findtime' seconds. Time format: 1h = 1 hour, 1d = 1 day, 1w = 1 week, 1m = 1 month, 1y = 1 year.

+
+

Number of failures before a host gets banned.

+
- - + +

Space separated list of IP addresses, CIDR masks or DNS hosts. Fail2ban will not ban a host which matches an address in this list.

+
+
+ +
+
@@ -3127,7 +3218,7 @@ showToast("Error saving jail settings: " + data.error, 'error'); return; } - showToast(t('jails.manage.save_success', 'Jail settings saved. Please restart Fail2ban.'), 'info'); + showToast(t('jails.manage.save_success', 'Jail settings saved and fail2ban reloaded.'), 'info'); return loadServers().then(function() { updateRestartBanner(); return refreshData({ silent: true }); @@ -3395,7 +3486,13 @@ document.getElementById('banTime').value = data.bantime || ''; document.getElementById('findTime').value = data.findtime || ''; document.getElementById('maxRetry').value = data.maxretry || ''; - document.getElementById('ignoreIP').value = data.ignoreip || ''; + // Load IgnoreIPs as array + const ignoreIPs = data.ignoreips || []; + renderIgnoreIPsTags(ignoreIPs); + + // Load banaction settings + document.getElementById('banaction').value = data.banaction || 'iptables-multiport'; + document.getElementById('banactionAllports').value = data.banactionAllports || 'iptables-allports'; applyAdvancedActionsSettings(data.advancedActions || {}); loadPermanentBlockLog(); @@ -3445,7 +3542,9 @@ bantime: document.getElementById('banTime').value.trim(), findtime: document.getElementById('findTime').value.trim(), maxretry: parseInt(document.getElementById('maxRetry').value, 10) || 3, - ignoreip: document.getElementById('ignoreIP').value.trim(), + ignoreips: getIgnoreIPsArray(), + banaction: document.getElementById('banaction').value, + banactionAllports: document.getElementById('banactionAllports').value, smtp: smtpSettings, advancedActions: collectAdvancedActionsSettings() }; @@ -3903,7 +4002,92 @@ } }); } + + // Setup IgnoreIPs tag input + setupIgnoreIPsInput(); }); + + //******************************************************************* + //* IgnoreIPs Tag Management Functions : * + //******************************************************************* + + function renderIgnoreIPsTags(ips) { + const container = document.getElementById('ignoreIPsTags'); + if (!container) return; + container.innerHTML = ''; + if (ips && ips.length > 0) { + ips.forEach(function(ip) { + if (ip && ip.trim()) { + addIgnoreIPTag(ip.trim()); + } + }); + } + } + + function addIgnoreIPTag(ip) { + if (!ip || !ip.trim()) return; + + const container = document.getElementById('ignoreIPsTags'); + if (!container) return; + + const existingTags = Array.from(container.querySelectorAll('.ignore-ip-tag')).map(tag => tag.dataset.ip); + if (existingTags.includes(ip.trim())) { + return; // Already exists + } + + const tag = document.createElement('span'); + tag.className = 'ignore-ip-tag inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800'; + tag.dataset.ip = ip.trim(); + const escapedIP = escapeHtml(ip.trim()); + tag.innerHTML = escapedIP + ' '; + container.appendChild(tag); + + // Clear input + const input = document.getElementById('ignoreIPInput'); + if (input) input.value = ''; + } + + function removeIgnoreIPTag(ip) { + const container = document.getElementById('ignoreIPsTags'); + if (!container) return; + const escapedIP = escapeHtml(ip); + const tag = container.querySelector('.ignore-ip-tag[data-ip="' + escapedIP.replace(/"/g, '"') + '"]'); + if (tag) { + tag.remove(); + } + } + + function getIgnoreIPsArray() { + const container = document.getElementById('ignoreIPsTags'); + if (!container) return []; + const tags = container.querySelectorAll('.ignore-ip-tag'); + return Array.from(tags).map(tag => tag.dataset.ip).filter(ip => ip && ip.trim()); + } + + function setupIgnoreIPsInput() { + const input = document.getElementById('ignoreIPInput'); + if (!input) return; + + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const value = input.value.trim(); + if (value) { + // Support space or comma separated IPs + const ips = value.split(/[,\s]+/).filter(ip => ip.trim()); + ips.forEach(ip => addIgnoreIPTag(ip.trim())); + } + } + }); + + input.addEventListener('blur', function(e) { + const value = input.value.trim(); + if (value) { + const ips = value.split(/[,\s]+/).filter(ip => ip.trim()); + ips.forEach(ip => addIgnoreIPTag(ip.trim())); + } + }); + } //******************************************************************* //* Translation Related Functions : *