diff --git a/internal/config/settings.go b/internal/config/settings.go index 809c11f..f7f1184 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1,6 +1,6 @@ // Fail2ban UI - A Swiss made, management interface for Fail2ban. // -// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // // Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. @@ -38,7 +38,40 @@ import ( "github.com/swissmakers/fail2ban-ui/internal/storage" ) -// SMTPSettings holds the SMTP server configuration for sending alert emails +// ========================================================================= +// Types +// ========================================================================= + +type AppSettings struct { + Language string `json:"language"` + Port int `json:"port"` + Debug bool `json:"debug"` + RestartNeeded bool `json:"restartNeeded"` + AlertCountries []string `json:"alertCountries"` + SMTP SMTPSettings `json:"smtp"` + CallbackURL string `json:"callbackUrl"` + CallbackSecret string `json:"callbackSecret"` + AdvancedActions AdvancedActionsConfig `json:"advancedActions"` + Servers []Fail2banServer `json:"servers"` + BantimeIncrement bool `json:"bantimeIncrement"` + DefaultJailEnable bool `json:"defaultJailEnable"` + IgnoreIPs []string `json:"ignoreips"` + Bantime string `json:"bantime"` + Findtime string `json:"findtime"` + Maxretry int `json:"maxretry"` + Destemail string `json:"destemail"` + Banaction string `json:"banaction"` + BanactionAllports string `json:"banactionAllports"` + Chain string `json:"chain"` + BantimeRndtime string `json:"bantimeRndtime"` + GeoIPProvider string `json:"geoipProvider"` + GeoIPDatabasePath string `json:"geoipDatabasePath"` + MaxLogLines int `json:"maxLogLines"` + EmailAlertsForBans bool `json:"emailAlertsForBans"` + EmailAlertsForUnbans bool `json:"emailAlertsForUnbans"` + ConsoleOutput bool `json:"consoleOutput"` +} + type SMTPSettings struct { Host string `json:"host"` Port int `json:"port"` @@ -50,62 +83,26 @@ type SMTPSettings struct { AuthMethod string `json:"authMethod"` } -// AppSettings holds the main UI settings and Fail2ban configuration -type AppSettings struct { - Language string `json:"language"` - Port int `json:"port"` - Debug bool `json:"debug"` - RestartNeeded bool `json:"restartNeeded"` - AlertCountries []string `json:"alertCountries"` - SMTP SMTPSettings `json:"smtp"` - CallbackURL string `json:"callbackUrl"` - CallbackSecret string `json:"callbackSecret"` - AdvancedActions AdvancedActionsConfig `json:"advancedActions"` - - Servers []Fail2banServer `json:"servers"` - - // Fail2Ban [DEFAULT] section values from jail.local - BantimeIncrement bool `json:"bantimeIncrement"` - DefaultJailEnable bool `json:"defaultJailEnable"` - IgnoreIPs []string `json:"ignoreips"` // Changed from string to []string for individual IP management - Bantime string `json:"bantime"` - Findtime string `json:"findtime"` - Maxretry int `json:"maxretry"` - Destemail string `json:"destemail"` - Banaction string `json:"banaction"` // Default banning action - BanactionAllports string `json:"banactionAllports"` // Allports banning action - Chain string `json:"chain"` // Default iptables/nftables chain (INPUT, DOCKER-USER, FORWARD) - BantimeRndtime string `json:"bantimeRndtime"` // Optional: bantime.rndtime in seconds (e.g. 2048) for bantime increment formula - //Sender string `json:"sender"` - - // GeoIP and Whois settings - GeoIPProvider string `json:"geoipProvider"` // "maxmind" or "builtin" - GeoIPDatabasePath string `json:"geoipDatabasePath"` // Path to MaxMind database (optional) - MaxLogLines int `json:"maxLogLines"` // Maximum log lines to include (default: 50) - - // Email alert preferences - EmailAlertsForBans bool `json:"emailAlertsForBans"` // Enable email alerts for ban events (default: true) - EmailAlertsForUnbans bool `json:"emailAlertsForUnbans"` // Enable email alerts for unban events (default: false) - - // Console output preferences - ConsoleOutput bool `json:"consoleOutput"` // Enable console output in web UI (default: false) -} - -// OIDCConfig holds OIDC authentication configuration -type OIDCConfig struct { - Enabled bool `json:"enabled"` - Provider string `json:"provider"` // keycloak, authentik, pocketid - IssuerURL string `json:"issuerURL"` - ClientID string `json:"clientID"` - ClientSecret string `json:"clientSecret"` - RedirectURL string `json:"redirectURL"` - Scopes []string `json:"scopes"` // Default: ["openid", "profile", "email"] - SessionSecret string `json:"sessionSecret"` // For session encryption - SessionMaxAge int `json:"sessionMaxAge"` // Session timeout in seconds - SkipVerify bool `json:"skipVerify"` // Skip TLS verification (dev only) - UsernameClaim string `json:"usernameClaim"` // Claim to use as username - LogoutURL string `json:"logoutURL"` // Provider logout URL (optional) - SkipLoginPage bool `json:"skipLoginPage"` // Skip login page and redirect directly to OIDC provider (default: false) +type Fail2banServer struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + SocketPath string `json:"socketPath,omitempty"` + LogPath string `json:"logPath,omitempty"` + SSHUser string `json:"sshUser,omitempty"` + SSHKeyPath string `json:"sshKeyPath,omitempty"` + AgentURL string `json:"agentUrl,omitempty"` + AgentSecret string `json:"agentSecret,omitempty"` + Hostname string `json:"hostname,omitempty"` + Tags []string `json:"tags,omitempty"` + IsDefault bool `json:"isDefault"` + Enabled bool `json:"enabled"` + RestartNeeded bool `json:"restartNeeded"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + enabledSet bool } type AdvancedActionsConfig struct { @@ -142,6 +139,22 @@ type OPNsenseIntegrationSettings struct { SkipTLSVerify bool `json:"skipTLSVerify"` } +type OIDCConfig struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider"` + IssuerURL string `json:"issuerURL"` + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + RedirectURL string `json:"redirectURL"` + Scopes []string `json:"scopes"` + SessionSecret string `json:"sessionSecret"` + SessionMaxAge int `json:"sessionMaxAge"` + SkipVerify bool `json:"skipVerify"` + UsernameClaim string `json:"usernameClaim"` + LogoutURL string `json:"logoutURL"` + SkipLoginPage bool `json:"skipLoginPage"` +} + func defaultAdvancedActionsConfig() AdvancedActionsConfig { return AdvancedActionsConfig{ Enabled: false, @@ -167,9 +180,12 @@ func normalizeAdvancedActionsConfig(cfg AdvancedActionsConfig) AdvancedActionsCo return cfg } -// init paths to key-files +// ========================================================================= +// Constants +// ========================================================================= + const ( - settingsFile = "fail2ban-ui-settings.json" // this file is created, relatively to where the app was started + settingsFile = "fail2ban-ui-settings.json" jailFile = "/etc/fail2ban/jail.local" actionFile = "/etc/fail2ban/action.d/ui-custom-action.conf" actionCallbackPlaceholder = "__CALLBACK_URL__" @@ -178,7 +194,6 @@ const ( actionCurlInsecureFlag = "__CURL_INSECURE_FLAG__" ) -// jailLocalBanner is the standard banner for jail.local files const jailLocalBanner = `################################################################################ # Fail2Ban-UI Managed Configuration # @@ -190,20 +205,12 @@ const jailLocalBanner = `####################################################### ################################################################################ ` - -// JailLocalBanner returns the standard banner for jail.local files -func JailLocalBanner() string { - return jailLocalBanner -} - const fail2banActionTemplate = `[Definition] -# Bypass ban/unban for restored tickets +# Bypasses ban/unban for restored bans norestored = 1 -# Option: actionban -# This executes a cURL request to notify our API when an IP is banned. - +# Executes a cURL request to notify our API when an IP is banned. actionban = /usr/bin/curl__CURL_INSECURE_FLAG__ -X POST __CALLBACK_URL__/api/ban \ -H "Content-Type: application/json" \ -H "X-Callback-Secret: __CALLBACK_SECRET__" \ @@ -215,9 +222,7 @@ actionban = /usr/bin/curl__CURL_INSECURE_FLAG__ -X POST __CALLBACK_URL__/api/ban --arg logs "$(tac | grep -wF )" \ '{serverId: $serverId, ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, logs: $logs}')" -# Option: actionunban -# This executes a cURL request to notify our API when an IP is unbanned. - +# Executes a cURL request to notify our API when an IP is unbanned. actionunban = /usr/bin/curl__CURL_INSECURE_FLAG__ -X POST __CALLBACK_URL__/api/unban \ -H "Content-Type: application/json" \ -H "X-Callback-Secret: __CALLBACK_SECRET__" \ @@ -239,41 +244,18 @@ logpath = /dev/null grepmax = 200 grepopts = -m ` -// in-memory copy of settings -var ( - currentSettings AppSettings - settingsLock sync.RWMutex -) +// ========================================================================= +// Package Variables +// ========================================================================= var ( + currentSettings AppSettings + settingsLock sync.RWMutex errSettingsNotFound = errors.New("settings not found") backgroundCtx = context.Background() ) -// Fail2banServer represents a Fail2ban instance the UI can manage. -type Fail2banServer struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` // local, ssh, agent - Host string `json:"host,omitempty"` - Port int `json:"port,omitempty"` - SocketPath string `json:"socketPath,omitempty"` - LogPath string `json:"logPath,omitempty"` - SSHUser string `json:"sshUser,omitempty"` - SSHKeyPath string `json:"sshKeyPath,omitempty"` - AgentURL string `json:"agentUrl,omitempty"` - AgentSecret string `json:"agentSecret,omitempty"` - Hostname string `json:"hostname,omitempty"` - Tags []string `json:"tags,omitempty"` - IsDefault bool `json:"isDefault"` - Enabled bool `json:"enabled"` - RestartNeeded bool `json:"restartNeeded"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - - enabledSet bool -} - +// Customizes JSON unmarshaling to distinguish between explicit false and unset values. func (s *Fail2banServer) UnmarshalJSON(data []byte) error { type Alias Fail2banServer aux := &struct { @@ -294,16 +276,18 @@ func (s *Fail2banServer) UnmarshalJSON(data []byte) error { return nil } +// ========================================================================= +// Initialization +// ========================================================================= + func init() { if err := storage.Init(""); err != nil { panic(fmt.Sprintf("failed to initialise storage: %v", err)) } - if err := loadSettingsFromStorage(); err != nil { if !errors.Is(err, errSettingsNotFound) { fmt.Println("Error loading settings from storage:", err) } - if err := migrateLegacySettings(); err != nil { if !errors.Is(err, os.ErrNotExist) { fmt.Println("Error migrating legacy settings:", err) @@ -315,7 +299,6 @@ func init() { setDefaults() fmt.Println("Initialized with defaults.") } - if err := persistAll(); err != nil { fmt.Println("Failed to persist settings:", err) } @@ -338,10 +321,8 @@ func loadSettingsFromStorage() error { if !found { return errSettingsNotFound } - settingsLock.Lock() defer settingsLock.Unlock() - applyAppSettingsRecordLocked(appRec) applyServerRecordsLocked(serverRecs) setDefaultsLocked() @@ -353,19 +334,20 @@ func migrateLegacySettings() error { if err != nil { return err } - var legacy AppSettings if err := json.Unmarshal(data, &legacy); err != nil { return err } - settingsLock.Lock() currentSettings = legacy settingsLock.Unlock() - return nil } +// ========================================================================= +// Persistence +// ========================================================================= + func persistAll() error { settingsLock.Lock() defer settingsLock.Unlock() @@ -404,7 +386,6 @@ func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) { currentSettings.RestartNeeded = rec.RestartNeeded currentSettings.BantimeIncrement = rec.BantimeIncrement currentSettings.DefaultJailEnable = rec.DefaultJailEnable - // Convert IgnoreIP string to array (backward compatibility) if rec.IgnoreIP != "" { currentSettings.IgnoreIPs = strings.Fields(rec.IgnoreIP) } else { @@ -432,7 +413,6 @@ func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) { InsecureSkipVerify: rec.SMTPInsecureSkipVerify, AuthMethod: rec.SMTPAuthMethod, } - if rec.AlertCountriesJSON != "" { var countries []string if err := json.Unmarshal([]byte(rec.AlertCountriesJSON), &countries); err == nil { @@ -503,19 +483,15 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) { } return storage.AppSettingsRecord{ - // Basic app settings - Language: currentSettings.Language, - Port: currentSettings.Port, - Debug: currentSettings.Debug, - RestartNeeded: currentSettings.RestartNeeded, - // Callback settings - CallbackURL: currentSettings.CallbackURL, - CallbackSecret: currentSettings.CallbackSecret, - // Alert settings - AlertCountriesJSON: string(countryBytes), - EmailAlertsForBans: currentSettings.EmailAlertsForBans, - EmailAlertsForUnbans: currentSettings.EmailAlertsForUnbans, - // SMTP settings + Language: currentSettings.Language, + Port: currentSettings.Port, + Debug: currentSettings.Debug, + RestartNeeded: currentSettings.RestartNeeded, + CallbackURL: currentSettings.CallbackURL, + CallbackSecret: currentSettings.CallbackSecret, + AlertCountriesJSON: string(countryBytes), + EmailAlertsForBans: currentSettings.EmailAlertsForBans, + EmailAlertsForUnbans: currentSettings.EmailAlertsForUnbans, SMTPHost: currentSettings.SMTP.Host, SMTPPort: currentSettings.SMTP.Port, SMTPUsername: currentSettings.SMTP.Username, @@ -524,26 +500,22 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) { SMTPUseTLS: currentSettings.SMTP.UseTLS, SMTPInsecureSkipVerify: currentSettings.SMTP.InsecureSkipVerify, SMTPAuthMethod: currentSettings.SMTP.AuthMethod, - // Fail2Ban DEFAULT settings - BantimeIncrement: currentSettings.BantimeIncrement, - DefaultJailEnable: currentSettings.DefaultJailEnable, - // Convert IgnoreIPs array to space-separated string for storage - IgnoreIP: strings.Join(currentSettings.IgnoreIPs, " "), - Bantime: currentSettings.Bantime, - Findtime: currentSettings.Findtime, - MaxRetry: currentSettings.Maxretry, - DestEmail: currentSettings.Destemail, - Banaction: currentSettings.Banaction, - BanactionAllports: currentSettings.BanactionAllports, - Chain: currentSettings.Chain, - BantimeRndtime: currentSettings.BantimeRndtime, - // Advanced features - AdvancedActionsJSON: string(advancedBytes), - GeoIPProvider: currentSettings.GeoIPProvider, - GeoIPDatabasePath: currentSettings.GeoIPDatabasePath, - MaxLogLines: currentSettings.MaxLogLines, - // Console output settings - ConsoleOutput: currentSettings.ConsoleOutput, + BantimeIncrement: currentSettings.BantimeIncrement, + DefaultJailEnable: currentSettings.DefaultJailEnable, + IgnoreIP: strings.Join(currentSettings.IgnoreIPs, " "), + Bantime: currentSettings.Bantime, + Findtime: currentSettings.Findtime, + MaxRetry: currentSettings.Maxretry, + DestEmail: currentSettings.Destemail, + Banaction: currentSettings.Banaction, + BanactionAllports: currentSettings.BanactionAllports, + Chain: currentSettings.Chain, + BantimeRndtime: currentSettings.BantimeRndtime, + AdvancedActionsJSON: string(advancedBytes), + GeoIPProvider: currentSettings.GeoIPProvider, + GeoIPDatabasePath: currentSettings.GeoIPDatabasePath, + MaxLogLines: currentSettings.MaxLogLines, + ConsoleOutput: currentSettings.ConsoleOutput, }, nil } @@ -590,7 +562,6 @@ func toServerRecordsLocked() ([]storage.ServerRecord, error) { return records, nil } -// setDefaults populates default values in currentSettings func setDefaults() { settingsLock.Lock() defer settingsLock.Unlock() @@ -601,19 +572,13 @@ func setDefaultsLocked() { if currentSettings.Language == "" { currentSettings.Language = "en" } - - // Set email alert defaults + // Set email alert defaults only when uninitialized. if !currentSettings.EmailAlertsForBans && !currentSettings.EmailAlertsForUnbans { - // Check if it is uninitialized by checking if we have other initialized values - // If we have a callback secret or port set, it means we've loaded from storage, so we don't override if currentSettings.CallbackSecret == "" && currentSettings.Port == 0 { - // Uninitialized so we set defaults currentSettings.EmailAlertsForBans = true currentSettings.EmailAlertsForUnbans = false } } - - // Check for PORT environment variable first - it always takes priority if portEnv := os.Getenv("PORT"); portEnv != "" { if port, err := strconv.Atoi(portEnv); err == nil && port > 0 && port <= 65535 { currentSettings.Port = port @@ -623,22 +588,16 @@ func setDefaultsLocked() { } else if currentSettings.Port == 0 { currentSettings.Port = 8080 } - // CALLBACK_URL env var always takes priority (external address where Fail2ban - // instances send back ban/unban API calls). If not set, fall back to the - // stored value or auto-generate from the port. if cbURL := os.Getenv("CALLBACK_URL"); cbURL != "" { currentSettings.CallbackURL = strings.TrimRight(strings.TrimSpace(cbURL), "/") } else if currentSettings.CallbackURL == "" { currentSettings.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d", currentSettings.Port) } else { - // If callback URL matches the old default pattern, update it to match the current port oldPattern := regexp.MustCompile(`^http://127\.0\.0\.1:\d+$`) if oldPattern.MatchString(currentSettings.CallbackURL) { currentSettings.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d", currentSettings.Port) } } - // CALLBACK_SECRET env var always takes priority. - // If not set, keep stored value or generate a new one (only once). if cbSecret := os.Getenv("CALLBACK_SECRET"); cbSecret != "" { currentSettings.CallbackSecret = strings.TrimSpace(cbSecret) } else if currentSettings.CallbackSecret == "" { @@ -706,21 +665,18 @@ func setDefaultsLocked() { currentSettings.AdvancedActions = defaultAdvancedActionsConfig() } currentSettings.AdvancedActions = normalizeAdvancedActionsConfig(currentSettings.AdvancedActions) - normalizeServersLocked() } -// initializeFromJailFile reads Fail2ban jail.local and merges its settings into currentSettings. +// Reads the jail.local file and merges its [DEFAULT] section values into currentSettings. (experimental) func initializeFromJailFile() error { file, err := os.Open(jailFile) if err != nil { return err } defer file.Close() - scanner := bufio.NewScanner(file) re := regexp.MustCompile(`^\s*(?P[a-zA-Z0-9_]+)\s*=\s*(?P.+)$`) - settings := map[string]string{} for scanner.Scan() { line := scanner.Text() @@ -730,10 +686,8 @@ func initializeFromJailFile() error { settings[key] = value } } - settingsLock.Lock() defer settingsLock.Unlock() - if val, ok := settings["bantime"]; ok { currentSettings.Bantime = val } @@ -746,7 +700,6 @@ func initializeFromJailFile() error { } } if val, ok := settings["ignoreip"]; ok { - // Convert space-separated string to array if val != "" { currentSettings.IgnoreIPs = strings.Fields(val) } else { @@ -765,10 +718,6 @@ func initializeFromJailFile() error { if val, ok := settings["bantime.rndtime"]; ok && val != "" { currentSettings.BantimeRndtime = val } - /*if val, ok := settings["destemail"]; ok { - currentSettings.Destemail = val - }*/ - return nil } @@ -791,7 +740,6 @@ func normalizeServersLocked() { }} return } - hasDefault := false for idx := range currentSettings.Servers { server := ¤tSettings.Servers[idx] @@ -834,7 +782,6 @@ func normalizeServersLocked() { hasDefault = true } } - if !hasDefault { for idx := range currentSettings.Servers { if currentSettings.Servers[idx].Enabled { @@ -844,11 +791,9 @@ func normalizeServersLocked() { } } } - sort.SliceStable(currentSettings.Servers, func(i, j int) bool { return currentSettings.Servers[i].CreatedAt.Before(currentSettings.Servers[j].CreatedAt) }) - updateGlobalRestartFlagLocked() } @@ -860,28 +805,26 @@ func generateServerID() string { return "srv-" + hex.EncodeToString(b[:]) } -// ensureFail2banActionFiles writes the local action files if Fail2ban is present. +// ========================================================================= +// Fail2ban File Management --> TODO: create a new connector_global.go for functions that are used by all connectors +// ========================================================================= + +// Ensures the local action files exist. (local connector only) -> will be moved to the connector_local.go func ensureFail2banActionFiles(callbackURL, serverID string) error { DebugLog("----------------------------") DebugLog("ensureFail2banActionFiles called (settings.go)") - if _, err := os.Stat(filepath.Dir(jailFile)); os.IsNotExist(err) { return nil } - - // Ensure jail.local has proper structure (banner, DEFAULT, action_mwlg, action override) if err := EnsureJailLocalStructure(); err != nil { return err } return writeFail2banAction(callbackURL, serverID) } -// BuildJailLocalContent builds the complete managed jail.local file content -// from the current settings. This is the single source of truth for the file -// format, shared by all connectors (local, SSH, agent). +// Builds the content of our fail2ban-UI managed jail.local file. (used by all connectors) func BuildJailLocalContent() string { settings := GetSettings() - ignoreIPStr := strings.Join(settings.IgnoreIPs, " ") if ignoreIPStr == "" { ignoreIPStr = "127.0.0.1/8 ::1" @@ -928,56 +871,42 @@ action = %(action_mwlg)s return jailLocalBanner + defaultSection + actionMwlgConfig + actionOverride } -// EnsureJailLocalStructure writes a complete managed jail.local to the local filesystem. +// Ensures that the managed jail.local file is valid and exists. (used by all connectors) func EnsureJailLocalStructure() error { DebugLog("Running EnsureJailLocalStructure()") - - // Check if /etc/fail2ban directory exists (fail2ban must be installed) if _, err := os.Stat(filepath.Dir(jailFile)); os.IsNotExist(err) { return fmt.Errorf("fail2ban is not installed: /etc/fail2ban directory does not exist. Please install fail2ban package first") } - - // Read existing jail.local content if it exists var existingContent string fileExists := false if content, err := os.ReadFile(jailFile); err == nil { existingContent = string(content) fileExists = len(strings.TrimSpace(existingContent)) > 0 } - - // If jail.local exists but is NOT managed by Fail2ban-UI, - // it belongs to the user — never overwrite it. if fileExists && !strings.Contains(existingContent, "ui-custom-action") { - DebugLog("jail.local exists but is not managed by Fail2ban-UI - skipping overwrite") + DebugLog("jail.local file exists but is not managed by Fail2ban-UI - skipping overwrite") return nil } - - // Full rewrite from current settings (self-healing, no stale keys) if err := os.WriteFile(jailFile, []byte(BuildJailLocalContent()), 0644); err != nil { return fmt.Errorf("failed to write jail.local: %v", err) } - - DebugLog("Created/updated jail.local with proper structure") + DebugLog("Created/updated jail.local with proper content.") return nil } -// writeFail2banAction creates or updates the action file with the AlertCountries. +// Writes the custom-action file. (for local connector only) -> will be moved to the connector_local.go func writeFail2banAction(callbackURL, serverID string) error { - DebugLog("Running initial writeFail2banAction()") // entry point + DebugLog("Running initial writeFail2banAction()") DebugLog("----------------------------") - - // Check if /etc/fail2ban/action.d directory exists (fail2ban must be installed) if _, err := os.Stat(filepath.Dir(actionFile)); os.IsNotExist(err) { return fmt.Errorf("fail2ban is not installed: /etc/fail2ban/action.d directory does not exist. Please install fail2ban package first") } - settings := GetSettings() actionConfig := BuildFail2banActionConfig(callbackURL, serverID, settings.CallbackSecret) err := os.WriteFile(actionFile, []byte(actionConfig), 0644) if err != nil { return fmt.Errorf("failed to write action file: %w", err) } - DebugLog("Custom-action file successfully written to %s\n", actionFile) return nil } @@ -991,6 +920,7 @@ func cloneServer(src Fail2banServer) Fail2banServer { return dst } +// Builds the content of our fail2ban-UI custom-action file. (used by all connectors) func BuildFail2banActionConfig(callbackURL, serverID, secret string) string { trimmed := strings.TrimRight(strings.TrimSpace(callbackURL), "/") if trimmed == "" { @@ -1000,22 +930,16 @@ func BuildFail2banActionConfig(callbackURL, serverID, secret string) string { serverID = "local" } if secret == "" { - // If secret is empty, get it from settings (should be generated by setDefaultsLocked) settings := GetSettings() secret = settings.CallbackSecret - // Last resort: if still empty, generate one (shouldn't happen) if secret == "" { secret = generateCallbackSecret() } } - // Determine if we need to use -k flag for HTTPS with self-signed certificates - // This allows curl to work with self-signed, in-house CA certificates - // For HTTP URLs, we use a empty string. curlInsecureFlag := "" if strings.HasPrefix(strings.ToLower(trimmed), "https://") { curlInsecureFlag = " -k" } - config := strings.ReplaceAll(fail2banActionTemplate, actionCallbackPlaceholder, trimmed) config = strings.ReplaceAll(config, actionServerIDPlaceholder, serverID) config = strings.ReplaceAll(config, actionSecretPlaceholder, secret) @@ -1023,27 +947,21 @@ func BuildFail2banActionConfig(callbackURL, serverID, secret string) string { return config } -// generateCallbackSecret generates a 42-character random secret using crypto/rand. +// Generates a 42-character random secret for the callback secret. func generateCallbackSecret() string { - // Generate 32 random bytes (256 bits of entropy) + // Generate first 32 random bytes (256 bits of entropy) bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { - // Fallback to hex encoding if crypto/rand fails (shouldn't happen) - fallbackBytes := make([]byte, 21) // 21 bytes = 42 hex chars + fallbackBytes := make([]byte, 21) if _, err := rand.Read(fallbackBytes); err != nil { - // Last resort: use time-based seed (not ideal but better than nothing) return fmt.Sprintf("%042x", time.Now().UnixNano()) } return hex.EncodeToString(fallbackBytes) } - // Use base64 URL-safe encoding, which gives us 43 chars for 32 bytes - // We need exactly 42, so we'll truncate the last character (which is padding anyway) encoded := base64.URLEncoding.EncodeToString(bytes) - // Base64 URL encoding of 32 bytes = 43 chars, take first 42 if len(encoded) >= 42 { return encoded[:42] } - // If somehow shorter, pad with random hex return encoded + hex.EncodeToString(bytes)[:42-len(encoded)] } @@ -1059,14 +977,13 @@ func getCallbackURLLocked() string { return strings.TrimRight(url, "/") } -// GetCallbackURL returns the callback URL used by Fail2ban agents. func GetCallbackURL() string { settingsLock.RLock() defer settingsLock.RUnlock() return getCallbackURLLocked() } -// EnsureLocalFail2banAction ensures the local Fail2ban action files exist when the local connector is enabled. +// Ensures the local Fail2ban action but only when the server is enabled. (local connector only) func EnsureLocalFail2banAction(server Fail2banServer) error { if !server.Enabled { return nil @@ -1077,6 +994,10 @@ func EnsureLocalFail2banAction(server Fail2banServer) error { return ensureFail2banActionFiles(callbackURL, server.ID) } +// ========================================================================= +// Server Management +// ========================================================================= + func serverByIDLocked(id string) (Fail2banServer, bool) { for _, srv := range currentSettings.Servers { if srv.ID == id { @@ -1086,7 +1007,6 @@ func serverByIDLocked(id string) (Fail2banServer, bool) { return Fail2banServer{}, false } -// ListServers returns a copy of the configured Fail2ban servers. func ListServers() []Fail2banServer { settingsLock.RLock() defer settingsLock.RUnlock() @@ -1098,7 +1018,6 @@ func ListServers() []Fail2banServer { return out } -// GetServerByID returns the server matching the supplied ID. func GetServerByID(id string) (Fail2banServer, bool) { settingsLock.RLock() defer settingsLock.RUnlock() @@ -1109,7 +1028,6 @@ func GetServerByID(id string) (Fail2banServer, bool) { return cloneServer(srv), true } -// GetServerByHostname returns the first server matching the hostname. func GetServerByHostname(hostname string) (Fail2banServer, bool) { settingsLock.RLock() defer settingsLock.RUnlock() @@ -1121,7 +1039,6 @@ func GetServerByHostname(hostname string) (Fail2banServer, bool) { return Fail2banServer{}, false } -// GetDefaultServer returns the default server. func GetDefaultServer() Fail2banServer { settingsLock.RLock() defer settingsLock.RUnlock() @@ -1139,7 +1056,7 @@ func GetDefaultServer() Fail2banServer { return Fail2banServer{} } -// UpsertServer adds or updates a Fail2ban server and persists the settings. +// Adds or updates a Fail2ban server. func UpsertServer(input Fail2banServer) (Fail2banServer, error) { settingsLock.Lock() defer settingsLock.Unlock() @@ -1188,7 +1105,6 @@ func UpsertServer(input Fail2banServer) (Fail2banServer, error) { if input.IsDefault { clearDefaultLocked() } - // preserve created timestamp if incoming zero if input.CreatedAt.IsZero() { input.CreatedAt = srv.CreatedAt } @@ -1222,7 +1138,7 @@ func clearDefaultLocked() { } } -func setServerRestartFlagLocked(serverID string, value bool) bool { +/*func setServerRestartFlagLocked(serverID string, value bool) bool { for idx := range currentSettings.Servers { if currentSettings.Servers[idx].ID == serverID { currentSettings.Servers[idx].RestartNeeded = value @@ -1230,7 +1146,7 @@ func setServerRestartFlagLocked(serverID string, value bool) bool { } } return false -} +}*/ func anyServerNeedsRestartLocked() bool { for _, srv := range currentSettings.Servers { @@ -1251,15 +1167,13 @@ func markAllServersRestartLocked() { } } -// DeleteServer removes a server by ID. +// Deletes a server by ID. func DeleteServer(id string) error { settingsLock.Lock() defer settingsLock.Unlock() - if len(currentSettings.Servers) == 0 { return fmt.Errorf("no servers configured") } - index := -1 for i, srv := range currentSettings.Servers { if srv.ID == id { @@ -1270,17 +1184,15 @@ func DeleteServer(id string) error { if index == -1 { return fmt.Errorf("server %s not found", id) } - currentSettings.Servers = append(currentSettings.Servers[:index], currentSettings.Servers[index+1:]...) normalizeServersLocked() return persistServersLocked() } -// SetDefaultServer marks the specified server as default. +// Marks the specified server as default. func SetDefaultServer(id string) (Fail2banServer, error) { settingsLock.Lock() defer settingsLock.Unlock() - found := false for idx := range currentSettings.Servers { srv := ¤tSettings.Servers[idx] @@ -1299,7 +1211,6 @@ func SetDefaultServer(id string) (Fail2banServer, error) { if !found { return Fail2banServer{}, fmt.Errorf("server %s not found", id) } - normalizeServersLocked() if err := persistServersLocked(); err != nil { return Fail2banServer{}, err @@ -1308,8 +1219,10 @@ func SetDefaultServer(id string) (Fail2banServer, error) { return cloneServer(srv), nil } -// GetSettings returns a copy of the current settings -// GetPortFromEnv returns the PORT environment variable value if set, and whether it's set +// ========================================================================= +// Get Settings from Environment Variables +// ========================================================================= + func GetPortFromEnv() (int, bool) { portEnv := os.Getenv("PORT") if portEnv == "" { @@ -1321,7 +1234,6 @@ func GetPortFromEnv() (int, bool) { return 0, false } -// GetCallbackURLFromEnv returns the CALLBACK_URL environment variable value and whether it's set. func GetCallbackURLFromEnv() (string, bool) { v := strings.TrimSpace(os.Getenv("CALLBACK_URL")) if v == "" { @@ -1330,35 +1242,30 @@ func GetCallbackURLFromEnv() (string, bool) { return strings.TrimRight(v, "/"), true } -// GetBindAddressFromEnv returns the BIND_ADDRESS environment variable value if set, and whether it's set -// If not set, returns "0.0.0.0" as the default bind address -// Validates that the address is a valid IP address format func GetBindAddressFromEnv() (string, bool) { bindAddrEnv := os.Getenv("BIND_ADDRESS") if bindAddrEnv == "" { return "0.0.0.0", false } - // Validate that it's a valid IP address format using net.ParseIP if ip := net.ParseIP(bindAddrEnv); ip != nil { return bindAddrEnv, true } - // Invalid format, return default return "0.0.0.0", false } -// GetOIDCConfigFromEnv reads OIDC configuration from environment variables -// Returns nil if OIDC is not enabled +// ========================================================================= +// OIDC Configuration from Env +// ========================================================================= + +// Returns the OIDC configuration from environment. Returns nil if OIDC is not enabled. func GetOIDCConfigFromEnv() (*OIDCConfig, error) { enabled := os.Getenv("OIDC_ENABLED") if enabled != "true" && enabled != "1" { - return nil, nil // OIDC not enabled + return nil, nil } - config := &OIDCConfig{ Enabled: true, } - - // Required fields config.Provider = os.Getenv("OIDC_PROVIDER") if config.Provider == "" { return nil, fmt.Errorf("OIDC_PROVIDER environment variable is required when OIDC_ENABLED=true") @@ -1366,24 +1273,19 @@ func GetOIDCConfigFromEnv() (*OIDCConfig, error) { if config.Provider != "keycloak" && config.Provider != "authentik" && config.Provider != "pocketid" { return nil, fmt.Errorf("OIDC_PROVIDER must be one of: keycloak, authentik, pocketid") } - config.IssuerURL = os.Getenv("OIDC_ISSUER_URL") if config.IssuerURL == "" { return nil, fmt.Errorf("OIDC_ISSUER_URL environment variable is required when OIDC_ENABLED=true") } - config.ClientID = os.Getenv("OIDC_CLIENT_ID") if config.ClientID == "" { return nil, fmt.Errorf("OIDC_CLIENT_ID environment variable is required when OIDC_ENABLED=true") } - config.ClientSecret = os.Getenv("OIDC_CLIENT_SECRET") - // If client secret is "auto-configured", try to read from file - // This is primarily used for Keycloak's automatic client setup in development if config.ClientSecret == "auto-configured" { secretFile := os.Getenv("OIDC_CLIENT_SECRET_FILE") if secretFile == "" { - secretFile = "/config/keycloak-client-secret" // Default path for Keycloak auto-configuration + secretFile = "/config/keycloak-client-secret" } if secretBytes, err := os.ReadFile(secretFile); err == nil { config.ClientSecret = strings.TrimSpace(string(secretBytes)) @@ -1394,14 +1296,12 @@ func GetOIDCConfigFromEnv() (*OIDCConfig, error) { if config.ClientSecret == "" { return nil, fmt.Errorf("OIDC_CLIENT_SECRET environment variable is required when OIDC_ENABLED=true") } - config.RedirectURL = os.Getenv("OIDC_REDIRECT_URL") if config.RedirectURL == "" { return nil, fmt.Errorf("OIDC_REDIRECT_URL environment variable is required when OIDC_ENABLED=true") } - - // Optional fields with defaults scopesEnv := os.Getenv("OIDC_SCOPES") + if scopesEnv != "" { config.Scopes = strings.Split(scopesEnv, ",") for i := range config.Scopes { @@ -1411,22 +1311,20 @@ func GetOIDCConfigFromEnv() (*OIDCConfig, error) { config.Scopes = []string{"openid", "profile", "email"} } - // Set default session max age - config.SessionMaxAge = 3600 // Default: 1 hour + config.SessionMaxAge = 3600 sessionMaxAgeEnv := os.Getenv("OIDC_SESSION_MAX_AGE") + if sessionMaxAgeEnv != "" { if maxAge, err := strconv.Atoi(sessionMaxAgeEnv); err == nil && maxAge > 0 { config.SessionMaxAge = maxAge } } - // Skip login page option (default: false) skipLoginPageEnv := os.Getenv("OIDC_SKIP_LOGINPAGE") config.SkipLoginPage = skipLoginPageEnv == "true" || skipLoginPageEnv == "1" - config.SessionSecret = os.Getenv("OIDC_SESSION_SECRET") + if config.SessionSecret == "" { - // Generate a random session secret secretBytes := make([]byte, 32) if _, err := rand.Read(secretBytes); err != nil { return nil, fmt.Errorf("failed to generate session secret: %w", err) @@ -1436,25 +1334,27 @@ func GetOIDCConfigFromEnv() (*OIDCConfig, error) { skipVerifyEnv := os.Getenv("OIDC_SKIP_VERIFY") config.SkipVerify = (skipVerifyEnv == "true" || skipVerifyEnv == "1") - config.UsernameClaim = os.Getenv("OIDC_USERNAME_CLAIM") if config.UsernameClaim == "" { - config.UsernameClaim = "preferred_username" // Default claim + config.UsernameClaim = "preferred_username" } - - config.LogoutURL = os.Getenv("OIDC_LOGOUT_URL") // Optional - + config.LogoutURL = os.Getenv("OIDC_LOGOUT_URL") return config, nil } +// Returns a copy of the current app settings. func GetSettings() AppSettings { settingsLock.RLock() defer settingsLock.RUnlock() return currentSettings } -// MarkRestartNeeded marks the specified server as requiring a restart. -func MarkRestartNeeded(serverID string) error { +// ========================================================================= +// Restart Tracking +// ========================================================================= + +// Marks the specified server as requiring a restart. -- currently not used +/*func MarkRestartNeeded(serverID string) error { settingsLock.Lock() defer settingsLock.Unlock() @@ -1473,7 +1373,7 @@ func MarkRestartNeeded(serverID string) error { return persistAppSettingsLocked() } -// MarkRestartDone marks the specified server as no longer requiring a restart. +// Marks the specified server as no longer requiring a restart. func MarkRestartDone(serverID string) error { settingsLock.Lock() defer settingsLock.Unlock() @@ -1492,18 +1392,13 @@ func MarkRestartDone(serverID string) error { } return persistAppSettingsLocked() } +*/ -// UpdateSettings merges new settings with old and sets restartNeeded if needed func UpdateSettings(new AppSettings) (AppSettings, error) { settingsLock.Lock() defer settingsLock.Unlock() - - DebugLog("--- Locked settings for update ---") // Log lock acquisition - + DebugLog("--- Locked settings for update ---") old := currentSettings - - // If certain fields change, we mark reload needed - // Compare IgnoreIPs arrays ignoreIPsChanged := false if len(old.IgnoreIPs) != len(new.IgnoreIPs) { ignoreIPsChanged = true @@ -1526,20 +1421,14 @@ func UpdateSettings(new AppSettings) (AppSettings, error) { } else { new.RestartNeeded = anyServerNeedsRestartLocked() } - new.CallbackURL = strings.TrimSpace(new.CallbackURL) - - // Auto-update callback URL if port changed and callback URL is still using default localhost pattern oldPort := currentSettings.Port if new.Port != oldPort && new.Port > 0 { - // Check if callback URL matches the default localhost pattern oldPattern := regexp.MustCompile(`^http://127\.0\.0\.1:\d+$`) if oldPattern.MatchString(new.CallbackURL) || new.CallbackURL == "" { - // Update to match new port new.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d", new.Port) } } - if len(new.Servers) == 0 && len(currentSettings.Servers) > 0 { new.Servers = make([]Fail2banServer, len(currentSettings.Servers)) for i, srv := range currentSettings.Servers { @@ -1552,15 +1441,10 @@ func UpdateSettings(new AppSettings) (AppSettings, error) { markAllServersRestartLocked() updateGlobalRestartFlagLocked() } - DebugLog("New settings applied: %v", currentSettings) // Log settings applied - - // Update console log enabled state if it changed + DebugLog("New settings applied: %v", currentSettings) if old.ConsoleOutput != new.ConsoleOutput { - // Import web package to update console log state - // We'll handle this via a callback or direct call updateConsoleLogState(new.ConsoleOutput) } - if err := persistAllLocked(); err != nil { fmt.Println("Error saving settings:", err) return currentSettings, err @@ -1568,11 +1452,13 @@ func UpdateSettings(new AppSettings) (AppSettings, error) { return currentSettings, nil } -// updateConsoleLogState updates the console log writer enabled state -// This is called from UpdateSettings when console output setting changes +// ========================================================================= +// Console Log State +// ========================================================================= + var updateConsoleLogStateFunc func(bool) -// SetUpdateConsoleLogStateFunc sets the callback function to update console log state +// Sets the callback to update console log enabled state. func SetUpdateConsoleLogStateFunc(fn func(bool)) { updateConsoleLogStateFunc = fn } diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go index 260d09d..9ce86d8 100644 --- a/internal/fail2ban/connector_agent.go +++ b/internal/fail2ban/connector_agent.go @@ -16,14 +16,22 @@ import ( "github.com/swissmakers/fail2ban-ui/internal/config" ) -// AgentConnector connects to a remote fail2ban-agent via HTTP API. +// ========================================================================= +// Types +// ========================================================================= + +// Connector for a remote Fail2ban-Agent via HTTP API. type AgentConnector struct { server config.Fail2banServer base *url.URL client *http.Client } -// NewAgentConnector constructs a new AgentConnector. +// ========================================================================= +// Constructor +// ========================================================================= + +// Create a new AgentConnector for the given server config. func NewAgentConnector(server config.Fail2banServer) (Connector, error) { if server.AgentURL == "" { return nil, fmt.Errorf("agentUrl is required for agent connector") @@ -52,6 +60,10 @@ func NewAgentConnector(server config.Fail2banServer) (Connector, error) { return conn, nil } +// ========================================================================= +// Connector Functions +// ========================================================================= + func (ac *AgentConnector) ID() string { return ac.server.ID } @@ -114,8 +126,6 @@ func (ac *AgentConnector) Restart(ctx context.Context) error { return ac.post(ctx, "/v1/actions/restart", nil, nil) } -// 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 @@ -123,6 +133,10 @@ func (ac *AgentConnector) RestartWithMode(ctx context.Context) (string, error) { return "restart", nil } +// ========================================================================= +// Filter Operations +// ========================================================================= + func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) { var resp struct { Config string `json:"config"` @@ -131,10 +145,8 @@ func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (str if err := ac.get(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), &resp); err != nil { 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) } return resp.Config, filePath, nil @@ -184,6 +196,10 @@ func (ac *AgentConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanE return result, nil } +// ========================================================================= +// HTTP Helpers +// ========================================================================= + func (ac *AgentConnector) get(ctx context.Context, endpoint string, out any) error { req, err := ac.newRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { @@ -280,7 +296,10 @@ func (ac *AgentConnector) do(req *http.Request, out any) error { return json.Unmarshal(data, out) } -// GetAllJails implements Connector. +// ========================================================================= +// Jail Operations +// ========================================================================= + func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { var resp struct { Jails []JailInfo `json:"jails"` @@ -291,12 +310,10 @@ func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { 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"` @@ -307,7 +324,6 @@ func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) { return resp.Filters, nil } -// TestFilter implements Connector. func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) { payload := map[string]any{ "filterName": filterName, @@ -323,16 +339,13 @@ func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, log if err := ac.post(ctx, "/v1/filters/test", payload, &resp); err != nil { 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) } return resp.Output, filterPath, nil } -// GetJailConfig implements Connector. func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) { var resp struct { Config string `json:"config"` @@ -341,35 +354,33 @@ func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (strin if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), &resp); err != nil { 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/jail.d/%s.local", jail) } return resp.Config, filePath, nil } -// 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. +// ========================================================================= +// Logpath Operations +// ========================================================================= + 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 []string{}, nil } return resp.Files, nil } -// 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 == "" { @@ -386,7 +397,7 @@ func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath // 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 + // Fallback; use old endpoint if new endpoint fails and assume no resolution files, err2 := ac.TestLogpath(ctx, originalPath) if err2 != nil { return originalPath, "", nil, fmt.Errorf("failed to test logpath: %w", err2) @@ -408,14 +419,14 @@ func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath return resp.OriginalLogpath, resp.ResolvedLogpath, resp.Files, nil } -// UpdateDefaultSettings implements Connector. +// ========================================================================= +// Settings and Structure +// ========================================================================= + func (ac *AgentConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error { - // Since the managed jail.local is fully owned by Fail2ban-UI, a complete - // rewrite from current settings is always correct and self-healing. return ac.EnsureJailLocalStructure(ctx) } -// CheckJailLocalIntegrity implements Connector. func (ac *AgentConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) { var result struct { Exists bool `json:"exists"` @@ -431,10 +442,8 @@ func (ac *AgentConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bo return result.Exists, result.HasUIAction, nil } -// EnsureJailLocalStructure implements Connector. func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error { - // Safety: if jail.local exists but is not managed by Fail2ban-UI, - // it belongs to the user; never overwrite it. + // If jail.local exists but is not managed by Fail2ban-UI, it belongs to the user, we do not 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 @@ -443,7 +452,10 @@ func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error { return ac.post(ctx, "/v1/jails/ensure-structure", nil, nil) } -// CreateJail implements Connector. +// ========================================================================= +// Filter and Jail Management +// ========================================================================= + func (ac *AgentConnector) CreateJail(ctx context.Context, jailName, content string) error { payload := map[string]interface{}{ "name": jailName, @@ -452,12 +464,10 @@ func (ac *AgentConnector) CreateJail(ctx context.Context, jailName, content stri 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, @@ -466,7 +476,6 @@ func (ac *AgentConnector) CreateFilter(ctx context.Context, filterName, 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) } diff --git a/internal/fail2ban/connector_local.go b/internal/fail2ban/connector_local.go index 097dc29..441fc5b 100644 --- a/internal/fail2ban/connector_local.go +++ b/internal/fail2ban/connector_local.go @@ -14,46 +14,43 @@ import ( "github.com/swissmakers/fail2ban-ui/internal/config" ) -// LocalConnector interacts with a local fail2ban instance via fail2ban-client CLI. +// Connector for a local Fail2ban instance via fail2ban-client CLI. type LocalConnector struct { server config.Fail2banServer } -// NewLocalConnector creates a new LocalConnector instance. +// ========================================================================= +// Constructor +// ========================================================================= + +// Create a new LocalConnector for the given server config. func NewLocalConnector(server config.Fail2banServer) *LocalConnector { return &LocalConnector{server: server} } -// ID implements Connector. func (lc *LocalConnector) ID() string { return lc.server.ID } -// Server implements Connector. func (lc *LocalConnector) Server() config.Fail2banServer { return lc.server } -// GetJailInfos implements Connector. +// Get jail information. func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) { jails, err := lc.getJails(ctx) if err != nil { return nil, err } - - logPath := lc.server.LogPath + logPath := lc.server.LogPath // LEGACY, WILL BE REMOVED IN FUTURE VERSIONS. if logPath == "" { logPath = "/var/log/fail2ban.log" } - - banHistory, err := ParseBanLog(logPath) + banHistory, err := ParseBanLog(logPath) // LEGACY, WILL BE REMOVED IN FUTURE VERSIONS. if err != nil { banHistory = make(map[string][]BanEvent) } - oneHourAgo := time.Now().Add(-1 * time.Hour) - - // Use parallel execution for better performance type jailResult struct { jail JailInfo err error @@ -89,12 +86,10 @@ func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) } }(jail) } - go func() { wg.Wait() close(results) }() - var finalResults []JailInfo for result := range results { if result.err != nil { @@ -102,14 +97,13 @@ func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) } finalResults = append(finalResults, result.jail) } - sort.SliceStable(finalResults, func(i, j int) bool { return finalResults[i].JailName < finalResults[j].JailName }) return finalResults, nil } -// GetBannedIPs implements Connector. +// Get banned IPs for a given jail. func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) { args := []string{"status", jail} out, err := lc.runFail2banClient(ctx, args...) @@ -120,7 +114,6 @@ func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]stri lines := strings.Split(out, "\n") for _, line := range lines { if strings.Contains(line, "IP list:") { - // Use SplitN to only split on the first colon, preserving IPv6 addresses parts := strings.SplitN(line, ":", 2) if len(parts) > 1 { ips := strings.Fields(strings.TrimSpace(parts[1])) @@ -132,7 +125,7 @@ func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]stri return bannedIPs, nil } -// UnbanIP implements Connector. +// Unban an IP from a given jail. func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error { args := []string{"set", jail, "unbanip", ip} if _, err := lc.runFail2banClient(ctx, args...); err != nil { @@ -141,7 +134,7 @@ func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error { return nil } -// BanIP implements Connector. +// Ban an IP in a given jail. func (lc *LocalConnector) BanIP(ctx context.Context, jail, ip string) error { args := []string{"set", jail, "banip", ip} if _, err := lc.runFail2banClient(ctx, args...); err != nil { @@ -150,35 +143,25 @@ func (lc *LocalConnector) BanIP(ctx context.Context, jail, ip string) error { return nil } -// Reload implements Connector. +// Reload the Fail2ban service. func (lc *LocalConnector) Reload(ctx context.Context) error { out, err := lc.runFail2banClient(ctx, "reload") if err != nil { - // Include the output in the error message for better debugging return fmt.Errorf("fail2ban reload error: %w (output: %s)", err, strings.TrimSpace(out)) } - - // Check if output indicates success (fail2ban-client returns "OK" on success) + // Check if fail2ban-client returns "OK" outputTrimmed := strings.TrimSpace(out) if outputTrimmed != "OK" && outputTrimmed != "" { config.DebugLog("fail2ban reload output: %s", out) - - // Check for jail errors in output even when command succeeds - // Look for patterns like "Errors in jail 'jailname'. Skipping..." if strings.Contains(out, "Errors in jail") || strings.Contains(out, "Unable to read the filter") { - // Return an error that includes the output so handler can parse it return fmt.Errorf("fail2ban reload completed but with errors (output: %s)", strings.TrimSpace(out)) } } return nil } -// RestartWithMode restarts (or reloads) the local Fail2ban instance 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 +// Restart or reload the local Fail2ban instance; returns "restart" or "reload". func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) { - // 1) Try systemd restart if systemctl is available. if _, err := exec.LookPath("systemctl"); err == nil { cmd := "systemctl restart fail2ban" out, err := executeShellCommand(ctx, cmd) @@ -191,9 +174,6 @@ func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) { } return "restart", nil } - - // 2) Fallback: no systemctl in PATH (container image without systemd, or - // non-systemd environment). Use fail2ban-client reload + ping. if err := lc.Reload(ctx); err != nil { return "reload", fmt.Errorf("failed to reload fail2ban via fail2ban-client (systemctl not available): %w", err) } @@ -203,23 +183,20 @@ func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) { return "reload", nil } -// Restart implements Connector. func (lc *LocalConnector) Restart(ctx context.Context) error { _, err := lc.RestartWithMode(ctx) return err } -// GetFilterConfig implements Connector. func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) { return GetFilterConfigLocal(jail) } -// SetFilterConfig implements Connector. func (lc *LocalConnector) SetFilterConfig(ctx context.Context, jail, content string) error { return SetFilterConfigLocal(jail, content) } -// FetchBanEvents implements Connector. +// REMOVE THIS FUNCTION func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) { logPath := lc.server.LogPath if logPath == "" { @@ -242,17 +219,16 @@ func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanE return all, nil } +// Get all jails. func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) { out, err := lc.runFail2banClient(ctx, "status") if err != nil { return nil, fmt.Errorf("error: unable to retrieve jail information. is your fail2ban service running? details: %w", err) } - var jails []string lines := strings.Split(out, "\n") for _, line := range lines { if strings.Contains(line, "Jail list:") { - // Use SplitN to only split on the first colon parts := strings.SplitN(line, ":", 2) if len(parts) > 1 { raw := strings.TrimSpace(parts[1]) @@ -266,6 +242,10 @@ func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) { return jails, nil } +// ========================================================================= +// CLI Helpers +// ========================================================================= + func (lc *LocalConnector) runFail2banClient(ctx context.Context, args ...string) (string, error) { cmdArgs := lc.buildFail2banArgs(args...) cmd := exec.CommandContext(ctx, "fail2ban-client", cmdArgs...) @@ -281,99 +261,84 @@ func (lc *LocalConnector) buildFail2banArgs(args ...string) []string { return append(base, args...) } -// checkFail2banHealthy runs a quick `fail2ban-client ping` via the existing -// runFail2banClient helper and expects a successful pong reply. func (lc *LocalConnector) checkFail2banHealthy(ctx context.Context) error { out, err := lc.runFail2banClient(ctx, "ping") trimmed := strings.TrimSpace(out) if err != nil { return fmt.Errorf("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 fail2ban ping output: %s", trimmed) } return nil } -// GetAllJails implements Connector. +// ========================================================================= +// Delegated Operations +// ========================================================================= + func (lc *LocalConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { return GetAllJails() } -// UpdateJailEnabledStates implements Connector. func (lc *LocalConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error { return UpdateJailEnabledStates(updates) } -// GetFilters implements Connector. func (lc *LocalConnector) GetFilters(ctx context.Context) ([]string, error) { return GetFiltersLocal() } -// TestFilter implements Connector. func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) { return TestFilterLocal(filterName, logLines, filterContent) } -// GetJailConfig implements Connector. func (lc *LocalConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) { return GetJailConfig(jail) } -// SetJailConfig implements Connector. func (lc *LocalConnector) SetJailConfig(ctx context.Context, jail, content string) error { return SetJailConfig(jail, content) } -// TestLogpath implements Connector. func (lc *LocalConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) { return TestLogpath(logpath) } -// TestLogpathWithResolution implements Connector. func (lc *LocalConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) { return TestLogpathWithResolution(logpath) } -// UpdateDefaultSettings implements Connector. func (lc *LocalConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error { return UpdateDefaultSettingsLocal(settings) } -// EnsureJailLocalStructure implements Connector. func (lc *LocalConnector) EnsureJailLocalStructure(ctx context.Context) error { return config.EnsureJailLocalStructure() } -// CreateJail implements Connector. func (lc *LocalConnector) CreateJail(ctx context.Context, jailName, content string) error { return CreateJail(jailName, content) } -// DeleteJail implements Connector. func (lc *LocalConnector) DeleteJail(ctx context.Context, jailName string) error { return DeleteJail(jailName) } -// CreateFilter implements Connector. func (lc *LocalConnector) CreateFilter(ctx context.Context, filterName, content string) error { return CreateFilter(filterName, content) } -// DeleteFilter implements Connector. func (lc *LocalConnector) DeleteFilter(ctx context.Context, filterName string) error { return DeleteFilter(filterName) } -// CheckJailLocalIntegrity implements Connector. func (lc *LocalConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) { const jailLocalPath = "/etc/fail2ban/jail.local" content, err := os.ReadFile(jailLocalPath) if err != nil { if os.IsNotExist(err) { - return false, false, nil // file does not exist; OK, will be created + return false, false, nil } return false, false, fmt.Errorf("failed to read jail.local: %w", err) } @@ -381,6 +346,10 @@ func (lc *LocalConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bo return true, hasUIAction, nil } +// ========================================================================= +// Shell Execution +// ========================================================================= + func executeShellCommand(ctx context.Context, command string) (string, error) { parts := strings.Fields(command) if len(parts) == 0 { diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index 618398d..ca85015 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -20,8 +20,18 @@ import ( "github.com/swissmakers/fail2ban-ui/internal/config" ) -// sshEnsureActionScript only deploys action.d/ui-custom-action.conf. -// jail.local is managed by EnsureJailLocalStructure +// ========================================================================= +// Types and Constants +// ========================================================================= + +// SSHConnector talks to a remote Fail2ban instance over SSH. +type SSHConnector struct { + server config.Fail2banServer + fail2banPath string + pathCached bool + pathMutex sync.RWMutex +} + const sshEnsureActionScript = `python3 - <<'PY' import base64 import pathlib @@ -37,15 +47,11 @@ except Exception as e: sys.exit(1) PY` -// SSHConnector connects to a remote Fail2ban instance over SSH. -type SSHConnector struct { - server config.Fail2banServer - fail2banPath string // Cache the fail2ban path - pathCached bool // Track if path is cached - pathMutex sync.RWMutex -} +// ========================================================================= +// Constructor +// ========================================================================= -// NewSSHConnector creates a new SSH connector. +// Create a new SSHConnector for the given server config. func NewSSHConnector(server config.Fail2banServer) (Connector, error) { if server.Host == "" { return nil, fmt.Errorf("host is required for ssh connector") @@ -61,14 +67,15 @@ func NewSSHConnector(server config.Fail2banServer) (Connector, error) { defer cancel() if err := conn.ensureAction(ctx); err != nil { - // Log warning but don't fail connector creation - action can be ensured later config.DebugLog("warning: failed to ensure remote fail2ban action for %s during startup (server may not be ready): %v", server.Name, err) - // Don't return error - allow connector to be created even if action setup fails - // The action will be ensured later when UpdateActionFiles is called } return conn, nil } +// ========================================================================= +// Connector Functions +// ========================================================================= + func (sc *SSHConnector) ID() string { return sc.server.ID } @@ -77,13 +84,13 @@ func (sc *SSHConnector) Server() config.Fail2banServer { return sc.server } +// Get jail infos for all jails. func (sc *SSHConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) { jails, err := sc.getJails(ctx) if err != nil { return nil, err } - // Use parallel execution for better performance type jailResult struct { jail JailInfo err error @@ -111,12 +118,10 @@ func (sc *SSHConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) { } }(jail) } - go func() { wg.Wait() close(results) }() - var infos []JailInfo for result := range results { if result.err != nil { @@ -124,13 +129,13 @@ func (sc *SSHConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) { } infos = append(infos, result.jail) } - sort.SliceStable(infos, func(i, j int) bool { return infos[i].JailName < infos[j].JailName }) return infos, nil } +// Get banned IPs for a given jail. func (sc *SSHConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) { out, err := sc.runFail2banCommand(ctx, "status", jail) if err != nil { @@ -140,7 +145,6 @@ func (sc *SSHConnector) GetBannedIPs(ctx context.Context, jail string) ([]string lines := strings.Split(out, "\n") for _, line := range lines { if strings.Contains(line, "IP list:") { - // Use SplitN to only split on the first colon, preserving IPv6 addresses parts := strings.SplitN(line, ":", 2) if len(parts) > 1 { ips := strings.Fields(strings.TrimSpace(parts[1])) @@ -167,18 +171,13 @@ func (sc *SSHConnector) Reload(ctx context.Context) error { return err } -// RestartWithMode restarts (or reloads) the remote Fail2ban instance over SSH -// and returns a mode string describing what happened: -// - "restart": systemd service was restarted and health check passed -// - "reload": configuration was reloaded via fail2ban-client and pong check passed func (sc *SSHConnector) Restart(ctx context.Context) error { _, err := sc.RestartWithMode(ctx) return err } -// RestartWithMode implements the detailed restart logic for SSH connectors. func (sc *SSHConnector) RestartWithMode(ctx context.Context) (string, error) { - // First, we try systemd restart on the remote host + // Try systemd restart on the remote host first. out, err := sc.runRemoteCommand(ctx, []string{"systemctl", "restart", "fail2ban"}) if err == nil { if err := sc.checkFail2banHealthyRemote(ctx); err != nil { @@ -186,8 +185,7 @@ func (sc *SSHConnector) RestartWithMode(ctx context.Context) (string, error) { } return "restart", nil } - - // Then, if systemd is not available, we fall back to fail2ban-client. + // If systemd is not available, we will fall back to fail2ban-client. if sc.isSystemctlUnavailable(out, err) { reloadOut, reloadErr := sc.runFail2banCommand(ctx, "reload") if reloadErr != nil { @@ -200,12 +198,11 @@ func (sc *SSHConnector) RestartWithMode(ctx context.Context) (string, error) { return "reload", nil } - // systemctl exists but restart failed for some other reason, we surface it. + // systemctl exists but restart failed for some other reason, we will return the error. return "restart", fmt.Errorf("failed to restart fail2ban via systemd on remote: %w (output: %s)", err, out) } func (sc *SSHConnector) GetFilterConfig(ctx context.Context, filterName string) (string, string, error) { - // Validate filter name filterName = strings.TrimSpace(filterName) if filterName == "" { return "", "", fmt.Errorf("filter name cannot be empty") @@ -221,7 +218,6 @@ func (sc *SSHConnector) GetFilterConfig(ctx context.Context, filterName string) return content, localPath, nil } - // Fallback to .conf content, err = sc.readRemoteFile(ctx, confPath) if err != nil { return "", "", fmt.Errorf("failed to read remote filter config (tried .local and .conf): %w", err) @@ -230,7 +226,6 @@ func (sc *SSHConnector) GetFilterConfig(ctx context.Context, filterName string) } func (sc *SSHConnector) SetFilterConfig(ctx context.Context, filterName, content string) error { - // Validate filter name filterName = strings.TrimSpace(filterName) if filterName == "" { return fmt.Errorf("filter name cannot be empty") @@ -239,7 +234,6 @@ func (sc *SSHConnector) SetFilterConfig(ctx context.Context, filterName, content fail2banPath := sc.getFail2banPath(ctx) filterDPath := filepath.Join(fail2banPath, "filter.d") - // Ensure directory exists _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", filterDPath}) if err != nil { return fmt.Errorf("failed to create filter.d directory: %w", err) @@ -250,7 +244,6 @@ func (sc *SSHConnector) SetFilterConfig(ctx context.Context, filterName, content return fmt.Errorf("failed to ensure filter .local file: %w", err) } - // Write to .local file localPath := filepath.Join(filterDPath, filterName+".local") if err := sc.writeRemoteFile(ctx, localPath, content); err != nil { return fmt.Errorf("failed to write filter config: %w", err) @@ -260,7 +253,6 @@ func (sc *SSHConnector) SetFilterConfig(ctx context.Context, filterName, content } func (sc *SSHConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) { - // Not available over SSH without copying logs; return empty slice. return []BanEvent{}, nil } @@ -270,23 +262,17 @@ func (sc *SSHConnector) ensureAction(ctx context.Context) error { actionConfig := config.BuildFail2banActionConfig(callbackURL, sc.server.ID, settings.CallbackSecret) payload := base64.StdEncoding.EncodeToString([]byte(actionConfig)) script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload) - // Base64 encode the entire script to avoid shell escaping issues scriptB64 := base64.StdEncoding.EncodeToString([]byte(script)) - - // Use sh -s to read commands from stdin, then pass the base64 string via stdin - // This is the most reliable way to pass data via SSH args := sc.buildSSHArgs([]string{"sh", "-s"}) cmd := exec.CommandContext(ctx, "ssh", args...) - // Set process group to ensure all child processes (including SSH control master) are killed - // when the context is cancelled. This prevents zombie processes. + // Set process group to ensure all child processes (including SSH control master) are killed when the context is cancelled. cmd.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, Pgid: 0, } - // Create a script that reads the base64 string from stdin and pipes it through base64 -d | bash - // We use a here-document to pass the base64 string + // Create a script that reads the base64 string from stdin and pipes it through base64 -d | bash. scriptContent := fmt.Sprintf("cat <<'ENDBASE64' | base64 -d | bash\n%s\nENDBASE64\n", scriptB64) cmd.Stdin = strings.NewReader(scriptContent) @@ -316,15 +302,10 @@ func (sc *SSHConnector) ensureAction(ctx context.Context) error { 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() @@ -344,6 +325,10 @@ func (sc *SSHConnector) ensureAction(ctx context.Context) error { return nil } +// ========================================================================= +// SSH Helpers +// ========================================================================= + func (sc *SSHConnector) getJails(ctx context.Context) ([]string, error) { out, err := sc.runFail2banCommand(ctx, "status") if err != nil { @@ -353,7 +338,6 @@ func (sc *SSHConnector) getJails(ctx context.Context) ([]string, error) { lines := strings.Split(out, "\n") for _, line := range lines { if strings.Contains(line, "Jail list:") { - // Use SplitN to only split on the first colon parts := strings.SplitN(line, ":", 2) if len(parts) > 1 { raw := strings.TrimSpace(parts[1]) @@ -373,7 +357,7 @@ func (sc *SSHConnector) runFail2banCommand(ctx context.Context, args ...string) return sc.runRemoteCommand(ctx, cmdArgs) } -// isSystemctlUnavailable tries to detect “no systemd” situations on the remote host. +// Detects "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") || @@ -381,16 +365,12 @@ func (sc *SSHConnector) isSystemctlUnavailable(output string, err error) bool { 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) } @@ -408,19 +388,14 @@ func (sc *SSHConnector) buildFail2banArgs(args ...string) []string { func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string) (string, error) { args := sc.buildSSHArgs(command) cmd := exec.Command("ssh", args...) - - // Set process group to ensure all child processes (including SSH control master) are killed - // when we need to terminate. This prevents zombie processes. cmd.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, Pgid: 0, } - settingSnapshot := config.GetSettings() if settingSnapshot.Debug { config.DebugLog("SSH command [%s]: ssh %s", sc.server.Name, strings.Join(args, " ")) } - // Capture stdout and stderr var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -430,16 +405,12 @@ func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string) 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 { @@ -453,15 +424,10 @@ func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string) } 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() @@ -470,13 +436,11 @@ func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string) func (sc *SSHConnector) buildSSHArgs(command []string) []string { args := []string{"-o", "BatchMode=yes"} - // Add connection timeout to prevent hanging args = append(args, "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2", ) - // In containerized environments, disable strict host key checking if _, container := os.LookupEnv("CONTAINER"); container { args = append(args, "-o", "StrictHostKeyChecking=no", @@ -484,13 +448,11 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string { "-o", "LogLevel=ERROR", ) } - // Enable SSH connection multiplexing for faster connections - // Use a control socket based on server ID for connection reuse controlPath := fmt.Sprintf("/tmp/ssh_control_%s_%s", sc.server.ID, strings.ReplaceAll(sc.server.Host, ".", "_")) args = append(args, "-o", "ControlMaster=auto", "-o", fmt.Sprintf("ControlPath=%s", controlPath), - "-o", "ControlPersist=300", // Keep connection alive for 5 minutes + "-o", "ControlPersist=300", ) if sc.server.SSHKeyPath != "" { args = append(args, "-i", sc.server.SSHKeyPath) @@ -507,51 +469,40 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string { return args } -// listRemoteFiles lists files in a remote directory matching a pattern. -// Uses find command which works reliably with FACL permissions. +// ========================================================================= +// Remote File Operations +// ========================================================================= + +// List files in a remote directory using find. func (sc *SSHConnector) listRemoteFiles(ctx context.Context, directory, pattern string) ([]string, error) { - // Use find command with absolute path - it will handle non-existent directories gracefully - // Find files ending with pattern, exclude hidden files, and ensure they're regular files - // Redirect stderr to /dev/null to suppress "No such file or directory" errors - // Pass the entire command as a single string to SSH (SSH executes through a shell by default) cmd := fmt.Sprintf(`find "%s" -maxdepth 1 -type f -name "*%s" ! -name ".*" 2>/dev/null | sort`, directory, pattern) out, err := sc.runRemoteCommand(ctx, []string{cmd}) if err != nil { - // If find fails (e.g., directory doesn't exist or permission denied), return empty list (not an error) + // If find fails (e.g., directory doesn't exist or permission denied), return empty list. config.DebugLog("Find command failed for %s on server %s: %v, returning empty list", directory, sc.server.Name, err) return []string{}, nil } - // If find succeeds but directory doesn't exist, it will return empty output - // This is fine - we'll just return an empty list - var files []string for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) - // Skip empty lines, current directory marker, and relative paths if line == "" || line == "." || strings.HasPrefix(line, "./") { continue } - // Only process files that match our pattern (end with .local or .conf) - // and are actually in the target directory if strings.HasSuffix(line, pattern) { - // If it's already an absolute path starting with our directory, use it directly if strings.HasPrefix(line, directory) { files = append(files, line) } else if !strings.HasPrefix(line, "/") { - // Relative path, join with directory fullPath := filepath.Join(directory, line) files = append(files, fullPath) } - // Skip any other absolute paths that don't start with our directory } } return files, nil } -// readRemoteFile reads the content of a remote file via SSH. func (sc *SSHConnector) readRemoteFile(ctx context.Context, filePath string) (string, error) { content, err := sc.runRemoteCommand(ctx, []string{"cat", filePath}) if err != nil { @@ -560,12 +511,9 @@ func (sc *SSHConnector) readRemoteFile(ctx context.Context, filePath string) (st 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 @@ -578,13 +526,10 @@ REMOTEEOF 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 @@ -603,11 +548,8 @@ func (sc *SSHConnector) ensureRemoteLocalFile(ctx context.Context, basePath, nam return nil } -// getFail2banPath detects the fail2ban configuration path on the remote system. -// Returns /config/fail2ban for linuxserver images, or /etc/fail2ban for standard installations. -// Uses caching to avoid repeated SSH calls. +// Returns /config/fail2ban for linuxserver images, /etc/fail2ban otherwise. Cached to avoid repeated SSH calls. func (sc *SSHConnector) getFail2banPath(ctx context.Context) string { - // Try to read from cache first sc.pathMutex.RLock() if sc.pathCached { path := sc.fail2banPath @@ -616,16 +558,13 @@ func (sc *SSHConnector) getFail2banPath(ctx context.Context) string { } sc.pathMutex.RUnlock() - // Acquire write lock to update cache sc.pathMutex.Lock() defer sc.pathMutex.Unlock() - // Double-check after acquiring write lock (another goroutine might have cached it) if sc.pathCached { return sc.fail2banPath } - // Actually fetch the path checkCmd := `test -d "/config/fail2ban" && echo "/config/fail2ban" || (test -d "/etc/fail2ban" && echo "/etc/fail2ban" || echo "/etc/fail2ban")` out, err := sc.runRemoteCommand(ctx, []string{checkCmd}) if err == nil { @@ -636,25 +575,23 @@ func (sc *SSHConnector) getFail2banPath(ctx context.Context) string { return path } } - // Default to /etc/fail2ban sc.fail2banPath = "/etc/fail2ban" sc.pathCached = true return sc.fail2banPath } -// GetAllJails implements Connector. -// Discovers all jails from filesystem (mirrors local connector behavior). -// Optimized to read all files in a single SSH command instead of individual reads. +// ========================================================================= +// Jail Operations +// ========================================================================= + 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 + processedFiles := make(map[string]bool) + processedJails := make(map[string]bool) - // 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 @@ -716,8 +653,6 @@ PYEOF`, jailDPath) 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 @@ -726,7 +661,6 @@ PYEOF`, jailDPath) 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) @@ -737,7 +671,6 @@ PYEOF`, jailDPath) } } } - // Parse new file header: FILE_START:path:type parts := strings.SplitN(line, ":", 3) if len(parts) == 3 { currentFile = parts[1] @@ -756,7 +689,6 @@ PYEOF`, jailDPath) } } } else if line == "FILE_END" { - // End of file, process it if inFile && currentFile != "" { content := currentContent.String() jails := parseJailConfigContent(content) @@ -771,7 +703,6 @@ PYEOF`, jailDPath) currentFile = "" currentContent.Reset() } else if inFile { - // Content line if currentContent.Len() > 0 { currentContent.WriteString("\n") } @@ -779,7 +710,6 @@ PYEOF`, jailDPath) } } - // Handle last file if output doesn't end with FILE_END if inFile && currentFile != "" { content := currentContent.String() jails := parseJailConfigContent(content) @@ -794,14 +724,11 @@ PYEOF`, jailDPath) return allJails, nil } -// getAllJailsFallback is the fallback method that reads files individually. -// Used when the optimized batch read fails. func (sc *SSHConnector) getAllJailsFallback(ctx context.Context, jailDPath string) ([]JailInfo, error) { var allJails []JailInfo processedFiles := make(map[string]bool) processedJails := make(map[string]bool) - // List all .local files first localFiles, err := sc.listRemoteFiles(ctx, jailDPath, ".local") if err != nil { config.DebugLog("Failed to list .local files in jail.d on server %s: %v", sc.server.Name, err) @@ -830,7 +757,6 @@ func (sc *SSHConnector) getAllJailsFallback(ctx context.Context, jailDPath strin } } - // 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) @@ -858,18 +784,15 @@ func (sc *SSHConnector) getAllJailsFallback(ctx context.Context, jailDPath strin } } } - return allJails, nil } -// UpdateJailEnabledStates implements Connector. func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error { fail2banPath := sc.getFail2banPath(ctx) jailDPath := filepath.Join(fail2banPath, "jail.d") // Update each jail in its own .local file for jailName, enabled := range updates { - // Validate jail name - skip empty or invalid names jailName = strings.TrimSpace(jailName) if jailName == "" { config.DebugLog("Skipping empty jail name in updates map") @@ -879,8 +802,6 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map localPath := filepath.Join(jailDPath, jailName+".local") confPath := filepath.Join(jailDPath, jailName+".conf") - // Combined script: ensure .local file exists AND read it in one SSH call - // This reduces SSH round-trips from 2 to 1 per jail combinedScript := fmt.Sprintf(` if [ ! -f "%s" ]; then if [ -f "%s" ]; then @@ -897,7 +818,6 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map return fmt.Errorf("failed to ensure and read .local file for jail %s: %w", jailName, err) } - // Update enabled state in existing file lines := strings.Split(content, "\n") var outputLines []string var foundEnabled bool @@ -920,7 +840,6 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map } } - // If enabled line not found, add it after the jail section header if !foundEnabled { var newLines []string for i, line := range outputLines { @@ -940,7 +859,6 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map } } - // Write updated content to .local file newContent := strings.Join(outputLines, "\n") cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, newContent) if _, err := sc.runRemoteCommand(ctx, []string{cmd}); err != nil { @@ -950,16 +868,11 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map return nil } -// GetFilters implements Connector. -// Discovers all filters from filesystem (mirrors local connector behavior). func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) { fail2banPath := sc.getFail2banPath(ctx) filterDPath := filepath.Join(fail2banPath, "filter.d") - - filterMap := make(map[string]bool) // Track unique filter names - processedFiles := make(map[string]bool) // Track base names to avoid duplicates - - // Helper function to check if file should be excluded + filterMap := make(map[string]bool) + processedFiles := make(map[string]bool) shouldExclude := func(filename string) bool { if strings.HasSuffix(filename, ".bak") || strings.HasSuffix(filename, "~") || @@ -972,7 +885,6 @@ func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) { 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) @@ -991,7 +903,6 @@ func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) { } } - // 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) @@ -1010,7 +921,6 @@ func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) { } } - // Convert map to sorted slice var filters []string for name := range filterMap { filters = append(filters, name) @@ -1020,8 +930,10 @@ func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) { return filters, nil } -// resolveFilterIncludesRemote resolves filter includes by reading included files from remote server -// This is similar to resolveFilterIncludes but reads files via SSH instead of local filesystem +// ========================================================================= +// Filter Include Resolution +// ========================================================================= + func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterContent string, filterDPath string, currentFilterName string) (string, error) { lines := strings.Split(filterContent, "\n") var beforeFiles []string @@ -1067,8 +979,6 @@ func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterC continue } } - - // Collect main content (everything except [INCLUDES] section) if !inIncludesSection { if i > 0 { mainContent.WriteString("\n") @@ -1089,14 +999,12 @@ func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterC 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) @@ -1106,7 +1014,6 @@ func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterC 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 @@ -1116,7 +1023,6 @@ func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterC 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 @@ -1125,10 +1031,9 @@ func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterC contentStr, err := readRemoteFilterFile(baseName) if err != nil { config.DebugLog("Warning: %v", err) - continue // Skip if file doesn't exist + continue } - // 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") { @@ -1137,15 +1042,12 @@ func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterC 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") @@ -1155,15 +1057,14 @@ func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterC // 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 + // Self-inclusion in "after" is intentional (e.g. after = apache-common.local). contentStr, err := readRemoteFilterFile(baseName) if err != nil { config.DebugLog("Warning: %v", err) - continue // Skip if file doesn't exist + continue } - // 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) @@ -1175,7 +1076,6 @@ func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterC return combined.String(), nil } -// TestFilter implements Connector. func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) { cleaned := normalizeLogLines(logLines) if len(cleaned) == 0 { @@ -1187,13 +1087,10 @@ func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLi if filterName == "" { return "", "", fmt.Errorf("filter name cannot be empty") } - // Remove any path components filterName = strings.ReplaceAll(filterName, "/", "") filterName = strings.ReplaceAll(filterName, "..", "") - // Get the fail2ban path dynamically fail2banPath := sc.getFail2banPath(ctx) - // Try .local first, then fallback to .conf localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local") confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf") @@ -1203,27 +1100,19 @@ func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLi var script string if filterContent != "" { - // Resolve filter includes locally (same approach as local connector) - // This avoids complex Python scripts and heredoc issues + // Resolve filter includes locally. 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 + // Ensure it ends with a newline. 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) @@ -1242,7 +1131,6 @@ cat <<'%[2]s' > "$TMPFILE" fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true `, resolvedContentB64, heredocMarker, logContent) } else { - // Use existing filter file script = fmt.Sprintf(` set -e LOCAL_PATH=%[1]q @@ -1271,7 +1159,7 @@ fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true return "", "", err } - // Extract filter path from output (it's on the first line with FILTER_PATH: prefix) + // Extract filter path from output. lines := strings.Split(out, "\n") var filterPath string var outputLines []string @@ -1282,7 +1170,6 @@ fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true filterPath = strings.TrimPrefix(line, "FILTER_PATH:") filterPath = strings.TrimSpace(filterPath) foundPathMarker = true - // Skip this line from the output continue } outputLines = append(outputLines, line) @@ -1303,9 +1190,7 @@ fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true return output, filterPath, nil } -// GetJailConfig implements Connector. func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) { - // Validate jail name jail = strings.TrimSpace(jail) if jail == "" { return "", "", fmt.Errorf("jail name cannot be empty") @@ -1321,18 +1206,14 @@ func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, return content, localPath, nil } - // Fallback to .conf content, err = sc.readRemoteFile(ctx, confPath) if err != nil { - // If neither exists, return empty jail section with .local path (will be created on save) return fmt.Sprintf("[%s]\n", jail), localPath, nil } return content, confPath, nil } -// SetJailConfig implements Connector. func (sc *SSHConnector) SetJailConfig(ctx context.Context, jail, content string) error { - // Validate jail name jail = strings.TrimSpace(jail) if jail == "" { return fmt.Errorf("jail name cannot be empty") @@ -1340,19 +1221,13 @@ func (sc *SSHConnector) SetJailConfig(ctx context.Context, jail, content string) fail2banPath := sc.getFail2banPath(ctx) jailDPath := filepath.Join(fail2banPath, "jail.d") - - // Ensure jail.d directory exists _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", jailDPath}) if err != nil { return fmt.Errorf("failed to create jail.d directory: %w", err) } - - // Ensure .local file exists (copy from .conf if needed) if err := sc.ensureRemoteLocalFile(ctx, jailDPath, jail); err != nil { return fmt.Errorf("failed to ensure .local file for jail %s: %w", jail, err) } - - // Write to .local file localPath := filepath.Join(jailDPath, jail+".local") if err := sc.writeRemoteFile(ctx, localPath, content); err != nil { return fmt.Errorf("failed to write jail config: %w", err) @@ -1361,7 +1236,6 @@ func (sc *SSHConnector) SetJailConfig(ctx context.Context, jail, content string) return nil } -// TestLogpath implements Connector. func (sc *SSHConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) { if logpath == "" { return []string{}, nil @@ -1372,7 +1246,6 @@ func (sc *SSHConnector) TestLogpath(ctx context.Context, logpath string) ([]stri var script string if hasWildcard { - // Use find with glob pattern script = fmt.Sprintf(` set -e LOGPATH=%q @@ -1380,7 +1253,6 @@ LOGPATH=%q 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 @@ -1394,7 +1266,7 @@ fi out, err := sc.runRemoteCommand(ctx, []string{script}) if err != nil { - return []string{}, nil // Return empty on error + return []string{}, nil } var matches []string @@ -1407,15 +1279,11 @@ fi return matches, nil } -// TestLogpathWithResolution implements Connector. -// Resolves variables on remote system, then tests the resolved path. func (sc *SSHConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) { originalPath = strings.TrimSpace(logpath) if originalPath == "" { return originalPath, "", []string{}, nil } - - // Create Python script to resolve variables on remote system resolveScript := fmt.Sprintf(`python3 - <<'PYEOF' import os import re @@ -1538,11 +1406,8 @@ PYEOF 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) @@ -1551,19 +1416,14 @@ PYEOF return originalPath, resolvedPath, files, nil } -// UpdateDefaultSettings implements Connector. func (sc *SSHConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error { - // Since the managed jail.local is fully owned by Fail2ban-UI, a complete - // rewrite from current settings is always correct and self-healing. return sc.EnsureJailLocalStructure(ctx) } -// 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 } @@ -1573,16 +1433,12 @@ func (sc *SSHConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool return true, hasUIAction, nil } -// EnsureJailLocalStructure implements Connector. -// If JAIL_AUTOMIGRATION=true, it first migrates any legacy jails to jail.d/. func (sc *SSHConnector) EnsureJailLocalStructure(ctx context.Context) error { jailLocalPath := "/etc/fail2ban/jail.local" - // 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. @@ -1598,7 +1454,7 @@ func (sc *SSHConnector) EnsureJailLocalStructure(ctx context.Context) error { } } - // Build content using the shared helper (single source of truth) + // Build content using the shared helper. content := config.BuildJailLocalContent() // Escape single quotes for safe use in a single-quoted heredoc @@ -1614,39 +1470,33 @@ JAILLOCAL return err } -// MigrateJailsFromJailLocalRemote migrates non-commented jail sections from jail.local to jail.d/*.local files on remote system. -// EXPERIMENTAL: Only called when JAIL_AUTOMIGRATION=true. It is always best to migrate a pre-existing jail.local by hand. +// Migrate jail.local to jail.d/*.local. EXPERIMENTAL, only when JAIL_AUTOMIGRATION=true. func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) error { jailLocalPath := "/etc/fail2ban/jail.local" jailDPath := "/etc/fail2ban/jail.d" - // Check if jail.local exists checkScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailLocalPath) out, err := sc.runRemoteCommand(ctx, []string{checkScript}) if err != nil || strings.TrimSpace(out) != "exists" { config.DebugLog("No jails to migrate from jail.local on server %s (file does not exist)", sc.server.Name) - return nil // Nothing to migrate + return nil } - // Read jail.local content content, err := sc.runRemoteCommand(ctx, []string{"cat", jailLocalPath}) if err != nil { return fmt.Errorf("failed to read jail.local on server %s: %w", sc.server.Name, err) } - // Parse content locally to extract non-commented sections sections, defaultContent, err := parseJailSectionsUncommented(content) if err != nil { return fmt.Errorf("failed to parse jail.local on server %s: %w", sc.server.Name, err) } - // If no non-commented, non-DEFAULT jails found, nothing to migrate if len(sections) == 0 { config.DebugLog("No jails to migrate from jail.local on remote system") return nil } - // Create backup backupPath := jailLocalPath + ".backup." + fmt.Sprintf("%d", time.Now().Unix()) backupScript := fmt.Sprintf("cp %s %s", jailLocalPath, backupPath) if _, err := sc.runRemoteCommand(ctx, []string{backupScript}); err != nil { @@ -1654,13 +1504,11 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err } config.DebugLog("Created backup of jail.local at %s on server %s", backupPath, sc.server.Name) - // Ensure jail.d directory exists ensureDirScript := fmt.Sprintf("mkdir -p %s", jailDPath) if _, err := sc.runRemoteCommand(ctx, []string{ensureDirScript}); err != nil { return fmt.Errorf("failed to create jail.d directory on server %s: %w", sc.server.Name, err) } - // Write each jail to its own .local file migratedCount := 0 for jailName, jailContent := range sections { if jailName == "" { @@ -1669,7 +1517,6 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err jailFilePath := fmt.Sprintf("%s/%s.local", jailDPath, jailName) - // Check if .local file already exists checkFileScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailFilePath) fileOut, err := sc.runRemoteCommand(ctx, []string{checkFileScript}) if err == nil && strings.TrimSpace(fileOut) == "exists" { @@ -1677,8 +1524,6 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err 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 @@ -1691,10 +1536,7 @@ JAILEOF 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 @@ -1709,31 +1551,19 @@ LOCALEOF 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) @@ -1742,19 +1572,14 @@ func (sc *SSHConnector) CreateJail(ctx context.Context, jailName, content string 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) @@ -1763,34 +1588,26 @@ func (sc *SSHConnector) DeleteJail(ctx context.Context, jailName string) error { 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 } @@ -1799,24 +1616,23 @@ func (sc *SSHConnector) DeleteFilter(ctx context.Context, filterName string) err localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local") confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf") - // Delete both .local and .conf files if they exist (rm -f doesn't error if file doesn't exist) - // Use a single command to delete both files _, err := sc.runRemoteCommand(ctx, []string{"rm", "-f", localPath, confPath}) if err != nil { return fmt.Errorf("failed to delete filter files %s or %s: %w", localPath, confPath, err) } - return nil } -// parseJailConfigContent parses jail configuration content and returns JailInfo slice. +// ========================================================================= +// Config Parsing +// ========================================================================= + func parseJailConfigContent(content string) []JailInfo { var jails []JailInfo scanner := bufio.NewScanner(strings.NewReader(content)) var currentJail string enabled := true - // Sections that should be ignored (not jails) ignoredSections := map[string]bool{ "DEFAULT": true, "INCLUDES": true, diff --git a/internal/fail2ban/filter_management.go b/internal/fail2ban/filter_management.go index 0bf3732..a4dda05 100644 --- a/internal/fail2ban/filter_management.go +++ b/internal/fail2ban/filter_management.go @@ -1,6 +1,6 @@ // Fail2ban UI - A Swiss made, management interface for Fail2ban. // -// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // // Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. @@ -29,8 +29,7 @@ import ( "github.com/swissmakers/fail2ban-ui/internal/config" ) -// GetFilterConfig returns the filter configuration using the default connector. -// Returns (config, filePath, error) +// Returns the filter configuration using the default connector. func GetFilterConfig(jail string) (string, string, error) { conn, err := GetManager().DefaultConnector() if err != nil { @@ -39,7 +38,7 @@ func GetFilterConfig(jail string) (string, string, error) { return conn.GetFilterConfig(context.Background(), jail) } -// SetFilterConfig writes the filter configuration using the default connector. +// Writes the filter configuration using the default connector. func SetFilterConfig(jail, newContent string) error { conn, err := GetManager().DefaultConnector() if err != nil { @@ -48,10 +47,7 @@ func SetFilterConfig(jail, newContent string) error { return conn.SetFilterConfig(context.Background(), jail, newContent) } -// ensureFilterLocalFile ensures that a .local file exists for the given filter. -// If .local doesn't exist, it copies from .conf if available, or creates an empty file. func ensureFilterLocalFile(filterName string) error { - // Validate filter name - must not be empty filterName = strings.TrimSpace(filterName) if filterName == "" { return fmt.Errorf("filter name cannot be empty") @@ -61,13 +57,11 @@ func ensureFilterLocalFile(filterName string) error { localPath := filepath.Join(filterDPath, filterName+".local") confPath := filepath.Join(filterDPath, filterName+".conf") - // Check if .local already exists if _, err := os.Stat(localPath); err == nil { config.DebugLog("Filter .local file already exists: %s", localPath) return nil } - // Try to copy from .conf if it exists if _, err := os.Stat(confPath); err == nil { config.DebugLog("Copying filter config from .conf to .local: %s -> %s", confPath, localPath) content, err := os.ReadFile(confPath) @@ -81,7 +75,6 @@ func ensureFilterLocalFile(filterName string) error { return nil } - // Neither exists, create empty .local file config.DebugLog("Neither .local nor .conf exists for filter %s, creating empty .local file", filterName) if err := os.WriteFile(localPath, []byte(""), 0644); err != nil { return fmt.Errorf("failed to create empty filter .local file %s: %w", localPath, err) @@ -90,26 +83,20 @@ func ensureFilterLocalFile(filterName string) error { return nil } -// RemoveComments removes all lines that start with # (comments) from filter content -// and trims leading/trailing empty newlines -// This is exported for use in handlers that need to display filter content without comments func RemoveComments(content string) string { lines := strings.Split(content, "\n") var result []string for _, line := range lines { trimmed := strings.TrimSpace(line) - // Skip lines that start with # (comments) if !strings.HasPrefix(trimmed, "#") { result = append(result, line) } } - // Remove leading empty lines for len(result) > 0 && strings.TrimSpace(result[0]) == "" { result = result[1:] } - // Remove trailing empty lines for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" { result = result[:len(result)-1] } @@ -117,10 +104,8 @@ func RemoveComments(content string) string { return strings.Join(result, "\n") } -// readFilterConfigWithFallback reads filter config from .local first, then falls back to .conf. -// Returns (content, filePath, error) +// Reads filter config from .local first, then falls back to .conf. func readFilterConfigWithFallback(filterName string) (string, string, error) { - // Validate filter name - must not be empty filterName = strings.TrimSpace(filterName) if filterName == "" { return "", "", fmt.Errorf("filter name cannot be empty") @@ -130,37 +115,26 @@ func readFilterConfigWithFallback(filterName string) (string, string, error) { localPath := filepath.Join(filterDPath, filterName+".local") confPath := filepath.Join(filterDPath, filterName+".conf") - // Try .local first if content, err := os.ReadFile(localPath); err == nil { config.DebugLog("Reading filter config from .local: %s", localPath) return string(content), localPath, nil } - // Fallback to .conf if content, err := os.ReadFile(confPath); err == nil { config.DebugLog("Reading filter config from .conf: %s", confPath) return string(content), confPath, nil } - - // Neither exists, return error with .local path (will be created on save) return "", localPath, fmt.Errorf("filter config not found: neither %s nor %s exists", localPath, confPath) } -// GetFilterConfigLocal reads a filter configuration from the local filesystem. -// Prefers .local over .conf files. -// Returns (content, filePath, error) func GetFilterConfigLocal(jail string) (string, string, error) { return readFilterConfigWithFallback(jail) } -// SetFilterConfigLocal writes the filter configuration to the local filesystem. -// Always writes to .local file, ensuring it exists first by copying from .conf if needed. func SetFilterConfigLocal(jail, newContent string) error { - // Ensure .local file exists (copy from .conf if needed) if err := ensureFilterLocalFile(jail); err != nil { return err } - localPath := filepath.Join("/etc/fail2ban/filter.d", jail+".local") if err := os.WriteFile(localPath, []byte(newContent), 0644); err != nil { return fmt.Errorf("failed to write filter .local file for %s: %w", jail, err) @@ -169,15 +143,13 @@ func SetFilterConfigLocal(jail, newContent string) error { return nil } -// ValidateFilterName validates a filter name format. -// Returns an error if the name is invalid (empty, contains invalid characters, or is reserved). +// Validates a filter name format. func ValidateFilterName(name string) error { name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("filter name cannot be empty") } - // Check for invalid characters (only alphanumeric, dash, underscore allowed) invalidChars := regexp.MustCompile(`[^a-zA-Z0-9_-]`) if invalidChars.MatchString(name) { return fmt.Errorf("filter name '%s' contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed", name) @@ -186,8 +158,7 @@ func ValidateFilterName(name string) error { return nil } -// ListFilterFiles lists all filter files in the specified directory. -// Returns full paths to .local and .conf files. +// Lists all filter files in the specified directory. func ListFilterFiles(directory string) ([]string, error) { var files []string @@ -200,14 +171,10 @@ func ListFilterFiles(directory string) ([]string, error) { if entry.IsDir() { continue } - name := entry.Name() - // Skip hidden files and invalid names if strings.HasPrefix(name, ".") { continue } - - // Only include .local and .conf files if strings.HasSuffix(name, ".local") || strings.HasSuffix(name, ".conf") { fullPath := filepath.Join(directory, name) files = append(files, fullPath) @@ -217,28 +184,22 @@ func ListFilterFiles(directory string) ([]string, error) { return files, nil } -// DiscoverFiltersFromFiles discovers all filters from the filesystem. -// Reads from /etc/fail2ban/filter.d/ directory, preferring .local files over .conf files. -// Returns unique filter names. +// Returns all filters from the filesystem. func DiscoverFiltersFromFiles() ([]string, error) { filterDPath := "/etc/fail2ban/filter.d" - // Check if directory exists if _, err := os.Stat(filterDPath); os.IsNotExist(err) { - // Directory doesn't exist, return empty list return []string{}, nil } - // List all filter files files, err := ListFilterFiles(filterDPath) if err != nil { return nil, err } - filterMap := make(map[string]bool) // Track unique filter names - processedFiles := make(map[string]bool) // Track base names to avoid duplicates + filterMap := make(map[string]bool) + processedFiles := make(map[string]bool) - // First pass: collect all .local files (these take precedence) for _, filePath := range files { if !strings.HasSuffix(filePath, ".local") { continue @@ -250,7 +211,6 @@ func DiscoverFiltersFromFiles() ([]string, error) { continue } - // Skip if we've already processed this base name if processedFiles[baseName] { continue } @@ -259,28 +219,22 @@ func DiscoverFiltersFromFiles() ([]string, error) { filterMap[baseName] = true } - // Second pass: collect .conf files that don't have corresponding .local files for _, filePath := range files { if !strings.HasSuffix(filePath, ".conf") { continue } - filename := filepath.Base(filePath) baseName := strings.TrimSuffix(filename, ".conf") if baseName == "" { continue } - - // Skip if we've already processed a .local file with the same base name if processedFiles[baseName] { continue } - processedFiles[baseName] = true filterMap[baseName] = true } - // Convert map to sorted slice var filters []string for name := range filterMap { filters = append(filters, name) @@ -290,32 +244,24 @@ func DiscoverFiltersFromFiles() ([]string, error) { return filters, nil } -// CreateFilter creates a new filter in filter.d/{name}.local. -// If the filter already exists, it will be overwritten. +// Creates a new filter. func CreateFilter(filterName, content string) error { if err := ValidateFilterName(filterName); err != nil { return err } - filterDPath := "/etc/fail2ban/filter.d" localPath := filepath.Join(filterDPath, filterName+".local") - - // Ensure directory exists if err := os.MkdirAll(filterDPath, 0755); err != nil { return fmt.Errorf("failed to create filter.d directory: %w", err) } - - // Write the file if err := os.WriteFile(localPath, []byte(content), 0644); err != nil { return fmt.Errorf("failed to create filter file %s: %w", localPath, err) } - config.DebugLog("Created filter file: %s", localPath) return nil } -// DeleteFilter deletes a filter's .local and .conf files from filter.d/ if they exist. -// Both files are deleted to ensure complete removal of the filter configuration. +// Deletes a filter's .local and .conf files from filter.d/ if they exist. func DeleteFilter(filterName string) error { if err := ValidateFilterName(filterName); err != nil { return err @@ -328,7 +274,6 @@ func DeleteFilter(filterName string) error { var deletedFiles []string var lastErr error - // Delete .local file if it exists if _, err := os.Stat(localPath); err == nil { if err := os.Remove(localPath); err != nil { lastErr = fmt.Errorf("failed to delete filter file %s: %w", localPath, err) @@ -337,8 +282,6 @@ func DeleteFilter(filterName string) error { config.DebugLog("Deleted filter file: %s", localPath) } } - - // Delete .conf file if it exists if _, err := os.Stat(confPath); err == nil { if err := os.Remove(confPath); err != nil { lastErr = fmt.Errorf("failed to delete filter file %s: %w", confPath, err) @@ -347,23 +290,15 @@ func DeleteFilter(filterName string) error { config.DebugLog("Deleted filter file: %s", confPath) } } - - // If no files were deleted and no error occurred, it means neither file existed if len(deletedFiles) == 0 && lastErr == nil { return fmt.Errorf("filter file %s or %s does not exist", localPath, confPath) } - - // Return the last error if any occurred if lastErr != nil { return lastErr } - return nil } -// GetFiltersLocal returns a list of filter names from /etc/fail2ban/filter.d -// Returns unique filter names from both .conf and .local files (prefers .local if both exist) -// This is the canonical implementation - now uses DiscoverFiltersFromFiles() func GetFiltersLocal() ([]string, error) { return DiscoverFiltersFromFiles() } @@ -380,7 +315,7 @@ func normalizeLogLines(logLines []string) []string { return cleaned } -// extractVariablesFromContent extracts variable names from [DEFAULT] section of filter content +// Extracts variable names from [DEFAULT] section of filter content. func extractVariablesFromContent(content string) map[string]bool { variables := make(map[string]bool) lines := strings.Split(content, "\n") @@ -388,20 +323,15 @@ func extractVariablesFromContent(content string) map[string]bool { for _, line := range lines { trimmed := strings.TrimSpace(line) - - // Check for [DEFAULT] section if strings.HasPrefix(trimmed, "[DEFAULT]") { inDefaultSection = true continue } - // Check for end of [DEFAULT] section (next section starts) if inDefaultSection && strings.HasPrefix(trimmed, "[") { inDefaultSection = false continue } - - // Extract variable name from [DEFAULT] section if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") { parts := strings.SplitN(trimmed, "=", 2) if len(parts) == 2 { @@ -412,11 +342,9 @@ func extractVariablesFromContent(content string) map[string]bool { } } } - return variables } -// removeDuplicateVariables removes variable definitions from included content that already exist in main filter func removeDuplicateVariables(includedContent string, mainVariables map[string]bool) string { lines := strings.Split(includedContent, "\n") var result strings.Builder @@ -427,7 +355,6 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b trimmed := strings.TrimSpace(line) originalLine := line - // Check for [DEFAULT] section if strings.HasPrefix(trimmed, "[DEFAULT]") { inDefaultSection = true result.WriteString(originalLine) @@ -443,13 +370,11 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b continue } - // In [DEFAULT] section, check if variable already exists in main filter if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") { parts := strings.SplitN(trimmed, "=", 2) if len(parts) == 2 { varName := strings.TrimSpace(parts[0]) if mainVariables[varName] { - // Skip this line - variable will be defined in main filter (takes precedence) removedCount++ config.DebugLog("Removing variable '%s' from included file (will be overridden by main filter)", varName) continue @@ -468,11 +393,6 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b return result.String() } -// resolveFilterIncludes parses the filter content to find [INCLUDES] section -// and loads the included files, combining them with the main filter content. -// Returns: combined content with before files + main filter + after files -// Duplicate variables in main filter are removed if they exist in included files -// currentFilterName: name of the current filter being tested (to avoid self-inclusion) func resolveFilterIncludes(filterContent string, filterDPath string, currentFilterName string) (string, error) { lines := strings.Split(filterContent, "\n") var beforeFiles []string @@ -484,18 +404,15 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt 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) @@ -519,7 +436,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt } } - // Collect main content (everything except [INCLUDES] section) if !inIncludesSection { if i > 0 { mainContent.WriteString("\n") @@ -532,12 +448,9 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt mainContentStr := mainContent.String() mainVariables := extractVariablesFromContent(mainContentStr) - // Build combined content: before files + main filter + after files var combined strings.Builder - // 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") @@ -545,13 +458,11 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt 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 } - // Always try .local first, then .conf (matching fail2ban's behavior) localPath := filepath.Join(filterDPath, baseName+".local") confPath := filepath.Join(filterDPath, baseName+".conf") @@ -559,7 +470,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt var err error var filePath string - // Try .local first if content, err = os.ReadFile(localPath); err == nil { filePath = localPath config.DebugLog("Loading included filter file from .local: %s", filePath) @@ -568,11 +478,11 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt config.DebugLog("Loading included filter file from .conf: %s", filePath) } else { config.DebugLog("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err) - continue // Skip if neither file exists + continue } contentStr := string(content) - // Remove variables from included file that are defined in main filter (main filter takes precedence) + // Remove variables from included file that are defined in main filter. cleanedContent := removeDuplicateVariables(contentStr, mainVariables) combined.WriteString(cleanedContent) if !strings.HasSuffix(cleanedContent, "\n") { @@ -581,15 +491,12 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt 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") @@ -597,11 +504,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt 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 - - // Always try .local first, then .conf (matching fail2ban's behavior) localPath := filepath.Join(filterDPath, baseName+".local") confPath := filepath.Join(filterDPath, baseName+".conf") @@ -609,7 +511,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt var err error var filePath string - // Try .local first if content, err = os.ReadFile(localPath); err == nil { filePath = localPath config.DebugLog("Loading included filter file from .local: %s", filePath) @@ -618,11 +519,9 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt config.DebugLog("Loading included filter file from .conf: %s", filePath) } else { config.DebugLog("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err) - continue // Skip if neither file exists + continue } - contentStr := string(content) - // 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) @@ -634,10 +533,10 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt return combined.String(), nil } -// TestFilterLocal tests a filter against log lines using fail2ban-regex -// Returns the full output of fail2ban-regex command and the filter path used -// Uses .local file if it exists, otherwise falls back to .conf file -// If filterContent is provided, it creates a temporary filter file and uses that instead +// ========================================================================= +// Filter Testing +// ========================================================================= + func TestFilterLocal(filterName string, logLines []string, filterContent string) (string, string, error) { cleaned := normalizeLogLines(logLines) if len(cleaned) == 0 { @@ -657,7 +556,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string) defer os.Remove(tempFilterFile.Name()) defer tempFilterFile.Close() - // Resolve filter includes to get complete filter content with all dependencies filterDPath := "/etc/fail2ban/filter.d" contentToWrite, err := resolveFilterIncludes(filterContent, filterDPath, filterName) if err != nil { @@ -665,7 +563,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string) contentToWrite = filterContent } - // Ensure it ends with a newline for proper parsing if !strings.HasSuffix(contentToWrite, "\n") { contentToWrite += "\n" } @@ -674,7 +571,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string) return "", "", fmt.Errorf("failed to write temporary filter file: %w", err) } - // Ensure the file is synced to disk if err := tempFilterFile.Sync(); err != nil { return "", "", fmt.Errorf("failed to sync temporary filter file: %w", err) } @@ -683,7 +579,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string) filterPath = tempFilterFile.Name() config.DebugLog("TestFilterLocal: using custom filter content from temporary file: %s (size: %d bytes, includes resolved: %v)", filterPath, len(contentToWrite), err == nil) } else { - // Try .local first, then fallback to .conf localPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".local") confPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".conf") @@ -706,7 +601,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string) defer os.Remove(tmpFile.Name()) defer tmpFile.Close() - // Write all log lines to the temp file for _, logLine := range cleaned { if _, err := tmpFile.WriteString(logLine + "\n"); err != nil { return "", filterPath, fmt.Errorf("failed to write to temporary log file: %w", err) @@ -714,13 +608,9 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string) } tmpFile.Close() - // Run fail2ban-regex with the log file and filter config - // Format: fail2ban-regex /path/to/logfile /etc/fail2ban/filter.d/filter-name.conf cmd := exec.Command("fail2ban-regex", tmpFile.Name(), filterPath) out, _ := cmd.CombinedOutput() output := string(out) - // Return the full output regardless of exit code (fail2ban-regex may exit non-zero for no matches) - // The output contains useful information even when there are no matches return output, filterPath, nil } diff --git a/internal/fail2ban/jail_management.go b/internal/fail2ban/jail_management.go index d38d0ab..f9ef092 100644 --- a/internal/fail2ban/jail_management.go +++ b/internal/fail2ban/jail_management.go @@ -13,20 +13,7 @@ import ( "github.com/swissmakers/fail2ban-ui/internal/config" ) -var ( - migrationOnce sync.Once -) - -// Auto-migration of an existing jail.local into jail.d/ is experimental and disabled by default; -// it is always best to migrate a pre-existing jail.local by hand. -func isJailAutoMigrationEnabled() bool { - return strings.EqualFold(os.Getenv("JAIL_AUTOMIGRATION"), "true") -} - -// ensureJailLocalFile ensures that a .local file exists for the given jail. -// If .local doesn't exist, it copies from .conf if available, or creates a minimal section. func ensureJailLocalFile(jailName string) error { - // Validate jail name - must not be empty jailName = strings.TrimSpace(jailName) if jailName == "" { return fmt.Errorf("jail name cannot be empty") @@ -36,13 +23,11 @@ func ensureJailLocalFile(jailName string) error { localPath := filepath.Join(jailDPath, jailName+".local") confPath := filepath.Join(jailDPath, jailName+".conf") - // Check if .local already exists if _, err := os.Stat(localPath); err == nil { config.DebugLog("Jail .local file already exists: %s", localPath) return nil } - // Try to copy from .conf if it exists if _, err := os.Stat(confPath); err == nil { config.DebugLog("Copying jail config from .conf to .local: %s -> %s", confPath, localPath) content, err := os.ReadFile(confPath) @@ -56,7 +41,6 @@ func ensureJailLocalFile(jailName string) error { return nil } - // Neither exists, create minimal section config.DebugLog("Creating minimal jail .local file: %s", localPath) if err := os.MkdirAll(jailDPath, 0755); err != nil { return fmt.Errorf("failed to create jail.d directory: %w", err) @@ -69,10 +53,11 @@ func ensureJailLocalFile(jailName string) error { return nil } -// readJailConfigWithFallback reads jail config from .local first, then falls back to .conf. -// Returns (content, filePath, error) +// ========================================================================= +// Config Read/Write +// ========================================================================= + func readJailConfigWithFallback(jailName string) (string, string, error) { - // Validate jail name - must not be empty jailName = strings.TrimSpace(jailName) if jailName == "" { return "", "", fmt.Errorf("jail name cannot be empty") @@ -82,32 +67,30 @@ func readJailConfigWithFallback(jailName string) (string, string, error) { localPath := filepath.Join(jailDPath, jailName+".local") confPath := filepath.Join(jailDPath, jailName+".conf") - // Try .local first if content, err := os.ReadFile(localPath); err == nil { config.DebugLog("Reading jail config from .local: %s", localPath) return string(content), localPath, nil } - // Fallback to .conf if content, err := os.ReadFile(confPath); err == nil { config.DebugLog("Reading jail config from .conf: %s", confPath) return string(content), confPath, nil } - // Neither exists, return empty section with .local path (will be created on save) config.DebugLog("Neither .local nor .conf exists for jail %s, returning empty section", jailName) return fmt.Sprintf("[%s]\n", jailName), localPath, nil } -// ValidateJailName validates a jail name format. -// Returns an error if the name is invalid (empty, contains invalid characters, or is reserved). +// ========================================================================= +// Validation +// ========================================================================= + func ValidateJailName(name string) error { name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("jail name cannot be empty") } - // Reserved names that should not be used reservedNames := map[string]bool{ "DEFAULT": true, "INCLUDES": true, @@ -125,8 +108,10 @@ func ValidateJailName(name string) error { return nil } -// ListJailFiles lists all jail config files in the specified directory. -// Returns full paths to .local and .conf files. +// ========================================================================= +// Jail Discovery +// ========================================================================= + func ListJailFiles(directory string) ([]string, error) { var files []string @@ -141,7 +126,6 @@ func ListJailFiles(directory string) ([]string, error) { } name := entry.Name() - // Skip hidden files and invalid names if strings.HasPrefix(name, ".") { continue } @@ -156,29 +140,24 @@ func ListJailFiles(directory string) ([]string, error) { return files, nil } -// DiscoverJailsFromFiles discovers all jails from the filesystem. -// Reads from /etc/fail2ban/jail.d/ directory, preferring .local files over .conf files. -// Returns all jails found (enabled and disabled). +// Returns all jails from /etc/fail2ban/jail.d directory. func DiscoverJailsFromFiles() ([]JailInfo, error) { jailDPath := "/etc/fail2ban/jail.d" - // Check if directory exists if _, err := os.Stat(jailDPath); os.IsNotExist(err) { - // Directory doesn't exist, return empty list return []JailInfo{}, nil } - // List all jail files files, err := ListJailFiles(jailDPath) if err != nil { return nil, err } var allJails []JailInfo - processedFiles := make(map[string]bool) // Track base names to avoid duplicates - processedJails := make(map[string]bool) // Track jail names to avoid duplicates + processedFiles := make(map[string]bool) + processedJails := make(map[string]bool) - // First pass: process all .local files + // Parse .local files for _, filePath := range files { if !strings.HasSuffix(filePath, ".local") { continue @@ -189,22 +168,17 @@ func DiscoverJailsFromFiles() ([]JailInfo, error) { if baseName == "" { continue } - - // Skip if we've already processed this base name if processedFiles[baseName] { continue } - processedFiles[baseName] = true - // Parse the file jails, err := parseJailConfigFile(filePath) if err != nil { config.DebugLog("Failed to parse jail file %s: %v", filePath, err) continue } - // Add jails from this file for _, jail := range jails { if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { allJails = append(allJails, jail) @@ -212,8 +186,6 @@ func DiscoverJailsFromFiles() ([]JailInfo, error) { } } } - - // Second pass: process .conf files that don't have corresponding .local files for _, filePath := range files { if !strings.HasSuffix(filePath, ".conf") { continue @@ -224,22 +196,17 @@ func DiscoverJailsFromFiles() ([]JailInfo, error) { if baseName == "" { continue } - - // Skip if we've already processed a .local file with the same base name if processedFiles[baseName] { continue } - processedFiles[baseName] = true - // Parse the file jails, err := parseJailConfigFile(filePath) if err != nil { config.DebugLog("Failed to parse jail file %s: %v", filePath, err) continue } - // Add jails from this file for _, jail := range jails { if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { allJails = append(allJails, jail) @@ -251,8 +218,10 @@ func DiscoverJailsFromFiles() ([]JailInfo, error) { return allJails, nil } -// CreateJail creates a new jail in jail.d/{name}.local. -// If the jail already exists, it will be overwritten. +// ========================================================================= +// Jail Creation +// ========================================================================= + func CreateJail(jailName, content string) error { if err := ValidateJailName(jailName); err != nil { return err @@ -261,20 +230,16 @@ func CreateJail(jailName, content string) error { jailDPath := "/etc/fail2ban/jail.d" localPath := filepath.Join(jailDPath, jailName+".local") - // Ensure directory exists if err := os.MkdirAll(jailDPath, 0755); err != nil { return fmt.Errorf("failed to create jail.d directory: %w", err) } - // Validate content starts with correct section header trimmed := strings.TrimSpace(content) expectedSection := fmt.Sprintf("[%s]", jailName) if !strings.HasPrefix(trimmed, expectedSection) { - // Prepend the section header if missing content = expectedSection + "\n" + content } - // Write the file if err := os.WriteFile(localPath, []byte(content), 0644); err != nil { return fmt.Errorf("failed to create jail file %s: %w", localPath, err) } @@ -283,8 +248,11 @@ func CreateJail(jailName, content string) error { return nil } -// DeleteJail deletes a jail's .local and .conf files from jail.d/ if they exist. -// Both files are deleted to ensure complete removal of the jail configuration. +// ========================================================================= +// +// Jail Deletion +// +// ========================================================================= func DeleteJail(jailName string) error { if err := ValidateJailName(jailName); err != nil { return err @@ -297,7 +265,6 @@ func DeleteJail(jailName string) error { var deletedFiles []string var lastErr error - // Delete .local file if it exists if _, err := os.Stat(localPath); err == nil { if err := os.Remove(localPath); err != nil { lastErr = fmt.Errorf("failed to delete jail file %s: %w", localPath, err) @@ -307,7 +274,6 @@ func DeleteJail(jailName string) error { } } - // Delete .conf file if it exists if _, err := os.Stat(confPath); err == nil { if err := os.Remove(confPath); err != nil { lastErr = fmt.Errorf("failed to delete jail file %s: %w", confPath, err) @@ -317,12 +283,10 @@ func DeleteJail(jailName string) error { } } - // If no files were deleted and no error occurred, it means neither file existed if len(deletedFiles) == 0 && lastErr == nil { return fmt.Errorf("jail file %s or %s does not exist", localPath, confPath) } - // Return the last error if any occurred if lastErr != nil { return lastErr } @@ -330,7 +294,7 @@ func DeleteJail(jailName string) error { return nil } -// GetAllJails reads jails from /etc/fail2ban/jail.d directory. +// Returns all jails. func GetAllJails() ([]JailInfo, error) { // Run migration once if enabled (experimental, off by default) if isJailAutoMigrationEnabled() { @@ -342,7 +306,6 @@ func GetAllJails() ([]JailInfo, error) { }) } - // Discover jails from filesystem jails, err := DiscoverJailsFromFiles() if err != nil { return nil, fmt.Errorf("failed to discover jails from files: %w", err) @@ -351,8 +314,6 @@ func GetAllJails() ([]JailInfo, error) { return jails, nil } -// parseJailConfigFile parses a jail configuration file and returns a slice of JailInfo. -// It assumes each jail section is defined by [JailName] and that an "enabled" line may exist. func parseJailConfigFile(path string) ([]JailInfo, error) { var jails []JailInfo file, err := os.Open(path) @@ -364,38 +325,30 @@ func parseJailConfigFile(path string) ([]JailInfo, error) { scanner := bufio.NewScanner(file) var currentJail string - // Sections that should be ignored (not jails) ignoredSections := map[string]bool{ "DEFAULT": true, "INCLUDES": true, } - // default value is true if "enabled" is missing; we set it for each section. enabled := true for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { - // When a new section starts, save the previous jail if exists. if currentJail != "" && !ignoredSections[currentJail] { jails = append(jails, JailInfo{ JailName: currentJail, Enabled: enabled, }) } - // Start a new jail section. currentJail = strings.TrimSpace(strings.Trim(line, "[]")) - // Skip empty jail names (e.g., from malformed config files with []) if currentJail == "" { - currentJail = "" // Reset to empty to skip this section + currentJail = "" enabled = true continue } - // Reset to default for the new section. enabled = true } else if strings.HasPrefix(strings.ToLower(line), "enabled") { - // Only process enabled line if we have a valid jail name if currentJail != "" { - // Expect format: enabled = true/false parts := strings.Split(line, "=") if len(parts) == 2 { value := strings.TrimSpace(parts[1]) @@ -404,7 +357,6 @@ func parseJailConfigFile(path string) ([]JailInfo, error) { } } } - // Add the final jail if one exists. if currentJail != "" && !ignoredSections[currentJail] { jails = append(jails, JailInfo{ JailName: currentJail, @@ -414,52 +366,38 @@ func parseJailConfigFile(path string) ([]JailInfo, error) { return jails, scanner.Err() } -// UpdateJailEnabledStates updates the enabled state for each jail based on the provided updates map. -// Updates only the corresponding .local file in /etc/fail2ban/jail.d/ for each jail. -// Creates .local file by copying from .conf if needed, preserving original .conf files. +// ========================================================================= +// Jail Enabled from "Manage Jails" +// ========================================================================= + func UpdateJailEnabledStates(updates map[string]bool) error { config.DebugLog("UpdateJailEnabledStates called with %d updates: %+v", len(updates), updates) jailDPath := "/etc/fail2ban/jail.d" - // Ensure jail.d directory exists - if err := os.MkdirAll(jailDPath, 0755); err != nil { - return fmt.Errorf("failed to create jail.d directory: %w", err) - } - - // Update each jail in its own .local file for jailName, enabled := range updates { - // Validate jail name - skip empty or invalid names jailName = strings.TrimSpace(jailName) if jailName == "" { config.DebugLog("Skipping empty jail name in updates map") continue } - config.DebugLog("Processing jail: %s, enabled: %t", jailName, enabled) - // Ensure .local file exists (copy from .conf if needed) + // Ensure .local file exists if err := ensureJailLocalFile(jailName); err != nil { return fmt.Errorf("failed to ensure .local file for jail %s: %w", jailName, err) } - jailFilePath := filepath.Join(jailDPath, jailName+".local") config.DebugLog("Jail file path: %s", jailFilePath) - - // Read existing .local file content, err := os.ReadFile(jailFilePath) if err != nil { return fmt.Errorf("failed to read jail .local file %s: %w", jailFilePath, err) } - var lines []string if len(content) > 0 { lines = strings.Split(string(content), "\n") } else { - // Create new file with jail section lines = []string{fmt.Sprintf("[%s]", jailName)} } - - // Update or add enabled line var outputLines []string var foundEnabled bool var currentJail string @@ -480,8 +418,6 @@ func UpdateJailEnabledStates(updates map[string]bool) error { outputLines = append(outputLines, line) } } - - // If enabled line not found, add it after the jail section header if !foundEnabled { var newLines []string for i, line := range outputLines { @@ -489,7 +425,6 @@ func UpdateJailEnabledStates(updates map[string]bool) error { if strings.TrimSpace(line) == fmt.Sprintf("[%s]", jailName) { // Insert enabled line after the section header newLines = append(newLines, fmt.Sprintf("enabled = %t", enabled)) - // Add remaining lines if i+1 < len(outputLines) { newLines = append(newLines, outputLines[i+1:]...) } @@ -499,12 +434,9 @@ func UpdateJailEnabledStates(updates map[string]bool) error { if len(newLines) > len(outputLines) { outputLines = newLines } else { - // Fallback: append at the end outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) } } - - // Write updated content newContent := strings.Join(outputLines, "\n") if !strings.HasSuffix(newContent, "\n") { newContent += "\n" @@ -517,239 +449,8 @@ func UpdateJailEnabledStates(updates map[string]bool) error { return nil } -// parseJailSectionsUncommented parses jail.local content and returns: -// - map of jail name to jail content (excluding DEFAULT, INCLUDES, and commented sections) -// - DEFAULT section content (including commented lines) -func parseJailSectionsUncommented(content string) (map[string]string, string, error) { - sections := make(map[string]string) - var defaultContent strings.Builder - - // Sections that should be ignored (not jails) - ignoredSections := map[string]bool{ - "DEFAULT": true, - "INCLUDES": true, - } - - scanner := bufio.NewScanner(strings.NewReader(content)) - var currentSection string - var currentContent strings.Builder - inDefault := false - sectionIsCommented := false - - for scanner.Scan() { - line := scanner.Text() - trimmed := strings.TrimSpace(line) - - // Check if this is a section header - if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { - // Check if the section is commented - originalLine := strings.TrimSpace(line) - isCommented := strings.HasPrefix(originalLine, "#") - - // Save previous section - if currentSection != "" { - sectionContent := strings.TrimSpace(currentContent.String()) - if inDefault { - // Always include DEFAULT section content (even if commented) - defaultContent.WriteString(sectionContent) - if !strings.HasSuffix(sectionContent, "\n") { - defaultContent.WriteString("\n") - } - } else if !ignoredSections[currentSection] && !sectionIsCommented { - // Only save non-commented, non-ignored sections - sections[currentSection] = sectionContent - } - } - - // Start new section - if isCommented { - // Remove the # from the section name - sectionName := strings.Trim(trimmed, "[]") - if strings.HasPrefix(sectionName, "#") { - sectionName = strings.TrimSpace(strings.TrimPrefix(sectionName, "#")) - } - currentSection = sectionName - sectionIsCommented = true - } else { - currentSection = strings.Trim(trimmed, "[]") - sectionIsCommented = false - } - currentContent.Reset() - currentContent.WriteString(line) - currentContent.WriteString("\n") - inDefault = (currentSection == "DEFAULT") - } else { - currentContent.WriteString(line) - currentContent.WriteString("\n") - } - } - - // Save final section - if currentSection != "" { - sectionContent := strings.TrimSpace(currentContent.String()) - if inDefault { - defaultContent.WriteString(sectionContent) - } else if !ignoredSections[currentSection] && !sectionIsCommented { - // Only save if it's not an ignored section and not commented - sections[currentSection] = sectionContent - } - } - - return sections, defaultContent.String(), scanner.Err() -} - -// MigrateJailsFromJailLocal migrates non-commented jail sections from jail.local to jail.d/*.local files. -// EXPERIMENTAL: Only called when JAIL_AUTOMIGRATION=true. It is always best to migrate a pre-existing jail.local by hand. -func MigrateJailsFromJailLocal() error { - localPath := "/etc/fail2ban/jail.local" - jailDPath := "/etc/fail2ban/jail.d" - - // Check if jail.local exists - if _, err := os.Stat(localPath); os.IsNotExist(err) { - return nil // Nothing to migrate - } - - // Read jail.local content - content, err := os.ReadFile(localPath) - if err != nil { - return fmt.Errorf("failed to read jail.local: %w", err) - } - - // Parse content to extract non-commented sections - sections, defaultContent, err := parseJailSectionsUncommented(string(content)) - if err != nil { - return fmt.Errorf("failed to parse jail.local: %w", err) - } - - // If no non-commented, non-DEFAULT jails found, nothing to migrate - if len(sections) == 0 { - config.DebugLog("No jails to migrate from jail.local") - return nil - } - - // Create backup of jail.local - backupPath := localPath + ".backup." + fmt.Sprintf("%d", time.Now().Unix()) - if err := os.WriteFile(backupPath, content, 0644); err != nil { - return fmt.Errorf("failed to create backup: %w", err) - } - config.DebugLog("Created backup of jail.local at %s", backupPath) - - // Ensure jail.d directory exists - if err := os.MkdirAll(jailDPath, 0755); err != nil { - return fmt.Errorf("failed to create jail.d directory: %w", err) - } - - // Write each jail to its own .local file in jail.d/ - migratedCount := 0 - for jailName, jailContent := range sections { - // Skip empty jail names - if jailName == "" { - continue - } - - jailFilePath := filepath.Join(jailDPath, jailName+".local") - - // Check if .local file already exists - if _, err := os.Stat(jailFilePath); err == nil { - // File already exists - skip migration for this jail - config.DebugLog("Skipping migration for jail %s: .local file already exists", jailName) - continue - } - - // Ensure enabled = false is set by default for migrated jails - // Check if enabled is already set in the content - enabledSet := strings.Contains(jailContent, "enabled") || strings.Contains(jailContent, "Enabled") - if !enabledSet { - // Add enabled = false at the beginning of the jail section - // Find the first line after [jailName] - lines := strings.Split(jailContent, "\n") - modifiedContent := "" - for i, line := range lines { - modifiedContent += line + "\n" - // After the section header, add enabled = false - if i == 0 && strings.HasPrefix(strings.TrimSpace(line), "[") && strings.HasSuffix(strings.TrimSpace(line), "]") { - modifiedContent += "enabled = false\n" - } - } - jailContent = modifiedContent - } else { - // If enabled is set, ensure it's false by replacing any enabled = true - jailContent = regexp.MustCompile(`(?m)^\s*enabled\s*=\s*true\s*$`).ReplaceAllString(jailContent, "enabled = false") - } - - // Write jail content to .local file - if err := os.WriteFile(jailFilePath, []byte(jailContent), 0644); err != nil { - return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) - } - config.DebugLog("Migrated jail %s to %s (enabled = false)", jailName, jailFilePath) - migratedCount++ - } - - // Only rewrite jail.local if we actually migrated something - if migratedCount > 0 { - // Rewrite jail.local with only DEFAULT section and commented jails - // We need to preserve commented sections, so we'll reconstruct the file - newLocalContent := defaultContent - - // Add back commented sections that weren't migrated - scanner := bufio.NewScanner(strings.NewReader(string(content))) - var inCommentedJail bool - var commentedJailContent strings.Builder - var commentedJailName string - for scanner.Scan() { - line := scanner.Text() - trimmed := strings.TrimSpace(line) - - if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { - // Check if this is a commented section - originalLine := strings.TrimSpace(line) - if strings.HasPrefix(originalLine, "#[") { - // Save previous commented jail if any - if inCommentedJail && commentedJailName != "" { - newLocalContent += commentedJailContent.String() - } - inCommentedJail = true - commentedJailContent.Reset() - commentedJailName = strings.Trim(trimmed, "[]") - if strings.HasPrefix(commentedJailName, "#") { - commentedJailName = strings.TrimSpace(strings.TrimPrefix(commentedJailName, "#")) - } - commentedJailContent.WriteString(line) - commentedJailContent.WriteString("\n") - } else { - // Non-commented section - save previous commented jail if any - if inCommentedJail && commentedJailName != "" { - newLocalContent += commentedJailContent.String() - inCommentedJail = false - commentedJailContent.Reset() - } - } - } else if inCommentedJail { - commentedJailContent.WriteString(line) - commentedJailContent.WriteString("\n") - } - } - // Save final commented jail if any - if inCommentedJail && commentedJailName != "" { - newLocalContent += commentedJailContent.String() - } - - if !strings.HasSuffix(newLocalContent, "\n") { - newLocalContent += "\n" - } - if err := os.WriteFile(localPath, []byte(newLocalContent), 0644); err != nil { - return fmt.Errorf("failed to rewrite jail.local: %w", err) - } - config.DebugLog("Migration completed: moved %d jails to jail.d/", migratedCount) - } - - return nil -} - -// GetJailConfig reads the full jail configuration from /etc/fail2ban/jail.d/{jailName}.local -// Falls back to .conf if .local doesn't exist. +// Returns the full jail configuration from /etc/fail2ban/jail.d/{jailName}.local func GetJailConfig(jailName string) (string, string, error) { - // Validate jail name jailName = strings.TrimSpace(jailName) if jailName == "" { return "", "", fmt.Errorf("jail name cannot be empty") @@ -761,37 +462,46 @@ func GetJailConfig(jailName string) (string, string, error) { config.DebugLog("Failed to read jail config: %v", err) return "", "", fmt.Errorf("failed to read jail config for %s: %w", jailName, err) } + config.DebugLog("Jail config read successfully, length: %d, file: %s", len(content), filePath) return content, filePath, nil } -// SetJailConfig writes the full jail configuration to /etc/fail2ban/jail.d/{jailName}.local -// Ensures .local file exists first by copying from .conf if needed. +// Extracts the filter name from the jail configuration. +func ExtractFilterFromJailConfig(jailContent string) string { + scanner := bufio.NewScanner(strings.NewReader(jailContent)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(strings.ToLower(line), "filter") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + filterValue := strings.TrimSpace(parts[1]) + if idx := strings.Index(filterValue, "["); idx >= 0 { + filterValue = filterValue[:idx] + } + return strings.TrimSpace(filterValue) + } + } + } + return "" +} + +// Writes the full jail configuration to /etc/fail2ban/jail.d/{jailName}.local func SetJailConfig(jailName, content string) error { - // Validate jail name jailName = strings.TrimSpace(jailName) if jailName == "" { return fmt.Errorf("jail name cannot be empty") } - config.DebugLog("SetJailConfig called for jail: %s, content length: %d", jailName, len(content)) jailDPath := "/etc/fail2ban/jail.d" - - // Ensure jail.d directory exists - if err := os.MkdirAll(jailDPath, 0755); err != nil { - config.DebugLog("Failed to create jail.d directory: %v", err) - return fmt.Errorf("failed to create jail.d directory: %w", err) - } - config.DebugLog("jail.d directory ensured") - - // Ensure .local file exists (copy from .conf if needed) if err := ensureJailLocalFile(jailName); err != nil { return fmt.Errorf("failed to ensure .local file for jail %s: %w", jailName, err) } - // Validate and fix the jail section header - // The content might start with comments, so we need to find the section header trimmed := strings.TrimSpace(content) if trimmed == "" { config.DebugLog("Content is empty, creating minimal jail config") @@ -824,8 +534,6 @@ func SetJailConfig(jailName, content string) error { } } } - - // Remove duplicate section headers (keep only the first correct one) if len(sectionIndices) > 1 { config.DebugLog("Found %d section headers, removing duplicates", len(sectionIndices)) var newLines []string @@ -836,12 +544,10 @@ func SetJailConfig(jailName, content string) error { if isSectionHeader { if !keptFirst && trimmedLine == expectedSection { - // Keep the first correct section header newLines = append(newLines, expectedSection) keptFirst = true config.DebugLog("Keeping section header at line %d", i) } else { - // Skip duplicate or incorrect section headers config.DebugLog("Removing duplicate/incorrect section header at line %d: %s", i, trimmedLine) continue } @@ -854,17 +560,14 @@ func SetJailConfig(jailName, content string) error { if !sectionFound { if sectionIndex >= 0 { - // Replace incorrect section header config.DebugLog("Replacing incorrect section header at line %d", sectionIndex) lines[sectionIndex] = expectedSection } else { - // No section header found, prepend it config.DebugLog("No section header found, prepending %s", expectedSection) lines = append([]string{expectedSection}, lines...) } content = strings.Join(lines, "\n") } else { - // Section header is correct, but we may have removed duplicates content = strings.Join(lines, "\n") } } @@ -880,41 +583,36 @@ func SetJailConfig(jailName, content string) error { return nil } -// TestLogpath tests a logpath pattern and returns matching files. -// Supports wildcards/glob patterns (e.g., /var/log/*.log) and directory paths. -// This function tests the path as-is without variable resolution. +// ========================================================================= +// Logpath Operations +// ========================================================================= + func TestLogpath(logpath string) ([]string, error) { if logpath == "" { return []string{}, nil } - // Trim whitespace logpath = strings.TrimSpace(logpath) - - // Check if it's a glob pattern (contains *, ?, or [) hasWildcard := strings.ContainsAny(logpath, "*?[") var matches []string if hasWildcard { - // Use filepath.Glob for pattern matching matched, err := filepath.Glob(logpath) if err != nil { return nil, fmt.Errorf("invalid glob pattern: %w", err) } matches = matched } else { - // Check if it's a directory info, err := os.Stat(logpath) if err != nil { if os.IsNotExist(err) { - return []string{}, nil // Path doesn't exist, return empty + return []string{}, nil } return nil, fmt.Errorf("failed to stat path: %w", err) } if info.IsDir() { - // List files in directory entries, err := os.ReadDir(logpath) if err != nil { return nil, fmt.Errorf("failed to read directory: %w", err) @@ -926,7 +624,6 @@ func TestLogpath(logpath string) ([]string, error) { } } } else { - // It's a file, return it matches = []string{logpath} } } @@ -934,26 +631,19 @@ func TestLogpath(logpath string) ([]string, error) { return matches, nil } -// TestLogpathWithResolution resolves variables in logpath and tests the resolved path. -// Returns the original path, resolved path, matching files, and any error. +// Resolves variables in logpath and tests the resolved path. func TestLogpathWithResolution(logpath string) (originalPath, resolvedPath string, files []string, err error) { originalPath = strings.TrimSpace(logpath) if originalPath == "" { return originalPath, "", []string{}, nil } - - // Resolve variables resolvedPath, err = ResolveLogpathVariables(originalPath) if err != nil { return originalPath, "", nil, fmt.Errorf("failed to resolve logpath variables: %w", err) } - - // If resolution didn't change the path, resolvedPath will be the same if resolvedPath == "" { resolvedPath = originalPath } - - // Test the resolved path files, err = TestLogpath(resolvedPath) if err != nil { return originalPath, resolvedPath, nil, fmt.Errorf("failed to test logpath: %w", err) @@ -962,15 +652,7 @@ func TestLogpathWithResolution(logpath string) (originalPath, resolvedPath strin return originalPath, resolvedPath, files, nil } -// ExtractLogpathFromJailConfig extracts the logpath value(s) from jail configuration content. -// Supports multiple logpaths in a single line (space-separated) or multiple lines. -// Fail2ban supports both formats: -// -// logpath = /var/log/file1.log /var/log/file2.log -// logpath = /var/log/file1.log -// /var/log/file2.log -// -// Returns all logpaths joined by newlines. +// Extracts the logpath from the jail configuration. func ExtractLogpathFromJailConfig(jailContent string) string { var logpaths []string scanner := bufio.NewScanner(strings.NewReader(jailContent)) @@ -979,10 +661,8 @@ func ExtractLogpathFromJailConfig(jailContent string) string { for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) - // Skip comments if strings.HasPrefix(line, "#") { if inLogpathLine && currentLogpath != "" { - // End of logpath block at comment, process current logpath paths := strings.Fields(currentLogpath) logpaths = append(logpaths, paths...) currentLogpath = "" @@ -991,7 +671,6 @@ func ExtractLogpathFromJailConfig(jailContent string) string { continue } - // Check if this line starts with logpath = if strings.HasPrefix(strings.ToLower(line), "logpath") { parts := strings.SplitN(line, "=", 2) if len(parts) == 2 { @@ -1001,24 +680,20 @@ func ExtractLogpathFromJailConfig(jailContent string) string { inLogpathLine = true } } + } else if inLogpathLine { - // Continuation line (indented or starting with space) - // Fail2ban allows continuation lines for logpath if line != "" && !strings.Contains(line, "=") { - // This is a continuation line, append to current logpath currentLogpath += " " + line } else { - // End of logpath block, process current logpath if currentLogpath != "" { - // Split by spaces to handle multiple logpaths in one line paths := strings.Fields(currentLogpath) logpaths = append(logpaths, paths...) currentLogpath = "" } inLogpathLine = false } + } else if inLogpathLine && line == "" { - // Empty line might end the logpath block if currentLogpath != "" { paths := strings.Fields(currentLogpath) logpaths = append(logpaths, paths...) @@ -1028,44 +703,208 @@ func ExtractLogpathFromJailConfig(jailContent string) string { } } - // Process any remaining logpath if currentLogpath != "" { paths := strings.Fields(currentLogpath) logpaths = append(logpaths, paths...) } - // Join multiple logpaths with newlines return strings.Join(logpaths, "\n") } -// ExtractFilterFromJailConfig extracts the filter name from jail configuration content. -// Handles formats like: filter = sshd, filter = sshd[mode=aggressive], etc. -// Returns the base filter name (without parameters in brackets). -func ExtractFilterFromJailConfig(jailContent string) string { - scanner := bufio.NewScanner(strings.NewReader(jailContent)) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - // Skip comments - if strings.HasPrefix(line, "#") { - continue - } - if strings.HasPrefix(strings.ToLower(line), "filter") { - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - filterValue := strings.TrimSpace(parts[1]) - // Extract base filter name (before [ if present) - if idx := strings.Index(filterValue, "["); idx >= 0 { - filterValue = filterValue[:idx] - } - return strings.TrimSpace(filterValue) - } - } - } - return "" -} - -// UpdateDefaultSettingsLocal rewrites /etc/fail2ban/jail.local with the current settings. +// Updates /etc/fail2ban/jail.local with the current settings. func UpdateDefaultSettingsLocal(settings config.AppSettings) error { config.DebugLog("UpdateDefaultSettingsLocal called") return config.EnsureJailLocalStructure() } + +// ========================================================================= +// Jail Auto Migration (EXPERIMENTAL, runs only when JAIL_AUTOMIGRATION=true) +// ========================================================================= + +var ( + migrationOnce sync.Once +) + +func isJailAutoMigrationEnabled() bool { + return strings.EqualFold(os.Getenv("JAIL_AUTOMIGRATION"), "true") +} + +// Migrates jail.local to jail.d/*.local. +func MigrateJailsFromJailLocal() error { + localPath := "/etc/fail2ban/jail.local" + jailDPath := "/etc/fail2ban/jail.d" + + if _, err := os.Stat(localPath); os.IsNotExist(err) { + return nil + } + content, err := os.ReadFile(localPath) + if err != nil { + return fmt.Errorf("failed to read jail.local: %w", err) + } + sections, defaultContent, err := parseJailSectionsUncommented(string(content)) + if err != nil { + return fmt.Errorf("failed to parse jail.local: %w", err) + } + if len(sections) == 0 { + config.DebugLog("No jails to migrate from jail.local") + return nil + } + backupPath := localPath + ".backup." + fmt.Sprintf("%d", time.Now().Unix()) + if err := os.WriteFile(backupPath, content, 0644); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + config.DebugLog("Created backup of jail.local at %s", backupPath) + + if err := os.MkdirAll(jailDPath, 0755); err != nil { + return fmt.Errorf("failed to create jail.d directory: %w", err) + } + migratedCount := 0 + for jailName, jailContent := range sections { + if jailName == "" { + continue + } + jailFilePath := filepath.Join(jailDPath, jailName+".local") + if _, err := os.Stat(jailFilePath); err == nil { + config.DebugLog("Skipping migration for jail %s: .local file already exists", jailName) + continue + } + enabledSet := strings.Contains(jailContent, "enabled") || strings.Contains(jailContent, "Enabled") + if !enabledSet { + lines := strings.Split(jailContent, "\n") + modifiedContent := "" + for i, line := range lines { + modifiedContent += line + "\n" + if i == 0 && strings.HasPrefix(strings.TrimSpace(line), "[") && strings.HasSuffix(strings.TrimSpace(line), "]") { + modifiedContent += "enabled = false\n" + } + } + jailContent = modifiedContent + } else { + jailContent = regexp.MustCompile(`(?m)^\s*enabled\s*=\s*true\s*$`).ReplaceAllString(jailContent, "enabled = false") + } + if err := os.WriteFile(jailFilePath, []byte(jailContent), 0644); err != nil { + return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) + } + config.DebugLog("Migrated jail %s to %s (enabled = false)", jailName, jailFilePath) + migratedCount++ + } + if migratedCount > 0 { + newLocalContent := defaultContent + + scanner := bufio.NewScanner(strings.NewReader(string(content))) + var inCommentedJail bool + var commentedJailContent strings.Builder + var commentedJailName string + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + originalLine := strings.TrimSpace(line) + if strings.HasPrefix(originalLine, "#[") { + if inCommentedJail && commentedJailName != "" { + newLocalContent += commentedJailContent.String() + } + inCommentedJail = true + commentedJailContent.Reset() + commentedJailName = strings.Trim(trimmed, "[]") + if strings.HasPrefix(commentedJailName, "#") { + commentedJailName = strings.TrimSpace(strings.TrimPrefix(commentedJailName, "#")) + } + commentedJailContent.WriteString(line) + commentedJailContent.WriteString("\n") + } else { + if inCommentedJail && commentedJailName != "" { + newLocalContent += commentedJailContent.String() + inCommentedJail = false + commentedJailContent.Reset() + } + } + } else if inCommentedJail { + commentedJailContent.WriteString(line) + commentedJailContent.WriteString("\n") + } + } + if inCommentedJail && commentedJailName != "" { + newLocalContent += commentedJailContent.String() + } + + if !strings.HasSuffix(newLocalContent, "\n") { + newLocalContent += "\n" + } + if err := os.WriteFile(localPath, []byte(newLocalContent), 0644); err != nil { + return fmt.Errorf("failed to rewrite jail.local: %w", err) + } + config.DebugLog("Migration completed: moved %d jails to jail.d/", migratedCount) + } + return nil +} + +// Parses an existing jail configuration and returns all jail sections from the file. +func parseJailSectionsUncommented(content string) (map[string]string, string, error) { + sections := make(map[string]string) + var defaultContent strings.Builder + + ignoredSections := map[string]bool{ + "DEFAULT": true, + "INCLUDES": true, + } + + scanner := bufio.NewScanner(strings.NewReader(content)) + var currentSection string + var currentContent strings.Builder + inDefault := false + sectionIsCommented := false + + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + originalLine := strings.TrimSpace(line) + isCommented := strings.HasPrefix(originalLine, "#") + + if currentSection != "" { + sectionContent := strings.TrimSpace(currentContent.String()) + if inDefault { + defaultContent.WriteString(sectionContent) + if !strings.HasSuffix(sectionContent, "\n") { + defaultContent.WriteString("\n") + } + } else if !ignoredSections[currentSection] && !sectionIsCommented { + sections[currentSection] = sectionContent + } + } + + if isCommented { + sectionName := strings.Trim(trimmed, "[]") + if strings.HasPrefix(sectionName, "#") { + sectionName = strings.TrimSpace(strings.TrimPrefix(sectionName, "#")) + } + currentSection = sectionName + sectionIsCommented = true + } else { + currentSection = strings.Trim(trimmed, "[]") + sectionIsCommented = false + } + currentContent.Reset() + currentContent.WriteString(line) + currentContent.WriteString("\n") + inDefault = (currentSection == "DEFAULT") + } else { + currentContent.WriteString(line) + currentContent.WriteString("\n") + } + } + + if currentSection != "" { + sectionContent := strings.TrimSpace(currentContent.String()) + if inDefault { + defaultContent.WriteString(sectionContent) + } else if !ignoredSections[currentSection] && !sectionIsCommented { + sections[currentSection] = sectionContent + } + } + + return sections, defaultContent.String(), scanner.Err() +} diff --git a/internal/integrations/opnsense.go b/internal/integrations/opnsense.go index d4950bc..9f8efd1 100644 --- a/internal/integrations/opnsense.go +++ b/internal/integrations/opnsense.go @@ -83,7 +83,7 @@ func (o *opnsenseIntegration) callAPI(req Request, action, ip string) error { } if cfg.SkipTLSVerify { httpClient.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 - user controlled + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } diff --git a/internal/integrations/pfsense.go b/internal/integrations/pfsense.go index 057ce56..17f5ba4 100644 --- a/internal/integrations/pfsense.go +++ b/internal/integrations/pfsense.go @@ -93,7 +93,7 @@ func (p *pfSenseIntegration) modifyAliasIP(req Request, ip, description string, } if cfg.SkipTLSVerify { httpClient.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 - user controlled + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index d3d0de4..6b1191a 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -15,6 +15,10 @@ import ( _ "modernc.org/sqlite" ) +// ========================================================================= +// Database Connection +// ========================================================================= + var ( db *sql.DB initOnce sync.Once @@ -22,6 +26,10 @@ var ( defaultPath = "fail2ban-ui.db" ) +// ========================================================================= +// Conversion Helpers +// ========================================================================= + func boolToInt(b bool) int { if b { return 1 @@ -47,22 +55,21 @@ func intFromNull(ni sql.NullInt64) int { return 0 } +// ========================================================================= +// Types +// ========================================================================= + type AppSettingsRecord struct { - // Basic app settings - Language string - Port int - Debug bool - RestartNeeded bool - // Callback settings - CallbackURL string - CallbackSecret string - // Alert settings - AlertCountriesJSON string - EmailAlertsForBans bool - EmailAlertsForUnbans bool - // Console output settings - ConsoleOutput bool - // SMTP settings + Language string + Port int + Debug bool + RestartNeeded bool + CallbackURL string + CallbackSecret string + AlertCountriesJSON string + EmailAlertsForBans bool + EmailAlertsForUnbans bool + ConsoleOutput bool SMTPHost string SMTPPort int SMTPUsername string @@ -71,23 +78,21 @@ type AppSettingsRecord struct { SMTPUseTLS bool SMTPInsecureSkipVerify bool SMTPAuthMethod string - // Fail2Ban DEFAULT settings - BantimeIncrement bool - DefaultJailEnable bool - IgnoreIP string // Stored as space-separated string, converted to array in AppSettings - Bantime string - Findtime string - MaxRetry int - DestEmail string - Banaction string - BanactionAllports string - Chain string - BantimeRndtime string - // Advanced features - AdvancedActionsJSON string - GeoIPProvider string - GeoIPDatabasePath string - MaxLogLines int + BantimeIncrement bool + DefaultJailEnable bool + IgnoreIP string + Bantime string + Findtime string + MaxRetry int + DestEmail string + Banaction string + BanactionAllports string + Chain string + BantimeRndtime string + AdvancedActionsJSON string + GeoIPProvider string + GeoIPDatabasePath string + MaxLogLines int } type ServerRecord struct { @@ -111,7 +116,6 @@ type ServerRecord struct { UpdatedAt time.Time } -// BanEventRecord represents a single ban or unban event stored in the internal database. type BanEventRecord struct { ID int64 `json:"id"` ServerID string `json:"serverId"` @@ -128,7 +132,6 @@ type BanEventRecord struct { CreatedAt time.Time `json:"createdAt"` } -// RecurringIPStat represents aggregation info for repeatedly banned IPs. type RecurringIPStat struct { IP string `json:"ip"` Country string `json:"country"` @@ -148,7 +151,7 @@ type PermanentBlockRecord struct { UpdatedAt time.Time `json:"updatedAt"` } -// Init initializes the internal storage. Safe to call multiple times. +// Initialize the database. func Init(dbPath string) error { initOnce.Do(func() { if dbPath == "" { @@ -159,9 +162,7 @@ func Init(dbPath string) error { return } - // Ensure .ssh directory exists (for SSH key storage) if err := ensureSSHDirectory(); err != nil { - // Log but don't fail - .ssh directory creation is not critical log.Printf("Warning: failed to ensure .ssh directory: %v", err) } @@ -182,7 +183,7 @@ func Init(dbPath string) error { return initErr } -// Close closes the underlying database if it has been initialised. +// Close the database. func Close() error { if db == nil { return nil @@ -190,6 +191,7 @@ func Close() error { return db.Close() } +// Get the app settings. func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) { if db == nil { return AppSettingsRecord{}, false, errors.New("storage not initialised") @@ -215,19 +217,15 @@ WHERE id = 1`) } rec := AppSettingsRecord{ - // Basic app settings - Language: stringFromNull(lang), - Port: intFromNull(port), - Debug: intToBool(intFromNull(debug)), - RestartNeeded: intToBool(intFromNull(restartNeeded)), - // Callback settings - CallbackURL: stringFromNull(callback), - CallbackSecret: stringFromNull(callbackSecret), - // Alert settings - AlertCountriesJSON: stringFromNull(alerts), - EmailAlertsForBans: intToBool(intFromNull(emailAlertsForBans)), - EmailAlertsForUnbans: intToBool(intFromNull(emailAlertsForUnbans)), - // SMTP settings + Language: stringFromNull(lang), + Port: intFromNull(port), + Debug: intToBool(intFromNull(debug)), + RestartNeeded: intToBool(intFromNull(restartNeeded)), + CallbackURL: stringFromNull(callback), + CallbackSecret: stringFromNull(callbackSecret), + AlertCountriesJSON: stringFromNull(alerts), + EmailAlertsForBans: intToBool(intFromNull(emailAlertsForBans)), + EmailAlertsForUnbans: intToBool(intFromNull(emailAlertsForUnbans)), SMTPHost: stringFromNull(smtpHost), SMTPPort: intFromNull(smtpPort), SMTPUsername: stringFromNull(smtpUser), @@ -236,25 +234,22 @@ WHERE id = 1`) SMTPUseTLS: intToBool(intFromNull(smtpTLS)), SMTPInsecureSkipVerify: intToBool(intFromNull(smtpInsecureSkipVerify)), SMTPAuthMethod: stringFromNull(smtpAuthMethod), - // Fail2Ban DEFAULT settings - BantimeIncrement: intToBool(intFromNull(bantimeInc)), - DefaultJailEnable: intToBool(intFromNull(defaultJailEn)), - IgnoreIP: stringFromNull(ignoreIP), - Bantime: stringFromNull(bantime), - Findtime: stringFromNull(findtime), - MaxRetry: intFromNull(maxretry), - DestEmail: stringFromNull(destemail), - Banaction: stringFromNull(banaction), - BanactionAllports: stringFromNull(banactionAllports), - Chain: stringFromNull(chain), - BantimeRndtime: stringFromNull(bantimeRndtime), - // Advanced features - AdvancedActionsJSON: stringFromNull(advancedActions), - GeoIPProvider: stringFromNull(geoipProvider), - GeoIPDatabasePath: stringFromNull(geoipDatabasePath), - MaxLogLines: intFromNull(maxLogLines), - // Console output settings - ConsoleOutput: intToBool(intFromNull(consoleOutput)), + BantimeIncrement: intToBool(intFromNull(bantimeInc)), + DefaultJailEnable: intToBool(intFromNull(defaultJailEn)), + IgnoreIP: stringFromNull(ignoreIP), + Bantime: stringFromNull(bantime), + Findtime: stringFromNull(findtime), + MaxRetry: intFromNull(maxretry), + DestEmail: stringFromNull(destemail), + Banaction: stringFromNull(banaction), + BanactionAllports: stringFromNull(banactionAllports), + Chain: stringFromNull(chain), + BantimeRndtime: stringFromNull(bantimeRndtime), + AdvancedActionsJSON: stringFromNull(advancedActions), + GeoIPProvider: stringFromNull(geoipProvider), + GeoIPDatabasePath: stringFromNull(geoipDatabasePath), + MaxLogLines: intFromNull(maxLogLines), + ConsoleOutput: intToBool(intFromNull(consoleOutput)), } return rec, true, nil @@ -339,6 +334,10 @@ INSERT INTO app_settings ( return err } +// ========================================================================= +// Servers +// ========================================================================= + func ListServers(ctx context.Context) ([]ServerRecord, error) { if db == nil { return nil, errors.New("storage not initialised") @@ -493,7 +492,11 @@ func DeleteServer(ctx context.Context, id string) error { return err } -// RecordBanEvent stores a ban event in the database. +// ========================================================================= +// Ban Events Records +// ========================================================================= + +// Stores a ban/unban event into the database. func RecordBanEvent(ctx context.Context, record BanEventRecord) error { if db == nil { return errors.New("storage not initialised") @@ -509,8 +512,7 @@ func RecordBanEvent(ctx context.Context, record BanEventRecord) error { if record.OccurredAt.IsZero() { record.OccurredAt = now } - - // Default to 'ban' if event type is not set + // If the event type is not set, we set it to "ban" by default. eventType := record.EventType if eventType == "" { eventType = "ban" @@ -544,7 +546,7 @@ INSERT INTO ban_events ( return nil } -// ListBanEvents returns ban events ordered by creation date descending. +// Returns ban events ordered by creation date descending. func ListBanEvents(ctx context.Context, serverID string, limit int, since time.Time) ([]BanEventRecord, error) { if db == nil { return nil, errors.New("storage not initialised") @@ -599,7 +601,6 @@ WHERE 1=1` ); err != nil { return nil, err } - // Default to 'ban' if event_type is NULL (for backward compatibility) if eventType.Valid { rec.EventType = eventType.String } else { @@ -618,7 +619,7 @@ const ( MaxBanEventsOffset = 1000 ) -// ListBanEventsFiltered returns ban events with optional search and country filter, ordered by occurred_at DESC. +// Returns ban events with optional search and country filter, ordered by occurred_at DESC. // search is applied as LIKE %search% on ip, jail, server_name, hostname, country. // limit is capped at MaxBanEventsLimit; offset is capped at MaxBanEventsOffset. func ListBanEventsFiltered(ctx context.Context, serverID string, limit, offset int, since time.Time, search, country string) ([]BanEventRecord, error) { @@ -703,7 +704,7 @@ WHERE 1=1` return results, rows.Err() } -// CountBanEventsFiltered returns the total count of ban events matching the same filters as ListBanEventsFiltered. +// Returns the total count of ban events matching the same filters as ListBanEventsFiltered. func CountBanEventsFiltered(ctx context.Context, serverID string, since time.Time, search, country string) (int64, error) { if db == nil { return 0, errors.New("storage not initialised") @@ -744,7 +745,7 @@ func CountBanEventsFiltered(ctx context.Context, serverID string, since time.Tim return total, nil } -// CountBanEventsByServer returns simple aggregation per server. +// Returns simple aggregation per server. func CountBanEventsByServer(ctx context.Context, since time.Time) (map[string]int64, error) { if db == nil { return nil, errors.New("storage not initialised") @@ -782,7 +783,7 @@ WHERE 1=1` return result, rows.Err() } -// CountBanEvents returns total number of ban events optionally filtered by time and server. +// Returns total number of ban events optionally filtered by time and server. func CountBanEvents(ctx context.Context, since time.Time, serverID string) (int64, error) { if db == nil { return 0, errors.New("storage not initialised") @@ -811,7 +812,7 @@ WHERE 1=1` return total, nil } -// CountBanEventsByIP returns total number of ban events for a specific IP and optional server. +// Returns total number of ban events for a specific IP and optional server. func CountBanEventsByIP(ctx context.Context, ip, serverID string) (int64, error) { if db == nil { return 0, errors.New("storage not initialised") @@ -838,7 +839,7 @@ WHERE ip = ? AND (event_type = 'ban' OR event_type IS NULL)` return total, nil } -// CountBanEventsByCountry returns aggregation per country code, optionally filtered by server. +// Returns aggregation per country code, optionally filtered by server. func CountBanEventsByCountry(ctx context.Context, since time.Time, serverID string) (map[string]int64, error) { if db == nil { return nil, errors.New("storage not initialised") @@ -881,7 +882,11 @@ WHERE 1=1` return result, rows.Err() } -// ListRecurringIPStats returns IPs that have been banned at least minCount times, optionally filtered by server. +// ========================================================================= +// Recurring IP Statistics +// ========================================================================= + +// Returns IPs that have been banned at least minCount times, optionally filtered by server. func ListRecurringIPStats(ctx context.Context, since time.Time, minCount, limit int, serverID string) ([]RecurringIPStat, error) { if db == nil { return nil, errors.New("storage not initialised") @@ -927,20 +932,14 @@ LIMIT ?` var results []RecurringIPStat for rows.Next() { var stat RecurringIPStat - // First, scan as string to see what format SQLite returns - // Then parse it properly var lastSeenStr sql.NullString if err := rows.Scan(&stat.IP, &stat.Country, &stat.Count, &lastSeenStr); err != nil { return nil, fmt.Errorf("failed to scan row: %w", err) } if lastSeenStr.Valid && lastSeenStr.String != "" { - // Try to parse the datetime string - // SQLite stores DATETIME as TEXT, format depends on how it was inserted - // The modernc.org/sqlite driver returns MAX(occurred_at) in format: - // "2006-01-02 15:04:05.999999999 -0700 MST" (e.g., "2025-11-22 12:17:24.697430041 +0000 UTC") formats := []string{ - "2006-01-02 15:04:05.999999999 -0700 MST", // Format returned by MAX() in SQLite + "2006-01-02 15:04:05.999999999 -0700 MST", time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05.999999999+00:00", @@ -952,20 +951,18 @@ LIMIT ?` "2006-01-02T15:04:05.999999999", "2006-01-02T15:04:05", } - parsed := time.Time{} // zero time + parsed := time.Time{} for _, format := range formats { if t, parseErr := time.Parse(format, lastSeenStr.String); parseErr == nil { parsed = t.UTC() break } } - // If still zero, log the actual string for debugging if parsed.IsZero() { log.Printf("ERROR: Could not parse lastSeen datetime '%s' (length: %d) for IP %s. All format attempts failed.", lastSeenStr.String, len(lastSeenStr.String), stat.IP) } stat.LastSeen = parsed } else { - // Log when lastSeen is NULL or empty log.Printf("WARNING: lastSeen is NULL or empty for IP %s", stat.IP) } results = append(results, stat) @@ -974,6 +971,10 @@ LIMIT ?` return results, rows.Err() } +// ========================================================================= +// Schema Management +// ========================================================================= + func ensureSchema(ctx context.Context) error { if db == nil { return errors.New("storage not initialised") @@ -1080,25 +1081,11 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status); if _, err := db.ExecContext(ctx, createTable); err != nil { return err } - - // NOTE: Database migrations for feature releases - // For this version, we start with a fresh schema. Future feature releases - // that require database schema changes should add migration logic here. - // Example migration pattern: - // if _, err := db.ExecContext(ctx, `ALTER TABLE table_name ADD COLUMN new_column TYPE DEFAULT value`); err != nil { - // if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { - // return err - // } - // } - - // Migration: Add console_output column if it doesn't exist if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN console_output INTEGER DEFAULT 0`); err != nil { if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { return err } } - - // Migration: Add new SMTP columns if they don't exist if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN smtp_insecure_skip_verify INTEGER DEFAULT 0`); err != nil { if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { return err @@ -1119,8 +1106,6 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status); return err } } - _ = strings.Contains // Keep strings import for migration example above - return nil } @@ -1135,16 +1120,12 @@ func ensureDirectory(path string) error { return os.MkdirAll(dir, 0o755) } -// ensureSSHDirectory ensures the .ssh directory exists for SSH key storage. -// In containers, this is /config/.ssh, on the host it's ~/.ssh +// Ensures .ssh exists for SSH key storage (/config/.ssh in container, ~/.ssh on host). func ensureSSHDirectory() error { var sshDir string - // Check if running inside a container if _, container := os.LookupEnv("CONTAINER"); container { - // In container, use /config/.ssh sshDir = "/config/.ssh" } else { - // On host, use ~/.ssh home, err := os.UserHomeDir() if err != nil { return fmt.Errorf("failed to get user home directory: %w", err) @@ -1152,7 +1133,6 @@ func ensureSSHDirectory() error { sshDir = filepath.Join(home, ".ssh") } - // Create directory with proper permissions (0700 for .ssh) if err := os.MkdirAll(sshDir, 0o700); err != nil { return fmt.Errorf("failed to create .ssh directory at %s: %w", sshDir, err) } @@ -1160,7 +1140,11 @@ func ensureSSHDirectory() error { return nil } -// UpsertPermanentBlock records or updates a permanent block entry. +// ========================================================================= +// Permanent Blocks Records +// ========================================================================= + +// Stores or updates a permanent block entry. func UpsertPermanentBlock(ctx context.Context, rec PermanentBlockRecord) error { if db == nil { return errors.New("storage not initialised") @@ -1200,7 +1184,7 @@ ON CONFLICT(ip, integration) DO UPDATE SET return err } -// GetPermanentBlock retrieves a permanent block entry. +// Returns a permanent block entry. func GetPermanentBlock(ctx context.Context, ip, integration string) (PermanentBlockRecord, bool, error) { if db == nil { return PermanentBlockRecord{}, false, errors.New("storage not initialised") @@ -1235,7 +1219,7 @@ WHERE ip = ? AND integration = ?`, ip, integration) return rec, true, nil } -// ListPermanentBlocks returns recent permanent block entries. +// Returns recent permanent block entries. func ListPermanentBlocks(ctx context.Context, limit int) ([]PermanentBlockRecord, error) { if db == nil { return nil, errors.New("storage not initialised") @@ -1276,7 +1260,7 @@ LIMIT ?`, limit) return records, rows.Err() } -// IsPermanentBlockActive returns true when IP is currently blocked by integration. +// Returns true when IP is currently blocked by integration. func IsPermanentBlockActive(ctx context.Context, ip, integration string) (bool, error) { rec, found, err := GetPermanentBlock(ctx, ip, integration) if err != nil || !found { diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index b4fbc7b..28d2df2 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -1,6 +1,6 @@ // Fail2ban UI - A Swiss made, management interface for Fail2ban. // -// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // // Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. @@ -53,7 +53,10 @@ import ( "github.com/swissmakers/fail2ban-ui/internal/version" ) -// wsHub is the global WebSocket hub instance +// ========================================================================= +// Types and Variables +// ========================================================================= + var wsHub *Hub // SetWebSocketHub sets the global WebSocket hub instance @@ -61,17 +64,19 @@ func SetWebSocketHub(hub *Hub) { wsHub = hub } -// SummaryResponse is what we return from /api/summary type SummaryResponse struct { Jails []fail2ban.JailInfo `json:"jails"` JailLocalWarning bool `json:"jailLocalWarning,omitempty"` } - type emailDetail struct { Label string Value string } +type githubReleaseResponse struct { + TagName string `json:"tag_name"` +} + var ( httpQuotedStatusPattern = regexp.MustCompile(`"[^"]*"\s+(\d{3})\b`) httpPlainStatusPattern = regexp.MustCompile(`\s(\d{3})\s+(?:\d+|-)`) @@ -93,6 +98,12 @@ var ( localeCacheLock sync.RWMutex ) +// ========================================================================= +// Request Helpers +// ========================================================================= + +// Resolves the Fail2ban connector for the current request. +// Uses the "serverId" query param, "X-F2B-Server" header, or the default server. func resolveConnector(c *gin.Context) (fail2ban.Connector, error) { serverID := c.Query("serverId") if serverID == "" { @@ -105,6 +116,7 @@ func resolveConnector(c *gin.Context) (fail2ban.Connector, error) { return manager.DefaultConnector() } +// Resolves a server by ID, hostname, or falls back to default. func resolveServerForNotification(serverID, hostname string) (config.Fail2banServer, error) { if serverID != "" { if srv, ok := config.GetServerByID(serverID); ok { @@ -133,8 +145,11 @@ func resolveServerForNotification(serverID, hostname string) (config.Fail2banSer return srv, nil } -// SummaryHandler returns a JSON summary of all jails, including -// number of banned IPs, how many are new in the last hour, etc. +// ========================================================================= +// Dashboard +// ========================================================================= + +// Returns a JSON summary of all jails for the selected server. func SummaryHandler(c *gin.Context) { conn, err := resolveConnector(c) if err != nil { @@ -152,8 +167,7 @@ func SummaryHandler(c *gin.Context) { Jails: jailInfos, } - // Check jail.local integrity on every summary request so the dashboard - // can display a persistent warning banner when the file is not managed by us. + // Checks the jail.local integrity on every summary request to warn the user if not managed by Fail2ban-UI. if exists, hasUI, chkErr := conn.CheckJailLocalIntegrity(c.Request.Context()); chkErr == nil { if exists && !hasUI { resp.JailLocalWarning = true @@ -170,33 +184,14 @@ func SummaryHandler(c *gin.Context) { c.JSON(http.StatusOK, resp) } -// UnbanIPHandler unbans a given IP in a specific jail. -func UnbanIPHandler(c *gin.Context) { - config.DebugLog("----------------------------") - config.DebugLog("UnbanIPHandler called (handlers.go)") // entry point - jail := c.Param("jail") - ip := c.Param("ip") +// ========================================================================= +// Ban / Unban Actions +// ========================================================================= - conn, err := resolveConnector(c) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := conn.UnbanIP(c.Request.Context(), jail, ip); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - fmt.Println(ip + " from jail " + jail + " unbanned successfully.") - c.JSON(http.StatusOK, gin.H{ - "message": "IP unbanned successfully", - }) -} - -// BanIPHandler bans a given IP in a specific jail. +// Bans a given IP in a specific jail. func BanIPHandler(c *gin.Context) { config.DebugLog("----------------------------") - config.DebugLog("BanIPHandler called (handlers.go)") // entry point + config.DebugLog("BanIPHandler called (handlers.go)") jail := c.Param("jail") ip := c.Param("ip") @@ -216,14 +211,35 @@ func BanIPHandler(c *gin.Context) { }) } -// BanNotificationHandler processes incoming ban notifications from Fail2Ban. +// Unbans a given IP from a specific jail. +func UnbanIPHandler(c *gin.Context) { + config.DebugLog("----------------------------") + config.DebugLog("UnbanIPHandler called (handlers.go)") + jail := c.Param("jail") + ip := c.Param("ip") + + conn, err := resolveConnector(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := conn.UnbanIP(c.Request.Context(), jail, ip); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + fmt.Println(ip + " from jail " + jail + " unbanned successfully.") + c.JSON(http.StatusOK, gin.H{ + "message": "IP unbanned successfully", + }) +} + +// Processes incoming ban callbacks from Fail2Ban action scripts. func BanNotificationHandler(c *gin.Context) { - // Validate callback secret settings := config.GetSettings() providedSecret := c.GetHeader("X-Callback-Secret") expectedSecret := settings.CallbackSecret - // Use constant-time comparison to prevent timing attacks if expectedSecret == "" { log.Printf("⚠️ Callback secret not configured, rejecting request from %s", c.ClientIP()) c.JSON(http.StatusUnauthorized, gin.H{"error": "Callback secret not configured"}) @@ -236,7 +252,6 @@ func BanNotificationHandler(c *gin.Context) { return } - // Constant-time comparison if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(expectedSecret)) != 1 { log.Printf("⚠️ Invalid callback secret in request from %s", c.ClientIP()) c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid callback secret"}) @@ -249,11 +264,11 @@ func BanNotificationHandler(c *gin.Context) { Jail string `json:"jail" binding:"required"` Hostname string `json:"hostname"` Failures string `json:"failures"` - Whois string `json:"whois"` // Optional for backward compatibility + Whois string `json:"whois"` Logs string `json:"logs"` } - // **DEBUGGING: Log Raw JSON Body** + // Logs the raw JSON body of the request body, _ := io.ReadAll(c.Request.Body) log.Printf("----------------------------------------------------") log.Printf("Request Content-Length: %d", c.Request.ContentLength) @@ -264,90 +279,12 @@ func BanNotificationHandler(c *gin.Context) { config.DebugLog("📩 Incoming Ban Notification: %s\n", string(body)) - // Rebind body so Gin can parse it again (important!) c.Request.Body = io.NopCloser(bytes.NewBuffer(body)) log.Printf("Request Content-Length: %d", c.Request.ContentLength) log.Printf("Request Headers: %v", c.Request.Header) log.Printf("Request Headers: %v", c.Request.Body) - // Parse JSON request body - if err := c.ShouldBindJSON(&request); err != nil { - var verr validator.ValidationErrors - if errors.As(err, &verr) { - for _, fe := range verr { - log.Printf("❌ Validierungsfehler: Feld '%s' verletzt Regel '%s'", fe.Field(), fe.ActualTag()) - } - } else { - log.Printf("❌ JSON-Parsing Fehler: %v", err) - } - log.Printf("Raw JSON: %s", string(body)) - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) - return - } - - // **DEBUGGING: Log Parsed Request** - log.Printf("✅ Parsed Ban Request - IP: %s, Jail: %s, Hostname: %s, Failures: %s", - request.IP, request.Jail, request.Hostname, request.Failures) - - server, err := resolveServerForNotification(request.ServerID, request.Hostname) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Handle the Fail2Ban notification - if err := HandleBanNotification(c.Request.Context(), server, request.IP, request.Jail, request.Hostname, request.Failures, request.Whois, request.Logs); err != nil { - log.Printf("❌ Failed to process ban notification: %v\n", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process ban notification: " + err.Error()}) - return - } - - // Respond with success - c.JSON(http.StatusOK, gin.H{"message": "Ban notification processed successfully"}) -} - -// UnbanNotificationHandler processes incoming unban notifications from Fail2Ban. -func UnbanNotificationHandler(c *gin.Context) { - // Validate callback secret - settings := config.GetSettings() - providedSecret := c.GetHeader("X-Callback-Secret") - expectedSecret := settings.CallbackSecret - - // Use constant-time comparison to prevent timing attacks - if expectedSecret == "" { - log.Printf("⚠️ Callback secret not configured, rejecting request from %s", c.ClientIP()) - c.JSON(http.StatusUnauthorized, gin.H{"error": "Callback secret not configured"}) - return - } - - if providedSecret == "" { - log.Printf("⚠️ Missing X-Callback-Secret header in request from %s", c.ClientIP()) - c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing X-Callback-Secret header"}) - return - } - - // Constant-time comparison - if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(expectedSecret)) != 1 { - log.Printf("⚠️ Invalid callback secret in request from %s", c.ClientIP()) - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid callback secret"}) - return - } - - var request struct { - ServerID string `json:"serverId"` - IP string `json:"ip" binding:"required"` - Jail string `json:"jail" binding:"required"` - Hostname string `json:"hostname"` - } - - body, _ := io.ReadAll(c.Request.Body) - config.DebugLog("📩 Incoming Unban Notification: %s\n", string(body)) - - // Rebind body so Gin can parse it again - c.Request.Body = io.NopCloser(bytes.NewBuffer(body)) - - // Parse JSON request body if err := c.ShouldBindJSON(&request); err != nil { var verr validator.ValidationErrors if errors.As(err, &verr) { @@ -362,7 +299,76 @@ func UnbanNotificationHandler(c *gin.Context) { return } - log.Printf("✅ Parsed Unban Request - IP: %s, Jail: %s, Hostname: %s", + // Logs the parsed request + log.Printf("✅ Parsed ban request - IP: %s, Jail: %s, Hostname: %s, Failures: %s", + request.IP, request.Jail, request.Hostname, request.Failures) + + server, err := resolveServerForNotification(request.ServerID, request.Hostname) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := HandleBanNotification(c.Request.Context(), server, request.IP, request.Jail, request.Hostname, request.Failures, request.Whois, request.Logs); err != nil { + log.Printf("❌ Failed to process ban notification: %v\n", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process ban notification: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Ban notification processed successfully"}) +} + +// Processes incoming unban callbacks from Fail2Ban action scripts. +func UnbanNotificationHandler(c *gin.Context) { + settings := config.GetSettings() + providedSecret := c.GetHeader("X-Callback-Secret") + expectedSecret := settings.CallbackSecret + + if expectedSecret == "" { + log.Printf("⚠️ Callback secret not configured, rejecting request from %s", c.ClientIP()) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Callback secret not configured"}) + return + } + + if providedSecret == "" { + log.Printf("⚠️ Missing X-Callback-Secret header in request from %s", c.ClientIP()) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing X-Callback-Secret header"}) + return + } + + if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(expectedSecret)) != 1 { + log.Printf("⚠️ Invalid callback secret in request from %s", c.ClientIP()) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid callback secret"}) + return + } + + var request struct { + ServerID string `json:"serverId"` + IP string `json:"ip" binding:"required"` + Jail string `json:"jail" binding:"required"` + Hostname string `json:"hostname"` + } + + body, _ := io.ReadAll(c.Request.Body) + config.DebugLog("📩 Incoming unban notification: %s\n", string(body)) + + c.Request.Body = io.NopCloser(bytes.NewBuffer(body)) + + if err := c.ShouldBindJSON(&request); err != nil { + var verr validator.ValidationErrors + if errors.As(err, &verr) { + for _, fe := range verr { + log.Printf("❌ Validation error: Field '%s' violated rule '%s'", fe.Field(), fe.ActualTag()) + } + } else { + log.Printf("❌ JSON parsing error: %v", err) + } + log.Printf("Raw JSON: %s", string(body)) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + log.Printf("✅ Parsed unban request - IP: %s, Jail: %s, Hostname: %s", request.IP, request.Jail, request.Hostname) server, err := resolveServerForNotification(request.ServerID, request.Hostname) @@ -371,20 +377,20 @@ func UnbanNotificationHandler(c *gin.Context) { return } - // Handle the Fail2Ban notification if err := HandleUnbanNotification(c.Request.Context(), server, request.IP, request.Jail, request.Hostname, "", ""); err != nil { log.Printf("❌ Failed to process unban notification: %v\n", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process unban notification: " + err.Error()}) return } - // Respond with success c.JSON(http.StatusOK, gin.H{"message": "Unban notification processed successfully"}) } -// ListBanEventsHandler returns stored ban events from the internal database with optional search and pagination. -// Query params: serverId, limit (default 50, max 50), offset (default 0, max 1000), since, search, country. -// When offset=0, response includes total (matching count). Response includes hasMore when more results exist. +// ========================================================================= +// Ban Events Records +// ========================================================================= + +// Returns paginated, filterable ban/unban events. func ListBanEventsHandler(c *gin.Context) { serverID := c.Query("serverId") limit := storage.MaxBanEventsLimit @@ -430,7 +436,7 @@ func ListBanEventsHandler(c *gin.Context) { c.JSON(http.StatusOK, resp) } -// BanStatisticsHandler returns aggregated ban counts per server. +// Returns aggregated ban event counts per server. func BanStatisticsHandler(c *gin.Context) { var since time.Time if sinceStr := c.Query("since"); sinceStr != "" { @@ -448,7 +454,7 @@ func BanStatisticsHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"counts": stats}) } -// BanInsightsHandler returns aggregate stats for countries and recurring IPs. +// Returns aggregate stats for countries and recurring IPs for ban events. func BanInsightsHandler(c *gin.Context) { var since time.Time if sinceStr := c.Query("since"); sinceStr != "" { @@ -549,13 +555,17 @@ func BanInsightsHandler(c *gin.Context) { }) } -// ListServersHandler returns configured Fail2ban servers. +// ========================================================================= +// Fail2ban Servers Management +// ========================================================================= + +// Returns all configured Fail2ban servers. func ListServersHandler(c *gin.Context) { servers := config.ListServers() c.JSON(http.StatusOK, gin.H{"servers": servers}) } -// UpsertServerHandler creates or updates a Fail2ban server configuration. +// Creates or updates a Fail2ban server configuration. func UpsertServerHandler(c *gin.Context) { var req config.Fail2banServer if err := c.ShouldBindJSON(&req); err != nil { @@ -599,19 +609,13 @@ func UpsertServerHandler(c *gin.Context) { return } - // Only update action files if: - // 1. Server was just enabled (transition from disabled to enabled) - // 2. Server is a remote server (SSH or Agent) - // Note: ReloadFromSettings already calls ensureAction when creating connectors, - // but we need to update if the server was just enabled to ensure it has the latest callback URL if justEnabled && (server.Type == "ssh" || server.Type == "agent") { if err := fail2ban.GetManager().UpdateActionFileForServer(c.Request.Context(), server.ID); err != nil { config.DebugLog("Warning: failed to update action file for server %s: %v", server.Name, err) - // Don't fail the request, just log the warning } } - // Ensure jail.local structure is properly initialized for newly enabled/added servers + // Ensures the jail.local structure is properly initialized for newly enabled/added servers var jailLocalWarning bool if justEnabled || !wasEnabled { conn, err := fail2ban.GetManager().Connector(server.ID) @@ -626,14 +630,13 @@ func UpsertServerHandler(c *gin.Context) { config.DebugLog("Successfully ensured jail.local structure for server %s", server.Name) } - // Check integrity AFTER ensuring structure so fresh servers don't - // trigger a false-positive warning. + // Checks the integrity AFTER ensuring structure so fresh servers don't trigger a false-positive warning. if exists, hasUI, chkErr := conn.CheckJailLocalIntegrity(c.Request.Context()); chkErr == nil && exists && !hasUI { jailLocalWarning = true log.Printf("⚠️ Server %s: jail.local is not managed by Fail2ban-UI. Please migrate your jail.local manually (see documentation).", server.Name) } - // If the server was just enabled, try to restart fail2ban and perform a basic health check. + // Tries to restart Fail2ban and performs a basic health check after the server was enabled if justEnabled { if err := conn.Restart(c.Request.Context()); err != nil { msg := fmt.Sprintf("failed to restart fail2ban for server %s: %v", server.Name, err) @@ -661,7 +664,7 @@ func UpsertServerHandler(c *gin.Context) { c.JSON(http.StatusOK, resp) } -// DeleteServerHandler removes a server configuration. +// Removes a server configuration by ID. func DeleteServerHandler(c *gin.Context) { id := c.Param("id") if id == "" { @@ -679,7 +682,7 @@ func DeleteServerHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "server deleted"}) } -// SetDefaultServerHandler marks a server as default. +// Marks a server as the default. func SetDefaultServerHandler(c *gin.Context) { id := c.Param("id") if id == "" { @@ -698,15 +701,14 @@ func SetDefaultServerHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"server": server}) } -// ListSSHKeysHandler returns SSH keys available on the UI host. +// Returns available SSH private keys from the host or container. func ListSSHKeysHandler(c *gin.Context) { var dir string - // Check if running inside a container if _, container := os.LookupEnv("CONTAINER"); container { - // In container, check /config/.ssh + // In container, we look for SSH keys in the /config/.ssh directory dir = "/config/.ssh" } else { - // On host, check ~/.ssh + // On host, we look for SSH keys in the user's home directory home, err := os.UserHomeDir() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -731,8 +733,6 @@ func ListSSHKeysHandler(c *gin.Context) { continue } name := entry.Name() - // Only include private keys, not public keys (.pub files) - // SSH requires the private key file, not the public key if (strings.HasPrefix(name, "id_") && !strings.HasSuffix(name, ".pub")) || strings.HasSuffix(name, ".pem") || (strings.HasSuffix(name, ".key") && !strings.HasSuffix(name, ".pub")) { @@ -747,7 +747,7 @@ func ListSSHKeysHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"keys": keys}) } -// TestServerHandler verifies connectivity to a configured Fail2ban server. +// Verifies connectivity to a configured Fail2ban server by ID. func TestServerHandler(c *gin.Context) { id := c.Param("id") if id == "" { @@ -789,8 +789,8 @@ func TestServerHandler(c *gin.Context) { return } - // Check jail.local integrity: if it exists but is not managed by fail2ban-ui, warn the user. - // If the file was removed (user finished migration), initialize a fresh managed file. + // Checks the jail.local integrity: if it exists but is not managed by Fail2ban-UI, we warn the user. + // If the file was removed (e.g. after finished migration or just deleted), we initialize a fresh managed file. resp := gin.H{"messageKey": "servers.actions.test_success"} if exists, hasUI, err := conn.CheckJailLocalIntegrity(ctx); err == nil { if exists && !hasUI { @@ -806,15 +806,18 @@ func TestServerHandler(c *gin.Context) { c.JSON(http.StatusOK, resp) } -// HandleBanNotification processes Fail2Ban notifications, checks geo-location, stores the event, and sends alerts. -func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, failures, whois, logs string) error { - // Load settings to get alert countries and GeoIP provider - settings := config.GetSettings() +// ========================================================================= +// Notification Processing (Internal) +// ========================================================================= - // Perform whois lookup if not provided (backward compatibility) +// Records a ban event, broadcasts it via WebSocket, +// evaluates advanced actions, and sends an email alert if enabled. +func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, failures, whois, logs string) error { + // Loads the settings to get alert countries and GeoIP provider from the database + settings := config.GetSettings() var whoisData string var err error - if whois == "" || whois == "missing whois program" { + if whois == "" { log.Printf("Performing whois lookup for IP %s", ip) whoisData, err = lookupWhois(ip) if err != nil { @@ -826,14 +829,13 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip whoisData = whois } - // Filter logs to show relevant lines + // Filters the logs for the email alert to show relevant lines filteredLogs := filterRelevantLogs(logs, ip, settings.MaxLogLines) - // Lookup the country for the given IP using configured provider + // Looks up the country for the given IP using the configured GeoIP provider country, err := lookupCountry(ip, settings.GeoIPProvider, settings.GeoIPDatabasePath) if err != nil { log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err) - // Try to extract country from whois as fallback if whoisData != "" { country = extractCountryFromWhois(whoisData) if country != "" { @@ -862,14 +864,13 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip log.Printf("⚠️ Failed to record ban event: %v", err) } - // Broadcast ban event to WebSocket clients + // Broadcasts the ban event to WebSocket clients if wsHub != nil { wsHub.BroadcastBanEvent(event) } evaluateAdvancedActions(ctx, settings, server, ip) - // Check if country is in alert list displayCountry := country if displayCountry == "" { displayCountry = "UNKNOWN" @@ -880,28 +881,24 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip return nil } - // Check if email alerts for bans are enabled if !settings.EmailAlertsForBans { log.Printf("❌ Email alerts for bans are disabled. No alert sent for IP %s", ip) return nil } - // Send email notification (best-effort; the ban event is already recorded) if err := sendBanAlert(ip, jail, hostname, failures, whoisData, filteredLogs, country, settings); err != nil { log.Printf("❌ Failed to send ban alert email: %v", err) } return nil } -// HandleUnbanNotification processes Fail2Ban unban notifications, stores the event, and sends alerts. +// Records an unban event, broadcasts it via WebSocket, and sends an email alert if enabled. func HandleUnbanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, whois, country string) error { - // Load settings to get alert countries and GeoIP provider + // Loads the settings to get alert countries and GeoIP provider from the database settings := config.GetSettings() - - // Perform whois lookup if not provided var whoisData string var err error - if whois == "" || whois == "missing whois program" { + if whois == "" { log.Printf("Performing whois lookup for IP %s", ip) whoisData, err = lookupWhois(ip) if err != nil { @@ -912,13 +909,10 @@ func HandleUnbanNotification(ctx context.Context, server config.Fail2banServer, log.Printf("Using provided whois data for IP %s", ip) whoisData = whois } - - // Lookup the country for the given IP if not provided if country == "" { country, err = lookupCountry(ip, settings.GeoIPProvider, settings.GeoIPDatabasePath) if err != nil { log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err) - // Try to extract country from whois as fallback if whoisData != "" { country = extractCountryFromWhois(whoisData) if country != "" { @@ -948,48 +942,49 @@ func HandleUnbanNotification(ctx context.Context, server config.Fail2banServer, log.Printf("⚠️ Failed to record unban event: %v", err) } - // Broadcast unban event to WebSocket clients + // Broadcasts the unban event to WebSocket clients if wsHub != nil { wsHub.BroadcastUnbanEvent(event) } - // Check if email alerts for unbans are enabled if !settings.EmailAlertsForUnbans { - log.Printf("❌ Email alerts for unbans are disabled. No alert sent for IP %s", ip) + log.Printf("🔕 Email alerts for unbans are disabled. No alert sent for IP %s", ip) return nil } - // Check if country is in alert list displayCountry := country if displayCountry == "" { displayCountry = "UNKNOWN" } if !shouldAlertForCountry(country, settings.AlertCountries) { - log.Printf("❌ IP %s belongs to %s, which is NOT in alert countries (%v). No alert sent.", ip, displayCountry, settings.AlertCountries) + log.Printf("🔕 IP %s belongs to %s, which is NOT in alert countries (%v). No alert sent.", ip, displayCountry, settings.AlertCountries) return nil } - // Send email notification (best-effort; the unban event is already recorded) + // Sends an unban email notification (if enabled) if err := sendUnbanAlert(ip, jail, hostname, whoisData, country, settings); err != nil { log.Printf("❌ Failed to send unban alert email: %v", err) } return nil } -// lookupCountry finds the country ISO code for a given IP using the configured provider. +// ========================================================================= +// GeoIP and Helpers +// ========================================================================= + +// Resolves the ISO country code for an IP using the configured GeoIP provider. func lookupCountry(ip, provider, dbPath string) (string, error) { switch provider { case "builtin": return lookupCountryBuiltin(ip) case "maxmind", "": - // Default to maxmind if empty if dbPath == "" { dbPath = "/usr/share/GeoIP/GeoLite2-Country.mmdb" } return lookupCountryMaxMind(ip, dbPath) default: - // Unknown provider, try maxmind as fallback + // Unknown GeoIP provider, falls back to MaxMind log.Printf("Unknown GeoIP provider '%s', falling back to MaxMind", provider) if dbPath == "" { dbPath = "/usr/share/GeoIP/GeoLite2-Country.mmdb" @@ -998,46 +993,40 @@ func lookupCountry(ip, provider, dbPath string) (string, error) { } } -// lookupCountryMaxMind finds the country ISO code using MaxMind GeoLite2 database. +// Looks up the country ISO code using MaxMind GeoLite2 database. func lookupCountryMaxMind(ip, dbPath string) (string, error) { - // Convert the IP string to net.IP parsedIP := net.ParseIP(ip) if parsedIP == nil { return "", fmt.Errorf("invalid IP address: %s", ip) } - // Open the GeoIP database db, err := maxminddb.Open(dbPath) if err != nil { return "", fmt.Errorf("failed to open GeoIP database at %s: %w", dbPath, err) } defer db.Close() - // Define the structure to store the lookup result var record struct { Country struct { ISOCode string `maxminddb:"iso_code"` } `maxminddb:"country"` } - // Perform the lookup using net.IP type if err := db.Lookup(parsedIP, &record); err != nil { return "", fmt.Errorf("GeoIP lookup error: %w", err) } - // Return the country code return record.Country.ISOCode, nil } -// lookupCountryBuiltin finds the country ISO code using ip-api.com free API. +// Looks up the country ISO code using ip-api.com free API. func lookupCountryBuiltin(ip string) (string, error) { - // Convert the IP string to net.IP to validate parsedIP := net.ParseIP(ip) if parsedIP == nil { return "", fmt.Errorf("invalid IP address: %s", ip) } - // Use ip-api.com free API (no account needed, rate limited to 45 requests/minute) + // Uses ip-api.com free API (no account needed, rate limited to 45 requests/minute) url := fmt.Sprintf("http://ip-api.com/json/%s?fields=countryCode", ip) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -1079,63 +1068,49 @@ func lookupCountryBuiltin(ip string) (string, error) { return result.CountryCode, nil } -// filterRelevantLogs filters log lines to show the most relevant ones that caused the block. +// Filters relevant logs for the email alert to show relevant lines. func filterRelevantLogs(logs, ip string, maxLines int) string { if logs == "" { return "" } - if maxLines <= 0 { - maxLines = 50 // Default + maxLines = 50 } - lines := strings.Split(logs, "\n") if len(lines) <= maxLines { - return logs // Return as-is if within limit + return logs } - - // Priority indicators for relevant log lines + // Priority patterns to identify relevant log lines priorityPatterns := []string{ "denied", "deny", "forbidden", "unauthorized", "failed", "failure", "error", "403", "404", "401", "500", "502", "503", "invalid", "rejected", "blocked", "ban", } - - // Score each line based on relevance type scoredLine struct { line string score int index int } - scored := make([]scoredLine, len(lines)) for i, line := range lines { lineLower := strings.ToLower(line) score := 0 - // Check if line contains the IP if strings.Contains(line, ip) { score += 10 } - - // Check for priority patterns for _, pattern := range priorityPatterns { if strings.Contains(lineLower, pattern) { score += 5 } } - - // Recent lines get higher score (lines at the end are more recent) score += (len(lines) - i) / 10 - scored[i] = scoredLine{ line: line, score: score, index: i, } } - - // Sort by score (descending) for i := 0; i < len(scored)-1; i++ { for j := i + 1; j < len(scored); j++ { if scored[i].score < scored[j].score { @@ -1143,8 +1118,6 @@ func filterRelevantLogs(logs, ip string, maxLines int) string { } } } - - // Take top N lines and sort by original index to maintain chronological order selected := scored[:maxLines] for i := 0; i < len(selected)-1; i++ { for j := i + 1; j < len(selected); j++ { @@ -1153,14 +1126,10 @@ func filterRelevantLogs(logs, ip string, maxLines int) string { } } } - - // Build result result := make([]string, len(selected)) for i, s := range selected { result[i] = s.line } - - // Remove duplicate consecutive lines filtered := []string{} lastLine := "" for _, line := range result { @@ -1169,14 +1138,13 @@ func filterRelevantLogs(logs, ip string, maxLines int) string { lastLine = line } } - return strings.Join(filtered, "\n") } -// shouldAlertForCountry checks if an IP’s country is in the allowed alert list. +// Checks if an IP's country is in the allowed alert list. func shouldAlertForCountry(country string, alertCountries []string) bool { if len(alertCountries) == 0 || strings.Contains(strings.Join(alertCountries, ","), "ALL") { - return true // If "ALL" is selected, alert for all bans + return true } for _, c := range alertCountries { if strings.EqualFold(country, c) { @@ -1186,13 +1154,15 @@ func shouldAlertForCountry(country string, alertCountries []string) bool { return false } -// renderIndexPage renders the index.html template with common data +// ========================================================================= +// Page Rendering +// ========================================================================= + +// Renders the main SPA page with template variables. func renderIndexPage(c *gin.Context) { - // Check if external IP lookup is disabled via environment variable - // Default is enabled (false means enabled, true means disabled) disableExternalIP := os.Getenv("DISABLE_EXTERNAL_IP_LOOKUP") == "true" || os.Getenv("DISABLE_EXTERNAL_IP_LOOKUP") == "1" - // Check if OIDC is enabled and skip login page setting + // Checks if OIDC is enabled and skip login page setting oidcEnabled := auth.IsEnabled() skipLoginPage := false if oidcEnabled { @@ -1202,7 +1172,7 @@ func renderIndexPage(c *gin.Context) { } } - // Update check: default true; set UPDATE_CHECK=false to disable external GitHub request + // Checks is a user wants to disable the github versioning check updateCheckEnabled := os.Getenv("UPDATE_CHECK") != "false" c.HTML(http.StatusOK, "index.html", gin.H{ @@ -1216,44 +1186,11 @@ func renderIndexPage(c *gin.Context) { }) } -// githubReleaseResponse is used to parse the GitHub releases/latest API response -type githubReleaseResponse struct { - TagName string `json:"tag_name"` -} +// ========================================================================= +// Version +// ========================================================================= -// versionLess returns true if a < b (e.g. "1.3.5" < "1.3.6") -func versionLess(a, b string) bool { - parse := func(s string) []int { - s = strings.TrimPrefix(strings.TrimSpace(s), "v") - parts := strings.Split(s, ".") - out := make([]int, 0, len(parts)) - for _, p := range parts { - n, _ := strconv.Atoi(p) - out = append(out, n) - } - return out - } - pa, pb := parse(a), parse(b) - for i := 0; i < len(pa) || i < len(pb); i++ { - va, vb := 0, 0 - if i < len(pa) { - va = pa[i] - } - if i < len(pb) { - vb = pb[i] - } - if va < vb { - return true - } - if va > vb { - return false - } - } - return false -} - -// GetVersionHandler returns the current app version and optionally the latest GitHub release. -// UPDATE_CHECK=false disables the external request to GitHub. +// Returns the app version and checks GitHub for updates. func GetVersionHandler(c *gin.Context) { updateCheckEnabled := os.Getenv("UPDATE_CHECK") != "false" out := gin.H{ @@ -1293,10 +1230,45 @@ func GetVersionHandler(c *gin.Context) { c.JSON(http.StatusOK, out) } -// GetJailFilterConfigHandler returns both the filter config and jail config for a given jail +// Checks if a version is less than another version. +func versionLess(a, b string) bool { + parse := func(s string) []int { + s = strings.TrimPrefix(strings.TrimSpace(s), "v") + parts := strings.Split(s, ".") + out := make([]int, 0, len(parts)) + for _, p := range parts { + n, _ := strconv.Atoi(p) + out = append(out, n) + } + return out + } + pa, pb := parse(a), parse(b) + for i := 0; i < len(pa) || i < len(pb); i++ { + va, vb := 0, 0 + if i < len(pa) { + va = pa[i] + } + if i < len(pb) { + vb = pb[i] + } + if va < vb { + return true + } + if va > vb { + return false + } + } + return false +} + +// ========================================================================= +// Jail Config +// ========================================================================= + +// Returns the filter and jail config for a given jail. func GetJailFilterConfigHandler(c *gin.Context) { config.DebugLog("----------------------------") - config.DebugLog("GetJailFilterConfigHandler called (handlers.go)") // entry point + config.DebugLog("GetJailFilterConfigHandler called (handlers.go)") jail := c.Param("jail") config.DebugLog("Jail name: %s", jail) @@ -1325,22 +1297,21 @@ func GetJailFilterConfigHandler(c *gin.Context) { } config.DebugLog("Jail config loaded, length: %d, file: %s", len(jailCfg), jailFilePath) - // Extract filter name from jail config, or use jail name as fallback + // Extracts the filter name from the jail config, or uses the jail name as fallback filterName := fail2ban.ExtractFilterFromJailConfig(jailCfg) if filterName == "" { - // No filter directive found, use jail name as filter name (default behavior) + // No filter directive found, uses the jail name as filter name filterName = jail config.DebugLog("No filter directive found in jail config, using jail name as filter name: %s", filterName) } else { config.DebugLog("Found filter directive in jail config: %s", filterName) } - // Load filter config using the determined filter name + // Loads the filter config using the filter name determined from the jail config config.DebugLog("Loading filter config for filter: %s", filterName) filterCfg, filterFilePath, filterErr = conn.GetFilterConfig(c.Request.Context(), filterName) if filterErr != nil { config.DebugLog("Failed to load filter config for %s: %v", filterName, filterErr) - // Don't fail completely - allow editing even if filter doesn't exist yet config.DebugLog("Continuing without filter config (filter may not exist yet)") filterCfg = "" filterFilePath = "" @@ -1357,7 +1328,7 @@ func GetJailFilterConfigHandler(c *gin.Context) { }) } -// SetJailFilterConfigHandler overwrites both the filter config and jail config with new content +// Saves updated filter/jail config and reloads Fail2ban. func SetJailFilterConfigHandler(c *gin.Context) { defer func() { if r := recover(); r != nil { @@ -1365,9 +1336,8 @@ func SetJailFilterConfigHandler(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Internal server error: %v", r)}) } }() - config.DebugLog("----------------------------") - config.DebugLog("SetJailFilterConfigHandler called (handlers.go)") // entry point + config.DebugLog("SetJailFilterConfigHandler called (handlers.go)") jail := c.Param("jail") config.DebugLog("Jail name: %s", jail) @@ -1379,7 +1349,6 @@ func SetJailFilterConfigHandler(c *gin.Context) { } config.DebugLog("Connector resolved: %s (type: %s)", conn.Server().Name, conn.Server().Type) - // Parse JSON body (containing both filter and jail content) var req struct { Filter string `json:"filter"` Jail string `json:"jail"` @@ -1397,33 +1366,30 @@ func SetJailFilterConfigHandler(c *gin.Context) { config.DebugLog("Jail preview (first 100 chars): %s", req.Jail[:min(100, len(req.Jail))]) } - // Save filter config - use original filter name, not the one from the new jail config if req.Filter != "" { - // Load the original jail config to determine which filter was originally loaded originalJailCfg, _, err := conn.GetJailConfig(c.Request.Context(), jail) if err != nil { config.DebugLog("Failed to load original jail config to determine filter name: %v", err) - // Fallback: extract from new jail config originalJailCfg = req.Jail } - // Extract the ORIGINAL filter name (the one that was loaded when the modal opened) + // Extracts the original filter name (the one that was loaded when the modal opened) originalFilterName := fail2ban.ExtractFilterFromJailConfig(originalJailCfg) if originalFilterName == "" { - // No filter directive found in original config, use jail name as filter name (default behavior) + // No filter directive found in original config, uses the jail name as filter name originalFilterName = jail config.DebugLog("No filter directive found in original jail config, using jail name as filter name: %s", originalFilterName) } else { config.DebugLog("Found original filter directive in jail config: %s", originalFilterName) } - // Extract the NEW filter name from the updated jail config + // Extracts the new filter name from the updated jail config newFilterName := fail2ban.ExtractFilterFromJailConfig(req.Jail) if newFilterName == "" { newFilterName = jail } - // If the filter name changed, save to the ORIGINAL filter name (not the new one) + // If the filter name changed, saves to the original filter name // This prevents overwriting a different filter with the old filter's content if originalFilterName != newFilterName { config.DebugLog("Filter name changed from %s to %s, saving filter to original name: %s", originalFilterName, newFilterName, originalFilterName) @@ -1442,7 +1408,6 @@ func SetJailFilterConfigHandler(c *gin.Context) { config.DebugLog("No filter config provided, skipping") } - // Save jail config if req.Jail != "" { config.DebugLog("Saving jail config for jail: %s", jail) if err := conn.SetJailConfig(c.Request.Context(), jail, req.Jail); err != nil { @@ -1455,11 +1420,11 @@ func SetJailFilterConfigHandler(c *gin.Context) { config.DebugLog("No jail config provided, skipping") } - // Reload fail2ban + // Reloads Fail2ban config.DebugLog("Reloading fail2ban") if err := conn.Reload(c.Request.Context()); err != nil { log.Printf("⚠️ Config saved but fail2ban reload failed: %v", err) - // Auto-disable this jail so fail2ban won't crash on next restart (invalid filter/jail config) + // If reload fails, we automatically disable the jail so Fail2ban won't crash on next restart (invalid filter/jail config) disableUpdate := map[string]bool{jail: false} if disableErr := conn.UpdateJailEnabledStates(c.Request.Context(), disableUpdate); disableErr != nil { log.Printf("⚠️ Failed to auto-disable jail %s after reload failure: %v", jail, disableErr) @@ -1481,18 +1446,9 @@ func SetJailFilterConfigHandler(c *gin.Context) { return } config.DebugLog("Fail2ban reloaded successfully") - c.JSON(http.StatusOK, gin.H{"message": "Filter and jail config updated and fail2ban reloaded"}) } -func min(a, b int) int { - if a < b { - return a - } - return b -} - -// equalStringSlices compares two string slices for equality func equalStringSlices(a, b []string) bool { if len(a) != len(b) { return false @@ -1505,12 +1461,10 @@ func equalStringSlices(a, b []string) bool { return true } -// TestLogpathHandler tests a logpath and returns matching files -// Resolves Fail2Ban variables before testing -// Accepts optional logpath in request body, otherwise reads from saved jail config +// Validates that a jail's log path resolves to real files. func TestLogpathHandler(c *gin.Context) { config.DebugLog("----------------------------") - config.DebugLog("TestLogpathHandler called (handlers.go)") // entry point + config.DebugLog("TestLogpathHandler called (handlers.go)") jail := c.Param("jail") conn, err := resolveConnector(c) if err != nil { @@ -1520,23 +1474,23 @@ func TestLogpathHandler(c *gin.Context) { var originalLogpath string - // Check if logpath is provided in request body + // Checks if a logpath is provided in the request body var reqBody struct { Logpath string `json:"logpath"` } if err := c.ShouldBindJSON(&reqBody); err == nil && reqBody.Logpath != "" { - // Use logpath from request body (from textarea) + // Uses the logpath from the request body (from textarea) originalLogpath = strings.TrimSpace(reqBody.Logpath) config.DebugLog("Using logpath from request body: %s", originalLogpath) } else { - // Fall back to reading from saved jail config + // Falls back to reading from the saved jail config jailCfg, _, err := conn.GetJailConfig(c.Request.Context(), jail) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + err.Error()}) return } - // Extract logpath from jail config + // Extracts the logpath from the jail config originalLogpath = fail2ban.ExtractLogpathFromJailConfig(jailCfg) if originalLogpath == "" { c.JSON(http.StatusOK, gin.H{ @@ -1555,19 +1509,18 @@ func TestLogpathHandler(c *gin.Context) { return } - // Get server type to determine test strategy + // Gets the server type to determine the test strategy server := conn.Server() isLocalServer := server.Type == "local" - // Split logpath by newlines and spaces (Fail2ban supports multiple logpaths separated by spaces or newlines) - // First split by newlines, then split each line by spaces + // Splits the logpath by newlines and spaces (Fail2ban supports multiple logpaths separated by spaces or newlines) + // First splits by newlines, then splits each line by spaces var logpaths []string for _, line := range strings.Split(originalLogpath, "\n") { line = strings.TrimSpace(line) if line == "" { continue } - // Split by spaces to handle multiple logpaths in one line paths := strings.Fields(line) logpaths = append(logpaths, paths...) } @@ -1579,10 +1532,7 @@ func TestLogpathHandler(c *gin.Context) { if logpathLine == "" { continue } - if isLocalServer { - // For local servers: only test in fail2ban-ui container (container can only see mounted paths) - // Resolve variables first resolvedPath, err := fail2ban.ResolveLogpathVariables(logpathLine) if err != nil { allResults = append(allResults, map[string]interface{}{ @@ -1594,12 +1544,10 @@ func TestLogpathHandler(c *gin.Context) { }) continue } - if resolvedPath == "" { resolvedPath = logpathLine } - // Test in fail2ban-ui container files, localErr := fail2ban.TestLogpath(resolvedPath) allResults = append(allResults, map[string]interface{}{ @@ -1615,7 +1563,6 @@ func TestLogpathHandler(c *gin.Context) { }(), }) } else { - // For SSH/Agent servers: test on remote server (via connector) _, resolvedPath, filesOnRemote, err := conn.TestLogpathWithResolution(c.Request.Context(), logpathLine) if err != nil { allResults = append(allResults, map[string]interface{}{ @@ -1627,7 +1574,6 @@ func TestLogpathHandler(c *gin.Context) { }) continue } - allResults = append(allResults, map[string]interface{}{ "logpath": logpathLine, "resolved_path": resolvedPath, @@ -1637,7 +1583,6 @@ func TestLogpathHandler(c *gin.Context) { }) } } - c.JSON(http.StatusOK, gin.H{ "original_logpath": originalLogpath, "is_local_server": isLocalServer, @@ -1645,11 +1590,14 @@ func TestLogpathHandler(c *gin.Context) { }) } -// ManageJailsHandler returns a list of all jails (from jail.local and jail.d) -// including their enabled status. +// ========================================================================= +// Jail Management +// ========================================================================= + +// Returns all jails (enabled and disabled) for the manage-jails modal. func ManageJailsHandler(c *gin.Context) { config.DebugLog("----------------------------") - config.DebugLog("ManageJailsHandler called (handlers.go)") // entry point + config.DebugLog("ManageJailsHandler called (handlers.go)") conn, err := resolveConnector(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -1663,7 +1611,11 @@ func ManageJailsHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"jails": jails}) } -// ListPermanentBlocksHandler exposes the permanent block log. +// ========================================================================= +// Advanced Actions +// ========================================================================= + +// Returns the permanent block log entries. func ListPermanentBlocksHandler(c *gin.Context) { limit := 100 if limitStr := c.DefaultQuery("limit", "100"); limitStr != "" { @@ -1679,7 +1631,7 @@ func ListPermanentBlocksHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"blocks": records}) } -// AdvancedActionsTestHandler allows manual block/unblock tests. +// Allows manual block/unblock against the configured integration. func AdvancedActionsTestHandler(c *gin.Context) { var req struct { Action string `json:"action"` @@ -1704,34 +1656,29 @@ func AdvancedActionsTestHandler(c *gin.Context) { settings := config.GetSettings() - // Check if integration is configured if settings.AdvancedActions.Integration == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "no integration configured. Please configure an integration (MikroTik, pfSense, or OPNsense) in Advanced Actions settings first"}) return } - // Verify integration exists integration, ok := integrations.Get(settings.AdvancedActions.Integration) if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("integration %s not found or not registered", settings.AdvancedActions.Integration)}) return } - // Validate integration configuration if err := integration.Validate(settings.AdvancedActions); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("integration configuration is invalid: %v", err)}) return } - // Advanced actions work globally, not per server server := config.Fail2banServer{} - // Check if IP is already blocked before attempting action (for block action only) + // Checks if the IP is already blocked before attempting the action (for block action only) skipLoggingIfAlreadyBlocked := false if action == "block" && settings.AdvancedActions.Integration != "" { active, checkErr := storage.IsPermanentBlockActive(c.Request.Context(), req.IP, settings.AdvancedActions.Integration) if checkErr == nil && active { - // IP is already blocked, we'll check the error message after the call skipLoggingIfAlreadyBlocked = true } } @@ -1746,13 +1693,12 @@ func AdvancedActionsTestHandler(c *gin.Context) { skipLoggingIfAlreadyBlocked, ) if err != nil { - // Check if error indicates IP is already blocked - show as info instead of error if skipLoggingIfAlreadyBlocked { errMsg := strings.ToLower(err.Error()) if strings.Contains(errMsg, "already have such entry") || strings.Contains(errMsg, "already exists") || strings.Contains(errMsg, "duplicate") { - // IP is already blocked, return info message with original error + // IP is already blocked, returns info message with original error c.JSON(http.StatusOK, gin.H{"message": err.Error(), "info": true}) return } @@ -1763,9 +1709,7 @@ func AdvancedActionsTestHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Action %s completed for %s", action, req.IP)}) } -// UpdateJailManagementHandler updates the enabled state for each jail. -// Expected JSON format: { "JailName1": true, "JailName2": false, ... } -// getJailNames converts a map of jail names to a sorted slice of jail names +// Returns a sorted slice of jail names from the map. func getJailNames(jails map[string]bool) []string { names := make([]string, 0, len(jails)) for name := range jails { @@ -1784,30 +1728,23 @@ func contains(slice []string, item string) bool { return false } -// parseJailErrorsFromReloadOutput extracts jail names that have errors from reload output. -// Looks for patterns like "Errors in jail 'jailname'. Skipping..." or "Unable to read the filter 'filtername'" +// Extracts problematic jail names from Fail2ban reload output. func parseJailErrorsFromReloadOutput(output string) []string { var problematicJails []string lines := strings.Split(output, "\n") for _, line := range lines { - // Look for "Errors in jail 'jailname'. Skipping..." if strings.Contains(line, "Errors in jail") && strings.Contains(line, "Skipping") { - // Extract jail name between single quotes re := regexp.MustCompile(`Errors in jail '([^']+)'`) matches := re.FindStringSubmatch(line) if len(matches) > 1 { problematicJails = append(problematicJails, matches[1]) } } - // Also check for filter errors that might indicate jail problems - // "Unable to read the filter 'filtername'" - this might be referenced by a jail - // Note: Filter errors are often associated with jails, but we primarily track - // jail errors directly via "Errors in jail" messages above - _ = strings.Contains(line, "Unable to read the filter") // Track for future enhancement + // Also checks for filter errors that might indicate jail problems + _ = strings.Contains(line, "Unable to read the filter") } - // Remove duplicates seen := make(map[string]bool) uniqueJails := []string{} for _, jail := range problematicJails { @@ -1820,10 +1757,10 @@ func parseJailErrorsFromReloadOutput(output string) []string { return uniqueJails } -// After updating, fail2ban is reloaded to apply the changes. +// Enables/disables jails and reloads Fail2ban. func UpdateJailManagementHandler(c *gin.Context) { config.DebugLog("----------------------------") - config.DebugLog("UpdateJailManagementHandler called (handlers.go)") // entry point + config.DebugLog("UpdateJailManagementHandler called (handlers.go)") conn, err := resolveConnector(c) if err != nil { config.DebugLog("Error resolving connector: %v", err) @@ -1843,7 +1780,7 @@ func UpdateJailManagementHandler(c *gin.Context) { return } - // Track which jails were enabled (for error recovery) + // Tracks which jails were enabled (for error recovery) enabledJails := make(map[string]bool) for jailName, enabled := range updates { if enabled { @@ -1851,7 +1788,6 @@ func UpdateJailManagementHandler(c *gin.Context) { } } - // Update jail configuration file(s) with the new enabled states. if err := conn.UpdateJailEnabledStates(c.Request.Context(), updates); err != nil { config.DebugLog("Error updating jail enabled states: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update jail settings: " + err.Error()}) @@ -1859,19 +1795,18 @@ func UpdateJailManagementHandler(c *gin.Context) { } config.DebugLog("Successfully updated jail enabled states") - // Reload fail2ban to apply the changes (reload is sufficient for jail enable/disable) + // Reloads fail2ban to apply the changes reloadErr := conn.Reload(c.Request.Context()) - // Check for errors in reload output even if reload "succeeded" + // Checks for errors in reload output var problematicJails []string var detailedErrorOutput string if reloadErr != nil { errMsg := reloadErr.Error() config.DebugLog("Error: failed to reload fail2ban after updating jail settings: %v", reloadErr) - // Extract output from error message (format: "fail2ban reload completed but with errors (output: ...)") + // Extracts the output from the error message if strings.Contains(errMsg, "(output:") { - // Extract the output part outputStart := strings.Index(errMsg, "(output:") + 8 outputEnd := strings.LastIndex(errMsg, ")") if outputEnd > outputStart { @@ -1879,7 +1814,6 @@ func UpdateJailManagementHandler(c *gin.Context) { problematicJails = parseJailErrorsFromReloadOutput(detailedErrorOutput) } } else if strings.Contains(errMsg, "output:") { - // Alternative format: "fail2ban reload error: ... (output: ...)" outputStart := strings.Index(errMsg, "output:") + 7 if outputStart < len(errMsg) { detailedErrorOutput = strings.TrimSpace(errMsg[outputStart:]) @@ -1887,17 +1821,15 @@ func UpdateJailManagementHandler(c *gin.Context) { } } - // If we found problematic jails, disable them + // If problematic jails are found, disables them // TODO: @matthias we need to further enhance this if len(problematicJails) > 0 { config.DebugLog("Found %d problematic jail(s) in reload output: %v", len(problematicJails), problematicJails) - // Create disable update for problematic jails disableUpdate := make(map[string]bool) for _, jailName := range problematicJails { disableUpdate[jailName] = false } - // Also disable any jails that were enabled in this request if they're in the problematic list for jailName := range enabledJails { if contains(problematicJails, jailName) { disableUpdate[jailName] = false @@ -1915,22 +1847,18 @@ func UpdateJailManagementHandler(c *gin.Context) { } } - // Update enabledJails to include problematic jails for response for _, jailName := range problematicJails { enabledJails[jailName] = true } } if detailedErrorOutput != "" { - // We use only the extracted error output errMsg = strings.TrimSpace(detailedErrorOutput) } - // If any jails were enabled in this request and reload failed, disable them all if len(enabledJails) > 0 { config.DebugLog("Reload failed after enabling %d jail(s), auto-disabling all enabled jails: %v", len(enabledJails), enabledJails) - // Disable all jails that were just enabled disableUpdate := make(map[string]bool) for jailName := range enabledJails { disableUpdate[jailName] = false @@ -1946,7 +1874,7 @@ func UpdateJailManagementHandler(c *gin.Context) { return } - // Reload again after disabling + // Reloads again after disabling if reloadErr = conn.Reload(c.Request.Context()); reloadErr != nil { config.DebugLog("Error: failed to reload fail2ban after disabling jails: %v", reloadErr) c.JSON(http.StatusOK, gin.H{ @@ -1977,7 +1905,6 @@ func UpdateJailManagementHandler(c *gin.Context) { return } - // Error occurred but no jails were enabled (only disabled), so just report the error c.JSON(http.StatusOK, gin.H{ "error": fmt.Sprintf("Failed to reload fail2ban: %s", errMsg), }) @@ -1986,7 +1913,7 @@ func UpdateJailManagementHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Jail settings updated and fail2ban reloaded successfully"}) } -// CreateJailHandler creates a new jail. +// Creates a new jail with the given name and optional config. func CreateJailHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("CreateJailHandler called (handlers.go)") @@ -2006,18 +1933,15 @@ func CreateJailHandler(c *gin.Context) { return } - // Validate jail name if err := fail2ban.ValidateJailName(req.JailName); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // If no content provided, create minimal jail config if req.Content == "" { req.Content = fmt.Sprintf("[%s]\nenabled = false\n", req.JailName) } - // Create the jail if err := conn.CreateJail(c.Request.Context(), req.JailName, req.Content); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create jail: " + err.Error()}) return @@ -2026,7 +1950,7 @@ func CreateJailHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Jail '%s' created successfully", req.JailName)}) } -// DeleteJailHandler deletes a jail. +// Removes a jail and its config file. func DeleteJailHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("DeleteJailHandler called (handlers.go)") @@ -2043,13 +1967,11 @@ func DeleteJailHandler(c *gin.Context) { return } - // Validate jail name if err := fail2ban.ValidateJailName(jailName); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // Delete the jail if err := conn.DeleteJail(c.Request.Context(), jailName); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete jail: " + err.Error()}) return @@ -2058,30 +1980,27 @@ func DeleteJailHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Jail '%s' deleted successfully", jailName)}) } -// GetSettingsHandler returns the entire AppSettings struct as JSON +// ========================================================================= +// App Settings +// ========================================================================= + +// Returns the current AppSettings as JSON. func GetSettingsHandler(c *gin.Context) { config.DebugLog("----------------------------") - config.DebugLog("GetSettingsHandler called (handlers.go)") // entry point + config.DebugLog("GetSettingsHandler called (handlers.go)") s := config.GetSettings() - // Check if PORT environment variable is set envPort, envPortSet := config.GetPortFromEnv() - - // Create response with PORT env info response := make(map[string]interface{}) responseBytes, _ := json.Marshal(s) json.Unmarshal(responseBytes, &response) - - // Add PORT environment variable information response["portFromEnv"] = envPort response["portEnvSet"] = envPortSet - // If PORT env is set, override the port value in response if envPortSet { response["port"] = envPort } - // Check if CALLBACK_URL environment variable is set envCallbackURL, envCallbackURLSet := config.GetCallbackURLFromEnv() response["callbackUrlEnvSet"] = envCallbackURLSet response["callbackUrlFromEnv"] = envCallbackURL @@ -2092,13 +2011,13 @@ func GetSettingsHandler(c *gin.Context) { c.JSON(http.StatusOK, response) } -// UpdateSettingsHandler updates the AppSettings from a JSON body +// Saves new settings, pushes defaults to servers, and reloads. func UpdateSettingsHandler(c *gin.Context) { config.DebugLog("----------------------------") - config.DebugLog("UpdateSettingsHandler called (handlers.go)") // entry point + config.DebugLog("UpdateSettingsHandler called (handlers.go)") var req config.AppSettings if err := c.ShouldBindJSON(&req); err != nil { - fmt.Println("JSON binding error:", err) // Debug + fmt.Println("JSON binding error:", err) c.JSON(http.StatusBadRequest, gin.H{ "error": "invalid JSON", "details": err.Error(), @@ -2107,14 +2026,13 @@ func UpdateSettingsHandler(c *gin.Context) { } config.DebugLog("JSON binding successful, updating settings (handlers.go)") - // Check if PORT environment variable is set - if so, ignore port changes from request + // Ignores port changes from request if the PORT environment variable is set envPort, envPortSet := config.GetPortFromEnv() if envPortSet { - // Don't allow port changes when PORT env is set req.Port = envPort } - // Check if CALLBACK_URL environment variable is set - if so, ignore changes from request + // Ignores callback URL changes from request if the CALLBACK_URL environment variable is set envCallbackURL, envCallbackURLSet := config.GetCallbackURLFromEnv() if envCallbackURLSet { req.CallbackURL = envCallbackURL @@ -2129,7 +2047,7 @@ func UpdateSettingsHandler(c *gin.Context) { } config.DebugLog("Settings updated successfully (handlers.go)") - // Check if callback URL changed - if so, update action files for all active remote servers + // Checks if the callback URL changed; if so, updates the action files for all active remote servers callbackURLChanged := oldSettings.CallbackURL != newSettings.CallbackURL if err := fail2ban.GetManager().ReloadFromSettings(config.GetSettings()); err != nil { @@ -2140,17 +2058,16 @@ func UpdateSettingsHandler(c *gin.Context) { if callbackURLChanged { config.DebugLog("Callback URL changed, updating action files and reloading fail2ban on all servers") - // Update action files for remote servers (SSH and Agent) + // Updates the action files for remote servers (SSH and Agent) if err := fail2ban.GetManager().UpdateActionFiles(c.Request.Context()); err != nil { config.DebugLog("Warning: failed to update some remote action files: %v", err) - // Don't fail the request, just log the warning } - // Reload all remote servers after updating action files + // Reloads all remote servers after updating the action files connectors := fail2ban.GetManager().Connectors() for _, conn := range connectors { server := conn.Server() - // Only reload remote servers (SSH and Agent), local will be handled separately + // Only reloads remote servers (SSH and Agent), local will be handled separately if (server.Type == "ssh" || server.Type == "agent") && server.Enabled { config.DebugLog("Reloading fail2ban on %s after callback URL change", server.Name) if err := conn.Reload(c.Request.Context()); err != nil { @@ -2161,14 +2078,14 @@ func UpdateSettingsHandler(c *gin.Context) { } } - // Also update local action file if callback URL changed + // Also updates the local action file if the callback URL changed settings := config.GetSettings() for _, server := range settings.Servers { if server.Type == "local" && server.Enabled { if err := config.EnsureLocalFail2banAction(server); err != nil { config.DebugLog("Warning: failed to update local action file: %v", err) } else { - // Reload local fail2ban after updating action file + // Reloads local fail2ban after updating the action file if conn, err := fail2ban.GetManager().Connector(server.ID); err == nil { config.DebugLog("Reloading local fail2ban after callback URL change") if reloadErr := conn.Reload(c.Request.Context()); reloadErr != nil { @@ -2182,8 +2099,6 @@ func UpdateSettingsHandler(c *gin.Context) { } } - // Check if Fail2Ban DEFAULT settings changed and push to all enabled servers - // Compare IgnoreIPs arrays ignoreIPsChanged := !equalStringSlices(oldSettings.IgnoreIPs, newSettings.IgnoreIPs) defaultSettingsChanged := oldSettings.BantimeIncrement != newSettings.BantimeIncrement || oldSettings.DefaultJailEnable != newSettings.DefaultJailEnable || @@ -2209,7 +2124,6 @@ func UpdateSettingsHandler(c *gin.Context) { errors = append(errors, errorMsg) } else { config.DebugLog("Successfully updated DEFAULT settings on %s", server.Name) - // Reload fail2ban to apply the changes if err := conn.Reload(c.Request.Context()); err != nil { config.DebugLog("Warning: failed to reload fail2ban on %s after updating DEFAULT settings: %v", server.Name, err) errors = append(errors, fmt.Sprintf("Settings updated on %s, but reload failed: %v", server.Name, err)) @@ -2220,18 +2134,17 @@ func UpdateSettingsHandler(c *gin.Context) { } if len(errors) > 0 { config.DebugLog("Some servers failed to update DEFAULT settings: %v", errors) - // Don't fail the request, but include warnings in response c.JSON(http.StatusOK, gin.H{ "message": "Settings updated", - "restartNeeded": false, // We reloaded, so no restart needed + "restartNeeded": false, "warnings": errors, }) return } - // Settings were updated and reloaded successfully, no restart needed + // Settings were updated and reloaded successfully c.JSON(http.StatusOK, gin.H{ "message": "Settings updated and fail2ban reloaded", - "restartNeeded": false, // We reloaded, so no restart needed + "restartNeeded": false, }) return } @@ -2242,11 +2155,14 @@ func UpdateSettingsHandler(c *gin.Context) { }) } -// ListFiltersHandler returns a JSON array of filter names -// found as *.conf in /etc/fail2ban/filter.d +// ========================================================================= +// Filters +// ========================================================================= + +// Returns all available filter names for the selected server. func ListFiltersHandler(c *gin.Context) { config.DebugLog("----------------------------") - config.DebugLog("ListFiltersHandler called (handlers.go)") // entry point + config.DebugLog("ListFiltersHandler called (handlers.go)") conn, err := resolveConnector(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -2254,7 +2170,6 @@ func ListFiltersHandler(c *gin.Context) { } server := conn.Server() if server.Type == "local" { - // For local, check if directory exists first dir := "/etc/fail2ban/filter.d" if _, statErr := os.Stat(dir); statErr != nil { if os.IsNotExist(statErr) { @@ -2274,6 +2189,7 @@ func ListFiltersHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"filters": filters}) } +// Returns the content of a specific filter file. func GetFilterContentHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("GetFilterContentHandler called (handlers.go)") @@ -2290,7 +2206,6 @@ func GetFilterContentHandler(c *gin.Context) { return } - // Remove comments for display in Filter Debug page only content = fail2ban.RemoveComments(content) c.JSON(http.StatusOK, gin.H{ @@ -2299,9 +2214,10 @@ func GetFilterContentHandler(c *gin.Context) { }) } +// Runs fail2ban-regex against provided log lines and filter content. func TestFilterHandler(c *gin.Context) { config.DebugLog("----------------------------") - config.DebugLog("TestFilterHandler called (handlers.go)") // entry point + config.DebugLog("TestFilterHandler called (handlers.go)") conn, err := resolveConnector(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -2310,7 +2226,7 @@ func TestFilterHandler(c *gin.Context) { var req struct { FilterName string `json:"filterName"` LogLines []string `json:"logLines"` - FilterContent string `json:"filterContent"` // Optional: if provided, use this instead of reading from file + FilterContent string `json:"filterContent"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) @@ -2328,7 +2244,7 @@ func TestFilterHandler(c *gin.Context) { }) } -// CreateFilterHandler creates a new filter. +// Creates a new filter definition file. func CreateFilterHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("CreateFilterHandler called (handlers.go)") @@ -2354,7 +2270,6 @@ func CreateFilterHandler(c *gin.Context) { return } - // If no content provided, create empty filter if req.Content == "" { req.Content = fmt.Sprintf("# Filter: %s\n", req.FilterName) } @@ -2368,7 +2283,7 @@ func CreateFilterHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Filter '%s' created successfully", req.FilterName)}) } -// DeleteFilterHandler deletes a filter. +// Removes a filter definition file. func DeleteFilterHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("DeleteFilterHandler called (handlers.go)") @@ -2385,13 +2300,11 @@ func DeleteFilterHandler(c *gin.Context) { return } - // Validate filter name if err := fail2ban.ValidateFilterName(filterName); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // Delete the filter if err := conn.DeleteFilter(c.Request.Context(), filterName); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete filter: " + err.Error()}) return @@ -2400,10 +2313,11 @@ func DeleteFilterHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Filter '%s' deleted successfully", filterName)}) } -// ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON +// Updates /etc/fail2ban/jail.local [DEFAULT] with our JSON +// TODO: @matthias we need to further enhance this func ApplyFail2banSettings(jailLocalPath string) error { config.DebugLog("----------------------------") - config.DebugLog("ApplyFail2banSettings called (handlers.go)") // entry point + config.DebugLog("ApplyFail2banSettings called (handlers.go)") s := config.GetSettings() // open /etc/fail2ban/jail.local, parse or do a simplistic approach: @@ -2435,18 +2349,20 @@ func ApplyFail2banSettings(jailLocalPath string) error { return os.WriteFile(jailLocalPath, []byte(content), 0644) } -// RestartFail2banHandler reloads the Fail2ban service +// ========================================================================= +// Restart +// ========================================================================= + +// Restarts (or reloads) the Fail2ban service on the selected server. func RestartFail2banHandler(c *gin.Context) { config.DebugLog("----------------------------") - config.DebugLog("RestartFail2banHandler called (handlers.go)") // entry point + config.DebugLog("RestartFail2banHandler called (handlers.go)") - // Check if serverId is provided in query parameter serverID := c.Query("serverId") var conn fail2ban.Connector var err error if serverID != "" { - // Use specific server manager := fail2ban.GetManager() conn, err = manager.Connector(serverID) if err != nil { @@ -2454,7 +2370,7 @@ func RestartFail2banHandler(c *gin.Context) { return } } else { - // Use default connector from context + // Uses the default connector from the context conn, err = resolveConnector(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -2464,19 +2380,18 @@ func RestartFail2banHandler(c *gin.Context) { server := conn.Server() - // Attempt to restart the fail2ban service via the connector. - // Any error here means the service was not restarted, so we surface it to the UI. + // Attempts to restart the fail2ban service via the connector. mode, err := fail2ban.RestartFail2ban(server.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - // Only call MarkRestartDone if we successfully restarted the service. - if err := config.MarkRestartDone(server.ID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } + // Only calls MarkRestartDone if the service was successfully restarted + //if err := config.MarkRestartDone(server.ID); err != nil { + // c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + // return + //} msg := "Fail2ban service restarted successfully" if mode == "reload" { msg = "Fail2ban configuration reloaded successfully (no systemd service restart)" @@ -2488,7 +2403,11 @@ func RestartFail2banHandler(c *gin.Context) { }) } -// loadLocale loads a locale JSON file and returns a map of translations +// ========================================================================= +// Email Alerts and SMTP +// ========================================================================= + +// loadLocale returns cached translations for the given language, loading from disk if needed. func loadLocale(lang string) (map[string]string, error) { localeCacheLock.RLock() if cached, ok := localeCache[lang]; ok { @@ -2497,7 +2416,7 @@ func loadLocale(lang string) (map[string]string, error) { } localeCacheLock.RUnlock() - // Determine locale file path + // Determines the locale file path var localePath string _, container := os.LookupEnv("CONTAINER") if container { @@ -2506,10 +2425,9 @@ func loadLocale(lang string) (map[string]string, error) { localePath = fmt.Sprintf("./internal/locales/%s.json", lang) } - // Read locale file data, err := os.ReadFile(localePath) if err != nil { - // Fallback to English if locale file not found + // Falls back to English if the locale file is not found if lang != "en" { return loadLocale("en") } @@ -2521,7 +2439,6 @@ func loadLocale(lang string) (map[string]string, error) { return nil, fmt.Errorf("failed to parse locale file: %w", err) } - // Cache the translations localeCacheLock.Lock() localeCache[lang] = translations localeCacheLock.Unlock() @@ -2529,15 +2446,14 @@ func loadLocale(lang string) (map[string]string, error) { return translations, nil } -// getEmailTranslation gets a translation key from the locale, with fallback to English +// Resolves a translation key, falling back to English. func getEmailTranslation(lang, key string) string { translations, err := loadLocale(lang) if err != nil { - // Try English as fallback if lang != "en" { translations, err = loadLocale("en") if err != nil { - return key // Return key if all else fails + return key } } else { return key @@ -2548,7 +2464,6 @@ func getEmailTranslation(lang, key string) string { return translation } - // Fallback to English if key not found if lang != "en" { enTranslations, err := loadLocale("en") if err == nil { @@ -2557,11 +2472,10 @@ func getEmailTranslation(lang, key string) string { } } } - return key } -// getEmailStyle returns the email style from environment variable, defaulting to "modern" +// Reads the email template style from environment variable (default: "modern"). func getEmailStyle() string { style := os.Getenv("emailStyle") if style == "classic" { @@ -2570,7 +2484,7 @@ func getEmailStyle() string { return "modern" } -// isLOTRModeActive checks if LOTR mode is enabled in alert countries +// Checks whether "LOTR" is among the configured alert countries. func isLOTRModeActive(alertCountries []string) bool { if len(alertCountries) == 0 { return false @@ -2583,51 +2497,43 @@ func isLOTRModeActive(alertCountries []string) bool { return false } -// ******************************************************************* -// * Unified Email Sending Function : * -// ******************************************************************* +// Connects to the SMTP server and delivers a single HTML message. func sendEmail(to, subject, body string, settings config.AppSettings) error { - // Skip sending if the destination email is still the default placeholder + // Skips sending if the destination email is still the default placeholder if strings.EqualFold(strings.TrimSpace(to), "alerts@example.com") { log.Printf("⚠️ sendEmail skipped: destination email is still the default placeholder (alerts@example.com). Please update the 'Destination Email' in Settings → Alert Settings.") return nil } - // Validate SMTP settings if settings.SMTP.Host == "" || settings.SMTP.Username == "" || settings.SMTP.Password == "" || settings.SMTP.From == "" { err := errors.New("SMTP settings are incomplete. Please configure all required fields") log.Printf("❌ sendEmail validation failed: %v (Host: %q, Username: %q, From: %q)", err, settings.SMTP.Host, settings.SMTP.Username, settings.SMTP.From) return err } - // Validate port range if settings.SMTP.Port <= 0 || settings.SMTP.Port > 65535 { err := errors.New("SMTP port must be between 1 and 65535") log.Printf("❌ sendEmail validation failed: %v (Port: %d)", err, settings.SMTP.Port) return err } - // Format message with correct HTML headers message := fmt.Sprintf("From: %s\nTo: %s\nSubject: %s\n"+ "MIME-Version: 1.0\nContent-Type: text/html; charset=\"UTF-8\"\n\n%s", settings.SMTP.From, to, subject, body) msg := []byte(message) - // SMTP Connection Config smtpHost := settings.SMTP.Host smtpPort := settings.SMTP.Port smtpAddr := net.JoinHostPort(smtpHost, fmt.Sprintf("%d", smtpPort)) - // Determine TLS configuration tlsConfig := &tls.Config{ ServerName: smtpHost, InsecureSkipVerify: settings.SMTP.InsecureSkipVerify, } - // Determine authentication method authMethod := settings.SMTP.AuthMethod if authMethod == "" { - authMethod = "auto" // Default to auto if not set + authMethod = "auto" } auth, err := getSMTPAuth(settings.SMTP.Username, settings.SMTP.Password, authMethod, smtpHost) if err != nil { @@ -2636,17 +2542,15 @@ func sendEmail(to, subject, body string, settings config.AppSettings) error { } log.Printf("📧 sendEmail: Using SMTP auth method: %q, host: %s, port: %d, useTLS: %v, insecureSkipVerify: %v", authMethod, smtpHost, smtpPort, settings.SMTP.UseTLS, settings.SMTP.InsecureSkipVerify) - // Determine connection type based on port and UseTLS setting + // Determines the connection type based on the port and UseTLS setting // Port 465 typically uses implicit TLS (SMTPS) // Port 587 typically uses STARTTLS - // Other ports: use UseTLS setting to determine behavior useImplicitTLS := (smtpPort == 465) || (settings.SMTP.UseTLS && smtpPort != 587 && smtpPort != 25) useSTARTTLS := settings.SMTP.UseTLS && (smtpPort == 587 || (smtpPort != 465 && smtpPort != 25)) var client *smtp.Client if useImplicitTLS { - // SMTPS (Implicit TLS) - Connect directly with TLS conn, err := tls.Dial("tcp", smtpAddr, tlsConfig) if err != nil { return fmt.Errorf("failed to connect via TLS: %w", err) @@ -2658,7 +2562,6 @@ func sendEmail(to, subject, body string, settings config.AppSettings) error { return fmt.Errorf("failed to create SMTP client: %w", err) } } else { - // Plain connection (may upgrade to STARTTLS) conn, err := net.DialTimeout("tcp", smtpAddr, 30*time.Second) if err != nil { return fmt.Errorf("failed to connect to SMTP server: %w", err) @@ -2670,7 +2573,6 @@ func sendEmail(to, subject, body string, settings config.AppSettings) error { return fmt.Errorf("failed to create SMTP client: %w", err) } - // Upgrade to STARTTLS if requested if useSTARTTLS { if err := client.StartTLS(tlsConfig); err != nil { return fmt.Errorf("failed to start TLS: %w", err) @@ -2678,14 +2580,12 @@ func sendEmail(to, subject, body string, settings config.AppSettings) error { } } - // Ensure client is closed defer func() { if client != nil { client.Quit() } }() - // Authenticate if credentials are provided if auth != nil { if err := client.Auth(auth); err != nil { log.Printf("❌ sendEmail: SMTP authentication failed: %v", err) @@ -2703,33 +2603,28 @@ func sendEmail(to, subject, body string, settings config.AppSettings) error { return nil } -// Helper Function to Send SMTP Message +// Sends the actual message +// Performs the MAIL/RCPT/DATA sequence on an open SMTP connection. func sendSMTPMessage(client *smtp.Client, from, to string, msg []byte) error { - // Set sender & recipient if err := client.Mail(from); err != nil { return fmt.Errorf("failed to set sender: %w", err) } if err := client.Rcpt(to); err != nil { return fmt.Errorf("failed to set recipient: %w", err) } - - // Send email body wc, err := client.Data() if err != nil { return fmt.Errorf("failed to start data command: %w", err) } defer wc.Close() - if _, err = wc.Write(msg); err != nil { return fmt.Errorf("failed to write email content: %w", err) } - - // Close connection client.Quit() return nil } -// renderClassicEmailDetails creates paragraph-based details for classic email template +// Builds paragraph-based details for the classic email template. func renderClassicEmailDetails(details []emailDetail) string { if len(details) == 0 { return `

