package fail2ban import ( "bufio" "fmt" "os" "path/filepath" "regexp" "strings" "sync" "time" "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. // Returns (content, filePath, error) func readJailConfigWithFallback(jailName string) (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), localPath, nil } // Fallback to .conf if content, err := os.ReadFile(confPath); err == nil { config.DebugLog("Reading jail config from .conf: %s", confPath) return string(content), confPath, nil } // Neither exists, return empty section with .local path (will be created on save) config.DebugLog("Neither .local nor .conf exists for jail %s, returning empty section", jailName) return fmt.Sprintf("[%s]\n", jailName), localPath, nil } // ValidateJailName validates a jail name format. // Returns an error if the name is invalid (empty, contains invalid characters, or is reserved). func ValidateJailName(name string) error { name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("jail name cannot be empty") } // Reserved names that should not be used reservedNames := map[string]bool{ "DEFAULT": true, "INCLUDES": true, } if reservedNames[strings.ToUpper(name)] { return fmt.Errorf("jail name '%s' is reserved and cannot be used", name) } // Check for invalid characters (only alphanumeric, dash, underscore allowed) invalidChars := regexp.MustCompile(`[^a-zA-Z0-9_-]`) if invalidChars.MatchString(name) { return fmt.Errorf("jail name '%s' contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed", name) } return nil } // ListJailFiles lists all jail config files in the specified directory. // Returns full paths to .local and .conf files. func ListJailFiles(directory string) ([]string, error) { var files []string entries, err := os.ReadDir(directory) if err != nil { return nil, fmt.Errorf("failed to read jail directory %s: %w", directory, err) } for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() // Skip hidden files and invalid names if strings.HasPrefix(name, ".") { continue } // Only include .local and .conf files if strings.HasSuffix(name, ".local") || strings.HasSuffix(name, ".conf") { fullPath := filepath.Join(directory, name) files = append(files, fullPath) } } return files, nil } // DiscoverJailsFromFiles discovers all jails from the filesystem. // Reads from /etc/fail2ban/jail.d/ directory, preferring .local files over .conf files. // Returns all jails found (enabled and disabled). func DiscoverJailsFromFiles() ([]JailInfo, error) { jailDPath := "/etc/fail2ban/jail.d" // Check if directory exists if _, err := os.Stat(jailDPath); os.IsNotExist(err) { // Directory doesn't exist, return empty list return []JailInfo{}, nil } // List all jail files files, err := ListJailFiles(jailDPath) if err != nil { return nil, err } var allJails []JailInfo processedFiles := make(map[string]bool) // Track base names to avoid duplicates processedJails := make(map[string]bool) // Track jail names to avoid duplicates // First pass: process all .local files for _, filePath := range files { if !strings.HasSuffix(filePath, ".local") { continue } filename := filepath.Base(filePath) baseName := strings.TrimSuffix(filename, ".local") if baseName == "" { continue } // Skip if we've already processed this base name if processedFiles[baseName] { continue } processedFiles[baseName] = true // Parse the file jails, err := parseJailConfigFile(filePath) if err != nil { config.DebugLog("Failed to parse jail file %s: %v", filePath, err) continue } // Add jails from this file for _, jail := range jails { if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { allJails = append(allJails, jail) processedJails[jail.JailName] = true } } } // Second pass: process .conf files that don't have corresponding .local files for _, filePath := range files { if !strings.HasSuffix(filePath, ".conf") { continue } filename := filepath.Base(filePath) baseName := strings.TrimSuffix(filename, ".conf") if baseName == "" { continue } // Skip if we've already processed a .local file with the same base name if processedFiles[baseName] { continue } processedFiles[baseName] = true // Parse the file jails, err := parseJailConfigFile(filePath) if err != nil { config.DebugLog("Failed to parse jail file %s: %v", filePath, err) continue } // Add jails from this file for _, jail := range jails { if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { allJails = append(allJails, jail) processedJails[jail.JailName] = true } } } return allJails, nil } // CreateJail creates a new jail in jail.d/{name}.local. // If the jail already exists, it will be overwritten. func CreateJail(jailName, content string) error { if err := ValidateJailName(jailName); err != nil { return err } jailDPath := "/etc/fail2ban/jail.d" localPath := filepath.Join(jailDPath, jailName+".local") // Ensure directory exists if err := os.MkdirAll(jailDPath, 0755); err != nil { return fmt.Errorf("failed to create jail.d directory: %w", err) } // Validate content starts with correct section header trimmed := strings.TrimSpace(content) expectedSection := fmt.Sprintf("[%s]", jailName) if !strings.HasPrefix(trimmed, expectedSection) { // Prepend the section header if missing content = expectedSection + "\n" + content } // Write the file if err := os.WriteFile(localPath, []byte(content), 0644); err != nil { return fmt.Errorf("failed to create jail file %s: %w", localPath, err) } config.DebugLog("Created jail file: %s", localPath) return nil } // DeleteJail deletes a jail's .local and .conf files from jail.d/ if they exist. // Both files are deleted to ensure complete removal of the jail configuration. func DeleteJail(jailName string) error { if err := ValidateJailName(jailName); err != nil { return err } jailDPath := "/etc/fail2ban/jail.d" localPath := filepath.Join(jailDPath, jailName+".local") confPath := filepath.Join(jailDPath, jailName+".conf") var deletedFiles []string var lastErr error // Delete .local file if it exists if _, err := os.Stat(localPath); err == nil { if err := os.Remove(localPath); err != nil { lastErr = fmt.Errorf("failed to delete jail file %s: %w", localPath, err) } else { deletedFiles = append(deletedFiles, localPath) config.DebugLog("Deleted jail file: %s", localPath) } } // Delete .conf file if it exists if _, err := os.Stat(confPath); err == nil { if err := os.Remove(confPath); err != nil { lastErr = fmt.Errorf("failed to delete jail file %s: %w", confPath, err) } else { deletedFiles = append(deletedFiles, confPath) config.DebugLog("Deleted jail file: %s", confPath) } } // If no files were deleted and no error occurred, it means neither file existed if len(deletedFiles) == 0 && lastErr == nil { return fmt.Errorf("jail file %s or %s does not exist", localPath, confPath) } // Return the last error if any occurred if lastErr != nil { return lastErr } return 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. // Now uses DiscoverJailsFromFiles() for file-based discovery. func GetAllJails() ([]JailInfo, error) { // Run migration once if needed migrationOnce.Do(func() { if err := MigrateJailsToJailD(); err != nil { config.DebugLog("Migration warning: %v", err) } }) // Discover jails from filesystem jails, err := DiscoverJailsFromFiles() if err != nil { return nil, fmt.Errorf("failed to discover jails from files: %w", err) } 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() } // parseJailSectionsUncommented parses jail.local content and returns: // - map of jail name to jail content (excluding DEFAULT, INCLUDES, and commented sections) // - DEFAULT section content (including commented lines) // Only extracts non-commented jail sections func parseJailSectionsUncommented(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 sectionIsCommented := false for scanner.Scan() { line := scanner.Text() trimmed := strings.TrimSpace(line) // Check if this is a section header if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { // Check if the section is commented originalLine := strings.TrimSpace(line) isCommented := strings.HasPrefix(originalLine, "#") // Save previous section if currentSection != "" { sectionContent := strings.TrimSpace(currentContent.String()) if inDefault { // Always include DEFAULT section content (even if commented) defaultContent.WriteString(sectionContent) if !strings.HasSuffix(sectionContent, "\n") { defaultContent.WriteString("\n") } } else if !ignoredSections[currentSection] && !sectionIsCommented { // Only save non-commented, non-ignored sections sections[currentSection] = sectionContent } } // Start new section if isCommented { // Remove the # from the section name sectionName := strings.Trim(trimmed, "[]") if strings.HasPrefix(sectionName, "#") { sectionName = strings.TrimSpace(strings.TrimPrefix(sectionName, "#")) } currentSection = sectionName sectionIsCommented = true } else { currentSection = strings.Trim(trimmed, "[]") sectionIsCommented = false } 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] && !sectionIsCommented { // Only save if it's not an ignored section and not commented sections[currentSection] = sectionContent } } return sections, defaultContent.String(), scanner.Err() } // MigrateJailsFromJailLocal migrates non-commented jail sections from jail.local to jail.d/*.local files. // This should be called when a server is added or enabled to migrate legacy jails. func MigrateJailsFromJailLocal() 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 non-commented sections sections, defaultContent, err := parseJailSectionsUncommented(string(content)) if err != nil { return fmt.Errorf("failed to parse jail.local: %w", err) } // If no non-commented, non-DEFAULT jails found, nothing to migrate if len(sections) == 0 { config.DebugLog("No jails to migrate from jail.local") return nil } // Create backup of jail.local backupPath := localPath + ".backup." + fmt.Sprintf("%d", time.Now().Unix()) 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 .local file in jail.d/ migratedCount := 0 for jailName, jailContent := range sections { // Skip empty jail names if jailName == "" { continue } jailFilePath := filepath.Join(jailDPath, jailName+".local") // Check if .local file already exists if _, err := os.Stat(jailFilePath); err == nil { // File already exists - skip migration for this jail config.DebugLog("Skipping migration for jail %s: .local file already exists", jailName) continue } // Ensure enabled = false is set by default for migrated jails // Check if enabled is already set in the content enabledSet := strings.Contains(jailContent, "enabled") || strings.Contains(jailContent, "Enabled") if !enabledSet { // Add enabled = false at the beginning of the jail section // Find the first line after [jailName] lines := strings.Split(jailContent, "\n") modifiedContent := "" for i, line := range lines { modifiedContent += line + "\n" // After the section header, add enabled = false if i == 0 && strings.HasPrefix(strings.TrimSpace(line), "[") && strings.HasSuffix(strings.TrimSpace(line), "]") { modifiedContent += "enabled = false\n" } } jailContent = modifiedContent } else { // If enabled is set, ensure it's false by replacing any enabled = true jailContent = regexp.MustCompile(`(?m)^\s*enabled\s*=\s*true\s*$`).ReplaceAllString(jailContent, "enabled = false") } // Write jail content to .local file if err := os.WriteFile(jailFilePath, []byte(jailContent), 0644); err != nil { return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) } config.DebugLog("Migrated jail %s to %s (enabled = false)", jailName, jailFilePath) migratedCount++ } // Only rewrite jail.local if we actually migrated something if migratedCount > 0 { // Rewrite jail.local with only DEFAULT section and commented jails // We need to preserve commented sections, so we'll reconstruct the file newLocalContent := defaultContent // Add back commented sections that weren't migrated scanner := bufio.NewScanner(strings.NewReader(string(content))) var inCommentedJail bool var commentedJailContent strings.Builder var commentedJailName string for scanner.Scan() { line := scanner.Text() trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { // Check if this is a commented section originalLine := strings.TrimSpace(line) if strings.HasPrefix(originalLine, "#[") { // Save previous commented jail if any if inCommentedJail && commentedJailName != "" { newLocalContent += commentedJailContent.String() } inCommentedJail = true commentedJailContent.Reset() commentedJailName = strings.Trim(trimmed, "[]") if strings.HasPrefix(commentedJailName, "#") { commentedJailName = strings.TrimSpace(strings.TrimPrefix(commentedJailName, "#")) } commentedJailContent.WriteString(line) commentedJailContent.WriteString("\n") } else { // Non-commented section - save previous commented jail if any if inCommentedJail && commentedJailName != "" { newLocalContent += commentedJailContent.String() inCommentedJail = false commentedJailContent.Reset() } } } else if inCommentedJail { commentedJailContent.WriteString(line) commentedJailContent.WriteString("\n") } } // Save final commented jail if any if inCommentedJail && commentedJailName != "" { newLocalContent += commentedJailContent.String() } 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/", migratedCount) } return nil } // 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, 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, filePath, 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, file: %s", len(content), filePath) return content, filePath, 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(s) from jail configuration content. // Supports multiple logpaths in a single line (space-separated) or multiple lines. // Fail2ban supports both formats: // // logpath = /var/log/file1.log /var/log/file2.log // logpath = /var/log/file1.log // /var/log/file2.log // // Returns all logpaths joined by newlines. func ExtractLogpathFromJailConfig(jailContent string) string { var logpaths []string scanner := bufio.NewScanner(strings.NewReader(jailContent)) inLogpathLine := false currentLogpath := "" for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Skip comments if strings.HasPrefix(line, "#") { if inLogpathLine && currentLogpath != "" { // End of logpath block at comment, process current logpath paths := strings.Fields(currentLogpath) logpaths = append(logpaths, paths...) currentLogpath = "" inLogpathLine = false } continue } // Check if this line starts with logpath = if strings.HasPrefix(strings.ToLower(line), "logpath") { parts := strings.SplitN(line, "=", 2) if len(parts) == 2 { logpathValue := strings.TrimSpace(parts[1]) if logpathValue != "" { currentLogpath = logpathValue inLogpathLine = true } } } else if inLogpathLine { // Continuation line (indented or starting with space) // Fail2ban allows continuation lines for logpath if line != "" && !strings.Contains(line, "=") { // This is a continuation line, append to current logpath currentLogpath += " " + line } else { // End of logpath block, process current logpath if currentLogpath != "" { // Split by spaces to handle multiple logpaths in one line paths := strings.Fields(currentLogpath) logpaths = append(logpaths, paths...) currentLogpath = "" } inLogpathLine = false } } else if inLogpathLine && line == "" { // Empty line might end the logpath block if currentLogpath != "" { paths := strings.Fields(currentLogpath) logpaths = append(logpaths, paths...) currentLogpath = "" } inLogpathLine = false } } // Process any remaining logpath if currentLogpath != "" { paths := strings.Fields(currentLogpath) logpaths = append(logpaths, paths...) } // Join multiple logpaths with newlines return strings.Join(logpaths, "\n") } // 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{ "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), } // 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{"enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "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{"enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "banaction", "banaction_allports"} { 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{"enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "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 }