2025-11-12 15:52:34 +01:00
package fail2ban
import (
2025-11-12 16:25:16 +01:00
"bufio"
2026-01-19 23:45:02 +01:00
"bytes"
2025-11-12 15:52:34 +01:00
"context"
"encoding/base64"
2025-12-05 23:54:21 +01:00
"errors"
2025-11-12 15:52:34 +01:00
"fmt"
2025-11-13 22:47:18 +01:00
"os"
2025-11-12 15:52:34 +01:00
"os/exec"
2025-12-05 16:47:05 +01:00
"path/filepath"
2025-11-12 15:52:34 +01:00
"sort"
"strconv"
"strings"
2025-11-12 16:25:16 +01:00
"sync"
2026-01-19 23:45:02 +01:00
"syscall"
2025-12-06 15:32:28 +01:00
"time"
2025-11-12 15:52:34 +01:00
"github.com/swissmakers/fail2ban-ui/internal/config"
)
2026-02-09 19:56:43 +01:00
// sshEnsureActionScript only deploys action.d/ui-custom-action.conf.
// jail.local is managed by EnsureJailLocalStructure
2025-11-12 16:25:16 +01:00
const sshEnsureActionScript = ` python3 - << ' PY '
2025-11-12 15:52:34 +01:00
import base64
import pathlib
2025-12-30 01:10:49 +01:00
import sys
2025-11-12 15:52:34 +01:00
2025-12-30 01:10:49 +01:00
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 )
except Exception as e :
sys . stderr . write ( f "Error: {e}\n" )
sys . exit ( 1 )
2025-11-12 15:52:34 +01:00
PY `
// SSHConnector connects to a remote Fail2ban instance over SSH.
type SSHConnector struct {
2025-12-30 10:46:44 +01:00
server config . Fail2banServer
fail2banPath string // Cache the fail2ban path
pathCached bool // Track if path is cached
pathMutex sync . RWMutex
2025-11-12 15:52:34 +01:00
}
// 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 }
2025-12-30 01:10:49 +01:00
// 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
2025-11-12 15:52:34 +01:00
}
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
}
2025-11-12 16:25:16 +01:00
// Use parallel execution for better performance
type jailResult struct {
jail JailInfo
err error
}
results := make ( chan jailResult , len ( jails ) )
var wg sync . WaitGroup
2025-11-12 15:52:34 +01:00
for _ , jail := range jails {
2025-11-12 16:25:16 +01:00
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 {
2025-11-12 15:52:34 +01:00
continue
}
2025-11-12 16:25:16 +01:00
infos = append ( infos , result . jail )
2025-11-12 15:52:34 +01:00
}
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:" ) {
2025-12-30 16:40:57 +01:00
// Use SplitN to only split on the first colon, preserving IPv6 addresses
parts := strings . SplitN ( line , ":" , 2 )
2025-11-12 15:52:34 +01:00
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
}
2026-01-07 16:14:48 +01:00
func ( sc * SSHConnector ) BanIP ( ctx context . Context , jail , ip string ) error {
_ , err := sc . runFail2banCommand ( ctx , "set" , jail , "banip" , ip )
return err
}
2025-11-12 15:52:34 +01:00
func ( sc * SSHConnector ) Reload ( ctx context . Context ) error {
_ , err := sc . runFail2banCommand ( ctx , "reload" )
return err
}
2025-12-17 19:16:20 +01:00
// 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
2025-11-12 15:52:34 +01:00
func ( sc * SSHConnector ) Restart ( ctx context . Context ) error {
2025-12-17 19:16:20 +01:00
_ , err := sc . RestartWithMode ( ctx )
2025-11-12 15:52:34 +01:00
return err
}
2025-12-17 19:16:20 +01:00
// 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
2025-12-30 01:10:49 +01:00
out , err := sc . runRemoteCommand ( ctx , [ ] string { "systemctl" , "restart" , "fail2ban" } )
2025-12-17 19:16:20 +01:00
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 )
}
2025-12-30 01:10:49 +01:00
func ( sc * SSHConnector ) GetFilterConfig ( ctx context . Context , filterName string ) ( string , string , error ) {
2025-12-05 16:47:05 +01:00
// Validate filter name
2025-12-30 01:10:49 +01:00
filterName = strings . TrimSpace ( filterName )
if filterName == "" {
return "" , "" , fmt . Errorf ( "filter name cannot be empty" )
2025-12-05 16:47:05 +01:00
}
2025-12-30 01:10:49 +01:00
fail2banPath := sc . getFail2banPath ( ctx )
2025-12-05 16:47:05 +01:00
// Try .local first, then fallback to .conf
2025-12-30 01:10:49 +01:00
localPath := filepath . Join ( fail2banPath , "filter.d" , filterName + ".local" )
confPath := filepath . Join ( fail2banPath , "filter.d" , filterName + ".conf" )
2025-12-05 16:47:05 +01:00
2025-12-30 01:10:49 +01:00
content , err := sc . readRemoteFile ( ctx , localPath )
2025-12-05 16:47:05 +01:00
if err == nil {
2025-12-30 01:10:49 +01:00
return content , localPath , nil
2025-12-05 16:47:05 +01:00
}
// Fallback to .conf
2025-12-30 01:10:49 +01:00
content , err = sc . readRemoteFile ( ctx , confPath )
2025-11-12 15:52:34 +01:00
if err != nil {
2025-12-30 01:10:49 +01:00
return "" , "" , fmt . Errorf ( "failed to read remote filter config (tried .local and .conf): %w" , err )
2025-11-12 15:52:34 +01:00
}
2025-12-30 01:10:49 +01:00
return content , confPath , nil
2025-11-12 15:52:34 +01:00
}
2025-12-30 01:10:49 +01:00
func ( sc * SSHConnector ) SetFilterConfig ( ctx context . Context , filterName , content string ) error {
2025-12-05 16:47:05 +01:00
// Validate filter name
2025-12-30 01:10:49 +01:00
filterName = strings . TrimSpace ( filterName )
if filterName == "" {
2025-12-05 16:47:05 +01:00
return fmt . Errorf ( "filter name cannot be empty" )
}
2025-12-30 01:10:49 +01:00
fail2banPath := sc . getFail2banPath ( ctx )
filterDPath := filepath . Join ( fail2banPath , "filter.d" )
2025-12-05 16:47:05 +01:00
2025-12-30 01:10:49 +01:00
// Ensure directory exists
_ , err := sc . runRemoteCommand ( ctx , [ ] string { "mkdir" , "-p" , filterDPath } )
2025-12-05 16:47:05 +01:00
if err != nil {
2025-12-30 01:10:49 +01:00
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 {
2025-12-05 16:47:05 +01:00
return fmt . Errorf ( "failed to ensure filter .local file: %w" , err )
}
// Write to .local file
2025-12-30 01:10:49 +01:00
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
2025-11-12 15:52:34 +01:00
}
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 ( )
2025-12-15 23:16:48 +01:00
settings := config . GetSettings ( )
actionConfig := config . BuildFail2banActionConfig ( callbackURL , sc . server . ID , settings . CallbackSecret )
2025-11-12 15:52:34 +01:00
payload := base64 . StdEncoding . EncodeToString ( [ ] byte ( actionConfig ) )
script := strings . ReplaceAll ( sshEnsureActionScript , "__PAYLOAD__" , payload )
2025-11-12 16:25:16 +01:00
// Base64 encode the entire script to avoid shell escaping issues
scriptB64 := base64 . StdEncoding . EncodeToString ( [ ] byte ( script ) )
2025-11-14 11:44:23 +01:00
// 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 ... )
2026-01-19 23:45:02 +01:00
// 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 ,
}
2025-11-14 11:44:23 +01:00
// 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 , " " ) )
}
2026-01-19 23:45:02 +01:00
// 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 ) )
2025-11-14 11:44:23 +01:00
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
2025-11-12 15:52:34 +01:00
}
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:" ) {
2025-12-30 16:40:57 +01:00
// Use SplitN to only split on the first colon
parts := strings . SplitN ( line , ":" , 2 )
2025-11-12 15:52:34 +01:00
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 )
}
2025-12-17 19:16:20 +01:00
// 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
}
2025-11-12 15:52:34 +01:00
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 )
2026-01-19 23:45:02 +01:00
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 ,
}
2025-11-12 15:52:34 +01:00
settingSnapshot := config . GetSettings ( )
if settingSnapshot . Debug {
config . DebugLog ( "SSH command [%s]: ssh %s" , sc . server . Name , strings . Join ( args , " " ) )
}
2026-01-19 23:45:02 +01:00
// 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 )
}
2025-11-12 15:52:34 +01:00
if settingSnapshot . Debug {
2026-01-19 23:45:02 +01:00
config . DebugLog ( "SSH command output [%s]: %s" , sc . server . Name , output )
2025-11-12 15:52:34 +01:00
}
2026-01-19 23:45:02 +01:00
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 ( )
2025-11-12 15:52:34 +01:00
}
}
func ( sc * SSHConnector ) buildSSHArgs ( command [ ] string ) [ ] string {
args := [ ] string { "-o" , "BatchMode=yes" }
2025-12-30 01:10:49 +01:00
// Add connection timeout to prevent hanging
args = append ( args ,
"-o" , "ConnectTimeout=10" ,
"-o" , "ServerAliveInterval=5" ,
"-o" , "ServerAliveCountMax=2" ,
)
2025-11-13 22:47:18 +01:00
// 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" ,
)
}
2025-12-30 01:10:49 +01:00
// 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
)
2025-11-12 15:52:34 +01:00
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
}
2025-11-12 16:25:16 +01:00
2025-12-30 01:10:49 +01:00
// listRemoteFiles lists files in a remote directory matching a pattern.
2025-12-30 10:46:44 +01:00
// Uses find command which works reliably with FACL permissions.
2025-12-30 01:10:49 +01:00
func ( sc * SSHConnector ) listRemoteFiles ( ctx context . Context , directory , pattern string ) ( [ ] string , error ) {
2025-12-30 10:46:44 +01:00
// 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 )
2025-12-30 01:10:49 +01:00
2025-12-30 10:46:44 +01:00
out , err := sc . runRemoteCommand ( ctx , [ ] string { cmd } )
2025-12-30 01:10:49 +01:00
if err != nil {
2025-12-30 10:46:44 +01:00
// 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
2025-12-30 01:10:49 +01:00
}
2025-12-30 10:46:44 +01:00
// If find succeeds but directory doesn't exist, it will return empty output
// This is fine - we'll just return an empty list
2025-12-30 01:10:49 +01:00
var files [ ] string
for _ , line := range strings . Split ( out , "\n" ) {
line = strings . TrimSpace ( line )
2025-12-30 10:46:44 +01:00
// 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
2025-12-30 01:10:49 +01:00
}
}
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 )
2025-12-30 10:46:44 +01:00
_ , err := sc . runRemoteCommand ( ctx , [ ] string { script } )
2025-12-30 01:10:49 +01:00
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 )
2025-12-30 10:46:44 +01:00
_ , err := sc . runRemoteCommand ( ctx , [ ] string { script } )
2025-12-30 01:10:49 +01:00
if err != nil {
return fmt . Errorf ( "failed to ensure remote .local file %s: %w" , localPath , err )
}
return nil
}
2025-11-12 16:25:16 +01:00
2025-12-30 01:10:49 +01:00
// getFail2banPath detects the fail2ban configuration path on the remote system.
// Returns /config/fail2ban for linuxserver images, or /etc/fail2ban for standard installations.
2025-12-30 10:46:44 +01:00
// Uses caching to avoid repeated SSH calls.
2025-12-30 01:10:49 +01:00
func ( sc * SSHConnector ) getFail2banPath ( ctx context . Context ) string {
2025-12-30 10:46:44 +01:00
// 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 } )
2025-11-12 16:25:16 +01:00
if err == nil {
2025-12-30 01:10:49 +01:00
path := strings . TrimSpace ( out )
if path != "" {
2025-12-30 10:46:44 +01:00
sc . fail2banPath = path
sc . pathCached = true
2025-12-30 01:10:49 +01:00
return path
2025-12-03 20:43:44 +01:00
}
2025-11-12 16:25:16 +01:00
}
2025-12-30 01:10:49 +01:00
// Default to /etc/fail2ban
2025-12-30 10:46:44 +01:00
sc . fail2banPath = "/etc/fail2ban"
sc . pathCached = true
return sc . fail2banPath
2025-12-30 01:10:49 +01:00
}
// GetAllJails implements Connector.
// Discovers all jails from filesystem (mirrors local connector behavior).
2025-12-30 10:46:44 +01:00
// Optimized to read all files in a single SSH command instead of individual reads.
2025-12-30 01:10:49 +01:00
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
2025-11-12 16:25:16 +01:00
2025-12-30 10:46:44 +01:00
// 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 )
2025-12-30 01:10:49 +01:00
// 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 ] {
2025-11-12 16:25:16 +01:00
continue
}
2025-12-30 01:10:49 +01:00
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 )
2025-12-05 16:47:05 +01:00
continue
}
2025-12-30 01:10:49 +01:00
jails := parseJailConfigContent ( content )
for _ , jail := range jails {
if jail . JailName != "" && jail . JailName != "DEFAULT" && ! processedJails [ jail . JailName ] {
allJails = append ( allJails , jail )
processedJails [ jail . JailName ] = true
2025-12-05 16:47:05 +01:00
}
}
}
}
2025-12-30 01:10:49 +01:00
// 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 ] {
2025-12-05 16:47:05 +01:00
continue
}
2025-12-30 01:10:49 +01:00
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 )
2025-12-05 16:47:05 +01:00
continue
}
2025-12-30 01:10:49 +01:00
jails := parseJailConfigContent ( content )
for _ , jail := range jails {
if jail . JailName != "" && jail . JailName != "DEFAULT" && ! processedJails [ jail . JailName ] {
allJails = append ( allJails , jail )
processedJails [ jail . JailName ] = true
2025-12-05 16:47:05 +01:00
}
2025-11-12 16:25:16 +01:00
}
}
}
return allJails , nil
}
// UpdateJailEnabledStates implements Connector.
func ( sc * SSHConnector ) UpdateJailEnabledStates ( ctx context . Context , updates map [ string ] bool ) error {
2025-12-30 01:10:49 +01:00
fail2banPath := sc . getFail2banPath ( ctx )
jailDPath := filepath . Join ( fail2banPath , "jail.d" )
2025-12-05 16:47:05 +01:00
// Update each jail in its own .local file
2025-12-03 20:43:44 +01:00
for jailName , enabled := range updates {
2025-12-05 16:47:05 +01:00
// Validate jail name - skip empty or invalid names
jailName = strings . TrimSpace ( jailName )
if jailName == "" {
config . DebugLog ( "Skipping empty jail name in updates map" )
continue
}
2025-12-03 20:43:44 +01:00
2025-12-30 01:10:49 +01:00
localPath := filepath . Join ( jailDPath , jailName + ".local" )
confPath := filepath . Join ( jailDPath , jailName + ".conf" )
2025-12-05 16:47:05 +01:00
2025-12-30 10:46:44 +01:00
// 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 ( `
2025-12-05 16:47:05 +01:00
if [ ! - f "%s" ] ; then
if [ - f "%s" ] ; then
cp "%s" "%s"
else
echo "[%s]" > "%s"
fi
fi
2025-12-30 10:46:44 +01:00
cat "%s"
` , localPath , confPath , confPath , localPath , jailName , localPath , localPath )
2025-12-05 16:47:05 +01:00
2025-12-30 10:46:44 +01:00
content , err := sc . runRemoteCommand ( ctx , [ ] string { combinedScript } )
2025-12-03 20:43:44 +01:00
if err != nil {
2025-12-30 10:46:44 +01:00
return fmt . Errorf ( "failed to ensure and read .local file for jail %s: %w" , jailName , err )
2025-12-03 20:43:44 +01:00
}
// 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 )
}
2025-11-12 16:25:16 +01:00
} else {
outputLines = append ( outputLines , line )
}
}
2025-12-03 20:43:44 +01:00
// 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 ) )
}
}
2025-12-05 16:47:05 +01:00
// Write updated content to .local file
2025-12-03 20:43:44 +01:00
newContent := strings . Join ( outputLines , "\n" )
2025-12-05 16:47:05 +01:00
cmd := fmt . Sprintf ( "cat <<'EOF' | tee %s >/dev/null\n%s\nEOF" , localPath , newContent )
2025-12-30 10:46:44 +01:00
if _ , err := sc . runRemoteCommand ( ctx , [ ] string { cmd } ) ; err != nil {
2025-12-05 16:47:05 +01:00
return fmt . Errorf ( "failed to write jail .local file %s: %w" , localPath , err )
2025-12-03 20:43:44 +01:00
}
}
return nil
2025-11-12 16:25:16 +01:00
}
// GetFilters implements Connector.
2025-12-30 01:10:49 +01:00
// Discovers all filters from filesystem (mirrors local connector behavior).
2025-11-12 16:25:16 +01:00
func ( sc * SSHConnector ) GetFilters ( ctx context . Context ) ( [ ] string , error ) {
2025-12-30 01:10:49 +01:00
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
2025-11-12 19:09:01 +01:00
}
2025-12-30 01:10:49 +01:00
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
2025-11-12 19:09:01 +01:00
}
2025-12-30 01:10:49 +01:00
}
// 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 ) {
2025-11-12 19:09:01 +01:00
continue
}
2025-12-30 01:10:49 +01:00
baseName := strings . TrimSuffix ( filename , ".conf" )
if baseName == "" || processedFiles [ baseName ] {
continue
2025-11-12 19:09:01 +01:00
}
2025-12-30 01:10:49 +01:00
processedFiles [ baseName ] = true
filterMap [ baseName ] = true
2025-11-12 16:25:16 +01:00
}
}
2025-12-30 01:10:49 +01:00
// Convert map to sorted slice
var filters [ ] string
for name := range filterMap {
filters = append ( filters , name )
}
2025-11-18 09:25:15 +01:00
sort . Strings ( filters )
2025-12-30 01:10:49 +01:00
2025-11-12 16:25:16 +01:00
return filters , nil
}
2026-01-16 11:39:51 +01:00
// 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
}
2025-11-12 16:25:16 +01:00
// TestFilter implements Connector.
2026-01-16 11:39:51 +01:00
func ( sc * SSHConnector ) TestFilter ( ctx context . Context , filterName string , logLines [ ] string , filterContent string ) ( string , string , error ) {
2025-11-18 09:25:15 +01:00
cleaned := normalizeLogLines ( logLines )
if len ( cleaned ) == 0 {
2025-12-06 13:11:15 +01:00
return "No log lines provided.\n" , "" , nil
2025-11-12 16:25:16 +01:00
}
2025-11-12 19:09:01 +01:00
// Sanitize filter name to prevent path traversal
filterName = strings . TrimSpace ( filterName )
if filterName == "" {
2025-12-06 13:11:15 +01:00
return "" , "" , fmt . Errorf ( "filter name cannot be empty" )
2025-11-12 16:25:16 +01:00
}
2025-11-12 19:09:01 +01:00
// Remove any path components
filterName = strings . ReplaceAll ( filterName , "/" , "" )
filterName = strings . ReplaceAll ( filterName , ".." , "" )
2025-11-12 16:25:16 +01:00
2025-12-30 10:46:44 +01:00
// Get the fail2ban path dynamically
fail2banPath := sc . getFail2banPath ( ctx )
2025-12-06 13:11:15 +01:00
// Try .local first, then fallback to .conf
2025-12-30 10:46:44 +01:00
localPath := filepath . Join ( fail2banPath , "filter.d" , filterName + ".local" )
confPath := filepath . Join ( fail2banPath , "filter.d" , filterName + ".conf" )
2025-11-12 16:25:16 +01:00
2025-11-18 09:25:15 +01:00
const heredocMarker = "F2B_FILTER_TEST_LOG"
2026-01-16 11:39:51 +01:00
const filterContentMarker = "F2B_FILTER_CONTENT"
2025-11-18 09:25:15 +01:00
logContent := strings . Join ( cleaned , "\n" )
2026-01-16 11:39:51 +01:00
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 ( `
2025-11-18 09:25:15 +01:00
set - e
2025-12-06 13:11:15 +01:00
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
2025-11-18 09:25:15 +01:00
exit 1
fi
2025-12-06 13:11:15 +01:00
echo "FILTER_PATH:$FILTER_PATH"
2025-11-18 09:25:15 +01:00
TMPFILE = $ ( mktemp / tmp / fail2ban - test - XXXXXX . log )
trap ' rm - f "$TMPFILE" ' EXIT
2025-12-06 13:11:15 +01:00
cat << ' % [ 3 ] s ' > "$TMPFILE"
% [ 4 ] s
2025-11-18 09:25:15 +01:00
% [ 3 ] s
fail2ban - regex "$TMPFILE" "$FILTER_PATH" || true
2025-12-06 13:11:15 +01:00
` , localPath , confPath , heredocMarker , logContent )
2026-01-16 11:39:51 +01:00
}
2025-11-18 09:25:15 +01:00
2025-12-30 10:46:44 +01:00
out , err := sc . runRemoteCommand ( ctx , [ ] string { script } )
2025-11-18 09:25:15 +01:00
if err != nil {
2025-12-06 13:11:15 +01:00
return "" , "" , err
2025-11-12 16:25:16 +01:00
}
2025-12-06 13:11:15 +01:00
// 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
2025-11-12 16:25:16 +01:00
}
2025-12-03 20:43:44 +01:00
// GetJailConfig implements Connector.
2025-12-30 01:10:49 +01:00
func ( sc * SSHConnector ) GetJailConfig ( ctx context . Context , jail string ) ( string , string , error ) {
2025-12-05 16:47:05 +01:00
// Validate jail name
jail = strings . TrimSpace ( jail )
if jail == "" {
2025-12-30 01:10:49 +01:00
return "" , "" , fmt . Errorf ( "jail name cannot be empty" )
2025-12-05 16:47:05 +01:00
}
2025-12-30 01:10:49 +01:00
fail2banPath := sc . getFail2banPath ( ctx )
2025-12-05 16:47:05 +01:00
// Try .local first, then fallback to .conf
2025-12-30 01:10:49 +01:00
localPath := filepath . Join ( fail2banPath , "jail.d" , jail + ".local" )
confPath := filepath . Join ( fail2banPath , "jail.d" , jail + ".conf" )
2025-12-05 16:47:05 +01:00
2025-12-30 01:10:49 +01:00
content , err := sc . readRemoteFile ( ctx , localPath )
2025-12-05 16:47:05 +01:00
if err == nil {
2025-12-30 01:10:49 +01:00
return content , localPath , nil
2025-12-05 16:47:05 +01:00
}
// Fallback to .conf
2025-12-30 01:10:49 +01:00
content , err = sc . readRemoteFile ( ctx , confPath )
2025-12-03 20:43:44 +01:00
if err != nil {
2025-12-30 01:10:49 +01:00
// If neither exists, return empty jail section with .local path (will be created on save)
return fmt . Sprintf ( "[%s]\n" , jail ) , localPath , nil
2025-12-03 20:43:44 +01:00
}
2025-12-30 01:10:49 +01:00
return content , confPath , nil
2025-12-03 20:43:44 +01:00
}
// SetJailConfig implements Connector.
func ( sc * SSHConnector ) SetJailConfig ( ctx context . Context , jail , content string ) error {
2025-12-05 16:47:05 +01:00
// Validate jail name
jail = strings . TrimSpace ( jail )
if jail == "" {
return fmt . Errorf ( "jail name cannot be empty" )
}
2025-12-30 01:10:49 +01:00
fail2banPath := sc . getFail2banPath ( ctx )
jailDPath := filepath . Join ( fail2banPath , "jail.d" )
2025-12-05 16:47:05 +01:00
2025-12-03 20:43:44 +01:00
// Ensure jail.d directory exists
2025-12-30 01:10:49 +01:00
_ , err := sc . runRemoteCommand ( ctx , [ ] string { "mkdir" , "-p" , jailDPath } )
2025-12-03 20:43:44 +01:00
if err != nil {
return fmt . Errorf ( "failed to create jail.d directory: %w" , err )
}
2025-12-05 16:47:05 +01:00
// Ensure .local file exists (copy from .conf if needed)
2025-12-30 01:10:49 +01:00
if err := sc . ensureRemoteLocalFile ( ctx , jailDPath , jail ) ; err != nil {
2025-12-05 16:47:05 +01:00
return fmt . Errorf ( "failed to ensure .local file for jail %s: %w" , jail , err )
}
// Write to .local file
2025-12-30 01:10:49 +01:00
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
2025-12-03 20:43:44 +01:00
}
// 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 )
}
2025-12-30 10:46:44 +01:00
out , err := sc . runRemoteCommand ( ctx , [ ] string { script } )
2025-12-03 20:43:44 +01:00
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
}
2025-12-05 23:21:08 +01:00
// 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
2025-12-30 10:46:44 +01:00
resolveOut , err := sc . runRemoteCommand ( ctx , [ ] string { resolveScript } )
2025-12-05 23:21:08 +01:00
if err != nil {
return originalPath , "" , nil , fmt . Errorf ( "failed to resolve variables: %w" , err )
}
resolveOut = strings . TrimSpace ( resolveOut )
if strings . HasPrefix ( resolveOut , "ERROR:" ) {
2025-12-05 23:54:21 +01:00
return originalPath , "" , nil , errors . New ( strings . TrimPrefix ( resolveOut , "ERROR:" ) )
2025-12-05 23:21:08 +01:00
}
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
}
2025-12-04 19:42:43 +01:00
// UpdateDefaultSettings implements Connector.
func ( sc * SSHConnector ) UpdateDefaultSettings ( ctx context . Context , settings config . AppSettings ) error {
jailLocalPath := "/etc/fail2ban/jail.local"
2026-02-09 22:33:13 +01:00
// Check jail.local integrity first
exists , hasUI , chkErr := sc . CheckJailLocalIntegrity ( ctx )
if chkErr != nil {
config . DebugLog ( "Warning: could not check jail.local integrity on %s: %v" , sc . server . Name , chkErr )
}
if exists && ! hasUI {
// File belongs to the user – never overwrite
return fmt . Errorf ( "jail.local on server %s is not managed by Fail2ban-UI - skipping settings update (please migrate your jail.local manually)" , sc . server . Name )
}
if ! exists {
// File was deleted (e.g. user finished migration) – create a fresh managed file
config . DebugLog ( "jail.local does not exist on server %s - initializing fresh managed file" , sc . server . Name )
if err := sc . EnsureJailLocalStructure ( ctx ) ; err != nil {
return fmt . Errorf ( "failed to initialize jail.local on server %s: %w" , sc . server . Name , err )
}
}
// Read existing file
2025-12-04 19:42:43 +01:00
existingContent , err := sc . runRemoteCommand ( ctx , [ ] string { "cat" , jailLocalPath } )
if err != nil {
existingContent = ""
}
// Remove commented lines (lines starting with #) using sed
if existingContent != "" {
// Use sed to remove lines starting with # (but preserve empty lines)
removeCommentsCmd := fmt . Sprintf ( "sed '/^[[:space:]]*#/d' %s" , jailLocalPath )
2025-12-30 10:46:44 +01:00
uncommentedContent , err := sc . runRemoteCommand ( ctx , [ ] string { removeCommentsCmd } )
2025-12-04 19:42:43 +01:00
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 == "" {
2026-01-21 19:23:42 +01:00
banactionVal = "nftables-multiport"
2025-12-04 19:42:43 +01:00
}
banactionAllportsVal := settings . BanactionAllports
if banactionAllportsVal == "" {
2026-01-21 19:23:42 +01:00
banactionAllportsVal = "nftables-allports"
2025-12-04 19:42:43 +01:00
}
2026-02-08 19:43:34 +01:00
chainVal := settings . Chain
if chainVal == "" {
chainVal = "INPUT"
}
2025-12-04 19:42:43 +01:00
// Define the keys we want to update
keysToUpdate := map [ string ] string {
2025-12-15 18:57:50 +01:00
"enabled" : fmt . Sprintf ( "enabled = %t" , settings . DefaultJailEnable ) ,
2025-12-04 19:42:43 +01:00
"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 ) ,
2026-02-08 19:43:34 +01:00
"chain" : fmt . Sprintf ( "chain = %s" , chainVal ) ,
}
if settings . BantimeRndtime != "" {
keysToUpdate [ "bantime.rndtime" ] = fmt . Sprintf ( "bantime.rndtime = %s" , settings . BantimeRndtime )
}
defaultKeysOrder := [ ] string { "enabled" , "bantime.increment" , "ignoreip" , "bantime" , "findtime" , "maxretry" , "banaction" , "banaction_allports" , "chain" }
if settings . BantimeRndtime != "" {
defaultKeysOrder = append ( defaultKeysOrder , "bantime.rndtime" )
2025-12-04 19:42:43 +01:00
}
// 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]" }
2026-02-08 19:43:34 +01:00
for _ , key := range defaultKeysOrder {
2025-12-04 19:42:43 +01:00
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 )
2025-12-30 10:46:44 +01:00
_ , err = sc . runRemoteCommand ( ctx , [ ] string { cmd } )
2025-12-04 19:42:43 +01:00
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 , "'" , "'\"'\"'" )
}
2025-12-30 16:13:36 +01:00
// Convert boolean values to Python boolean literals
defaultJailEnablePython := "False"
if settings . DefaultJailEnable {
defaultJailEnablePython = "True"
}
bantimeIncrementPython := "False"
if settings . BantimeIncrement {
bantimeIncrementPython = "True"
}
2026-02-08 19:43:34 +01:00
chainValEsc := escapeForShell ( chainVal )
bantimeRndtimeEsc := ""
if settings . BantimeRndtime != "" {
bantimeRndtimeEsc = escapeForShell ( settings . BantimeRndtime )
}
2025-12-04 19:42:43 +01:00
updateScript := fmt . Sprintf ( ` python3 << ' PY '
import re
jail_file = ' % s '
ignore_ip_str = ' % s '
banaction_val = ' % s '
banaction_allports_val = ' % s '
2025-12-30 16:13:36 +01:00
default_jail_enable_val = % s
bantime_increment_val = % s
2025-12-15 18:57:50 +01:00
bantime_val = ' % s '
findtime_val = ' % s '
maxretry_val = % d
2026-02-08 19:43:34 +01:00
chain_val = ' % s '
bantime_rndtime_val = ' % s '
2025-12-04 19:42:43 +01:00
keys_to_update = {
2025-12-30 16:13:36 +01:00
' enabled ' : ' enabled = ' + str ( default_jail_enable_val ) . lower ( ) ,
' bantime . increment ' : ' bantime . increment = ' + str ( bantime_increment_val ) . lower ( ) ,
2025-12-04 19:42:43 +01:00
' ignoreip ' : ' ignoreip = ' + ignore_ip_str ,
2025-12-15 18:57:50 +01:00
' bantime ' : ' bantime = ' + bantime_val ,
' findtime ' : ' findtime = ' + findtime_val ,
' maxretry ' : ' maxretry = ' + str ( maxretry_val ) ,
2025-12-04 19:42:43 +01:00
' banaction ' : ' banaction = ' + banaction_val ,
2026-02-08 19:43:34 +01:00
' banaction_allports ' : ' banaction_allports = ' + banaction_allports_val ,
' chain ' : ' chain = ' + chain_val
2025-12-04 19:42:43 +01:00
}
2026-02-08 19:43:34 +01:00
if bantime_rndtime_val :
keys_to_update [ ' bantime . rndtime ' ] = ' bantime . rndtime = ' + bantime_rndtime_val
keys_order = [ ' enabled ' , ' bantime . increment ' , ' ignoreip ' , ' bantime ' , ' findtime ' , ' maxretry ' , ' banaction ' , ' banaction_allports ' , ' chain ' ]
if bantime_rndtime_val :
keys_order . append ( ' bantime . rndtime ' )
2025-12-04 19:42:43 +01:00
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
2026-02-08 19:43:34 +01:00
# When user cleared bantime . rndtime , remove the line from config instead of keeping old value
if not bantime_rndtime_val and re . match ( r ' ^ \ s * bantime \ . rndtime \ s *= ' , stripped ) :
key_updated = True
# don ' t append : line is removed
if not key_updated :
for key , new_value in keys_to_update . items ( ) :
pattern = r ' ^ \ s * ' + re . escape ( key ) + r ' \ s *= '
if re . match ( pattern , stripped ) :
output_lines . append ( new_value + '\n' )
keys_updated . add ( key )
key_updated = True
break
2025-12-04 19:42:43 +01:00
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" ]
2026-02-08 19:43:34 +01:00
for key in keys_order :
2025-12-04 19:42:43 +01:00
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
2026-02-08 19:43:34 +01:00
for key in keys_order :
2025-12-04 19:42:43 +01:00
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 )
2026-02-08 19:43:34 +01:00
PY ` , escapeForShell ( jailLocalPath ) , escapeForShell ( ignoreIPStr ) , escapeForShell ( banactionVal ) , escapeForShell ( banactionAllportsVal ) , defaultJailEnablePython , bantimeIncrementPython , escapeForShell ( settings . Bantime ) , escapeForShell ( settings . Findtime ) , settings . Maxretry , chainValEsc , bantimeRndtimeEsc )
2025-12-04 19:42:43 +01:00
2025-12-30 10:46:44 +01:00
_ , err = sc . runRemoteCommand ( ctx , [ ] string { updateScript } )
2025-12-04 19:42:43 +01:00
return err
}
2026-02-09 19:56:43 +01:00
// CheckJailLocalIntegrity implements Connector.
func ( sc * SSHConnector ) CheckJailLocalIntegrity ( ctx context . Context ) ( bool , bool , error ) {
const jailLocalPath = "/etc/fail2ban/jail.local"
output , err := sc . runRemoteCommand ( ctx , [ ] string { "cat" , jailLocalPath } )
if err != nil {
// "No such file" means jail.local does not exist – that's fine
if strings . Contains ( err . Error ( ) , "No such file" ) || strings . Contains ( output , "No such file" ) {
return false , false , nil
}
return false , false , fmt . Errorf ( "failed to read jail.local on %s: %w" , sc . server . Name , err )
}
hasUIAction := strings . Contains ( output , "ui-custom-action" )
return true , hasUIAction , nil
}
2025-12-04 19:42:43 +01:00
// EnsureJailLocalStructure implements Connector.
2026-02-09 19:56:43 +01:00
// If JAIL_AUTOMIGRATION=true, it first migrates any legacy jails to jail.d/.
2025-12-04 19:42:43 +01:00
func ( sc * SSHConnector ) EnsureJailLocalStructure ( ctx context . Context ) error {
jailLocalPath := "/etc/fail2ban/jail.local"
2026-02-09 19:56:43 +01:00
// Check whether jail.local already exists and whether it belongs to us
exists , hasUI , chkErr := sc . CheckJailLocalIntegrity ( ctx )
if chkErr != nil {
config . DebugLog ( "Warning: could not check jail.local integrity on %s: %v" , sc . server . Name , chkErr )
// Proceed cautiously; treat as "not ours" if the check itself failed.
}
if exists && ! hasUI {
// The file belongs to the user; never overwrite it.
config . DebugLog ( "jail.local on server %s exists but is not managed by Fail2ban-UI - skipping overwrite" , sc . server . Name )
return nil
}
// Run experimental migration if enabled
if isJailAutoMigrationEnabled ( ) {
config . DebugLog ( "JAIL_AUTOMIGRATION=true: running experimental jail.local → jail.d/ migration for SSH server %s" , sc . server . Name )
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 )
}
}
// Build the managed jail.local content
2025-12-04 19:42:43 +01:00
settings := config . GetSettings ( )
ignoreIPStr := strings . Join ( settings . IgnoreIPs , " " )
if ignoreIPStr == "" {
ignoreIPStr = "127.0.0.1/8 ::1"
}
banactionVal := settings . Banaction
if banactionVal == "" {
2026-01-21 19:23:42 +01:00
banactionVal = "nftables-multiport"
2025-12-04 19:42:43 +01:00
}
banactionAllportsVal := settings . BanactionAllports
if banactionAllportsVal == "" {
2026-01-21 19:23:42 +01:00
banactionAllportsVal = "nftables-allports"
2025-12-04 19:42:43 +01:00
}
2026-02-08 19:43:34 +01:00
chainVal := settings . Chain
if chainVal == "" {
chainVal = "INPUT"
}
2025-12-04 19:42:43 +01:00
2025-12-17 12:28:26 +01:00
banner := config . JailLocalBanner ( )
2025-12-04 19:42:43 +01:00
2025-12-17 12:28:26 +01:00
defaultSection := fmt . Sprintf ( ` [ DEFAULT ]
enabled = % t
bantime . increment = % t
ignoreip = % s
bantime = % s
findtime = % s
maxretry = % d
banaction = % s
banaction_allports = % s
2026-02-08 19:43:34 +01:00
chain = % s
2025-12-04 19:42:43 +01:00
2025-12-17 12:28:26 +01:00
` ,
settings . DefaultJailEnable ,
settings . BantimeIncrement ,
ignoreIPStr ,
settings . Bantime ,
settings . Findtime ,
settings . Maxretry ,
banactionVal ,
banactionAllportsVal ,
2026-02-08 19:43:34 +01:00
chainVal ,
2025-12-17 12:28:26 +01:00
)
2026-02-08 19:43:34 +01:00
if settings . BantimeRndtime != "" {
defaultSection += fmt . Sprintf ( "bantime.rndtime = %s\n" , settings . BantimeRndtime )
}
defaultSection += "\n"
2025-12-04 19:42:43 +01:00
2026-01-21 19:23:42 +01:00
actionMwlgConfig := ` # Custom Fail2Ban action for UI callbacks
2025-12-17 12:28:26 +01:00
action_mwlg = % ( action_ ) s
2026-01-21 19:23:42 +01:00
ui - custom - action [ logpath = "%(logpath)s" , chain = "%(chain)s" ]
2025-12-04 19:42:43 +01:00
2025-12-17 12:28:26 +01:00
`
2025-12-04 19:42:43 +01:00
2025-12-17 12:28:26 +01:00
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 , "'" , "'\"'\"'" )
2025-12-04 19:42:43 +01:00
2025-12-17 12:28:26 +01:00
// Write the rebuilt content via heredoc over SSH
writeScript := fmt . Sprintf ( ` cat > % s << ' JAILLOCAL '
% s
JAILLOCAL
` , jailLocalPath , escaped )
2025-12-30 10:46:44 +01:00
_ , err := sc . runRemoteCommand ( ctx , [ ] string { writeScript } )
2025-12-04 19:42:43 +01:00
return err
}
2025-12-06 15:32:28 +01:00
// MigrateJailsFromJailLocalRemote migrates non-commented jail sections from jail.local to jail.d/*.local files on remote system.
2026-02-09 19:56:43 +01:00
// EXPERIMENTAL: Only called when JAIL_AUTOMIGRATION=true. It is always best to migrate a pre-existing jail.local by hand.
2025-12-06 15:32:28 +01:00
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 )
2025-12-30 10:46:44 +01:00
out , err := sc . runRemoteCommand ( ctx , [ ] string { checkScript } )
2025-12-06 15:32:28 +01:00
if err != nil || strings . TrimSpace ( out ) != "exists" {
2025-12-30 01:10:49 +01:00
config . DebugLog ( "No jails to migrate from jail.local on server %s (file does not exist)" , sc . server . Name )
2025-12-06 15:32:28 +01:00
return nil // Nothing to migrate
}
// Read jail.local content
content , err := sc . runRemoteCommand ( ctx , [ ] string { "cat" , jailLocalPath } )
if err != nil {
2025-12-30 01:10:49 +01:00
return fmt . Errorf ( "failed to read jail.local on server %s: %w" , sc . server . Name , err )
2025-12-06 15:32:28 +01:00
}
// Parse content locally to extract non-commented sections
sections , defaultContent , err := parseJailSectionsUncommented ( content )
if err != nil {
2025-12-30 01:10:49 +01:00
return fmt . Errorf ( "failed to parse jail.local on server %s: %w" , sc . server . Name , err )
2025-12-06 15:32:28 +01:00
}
// 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 )
2025-12-30 10:46:44 +01:00
if _ , err := sc . runRemoteCommand ( ctx , [ ] string { backupScript } ) ; err != nil {
2025-12-30 01:10:49 +01:00
return fmt . Errorf ( "failed to create backup on server %s: %w" , sc . server . Name , err )
2025-12-06 15:32:28 +01:00
}
2025-12-30 01:10:49 +01:00
config . DebugLog ( "Created backup of jail.local at %s on server %s" , backupPath , sc . server . Name )
2025-12-06 15:32:28 +01:00
// Ensure jail.d directory exists
ensureDirScript := fmt . Sprintf ( "mkdir -p %s" , jailDPath )
2025-12-30 10:46:44 +01:00
if _ , err := sc . runRemoteCommand ( ctx , [ ] string { ensureDirScript } ) ; err != nil {
2025-12-30 01:10:49 +01:00
return fmt . Errorf ( "failed to create jail.d directory on server %s: %w" , sc . server . Name , err )
2025-12-06 15:32:28 +01:00
}
// 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 )
2025-12-30 10:46:44 +01:00
fileOut , err := sc . runRemoteCommand ( ctx , [ ] string { checkFileScript } )
2025-12-06 15:32:28 +01:00
if err == nil && strings . TrimSpace ( fileOut ) == "exists" {
2025-12-30 01:10:49 +01:00
config . DebugLog ( "Skipping migration for jail %s on server %s: .local file already exists" , jailName , sc . server . Name )
2025-12-06 15:32:28 +01:00
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
2026-02-09 19:56:43 +01:00
` , jailFilePath , escapedContent )
2025-12-30 10:46:44 +01:00
if _ , err := sc . runRemoteCommand ( ctx , [ ] string { writeScript } ) ; err != nil {
2025-12-06 15:32:28 +01:00
return fmt . Errorf ( "failed to write jail file %s: %w" , jailFilePath , err )
}
2025-12-30 01:10:49 +01:00
config . DebugLog ( "Migrated jail %s to %s on server %s" , jailName , jailFilePath , sc . server . Name )
2025-12-06 15:32:28 +01:00
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
2026-02-09 19:56:43 +01:00
` , jailLocalPath , escapedDefault )
2025-12-30 10:46:44 +01:00
if _ , err := sc . runRemoteCommand ( ctx , [ ] string { writeLocalScript } ) ; err != nil {
2025-12-06 15:32:28 +01:00
return fmt . Errorf ( "failed to rewrite jail.local: %w" , err )
}
2025-12-30 01:10:49 +01:00
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 )
2025-12-06 15:32:28 +01:00
}
return nil
}
2025-11-12 16:25:16 +01:00
// 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
2025-12-03 20:43:44 +01:00
// Sections that should be ignored (not jails)
ignoredSections := map [ string ] bool {
"DEFAULT" : true ,
"INCLUDES" : true ,
}
2025-11-12 16:25:16 +01:00
for scanner . Scan ( ) {
line := strings . TrimSpace ( scanner . Text ( ) )
if strings . HasPrefix ( line , "[" ) && strings . HasSuffix ( line , "]" ) {
2025-12-03 20:43:44 +01:00
if currentJail != "" && ! ignoredSections [ currentJail ] {
2025-11-12 16:25:16 +01:00
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" )
}
}
}
2025-12-03 20:43:44 +01:00
if currentJail != "" && ! ignoredSections [ currentJail ] {
2025-11-12 16:25:16 +01:00
jails = append ( jails , JailInfo {
JailName : currentJail ,
Enabled : enabled ,
} )
}
return jails
}