No metadata available.

` @@ -2742,7 +2637,7 @@ func renderClassicEmailDetails(details []emailDetail) string { return b.String() } -// buildClassicEmailBody creates the classic email template (original design with multilingual support) +// Renders the original email template layout. func buildClassicEmailBody(title, intro string, details []emailDetail, whoisHTML, logsHTML, whoisTitle, logsTitle, footerText, supportEmail string) string { detailRows := renderClassicEmailDetails(details) year := time.Now().Year() @@ -2810,7 +2705,7 @@ func buildClassicEmailBody(title, intro string, details []emailDetail, whoisHTML `, html.EscapeString(title), html.EscapeString(title), html.EscapeString(intro), detailRows, html.EscapeString(whoisTitle), whoisHTML, html.EscapeString(logsTitle), logsHTML, html.EscapeString(footerText), html.EscapeString(supportEmail), html.EscapeString(supportEmail), year) } -// buildLOTREmailBody creates the dramatic LOTR-themed email template with "You Shall Not Pass" styling +// Renders the LOTR-themed email template. func buildLOTREmailBody(title, intro string, details []emailDetail, whoisHTML, logsHTML, whoisTitle, logsTitle, footerText string) string { detailRows := renderEmailDetails(details) year := strconv.Itoa(time.Now().Year()) @@ -2909,7 +2804,7 @@ func buildLOTREmailBody(title, intro string, details []emailDetail, whoisHTML, l `, html.EscapeString(title), html.EscapeString(intro), detailRows, html.EscapeString(whoisTitle), whoisHTML, html.EscapeString(logsTitle), logsHTML, html.EscapeString(footerText), year) } -// buildModernEmailBody creates the modern responsive email template (new design) +// Renders the default responsive email template. func buildModernEmailBody(title, intro string, details []emailDetail, whoisHTML, logsHTML, whoisTitle, logsTitle, footerText string) string { detailRows := renderEmailDetails(details) year := strconv.Itoa(time.Now().Year()) @@ -3008,6 +2903,7 @@ func buildModernEmailBody(title, intro string, details []emailDetail, whoisHTML, `, html.EscapeString(title), html.EscapeString(title), html.EscapeString(intro), detailRows, html.EscapeString(whoisTitle), whoisHTML, html.EscapeString(logsTitle), logsHTML, html.EscapeString(footerText), year) } +// Builds table rows for the modern/LOTR email templates. func renderEmailDetails(details []emailDetail) string { if len(details) == 0 { return `` @@ -3020,6 +2916,7 @@ func renderEmailDetails(details []emailDetail) string { return b.String() } +// Wraps raw WHOIS text in a styled
 block for email.
 func formatWhoisForEmail(whois string, lang string, isModern bool) string {
 	noDataMsg := getEmailTranslation(lang, "email.whois.no_data")
 	if strings.TrimSpace(whois) == "" {
@@ -3028,13 +2925,13 @@ func formatWhoisForEmail(whois string, lang string, isModern bool) string {
 		}
 		return `
` + html.EscapeString(noDataMsg) + `
` } - // Use
 to preserve all whitespace and newlines exactly as they are
 	if isModern {
 		return ``
 	}
 	return `
` + html.EscapeString(whois) + `
` } +// Highlights suspicious lines and HTTP status codes in email logs. func formatLogsForEmail(ip, logs string, lang string, isModern bool) string { noLogsMsg := getEmailTranslation(lang, "email.logs.no_data") if strings.TrimSpace(logs) == "" { @@ -3043,7 +2940,6 @@ func formatLogsForEmail(ip, logs string, lang string, isModern bool) string { } return `
` + html.EscapeString(noLogsMsg) + `
` } - if isModern { var b strings.Builder b.WriteString(``) return b.String() } - - // Classic format: simple pre tag return `
` + html.EscapeString(logs) + `
` } +// Checks if the line contains known attack indicators. func isSuspiciousLogLineEmail(line, ip string) bool { trimmed := strings.TrimSpace(line) if trimmed == "" { @@ -3089,6 +2984,7 @@ func isSuspiciousLogLineEmail(line, ip string) bool { return (hasBadStatus || hasIndicator) && ip == "" } +// Parses the HTTP status code from a log line. func extractStatusCodeFromLine(line string) int { if match := httpQuotedStatusPattern.FindStringSubmatch(line); len(match) == 2 { if code, err := strconv.Atoi(match[1]); err == nil { @@ -3103,19 +2999,13 @@ func extractStatusCodeFromLine(line string) int { return 0 } -// ******************************************************************* -// * sendBanAlert Function : * -// ******************************************************************* +// Composes and sends the ban notification email. func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, settings config.AppSettings) error { lang := settings.Language if lang == "" { lang = "en" } - - // Check if LOTR mode is active for subject line isLOTRMode := isLOTRModeActive(settings.AlertCountries) - - // Get translations var subject string if isLOTRMode { subject = fmt.Sprintf("[Middle-earth] %s: %s %s %s", @@ -3130,12 +3020,9 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set getEmailTranslation(lang, "email.ban.subject.from"), hostname) } - - // Determine email style and LOTR mode emailStyle := getEmailStyle() isModern := emailStyle == "modern" - // Get translations - use LOTR translations if in LOTR mode var title, intro, whoisTitle, logsTitle, footerText string if isLOTRMode { title = getEmailTranslation(lang, "lotr.email.title") @@ -3152,10 +3039,8 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set } supportEmail := "support@swissmakers.ch" - // Format details with LOTR terminology if in LOTR mode var details []emailDetail if isLOTRMode { - // Transform labels to LOTR terminology bannedIPLabel := getEmailTranslation(lang, "lotr.email.details.dark_servant_location") jailLabel := getEmailTranslation(lang, "lotr.email.details.realm_protection") countryLabelKey := getEmailTranslation(lang, "lotr.email.details.origins") @@ -3191,7 +3076,6 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set var body string if isLOTRMode { - // Use LOTR-themed email template body = buildLOTREmailBody(title, intro, details, whoisHTML, logsHTML, whoisTitle, logsTitle, footerText) } else if isModern { body = buildModernEmailBody(title, intro, details, whoisHTML, logsHTML, whoisTitle, logsTitle, footerText) @@ -3202,18 +3086,13 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set return sendEmail(settings.Destemail, subject, body, settings) } -// ******************************************************************* -// * sendUnbanAlert Function : * -// ******************************************************************* +// Composes and sends the unban notification email. func sendUnbanAlert(ip, jail, hostname, whois, country string, settings config.AppSettings) error { lang := settings.Language if lang == "" { lang = "en" } - isLOTRMode := isLOTRModeActive(settings.AlertCountries) - - // Get translations var subject string if isLOTRMode { subject = fmt.Sprintf("[Middle-earth] %s: %s %s %s", @@ -3228,12 +3107,9 @@ func sendUnbanAlert(ip, jail, hostname, whois, country string, settings config.A getEmailTranslation(lang, "email.unban.subject.from"), hostname) } - - // Determine email style and LOTR mode emailStyle := getEmailStyle() isModern := emailStyle == "modern" - // Get translations - use LOTR translations if in LOTR mode var title, intro, whoisTitle, footerText string if isLOTRMode { title = getEmailTranslation(lang, "lotr.email.unban.title") @@ -3247,8 +3123,6 @@ func sendUnbanAlert(ip, jail, hostname, whois, country string, settings config.A footerText = getEmailTranslation(lang, "email.footer.text") } supportEmail := "support@swissmakers.ch" - - // Format details - use shared keys for common fields, LOTR-specific only for restored_ip var details []emailDetail if isLOTRMode { details = []emailDetail{ @@ -3272,20 +3146,16 @@ func sendUnbanAlert(ip, jail, hostname, whois, country string, settings config.A var body string if isLOTRMode { - // Use LOTR-themed email template body = buildLOTREmailBody(title, intro, details, whoisHTML, "", whoisTitle, "", footerText) } else if isModern { body = buildModernEmailBody(title, intro, details, whoisHTML, "", whoisTitle, "", footerText) } else { body = buildClassicEmailBody(title, intro, details, whoisHTML, "", whoisTitle, "", footerText, supportEmail) } - return sendEmail(settings.Destemail, subject, body, settings) } -// ******************************************************************* -// * TestEmailHandler to send test-mail : * -// ******************************************************************* +// Sends a test email to verify the SMTP configuration. func TestEmailHandler(c *gin.Context) { settings := config.GetSettings() @@ -3293,8 +3163,6 @@ func TestEmailHandler(c *gin.Context) { if lang == "" { lang = "en" } - - // Get translations testDetails := []emailDetail{ {Label: getEmailTranslation(lang, "email.test.details.recipient"), Value: settings.Destemail}, {Label: getEmailTranslation(lang, "email.test.details.smtp_host"), Value: settings.SMTP.Host}, @@ -3308,8 +3176,6 @@ func TestEmailHandler(c *gin.Context) { footerText := getEmailTranslation(lang, "email.footer.text") whoisNoData := getEmailTranslation(lang, "email.test.whois_no_data") supportEmail := "support@swissmakers.ch" - - // Determine email style emailStyle := getEmailStyle() isModern := emailStyle == "modern" @@ -3336,35 +3202,25 @@ func TestEmailHandler(c *gin.Context) { testBody, settings, ) - if err != nil { log.Printf("❌ Test email failed: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test email: " + err.Error()}) return } - log.Println("✅ Test email sent successfully!") c.JSON(http.StatusOK, gin.H{"message": "Test email sent successfully!"}) } -// ******************************************************************* -// * SMTP Authentication Methods : * -// ******************************************************************* - -// getSMTPAuth returns the appropriate SMTP authentication mechanism -// based on the authMethod parameter: "auto", "login", "plain", "cram-md5" +// Returns the SMTP auth mechanism based on authMethod ("auto", "login", "plain", "cram-md5"). func getSMTPAuth(username, password, authMethod, host string) (smtp.Auth, error) { if username == "" || password == "" { - return nil, nil // No auth if credentials are empty + return nil, nil } - - // Normalize auth method authMethod = strings.ToLower(strings.TrimSpace(authMethod)) if authMethod == "" || authMethod == "auto" { - // Auto-detect: prefer LOGIN for Office365/Gmail, fallback to PLAIN + // Auto-detect: prefers LOGIN for Office365/Gmail, falls back to PLAIN (default) authMethod = "login" } - switch authMethod { case "login": return LoginAuth(username, password), nil @@ -3377,8 +3233,7 @@ func getSMTPAuth(username, password, authMethod, host string) (smtp.Auth, error) } } -// LoginAuth implements the LOGIN authentication mechanism -// Used by Office365, Gmail, and other providers that require LOGIN instead of PLAIN +// Implements the LOGIN authentication mechanism used by Office365, Gmail, and other providers that require LOGIN instead of PLAIN type loginAuth struct { username, password string } @@ -3405,26 +3260,19 @@ func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { return nil, nil } -// ******************************************************************* -// * OIDC Authentication Handlers * -// ******************************************************************* +// ========================================================================= +// Auth Handlers +// ========================================================================= -// LoginHandler shows the login page or initiates the OIDC login flow -// If action=redirect query parameter is present, redirects to OIDC provider -// If OIDC_SKIP_LOGINPAGE is true, redirects directly to OIDC provider -// Otherwise, renders the login page +// Initiates the OIDC login flow or renders the login page. func LoginHandler(c *gin.Context) { oidcClient := auth.GetOIDCClient() if oidcClient == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC authentication is not configured"}) return } - - // Check if skip login page is enabled oidcConfig := auth.GetConfig() if oidcConfig != nil && oidcConfig.SkipLoginPage { - // Skip login page - redirect directly to OIDC provider - // Generate state parameter for CSRF protection stateBytes := make([]byte, 32) if _, err := rand.Read(stateBytes); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate state parameter"}) @@ -3432,31 +3280,30 @@ func LoginHandler(c *gin.Context) { } state := base64.URLEncoding.EncodeToString(stateBytes) - // Determine if we're using HTTPS + // Determine if we're using HTTPS (if not, the state cookie is not secure) isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" - // Store state in session cookie for validation + // Stores the state in a session cookie for validation stateCookie := &http.Cookie{ Name: "oidc_state", Value: state, Path: "/", - MaxAge: 600, // 10 minutes + MaxAge: 600, HttpOnly: true, - Secure: isSecure, // Only secure over HTTPS + Secure: isSecure, SameSite: http.SameSiteLaxMode, } http.SetCookie(c.Writer, stateCookie) config.DebugLog("Set state cookie: %s (Secure: %v)", state, isSecure) - // Get authorization URL and redirect + // Gets the authorization URL and redirects to it authURL := oidcClient.GetAuthURL(state) c.Redirect(http.StatusFound, authURL) return } - // Check if this is a redirect action (triggered by clicking the login button) + // Checks if this is a redirect action (triggered by clicking the login button) if c.Query("action") == "redirect" { - // Generate state parameter for CSRF protection stateBytes := make([]byte, 32) if _, err := rand.Read(stateBytes); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate state parameter"}) @@ -3464,17 +3311,17 @@ func LoginHandler(c *gin.Context) { } state := base64.URLEncoding.EncodeToString(stateBytes) - // Determine if we're using HTTPS + // Determines if we're using HTTPS (if not, the state cookie is not secure) isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" - // Store state in session cookie for validation + // Stores the state in a session cookie for validation stateCookie := &http.Cookie{ Name: "oidc_state", Value: state, Path: "/", - MaxAge: 600, // 10 minutes + MaxAge: 600, HttpOnly: true, - Secure: isSecure, // Only secure over HTTPS + Secure: isSecure, SameSite: http.SameSiteLaxMode, } http.SetCookie(c.Writer, stateCookie) @@ -3485,21 +3332,16 @@ func LoginHandler(c *gin.Context) { c.Redirect(http.StatusFound, authURL) return } - - // Otherwise, render the login page (index.html) - // The JavaScript will handle showing the login page and redirecting when button is clicked renderIndexPage(c) } -// CallbackHandler handles the OIDC callback after user authentication +// Handles the OIDC callback, exchanging the code for a session. func CallbackHandler(c *gin.Context) { oidcClient := auth.GetOIDCClient() if oidcClient == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC authentication is not configured"}) return } - - // Get state from cookie stateCookie, err := c.Cookie("oidc_state") if err != nil { config.DebugLog("Failed to get state cookie: %v", err) @@ -3508,29 +3350,21 @@ func CallbackHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "Missing state parameter", "details": err.Error()}) return } - - // Determine if we're using HTTPS isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" - - // Clear state cookie http.SetCookie(c.Writer, &http.Cookie{ Name: "oidc_state", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, - Secure: isSecure, // Only secure over HTTPS + Secure: isSecure, SameSite: http.SameSiteLaxMode, }) - - // Verify state parameter returnedState := c.Query("state") if returnedState != stateCookie { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid state parameter"}) return } - - // Get authorization code code := c.Query("code") if code == "" { errorDesc := c.Query("error_description") @@ -3541,93 +3375,75 @@ func CallbackHandler(c *gin.Context) { } return } - - // Exchange code for tokens token, err := oidcClient.ExchangeCode(c.Request.Context(), code) if err != nil { config.DebugLog("Failed to exchange code for token: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange authorization code"}) return } - - // Verify token and extract user info userInfo, err := oidcClient.VerifyToken(c.Request.Context(), token) if err != nil { config.DebugLog("Failed to verify token: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify authentication token"}) return } - - // Create session + // Create the session if err := auth.CreateSession(c.Writer, c.Request, userInfo, oidcClient.Config.SessionMaxAge); err != nil { config.DebugLog("Failed to create session: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"}) return } - config.DebugLog("User authenticated: %s (%s)", userInfo.Username, userInfo.Email) - // Redirect to main page c.Redirect(http.StatusFound, "/") } -// LogoutHandler clears the session and optionally redirects to provider logout +// Clears the session and redirects to the OIDC provider logout. func LogoutHandler(c *gin.Context) { oidcClient := auth.GetOIDCClient() - - // Clear session first + // Clears the session auth.DeleteSession(c.Writer, c.Request) - - // If provider logout URL is configured, redirect there - // Auto-construct logout URL for standard OIDC providers if not explicitly set + // If a provider logout URL is configured, redirects there + // Otherwise, auto-constructs the logout URL for standard OIDC providers if oidcClient != nil { logoutURL := oidcClient.Config.LogoutURL if logoutURL == "" && oidcClient.Config.IssuerURL != "" { - // Auto-construct standard OIDC logout URL for Keycloak, Authentik, and Pocket-ID issuerURL := oidcClient.Config.IssuerURL redirectURI := oidcClient.Config.RedirectURL - // Extract base URL from redirect URI for logout redirect (remove /auth/callback) if strings.Contains(redirectURI, "/auth/callback") { redirectURI = strings.TrimSuffix(redirectURI, "/auth/callback") } - // Redirect to login page after logout redirectURI = redirectURI + "/auth/login" - // URL encode the redirect_uri parameter redirectURIEncoded := url.QueryEscape(redirectURI) clientIDEncoded := url.QueryEscape(oidcClient.Config.ClientID) - // Provider-specific logout URL construction switch oidcClient.Config.Provider { case "keycloak": // Keycloak requires client_id when using post_logout_redirect_uri // Format: {issuer}/protocol/openid-connect/logout?post_logout_redirect_uri={redirect}&client_id={client_id} logoutURL = fmt.Sprintf("%s/protocol/openid-connect/logout?post_logout_redirect_uri=%s&client_id=%s", issuerURL, redirectURIEncoded, clientIDEncoded) case "pocketid": - // Pocket-ID uses a different logout endpoint + // Pocket-ID uses a different logout endpoint (https://pocket-id.io/docs/oidc/#end-session) // Format: {issuer}/api/oidc/end-session?redirect_uri={redirect} logoutURL = fmt.Sprintf("%s/api/oidc/end-session?redirect_uri=%s", issuerURL, redirectURIEncoded) case "authentik": - // Standard OIDC format for Authentik + // OIDC format for Authentik (https://docs.goauthentik.io/docs/providers/oidc/#logout) // Format: {issuer}/protocol/openid-connect/logout?redirect_uri={redirect} logoutURL = fmt.Sprintf("%s/protocol/openid-connect/logout?redirect_uri=%s", issuerURL, redirectURIEncoded) default: - // Fallback to standard OIDC format logoutURL = fmt.Sprintf("%s/protocol/openid-connect/logout?redirect_uri=%s", issuerURL, redirectURIEncoded) } } - if logoutURL != "" { config.DebugLog("Redirecting to provider logout: %s", logoutURL) c.Redirect(http.StatusFound, logoutURL) return } } - - // Otherwise, redirect to login page c.Redirect(http.StatusFound, "/auth/login") } -// AuthStatusHandler returns the current authentication status +// Returns the current authentication status as JSON. func AuthStatusHandler(c *gin.Context) { if !auth.IsEnabled() { c.JSON(http.StatusOK, gin.H{ @@ -3666,7 +3482,7 @@ func AuthStatusHandler(c *gin.Context) { }) } -// UserInfoHandler returns the current user information +// Returns the authenticated user's profile information. func UserInfoHandler(c *gin.Context) { if !auth.IsEnabled() { c.JSON(http.StatusOK, gin.H{"authenticated": false}) diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 95f6c50..bbfb925 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -1,7 +1,7 @@ - - - - - - @@ -80,7 +73,6 @@
--:--:--
- - + - + - + - + - + - + - +