mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
Simplify the connector and jail.local cunstruction with a unified function for all connectors
This commit is contained in:
@@ -863,70 +863,22 @@ func ensureFail2banActionFiles(callbackURL, serverID string) error {
|
||||
}
|
||||
|
||||
// Ensure jail.local has proper structure (banner, DEFAULT, action_mwlg, action override)
|
||||
if err := ensureJailLocalStructure(); err != nil {
|
||||
if err := EnsureJailLocalStructure(); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFail2banAction(callbackURL, serverID)
|
||||
}
|
||||
|
||||
// EnsureJailLocalStructure creates or updates jail.local with proper structure:
|
||||
// This is exported so connectors can call it.
|
||||
func EnsureJailLocalStructure() error {
|
||||
return ensureJailLocalStructure()
|
||||
}
|
||||
|
||||
// ensureJailLocalStructure creates or updates jail.local with proper structure:
|
||||
func ensureJailLocalStructure() error {
|
||||
DebugLog("Running ensureJailLocalStructure()") // entry point
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Get current settings
|
||||
// BuildJailLocalContent builds the complete managed jail.local file content
|
||||
// from the current settings. This is the single source of truth for the file
|
||||
// format, shared by all connectors (local, SSH, agent).
|
||||
func BuildJailLocalContent() string {
|
||||
settings := GetSettings()
|
||||
|
||||
// Read existing jail.local content if it exists
|
||||
var existingContent string
|
||||
fileExists := false
|
||||
if content, err := os.ReadFile(jailFile); err == nil {
|
||||
existingContent = string(content)
|
||||
fileExists = len(strings.TrimSpace(existingContent)) > 0
|
||||
}
|
||||
|
||||
// If jail.local exists but is NOT managed by Fail2ban-UI,
|
||||
// it belongs to the user; never overwrite it.
|
||||
hasUIAction := strings.Contains(existingContent, "ui-custom-action")
|
||||
if fileExists && !hasUIAction {
|
||||
DebugLog("jail.local exists but is not managed by Fail2ban-UI - skipping overwrite")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if file already has our full banner; indicating it's already properly structured
|
||||
hasFullBanner := strings.Contains(existingContent, "################################################################################") &&
|
||||
strings.Contains(existingContent, "Fail2Ban-UI Managed Configuration") &&
|
||||
strings.Contains(existingContent, "DO NOT EDIT THIS FILE MANUALLY")
|
||||
hasActionMwlg := strings.Contains(existingContent, "action_mwlg") && hasUIAction
|
||||
hasActionOverride := strings.Contains(existingContent, "action = %(action_mwlg)s")
|
||||
|
||||
// If file is already properly structured, just ensure DEFAULT section is up to date
|
||||
if hasFullBanner && hasActionMwlg && hasActionOverride {
|
||||
DebugLog("jail.local already has proper structure, updating DEFAULT section if needed")
|
||||
// Update DEFAULT section values without changing structure
|
||||
return updateJailLocalDefaultSection(settings)
|
||||
}
|
||||
|
||||
// Use the standard banner
|
||||
banner := jailLocalBanner
|
||||
|
||||
// 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 = "nftables-multiport"
|
||||
@@ -949,34 +901,52 @@ maxretry = %d
|
||||
banaction = %s
|
||||
banaction_allports = %s
|
||||
chain = %s
|
||||
|
||||
`, settings.DefaultJailEnable, settings.BantimeIncrement, ignoreIPStr, settings.Bantime, settings.Findtime, settings.Maxretry, banaction, banactionAllports, chain)
|
||||
`, settings.DefaultJailEnable, settings.BantimeIncrement, ignoreIPStr,
|
||||
settings.Bantime, settings.Findtime, settings.Maxretry,
|
||||
banaction, banactionAllports, chain)
|
||||
if settings.BantimeRndtime != "" {
|
||||
defaultSection += fmt.Sprintf("bantime.rndtime = %s\n", settings.BantimeRndtime)
|
||||
}
|
||||
defaultSection += "\n"
|
||||
|
||||
// Build action_mwlg configuration
|
||||
// Note: action_mwlg depends on action_ which depends on banaction
|
||||
// The multi-line format uses indentation for continuation
|
||||
// ui-custom-action only needs logpath and chain
|
||||
actionMwlgConfig := `# Custom Fail2Ban action for UI callbacks
|
||||
action_mwlg = %(action_)s
|
||||
ui-custom-action[logpath="%(logpath)s", chain="%(chain)s"]
|
||||
|
||||
`
|
||||
|
||||
// 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
|
||||
return jailLocalBanner + defaultSection + actionMwlgConfig + actionOverride
|
||||
}
|
||||
|
||||
// Write the new content
|
||||
err := os.WriteFile(jailFile, []byte(newContent), 0644)
|
||||
if err != nil {
|
||||
// EnsureJailLocalStructure writes a complete managed jail.local to the local filesystem.
|
||||
func EnsureJailLocalStructure() error {
|
||||
DebugLog("Running EnsureJailLocalStructure()")
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Read existing jail.local content if it exists
|
||||
var existingContent string
|
||||
fileExists := false
|
||||
if content, err := os.ReadFile(jailFile); err == nil {
|
||||
existingContent = string(content)
|
||||
fileExists = len(strings.TrimSpace(existingContent)) > 0
|
||||
}
|
||||
|
||||
// If jail.local exists but is NOT managed by Fail2ban-UI,
|
||||
// it belongs to the user — never overwrite it.
|
||||
if fileExists && !strings.Contains(existingContent, "ui-custom-action") {
|
||||
DebugLog("jail.local exists but is not managed by Fail2ban-UI - skipping overwrite")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Full rewrite from current settings (self-healing, no stale keys)
|
||||
if err := os.WriteFile(jailFile, []byte(BuildJailLocalContent()), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write jail.local: %v", err)
|
||||
}
|
||||
|
||||
@@ -984,144 +954,6 @@ action = %(action_mwlg)s
|
||||
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)
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
lines := strings.Split(contentStr, "\n")
|
||||
var outputLines []string
|
||||
inDefault := false
|
||||
defaultUpdated := false
|
||||
|
||||
// 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 = "nftables-multiport"
|
||||
}
|
||||
banactionAllports := settings.BanactionAllports
|
||||
if banactionAllports == "" {
|
||||
banactionAllports = "nftables-allports"
|
||||
}
|
||||
chain := settings.Chain
|
||||
if chain == "" {
|
||||
chain = "INPUT"
|
||||
}
|
||||
// Keys to update
|
||||
keysToUpdate := map[string]string{
|
||||
"enabled": fmt.Sprintf("enabled = %t", settings.DefaultJailEnable),
|
||||
"bantime.increment": fmt.Sprintf("bantime.increment = %t", settings.BantimeIncrement),
|
||||
"ignoreip": fmt.Sprintf("ignoreip = %s", ignoreIPStr),
|
||||
"bantime": fmt.Sprintf("bantime = %s", settings.Bantime),
|
||||
"findtime": fmt.Sprintf("findtime = %s", settings.Findtime),
|
||||
"maxretry": fmt.Sprintf("maxretry = %d", settings.Maxretry),
|
||||
"banaction": fmt.Sprintf("banaction = %s", banaction),
|
||||
"banaction_allports": fmt.Sprintf("banaction_allports = %s", banactionAllports),
|
||||
"chain": fmt.Sprintf("chain = %s", chain),
|
||||
}
|
||||
if settings.BantimeRndtime != "" {
|
||||
keysToUpdate["bantime.rndtime"] = fmt.Sprintf("bantime.rndtime = %s", settings.BantimeRndtime)
|
||||
}
|
||||
defaultKeysOrder := []string{"enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "banaction", "banaction_allports", "chain"}
|
||||
if settings.BantimeRndtime != "" {
|
||||
defaultKeysOrder = append(defaultKeysOrder, "bantime.rndtime")
|
||||
}
|
||||
keysUpdated := make(map[string]bool)
|
||||
|
||||
// Always add the full banner at the start
|
||||
outputLines = append(outputLines, strings.Split(strings.TrimRight(jailLocalBanner, "\n"), "\n")...)
|
||||
|
||||
// Skip everything before [DEFAULT] section (old banner, comments, empty lines)
|
||||
foundSection := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
||||
// Found a section - stop skipping and process this line
|
||||
foundSection = true
|
||||
}
|
||||
if !foundSection {
|
||||
// Skip lines before any section (old banner, comments, empty lines)
|
||||
continue
|
||||
}
|
||||
|
||||
// Process lines after we found a section
|
||||
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
|
||||
// When user cleared bantime.rndtime, remove the line from config instead of keeping old value
|
||||
if settings.BantimeRndtime == "" {
|
||||
if matched, _ := regexp.MatchString(`^\s*bantime\.rndtime\s*=`, trimmed); matched {
|
||||
keyUpdated = true
|
||||
defaultUpdated = true
|
||||
// don't append: line is removed
|
||||
}
|
||||
}
|
||||
if !keyUpdated {
|
||||
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 := range defaultKeysOrder {
|
||||
if newValue, ok := keysToUpdate[key]; ok && !keysUpdated[key] {
|
||||
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")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeFail2banAction creates or updates the action file with the AlertCountries.
|
||||
func writeFail2banAction(callbackURL, serverID string) error {
|
||||
DebugLog("Running initial writeFail2banAction()") // entry point
|
||||
|
||||
@@ -410,52 +410,9 @@ func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath
|
||||
|
||||
// UpdateDefaultSettings implements Connector.
|
||||
func (ac *AgentConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
|
||||
// Check jail.local integrity first
|
||||
exists, hasUI, chkErr := ac.CheckJailLocalIntegrity(ctx)
|
||||
if chkErr != nil {
|
||||
config.DebugLog("Warning: could not check jail.local integrity on agent %s: %v", ac.server.Name, chkErr)
|
||||
}
|
||||
if exists && !hasUI {
|
||||
return fmt.Errorf("jail.local on agent server %s is not managed by Fail2ban-UI - skipping settings update (please migrate your jail.local manually)", ac.server.Name)
|
||||
}
|
||||
if !exists {
|
||||
config.DebugLog("jail.local does not exist on agent server %s - initializing fresh managed file", ac.server.Name)
|
||||
if err := ac.EnsureJailLocalStructure(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize jail.local on agent server %s: %w", ac.server.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = "nftables-multiport"
|
||||
}
|
||||
banactionAllports := settings.BanactionAllports
|
||||
if banactionAllports == "" {
|
||||
banactionAllports = "nftables-allports"
|
||||
}
|
||||
chain := settings.Chain
|
||||
if chain == "" {
|
||||
chain = "INPUT"
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"bantimeIncrement": settings.BantimeIncrement,
|
||||
"defaultJailEnable": settings.DefaultJailEnable,
|
||||
"ignoreip": ignoreIPStr,
|
||||
"bantime": settings.Bantime,
|
||||
"findtime": settings.Findtime,
|
||||
"maxretry": settings.Maxretry,
|
||||
"banaction": banaction,
|
||||
"banactionAllports": banactionAllports,
|
||||
"chain": chain,
|
||||
"bantimeRndtime": settings.BantimeRndtime,
|
||||
}
|
||||
return ac.put(ctx, "/v1/jails/default-settings", payload, nil)
|
||||
// Since the managed jail.local is fully owned by Fail2ban-UI, a complete
|
||||
// rewrite from current settings is always correct and self-healing.
|
||||
return ac.EnsureJailLocalStructure(ctx)
|
||||
}
|
||||
|
||||
// CheckJailLocalIntegrity implements Connector.
|
||||
|
||||
@@ -1553,222 +1553,9 @@ PYEOF
|
||||
|
||||
// UpdateDefaultSettings implements Connector.
|
||||
func (sc *SSHConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
|
||||
jailLocalPath := "/etc/fail2ban/jail.local"
|
||||
|
||||
// Check jail.local integrity first
|
||||
exists, hasUI, chkErr := sc.CheckJailLocalIntegrity(ctx)
|
||||
if chkErr != nil {
|
||||
config.DebugLog("Warning: could not check jail.local integrity on %s: %v", sc.server.Name, chkErr)
|
||||
}
|
||||
|
||||
if exists && !hasUI {
|
||||
// File belongs to the user – never overwrite
|
||||
return fmt.Errorf("jail.local on server %s is not managed by Fail2ban-UI - skipping settings update (please migrate your jail.local manually)", sc.server.Name)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// File was deleted (e.g. user finished migration) – create a fresh managed file
|
||||
config.DebugLog("jail.local does not exist on server %s - initializing fresh managed file", sc.server.Name)
|
||||
if err := sc.EnsureJailLocalStructure(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize jail.local on server %s: %w", sc.server.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read existing file
|
||||
existingContent, err := sc.runRemoteCommand(ctx, []string{"cat", jailLocalPath})
|
||||
if err != nil {
|
||||
existingContent = ""
|
||||
}
|
||||
|
||||
// Remove commented lines (lines starting with #) using sed
|
||||
if existingContent != "" {
|
||||
// Use sed to remove lines starting with # (but preserve empty lines)
|
||||
removeCommentsCmd := fmt.Sprintf("sed '/^[[:space:]]*#/d' %s", jailLocalPath)
|
||||
uncommentedContent, err := sc.runRemoteCommand(ctx, []string{removeCommentsCmd})
|
||||
if err == nil {
|
||||
existingContent = uncommentedContent
|
||||
}
|
||||
}
|
||||
|
||||
// Convert IgnoreIPs array to space-separated string
|
||||
ignoreIPStr := strings.Join(settings.IgnoreIPs, " ")
|
||||
if ignoreIPStr == "" {
|
||||
ignoreIPStr = "127.0.0.1/8 ::1"
|
||||
}
|
||||
// Set default banaction values if not set
|
||||
banactionVal := settings.Banaction
|
||||
if banactionVal == "" {
|
||||
banactionVal = "nftables-multiport"
|
||||
}
|
||||
banactionAllportsVal := settings.BanactionAllports
|
||||
if banactionAllportsVal == "" {
|
||||
banactionAllportsVal = "nftables-allports"
|
||||
}
|
||||
chainVal := settings.Chain
|
||||
if chainVal == "" {
|
||||
chainVal = "INPUT"
|
||||
}
|
||||
// Define the keys we want to update
|
||||
keysToUpdate := map[string]string{
|
||||
"enabled": fmt.Sprintf("enabled = %t", settings.DefaultJailEnable),
|
||||
"bantime.increment": fmt.Sprintf("bantime.increment = %t", settings.BantimeIncrement),
|
||||
"ignoreip": fmt.Sprintf("ignoreip = %s", ignoreIPStr),
|
||||
"bantime": fmt.Sprintf("bantime = %s", settings.Bantime),
|
||||
"findtime": fmt.Sprintf("findtime = %s", settings.Findtime),
|
||||
"maxretry": fmt.Sprintf("maxretry = %d", settings.Maxretry),
|
||||
"banaction": fmt.Sprintf("banaction = %s", banactionVal),
|
||||
"banaction_allports": fmt.Sprintf("banaction_allports = %s", banactionAllportsVal),
|
||||
"chain": fmt.Sprintf("chain = %s", chainVal),
|
||||
}
|
||||
if settings.BantimeRndtime != "" {
|
||||
keysToUpdate["bantime.rndtime"] = fmt.Sprintf("bantime.rndtime = %s", settings.BantimeRndtime)
|
||||
}
|
||||
defaultKeysOrder := []string{"enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "banaction", "banaction_allports", "chain"}
|
||||
if settings.BantimeRndtime != "" {
|
||||
defaultKeysOrder = append(defaultKeysOrder, "bantime.rndtime")
|
||||
}
|
||||
|
||||
// Parse existing content and update only specific keys in DEFAULT section
|
||||
if existingContent == "" {
|
||||
// File doesn't exist, create new one with DEFAULT section
|
||||
defaultLines := []string{"[DEFAULT]"}
|
||||
for _, key := range defaultKeysOrder {
|
||||
defaultLines = append(defaultLines, keysToUpdate[key])
|
||||
}
|
||||
defaultLines = append(defaultLines, "")
|
||||
newContent := strings.Join(defaultLines, "\n")
|
||||
cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailLocalPath, newContent)
|
||||
_, err = sc.runRemoteCommand(ctx, []string{cmd})
|
||||
return err
|
||||
}
|
||||
|
||||
// Use Python script to update only specific keys in DEFAULT section
|
||||
// Preserves banner, action_mwlg, and action override sections
|
||||
// Escape values for shell/Python
|
||||
escapeForShell := func(s string) string {
|
||||
// Escape single quotes for shell
|
||||
return strings.ReplaceAll(s, "'", "'\"'\"'")
|
||||
}
|
||||
|
||||
// Convert boolean values to Python boolean literals
|
||||
defaultJailEnablePython := "False"
|
||||
if settings.DefaultJailEnable {
|
||||
defaultJailEnablePython = "True"
|
||||
}
|
||||
bantimeIncrementPython := "False"
|
||||
if settings.BantimeIncrement {
|
||||
bantimeIncrementPython = "True"
|
||||
}
|
||||
|
||||
chainValEsc := escapeForShell(chainVal)
|
||||
bantimeRndtimeEsc := ""
|
||||
if settings.BantimeRndtime != "" {
|
||||
bantimeRndtimeEsc = escapeForShell(settings.BantimeRndtime)
|
||||
}
|
||||
updateScript := fmt.Sprintf(`python3 <<'PY'
|
||||
import re
|
||||
|
||||
jail_file = '%s'
|
||||
ignore_ip_str = '%s'
|
||||
banaction_val = '%s'
|
||||
banaction_allports_val = '%s'
|
||||
default_jail_enable_val = %s
|
||||
bantime_increment_val = %s
|
||||
bantime_val = '%s'
|
||||
findtime_val = '%s'
|
||||
maxretry_val = %d
|
||||
chain_val = '%s'
|
||||
bantime_rndtime_val = '%s'
|
||||
keys_to_update = {
|
||||
'enabled': 'enabled = ' + str(default_jail_enable_val).lower(),
|
||||
'bantime.increment': 'bantime.increment = ' + str(bantime_increment_val).lower(),
|
||||
'ignoreip': 'ignoreip = ' + ignore_ip_str,
|
||||
'bantime': 'bantime = ' + bantime_val,
|
||||
'findtime': 'findtime = ' + findtime_val,
|
||||
'maxretry': 'maxretry = ' + str(maxretry_val),
|
||||
'banaction': 'banaction = ' + banaction_val,
|
||||
'banaction_allports': 'banaction_allports = ' + banaction_allports_val,
|
||||
'chain': 'chain = ' + chain_val
|
||||
}
|
||||
if bantime_rndtime_val:
|
||||
keys_to_update['bantime.rndtime'] = 'bantime.rndtime = ' + bantime_rndtime_val
|
||||
keys_order = ['enabled', 'bantime.increment', 'ignoreip', 'bantime', 'findtime', 'maxretry', 'banaction', 'banaction_allports', 'chain']
|
||||
if bantime_rndtime_val:
|
||||
keys_order.append('bantime.rndtime')
|
||||
|
||||
try:
|
||||
with open(jail_file, 'r') as f:
|
||||
lines = f.readlines()
|
||||
except FileNotFoundError:
|
||||
lines = []
|
||||
|
||||
output_lines = []
|
||||
in_default = False
|
||||
default_section_found = False
|
||||
keys_updated = set()
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
# Preserve banner lines, action_mwlg lines, and action override lines
|
||||
is_banner = 'Fail2Ban-UI' in line or 'fail2ban-ui' in line
|
||||
is_action_mwlg = 'action_mwlg' in stripped
|
||||
is_action_override = 'action = %%(action_mwlg)s' in stripped
|
||||
|
||||
if stripped.startswith('[') and stripped.endswith(']'):
|
||||
section_name = stripped.strip('[]')
|
||||
if section_name == "DEFAULT":
|
||||
in_default = True
|
||||
default_section_found = True
|
||||
output_lines.append(line)
|
||||
else:
|
||||
in_default = False
|
||||
output_lines.append(line)
|
||||
elif in_default:
|
||||
# Check if this line is a key we need to update
|
||||
key_updated = False
|
||||
# When user cleared bantime.rndtime, remove the line from config instead of keeping old value
|
||||
if not bantime_rndtime_val and re.match(r'^\s*bantime\.rndtime\s*=', stripped):
|
||||
key_updated = True
|
||||
# don't append: line is removed
|
||||
if not key_updated:
|
||||
for key, new_value in keys_to_update.items():
|
||||
pattern = r'^\s*' + re.escape(key) + r'\s*='
|
||||
if re.match(pattern, stripped):
|
||||
output_lines.append(new_value + '\n')
|
||||
keys_updated.add(key)
|
||||
key_updated = True
|
||||
break
|
||||
if not key_updated:
|
||||
# Keep the line as-is (might be action_mwlg or other DEFAULT settings)
|
||||
output_lines.append(line)
|
||||
else:
|
||||
# Keep lines outside DEFAULT section (preserves banner, action_mwlg, action override)
|
||||
output_lines.append(line)
|
||||
|
||||
# If DEFAULT section wasn't found, create it at the beginning
|
||||
if not default_section_found:
|
||||
default_lines = ["[DEFAULT]\n"]
|
||||
for key in keys_order:
|
||||
default_lines.append(keys_to_update[key] + "\n")
|
||||
default_lines.append("\n")
|
||||
output_lines = default_lines + output_lines
|
||||
else:
|
||||
# Add any missing keys to the DEFAULT section
|
||||
for key in keys_order:
|
||||
if key not in keys_updated:
|
||||
# Find the DEFAULT section and insert after it
|
||||
for i, line in enumerate(output_lines):
|
||||
if line.strip() == "[DEFAULT]":
|
||||
output_lines.insert(i + 1, keys_to_update[key] + "\n")
|
||||
break
|
||||
|
||||
with open(jail_file, 'w') as f:
|
||||
f.writelines(output_lines)
|
||||
PY`, escapeForShell(jailLocalPath), escapeForShell(ignoreIPStr), escapeForShell(banactionVal), escapeForShell(banactionAllportsVal), defaultJailEnablePython, bantimeIncrementPython, escapeForShell(settings.Bantime), escapeForShell(settings.Findtime), settings.Maxretry, chainValEsc, bantimeRndtimeEsc)
|
||||
|
||||
_, err = sc.runRemoteCommand(ctx, []string{updateScript})
|
||||
return err
|
||||
// Since the managed jail.local is fully owned by Fail2ban-UI, a complete
|
||||
// rewrite from current settings is always correct and self-healing.
|
||||
return sc.EnsureJailLocalStructure(ctx)
|
||||
}
|
||||
|
||||
// CheckJailLocalIntegrity implements Connector.
|
||||
@@ -1811,66 +1598,8 @@ func (sc *SSHConnector) EnsureJailLocalStructure(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Build the managed jail.local content
|
||||
settings := config.GetSettings()
|
||||
|
||||
ignoreIPStr := strings.Join(settings.IgnoreIPs, " ")
|
||||
if ignoreIPStr == "" {
|
||||
ignoreIPStr = "127.0.0.1/8 ::1"
|
||||
}
|
||||
banactionVal := settings.Banaction
|
||||
if banactionVal == "" {
|
||||
banactionVal = "nftables-multiport"
|
||||
}
|
||||
banactionAllportsVal := settings.BanactionAllports
|
||||
if banactionAllportsVal == "" {
|
||||
banactionAllportsVal = "nftables-allports"
|
||||
}
|
||||
chainVal := settings.Chain
|
||||
if chainVal == "" {
|
||||
chainVal = "INPUT"
|
||||
}
|
||||
|
||||
banner := config.JailLocalBanner()
|
||||
|
||||
defaultSection := fmt.Sprintf(`[DEFAULT]
|
||||
enabled = %t
|
||||
bantime.increment = %t
|
||||
ignoreip = %s
|
||||
bantime = %s
|
||||
findtime = %s
|
||||
maxretry = %d
|
||||
banaction = %s
|
||||
banaction_allports = %s
|
||||
chain = %s
|
||||
|
||||
`,
|
||||
settings.DefaultJailEnable,
|
||||
settings.BantimeIncrement,
|
||||
ignoreIPStr,
|
||||
settings.Bantime,
|
||||
settings.Findtime,
|
||||
settings.Maxretry,
|
||||
banactionVal,
|
||||
banactionAllportsVal,
|
||||
chainVal,
|
||||
)
|
||||
if settings.BantimeRndtime != "" {
|
||||
defaultSection += fmt.Sprintf("bantime.rndtime = %s\n", settings.BantimeRndtime)
|
||||
}
|
||||
defaultSection += "\n"
|
||||
|
||||
actionMwlgConfig := `# Custom Fail2Ban action for UI callbacks
|
||||
action_mwlg = %(action_)s
|
||||
ui-custom-action[logpath="%(logpath)s", chain="%(chain)s"]
|
||||
|
||||
`
|
||||
|
||||
actionOverride := `# Custom Fail2Ban action applied by fail2ban-ui
|
||||
action = %(action_mwlg)s
|
||||
`
|
||||
|
||||
content := banner + defaultSection + actionMwlgConfig + actionOverride
|
||||
// Build content using the shared helper (single source of truth)
|
||||
content := config.BuildJailLocalContent()
|
||||
|
||||
// Escape single quotes for safe use in a single-quoted heredoc
|
||||
escaped := strings.ReplaceAll(content, "'", "'\"'\"'")
|
||||
|
||||
@@ -1064,214 +1064,8 @@ 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.
|
||||
// UpdateDefaultSettingsLocal rewrites /etc/fail2ban/jail.local with the current settings.
|
||||
func UpdateDefaultSettingsLocal(settings config.AppSettings) error {
|
||||
config.DebugLog("UpdateDefaultSettingsLocal called")
|
||||
localPath := "/etc/fail2ban/jail.local"
|
||||
|
||||
// Check jail.local integrity first
|
||||
var existingContent string
|
||||
fileExists := false
|
||||
if content, err := os.ReadFile(localPath); err == nil {
|
||||
existingContent = string(content)
|
||||
fileExists = len(strings.TrimSpace(existingContent)) > 0
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to read jail.local: %w", err)
|
||||
}
|
||||
|
||||
hasUIAction := strings.Contains(existingContent, "ui-custom-action")
|
||||
|
||||
if fileExists && !hasUIAction {
|
||||
// File belongs to the user – never overwrite
|
||||
return fmt.Errorf("jail.local is not managed by Fail2ban-UI - skipping settings update (please migrate your jail.local manually)")
|
||||
}
|
||||
|
||||
if !fileExists {
|
||||
// File was deleted (e.g. user finished migration); create a fresh managed file
|
||||
config.DebugLog("jail.local does not exist - initializing fresh managed file")
|
||||
if err := config.EnsureJailLocalStructure(); err != nil {
|
||||
return fmt.Errorf("failed to initialize jail.local: %w", err)
|
||||
}
|
||||
// Re-read the freshly created file
|
||||
if content, err := os.ReadFile(localPath); err == nil {
|
||||
existingContent = string(content)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = "nftables-multiport"
|
||||
}
|
||||
banactionAllports := settings.BanactionAllports
|
||||
if banactionAllports == "" {
|
||||
banactionAllports = "nftables-allports"
|
||||
}
|
||||
chain := settings.Chain
|
||||
if chain == "" {
|
||||
chain = "INPUT"
|
||||
}
|
||||
// Define the keys we want to update
|
||||
keysToUpdate := map[string]string{
|
||||
"enabled": fmt.Sprintf("enabled = %t", settings.DefaultJailEnable),
|
||||
"bantime.increment": fmt.Sprintf("bantime.increment = %t", settings.BantimeIncrement),
|
||||
"ignoreip": fmt.Sprintf("ignoreip = %s", ignoreIPStr),
|
||||
"bantime": fmt.Sprintf("bantime = %s", settings.Bantime),
|
||||
"findtime": fmt.Sprintf("findtime = %s", settings.Findtime),
|
||||
"maxretry": fmt.Sprintf("maxretry = %d", settings.Maxretry),
|
||||
"banaction": fmt.Sprintf("banaction = %s", banaction),
|
||||
"banaction_allports": fmt.Sprintf("banaction_allports = %s", banactionAllports),
|
||||
"chain": fmt.Sprintf("chain = %s", chain),
|
||||
}
|
||||
if settings.BantimeRndtime != "" {
|
||||
keysToUpdate["bantime.rndtime"] = fmt.Sprintf("bantime.rndtime = %s", settings.BantimeRndtime)
|
||||
}
|
||||
defaultKeysOrder := []string{"enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "banaction", "banaction_allports", "chain"}
|
||||
if settings.BantimeRndtime != "" {
|
||||
defaultKeysOrder = append(defaultKeysOrder, "bantime.rndtime")
|
||||
}
|
||||
|
||||
// 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 banner and DEFAULT section
|
||||
var newLines []string
|
||||
newLines = append(newLines, strings.Split(strings.TrimRight(config.JailLocalBanner(), "\n"), "\n")...)
|
||||
newLines = append(newLines, "[DEFAULT]")
|
||||
for _, key := range defaultKeysOrder {
|
||||
newLines = append(newLines, keysToUpdate[key])
|
||||
}
|
||||
newLines = append(newLines, "")
|
||||
newContent := strings.Join(newLines, "\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 banner and 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
|
||||
|
||||
// Always add the full banner at the start
|
||||
outputLines = append(outputLines, strings.Split(strings.TrimRight(config.JailLocalBanner(), "\n"), "\n")...)
|
||||
|
||||
// Skip everything before [DEFAULT] section (old banner, comments, empty lines)
|
||||
foundSection := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
||||
// Found a section - stop skipping and process this line
|
||||
foundSection = true
|
||||
}
|
||||
if !foundSection {
|
||||
// Skip lines before any section (old banner, comments, empty lines)
|
||||
continue
|
||||
}
|
||||
|
||||
// Process lines after we found a section
|
||||
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
|
||||
// When user cleared bantime.rndtime, remove the line from config instead of keeping old value
|
||||
if settings.BantimeRndtime == "" {
|
||||
if matched, _ := regexp.MatchString(`^\s*bantime\.rndtime\s*=`, trimmed); matched {
|
||||
keyUpdated = true
|
||||
// don't append: line is removed
|
||||
}
|
||||
}
|
||||
if !keyUpdated {
|
||||
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 defaultKeysOrder {
|
||||
defaultLines = append(defaultLines, keysToUpdate[key])
|
||||
}
|
||||
defaultLines = append(defaultLines, "")
|
||||
outputLines = append(defaultLines, outputLines...)
|
||||
} else {
|
||||
// Add any missing keys to the DEFAULT section
|
||||
for _, key := range defaultKeysOrder {
|
||||
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
|
||||
return config.EnsureJailLocalStructure()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user