restructure jail.local default config functions, make banactions configurable

This commit is contained in:
2025-12-04 19:42:43 +01:00
parent 366d0965e3
commit 13704df994
15 changed files with 1105 additions and 158 deletions

View File

@@ -24,7 +24,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -61,12 +60,14 @@ type AppSettings struct {
Servers []Fail2banServer `json:"servers"` Servers []Fail2banServer `json:"servers"`
// Fail2Ban [DEFAULT] section values from jail.local // Fail2Ban [DEFAULT] section values from jail.local
BantimeIncrement bool `json:"bantimeIncrement"` BantimeIncrement bool `json:"bantimeIncrement"`
IgnoreIP string `json:"ignoreip"` IgnoreIPs []string `json:"ignoreips"` // Changed from string to []string for individual IP management
Bantime string `json:"bantime"` Bantime string `json:"bantime"`
Findtime string `json:"findtime"` Findtime string `json:"findtime"`
Maxretry int `json:"maxretry"` Maxretry int `json:"maxretry"`
Destemail string `json:"destemail"` Destemail string `json:"destemail"`
Banaction string `json:"banaction"` // Default banning action
BanactionAllports string `json:"banactionAllports"` // Allports banning action
//Sender string `json:"sender"` //Sender string `json:"sender"`
} }
@@ -332,7 +333,12 @@ func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) {
currentSettings.CallbackURL = rec.CallbackURL currentSettings.CallbackURL = rec.CallbackURL
currentSettings.RestartNeeded = rec.RestartNeeded currentSettings.RestartNeeded = rec.RestartNeeded
currentSettings.BantimeIncrement = rec.BantimeIncrement 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.Bantime = rec.Bantime
currentSettings.Findtime = rec.Findtime currentSettings.Findtime = rec.Findtime
currentSettings.Maxretry = rec.MaxRetry currentSettings.Maxretry = rec.MaxRetry
@@ -409,24 +415,27 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) {
} }
return storage.AppSettingsRecord{ return storage.AppSettingsRecord{
Language: currentSettings.Language, Language: currentSettings.Language,
Port: currentSettings.Port, Port: currentSettings.Port,
Debug: currentSettings.Debug, Debug: currentSettings.Debug,
CallbackURL: currentSettings.CallbackURL, CallbackURL: currentSettings.CallbackURL,
RestartNeeded: currentSettings.RestartNeeded, RestartNeeded: currentSettings.RestartNeeded,
AlertCountriesJSON: string(countryBytes), AlertCountriesJSON: string(countryBytes),
SMTPHost: currentSettings.SMTP.Host, SMTPHost: currentSettings.SMTP.Host,
SMTPPort: currentSettings.SMTP.Port, SMTPPort: currentSettings.SMTP.Port,
SMTPUsername: currentSettings.SMTP.Username, SMTPUsername: currentSettings.SMTP.Username,
SMTPPassword: currentSettings.SMTP.Password, SMTPPassword: currentSettings.SMTP.Password,
SMTPFrom: currentSettings.SMTP.From, SMTPFrom: currentSettings.SMTP.From,
SMTPUseTLS: currentSettings.SMTP.UseTLS, SMTPUseTLS: currentSettings.SMTP.UseTLS,
BantimeIncrement: currentSettings.BantimeIncrement, BantimeIncrement: currentSettings.BantimeIncrement,
IgnoreIP: currentSettings.IgnoreIP, // Convert IgnoreIPs array to space-separated string for storage
IgnoreIP: strings.Join(currentSettings.IgnoreIPs, " "),
Bantime: currentSettings.Bantime, Bantime: currentSettings.Bantime,
Findtime: currentSettings.Findtime, Findtime: currentSettings.Findtime,
MaxRetry: currentSettings.Maxretry, MaxRetry: currentSettings.Maxretry,
DestEmail: currentSettings.Destemail, DestEmail: currentSettings.Destemail,
Banaction: currentSettings.Banaction,
BanactionAllports: currentSettings.BanactionAllports,
AdvancedActionsJSON: string(advancedBytes), AdvancedActionsJSON: string(advancedBytes),
}, nil }, nil
} }
@@ -538,8 +547,14 @@ func setDefaultsLocked() {
if !currentSettings.SMTP.UseTLS { if !currentSettings.SMTP.UseTLS {
currentSettings.SMTP.UseTLS = true currentSettings.SMTP.UseTLS = true
} }
if currentSettings.IgnoreIP == "" { if len(currentSettings.IgnoreIPs) == 0 {
currentSettings.IgnoreIP = "127.0.0.1/8 ::1" 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{}) { if (currentSettings.AdvancedActions == AdvancedActionsConfig{}) {
@@ -586,7 +601,18 @@ func initializeFromJailFile() error {
} }
} }
if val, ok := settings["ignoreip"]; ok { 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 { if val, ok := settings["destemail"]; ok {
currentSettings.Destemail = val currentSettings.Destemail = val
@@ -695,124 +721,203 @@ func ensureFail2banActionFiles(callbackURL, serverID string) error {
return nil return nil
} }
if err := setupGeoCustomAction(); err != nil { // Ensure jail.local has proper structure (banner, DEFAULT, action_mwlg, action override)
return err if err := ensureJailLocalStructure(); err != nil {
}
if err := ensureJailDConfig(); err != nil {
return err return err
} }
return writeFail2banAction(callbackURL, serverID) return writeFail2banAction(callbackURL, serverID)
} }
// setupGeoCustomAction checks and replaces the default action in jail.local with our from fail2ban-UI // EnsureJailLocalStructure creates or updates jail.local with proper structure:
func setupGeoCustomAction() error { // This is exported so connectors can call it.
DebugLog("Running initial setupGeoCustomAction()") // entry point func EnsureJailLocalStructure() error {
if err := os.MkdirAll(filepath.Dir(jailFile), 0o755); err != nil { return ensureJailLocalStructure()
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)
} }
// copyFile copies a file from src to dst. If the destination file does not exist, it will be created. // ensureJailLocalStructure creates or updates jail.local with proper structure:
func copyFile(src, dst string) error { // 1. Banner at top warning users not to edit manually
sourceFile, err := os.Open(src) // 2. [DEFAULT] section with current UI settings
if err != nil { // 3. action_mwlg configuration
return err // 4. action = %(action_mwlg)s at the end
} func ensureJailLocalStructure() error {
defer sourceFile.Close() DebugLog("Running ensureJailLocalStructure()") // entry point
destFile, err := os.Create(dst) // Check if /etc/fail2ban directory exists (fail2ban must be installed)
if err != nil { if _, err := os.Stat(filepath.Dir(jailFile)); os.IsNotExist(err) {
return err return fmt.Errorf("fail2ban is not installed: /etc/fail2ban directory does not exist. Please install fail2ban package first")
}
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
} }
if err := os.MkdirAll(filepath.Dir(jailDFile), 0o755); err != nil { // Get current settings
return fmt.Errorf("failed to ensure jail.d directory: %v", err) 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 // Check if file already has our banner (indicating it's already structured)
jailDConfig := `[DEFAULT] hasBanner := strings.Contains(existingContent, "Fail2Ban-UI") || strings.Contains(existingContent, "fail2ban-ui")
# Custom Fail2Ban action using geo-filter for email alerts 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 action_mwlg = %(action_)s
ui-custom-action[sender="%(sender)s", dest="%(destemail)s", logpath="%(logpath)s", chain="%(chain)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 { 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 return nil
} }
@@ -820,8 +925,10 @@ action_mwlg = %(action_)s
func writeFail2banAction(callbackURL, serverID string) error { func writeFail2banAction(callbackURL, serverID string) error {
DebugLog("Running initial writeFail2banAction()") // entry point DebugLog("Running initial writeFail2banAction()") // entry point
DebugLog("----------------------------") 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) actionConfig := BuildFail2banActionConfig(callbackURL, serverID)
@@ -1185,8 +1292,20 @@ func UpdateSettings(new AppSettings) (AppSettings, error) {
old := currentSettings old := currentSettings
// If certain fields change, we mark reload needed // 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 || restartTriggered := old.BantimeIncrement != new.BantimeIncrement ||
old.IgnoreIP != new.IgnoreIP || ignoreIPsChanged ||
old.Bantime != new.Bantime || old.Bantime != new.Bantime ||
old.Findtime != new.Findtime || old.Findtime != new.Findtime ||
old.Maxretry != new.Maxretry old.Maxretry != new.Maxretry

View File

@@ -320,3 +320,40 @@ func (ac *AgentConnector) TestLogpath(ctx context.Context, logpath string) ([]st
} }
return resp.Files, nil 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)
}

View File

@@ -142,8 +142,14 @@ func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error {
// Reload implements Connector. // Reload implements Connector.
func (lc *LocalConnector) Reload(ctx context.Context) error { func (lc *LocalConnector) Reload(ctx context.Context) error {
if _, err := lc.runFail2banClient(ctx, "reload"); err != nil { out, err := lc.runFail2banClient(ctx, "reload")
return fmt.Errorf("fail2ban reload error: %w", err) 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 return nil
} }
@@ -267,6 +273,16 @@ func (lc *LocalConnector) TestLogpath(ctx context.Context, logpath string) ([]st
return TestLogpath(logpath) 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) { func executeShellCommand(ctx context.Context, command string) (string, error) {
parts := strings.Fields(command) parts := strings.Fields(command)
if len(parts) == 0 { if len(parts) == 0 {

View File

@@ -591,6 +591,333 @@ fi
return matches, nil 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. // parseJailConfigContent parses jail configuration content and returns JailInfo slice.
func parseJailConfigContent(content string) []JailInfo { func parseJailConfigContent(content string) []JailInfo {
var jails []JailInfo var jails []JailInfo

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"sync" "sync"
@@ -562,3 +563,157 @@ func ExtractFilterFromJailConfig(jailContent string) string {
} }
return "" 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
}

View File

@@ -34,6 +34,12 @@ type Connector interface {
GetJailConfig(ctx context.Context, jail string) (string, error) GetJailConfig(ctx context.Context, jail string) (string, error)
SetJailConfig(ctx context.Context, jail, content string) error SetJailConfig(ctx context.Context, jail, content string) error
TestLogpath(ctx context.Context, logpath string) ([]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. // Manager orchestrates all connectors for configured Fail2ban servers.

View File

@@ -119,7 +119,8 @@
"settings.smtp_sender_placeholder": "noreply@swissmakers.ch", "settings.smtp_sender_placeholder": "noreply@swissmakers.ch",
"settings.smtp_tls": "TLS verwenden (empfohlen)", "settings.smtp_tls": "TLS verwenden (empfohlen)",
"settings.send_test_email": "Test-E-Mail senden", "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.enable_bantime_increment": "Bantime-Inkrement aktivieren",
"settings.default_bantime": "Standard-Bantime", "settings.default_bantime": "Standard-Bantime",
"settings.default_bantime_placeholder": "z.B. 48h", "settings.default_bantime_placeholder": "z.B. 48h",

View File

@@ -119,7 +119,8 @@
"settings.smtp_sender_placeholder": "noreply@swissmakers.ch", "settings.smtp_sender_placeholder": "noreply@swissmakers.ch",
"settings.smtp_tls": "TLS bruuche (empfohlen)", "settings.smtp_tls": "TLS bruuche (empfohlen)",
"settings.send_test_email": "Test-Email schicke", "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.enable_bantime_increment": "Bantime-Inkrement aktivierä",
"settings.default_bantime": "Standard-Bantime", "settings.default_bantime": "Standard-Bantime",
"settings.default_bantime_placeholder": "z.B. 48h", "settings.default_bantime_placeholder": "z.B. 48h",

View File

@@ -119,7 +119,8 @@
"settings.smtp_sender_placeholder": "noreply@swissmakers.ch", "settings.smtp_sender_placeholder": "noreply@swissmakers.ch",
"settings.smtp_tls": "Use TLS (Recommended)", "settings.smtp_tls": "Use TLS (Recommended)",
"settings.send_test_email": "Send Test Email", "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.enable_bantime_increment": "Enable Bantime Increment",
"settings.default_bantime": "Default Bantime", "settings.default_bantime": "Default Bantime",
"settings.default_bantime_placeholder": "e.g., 48h", "settings.default_bantime_placeholder": "e.g., 48h",

View File

@@ -119,7 +119,8 @@
"settings.smtp_sender_placeholder": "noreply@swissmakers.ch", "settings.smtp_sender_placeholder": "noreply@swissmakers.ch",
"settings.smtp_tls": "Usar TLS (recomendado)", "settings.smtp_tls": "Usar TLS (recomendado)",
"settings.send_test_email": "Enviar correo de prueba", "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.enable_bantime_increment": "Habilitar incremento de Bantime",
"settings.default_bantime": "Bantime por defecto", "settings.default_bantime": "Bantime por defecto",
"settings.default_bantime_placeholder": "p.ej., 48h", "settings.default_bantime_placeholder": "p.ej., 48h",

View File

@@ -119,7 +119,8 @@
"settings.smtp_sender_placeholder": "noreply@swissmakers.ch", "settings.smtp_sender_placeholder": "noreply@swissmakers.ch",
"settings.smtp_tls": "Utiliser TLS (recommandé)", "settings.smtp_tls": "Utiliser TLS (recommandé)",
"settings.send_test_email": "Envoyer un email de test", "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.enable_bantime_increment": "Activer l'incrémentation du Bantime",
"settings.default_bantime": "Bantime par défaut", "settings.default_bantime": "Bantime par défaut",
"settings.default_bantime_placeholder": "par exemple, 48h", "settings.default_bantime_placeholder": "par exemple, 48h",

View File

@@ -119,7 +119,8 @@
"settings.smtp_sender_placeholder": "noreply@swissmakers.ch", "settings.smtp_sender_placeholder": "noreply@swissmakers.ch",
"settings.smtp_tls": "Usa TLS (raccomandato)", "settings.smtp_tls": "Usa TLS (raccomandato)",
"settings.send_test_email": "Invia email di test", "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.enable_bantime_increment": "Abilita incremento del Bantime",
"settings.default_bantime": "Bantime predefinito", "settings.default_bantime": "Bantime predefinito",
"settings.default_bantime_placeholder": "es. 48h", "settings.default_bantime_placeholder": "es. 48h",

View File

@@ -61,11 +61,13 @@ type AppSettingsRecord struct {
SMTPFrom string SMTPFrom string
SMTPUseTLS bool SMTPUseTLS bool
BantimeIncrement bool BantimeIncrement bool
IgnoreIP string IgnoreIP string // Stored as space-separated string, converted to array in AppSettings
Bantime string Bantime string
Findtime string Findtime string
MaxRetry int MaxRetry int
DestEmail string DestEmail string
Banaction string
BanactionAllports string
AdvancedActionsJSON string AdvancedActionsJSON string
} }
@@ -168,17 +170,17 @@ func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) {
} }
row := db.QueryRowContext(ctx, ` 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 FROM app_settings
WHERE id = 1`) WHERE id = 1`)
var ( var (
lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, advancedActions sql.NullString lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, banaction, banactionAllports, advancedActions sql.NullString
port, smtpPort, maxretry sql.NullInt64 port, smtpPort, maxretry sql.NullInt64
debug, restartNeeded, smtpTLS, bantimeInc 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) { if errors.Is(err, sql.ErrNoRows) {
return AppSettingsRecord{}, false, nil return AppSettingsRecord{}, false, nil
} }
@@ -205,6 +207,8 @@ WHERE id = 1`)
Findtime: stringFromNull(findtime), Findtime: stringFromNull(findtime),
MaxRetry: intFromNull(maxretry), MaxRetry: intFromNull(maxretry),
DestEmail: stringFromNull(destemail), DestEmail: stringFromNull(destemail),
Banaction: stringFromNull(banaction),
BanactionAllports: stringFromNull(banactionAllports),
AdvancedActionsJSON: stringFromNull(advancedActions), AdvancedActionsJSON: stringFromNull(advancedActions),
} }
@@ -217,9 +221,9 @@ func SaveAppSettings(ctx context.Context, rec AppSettingsRecord) error {
} }
_, err := db.ExecContext(ctx, ` _, err := db.ExecContext(ctx, `
INSERT INTO app_settings ( 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 ( ) VALUES (
1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
) ON CONFLICT(id) DO UPDATE SET ) ON CONFLICT(id) DO UPDATE SET
language = excluded.language, language = excluded.language,
port = excluded.port, port = excluded.port,
@@ -239,6 +243,8 @@ INSERT INTO app_settings (
findtime = excluded.findtime, findtime = excluded.findtime,
maxretry = excluded.maxretry, maxretry = excluded.maxretry,
destemail = excluded.destemail, destemail = excluded.destemail,
banaction = excluded.banaction,
banaction_allports = excluded.banaction_allports,
advanced_actions = excluded.advanced_actions advanced_actions = excluded.advanced_actions
`, rec.Language, `, rec.Language,
rec.Port, rec.Port,
@@ -258,6 +264,8 @@ INSERT INTO app_settings (
rec.Findtime, rec.Findtime,
rec.MaxRetry, rec.MaxRetry,
rec.DestEmail, rec.DestEmail,
rec.Banaction,
rec.BanactionAllports,
rec.AdvancedActionsJSON, rec.AdvancedActionsJSON,
) )
return err return err
@@ -772,6 +780,8 @@ CREATE TABLE IF NOT EXISTS app_settings (
findtime TEXT, findtime TEXT,
maxretry INTEGER, maxretry INTEGER,
destemail TEXT, destemail TEXT,
banaction TEXT,
banaction_allports TEXT,
advanced_actions 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 _, 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") { if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
return err return err

View File

@@ -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}) c.JSON(http.StatusOK, gin.H{"server": server})
} }
@@ -833,6 +846,19 @@ func min(a, b int) int {
return b 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 // TestLogpathHandler tests a logpath and returns matching files
func TestLogpathHandler(c *gin.Context) { func TestLogpathHandler(c *gin.Context) {
config.DebugLog("----------------------------") config.DebugLog("----------------------------")
@@ -979,7 +1005,7 @@ func AdvancedActionsTestHandler(c *gin.Context) {
// UpdateJailManagementHandler updates the enabled state for each jail. // UpdateJailManagementHandler updates the enabled state for each jail.
// Expected JSON format: { "JailName1": true, "JailName2": false, ... } // 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) { func UpdateJailManagementHandler(c *gin.Context) {
config.DebugLog("----------------------------") config.DebugLog("----------------------------")
config.DebugLog("UpdateJailManagementHandler called (handlers.go)") // entry point 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()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update jail settings: " + err.Error()})
return return
} }
if err := config.MarkRestartNeeded(conn.Server().ID); err != nil { // Reload fail2ban to apply the changes (reload is sufficient for jail enable/disable)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 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 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 // 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{ c.JSON(http.StatusOK, gin.H{
"message": "Settings updated", "message": "Settings updated",
"restartNeeded": newSettings.RestartNeeded, "restartNeeded": newSettings.RestartNeeded,
@@ -1154,7 +1229,7 @@ func ApplyFail2banSettings(jailLocalPath string) error {
newLines := []string{ newLines := []string{
"[DEFAULT]", "[DEFAULT]",
fmt.Sprintf("bantime.increment = %t", s.BantimeIncrement), 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("bantime = %s", s.Bantime),
fmt.Sprintf("findtime = %s", s.Findtime), fmt.Sprintf("findtime = %s", s.Findtime),
fmt.Sprintf("maxretry = %d", s.Maxretry), fmt.Sprintf("maxretry = %d", s.Maxretry),

View File

@@ -569,36 +569,127 @@
<!-- Fail2Ban Configuration Group --> <!-- Fail2Ban Configuration Group -->
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.fail2ban">Fail2Ban Configuration</h3> <h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.fail2ban">Global Default Fail2Ban Configurations</h3>
<p class="text-sm text-gray-600 mb-4" data-i18n="settings.fail2ban.description">These settings will be applied to all enabled Fail2Ban servers and stored in their jail.local [DEFAULT] section.</p>
<!-- Bantime Increment --> <!-- Bantime Increment -->
<div class="flex items-center mb-4"> <div class="mb-4">
<input type="checkbox" id="bantimeIncrement" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out" /> <div class="flex items-center mb-2">
<label for="bantimeIncrement" class="ml-2 block text-sm text-gray-700" data-i18n="settings.enable_bantime_increment">Enable Bantime Increment</label> <input type="checkbox" id="bantimeIncrement" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out" />
<label for="bantimeIncrement" class="ml-2 block text-sm font-medium text-gray-700" data-i18n="settings.enable_bantime_increment">Enable Bantime Increment</label>
</div>
<p class="text-xs text-gray-500 ml-9" data-i18n="settings.enable_bantime_increment.description">If set to true, the bantime will be calculated using the formula: bantime = findtime * (number of failures / maxretry) * (1 + bantime.rndtime).</p>
</div> </div>
<!-- Bantime --> <!-- Bantime -->
<div class="mb-4"> <div class="mb-4">
<label for="banTime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_bantime">Default Bantime</label> <label for="banTime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_bantime">Default Bantime</label>
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.default_bantime.description">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.</p>
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="banTime" <input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="banTime"
data-i18n-placeholder="settings.default_bantime_placeholder" placeholder="e.g., 48h" /> data-i18n-placeholder="settings.default_bantime_placeholder" placeholder="e.g., 48h" />
</div> </div>
<!-- Banaction -->
<div class="mb-4">
<label for="banaction" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.banaction">Banaction</label>
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.banaction.description">Default banning action (e.g. iptables-multiport, iptables-allports, firewallcmd-multiport, etc). It is used to define action_* variables.</p>
<select id="banaction" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="iptables-multiport">iptables-multiport</option>
<option value="iptables-allports">iptables-allports</option>
<option value="iptables-new">iptables-new</option>
<option value="iptables-ipset">iptables-ipset</option>
<option value="iptables-ipset-proto4">iptables-ipset-proto4</option>
<option value="iptables-ipset-proto6">iptables-ipset-proto6</option>
<option value="iptables-ipset-proto6-allports">iptables-ipset-proto6-allports</option>
<option value="iptables-multiport-log">iptables-multiport-log</option>
<option value="iptables-xt_recent-echo">iptables-xt_recent-echo</option>
<option value="firewallcmd-multiport">firewallcmd-multiport</option>
<option value="firewallcmd-allports">firewallcmd-allports</option>
<option value="firewallcmd-ipset">firewallcmd-ipset</option>
<option value="firewallcmd-new">firewallcmd-new</option>
<option value="firewallcmd-rich-rules">firewallcmd-rich-rules</option>
<option value="firewallcmd-rich-logging">firewallcmd-rich-logging</option>
<option value="nftables-multiport">nftables-multiport</option>
<option value="nftables-allports">nftables-allports</option>
<option value="nftables">nftables</option>
<option value="shorewall">shorewall</option>
<option value="shorewall-ipset-proto6">shorewall-ipset-proto6</option>
<option value="ufw">ufw</option>
<option value="pf">pf</option>
<option value="bsd-ipfw">bsd-ipfw</option>
<option value="ipfw">ipfw</option>
<option value="ipfilter">ipfilter</option>
<option value="npf">npf</option>
<option value="osx-ipfw">osx-ipfw</option>
<option value="osx-afctl">osx-afctl</option>
<option value="apf">apf</option>
</select>
</div>
<!-- Banaction Allports -->
<div class="mb-4">
<label for="banactionAllports" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.banaction_allports">Banaction Allports</label>
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.banaction_allports.description">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.</p>
<select id="banactionAllports" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="iptables-allports">iptables-allports</option>
<option value="iptables-multiport">iptables-multiport</option>
<option value="iptables-new">iptables-new</option>
<option value="iptables-ipset">iptables-ipset</option>
<option value="iptables-ipset-proto4">iptables-ipset-proto4</option>
<option value="iptables-ipset-proto6">iptables-ipset-proto6</option>
<option value="iptables-ipset-proto6-allports">iptables-ipset-proto6-allports</option>
<option value="iptables-multiport-log">iptables-multiport-log</option>
<option value="iptables-xt_recent-echo">iptables-xt_recent-echo</option>
<option value="firewallcmd-allports">firewallcmd-allports</option>
<option value="firewallcmd-multiport">firewallcmd-multiport</option>
<option value="firewallcmd-ipset">firewallcmd-ipset</option>
<option value="firewallcmd-new">firewallcmd-new</option>
<option value="firewallcmd-rich-rules">firewallcmd-rich-rules</option>
<option value="firewallcmd-rich-logging">firewallcmd-rich-logging</option>
<option value="nftables-allports">nftables-allports</option>
<option value="nftables-multiport">nftables-multiport</option>
<option value="nftables">nftables</option>
<option value="shorewall">shorewall</option>
<option value="shorewall-ipset-proto6">shorewall-ipset-proto6</option>
<option value="ufw">ufw</option>
<option value="pf">pf</option>
<option value="bsd-ipfw">bsd-ipfw</option>
<option value="ipfw">ipfw</option>
<option value="ipfilter">ipfilter</option>
<option value="npf">npf</option>
<option value="osx-ipfw">osx-ipfw</option>
<option value="osx-afctl">osx-afctl</option>
<option value="apf">apf</option>
</select>
</div>
<!-- Findtime --> <!-- Findtime -->
<div class="mb-4"> <div class="mb-4">
<label for="findTime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_findtime">Default Findtime</label> <label for="findTime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_findtime">Default Findtime</label>
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.default_findtime.description">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.</p>
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="findTime" <input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="findTime"
data-i18n-placeholder="settings.default_findtime_placeholder" placeholder="e.g., 30m" /> data-i18n-placeholder="settings.default_findtime_placeholder" placeholder="e.g., 30m" />
</div> </div>
<!-- Max Retry --> <!-- Max Retry -->
<div class="mb-4"> <div class="mb-4">
<label for="maxRetry" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_max_retry">Default Max Retry</label> <label for="maxRetry" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_max_retry">Default Max Retry</label>
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.default_max_retry.description">Number of failures before a host gets banned.</p>
<input type="number" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="maxRetry" <input type="number" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="maxRetry"
data-i18n-placeholder="settings.default_max_retry_placeholder" placeholder="Enter maximum retries" /> data-i18n-placeholder="settings.default_max_retry_placeholder" placeholder="Enter maximum retries" />
</div> </div>
<!-- Ignore IPs --> <!-- Ignore IPs -->
<div class="mb-4"> <div class="mb-4">
<label for="ignoreIP" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.ignore_ips">Ignore IPs</label> <label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.ignore_ips">Ignore IPs</label>
<textarea class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="ignoreIP" rows="2" <p class="text-xs text-gray-500 mb-2" data-i18n="settings.ignore_ips.description">Space separated list of IP addresses, CIDR masks or DNS hosts. Fail2ban will not ban a host which matches an address in this list.</p>
data-i18n-placeholder="settings.ignore_ips_placeholder" placeholder="IPs to ignore, separated by spaces"></textarea> <div class="border border-gray-300 rounded-md p-2 min-h-[60px] bg-gray-50" id="ignoreIPsContainer">
<div id="ignoreIPsTags" class="flex flex-wrap gap-2 mb-2"></div>
<input type="text" id="ignoreIPInput" class="w-full border-0 bg-transparent focus:outline-none focus:ring-0 text-sm"
data-i18n-placeholder="settings.ignore_ips_placeholder" placeholder="Enter IP address and press Enter" />
</div>
</div> </div>
</div> </div>
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors" data-i18n="settings.save">Save</button> <button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors" data-i18n="settings.save">Save</button>
</form> </form>
@@ -3127,7 +3218,7 @@
showToast("Error saving jail settings: " + data.error, 'error'); showToast("Error saving jail settings: " + data.error, 'error');
return; 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() { return loadServers().then(function() {
updateRestartBanner(); updateRestartBanner();
return refreshData({ silent: true }); return refreshData({ silent: true });
@@ -3395,7 +3486,13 @@
document.getElementById('banTime').value = data.bantime || ''; document.getElementById('banTime').value = data.bantime || '';
document.getElementById('findTime').value = data.findtime || ''; document.getElementById('findTime').value = data.findtime || '';
document.getElementById('maxRetry').value = data.maxretry || ''; 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 || {}); applyAdvancedActionsSettings(data.advancedActions || {});
loadPermanentBlockLog(); loadPermanentBlockLog();
@@ -3445,7 +3542,9 @@
bantime: document.getElementById('banTime').value.trim(), bantime: document.getElementById('banTime').value.trim(),
findtime: document.getElementById('findTime').value.trim(), findtime: document.getElementById('findTime').value.trim(),
maxretry: parseInt(document.getElementById('maxRetry').value, 10) || 3, 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, smtp: smtpSettings,
advancedActions: collectAdvancedActionsSettings() 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 + ' <button type="button" class="ml-1 text-blue-600 hover:text-blue-800 focus:outline-none" onclick="removeIgnoreIPTag(\'' + escapedIP.replace(/'/g, "\\'") + '\')">×</button>';
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, '&quot;') + '"]');
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 : * //* Translation Related Functions : *