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"
"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

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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