2025-11-12 15:52:34 +01:00
package fail2ban
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/swissmakers/fail2ban-ui/internal/config"
)
// AgentConnector connects to a remote fail2ban-agent via HTTP API.
type AgentConnector struct {
server config . Fail2banServer
base * url . URL
client * http . Client
}
// NewAgentConnector constructs a new AgentConnector.
func NewAgentConnector ( server config . Fail2banServer ) ( Connector , error ) {
if server . AgentURL == "" {
return nil , fmt . Errorf ( "agentUrl is required for agent connector" )
}
if server . AgentSecret == "" {
return nil , fmt . Errorf ( "agentSecret is required for agent connector" )
}
parsed , err := url . Parse ( server . AgentURL )
if err != nil {
return nil , fmt . Errorf ( "invalid agentUrl: %w" , err )
}
if parsed . Scheme == "" {
parsed . Scheme = "https"
}
client := & http . Client {
Timeout : 15 * time . Second ,
}
conn := & AgentConnector {
server : server ,
base : parsed ,
client : client ,
}
if err := conn . ensureAction ( context . Background ( ) ) ; err != nil {
fmt . Printf ( "warning: failed to ensure agent action for %s: %v\n" , server . Name , err )
}
return conn , nil
}
func ( ac * AgentConnector ) ID ( ) string {
return ac . server . ID
}
func ( ac * AgentConnector ) Server ( ) config . Fail2banServer {
return ac . server
}
func ( ac * AgentConnector ) ensureAction ( ctx context . Context ) error {
2025-12-15 23:16:48 +01:00
settings := config . GetSettings ( )
2025-11-12 15:52:34 +01:00
payload := map [ string ] any {
"name" : "ui-custom-action" ,
2025-12-15 23:16:48 +01:00
"config" : config . BuildFail2banActionConfig ( config . GetCallbackURL ( ) , ac . server . ID , settings . CallbackSecret ) ,
2025-11-12 15:52:34 +01:00
"callbackUrl" : config . GetCallbackURL ( ) ,
"setDefault" : true ,
}
return ac . put ( ctx , "/v1/actions/ui-custom" , payload , nil )
}
func ( ac * AgentConnector ) GetJailInfos ( ctx context . Context ) ( [ ] JailInfo , error ) {
var resp struct {
Jails [ ] JailInfo ` json:"jails" `
}
if err := ac . get ( ctx , "/v1/jails" , & resp ) ; err != nil {
return nil , err
}
return resp . Jails , nil
}
func ( ac * AgentConnector ) GetBannedIPs ( ctx context . Context , jail string ) ( [ ] string , error ) {
var resp struct {
Jail string ` json:"jail" `
BannedIPs [ ] string ` json:"bannedIPs" `
TotalBanned int ` json:"totalBanned" `
}
if err := ac . get ( ctx , fmt . Sprintf ( "/v1/jails/%s" , url . PathEscape ( jail ) ) , & resp ) ; err != nil {
return nil , err
}
if len ( resp . BannedIPs ) > 0 {
return resp . BannedIPs , nil
}
return [ ] string { } , nil
}
func ( ac * AgentConnector ) UnbanIP ( ctx context . Context , jail , ip string ) error {
payload := map [ string ] string { "ip" : ip }
return ac . post ( ctx , fmt . Sprintf ( "/v1/jails/%s/unban" , url . PathEscape ( jail ) ) , payload , nil )
}
2026-01-07 16:14:48 +01:00
func ( ac * AgentConnector ) BanIP ( ctx context . Context , jail , ip string ) error {
payload := map [ string ] string { "ip" : ip }
return ac . post ( ctx , fmt . Sprintf ( "/v1/jails/%s/ban" , url . PathEscape ( jail ) ) , payload , nil )
}
2025-11-12 15:52:34 +01:00
func ( ac * AgentConnector ) Reload ( ctx context . Context ) error {
return ac . post ( ctx , "/v1/actions/reload" , nil , nil )
}
func ( ac * AgentConnector ) Restart ( ctx context . Context ) error {
return ac . post ( ctx , "/v1/actions/restart" , nil , nil )
}
2025-12-17 19:16:20 +01:00
// RestartWithMode restarts the remote agent-managed Fail2ban service and
// always reports mode "restart". Any error is propagated to the caller.
func ( ac * AgentConnector ) RestartWithMode ( ctx context . Context ) ( string , error ) {
if err := ac . Restart ( ctx ) ; err != nil {
return "restart" , err
}
return "restart" , nil
}
2025-12-30 01:10:49 +01:00
func ( ac * AgentConnector ) GetFilterConfig ( ctx context . Context , jail string ) ( string , string , error ) {
2025-11-12 15:52:34 +01:00
var resp struct {
2025-12-30 01:10:49 +01:00
Config string ` json:"config" `
FilePath string ` json:"filePath" `
2025-11-12 15:52:34 +01:00
}
if err := ac . get ( ctx , fmt . Sprintf ( "/v1/filters/%s" , url . PathEscape ( jail ) ) , & resp ) ; err != nil {
2025-12-30 01:10:49 +01:00
return "" , "" , err
}
// If agent doesn't return filePath, construct it (agent should handle .local priority)
filePath := resp . FilePath
if filePath == "" {
// Default to .local path (agent should handle .local priority on its side)
filePath = fmt . Sprintf ( "/etc/fail2ban/filter.d/%s.local" , jail )
2025-11-12 15:52:34 +01:00
}
2025-12-30 01:10:49 +01:00
return resp . Config , filePath , nil
2025-11-12 15:52:34 +01:00
}
func ( ac * AgentConnector ) SetFilterConfig ( ctx context . Context , jail , content string ) error {
payload := map [ string ] string { "config" : content }
return ac . put ( ctx , fmt . Sprintf ( "/v1/filters/%s" , url . PathEscape ( jail ) ) , payload , nil )
}
func ( ac * AgentConnector ) FetchBanEvents ( ctx context . Context , limit int ) ( [ ] BanEvent , error ) {
query := url . Values { }
if limit > 0 {
query . Set ( "limit" , strconv . Itoa ( limit ) )
}
var resp struct {
Events [ ] struct {
IP string ` json:"ip" `
Jail string ` json:"jail" `
Hostname string ` json:"hostname" `
Failures string ` json:"failures" `
Whois string ` json:"whois" `
Logs string ` json:"logs" `
Timestamp string ` json:"timestamp" `
} ` json:"events" `
}
endpoint := "/v1/events"
if encoded := query . Encode ( ) ; encoded != "" {
endpoint += "?" + encoded
}
if err := ac . get ( ctx , endpoint , & resp ) ; err != nil {
return nil , err
}
result := make ( [ ] BanEvent , 0 , len ( resp . Events ) )
for _ , evt := range resp . Events {
ts , err := time . Parse ( time . RFC3339 , evt . Timestamp )
if err != nil {
ts = time . Now ( )
}
result = append ( result , BanEvent {
Time : ts ,
Jail : evt . Jail ,
IP : evt . IP ,
LogLine : fmt . Sprintf ( "%s %s" , evt . Hostname , evt . Failures ) ,
} )
}
return result , nil
}
func ( ac * AgentConnector ) get ( ctx context . Context , endpoint string , out any ) error {
req , err := ac . newRequest ( ctx , http . MethodGet , endpoint , nil )
if err != nil {
return err
}
return ac . do ( req , out )
}
func ( ac * AgentConnector ) post ( ctx context . Context , endpoint string , payload any , out any ) error {
req , err := ac . newRequest ( ctx , http . MethodPost , endpoint , payload )
if err != nil {
return err
}
return ac . do ( req , out )
}
func ( ac * AgentConnector ) put ( ctx context . Context , endpoint string , payload any , out any ) error {
req , err := ac . newRequest ( ctx , http . MethodPut , endpoint , payload )
if err != nil {
return err
}
return ac . do ( req , out )
}
2025-12-30 01:10:49 +01:00
func ( ac * AgentConnector ) delete ( ctx context . Context , endpoint string , out any ) error {
req , err := ac . newRequest ( ctx , http . MethodDelete , endpoint , nil )
if err != nil {
return err
}
return ac . do ( req , out )
}
2025-11-12 15:52:34 +01:00
func ( ac * AgentConnector ) newRequest ( ctx context . Context , method , endpoint string , payload any ) ( * http . Request , error ) {
u := * ac . base
u . Path = path . Join ( ac . base . Path , strings . TrimPrefix ( endpoint , "/" ) )
var body io . Reader
if payload != nil {
data , err := json . Marshal ( payload )
if err != nil {
return nil , err
}
body = bytes . NewReader ( data )
}
req , err := http . NewRequestWithContext ( ctx , method , u . String ( ) , body )
if err != nil {
return nil , err
}
if payload != nil {
req . Header . Set ( "Content-Type" , "application/json" )
}
req . Header . Set ( "Accept" , "application/json" )
req . Header . Set ( "X-F2B-Token" , ac . server . AgentSecret )
return req , nil
}
func ( ac * AgentConnector ) do ( req * http . Request , out any ) error {
settingsSnapshot := config . GetSettings ( )
if settingsSnapshot . Debug {
config . DebugLog ( "Agent request [%s]: %s %s" , ac . server . Name , req . Method , req . URL . String ( ) )
}
resp , err := ac . client . Do ( req )
if err != nil {
if settingsSnapshot . Debug {
config . DebugLog ( "Agent request error [%s]: %v" , ac . server . Name , err )
}
return fmt . Errorf ( "agent request failed: %w" , err )
}
defer resp . Body . Close ( )
data , err := io . ReadAll ( io . LimitReader ( resp . Body , 4096 ) )
if err != nil {
return err
}
trimmed := strings . TrimSpace ( string ( data ) )
if settingsSnapshot . Debug {
config . DebugLog ( "Agent response [%s]: %s | %s" , ac . server . Name , resp . Status , trimmed )
}
if resp . StatusCode >= 400 {
return fmt . Errorf ( "agent request failed: %s (%s)" , resp . Status , trimmed )
}
if out == nil {
return nil
}
if len ( trimmed ) == 0 {
return nil
}
return json . Unmarshal ( data , out )
}
2025-11-12 16:25:16 +01:00
// GetAllJails implements Connector.
func ( ac * AgentConnector ) GetAllJails ( ctx context . Context ) ( [ ] JailInfo , error ) {
var resp struct {
Jails [ ] JailInfo ` json:"jails" `
}
if err := ac . get ( ctx , "/v1/jails/all" , & resp ) ; err != nil {
return nil , err
}
return resp . Jails , nil
}
// UpdateJailEnabledStates implements Connector.
func ( ac * AgentConnector ) UpdateJailEnabledStates ( ctx context . Context , updates map [ string ] bool ) error {
return ac . post ( ctx , "/v1/jails/update-enabled" , updates , nil )
}
// GetFilters implements Connector.
func ( ac * AgentConnector ) GetFilters ( ctx context . Context ) ( [ ] string , error ) {
var resp struct {
Filters [ ] string ` json:"filters" `
}
if err := ac . get ( ctx , "/v1/filters" , & resp ) ; err != nil {
return nil , err
}
return resp . Filters , nil
}
// TestFilter implements Connector.
2026-01-16 11:39:51 +01:00
func ( ac * AgentConnector ) TestFilter ( ctx context . Context , filterName string , logLines [ ] string , filterContent string ) ( string , string , error ) {
2025-11-12 16:25:16 +01:00
payload := map [ string ] any {
"filterName" : filterName ,
"logLines" : logLines ,
}
2026-01-16 11:39:51 +01:00
if filterContent != "" {
payload [ "filterContent" ] = filterContent
}
2025-11-12 16:25:16 +01:00
var resp struct {
2025-12-06 13:11:15 +01:00
Output string ` json:"output" `
FilterPath string ` json:"filterPath" `
2025-11-12 16:25:16 +01:00
}
if err := ac . post ( ctx , "/v1/filters/test" , payload , & resp ) ; err != nil {
2025-12-06 13:11:15 +01:00
return "" , "" , err
}
// If agent doesn't return filterPath, construct it (agent should handle .local priority)
filterPath := resp . FilterPath
if filterPath == "" {
// Default to .conf path (agent should handle .local priority on its side)
filterPath = fmt . Sprintf ( "/etc/fail2ban/filter.d/%s.conf" , filterName )
2025-11-12 16:25:16 +01:00
}
2025-12-06 13:11:15 +01:00
return resp . 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 ( ac * AgentConnector ) GetJailConfig ( ctx context . Context , jail string ) ( string , string , error ) {
2025-12-03 20:43:44 +01:00
var resp struct {
2025-12-30 01:10:49 +01:00
Config string ` json:"config" `
FilePath string ` json:"filePath" `
2025-12-03 20:43:44 +01:00
}
if err := ac . get ( ctx , fmt . Sprintf ( "/v1/jails/%s/config" , url . PathEscape ( jail ) ) , & resp ) ; err != nil {
2025-12-30 01:10:49 +01:00
return "" , "" , err
2025-12-03 20:43:44 +01:00
}
2025-12-30 01:10:49 +01:00
// If agent doesn't return filePath, construct it (agent should handle .local priority)
filePath := resp . FilePath
if filePath == "" {
// Default to .local path (agent should handle .local priority on its side)
filePath = fmt . Sprintf ( "/etc/fail2ban/jail.d/%s.local" , jail )
}
return resp . Config , filePath , nil
2025-12-03 20:43:44 +01:00
}
// SetJailConfig implements Connector.
func ( ac * AgentConnector ) SetJailConfig ( ctx context . Context , jail , content string ) error {
payload := map [ string ] string { "config" : content }
return ac . put ( ctx , fmt . Sprintf ( "/v1/jails/%s/config" , url . PathEscape ( jail ) ) , payload , nil )
}
// TestLogpath implements Connector.
func ( ac * AgentConnector ) TestLogpath ( ctx context . Context , logpath string ) ( [ ] string , error ) {
payload := map [ string ] string { "logpath" : logpath }
var resp struct {
Files [ ] string ` json:"files" `
}
if err := ac . post ( ctx , "/v1/jails/test-logpath" , payload , & resp ) ; err != nil {
return [ ] string { } , nil // Return empty on error
}
return resp . Files , nil
}
2025-12-04 19:42:43 +01:00
2025-12-05 23:21:08 +01:00
// TestLogpathWithResolution implements Connector.
// Agent server should handle variable resolution.
func ( ac * AgentConnector ) TestLogpathWithResolution ( ctx context . Context , logpath string ) ( originalPath , resolvedPath string , files [ ] string , err error ) {
originalPath = strings . TrimSpace ( logpath )
if originalPath == "" {
return originalPath , "" , [ ] string { } , nil
}
payload := map [ string ] string { "logpath" : originalPath }
var resp struct {
OriginalLogpath string ` json:"original_logpath" `
ResolvedLogpath string ` json:"resolved_logpath" `
Files [ ] string ` json:"files" `
Error string ` json:"error,omitempty" `
}
// Try new endpoint first, fallback to old endpoint
if err := ac . post ( ctx , "/v1/jails/test-logpath-with-resolution" , payload , & resp ) ; err != nil {
// Fallback: use old endpoint and assume no resolution
files , err2 := ac . TestLogpath ( ctx , originalPath )
if err2 != nil {
return originalPath , "" , nil , fmt . Errorf ( "failed to test logpath: %w" , err2 )
}
return originalPath , originalPath , files , nil
}
if resp . Error != "" {
return originalPath , "" , nil , fmt . Errorf ( "agent error: %s" , resp . Error )
}
if resp . ResolvedLogpath == "" {
resp . ResolvedLogpath = resp . OriginalLogpath
}
if resp . OriginalLogpath == "" {
resp . OriginalLogpath = originalPath
}
return resp . OriginalLogpath , resp . ResolvedLogpath , resp . Files , nil
}
2025-12-04 19:42:43 +01:00
// UpdateDefaultSettings implements Connector.
func ( ac * AgentConnector ) UpdateDefaultSettings ( ctx context . Context , settings config . AppSettings ) error {
2026-02-09 22:33:13 +01:00
// Check jail.local integrity first
exists , hasUI , chkErr := ac . CheckJailLocalIntegrity ( ctx )
if chkErr != nil {
config . DebugLog ( "Warning: could not check jail.local integrity on agent %s: %v" , ac . server . Name , chkErr )
}
if exists && ! hasUI {
return fmt . Errorf ( "jail.local on agent server %s is not managed by Fail2ban-UI - skipping settings update (please migrate your jail.local manually)" , ac . server . Name )
}
if ! exists {
config . DebugLog ( "jail.local does not exist on agent server %s - initializing fresh managed file" , ac . server . Name )
if err := ac . EnsureJailLocalStructure ( ctx ) ; err != nil {
return fmt . Errorf ( "failed to initialize jail.local on agent server %s: %w" , ac . server . Name , err )
}
}
2025-12-04 19:42:43 +01:00
// Convert IgnoreIPs array to space-separated string
ignoreIPStr := strings . Join ( settings . IgnoreIPs , " " )
if ignoreIPStr == "" {
ignoreIPStr = "127.0.0.1/8 ::1"
}
// Set default banaction values if not set
banaction := settings . Banaction
if banaction == "" {
2026-01-21 19:23:42 +01:00
banaction = "nftables-multiport"
2025-12-04 19:42:43 +01:00
}
banactionAllports := settings . BanactionAllports
if banactionAllports == "" {
2026-01-21 19:23:42 +01:00
banactionAllports = "nftables-allports"
2025-12-04 19:42:43 +01:00
}
2026-02-08 19:43:34 +01:00
chain := settings . Chain
if chain == "" {
chain = "INPUT"
}
2025-12-04 19:42:43 +01:00
payload := map [ string ] interface { } {
"bantimeIncrement" : settings . BantimeIncrement ,
2025-12-15 18:57:50 +01:00
"defaultJailEnable" : settings . DefaultJailEnable ,
2025-12-04 19:42:43 +01:00
"ignoreip" : ignoreIPStr ,
"bantime" : settings . Bantime ,
"findtime" : settings . Findtime ,
"maxretry" : settings . Maxretry ,
"banaction" : banaction ,
"banactionAllports" : banactionAllports ,
2026-02-08 19:43:34 +01:00
"chain" : chain ,
"bantimeRndtime" : settings . BantimeRndtime ,
2025-12-04 19:42:43 +01:00
}
return ac . put ( ctx , "/v1/jails/default-settings" , payload , nil )
}
2026-02-09 19:56:43 +01:00
// CheckJailLocalIntegrity implements Connector.
func ( ac * AgentConnector ) CheckJailLocalIntegrity ( ctx context . Context ) ( bool , bool , error ) {
var result struct {
Exists bool ` json:"exists" `
HasUIAction bool ` json:"hasUIAction" `
}
if err := ac . get ( ctx , "/v1/jails/check-integrity" , & result ) ; err != nil {
// If the agent does not implement this endpoint, assume OK
if strings . Contains ( err . Error ( ) , "404" ) || strings . Contains ( err . Error ( ) , "not found" ) {
return false , false , nil
}
return false , false , fmt . Errorf ( "failed to check jail.local integrity on %s: %w" , ac . server . Name , err )
}
return result . Exists , result . HasUIAction , nil
}
2025-12-04 19:42:43 +01:00
// EnsureJailLocalStructure implements Connector.
func ( ac * AgentConnector ) EnsureJailLocalStructure ( ctx context . Context ) error {
2026-02-09 19:56:43 +01:00
// Safety: if jail.local exists but is not managed by Fail2ban-UI,
// it belongs to the user; never overwrite it.
if exists , hasUI , err := ac . CheckJailLocalIntegrity ( ctx ) ; err == nil && exists && ! hasUI {
config . DebugLog ( "jail.local on agent server %s exists but is not managed by Fail2ban-UI -- skipping overwrite" , ac . server . Name )
return nil
}
2025-12-04 19:42:43 +01:00
return ac . post ( ctx , "/v1/jails/ensure-structure" , nil , nil )
}
2025-12-30 01:10:49 +01:00
// CreateJail implements Connector.
func ( ac * AgentConnector ) CreateJail ( ctx context . Context , jailName , content string ) error {
payload := map [ string ] interface { } {
"name" : jailName ,
"content" : content ,
}
return ac . post ( ctx , "/v1/jails" , payload , nil )
}
// DeleteJail implements Connector.
func ( ac * AgentConnector ) DeleteJail ( ctx context . Context , jailName string ) error {
return ac . delete ( ctx , fmt . Sprintf ( "/v1/jails/%s" , jailName ) , nil )
}
// CreateFilter implements Connector.
func ( ac * AgentConnector ) CreateFilter ( ctx context . Context , filterName , content string ) error {
payload := map [ string ] interface { } {
"name" : filterName ,
"content" : content ,
}
return ac . post ( ctx , "/v1/filters" , payload , nil )
}
// DeleteFilter implements Connector.
func ( ac * AgentConnector ) DeleteFilter ( ctx context . Context , filterName string ) error {
return ac . delete ( ctx , fmt . Sprintf ( "/v1/filters/%s" , filterName ) , nil )
}