package fail2ban import ( "bufio" "fmt" "os" "path/filepath" "regexp" "strings" "sync" "github.com/swissmakers/fail2ban-ui/internal/config" ) var ( migrationOnce sync.Once ) // ensureJailLocalFile ensures that a .local file exists for the given jail. // If .local doesn't exist, it copies from .conf if available, or creates a minimal section. func ensureJailLocalFile(jailName string) error { // Validate jail name - must not be empty jailName = strings.TrimSpace(jailName) if jailName == "" { return fmt.Errorf("jail name cannot be empty") } jailDPath := "/etc/fail2ban/jail.d" localPath := filepath.Join(jailDPath, jailName+".local") confPath := filepath.Join(jailDPath, jailName+".conf") // Check if .local already exists if _, err := os.Stat(localPath); err == nil { config.DebugLog("Jail .local file already exists: %s", localPath) return nil } // Try to copy from .conf if it exists if _, err := os.Stat(confPath); err == nil { config.DebugLog("Copying jail config from .conf to .local: %s -> %s", confPath, localPath) content, err := os.ReadFile(confPath) if err != nil { return fmt.Errorf("failed to read jail .conf file %s: %w", confPath, err) } if err := os.WriteFile(localPath, content, 0644); err != nil { return fmt.Errorf("failed to write jail .local file %s: %w", localPath, err) } config.DebugLog("Successfully copied jail config to .local file") return nil } // Neither exists, create minimal section config.DebugLog("Creating minimal jail .local file: %s", localPath) if err := os.MkdirAll(jailDPath, 0755); err != nil { return fmt.Errorf("failed to create jail.d directory: %w", err) } minimalContent := fmt.Sprintf("[%s]\n", jailName) if err := os.WriteFile(localPath, []byte(minimalContent), 0644); err != nil { return fmt.Errorf("failed to create jail .local file %s: %w", localPath, err) } config.DebugLog("Successfully created minimal jail .local file") return nil } // readJailConfigWithFallback reads jail config from .local first, then falls back to .conf. func readJailConfigWithFallback(jailName string) (string, error) { // Validate jail name - must not be empty jailName = strings.TrimSpace(jailName) if jailName == "" { return "", fmt.Errorf("jail name cannot be empty") } jailDPath := "/etc/fail2ban/jail.d" localPath := filepath.Join(jailDPath, jailName+".local") confPath := filepath.Join(jailDPath, jailName+".conf") // Try .local first if content, err := os.ReadFile(localPath); err == nil { config.DebugLog("Reading jail config from .local: %s", localPath) return string(content), nil } // Fallback to .conf if content, err := os.ReadFile(confPath); err == nil { config.DebugLog("Reading jail config from .conf: %s", confPath) return string(content), nil } // Neither exists, return empty section config.DebugLog("Neither .local nor .conf exists for jail %s, returning empty section", jailName) return fmt.Sprintf("[%s]\n", jailName), nil } // GetAllJails reads jails from /etc/fail2ban/jail.local (DEFAULT only) and /etc/fail2ban/jail.d directory. // Automatically migrates legacy jails from jail.local to jail.d on first call. func GetAllJails() ([]JailInfo, error) { // Run migration once if needed migrationOnce.Do(func() { if err := MigrateJailsToJailD(); err != nil { config.DebugLog("Migration warning: %v", err) } }) var jails []JailInfo // Parse only DEFAULT section from jail.local (skip other jails) localPath := "/etc/fail2ban/jail.local" if _, err := os.Stat(localPath); err == nil { defaultJails, err := parseJailConfigFileOnlyDefault(localPath) if err == nil { jails = append(jails, defaultJails...) } } // Parse jails from jail.d directory // Prefer .local files over .conf files (if both exist for same jail, use .local) jailDPath := "/etc/fail2ban/jail.d" if _, err := os.Stat(jailDPath); err == nil { files, err := os.ReadDir(jailDPath) if err == nil { // Track which jails we've already processed (from .local files) processedJails := make(map[string]bool) // First pass: process all .local files for _, f := range files { if !f.IsDir() && filepath.Ext(f.Name()) == ".local" { jailName := strings.TrimSuffix(f.Name(), ".local") // Skip files that start with . (like .local) - these are invalid if jailName == "" || strings.HasPrefix(f.Name(), ".") { config.DebugLog("Skipping invalid jail file: %s", f.Name()) continue } fullPath := filepath.Join(jailDPath, f.Name()) dJails, err := parseJailConfigFile(fullPath) if err == nil { for _, jail := range dJails { // Skip jails with empty names if jail.JailName != "" { jails = append(jails, jail) processedJails[jail.JailName] = true } } } } } // Second pass: process .conf files that don't have corresponding .local files for _, f := range files { if !f.IsDir() && filepath.Ext(f.Name()) == ".conf" { jailName := strings.TrimSuffix(f.Name(), ".conf") // Skip files that start with . (like .conf) - these are invalid if jailName == "" || strings.HasPrefix(f.Name(), ".") { config.DebugLog("Skipping invalid jail file: %s", f.Name()) continue } // Only process if we haven't already processed this jail from a .local file if !processedJails[jailName] { fullPath := filepath.Join(jailDPath, f.Name()) dJails, err := parseJailConfigFile(fullPath) if err == nil { jails = append(jails, dJails...) } } } } } } return jails, nil } // parseJailConfigFile parses a jail configuration file and returns a slice of JailInfo. // It assumes each jail section is defined by [JailName] and that an "enabled" line may exist. func parseJailConfigFile(path string) ([]JailInfo, error) { var jails []JailInfo file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() scanner := bufio.NewScanner(file) var currentJail string // Sections that should be ignored (not jails) ignoredSections := map[string]bool{ "DEFAULT": true, "INCLUDES": true, } // default value is true if "enabled" is missing; we set it for each section. enabled := true for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { // When a new section starts, save the previous jail if exists. if currentJail != "" && !ignoredSections[currentJail] { jails = append(jails, JailInfo{ JailName: currentJail, Enabled: enabled, }) } // Start a new jail section. currentJail = strings.TrimSpace(strings.Trim(line, "[]")) // Skip empty jail names (e.g., from malformed config files with []) if currentJail == "" { currentJail = "" // Reset to empty to skip this section enabled = true continue } // Reset to default for the new section. enabled = true } else if strings.HasPrefix(strings.ToLower(line), "enabled") { // Only process enabled line if we have a valid jail name if currentJail != "" { // Expect format: enabled = true/false parts := strings.Split(line, "=") if len(parts) == 2 { value := strings.TrimSpace(parts[1]) enabled = strings.EqualFold(value, "true") } } } } // Add the final jail if one exists. if currentJail != "" && !ignoredSections[currentJail] { jails = append(jails, JailInfo{ JailName: currentJail, Enabled: enabled, }) } return jails, scanner.Err() } // UpdateJailEnabledStates updates the enabled state for each jail based on the provided updates map. // Updates only the corresponding .local file in /etc/fail2ban/jail.d/ for each jail. // Creates .local file by copying from .conf if needed, preserving original .conf files. func UpdateJailEnabledStates(updates map[string]bool) error { config.DebugLog("UpdateJailEnabledStates called with %d updates: %+v", len(updates), updates) jailDPath := "/etc/fail2ban/jail.d" // Ensure jail.d directory exists if err := os.MkdirAll(jailDPath, 0755); err != nil { return fmt.Errorf("failed to create jail.d directory: %w", err) } // Update each jail in its own .local file for jailName, enabled := range updates { // Validate jail name - skip empty or invalid names jailName = strings.TrimSpace(jailName) if jailName == "" { config.DebugLog("Skipping empty jail name in updates map") continue } config.DebugLog("Processing jail: %s, enabled: %t", jailName, enabled) // Ensure .local file exists (copy from .conf if needed) if err := ensureJailLocalFile(jailName); err != nil { return fmt.Errorf("failed to ensure .local file for jail %s: %w", jailName, err) } jailFilePath := filepath.Join(jailDPath, jailName+".local") config.DebugLog("Jail file path: %s", jailFilePath) // Read existing .local file content, err := os.ReadFile(jailFilePath) if err != nil { return fmt.Errorf("failed to read jail .local file %s: %w", jailFilePath, err) } var lines []string if len(content) > 0 { lines = strings.Split(string(content), "\n") } else { // Create new file with jail section lines = []string{fmt.Sprintf("[%s]", jailName)} } // Update or add enabled line var outputLines []string var foundEnabled bool var currentJail string for _, line := range lines { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { currentJail = strings.Trim(trimmed, "[]") outputLines = append(outputLines, line) } else if strings.HasPrefix(strings.ToLower(trimmed), "enabled") { if currentJail == jailName { outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) foundEnabled = true } else { outputLines = append(outputLines, line) } } else { outputLines = append(outputLines, line) } } // If enabled line not found, add it after the jail section header if !foundEnabled { var newLines []string for i, line := range outputLines { newLines = append(newLines, line) if strings.TrimSpace(line) == fmt.Sprintf("[%s]", jailName) { // Insert enabled line after the section header newLines = append(newLines, fmt.Sprintf("enabled = %t", enabled)) // Add remaining lines if i+1 < len(outputLines) { newLines = append(newLines, outputLines[i+1:]...) } break } } if len(newLines) > len(outputLines) { outputLines = newLines } else { // Fallback: append at the end outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) } } // Write updated content newContent := strings.Join(outputLines, "\n") if !strings.HasSuffix(newContent, "\n") { newContent += "\n" } if err := os.WriteFile(jailFilePath, []byte(newContent), 0644); err != nil { return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) } config.DebugLog("Updated jail %s: enabled = %t (file: %s)", jailName, enabled, jailFilePath) } return nil } // MigrateJailsToJailD migrates all non-DEFAULT jails from jail.local to individual files in jail.d/. // Creates a backup of jail.local before migration. If a jail already exists in jail.d, jail.local takes precedence. func MigrateJailsToJailD() error { localPath := "/etc/fail2ban/jail.local" jailDPath := "/etc/fail2ban/jail.d" // Check if jail.local exists if _, err := os.Stat(localPath); os.IsNotExist(err) { return nil // Nothing to migrate } // Read jail.local content content, err := os.ReadFile(localPath) if err != nil { return fmt.Errorf("failed to read jail.local: %w", err) } // Parse content to extract sections sections, defaultContent, err := parseJailSections(string(content)) if err != nil { return fmt.Errorf("failed to parse jail.local: %w", err) } // If no non-DEFAULT jails found, nothing to migrate if len(sections) == 0 { return nil } // Create backup of jail.local backupPath := localPath + ".backup." + fmt.Sprintf("%d", os.Getpid()) if err := os.WriteFile(backupPath, content, 0644); err != nil { return fmt.Errorf("failed to create backup: %w", err) } config.DebugLog("Created backup of jail.local at %s", backupPath) // Ensure jail.d directory exists if err := os.MkdirAll(jailDPath, 0755); err != nil { return fmt.Errorf("failed to create jail.d directory: %w", err) } // Write each jail to its own file in jail.d/ for jailName, jailContent := range sections { jailFilePath := filepath.Join(jailDPath, jailName+".conf") // Check if file already exists if _, err := os.Stat(jailFilePath); err == nil { // File exists - jail.local takes precedence, so overwrite config.DebugLog("Overwriting existing jail file %s with content from jail.local", jailFilePath) } // Write jail content to file if err := os.WriteFile(jailFilePath, []byte(jailContent), 0644); err != nil { return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) } } // Rewrite jail.local with only DEFAULT section newLocalContent := defaultContent if !strings.HasSuffix(newLocalContent, "\n") { newLocalContent += "\n" } if err := os.WriteFile(localPath, []byte(newLocalContent), 0644); err != nil { return fmt.Errorf("failed to rewrite jail.local: %w", err) } config.DebugLog("Migration completed: moved %d jails to jail.d/", len(sections)) return nil } // parseJailSections parses jail.local content and returns: // - map of jail name to jail content (excluding DEFAULT and INCLUDES) // - DEFAULT section content func parseJailSections(content string) (map[string]string, string, error) { sections := make(map[string]string) var defaultContent strings.Builder // Sections that should be ignored (not jails) ignoredSections := map[string]bool{ "DEFAULT": true, "INCLUDES": true, } scanner := bufio.NewScanner(strings.NewReader(content)) var currentSection string var currentContent strings.Builder inDefault := false for scanner.Scan() { line := scanner.Text() trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { // Save previous section if currentSection != "" { sectionContent := strings.TrimSpace(currentContent.String()) if inDefault { defaultContent.WriteString(sectionContent) if !strings.HasSuffix(sectionContent, "\n") { defaultContent.WriteString("\n") } } else if !ignoredSections[currentSection] { // Only save if it's not an ignored section sections[currentSection] = sectionContent } } // Start new section currentSection = strings.Trim(trimmed, "[]") currentContent.Reset() currentContent.WriteString(line) currentContent.WriteString("\n") inDefault = (currentSection == "DEFAULT") } else { currentContent.WriteString(line) currentContent.WriteString("\n") } } // Save final section if currentSection != "" { sectionContent := strings.TrimSpace(currentContent.String()) if inDefault { defaultContent.WriteString(sectionContent) } else if !ignoredSections[currentSection] { // Only save if it's not an ignored section sections[currentSection] = sectionContent } } return sections, defaultContent.String(), scanner.Err() } // parseJailConfigFileOnlyDefault parses only the DEFAULT section from a jail config file. func parseJailConfigFileOnlyDefault(path string) ([]JailInfo, error) { var jails []JailInfo file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() scanner := bufio.NewScanner(file) // We scan through the file but don't return any jails from DEFAULT section // This function exists to validate the file can be read for scanner.Scan() { // Just scan through - we don't need to parse anything for DEFAULT } // We don't return DEFAULT as a jail, but we could if needed // For now, we only return jails from DEFAULT if they're explicitly listed (unlikely) return jails, scanner.Err() } // GetJailConfig reads the full jail configuration from /etc/fail2ban/jail.d/{jailName}.local // Falls back to .conf if .local doesn't exist. func GetJailConfig(jailName string) (string, error) { // Validate jail name jailName = strings.TrimSpace(jailName) if jailName == "" { return "", fmt.Errorf("jail name cannot be empty") } config.DebugLog("GetJailConfig called for jail: %s", jailName) content, err := readJailConfigWithFallback(jailName) if err != nil { config.DebugLog("Failed to read jail config: %v", err) return "", fmt.Errorf("failed to read jail config for %s: %w", jailName, err) } config.DebugLog("Jail config read successfully, length: %d", len(content)) return content, nil } // SetJailConfig writes the full jail configuration to /etc/fail2ban/jail.d/{jailName}.local // Ensures .local file exists first by copying from .conf if needed. func SetJailConfig(jailName, content string) error { // Validate jail name jailName = strings.TrimSpace(jailName) if jailName == "" { return fmt.Errorf("jail name cannot be empty") } config.DebugLog("SetJailConfig called for jail: %s, content length: %d", jailName, len(content)) jailDPath := "/etc/fail2ban/jail.d" // Ensure jail.d directory exists if err := os.MkdirAll(jailDPath, 0755); err != nil { config.DebugLog("Failed to create jail.d directory: %v", err) return fmt.Errorf("failed to create jail.d directory: %w", err) } config.DebugLog("jail.d directory ensured") // Ensure .local file exists (copy from .conf if needed) if err := ensureJailLocalFile(jailName); err != nil { return fmt.Errorf("failed to ensure .local file for jail %s: %w", jailName, err) } // Validate and fix the jail section header // The content might start with comments, so we need to find the section header trimmed := strings.TrimSpace(content) if trimmed == "" { config.DebugLog("Content is empty, creating minimal jail config") content = fmt.Sprintf("[%s]\n", jailName) } else { expectedSection := fmt.Sprintf("[%s]", jailName) lines := strings.Split(content, "\n") sectionFound := false sectionIndex := -1 var sectionIndices []int // Find all section headers in the content for i, line := range lines { trimmedLine := strings.TrimSpace(line) if strings.HasPrefix(trimmedLine, "[") && strings.HasSuffix(trimmedLine, "]") { sectionIndices = append(sectionIndices, i) if trimmedLine == expectedSection { if !sectionFound { sectionIndex = i sectionFound = true config.DebugLog("Correct section header found at line %d", i) } else { config.DebugLog("Duplicate correct section header found at line %d, will remove", i) } } else { config.DebugLog("Incorrect section header found at line %d: %s (expected %s)", i, trimmedLine, expectedSection) if sectionIndex == -1 { sectionIndex = i } } } } // Remove duplicate section headers (keep only the first correct one) if len(sectionIndices) > 1 { config.DebugLog("Found %d section headers, removing duplicates", len(sectionIndices)) var newLines []string keptFirst := false for i, line := range lines { trimmedLine := strings.TrimSpace(line) isSectionHeader := strings.HasPrefix(trimmedLine, "[") && strings.HasSuffix(trimmedLine, "]") if isSectionHeader { if !keptFirst && trimmedLine == expectedSection { // Keep the first correct section header newLines = append(newLines, expectedSection) keptFirst = true config.DebugLog("Keeping section header at line %d", i) } else { // Skip duplicate or incorrect section headers config.DebugLog("Removing duplicate/incorrect section header at line %d: %s", i, trimmedLine) continue } } else { newLines = append(newLines, line) } } lines = newLines } if !sectionFound { if sectionIndex >= 0 { // Replace incorrect section header config.DebugLog("Replacing incorrect section header at line %d", sectionIndex) lines[sectionIndex] = expectedSection } else { // No section header found, prepend it config.DebugLog("No section header found, prepending %s", expectedSection) lines = append([]string{expectedSection}, lines...) } content = strings.Join(lines, "\n") } else { // Section header is correct, but we may have removed duplicates content = strings.Join(lines, "\n") } } jailFilePath := filepath.Join(jailDPath, jailName+".local") config.DebugLog("Writing jail config to: %s", jailFilePath) if err := os.WriteFile(jailFilePath, []byte(content), 0644); err != nil { config.DebugLog("Failed to write jail config: %v", err) return fmt.Errorf("failed to write jail config for %s: %w", jailName, err) } config.DebugLog("Jail config written successfully to .local file") return nil } // TestLogpath tests a logpath pattern and returns matching files. // Supports wildcards/glob patterns (e.g., /var/log/*.log) and directory paths. // This function tests the path as-is without variable resolution. func TestLogpath(logpath string) ([]string, error) { if logpath == "" { return []string{}, nil } // Trim whitespace logpath = strings.TrimSpace(logpath) // Check if it's a glob pattern (contains *, ?, or [) hasWildcard := strings.ContainsAny(logpath, "*?[") var matches []string if hasWildcard { // Use filepath.Glob for pattern matching matched, err := filepath.Glob(logpath) if err != nil { return nil, fmt.Errorf("invalid glob pattern: %w", err) } matches = matched } else { // Check if it's a directory info, err := os.Stat(logpath) if err != nil { if os.IsNotExist(err) { return []string{}, nil // Path doesn't exist, return empty } return nil, fmt.Errorf("failed to stat path: %w", err) } if info.IsDir() { // List files in directory entries, err := os.ReadDir(logpath) if err != nil { return nil, fmt.Errorf("failed to read directory: %w", err) } for _, entry := range entries { if !entry.IsDir() { fullPath := filepath.Join(logpath, entry.Name()) matches = append(matches, fullPath) } } } else { // It's a file, return it matches = []string{logpath} } } return matches, nil } // TestLogpathWithResolution resolves variables in logpath and tests the resolved path. // Returns the original path, resolved path, matching files, and any error. func TestLogpathWithResolution(logpath string) (originalPath, resolvedPath string, files []string, err error) { originalPath = strings.TrimSpace(logpath) if originalPath == "" { return originalPath, "", []string{}, nil } // Resolve variables resolvedPath, err = ResolveLogpathVariables(originalPath) if err != nil { return originalPath, "", nil, fmt.Errorf("failed to resolve logpath variables: %w", err) } // If resolution didn't change the path, resolvedPath will be the same if resolvedPath == "" { resolvedPath = originalPath } // Test the resolved path files, err = TestLogpath(resolvedPath) if err != nil { return originalPath, resolvedPath, nil, fmt.Errorf("failed to test logpath: %w", err) } return originalPath, resolvedPath, files, nil } // ExtractLogpathFromJailConfig extracts the logpath value from jail configuration content. func ExtractLogpathFromJailConfig(jailContent string) string { scanner := bufio.NewScanner(strings.NewReader(jailContent)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(strings.ToLower(line), "logpath") { parts := strings.SplitN(line, "=", 2) if len(parts) == 2 { return strings.TrimSpace(parts[1]) } } } return "" } // ExtractFilterFromJailConfig extracts the filter name from jail configuration content. // Handles formats like: filter = sshd, filter = sshd[mode=aggressive], etc. // Returns the base filter name (without parameters in brackets). func ExtractFilterFromJailConfig(jailContent string) string { scanner := bufio.NewScanner(strings.NewReader(jailContent)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Skip comments if strings.HasPrefix(line, "#") { continue } if strings.HasPrefix(strings.ToLower(line), "filter") { parts := strings.SplitN(line, "=", 2) if len(parts) == 2 { filterValue := strings.TrimSpace(parts[1]) // Extract base filter name (before [ if present) if idx := strings.Index(filterValue, "["); idx >= 0 { filterValue = filterValue[:idx] } return strings.TrimSpace(filterValue) } } } 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 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 []string{"bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "destemail", "banaction", "banaction_allports"} { 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 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", "banaction", "banaction_allports"} { 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 }