diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index f1f0c14..1351d32 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "sort" "strconv" "strings" @@ -177,18 +178,60 @@ func (sc *SSHConnector) Restart(ctx context.Context) error { } func (sc *SSHConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) { - path := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail) - out, err := sc.runRemoteCommand(ctx, []string{"cat", path}) + // Validate filter name + jail = strings.TrimSpace(jail) + if jail == "" { + return "", fmt.Errorf("filter name cannot be empty") + } + + // Try .local first, then fallback to .conf + localPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail) + confPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail) + + out, err := sc.runRemoteCommand(ctx, []string{"cat", localPath}) + if err == nil { + return out, nil + } + + // Fallback to .conf + out, err = sc.runRemoteCommand(ctx, []string{"cat", confPath}) if err != nil { - return "", fmt.Errorf("failed to read remote filter config: %w", err) + return "", fmt.Errorf("failed to read remote filter config (tried .local and .conf): %w", err) } return out, nil } func (sc *SSHConnector) SetFilterConfig(ctx context.Context, jail, content string) error { - path := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail) - cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", path, content) - _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) + // Validate filter name + jail = strings.TrimSpace(jail) + if jail == "" { + return fmt.Errorf("filter name cannot be empty") + } + + // Ensure .local file exists (copy from .conf if needed) + localPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail) + confPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail) + + // Check if .local exists, if not, copy from .conf + checkScript := fmt.Sprintf(` + if [ ! -f "%s" ]; then + if [ -f "%s" ]; then + cp "%s" "%s" + else + echo "Error: filter .conf file does not exist: %s" >&2 + exit 1 + fi + fi + `, localPath, confPath, confPath, localPath, confPath) + + _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", checkScript}) + if err != nil { + return fmt.Errorf("failed to ensure filter .local file: %w", err) + } + + // Write to .local file + cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, content) + _, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) return err } @@ -334,19 +377,59 @@ func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { } } - // Parse jail.d directory - jailDCmd := "find /etc/fail2ban/jail.d -maxdepth 1 -name '*.conf' -type f 2>/dev/null" - jailDList, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", jailDCmd}) - if err == nil && jailDList != "" { - for _, file := range strings.Split(jailDList, "\n") { + // Parse jail.d directory - prefer .local over .conf files + // First get .local files + jailDLocalCmd := "find /etc/fail2ban/jail.d -maxdepth 1 -name '*.local' -type f 2>/dev/null" + jailDLocalList, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", jailDLocalCmd}) + processedJails := make(map[string]bool) + if err == nil && jailDLocalList != "" { + for _, file := range strings.Split(jailDLocalList, "\n") { file = strings.TrimSpace(file) if file == "" { continue } + // Skip files that start with . (like .local) - these are invalid + baseName := filepath.Base(file) + if strings.HasPrefix(baseName, ".") { + config.DebugLog("Skipping invalid jail file: %s", file) + continue + } content, err := sc.runRemoteCommand(ctx, []string{"cat", file}) if err == nil { jails := parseJailConfigContent(content) - allJails = append(allJails, jails...) + for _, jail := range jails { + // Skip jails with empty names + if jail.JailName != "" { + allJails = append(allJails, jail) + processedJails[jail.JailName] = true + } + } + } + } + } + // Then get .conf files that don't have corresponding .local files + jailDConfCmd := "find /etc/fail2ban/jail.d -maxdepth 1 -name '*.conf' -type f 2>/dev/null" + jailDConfList, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", jailDConfCmd}) + if err == nil && jailDConfList != "" { + for _, file := range strings.Split(jailDConfList, "\n") { + file = strings.TrimSpace(file) + if file == "" { + continue + } + // Extract jail name from filename + baseName := strings.TrimSuffix(filepath.Base(file), ".conf") + // Skip files that start with . (like .conf) - these are invalid + if baseName == "" || strings.HasPrefix(filepath.Base(file), ".") { + config.DebugLog("Skipping invalid jail file: %s", file) + continue + } + // Only process if we haven't already processed this jail from a .local file + if !processedJails[baseName] { + content, err := sc.runRemoteCommand(ctx, []string{"cat", file}) + if err == nil { + jails := parseJailConfigContent(content) + allJails = append(allJails, jails...) + } } } } @@ -362,22 +445,39 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map return fmt.Errorf("failed to create jail.d directory: %w", err) } - // Update each jail in its own file + // Update each jail in its own .local file for jailName, enabled := range updates { - jailPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jailName) - - // Read existing file if it exists - content, err := sc.runRemoteCommand(ctx, []string{"cat", jailPath}) - if err != nil { - // File doesn't exist, create new one - newContent := fmt.Sprintf("[%s]\nenabled = %t\n", jailName, enabled) - cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailPath, newContent) - if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}); err != nil { - return fmt.Errorf("failed to write jail file %s: %w", jailPath, err) - } + // Validate jail name - skip empty or invalid names + jailName = strings.TrimSpace(jailName) + if jailName == "" { + config.DebugLog("Skipping empty jail name in updates map") continue } + localPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jailName) + confPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jailName) + + // Ensure .local file exists (copy from .conf if needed) + ensureScript := fmt.Sprintf(` + if [ ! -f "%s" ]; then + if [ -f "%s" ]; then + cp "%s" "%s" + else + echo "[%s]" > "%s" + fi + fi + `, localPath, confPath, confPath, localPath, jailName, localPath) + + if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", ensureScript}); err != nil { + return fmt.Errorf("failed to ensure .local file for jail %s: %w", jailName, err) + } + + // Read existing .local file + content, err := sc.runRemoteCommand(ctx, []string{"cat", localPath}) + if err != nil { + return fmt.Errorf("failed to read jail .local file %s: %w", localPath, err) + } + // Update enabled state in existing file lines := strings.Split(content, "\n") var outputLines []string @@ -421,11 +521,11 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map } } - // Write updated content + // Write updated content to .local file newContent := strings.Join(outputLines, "\n") - cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailPath, newContent) + cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, newContent) if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}); err != nil { - return fmt.Errorf("failed to write jail file %s: %w", jailPath, err) + return fmt.Errorf("failed to write jail .local file %s: %w", localPath, err) } } return nil @@ -522,10 +622,25 @@ fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true // GetJailConfig implements Connector. func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, error) { - jailPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jail) - out, err := sc.runRemoteCommand(ctx, []string{"cat", jailPath}) + // Validate jail name + jail = strings.TrimSpace(jail) + if jail == "" { + return "", fmt.Errorf("jail name cannot be empty") + } + + // Try .local first, then fallback to .conf + localPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail) + confPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jail) + + out, err := sc.runRemoteCommand(ctx, []string{"cat", localPath}) + if err == nil { + return out, nil + } + + // Fallback to .conf + out, err = sc.runRemoteCommand(ctx, []string{"cat", confPath}) if err != nil { - // If file doesn't exist, return empty jail section + // If neither exists, return empty jail section return fmt.Sprintf("[%s]\n", jail), nil } return out, nil @@ -533,14 +648,38 @@ func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, // SetJailConfig implements Connector. func (sc *SSHConnector) SetJailConfig(ctx context.Context, jail, content string) error { - jailPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jail) + // Validate jail name + jail = strings.TrimSpace(jail) + if jail == "" { + return fmt.Errorf("jail name cannot be empty") + } + + localPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail) + confPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jail) + // Ensure jail.d directory exists _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", "/etc/fail2ban/jail.d"}) if err != nil { return fmt.Errorf("failed to create jail.d directory: %w", err) } - cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailPath, content) + // Ensure .local file exists (copy from .conf if needed) + ensureScript := fmt.Sprintf(` + if [ ! -f "%s" ]; then + if [ -f "%s" ]; then + cp "%s" "%s" + else + echo "[%s]" > "%s" + fi + fi + `, localPath, confPath, confPath, localPath, jail, localPath) + + if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", ensureScript}); err != nil { + return fmt.Errorf("failed to ensure .local file for jail %s: %w", jail, err) + } + + // Write to .local file + cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, content) _, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) return err } diff --git a/internal/fail2ban/filter_management.go b/internal/fail2ban/filter_management.go index 39f5a0f..4425f76 100644 --- a/internal/fail2ban/filter_management.go +++ b/internal/fail2ban/filter_management.go @@ -24,6 +24,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/swissmakers/fail2ban-ui/internal/config" ) // GetFilterConfig returns the filter configuration using the default connector. @@ -44,39 +46,126 @@ func SetFilterConfig(jail, newContent string) error { return conn.SetFilterConfig(context.Background(), jail, newContent) } -// GetFilterConfigLocal reads a filter configuration from the local filesystem. -func GetFilterConfigLocal(jail string) (string, error) { - configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf") - content, err := os.ReadFile(configPath) - if err != nil { - return "", fmt.Errorf("failed to read config for jail %s: %v", jail, err) +// ensureFilterLocalFile ensures that a .local file exists for the given filter. +// If .local doesn't exist, it copies from .conf if available. +// Returns error if neither .local nor .conf exists (filters must have base .conf). +func ensureFilterLocalFile(filterName string) error { + // Validate filter name - must not be empty + filterName = strings.TrimSpace(filterName) + if filterName == "" { + return fmt.Errorf("filter name cannot be empty") } - return string(content), nil + + filterDPath := "/etc/fail2ban/filter.d" + localPath := filepath.Join(filterDPath, filterName+".local") + confPath := filepath.Join(filterDPath, filterName+".conf") + + // Check if .local already exists + if _, err := os.Stat(localPath); err == nil { + config.DebugLog("Filter .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 filter config from .conf to .local: %s -> %s", confPath, localPath) + content, err := os.ReadFile(confPath) + if err != nil { + return fmt.Errorf("failed to read filter .conf file %s: %w", confPath, err) + } + if err := os.WriteFile(localPath, content, 0644); err != nil { + return fmt.Errorf("failed to write filter .local file %s: %w", localPath, err) + } + config.DebugLog("Successfully copied filter config to .local file") + return nil + } + + // Neither exists, return error (filters must have base .conf) + return fmt.Errorf("filter .conf file does not exist: %s (filters must have a base .conf file)", confPath) +} + +// readFilterConfigWithFallback reads filter config from .local first, then falls back to .conf. +func readFilterConfigWithFallback(filterName string) (string, error) { + // Validate filter name - must not be empty + filterName = strings.TrimSpace(filterName) + if filterName == "" { + return "", fmt.Errorf("filter name cannot be empty") + } + + filterDPath := "/etc/fail2ban/filter.d" + localPath := filepath.Join(filterDPath, filterName+".local") + confPath := filepath.Join(filterDPath, filterName+".conf") + + // Try .local first + if content, err := os.ReadFile(localPath); err == nil { + config.DebugLog("Reading filter config from .local: %s", localPath) + return string(content), nil + } + + // Fallback to .conf + if content, err := os.ReadFile(confPath); err == nil { + config.DebugLog("Reading filter config from .conf: %s", confPath) + return string(content), nil + } + + // Neither exists, return error + return "", fmt.Errorf("filter config not found: neither %s nor %s exists", localPath, confPath) +} + +// GetFilterConfigLocal reads a filter configuration from the local filesystem. +// Prefers .local over .conf files. +func GetFilterConfigLocal(jail string) (string, error) { + return readFilterConfigWithFallback(jail) } // SetFilterConfigLocal writes the filter configuration to the local filesystem. +// Always writes to .local file, ensuring it exists first by copying from .conf if needed. func SetFilterConfigLocal(jail, newContent string) error { - configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf") - if err := os.WriteFile(configPath, []byte(newContent), 0644); err != nil { - return fmt.Errorf("failed to write config for jail %s: %v", jail, err) + // Ensure .local file exists (copy from .conf if needed) + if err := ensureFilterLocalFile(jail); err != nil { + return err } + + localPath := filepath.Join("/etc/fail2ban/filter.d", jail+".local") + if err := os.WriteFile(localPath, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write filter .local file for %s: %w", jail, err) + } + config.DebugLog("Successfully wrote filter config to .local file: %s", localPath) return nil } // GetFiltersLocal returns a list of filter names from /etc/fail2ban/filter.d +// Returns unique filter names from both .conf and .local files (prefers .local if both exist) func GetFiltersLocal() ([]string, error) { dir := "/etc/fail2ban/filter.d" entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("failed to read filter directory: %w", err) } - var filters []string + filterMap := make(map[string]bool) + + // First pass: collect all .local files (these take precedence) + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".local") { + name := strings.TrimSuffix(entry.Name(), ".local") + filterMap[name] = true + } + } + + // Second pass: collect .conf files that don't have corresponding .local files for _, entry := range entries { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".conf") { name := strings.TrimSuffix(entry.Name(), ".conf") - filters = append(filters, name) + if !filterMap[name] { + filterMap[name] = true + } } } + + var filters []string + for name := range filterMap { + filters = append(filters, name) + } sort.Strings(filters) return filters, nil } diff --git a/internal/fail2ban/jail_management.go b/internal/fail2ban/jail_management.go index d57ec60..b2d834e 100644 --- a/internal/fail2ban/jail_management.go +++ b/internal/fail2ban/jail_management.go @@ -16,6 +16,81 @@ 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) { @@ -38,16 +113,53 @@ func GetAllJails() ([]JailInfo, error) { } // 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()) == ".conf" { + 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 { - jails = append(jails, dJails...) + 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...) + } } } } @@ -88,15 +200,24 @@ func parseJailConfigFile(path string) ([]JailInfo, error) { }) } // Start a new jail section. - currentJail = strings.Trim(line, "[]") + 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") { - // Expect format: enabled = true/false - parts := strings.Split(line, "=") - if len(parts) == 2 { - value := strings.TrimSpace(parts[1]) - enabled = strings.EqualFold(value, "true") + // 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") + } } } } @@ -111,7 +232,8 @@ func parseJailConfigFile(path string) ([]JailInfo, error) { } // UpdateJailEnabledStates updates the enabled state for each jail based on the provided updates map. -// Updates only the corresponding file in /etc/fail2ban/jail.d/ for each jail. +// 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" @@ -121,16 +243,29 @@ func UpdateJailEnabledStates(updates map[string]bool) error { return fmt.Errorf("failed to create jail.d directory: %w", err) } - // Update each jail in its own file + // 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) - jailFilePath := filepath.Join(jailDPath, jailName+".conf") + + // 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 file if it exists + // Read existing .local file content, err := os.ReadFile(jailFilePath) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to read jail file %s: %w", jailFilePath, err) + if err != nil { + return fmt.Errorf("failed to read jail .local file %s: %w", jailFilePath, err) } var lines []string @@ -352,30 +487,34 @@ func parseJailConfigFileOnlyDefault(path string) ([]JailInfo, error) { return jails, scanner.Err() } -// GetJailConfig reads the full jail configuration from /etc/fail2ban/jail.d/{jailName}.conf +// 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) { - config.DebugLog("GetJailConfig called for jail: %s", jailName) - jailDPath := "/etc/fail2ban/jail.d" - jailFilePath := filepath.Join(jailDPath, jailName+".conf") - config.DebugLog("Reading jail config from: %s", jailFilePath) - - content, err := os.ReadFile(jailFilePath) - if err != nil { - if os.IsNotExist(err) { - config.DebugLog("Jail config file does not exist, returning empty section") - // Return empty jail section if file doesn't exist - return fmt.Sprintf("[%s]\n", jailName), nil - } - config.DebugLog("Failed to read jail config file: %v", err) - return "", fmt.Errorf("failed to read jail config for %s: %w", jailName, err) + // 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 string(content), nil + return content, nil } -// SetJailConfig writes the full jail configuration to /etc/fail2ban/jail.d/{jailName}.conf +// 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" @@ -387,6 +526,11 @@ func SetJailConfig(jailName, content string) error { } 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) @@ -466,13 +610,13 @@ func SetJailConfig(jailName, content string) error { } } - jailFilePath := filepath.Join(jailDPath, jailName+".conf") + 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") + config.DebugLog("Jail config written successfully to .local file") return nil }