package fail2ban import ( "bufio" "bytes" "context" "encoding/base64" "errors" "fmt" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" "syscall" "time" "github.com/swissmakers/fail2ban-ui/internal/config" ) const sshEnsureActionScript = `python3 - <<'PY' import base64 import pathlib import sys try: action_dir = pathlib.Path("/etc/fail2ban/action.d") action_dir.mkdir(parents=True, exist_ok=True) action_cfg = base64.b64decode("__PAYLOAD__").decode("utf-8") (action_dir / "ui-custom-action.conf").write_text(action_cfg) jail_file = pathlib.Path("/etc/fail2ban/jail.local") if not jail_file.exists(): jail_file.write_text("[DEFAULT]\n") lines = jail_file.read_text().splitlines() already = any("Custom Fail2Ban action applied by fail2ban-ui" in line for line in lines) if not already: new_lines = [] inserted = False for line in lines: stripped = line.strip() if stripped.startswith("action") and "ui-custom-action" not in stripped and not inserted: if not stripped.startswith("#"): new_lines.append("# " + line) else: new_lines.append(line) new_lines.append("# Custom Fail2Ban action applied by fail2ban-ui") new_lines.append("action = %(action_mwlg)s") inserted = True continue new_lines.append(line) if not inserted: insert_at = None for idx, value in enumerate(new_lines): if value.strip().startswith("[DEFAULT]"): insert_at = idx + 1 break if insert_at is None: new_lines.append("[DEFAULT]") insert_at = len(new_lines) new_lines.insert(insert_at, "# Custom Fail2Ban action applied by fail2ban-ui") new_lines.insert(insert_at + 1, "action = %(action_mwlg)s") jail_file.write_text("\n".join(new_lines) + "\n") except Exception as e: sys.stderr.write(f"Error: {e}\n") sys.exit(1) PY` // SSHConnector connects to a remote Fail2ban instance over SSH. type SSHConnector struct { server config.Fail2banServer fail2banPath string // Cache the fail2ban path pathCached bool // Track if path is cached pathMutex sync.RWMutex } // NewSSHConnector creates a new SSH connector. func NewSSHConnector(server config.Fail2banServer) (Connector, error) { if server.Host == "" { return nil, fmt.Errorf("host is required for ssh connector") } if server.SSHUser == "" { return nil, fmt.Errorf("sshUser is required for ssh connector") } conn := &SSHConnector{server: server} // Use a timeout context to prevent hanging if SSH server isn't ready yet // The action file can be ensured later when actually needed ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := conn.ensureAction(ctx); err != nil { // Log warning but don't fail connector creation - action can be ensured later config.DebugLog("warning: failed to ensure remote fail2ban action for %s during startup (server may not be ready): %v", server.Name, err) // Don't return error - allow connector to be created even if action setup fails // The action will be ensured later when UpdateActionFiles is called } return conn, nil } func (sc *SSHConnector) ID() string { return sc.server.ID } func (sc *SSHConnector) Server() config.Fail2banServer { return sc.server } func (sc *SSHConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) { jails, err := sc.getJails(ctx) if err != nil { return nil, err } // Use parallel execution for better performance type jailResult struct { jail JailInfo err error } results := make(chan jailResult, len(jails)) var wg sync.WaitGroup for _, jail := range jails { wg.Add(1) go func(j string) { defer wg.Done() ips, err := sc.GetBannedIPs(ctx, j) if err != nil { results <- jailResult{err: err} return } results <- jailResult{ jail: JailInfo{ JailName: j, TotalBanned: len(ips), NewInLastHour: 0, BannedIPs: ips, Enabled: true, }, } }(jail) } go func() { wg.Wait() close(results) }() var infos []JailInfo for result := range results { if result.err != nil { continue } infos = append(infos, result.jail) } sort.SliceStable(infos, func(i, j int) bool { return infos[i].JailName < infos[j].JailName }) return infos, nil } func (sc *SSHConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) { out, err := sc.runFail2banCommand(ctx, "status", jail) if err != nil { return nil, err } var bannedIPs []string lines := strings.Split(out, "\n") for _, line := range lines { if strings.Contains(line, "IP list:") { // Use SplitN to only split on the first colon, preserving IPv6 addresses parts := strings.SplitN(line, ":", 2) if len(parts) > 1 { ips := strings.Fields(strings.TrimSpace(parts[1])) bannedIPs = append(bannedIPs, ips...) } break } } return bannedIPs, nil } func (sc *SSHConnector) UnbanIP(ctx context.Context, jail, ip string) error { _, err := sc.runFail2banCommand(ctx, "set", jail, "unbanip", ip) return err } func (sc *SSHConnector) BanIP(ctx context.Context, jail, ip string) error { _, err := sc.runFail2banCommand(ctx, "set", jail, "banip", ip) return err } func (sc *SSHConnector) Reload(ctx context.Context) error { _, err := sc.runFail2banCommand(ctx, "reload") return err } // RestartWithMode restarts (or reloads) the remote Fail2ban instance over SSH // and returns a mode string describing what happened: // - "restart": systemd service was restarted and health check passed // - "reload": configuration was reloaded via fail2ban-client and pong check passed func (sc *SSHConnector) Restart(ctx context.Context) error { _, err := sc.RestartWithMode(ctx) return err } // RestartWithMode implements the detailed restart logic for SSH connectors. func (sc *SSHConnector) RestartWithMode(ctx context.Context) (string, error) { // First, we try systemd restart on the remote host out, err := sc.runRemoteCommand(ctx, []string{"systemctl", "restart", "fail2ban"}) if err == nil { if err := sc.checkFail2banHealthyRemote(ctx); err != nil { return "restart", fmt.Errorf("remote fail2ban health check after systemd restart failed: %w", err) } return "restart", nil } // Then, if systemd is not available, we fall back to fail2ban-client. if sc.isSystemctlUnavailable(out, err) { reloadOut, reloadErr := sc.runFail2banCommand(ctx, "reload") if reloadErr != nil { return "reload", fmt.Errorf("failed to reload fail2ban via fail2ban-client on remote: %w (output: %s)", reloadErr, strings.TrimSpace(reloadOut)) } if err := sc.checkFail2banHealthyRemote(ctx); err != nil { return "reload", fmt.Errorf("remote fail2ban health check after reload failed: %w", err) } return "reload", nil } // systemctl exists but restart failed for some other reason, we surface it. return "restart", fmt.Errorf("failed to restart fail2ban via systemd on remote: %w (output: %s)", err, out) } func (sc *SSHConnector) GetFilterConfig(ctx context.Context, filterName string) (string, string, error) { // Validate filter name filterName = strings.TrimSpace(filterName) if filterName == "" { return "", "", fmt.Errorf("filter name cannot be empty") } fail2banPath := sc.getFail2banPath(ctx) // Try .local first, then fallback to .conf localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local") confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf") content, err := sc.readRemoteFile(ctx, localPath) if err == nil { return content, localPath, nil } // Fallback to .conf content, err = sc.readRemoteFile(ctx, confPath) if err != nil { return "", "", fmt.Errorf("failed to read remote filter config (tried .local and .conf): %w", err) } return content, confPath, nil } func (sc *SSHConnector) SetFilterConfig(ctx context.Context, filterName, content string) error { // Validate filter name filterName = strings.TrimSpace(filterName) if filterName == "" { return fmt.Errorf("filter name cannot be empty") } fail2banPath := sc.getFail2banPath(ctx) filterDPath := filepath.Join(fail2banPath, "filter.d") // Ensure directory exists _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", filterDPath}) if err != nil { return fmt.Errorf("failed to create filter.d directory: %w", err) } // Ensure .local file exists (copy from .conf if needed) if err := sc.ensureRemoteLocalFile(ctx, filterDPath, filterName); err != nil { return fmt.Errorf("failed to ensure filter .local file: %w", err) } // Write to .local file localPath := filepath.Join(filterDPath, filterName+".local") if err := sc.writeRemoteFile(ctx, localPath, content); err != nil { return fmt.Errorf("failed to write filter config: %w", err) } return nil } func (sc *SSHConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) { // Not available over SSH without copying logs; return empty slice. return []BanEvent{}, nil } func (sc *SSHConnector) ensureAction(ctx context.Context) error { callbackURL := config.GetCallbackURL() settings := config.GetSettings() actionConfig := config.BuildFail2banActionConfig(callbackURL, sc.server.ID, settings.CallbackSecret) payload := base64.StdEncoding.EncodeToString([]byte(actionConfig)) script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload) // Base64 encode the entire script to avoid shell escaping issues scriptB64 := base64.StdEncoding.EncodeToString([]byte(script)) // Use sh -s to read commands from stdin, then pass the base64 string via stdin // This is the most reliable way to pass data via SSH args := sc.buildSSHArgs([]string{"sh", "-s"}) cmd := exec.CommandContext(ctx, "ssh", args...) // Set process group to ensure all child processes (including SSH control master) are killed // when the context is cancelled. This prevents zombie processes. cmd.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, Pgid: 0, } // Create a script that reads the base64 string from stdin and pipes it through base64 -d | bash // We use a here-document to pass the base64 string scriptContent := fmt.Sprintf("cat <<'ENDBASE64' | base64 -d | bash\n%s\nENDBASE64\n", scriptB64) cmd.Stdin = strings.NewReader(scriptContent) settingSnapshot := config.GetSettings() if settingSnapshot.Debug { config.DebugLog("SSH ensureAction command [%s]: ssh %s (with here-doc via stdin)", sc.server.Name, strings.Join(args, " ")) } // Capture stdout and stderr var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr // Start the command if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start ssh command: %w", err) } // Monitor context cancellation and command completion done := make(chan error, 1) go func() { done <- cmd.Wait() }() var err error select { case err = <-done: // Command completed normally case <-ctx.Done(): // Context cancelled - kill the entire process group to prevent zombies if cmd.Process != nil && cmd.Process.Pid > 0 { // Kill the entire process group (negative PID kills the process group) _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) // Give it a moment to exit gracefully time.Sleep(100 * time.Millisecond) // Force kill if still running _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // Wait for the process to exit to prevent zombies _, _ = cmd.Process.Wait() } return ctx.Err() } combinedOutput := append(stdout.Bytes(), stderr.Bytes()...) output := strings.TrimSpace(string(combinedOutput)) if err != nil { config.DebugLog("Failed to ensure action file for server %s: %v (output: %s)", sc.server.Name, err, output) return fmt.Errorf("failed to ensure action file on remote server %s: %w (remote output: %s)", sc.server.Name, err, output) } if output != "" { config.DebugLog("Successfully ensured action file for server %s (output: %s)", sc.server.Name, output) } else { config.DebugLog("Successfully ensured action file for server %s (no output)", sc.server.Name) } return nil } func (sc *SSHConnector) getJails(ctx context.Context) ([]string, error) { out, err := sc.runFail2banCommand(ctx, "status") if err != nil { return nil, err } var jails []string lines := strings.Split(out, "\n") for _, line := range lines { if strings.Contains(line, "Jail list:") { // Use SplitN to only split on the first colon parts := strings.SplitN(line, ":", 2) if len(parts) > 1 { raw := strings.TrimSpace(parts[1]) jails = strings.Split(raw, ",") for i := range jails { jails[i] = strings.TrimSpace(jails[i]) } } } } return jails, nil } func (sc *SSHConnector) runFail2banCommand(ctx context.Context, args ...string) (string, error) { fail2banArgs := sc.buildFail2banArgs(args...) cmdArgs := append([]string{"sudo", "fail2ban-client"}, fail2banArgs...) return sc.runRemoteCommand(ctx, cmdArgs) } // isSystemctlUnavailable tries to detect “no systemd” situations on the remote host. func (sc *SSHConnector) isSystemctlUnavailable(output string, err error) bool { msg := strings.ToLower(output + " " + err.Error()) return strings.Contains(msg, "command not found") || strings.Contains(msg, "system has not been booted with systemd") || strings.Contains(msg, "failed to connect to bus") } // checkFail2banHealthyRemote runs `sudo fail2ban-client ping` on the remote host // and expects a successful pong reply. func (sc *SSHConnector) checkFail2banHealthyRemote(ctx context.Context) error { out, err := sc.runFail2banCommand(ctx, "ping") trimmed := strings.TrimSpace(out) if err != nil { return fmt.Errorf("remote fail2ban ping error: %w (output: %s)", err, trimmed) } // Typical output is e.g. "Server replied: pong" – accept anything that // contains "pong" case-insensitively. if !strings.Contains(strings.ToLower(trimmed), "pong") { return fmt.Errorf("unexpected remote fail2ban ping output: %s", trimmed) } return nil } func (sc *SSHConnector) buildFail2banArgs(args ...string) []string { if sc.server.SocketPath == "" { return args } base := []string{"-s", sc.server.SocketPath} return append(base, args...) } func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string) (string, error) { args := sc.buildSSHArgs(command) cmd := exec.Command("ssh", args...) // Set process group to ensure all child processes (including SSH control master) are killed // when we need to terminate. This prevents zombie processes. cmd.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, Pgid: 0, } settingSnapshot := config.GetSettings() if settingSnapshot.Debug { config.DebugLog("SSH command [%s]: ssh %s", sc.server.Name, strings.Join(args, " ")) } // Capture stdout and stderr var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr // Start the command if err := cmd.Start(); err != nil { return "", fmt.Errorf("failed to start ssh command: %w", err) } // Monitor context cancellation and command completion done := make(chan error, 1) go func() { done <- cmd.Wait() }() select { case err := <-done: // Command completed combinedOutput := append(stdout.Bytes(), stderr.Bytes()...) output := strings.TrimSpace(string(combinedOutput)) if err != nil { if settingSnapshot.Debug { config.DebugLog("SSH command error [%s]: %v | output: %s", sc.server.Name, err, output) } return output, fmt.Errorf("ssh command failed: %w (output: %s)", err, output) } if settingSnapshot.Debug { config.DebugLog("SSH command output [%s]: %s", sc.server.Name, output) } return output, nil case <-ctx.Done(): // Context cancelled - kill the entire process group to prevent zombies if cmd.Process != nil && cmd.Process.Pid > 0 { // Kill the entire process group (negative PID kills the process group) _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) // Give it a moment to exit gracefully time.Sleep(100 * time.Millisecond) // Force kill if still running _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // Wait for the process to exit to prevent zombies _, _ = cmd.Process.Wait() } return "", ctx.Err() } } func (sc *SSHConnector) buildSSHArgs(command []string) []string { args := []string{"-o", "BatchMode=yes"} // Add connection timeout to prevent hanging args = append(args, "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2", ) // In containerized environments, disable strict host key checking if _, container := os.LookupEnv("CONTAINER"); container { args = append(args, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", ) } // Enable SSH connection multiplexing for faster connections // Use a control socket based on server ID for connection reuse controlPath := fmt.Sprintf("/tmp/ssh_control_%s_%s", sc.server.ID, strings.ReplaceAll(sc.server.Host, ".", "_")) args = append(args, "-o", "ControlMaster=auto", "-o", fmt.Sprintf("ControlPath=%s", controlPath), "-o", "ControlPersist=300", // Keep connection alive for 5 minutes ) if sc.server.SSHKeyPath != "" { args = append(args, "-i", sc.server.SSHKeyPath) } if sc.server.Port > 0 { args = append(args, "-p", strconv.Itoa(sc.server.Port)) } target := sc.server.Host if sc.server.SSHUser != "" { target = fmt.Sprintf("%s@%s", sc.server.SSHUser, target) } args = append(args, target) args = append(args, command...) return args } // listRemoteFiles lists files in a remote directory matching a pattern. // Uses find command which works reliably with FACL permissions. func (sc *SSHConnector) listRemoteFiles(ctx context.Context, directory, pattern string) ([]string, error) { // Use find command with absolute path - it will handle non-existent directories gracefully // Find files ending with pattern, exclude hidden files, and ensure they're regular files // Redirect stderr to /dev/null to suppress "No such file or directory" errors // Pass the entire command as a single string to SSH (SSH executes through a shell by default) cmd := fmt.Sprintf(`find "%s" -maxdepth 1 -type f -name "*%s" ! -name ".*" 2>/dev/null | sort`, directory, pattern) out, err := sc.runRemoteCommand(ctx, []string{cmd}) if err != nil { // If find fails (e.g., directory doesn't exist or permission denied), return empty list (not an error) config.DebugLog("Find command failed for %s on server %s: %v, returning empty list", directory, sc.server.Name, err) return []string{}, nil } // If find succeeds but directory doesn't exist, it will return empty output // This is fine - we'll just return an empty list var files []string for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) // Skip empty lines, current directory marker, and relative paths if line == "" || line == "." || strings.HasPrefix(line, "./") { continue } // Only process files that match our pattern (end with .local or .conf) // and are actually in the target directory if strings.HasSuffix(line, pattern) { // If it's already an absolute path starting with our directory, use it directly if strings.HasPrefix(line, directory) { files = append(files, line) } else if !strings.HasPrefix(line, "/") { // Relative path, join with directory fullPath := filepath.Join(directory, line) files = append(files, fullPath) } // Skip any other absolute paths that don't start with our directory } } return files, nil } // readRemoteFile reads the content of a remote file via SSH. func (sc *SSHConnector) readRemoteFile(ctx context.Context, filePath string) (string, error) { content, err := sc.runRemoteCommand(ctx, []string{"cat", filePath}) if err != nil { return "", fmt.Errorf("failed to read remote file %s: %w", filePath, err) } return content, nil } // writeRemoteFile writes content to a remote file via SSH using a heredoc. func (sc *SSHConnector) writeRemoteFile(ctx context.Context, filePath, content string) error { // Escape single quotes for safe use in a single-quoted heredoc escaped := strings.ReplaceAll(content, "'", "'\"'\"'") // Use heredoc to write file content script := fmt.Sprintf(`cat > %s <<'REMOTEEOF' %s REMOTEEOF `, filePath, escaped) _, err := sc.runRemoteCommand(ctx, []string{script}) if err != nil { return fmt.Errorf("failed to write remote file %s: %w", filePath, err) } return nil } // ensureRemoteLocalFile ensures that a .local file exists on the remote system. // If .local doesn't exist, it copies from .conf if available, or creates an empty file. func (sc *SSHConnector) ensureRemoteLocalFile(ctx context.Context, basePath, name string) error { localPath := fmt.Sprintf("%s/%s.local", basePath, name) confPath := fmt.Sprintf("%s/%s.conf", basePath, name) // Check if .local exists, if not, copy from .conf or create empty file script := fmt.Sprintf(` if [ ! -f "%s" ]; then if [ -f "%s" ]; then cp "%s" "%s" else # Create empty .local file if neither exists touch "%s" fi fi `, localPath, confPath, confPath, localPath, localPath) _, err := sc.runRemoteCommand(ctx, []string{script}) if err != nil { return fmt.Errorf("failed to ensure remote .local file %s: %w", localPath, err) } return nil } // getFail2banPath detects the fail2ban configuration path on the remote system. // Returns /config/fail2ban for linuxserver images, or /etc/fail2ban for standard installations. // Uses caching to avoid repeated SSH calls. func (sc *SSHConnector) getFail2banPath(ctx context.Context) string { // Try to read from cache first sc.pathMutex.RLock() if sc.pathCached { path := sc.fail2banPath sc.pathMutex.RUnlock() return path } sc.pathMutex.RUnlock() // Acquire write lock to update cache sc.pathMutex.Lock() defer sc.pathMutex.Unlock() // Double-check after acquiring write lock (another goroutine might have cached it) if sc.pathCached { return sc.fail2banPath } // Actually fetch the path checkCmd := `test -d "/config/fail2ban" && echo "/config/fail2ban" || (test -d "/etc/fail2ban" && echo "/etc/fail2ban" || echo "/etc/fail2ban")` out, err := sc.runRemoteCommand(ctx, []string{checkCmd}) if err == nil { path := strings.TrimSpace(out) if path != "" { sc.fail2banPath = path sc.pathCached = true return path } } // Default to /etc/fail2ban sc.fail2banPath = "/etc/fail2ban" sc.pathCached = true return sc.fail2banPath } // GetAllJails implements Connector. // Discovers all jails from filesystem (mirrors local connector behavior). // Optimized to read all files in a single SSH command instead of individual reads. func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { fail2banPath := sc.getFail2banPath(ctx) jailDPath := filepath.Join(fail2banPath, "jail.d") 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 // Use a Python script to read all files in a single SSH command // This is much more efficient than reading each file individually readAllScript := fmt.Sprintf(`python3 << 'PYEOF' import os import sys import json jail_d_path = %q files_data = {} # Read all .local files first local_files = [] if os.path.isdir(jail_d_path): for filename in os.listdir(jail_d_path): if filename.endswith('.local') and not filename.startswith('.'): local_files.append(os.path.join(jail_d_path, filename)) # Process .local files for filepath in sorted(local_files): try: filename = os.path.basename(filepath) basename = filename[:-6] # Remove .local if basename and basename not in files_data: with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: content = f.read() files_data[basename] = {'path': filepath, 'content': content, 'type': 'local'} except Exception as e: sys.stderr.write(f"Error reading {filepath}: {e}\n") # Read all .conf files that don't have corresponding .local files conf_files = [] if os.path.isdir(jail_d_path): for filename in os.listdir(jail_d_path): if filename.endswith('.conf') and not filename.startswith('.'): basename = filename[:-5] # Remove .conf if basename not in files_data: conf_files.append(os.path.join(jail_d_path, filename)) # Process .conf files for filepath in sorted(conf_files): try: filename = os.path.basename(filepath) basename = filename[:-5] # Remove .conf if basename: with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: content = f.read() files_data[basename] = {'path': filepath, 'content': content, 'type': 'conf'} except Exception as e: sys.stderr.write(f"Error reading {filepath}: {e}\n") # Output files with a delimiter: FILE_START:path:type\ncontent\nFILE_END\n for basename, data in sorted(files_data.items()): print(f"FILE_START:{data['path']}:{data['type']}") print(data['content'], end='') print("FILE_END") PYEOF`, jailDPath) output, err := sc.runRemoteCommand(ctx, []string{readAllScript}) if err != nil { // Fallback to individual file reads if the script fails config.DebugLog("Failed to read all jail files at once on server %s, falling back to individual reads: %v", sc.server.Name, err) return sc.getAllJailsFallback(ctx, jailDPath) } // Parse the output: files are separated by FILE_START:path:type\ncontent\nFILE_END\n var currentFile string var currentContent strings.Builder var currentType string inFile := false lines := strings.Split(output, "\n") for _, line := range lines { if strings.HasPrefix(line, "FILE_START:") { // Save previous file if any if inFile && currentFile != "" { content := currentContent.String() jails := parseJailConfigContent(content) for _, jail := range jails { if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { allJails = append(allJails, jail) processedJails[jail.JailName] = true } } } // Parse new file header: FILE_START:path:type parts := strings.SplitN(line, ":", 3) if len(parts) == 3 { currentFile = parts[1] currentType = parts[2] currentContent.Reset() inFile = true filename := filepath.Base(currentFile) var baseName string if currentType == "local" { baseName = strings.TrimSuffix(filename, ".local") } else { baseName = strings.TrimSuffix(filename, ".conf") } if baseName != "" { processedFiles[baseName] = true } } } else if line == "FILE_END" { // End of file, process it if inFile && currentFile != "" { content := currentContent.String() jails := parseJailConfigContent(content) for _, jail := range jails { if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { allJails = append(allJails, jail) processedJails[jail.JailName] = true } } } inFile = false currentFile = "" currentContent.Reset() } else if inFile { // Content line if currentContent.Len() > 0 { currentContent.WriteString("\n") } currentContent.WriteString(line) } } // Handle last file if output doesn't end with FILE_END if inFile && currentFile != "" { content := currentContent.String() jails := parseJailConfigContent(content) for _, jail := range jails { if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { allJails = append(allJails, jail) processedJails[jail.JailName] = true } } } return allJails, nil } // getAllJailsFallback is the fallback method that reads files individually. // Used when the optimized batch read fails. func (sc *SSHConnector) getAllJailsFallback(ctx context.Context, jailDPath string) ([]JailInfo, error) { var allJails []JailInfo processedFiles := make(map[string]bool) processedJails := make(map[string]bool) // List all .local files first localFiles, err := sc.listRemoteFiles(ctx, jailDPath, ".local") if err != nil { config.DebugLog("Failed to list .local files in jail.d on server %s: %v", sc.server.Name, err) } else { for _, filePath := range localFiles { filename := filepath.Base(filePath) baseName := strings.TrimSuffix(filename, ".local") if baseName == "" || processedFiles[baseName] { continue } processedFiles[baseName] = true content, err := sc.readRemoteFile(ctx, filePath) if err != nil { config.DebugLog("Failed to read jail file %s on server %s: %v", filePath, sc.server.Name, err) continue } jails := parseJailConfigContent(content) for _, jail := range jails { if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { allJails = append(allJails, jail) processedJails[jail.JailName] = true } } } } // List all .conf files that don't have corresponding .local files confFiles, err := sc.listRemoteFiles(ctx, jailDPath, ".conf") if err != nil { config.DebugLog("Failed to list .conf files in jail.d on server %s: %v", sc.server.Name, err) } else { for _, filePath := range confFiles { filename := filepath.Base(filePath) baseName := strings.TrimSuffix(filename, ".conf") if baseName == "" || processedFiles[baseName] { continue } processedFiles[baseName] = true content, err := sc.readRemoteFile(ctx, filePath) if err != nil { config.DebugLog("Failed to read jail file %s on server %s: %v", filePath, sc.server.Name, err) continue } jails := parseJailConfigContent(content) for _, jail := range jails { if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { allJails = append(allJails, jail) processedJails[jail.JailName] = true } } } } return allJails, nil } // UpdateJailEnabledStates implements Connector. func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error { fail2banPath := sc.getFail2banPath(ctx) jailDPath := filepath.Join(fail2banPath, "jail.d") // 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 } localPath := filepath.Join(jailDPath, jailName+".local") confPath := filepath.Join(jailDPath, jailName+".conf") // Combined script: ensure .local file exists AND read it in one SSH call // This reduces SSH round-trips from 2 to 1 per jail combinedScript := fmt.Sprintf(` if [ ! -f "%s" ]; then if [ -f "%s" ]; then cp "%s" "%s" else echo "[%s]" > "%s" fi fi cat "%s" `, localPath, confPath, confPath, localPath, jailName, localPath, localPath) content, err := sc.runRemoteCommand(ctx, []string{combinedScript}) if err != nil { return fmt.Errorf("failed to ensure and read .local file for jail %s: %w", jailName, err) } // Update enabled state in existing file lines := strings.Split(content, "\n") 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) { newLines = append(newLines, fmt.Sprintf("enabled = %t", enabled)) if i+1 < len(outputLines) { newLines = append(newLines, outputLines[i+1:]...) } break } } if len(newLines) > len(outputLines) { outputLines = newLines } else { outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) } } // Write updated content to .local file newContent := strings.Join(outputLines, "\n") cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, newContent) if _, err := sc.runRemoteCommand(ctx, []string{cmd}); err != nil { return fmt.Errorf("failed to write jail .local file %s: %w", localPath, err) } } return nil } // GetFilters implements Connector. // Discovers all filters from filesystem (mirrors local connector behavior). func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) { fail2banPath := sc.getFail2banPath(ctx) filterDPath := filepath.Join(fail2banPath, "filter.d") filterMap := make(map[string]bool) // Track unique filter names processedFiles := make(map[string]bool) // Track base names to avoid duplicates // Helper function to check if file should be excluded shouldExclude := func(filename string) bool { if strings.HasSuffix(filename, ".bak") || strings.HasSuffix(filename, "~") || strings.HasSuffix(filename, ".old") || strings.HasSuffix(filename, ".rpmnew") || strings.HasSuffix(filename, ".rpmsave") || strings.Contains(filename, "README") { return true } return false } // First pass: collect all .local files (these take precedence) localFiles, err := sc.listRemoteFiles(ctx, filterDPath, ".local") if err != nil { config.DebugLog("Failed to list .local filters on server %s: %v", sc.server.Name, err) } else { for _, filePath := range localFiles { filename := filepath.Base(filePath) if shouldExclude(filename) { continue } baseName := strings.TrimSuffix(filename, ".local") if baseName == "" || processedFiles[baseName] { continue } processedFiles[baseName] = true filterMap[baseName] = true } } // Second pass: collect .conf files that don't have corresponding .local files confFiles, err := sc.listRemoteFiles(ctx, filterDPath, ".conf") if err != nil { config.DebugLog("Failed to list .conf filters on server %s: %v", sc.server.Name, err) } else { for _, filePath := range confFiles { filename := filepath.Base(filePath) if shouldExclude(filename) { continue } baseName := strings.TrimSuffix(filename, ".conf") if baseName == "" || processedFiles[baseName] { continue } processedFiles[baseName] = true filterMap[baseName] = true } } // Convert map to sorted slice var filters []string for name := range filterMap { filters = append(filters, name) } sort.Strings(filters) return filters, nil } // resolveFilterIncludesRemote resolves filter includes by reading included files from remote server // This is similar to resolveFilterIncludes but reads files via SSH instead of local filesystem func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterContent string, filterDPath string, currentFilterName string) (string, error) { lines := strings.Split(filterContent, "\n") var beforeFiles []string var afterFiles []string var inIncludesSection bool var mainContent strings.Builder // Parse the filter content to find [INCLUDES] section for i, line := range lines { trimmed := strings.TrimSpace(line) // Check for [INCLUDES] section if strings.HasPrefix(trimmed, "[INCLUDES]") { inIncludesSection = true continue } // Check for end of [INCLUDES] section (next section starts) if inIncludesSection && strings.HasPrefix(trimmed, "[") { inIncludesSection = false } // Parse before and after directives if inIncludesSection { if strings.HasPrefix(strings.ToLower(trimmed), "before") { parts := strings.SplitN(trimmed, "=", 2) if len(parts) == 2 { file := strings.TrimSpace(parts[1]) if file != "" { beforeFiles = append(beforeFiles, file) } } continue } if strings.HasPrefix(strings.ToLower(trimmed), "after") { parts := strings.SplitN(trimmed, "=", 2) if len(parts) == 2 { file := strings.TrimSpace(parts[1]) if file != "" { afterFiles = append(afterFiles, file) } } continue } } // Collect main content (everything except [INCLUDES] section) if !inIncludesSection { if i > 0 { mainContent.WriteString("\n") } mainContent.WriteString(line) } } // Extract variables from main filter content first mainContentStr := mainContent.String() mainVariables := extractVariablesFromContent(mainContentStr) // Build combined content: before files + main filter + after files var combined strings.Builder // Helper function to read remote file readRemoteFilterFile := func(baseName string) (string, error) { localPath := filepath.Join(filterDPath, baseName+".local") confPath := filepath.Join(filterDPath, baseName+".conf") // Try .local first content, err := sc.readRemoteFile(ctx, localPath) if err == nil { config.DebugLog("Loading included filter file from .local: %s", localPath) return content, nil } // Fallback to .conf content, err = sc.readRemoteFile(ctx, confPath) if err == nil { config.DebugLog("Loading included filter file from .conf: %s", confPath) return content, nil } return "", fmt.Errorf("could not load included filter file '%s' or '%s'", localPath, confPath) } // Load and append before files, removing duplicates that exist in main filter for _, fileName := range beforeFiles { // Remove any existing extension to get base name baseName := fileName if strings.HasSuffix(baseName, ".local") { baseName = strings.TrimSuffix(baseName, ".local") } else if strings.HasSuffix(baseName, ".conf") { baseName = strings.TrimSuffix(baseName, ".conf") } // Skip if this is the same filter (avoid self-inclusion) if baseName == currentFilterName { config.DebugLog("Skipping self-inclusion of filter '%s' in before files", baseName) continue } contentStr, err := readRemoteFilterFile(baseName) if err != nil { config.DebugLog("Warning: %v", err) continue // Skip if file doesn't exist } // Remove variables from included file that are defined in main filter (main filter takes precedence) cleanedContent := removeDuplicateVariables(contentStr, mainVariables) combined.WriteString(cleanedContent) if !strings.HasSuffix(cleanedContent, "\n") { combined.WriteString("\n") } combined.WriteString("\n") } // Append main filter content (unchanged - this is what the user is editing) combined.WriteString(mainContentStr) if !strings.HasSuffix(mainContentStr, "\n") { combined.WriteString("\n") } // Load and append after files, also removing duplicates that exist in main filter for _, fileName := range afterFiles { // Remove any existing extension to get base name baseName := fileName if strings.HasSuffix(baseName, ".local") { baseName = strings.TrimSuffix(baseName, ".local") } else if strings.HasSuffix(baseName, ".conf") { baseName = strings.TrimSuffix(baseName, ".conf") } // Note: Self-inclusion in "after" directive is intentional in fail2ban // (e.g., after = apache-common.local is standard pattern for .local files) // So we always load it, even if it's the same filter name contentStr, err := readRemoteFilterFile(baseName) if err != nil { config.DebugLog("Warning: %v", err) continue // Skip if file doesn't exist } // Remove variables from included file that are defined in main filter (main filter takes precedence) cleanedContent := removeDuplicateVariables(contentStr, mainVariables) combined.WriteString("\n") combined.WriteString(cleanedContent) if !strings.HasSuffix(cleanedContent, "\n") { combined.WriteString("\n") } } return combined.String(), nil } // TestFilter implements Connector. func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) { cleaned := normalizeLogLines(logLines) if len(cleaned) == 0 { return "No log lines provided.\n", "", nil } // Sanitize filter name to prevent path traversal filterName = strings.TrimSpace(filterName) if filterName == "" { return "", "", fmt.Errorf("filter name cannot be empty") } // Remove any path components filterName = strings.ReplaceAll(filterName, "/", "") filterName = strings.ReplaceAll(filterName, "..", "") // Get the fail2ban path dynamically fail2banPath := sc.getFail2banPath(ctx) // Try .local first, then fallback to .conf localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local") confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf") const heredocMarker = "F2B_FILTER_TEST_LOG" const filterContentMarker = "F2B_FILTER_CONTENT" logContent := strings.Join(cleaned, "\n") var script string if filterContent != "" { // Resolve filter includes locally (same approach as local connector) // This avoids complex Python scripts and heredoc issues filterDPath := filepath.Join(fail2banPath, "filter.d") // First, we need to create a remote-aware version of resolveFilterIncludes // that can read included files from the remote server resolvedContent, err := sc.resolveFilterIncludesRemote(ctx, filterContent, filterDPath, filterName) if err != nil { config.DebugLog("Warning: failed to resolve filter includes remotely, using original content: %v", err) resolvedContent = filterContent } // Ensure it ends with a newline for proper parsing if !strings.HasSuffix(resolvedContent, "\n") { resolvedContent += "\n" } // Base64 encode resolved filter content to avoid any heredoc/escaping issues resolvedContentB64 := base64.StdEncoding.EncodeToString([]byte(resolvedContent)) // Simple script: just write the resolved content to temp file and test script = fmt.Sprintf(` set -e TMPFILTER=$(mktemp /tmp/fail2ban-filter-XXXXXX.conf) trap 'rm -f "$TMPFILTER"' EXIT # Write resolved filter content to temp file using base64 decode echo '%[1]s' | base64 -d > "$TMPFILTER" FILTER_PATH="$TMPFILTER" echo "FILTER_PATH:$FILTER_PATH" TMPFILE=$(mktemp /tmp/fail2ban-test-XXXXXX.log) trap 'rm -f "$TMPFILE" "$TMPFILTER"' EXIT cat <<'%[2]s' > "$TMPFILE" %[3]s %[2]s fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true `, resolvedContentB64, heredocMarker, logContent) } else { // Use existing filter file script = fmt.Sprintf(` set -e LOCAL_PATH=%[1]q CONF_PATH=%[2]q FILTER_PATH="" if [ -f "$LOCAL_PATH" ]; then FILTER_PATH="$LOCAL_PATH" elif [ -f "$CONF_PATH" ]; then FILTER_PATH="$CONF_PATH" else echo "Filter not found: checked both $LOCAL_PATH and $CONF_PATH" >&2 exit 1 fi echo "FILTER_PATH:$FILTER_PATH" TMPFILE=$(mktemp /tmp/fail2ban-test-XXXXXX.log) trap 'rm -f "$TMPFILE"' EXIT cat <<'%[3]s' > "$TMPFILE" %[4]s %[3]s fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true `, localPath, confPath, heredocMarker, logContent) } out, err := sc.runRemoteCommand(ctx, []string{script}) if err != nil { return "", "", err } // Extract filter path from output (it's on the first line with FILTER_PATH: prefix) lines := strings.Split(out, "\n") var filterPath string var outputLines []string foundPathMarker := false for _, line := range lines { if strings.HasPrefix(line, "FILTER_PATH:") { filterPath = strings.TrimPrefix(line, "FILTER_PATH:") filterPath = strings.TrimSpace(filterPath) foundPathMarker = true // Skip this line from the output continue } outputLines = append(outputLines, line) } // If we didn't find FILTER_PATH marker, try to determine it if !foundPathMarker || filterPath == "" { // Check which file exists remotely localOut, localErr := sc.runRemoteCommand(ctx, []string{"test", "-f", localPath, "&&", "echo", localPath, "||", "echo", ""}) if localErr == nil && strings.TrimSpace(localOut) != "" { filterPath = strings.TrimSpace(localOut) } else { filterPath = confPath } } output := strings.Join(outputLines, "\n") return output, filterPath, nil } // GetJailConfig implements Connector. func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) { // Validate jail name jail = strings.TrimSpace(jail) if jail == "" { return "", "", fmt.Errorf("jail name cannot be empty") } fail2banPath := sc.getFail2banPath(ctx) // Try .local first, then fallback to .conf localPath := filepath.Join(fail2banPath, "jail.d", jail+".local") confPath := filepath.Join(fail2banPath, "jail.d", jail+".conf") content, err := sc.readRemoteFile(ctx, localPath) if err == nil { return content, localPath, nil } // Fallback to .conf content, err = sc.readRemoteFile(ctx, confPath) if err != nil { // If neither exists, return empty jail section with .local path (will be created on save) return fmt.Sprintf("[%s]\n", jail), localPath, nil } return content, confPath, nil } // SetJailConfig implements Connector. func (sc *SSHConnector) SetJailConfig(ctx context.Context, jail, content string) error { // Validate jail name jail = strings.TrimSpace(jail) if jail == "" { return fmt.Errorf("jail name cannot be empty") } fail2banPath := sc.getFail2banPath(ctx) jailDPath := filepath.Join(fail2banPath, "jail.d") // Ensure jail.d directory exists _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", jailDPath}) if err != nil { return fmt.Errorf("failed to create jail.d directory: %w", err) } // Ensure .local file exists (copy from .conf if needed) if err := sc.ensureRemoteLocalFile(ctx, jailDPath, jail); err != nil { return fmt.Errorf("failed to ensure .local file for jail %s: %w", jail, err) } // Write to .local file localPath := filepath.Join(jailDPath, jail+".local") if err := sc.writeRemoteFile(ctx, localPath, content); err != nil { return fmt.Errorf("failed to write jail config: %w", err) } return nil } // TestLogpath implements Connector. func (sc *SSHConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) { if logpath == "" { return []string{}, nil } logpath = strings.TrimSpace(logpath) hasWildcard := strings.ContainsAny(logpath, "*?[") var script string if hasWildcard { // Use find with glob pattern script = fmt.Sprintf(` set -e LOGPATH=%q # Use find for glob patterns find $(dirname "$LOGPATH") -maxdepth 1 -path "$LOGPATH" -type f 2>/dev/null | sort `, logpath) } else { // Check if it's a directory or file script = fmt.Sprintf(` set -e LOGPATH=%q if [ -d "$LOGPATH" ]; then find "$LOGPATH" -maxdepth 1 -type f 2>/dev/null | sort elif [ -f "$LOGPATH" ]; then echo "$LOGPATH" fi `, logpath) } out, err := sc.runRemoteCommand(ctx, []string{script}) if err != nil { return []string{}, nil // Return empty on error } var matches []string for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line != "" { matches = append(matches, line) } } return matches, nil } // TestLogpathWithResolution implements Connector. // Resolves variables on remote system, then tests the resolved path. func (sc *SSHConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) { originalPath = strings.TrimSpace(logpath) if originalPath == "" { return originalPath, "", []string{}, nil } // Create Python script to resolve variables on remote system resolveScript := fmt.Sprintf(`python3 - <<'PYEOF' import os import re import glob from pathlib import Path def extract_variables(s): """Extract all variable names from a string.""" pattern = r'%%\(([^)]+)\)s' return re.findall(pattern, s) def find_variable_definition(var_name, fail2ban_path="/etc/fail2ban"): """Search for variable definition in all .conf files.""" var_name_lower = var_name.lower() for conf_file in Path(fail2ban_path).rglob("*.conf"): try: with open(conf_file, 'r') as f: current_var = None current_value = [] in_multiline = False for line in f: original_line = line line = line.strip() if not in_multiline: if '=' in line and not line.startswith('#'): parts = line.split('=', 1) key = parts[0].strip() value = parts[1].strip() if key.lower() == var_name_lower: current_var = key current_value = [value] in_multiline = True continue else: # Check if continuation or new variable/section if line.startswith('[') or (not line.startswith(' ') and '=' in line and not line.startswith('\t')): # End of multi-line return ' '.join(current_value) else: # Continuation current_value.append(line) if in_multiline and current_var: return ' '.join(current_value) except: continue return None def resolve_variable_recursive(var_name, visited=None): """Resolve variable recursively.""" if visited is None: visited = set() if var_name in visited: raise ValueError(f"Circular reference detected for variable '{var_name}'") visited.add(var_name) try: value = find_variable_definition(var_name) if value is None: raise ValueError(f"Variable '{var_name}' not found") # Check for nested variables nested_vars = extract_variables(value) if not nested_vars: return value # Resolve nested variables resolved = value for nested_var in nested_vars: nested_value = resolve_variable_recursive(nested_var, visited.copy()) pattern = f'%%({re.escape(nested_var)})s' resolved = re.sub(pattern, nested_value, resolved) return resolved finally: visited.discard(var_name) def resolve_logpath(logpath): """Resolve all variables in logpath.""" variables = extract_variables(logpath) if not variables: return logpath resolved = logpath for var_name in variables: var_value = resolve_variable_recursive(var_name) pattern = f'%%({re.escape(var_name)})s' resolved = re.sub(pattern, var_value, resolved) return resolved # Main logpath = %q try: resolved = resolve_logpath(logpath) print(f"RESOLVED:{resolved}") except Exception as e: print(f"ERROR:{str(e)}") exit(1) PYEOF `, originalPath) // Run resolution script resolveOut, err := sc.runRemoteCommand(ctx, []string{resolveScript}) if err != nil { return originalPath, "", nil, fmt.Errorf("failed to resolve variables: %w", err) } resolveOut = strings.TrimSpace(resolveOut) if strings.HasPrefix(resolveOut, "ERROR:") { return originalPath, "", nil, errors.New(strings.TrimPrefix(resolveOut, "ERROR:")) } if strings.HasPrefix(resolveOut, "RESOLVED:") { resolvedPath = strings.TrimPrefix(resolveOut, "RESOLVED:") } else { // Fallback: use original if resolution failed resolvedPath = originalPath } // Test the resolved path files, err = sc.TestLogpath(ctx, resolvedPath) if err != nil { return originalPath, resolvedPath, nil, fmt.Errorf("failed to test logpath: %w", err) } return originalPath, resolvedPath, files, nil } // UpdateDefaultSettings implements Connector. func (sc *SSHConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error { jailLocalPath := "/etc/fail2ban/jail.local" // Read existing file if it exists existingContent, err := sc.runRemoteCommand(ctx, []string{"cat", jailLocalPath}) if err != nil { // File doesn't exist, create new one existingContent = "" } // Remove commented lines (lines starting with #) using sed if existingContent != "" { // Use sed to remove lines starting with # (but preserve empty lines) removeCommentsCmd := fmt.Sprintf("sed '/^[[:space:]]*#/d' %s", jailLocalPath) uncommentedContent, err := sc.runRemoteCommand(ctx, []string{removeCommentsCmd}) if err == nil { existingContent = uncommentedContent } } // Convert IgnoreIPs array to space-separated string ignoreIPStr := strings.Join(settings.IgnoreIPs, " ") if ignoreIPStr == "" { ignoreIPStr = "127.0.0.1/8 ::1" } // Set default banaction values if not set banactionVal := settings.Banaction if banactionVal == "" { banactionVal = "nftables-multiport" } banactionAllportsVal := settings.BanactionAllports if banactionAllportsVal == "" { banactionAllportsVal = "nftables-allports" } // Define the keys we want to update keysToUpdate := map[string]string{ "enabled": fmt.Sprintf("enabled = %t", settings.DefaultJailEnable), "bantime.increment": fmt.Sprintf("bantime.increment = %t", settings.BantimeIncrement), "ignoreip": fmt.Sprintf("ignoreip = %s", ignoreIPStr), "bantime": fmt.Sprintf("bantime = %s", settings.Bantime), "findtime": fmt.Sprintf("findtime = %s", settings.Findtime), "maxretry": fmt.Sprintf("maxretry = %d", settings.Maxretry), "banaction": fmt.Sprintf("banaction = %s", banactionVal), "banaction_allports": fmt.Sprintf("banaction_allports = %s", banactionAllportsVal), } // Parse existing content and update only specific keys in DEFAULT section if existingContent == "" { // File doesn't exist, create new one with DEFAULT section defaultLines := []string{"[DEFAULT]"} for _, key := range []string{"enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "banaction", "banaction_allports"} { defaultLines = append(defaultLines, keysToUpdate[key]) } defaultLines = append(defaultLines, "") newContent := strings.Join(defaultLines, "\n") cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailLocalPath, newContent) _, err = sc.runRemoteCommand(ctx, []string{cmd}) return err } // Use Python script to update only specific keys in DEFAULT section // Preserves banner, action_mwlg, and action override sections // Escape values for shell/Python escapeForShell := func(s string) string { // Escape single quotes for shell return strings.ReplaceAll(s, "'", "'\"'\"'") } // Convert boolean values to Python boolean literals defaultJailEnablePython := "False" if settings.DefaultJailEnable { defaultJailEnablePython = "True" } bantimeIncrementPython := "False" if settings.BantimeIncrement { bantimeIncrementPython = "True" } updateScript := fmt.Sprintf(`python3 <<'PY' import re jail_file = '%s' ignore_ip_str = '%s' banaction_val = '%s' banaction_allports_val = '%s' default_jail_enable_val = %s bantime_increment_val = %s bantime_val = '%s' findtime_val = '%s' maxretry_val = %d keys_to_update = { 'enabled': 'enabled = ' + str(default_jail_enable_val).lower(), 'bantime.increment': 'bantime.increment = ' + str(bantime_increment_val).lower(), 'ignoreip': 'ignoreip = ' + ignore_ip_str, 'bantime': 'bantime = ' + bantime_val, 'findtime': 'findtime = ' + findtime_val, 'maxretry': 'maxretry = ' + str(maxretry_val), 'banaction': 'banaction = ' + banaction_val, 'banaction_allports': 'banaction_allports = ' + banaction_allports_val } try: with open(jail_file, 'r') as f: lines = f.readlines() except FileNotFoundError: lines = [] output_lines = [] in_default = False default_section_found = False keys_updated = set() for line in lines: stripped = line.strip() # Preserve banner lines, action_mwlg lines, and action override lines is_banner = 'Fail2Ban-UI' in line or 'fail2ban-ui' in line is_action_mwlg = 'action_mwlg' in stripped is_action_override = 'action = %%(action_mwlg)s' in stripped if stripped.startswith('[') and stripped.endswith(']'): section_name = stripped.strip('[]') if section_name == "DEFAULT": in_default = True default_section_found = True output_lines.append(line) else: in_default = False output_lines.append(line) elif in_default: # Check if this line is a key we need to update key_updated = False for key, new_value in keys_to_update.items(): pattern = r'^\s*' + re.escape(key) + r'\s*=' if re.match(pattern, stripped): output_lines.append(new_value + '\n') keys_updated.add(key) key_updated = True break if not key_updated: # Keep the line as-is (might be action_mwlg or other DEFAULT settings) output_lines.append(line) else: # Keep lines outside DEFAULT section (preserves banner, action_mwlg, action override) output_lines.append(line) # If DEFAULT section wasn't found, create it at the beginning if not default_section_found: default_lines = ["[DEFAULT]\n"] for key in ["enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "banaction", "banaction_allports"]: default_lines.append(keys_to_update[key] + "\n") default_lines.append("\n") output_lines = default_lines + output_lines else: # Add any missing keys to the DEFAULT section for key in ["enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "banaction", "banaction_allports"]: if key not in keys_updated: # Find the DEFAULT section and insert after it for i, line in enumerate(output_lines): if line.strip() == "[DEFAULT]": output_lines.insert(i + 1, keys_to_update[key] + "\n") break with open(jail_file, 'w') as f: f.writelines(output_lines) PY`, escapeForShell(jailLocalPath), escapeForShell(ignoreIPStr), escapeForShell(banactionVal), escapeForShell(banactionAllportsVal), defaultJailEnablePython, bantimeIncrementPython, escapeForShell(settings.Bantime), escapeForShell(settings.Findtime), settings.Maxretry) _, err = sc.runRemoteCommand(ctx, []string{updateScript}) return err } // EnsureJailLocalStructure implements Connector. // For SSH connectors we: // 1. Migrate any legacy jails out of jail.local into jail.d/*.local // 2. Rebuild /etc/fail2ban/jail.local with a clean, managed structure // (banner, [DEFAULT] section based on current settings, and action_mwlg/action override). func (sc *SSHConnector) EnsureJailLocalStructure(ctx context.Context) error { jailLocalPath := "/etc/fail2ban/jail.local" settings := config.GetSettings() // Convert IgnoreIPs array to space-separated string ignoreIPStr := strings.Join(settings.IgnoreIPs, " ") if ignoreIPStr == "" { ignoreIPStr = "127.0.0.1/8 ::1" } // Set default banaction values if not set banactionVal := settings.Banaction if banactionVal == "" { banactionVal = "nftables-multiport" } banactionAllportsVal := settings.BanactionAllports if banactionAllportsVal == "" { banactionAllportsVal = "nftables-allports" } // Build the new jail.local content in Go (mirrors local ensureJailLocalStructure) banner := config.JailLocalBanner() defaultSection := fmt.Sprintf(`[DEFAULT] enabled = %t bantime.increment = %t ignoreip = %s bantime = %s findtime = %s maxretry = %d banaction = %s banaction_allports = %s `, settings.DefaultJailEnable, settings.BantimeIncrement, ignoreIPStr, settings.Bantime, settings.Findtime, settings.Maxretry, banactionVal, banactionAllportsVal, ) actionMwlgConfig := `# Custom Fail2Ban action for UI callbacks action_mwlg = %(action_)s ui-custom-action[logpath="%(logpath)s", chain="%(chain)s"] ` actionOverride := `# Custom Fail2Ban action applied by fail2ban-ui action = %(action_mwlg)s ` content := banner + defaultSection + actionMwlgConfig + actionOverride // Escape single quotes for safe use in a single-quoted heredoc escaped := strings.ReplaceAll(content, "'", "'\"'\"'") // IMPORTANT: Run migration FIRST before ensuring structure. // This is because EnsureJailLocalStructure may overwrite jail.local, // which would destroy any jail sections that need to be migrated. // If migration fails for any reason, we SHOULD NOT overwrite jail.local, // otherwise legacy jails would be lost. if err := sc.MigrateJailsFromJailLocalRemote(ctx); err != nil { return fmt.Errorf("failed to migrate legacy jails from jail.local on remote server %s: %w", sc.server.Name, err) } // Write the rebuilt content via heredoc over SSH writeScript := fmt.Sprintf(`cat > %s <<'JAILLOCAL' %s JAILLOCAL `, jailLocalPath, escaped) _, err := sc.runRemoteCommand(ctx, []string{writeScript}) return err } // MigrateJailsFromJailLocalRemote migrates non-commented jail sections from jail.local to jail.d/*.local files on remote system. func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) error { jailLocalPath := "/etc/fail2ban/jail.local" jailDPath := "/etc/fail2ban/jail.d" // Check if jail.local exists checkScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailLocalPath) out, err := sc.runRemoteCommand(ctx, []string{checkScript}) if err != nil || strings.TrimSpace(out) != "exists" { config.DebugLog("No jails to migrate from jail.local on server %s (file does not exist)", sc.server.Name) return nil // Nothing to migrate } // Read jail.local content content, err := sc.runRemoteCommand(ctx, []string{"cat", jailLocalPath}) if err != nil { return fmt.Errorf("failed to read jail.local on server %s: %w", sc.server.Name, err) } // Parse content locally to extract non-commented sections sections, defaultContent, err := parseJailSectionsUncommented(content) if err != nil { return fmt.Errorf("failed to parse jail.local on server %s: %w", sc.server.Name, 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 on remote system") return nil } // Create backup backupPath := jailLocalPath + ".backup." + fmt.Sprintf("%d", time.Now().Unix()) backupScript := fmt.Sprintf("cp %s %s", jailLocalPath, backupPath) if _, err := sc.runRemoteCommand(ctx, []string{backupScript}); err != nil { return fmt.Errorf("failed to create backup on server %s: %w", sc.server.Name, err) } config.DebugLog("Created backup of jail.local at %s on server %s", backupPath, sc.server.Name) // Ensure jail.d directory exists ensureDirScript := fmt.Sprintf("mkdir -p %s", jailDPath) if _, err := sc.runRemoteCommand(ctx, []string{ensureDirScript}); err != nil { return fmt.Errorf("failed to create jail.d directory on server %s: %w", sc.server.Name, err) } // Write each jail to its own .local file migratedCount := 0 for jailName, jailContent := range sections { if jailName == "" { continue } jailFilePath := fmt.Sprintf("%s/%s.local", jailDPath, jailName) // Check if .local file already exists checkFileScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailFilePath) fileOut, err := sc.runRemoteCommand(ctx, []string{checkFileScript}) if err == nil && strings.TrimSpace(fileOut) == "exists" { config.DebugLog("Skipping migration for jail %s on server %s: .local file already exists", jailName, sc.server.Name) continue } // Write jail content to .local file using heredoc // Escape single quotes in content for shell escapedContent := strings.ReplaceAll(jailContent, "'", "'\"'\"'") writeScript := fmt.Sprintf(`cat > %s <<'JAILEOF' %s JAILEOF '`, jailFilePath, escapedContent) if _, err := sc.runRemoteCommand(ctx, []string{writeScript}); err != nil { return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) } config.DebugLog("Migrated jail %s to %s on server %s", jailName, jailFilePath, sc.server.Name) migratedCount++ } // Only rewrite jail.local if we migrated something if migratedCount > 0 { // Rewrite jail.local with only DEFAULT section // Escape single quotes in defaultContent for shell escapedDefault := strings.ReplaceAll(defaultContent, "'", "'\"'\"'") writeLocalScript := fmt.Sprintf(`cat > %s <<'LOCALEOF' %s LOCALEOF '`, jailLocalPath, escapedDefault) if _, err := sc.runRemoteCommand(ctx, []string{writeLocalScript}); err != nil { return fmt.Errorf("failed to rewrite jail.local: %w", err) } config.DebugLog("Migration completed on server %s: moved %d jails to jail.d/", sc.server.Name, migratedCount) } return nil } // CreateJail implements Connector. func (sc *SSHConnector) CreateJail(ctx context.Context, jailName, content string) error { // Validate jail name if err := ValidateJailName(jailName); err != nil { return err } fail2banPath := sc.getFail2banPath(ctx) jailDPath := filepath.Join(fail2banPath, "jail.d") // Ensure jail.d directory exists _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", jailDPath}) if 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 localPath := filepath.Join(jailDPath, jailName+".local") if err := sc.writeRemoteFile(ctx, localPath, content); err != nil { return fmt.Errorf("failed to create jail file: %w", err) } return nil } // DeleteJail implements Connector. func (sc *SSHConnector) DeleteJail(ctx context.Context, jailName string) error { // Validate jail name if err := ValidateJailName(jailName); err != nil { return err } fail2banPath := sc.getFail2banPath(ctx) localPath := filepath.Join(fail2banPath, "jail.d", jailName+".local") confPath := filepath.Join(fail2banPath, "jail.d", jailName+".conf") // Delete both .local and .conf files if they exist (rm -f doesn't error if file doesn't exist) // Use a single command to delete both files _, err := sc.runRemoteCommand(ctx, []string{"rm", "-f", localPath, confPath}) if err != nil { return fmt.Errorf("failed to delete jail files %s or %s: %w", localPath, confPath, err) } return nil } // CreateFilter implements Connector. func (sc *SSHConnector) CreateFilter(ctx context.Context, filterName, content string) error { // Validate filter name if err := ValidateFilterName(filterName); err != nil { return err } fail2banPath := sc.getFail2banPath(ctx) filterDPath := filepath.Join(fail2banPath, "filter.d") // Ensure filter.d directory exists _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", filterDPath}) if err != nil { return fmt.Errorf("failed to create filter.d directory: %w", err) } // Write the file localPath := filepath.Join(filterDPath, filterName+".local") if err := sc.writeRemoteFile(ctx, localPath, content); err != nil { return fmt.Errorf("failed to create filter file: %w", err) } return nil } // DeleteFilter implements Connector. func (sc *SSHConnector) DeleteFilter(ctx context.Context, filterName string) error { // Validate filter name if err := ValidateFilterName(filterName); err != nil { return err } fail2banPath := sc.getFail2banPath(ctx) localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local") confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf") // Delete both .local and .conf files if they exist (rm -f doesn't error if file doesn't exist) // Use a single command to delete both files _, err := sc.runRemoteCommand(ctx, []string{"rm", "-f", localPath, confPath}) if err != nil { return fmt.Errorf("failed to delete filter files %s or %s: %w", localPath, confPath, err) } return nil } // parseJailConfigContent parses jail configuration content and returns JailInfo slice. func parseJailConfigContent(content string) []JailInfo { var jails []JailInfo scanner := bufio.NewScanner(strings.NewReader(content)) var currentJail string enabled := true // Sections that should be ignored (not jails) ignoredSections := map[string]bool{ "DEFAULT": true, "INCLUDES": true, } for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { if currentJail != "" && !ignoredSections[currentJail] { jails = append(jails, JailInfo{ JailName: currentJail, Enabled: enabled, }) } currentJail = strings.Trim(line, "[]") enabled = true } else if strings.HasPrefix(strings.ToLower(line), "enabled") { parts := strings.Split(line, "=") if len(parts) == 2 { value := strings.TrimSpace(parts[1]) enabled = strings.EqualFold(value, "true") } } } if currentJail != "" && !ignoredSections[currentJail] { jails = append(jails, JailInfo{ JailName: currentJail, Enabled: enabled, }) } return jails }