From 2fcc30b1b6893e7fdd7d1a7dd7c5c6ea288d1602 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Tue, 18 Nov 2025 15:02:50 +0100 Subject: [PATCH] First steps to implement a advanced-actions function to block recurring offenders before fail2ban --- internal/config/settings.go | 118 +++++++-- internal/integrations/mikrotik.go | 122 +++++++++ internal/integrations/pfsense.go | 112 +++++++++ internal/integrations/types.go | 61 +++++ internal/locales/de.json | 39 +++ internal/locales/de_ch.json | 39 +++ internal/locales/en.json | 39 +++ internal/locales/es.json | 39 +++ internal/locales/fr.json | 39 +++ internal/locales/it.json | 39 +++ internal/storage/storage.go | 280 +++++++++++++++++---- pkg/web/advanced_actions.go | 111 +++++++++ pkg/web/handlers.go | 68 +++++ pkg/web/routes.go | 2 + pkg/web/templates/index.html | 397 ++++++++++++++++++++++++++---- 15 files changed, 1391 insertions(+), 114 deletions(-) create mode 100644 internal/integrations/mikrotik.go create mode 100644 internal/integrations/pfsense.go create mode 100644 internal/integrations/types.go create mode 100644 pkg/web/advanced_actions.go diff --git a/internal/config/settings.go b/internal/config/settings.go index e98f8a7..64f4b29 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -49,13 +49,14 @@ type SMTPSettings struct { // 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"` + 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"` + AdvancedActions AdvancedActionsConfig `json:"advancedActions"` Servers []Fail2banServer `json:"servers"` @@ -69,6 +70,56 @@ type AppSettings struct { //Sender string `json:"sender"` } +type AdvancedActionsConfig struct { + Enabled bool `json:"enabled"` + Threshold int `json:"threshold"` + Integration string `json:"integration"` + Mikrotik MikrotikIntegrationSettings `json:"mikrotik"` + PfSense PfSenseIntegrationSettings `json:"pfSense"` +} + +type MikrotikIntegrationSettings struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + SSHKeyPath string `json:"sshKeyPath"` + AddressList string `json:"addressList"` +} + +type PfSenseIntegrationSettings struct { + BaseURL string `json:"baseUrl"` + APIToken string `json:"apiToken"` + APISecret string `json:"apiSecret"` + Alias string `json:"alias"` + SkipTLSVerify bool `json:"skipTLSVerify"` +} + +func defaultAdvancedActionsConfig() AdvancedActionsConfig { + return AdvancedActionsConfig{ + Enabled: false, + Threshold: 5, + Integration: "", + Mikrotik: MikrotikIntegrationSettings{ + Port: 22, + AddressList: "fail2ban-permanent", + }, + } +} + +func normalizeAdvancedActionsConfig(cfg AdvancedActionsConfig) AdvancedActionsConfig { + if cfg.Threshold <= 0 { + cfg.Threshold = 5 + } + if cfg.Mikrotik.Port <= 0 { + cfg.Mikrotik.Port = 22 + } + if cfg.Mikrotik.AddressList == "" { + cfg.Mikrotik.AddressList = "fail2ban-permanent" + } + return cfg +} + // init paths to key-files const ( settingsFile = "fail2ban-ui-settings.json" // this file is created, relatively to where the app was started @@ -301,6 +352,12 @@ func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) { currentSettings.AlertCountries = countries } } + if rec.AdvancedActionsJSON != "" { + var adv AdvancedActionsConfig + if err := json.Unmarshal([]byte(rec.AdvancedActionsJSON), &adv); err == nil { + currentSettings.AdvancedActions = adv + } + } } func applyServerRecordsLocked(records []storage.ServerRecord) { @@ -346,25 +403,31 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) { return storage.AppSettingsRecord{}, err } + advancedBytes, err := json.Marshal(currentSettings.AdvancedActions) + if err != nil { + return storage.AppSettingsRecord{}, err + } + return storage.AppSettingsRecord{ - Language: currentSettings.Language, - Port: currentSettings.Port, - Debug: currentSettings.Debug, - CallbackURL: currentSettings.CallbackURL, - RestartNeeded: currentSettings.RestartNeeded, - AlertCountriesJSON: string(countryBytes), - SMTPHost: currentSettings.SMTP.Host, - SMTPPort: currentSettings.SMTP.Port, - SMTPUsername: currentSettings.SMTP.Username, - SMTPPassword: currentSettings.SMTP.Password, - SMTPFrom: currentSettings.SMTP.From, - SMTPUseTLS: currentSettings.SMTP.UseTLS, - BantimeIncrement: currentSettings.BantimeIncrement, - IgnoreIP: currentSettings.IgnoreIP, - Bantime: currentSettings.Bantime, - Findtime: currentSettings.Findtime, - MaxRetry: currentSettings.Maxretry, - DestEmail: currentSettings.Destemail, + Language: currentSettings.Language, + Port: currentSettings.Port, + Debug: currentSettings.Debug, + CallbackURL: currentSettings.CallbackURL, + RestartNeeded: currentSettings.RestartNeeded, + AlertCountriesJSON: string(countryBytes), + SMTPHost: currentSettings.SMTP.Host, + SMTPPort: currentSettings.SMTP.Port, + SMTPUsername: currentSettings.SMTP.Username, + SMTPPassword: currentSettings.SMTP.Password, + SMTPFrom: currentSettings.SMTP.From, + SMTPUseTLS: currentSettings.SMTP.UseTLS, + BantimeIncrement: currentSettings.BantimeIncrement, + IgnoreIP: currentSettings.IgnoreIP, + Bantime: currentSettings.Bantime, + Findtime: currentSettings.Findtime, + MaxRetry: currentSettings.Maxretry, + DestEmail: currentSettings.Destemail, + AdvancedActionsJSON: string(advancedBytes), }, nil } @@ -465,6 +528,11 @@ func setDefaultsLocked() { currentSettings.IgnoreIP = "127.0.0.1/8 ::1" } + if (currentSettings.AdvancedActions == AdvancedActionsConfig{}) { + currentSettings.AdvancedActions = defaultAdvancedActionsConfig() + } + currentSettings.AdvancedActions = normalizeAdvancedActionsConfig(currentSettings.AdvancedActions) + normalizeServersLocked() } diff --git a/internal/integrations/mikrotik.go b/internal/integrations/mikrotik.go new file mode 100644 index 0000000..bfede1b --- /dev/null +++ b/internal/integrations/mikrotik.go @@ -0,0 +1,122 @@ +package integrations + +import ( + "fmt" + "net" + "os" + "time" + + "golang.org/x/crypto/ssh" + + "github.com/swissmakers/fail2ban-ui/internal/config" +) + +type mikrotikIntegration struct{} + +func init() { + Register(&mikrotikIntegration{}) +} + +func (m *mikrotikIntegration) ID() string { + return "mikrotik" +} + +func (m *mikrotikIntegration) DisplayName() string { + return "Mikrotik RouterOS" +} + +func (m *mikrotikIntegration) Validate(cfg config.AdvancedActionsConfig) error { + if cfg.Mikrotik.Host == "" { + return fmt.Errorf("mikrotik host is required") + } + if cfg.Mikrotik.Username == "" { + return fmt.Errorf("mikrotik username is required") + } + if cfg.Mikrotik.Password == "" && cfg.Mikrotik.SSHKeyPath == "" { + return fmt.Errorf("mikrotik password or SSH key path is required") + } + if cfg.Mikrotik.AddressList == "" { + return fmt.Errorf("mikrotik address list is required") + } + return nil +} + +func (m *mikrotikIntegration) BlockIP(req Request) error { + if err := m.Validate(req.Config); err != nil { + return err + } + cmd := fmt.Sprintf(`/ip firewall address-list add list=%s address=%s comment="Fail2ban-UI permanent block"`, + req.Config.Mikrotik.AddressList, req.IP) + return m.runCommand(req, cmd) +} + +func (m *mikrotikIntegration) UnblockIP(req Request) error { + if err := m.Validate(req.Config); err != nil { + return err + } + cmd := fmt.Sprintf(`/ip firewall address-list remove [/ip firewall address-list find address=%s list=%s]`, + req.IP, req.Config.Mikrotik.AddressList) + return m.runCommand(req, cmd) +} + +func (m *mikrotikIntegration) runCommand(req Request, command string) error { + cfg := req.Config.Mikrotik + + authMethods := []ssh.AuthMethod{} + if cfg.Password != "" { + authMethods = append(authMethods, ssh.Password(cfg.Password)) + } + if cfg.SSHKeyPath != "" { + key, err := os.ReadFile(cfg.SSHKeyPath) + if err != nil { + return fmt.Errorf("failed to read mikrotik ssh key: %w", err) + } + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + return fmt.Errorf("failed to parse mikrotik ssh key: %w", err) + } + authMethods = append(authMethods, ssh.PublicKeys(signer)) + } + + if len(authMethods) == 0 { + return fmt.Errorf("no authentication method available for mikrotik") + } + + port := cfg.Port + if port == 0 { + port = 22 + } + + clientCfg := &ssh.ClientConfig{ + User: cfg.Username, + Auth: authMethods, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + + address := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", port)) + client, err := ssh.Dial("tcp", address, clientCfg) + if err != nil { + return fmt.Errorf("failed to connect to mikrotik: %w", err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("failed to create mikrotik ssh session: %w", err) + } + defer session.Close() + + if req.Logger != nil { + req.Logger("Running Mikrotik command: %s", command) + } + + output, err := session.CombinedOutput(command) + if err != nil { + return fmt.Errorf("mikrotik command failed: %w (output: %s)", err, string(output)) + } + if req.Logger != nil { + req.Logger("Mikrotik command output: %s", string(output)) + } + return nil +} diff --git a/internal/integrations/pfsense.go b/internal/integrations/pfsense.go new file mode 100644 index 0000000..3a84327 --- /dev/null +++ b/internal/integrations/pfsense.go @@ -0,0 +1,112 @@ +package integrations + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/swissmakers/fail2ban-ui/internal/config" +) + +type pfSenseIntegration struct{} + +func init() { + Register(&pfSenseIntegration{}) +} + +func (p *pfSenseIntegration) ID() string { + return "pfsense" +} + +func (p *pfSenseIntegration) DisplayName() string { + return "pfSense" +} + +func (p *pfSenseIntegration) Validate(cfg config.AdvancedActionsConfig) error { + if cfg.PfSense.BaseURL == "" { + return fmt.Errorf("pfSense base URL is required") + } + if cfg.PfSense.APIToken == "" || cfg.PfSense.APISecret == "" { + return fmt.Errorf("pfSense API token and secret are required") + } + if cfg.PfSense.Alias == "" { + return fmt.Errorf("pfSense alias is required") + } + return nil +} + +func (p *pfSenseIntegration) BlockIP(req Request) error { + if err := p.Validate(req.Config); err != nil { + return err + } + payload := map[string]any{ + "alias": req.Config.PfSense.Alias, + "ip": req.IP, + "descr": "Fail2ban-UI permanent block", + } + return p.callAPI(req, "add", payload) +} + +func (p *pfSenseIntegration) UnblockIP(req Request) error { + if err := p.Validate(req.Config); err != nil { + return err + } + payload := map[string]any{ + "alias": req.Config.PfSense.Alias, + "ip": req.IP, + } + return p.callAPI(req, "delete", payload) +} + +func (p *pfSenseIntegration) callAPI(req Request, action string, payload map[string]any) error { + cfg := req.Config.PfSense + payload["action"] = action + + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to encode pfSense payload: %w", err) + } + + apiURL := strings.TrimSuffix(cfg.BaseURL, "/") + "/api/v1/firewall/alias/ip" + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + if cfg.SkipTLSVerify { + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 - user controlled + } + } + + reqLogger := "pfSense" + if req.Logger != nil { + req.Logger("Calling pfSense API %s action=%s payload=%s", apiURL, action, string(data)) + } + + httpReq, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to create pfSense request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("X-API-Key", cfg.APIToken) + httpReq.Header.Set("X-API-Secret", cfg.APISecret) + + resp, err := httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("pfSense request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return fmt.Errorf("pfSense request failed: status %s", resp.Status) + } + + if req.Logger != nil { + req.Logger("%s API call succeeded", reqLogger) + } + return nil +} diff --git a/internal/integrations/types.go b/internal/integrations/types.go new file mode 100644 index 0000000..5fd8635 --- /dev/null +++ b/internal/integrations/types.go @@ -0,0 +1,61 @@ +package integrations + +import ( + "context" + "fmt" + + "github.com/swissmakers/fail2ban-ui/internal/config" +) + +// Request represents a block/unblock request for an integration plugin. +type Request struct { + Context context.Context + IP string + Config config.AdvancedActionsConfig + Server config.Fail2banServer + + Logger func(format string, args ...interface{}) +} + +// Integration exposes functionality required by an external firewall vendor. +type Integration interface { + ID() string + DisplayName() string + BlockIP(req Request) error + UnblockIP(req Request) error + Validate(cfg config.AdvancedActionsConfig) error +} + +var registry = map[string]Integration{} + +// Register adds an integration to the global registry. +func Register(integration Integration) { + if integration == nil { + return + } + registry[integration.ID()] = integration +} + +// Get returns the integration by id. +func Get(id string) (Integration, bool) { + integration, ok := registry[id] + return integration, ok +} + +// MustGet obtains the integration or panics – used during init. +func MustGet(id string) Integration { + integration, ok := Get(id) + if !ok { + panic(fmt.Sprintf("integration %s not registered", id)) + } + return integration +} + +// Supported returns ids of all registered integrations. +func Supported() []string { + keys := make([]string, 0, len(registry)) + for id := range registry { + keys = append(keys, id) + } + return keys +} diff --git a/internal/locales/de.json b/internal/locales/de.json index 3abfdc3..93c89c7 100644 --- a/internal/locales/de.json +++ b/internal/locales/de.json @@ -118,6 +118,45 @@ "settings.default_max_retry_placeholder": "Geben Sie die maximale Anzahl der Versuche ein", "settings.ignore_ips": "IP-Adressen ignorieren", "settings.ignore_ips_placeholder": "IP-Adressen, getrennt durch Leerzeichen", + "settings.advanced.title": "Erweiterte Aktionen für Wiederholungstäter", + "settings.advanced.description": "Synchronisiere wiederholte Angreifer automatisch mit einer externen Firewall oder Blockliste.", + "settings.advanced.refresh_log": "Protokoll aktualisieren", + "settings.advanced.test_button": "Integration testen", + "settings.advanced.enable": "Automatische permanente Sperre aktivieren", + "settings.advanced.threshold": "Schwelle vor permanenter Sperre", + "settings.advanced.threshold_hint": "Sobald eine IP diesen Wert erreicht, wird sie an die Integration übergeben.", + "settings.advanced.integration": "Integration", + "settings.advanced.integration_none": "Integration auswählen", + "settings.advanced.integration_hint": "Wähle die Firewall oder Appliance, in der permanente Sperren erstellt werden sollen.", + "settings.advanced.mikrotik.note": "SSH-Zugang zum Mikrotik-Router und die Address-Liste angeben, in die IPs eingetragen werden.", + "settings.advanced.mikrotik.host": "Host", + "settings.advanced.mikrotik.port": "Port", + "settings.advanced.mikrotik.username": "SSH-Benutzername", + "settings.advanced.mikrotik.password": "SSH-Passwort", + "settings.advanced.mikrotik.key": "SSH-Key-Pfad (optional)", + "settings.advanced.mikrotik.list": "Address-Listenname", + "settings.advanced.pfsense.note": "Benötigt das pfSense API-Paket. Verwende ein Token mit Alias-Rechten.", + "settings.advanced.pfsense.base_url": "Basis-URL", + "settings.advanced.pfsense.token": "API-Token", + "settings.advanced.pfsense.secret": "API-Secret", + "settings.advanced.pfsense.alias": "Alias-Name", + "settings.advanced.pfsense.skip_tls": "TLS-Validierung überspringen (Self-Signed)", + "settings.advanced.log_title": "Log der permanenten Sperren", + "settings.advanced.log_empty": "Noch keine permanenten Sperren vorhanden.", + "settings.advanced.log_ip": "IP", + "settings.advanced.log_integration": "Integration", + "settings.advanced.log_status": "Status", + "settings.advanced.log_message": "Nachricht", + "settings.advanced.log_server": "Server", + "settings.advanced.log_updated": "Aktualisiert", + "settings.advanced.log_actions": "Aktionen", + "settings.advanced.unblock_btn": "Entfernen", + "settings.advanced.test_title": "Integration testen", + "settings.advanced.test_ip": "IP-Adresse", + "settings.advanced.test_server": "Optionaler Server", + "settings.advanced.test_server_none": "Globale Integration verwenden", + "settings.advanced.test_block": "IP sperren", + "settings.advanced.test_unblock": "IP entfernen", "settings.save": "Speichern", "modal.filter_config": "Filter-Konfiguration:", "modal.filter_config_edit": "Filter bearbeiten", diff --git a/internal/locales/de_ch.json b/internal/locales/de_ch.json index 31c6344..0a99abb 100644 --- a/internal/locales/de_ch.json +++ b/internal/locales/de_ch.json @@ -118,6 +118,45 @@ "settings.default_max_retry_placeholder": "Gib d'maximal Versüech ii", "settings.ignore_ips": "IPs ignorierä", "settings.ignore_ips_placeholder": "IPs, getrennt dur e Leerzeichä", + "settings.advanced.title": "Erwieterti Aktione für Wiederholungstäter", + "settings.advanced.description": "Synchronisiere wiederholti Täters automatisch mit ere externe Firewall oder Sperrlischt.", + "settings.advanced.refresh_log": "Log aktualisiere", + "settings.advanced.test_button": "Integration teste", + "settings.advanced.enable": "Automatischi permanente Sperri aktiviere", + "settings.advanced.threshold": "Schwelle vor de permanente Sperri", + "settings.advanced.threshold_hint": "Sobald e IP die Zah erreitcht, wird sie a d Integration übergeh.", + "settings.advanced.integration": "Integration", + "settings.advanced.integration_none": "Integration uswähle", + "settings.advanced.integration_hint": "Wähl d Firewall oder Appliance, wo d permanente Sperre sött ahlegt werde.", + "settings.advanced.mikrotik.note": "Git d'SSH-Zuegriff uf din Mikrotik-Router a und d Address-Lischt, wo d'Sperre ine chöme.", + "settings.advanced.mikrotik.host": "Host", + "settings.advanced.mikrotik.port": "Port", + "settings.advanced.mikrotik.username": "SSH-Benutzername", + "settings.advanced.mikrotik.password": "SSH-Passwort", + "settings.advanced.mikrotik.key": "SSH-Key-Pfad (optional)", + "settings.advanced.mikrotik.list": "Adress-Lischtname", + "settings.advanced.pfsense.note": "Bruucht s pfSense API-Päckli. Nimm es Token wo Aliase cha bearbeite.", + "settings.advanced.pfsense.base_url": "Basis-URL", + "settings.advanced.pfsense.token": "API-Token", + "settings.advanced.pfsense.secret": "API-Secret", + "settings.advanced.pfsense.alias": "Alias-Name", + "settings.advanced.pfsense.skip_tls": "TLS-Prüfig überspringe (Self-Signed)", + "settings.advanced.log_title": "Log vo de permanente Sperre", + "settings.advanced.log_empty": "No kei permanente Sperre erfasst.", + "settings.advanced.log_ip": "IP", + "settings.advanced.log_integration": "Integration", + "settings.advanced.log_status": "Status", + "settings.advanced.log_message": "Meldig", + "settings.advanced.log_server": "Server", + "settings.advanced.log_updated": "Aktualisiert", + "settings.advanced.log_actions": "Aktione", + "settings.advanced.unblock_btn": "Entferne", + "settings.advanced.test_title": "Integration teste", + "settings.advanced.test_ip": "IP-Adrässe", + "settings.advanced.test_server": "Optionaler Server", + "settings.advanced.test_server_none": "Globali Integration bruuchä", + "settings.advanced.test_block": "IP sperre", + "settings.advanced.test_unblock": "IP entferne", "settings.save": "Speicherä", "modal.filter_config": "Filter-Konfiguration:", "modal.filter_config_edit": "Filter bearbeite", diff --git a/internal/locales/en.json b/internal/locales/en.json index e964fef..db16bf0 100644 --- a/internal/locales/en.json +++ b/internal/locales/en.json @@ -118,6 +118,45 @@ "settings.default_max_retry_placeholder": "Enter maximum retries", "settings.ignore_ips": "Ignore IPs", "settings.ignore_ips_placeholder": "IPs to ignore, separated by spaces", + "settings.advanced.title": "Advanced Actions for Recurring Offenders", + "settings.advanced.description": "Automatically synchronize recurring offenders to an external firewall or blocklist.", + "settings.advanced.refresh_log": "Refresh Log", + "settings.advanced.test_button": "Test Integration", + "settings.advanced.enable": "Enable automatic permanent blocking", + "settings.advanced.threshold": "Threshold before permanent block", + "settings.advanced.threshold_hint": "Once an IP reaches this number of bans it will be forwarded to the integration.", + "settings.advanced.integration": "Integration", + "settings.advanced.integration_none": "Select integration", + "settings.advanced.integration_hint": "Choose the firewall or appliance where permanent bans should be created.", + "settings.advanced.mikrotik.note": "Provide SSH access to your Mikrotik router and the address list that should contain blocked IPs.", + "settings.advanced.mikrotik.host": "Host", + "settings.advanced.mikrotik.port": "Port", + "settings.advanced.mikrotik.username": "SSH Username", + "settings.advanced.mikrotik.password": "SSH Password", + "settings.advanced.mikrotik.key": "SSH Key Path (optional)", + "settings.advanced.mikrotik.list": "Address List Name", + "settings.advanced.pfsense.note": "Requires the pfSense API package. Use an API token that may edit aliases.", + "settings.advanced.pfsense.base_url": "Base URL", + "settings.advanced.pfsense.token": "API Token", + "settings.advanced.pfsense.secret": "API Secret", + "settings.advanced.pfsense.alias": "Alias Name", + "settings.advanced.pfsense.skip_tls": "Skip TLS verification (self-signed)", + "settings.advanced.log_title": "Permanent Block Log", + "settings.advanced.log_empty": "No permanent blocks recorded yet.", + "settings.advanced.log_ip": "IP", + "settings.advanced.log_integration": "Integration", + "settings.advanced.log_status": "Status", + "settings.advanced.log_message": "Message", + "settings.advanced.log_server": "Server", + "settings.advanced.log_updated": "Updated", + "settings.advanced.log_actions": "Actions", + "settings.advanced.unblock_btn": "Remove", + "settings.advanced.test_title": "Test Advanced Integration", + "settings.advanced.test_ip": "IP address", + "settings.advanced.test_server": "Optional server", + "settings.advanced.test_server_none": "Use global integration settings", + "settings.advanced.test_block": "Block IP", + "settings.advanced.test_unblock": "Remove IP", "settings.save": "Save", "modal.filter_config": "Filter Config:", "modal.filter_config_edit": "Edit Filter", diff --git a/internal/locales/es.json b/internal/locales/es.json index a490aca..90f7fce 100644 --- a/internal/locales/es.json +++ b/internal/locales/es.json @@ -118,6 +118,45 @@ "settings.default_max_retry_placeholder": "Introduce el número máximo de reintentos", "settings.ignore_ips": "Ignorar IPs", "settings.ignore_ips_placeholder": "IPs a ignorar, separadas por espacios", + "settings.advanced.title": "Acciones avanzadas para reincidentes", + "settings.advanced.description": "Añade automáticamente IPs reincidentes a un firewall o lista de bloqueo externa.", + "settings.advanced.refresh_log": "Actualizar registro", + "settings.advanced.test_button": "Probar integración", + "settings.advanced.enable": "Habilitar bloqueo permanente automático", + "settings.advanced.threshold": "Umbral antes del bloqueo permanente", + "settings.advanced.threshold_hint": "Cuando una IP alcanza este número de bloqueos se enviará a la integración.", + "settings.advanced.integration": "Integración", + "settings.advanced.integration_none": "Selecciona una integración", + "settings.advanced.integration_hint": "Elige el firewall o dispositivo donde crear los bloqueos permanentes.", + "settings.advanced.mikrotik.note": "Proporciona acceso SSH al router Mikrotik y la lista de direcciones de destino.", + "settings.advanced.mikrotik.host": "Host", + "settings.advanced.mikrotik.port": "Puerto", + "settings.advanced.mikrotik.username": "Usuario SSH", + "settings.advanced.mikrotik.password": "Contraseña SSH", + "settings.advanced.mikrotik.key": "Ruta de la clave SSH (opcional)", + "settings.advanced.mikrotik.list": "Nombre de la lista", + "settings.advanced.pfsense.note": "Requiere el paquete de API de pfSense. Usa un token con acceso a alias.", + "settings.advanced.pfsense.base_url": "URL base", + "settings.advanced.pfsense.token": "Token API", + "settings.advanced.pfsense.secret": "Secreto API", + "settings.advanced.pfsense.alias": "Nombre del alias", + "settings.advanced.pfsense.skip_tls": "Omitir verificación TLS (autofirmado)", + "settings.advanced.log_title": "Registro de bloqueos permanentes", + "settings.advanced.log_empty": "Aún no hay bloqueos permanentes.", + "settings.advanced.log_ip": "IP", + "settings.advanced.log_integration": "Integración", + "settings.advanced.log_status": "Estado", + "settings.advanced.log_message": "Mensaje", + "settings.advanced.log_server": "Servidor", + "settings.advanced.log_updated": "Actualizado", + "settings.advanced.log_actions": "Acciones", + "settings.advanced.unblock_btn": "Eliminar", + "settings.advanced.test_title": "Probar integración avanzada", + "settings.advanced.test_ip": "Dirección IP", + "settings.advanced.test_server": "Servidor opcional", + "settings.advanced.test_server_none": "Usar integración global", + "settings.advanced.test_block": "Bloquear IP", + "settings.advanced.test_unblock": "Eliminar IP", "settings.save": "Guardar", "modal.filter_config": "Configuración del filtro:", "modal.filter_config_edit": "Editar filtro", diff --git a/internal/locales/fr.json b/internal/locales/fr.json index 348c448..df6b0ab 100644 --- a/internal/locales/fr.json +++ b/internal/locales/fr.json @@ -118,6 +118,45 @@ "settings.default_max_retry_placeholder": "Entrez le nombre maximal de réessais", "settings.ignore_ips": "Ignorer les IPs", "settings.ignore_ips_placeholder": "IPs à ignorer, séparées par des espaces", + "settings.advanced.title": "Actions avancées pour récidivistes", + "settings.advanced.description": "Ajoutez automatiquement les récidivistes à un pare-feu ou une liste de blocage externe.", + "settings.advanced.refresh_log": "Actualiser le journal", + "settings.advanced.test_button": "Tester l’intégration", + "settings.advanced.enable": "Activer le blocage permanent automatique", + "settings.advanced.threshold": "Seuil avant blocage permanent", + "settings.advanced.threshold_hint": "Une IP atteignant ce nombre de bans sera envoyée à l’intégration.", + "settings.advanced.integration": "Intégration", + "settings.advanced.integration_none": "Choisir une intégration", + "settings.advanced.integration_hint": "Choisissez le pare-feu ou l’équipement où créer les blocages permanents.", + "settings.advanced.mikrotik.note": "Fournissez les accès SSH au routeur Mikrotik et la liste d’adresses ciblée.", + "settings.advanced.mikrotik.host": "Hôte", + "settings.advanced.mikrotik.port": "Port", + "settings.advanced.mikrotik.username": "Utilisateur SSH", + "settings.advanced.mikrotik.password": "Mot de passe SSH", + "settings.advanced.mikrotik.key": "Chemin de clé SSH (optionnel)", + "settings.advanced.mikrotik.list": "Nom de la liste", + "settings.advanced.pfsense.note": "Nécessite le paquet API pfSense. Utiliser un jeton ayant accès aux alias.", + "settings.advanced.pfsense.base_url": "URL de base", + "settings.advanced.pfsense.token": "Jeton API", + "settings.advanced.pfsense.secret": "Secret API", + "settings.advanced.pfsense.alias": "Nom d’alias", + "settings.advanced.pfsense.skip_tls": "Ignorer la vérification TLS (auto-signé)", + "settings.advanced.log_title": "Journal des blocages permanents", + "settings.advanced.log_empty": "Aucun blocage permanent pour le moment.", + "settings.advanced.log_ip": "IP", + "settings.advanced.log_integration": "Intégration", + "settings.advanced.log_status": "Statut", + "settings.advanced.log_message": "Message", + "settings.advanced.log_server": "Serveur", + "settings.advanced.log_updated": "Mis à jour", + "settings.advanced.log_actions": "Actions", + "settings.advanced.unblock_btn": "Retirer", + "settings.advanced.test_title": "Tester l’intégration avancée", + "settings.advanced.test_ip": "Adresse IP", + "settings.advanced.test_server": "Serveur optionnel", + "settings.advanced.test_server_none": "Utiliser l’intégration globale", + "settings.advanced.test_block": "Bloquer l’IP", + "settings.advanced.test_unblock": "Retirer l’IP", "settings.save": "Enregistrer", "modal.filter_config": "Configuration du filtre:", "modal.filter_config_edit": "Modifier le filtre", diff --git a/internal/locales/it.json b/internal/locales/it.json index 58c5b08..abfa9fa 100644 --- a/internal/locales/it.json +++ b/internal/locales/it.json @@ -118,6 +118,45 @@ "settings.default_max_retry_placeholder": "Inserisci il numero massimo di tentativi", "settings.ignore_ips": "Ignora IP", "settings.ignore_ips_placeholder": "IP da ignorare, separate da spazi", + "settings.advanced.title": "Azioni avanzate per ripetuti offensori", + "settings.advanced.description": "Aggiungi automaticamente gli IP ricorrenti a un firewall o blocklist esterna.", + "settings.advanced.refresh_log": "Aggiorna registro", + "settings.advanced.test_button": "Testa integrazione", + "settings.advanced.enable": "Abilita blocco permanente automatico", + "settings.advanced.threshold": "Soglia prima del blocco permanente", + "settings.advanced.threshold_hint": "Quando un IP raggiunge questa soglia verrà inviato all’integrazione.", + "settings.advanced.integration": "Integrazione", + "settings.advanced.integration_none": "Seleziona integrazione", + "settings.advanced.integration_hint": "Scegli il firewall o dispositivo dove creare i blocchi permanenti.", + "settings.advanced.mikrotik.note": "Fornisci l’accesso SSH al router Mikrotik e la lista indirizzi di destinazione.", + "settings.advanced.mikrotik.host": "Host", + "settings.advanced.mikrotik.port": "Porta", + "settings.advanced.mikrotik.username": "Utente SSH", + "settings.advanced.mikrotik.password": "Password SSH", + "settings.advanced.mikrotik.key": "Percorso chiave SSH (opzionale)", + "settings.advanced.mikrotik.list": "Nome della lista", + "settings.advanced.pfsense.note": "Richiede il pacchetto API di pfSense. Usa un token con accesso agli alias.", + "settings.advanced.pfsense.base_url": "URL base", + "settings.advanced.pfsense.token": "Token API", + "settings.advanced.pfsense.secret": "Segreto API", + "settings.advanced.pfsense.alias": "Nome alias", + "settings.advanced.pfsense.skip_tls": "Ignora verifica TLS (auto-firmato)", + "settings.advanced.log_title": "Registro dei blocchi permanenti", + "settings.advanced.log_empty": "Nessun blocco permanente ancora registrato.", + "settings.advanced.log_ip": "IP", + "settings.advanced.log_integration": "Integrazione", + "settings.advanced.log_status": "Stato", + "settings.advanced.log_message": "Messaggio", + "settings.advanced.log_server": "Server", + "settings.advanced.log_updated": "Aggiornato", + "settings.advanced.log_actions": "Azioni", + "settings.advanced.unblock_btn": "Rimuovi", + "settings.advanced.test_title": "Testa integrazione avanzata", + "settings.advanced.test_ip": "Indirizzo IP", + "settings.advanced.test_server": "Server opzionale", + "settings.advanced.test_server_none": "Usa integrazione globale", + "settings.advanced.test_block": "Blocca IP", + "settings.advanced.test_unblock": "Rimuovi IP", "settings.save": "Salva", "modal.filter_config": "Configurazione del filtro:", "modal.filter_config_edit": "Modifica filtro", diff --git a/internal/storage/storage.go b/internal/storage/storage.go index d8209f7..f164456 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -47,24 +47,25 @@ func intFromNull(ni sql.NullInt64) int { } type AppSettingsRecord struct { - Language string - Port int - Debug bool - CallbackURL string - RestartNeeded bool - AlertCountriesJSON string - SMTPHost string - SMTPPort int - SMTPUsername string - SMTPPassword string - SMTPFrom string - SMTPUseTLS bool - BantimeIncrement bool - IgnoreIP string - Bantime string - Findtime string - MaxRetry int - DestEmail string + Language string + Port int + Debug bool + CallbackURL string + RestartNeeded bool + AlertCountriesJSON string + SMTPHost string + SMTPPort int + SMTPUsername string + SMTPPassword string + SMTPFrom string + SMTPUseTLS bool + BantimeIncrement bool + IgnoreIP string + Bantime string + Findtime string + MaxRetry int + DestEmail string + AdvancedActionsJSON string } type ServerRecord struct { @@ -112,6 +113,18 @@ type RecurringIPStat struct { LastSeen time.Time `json:"lastSeen"` } +type PermanentBlockRecord struct { + ID int64 `json:"id"` + IP string `json:"ip"` + Integration string `json:"integration"` + Status string `json:"status"` + Details string `json:"details"` + Message string `json:"message"` + ServerID string `json:"serverId"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + // Init initializes the internal storage. Safe to call multiple times. func Init(dbPath string) error { initOnce.Do(func() { @@ -154,17 +167,17 @@ func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) { } row := db.QueryRowContext(ctx, ` -SELECT language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, ignore_ip, bantime, findtime, maxretry, destemail +SELECT language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, ignore_ip, bantime, findtime, maxretry, destemail, advanced_actions FROM app_settings WHERE id = 1`) var ( - lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail sql.NullString - port, smtpPort, maxretry sql.NullInt64 - debug, restartNeeded, smtpTLS, bantimeInc sql.NullInt64 + lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, advancedActions sql.NullString + port, smtpPort, maxretry sql.NullInt64 + debug, restartNeeded, smtpTLS, bantimeInc sql.NullInt64 ) - err := row.Scan(&lang, &port, &debug, &callback, &restartNeeded, &alerts, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &ignoreIP, &bantime, &findtime, &maxretry, &destemail) + err := row.Scan(&lang, &port, &debug, &callback, &restartNeeded, &alerts, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &ignoreIP, &bantime, &findtime, &maxretry, &destemail, &advancedActions) if errors.Is(err, sql.ErrNoRows) { return AppSettingsRecord{}, false, nil } @@ -173,24 +186,25 @@ WHERE id = 1`) } rec := AppSettingsRecord{ - Language: stringFromNull(lang), - Port: intFromNull(port), - Debug: intToBool(intFromNull(debug)), - CallbackURL: stringFromNull(callback), - RestartNeeded: intToBool(intFromNull(restartNeeded)), - AlertCountriesJSON: stringFromNull(alerts), - SMTPHost: stringFromNull(smtpHost), - SMTPPort: intFromNull(smtpPort), - SMTPUsername: stringFromNull(smtpUser), - SMTPPassword: stringFromNull(smtpPass), - SMTPFrom: stringFromNull(smtpFrom), - SMTPUseTLS: intToBool(intFromNull(smtpTLS)), - BantimeIncrement: intToBool(intFromNull(bantimeInc)), - IgnoreIP: stringFromNull(ignoreIP), - Bantime: stringFromNull(bantime), - Findtime: stringFromNull(findtime), - MaxRetry: intFromNull(maxretry), - DestEmail: stringFromNull(destemail), + Language: stringFromNull(lang), + Port: intFromNull(port), + Debug: intToBool(intFromNull(debug)), + CallbackURL: stringFromNull(callback), + RestartNeeded: intToBool(intFromNull(restartNeeded)), + AlertCountriesJSON: stringFromNull(alerts), + SMTPHost: stringFromNull(smtpHost), + SMTPPort: intFromNull(smtpPort), + SMTPUsername: stringFromNull(smtpUser), + SMTPPassword: stringFromNull(smtpPass), + SMTPFrom: stringFromNull(smtpFrom), + SMTPUseTLS: intToBool(intFromNull(smtpTLS)), + BantimeIncrement: intToBool(intFromNull(bantimeInc)), + IgnoreIP: stringFromNull(ignoreIP), + Bantime: stringFromNull(bantime), + Findtime: stringFromNull(findtime), + MaxRetry: intFromNull(maxretry), + DestEmail: stringFromNull(destemail), + AdvancedActionsJSON: stringFromNull(advancedActions), } return rec, true, nil @@ -202,9 +216,9 @@ func SaveAppSettings(ctx context.Context, rec AppSettingsRecord) error { } _, err := db.ExecContext(ctx, ` INSERT INTO app_settings ( - id, language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, ignore_ip, bantime, findtime, maxretry, destemail + id, language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, ignore_ip, bantime, findtime, maxretry, destemail, advanced_actions ) VALUES ( - 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) ON CONFLICT(id) DO UPDATE SET language = excluded.language, port = excluded.port, @@ -223,7 +237,8 @@ INSERT INTO app_settings ( bantime = excluded.bantime, findtime = excluded.findtime, maxretry = excluded.maxretry, - destemail = excluded.destemail + destemail = excluded.destemail, + advanced_actions = excluded.advanced_actions `, rec.Language, rec.Port, boolToInt(rec.Debug), @@ -242,6 +257,7 @@ INSERT INTO app_settings ( rec.Findtime, rec.MaxRetry, rec.DestEmail, + rec.AdvancedActionsJSON, ) return err } @@ -566,6 +582,33 @@ WHERE 1=1` return total, nil } +// CountBanEventsByIP 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") + } + if ip == "" { + return 0, errors.New("ip is required") + } + + query := ` +SELECT COUNT(*) +FROM ban_events +WHERE ip = ?` + args := []any{ip} + + if serverID != "" { + query += " AND server_id = ?" + args = append(args, serverID) + } + + var total int64 + if err := db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil { + return 0, err + } + return total, nil +} + // CountBanEventsByCountry 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 { @@ -695,7 +738,8 @@ CREATE TABLE IF NOT EXISTS app_settings ( bantime TEXT, findtime TEXT, maxretry INTEGER, - destemail TEXT + destemail TEXT, + advanced_actions TEXT ); CREATE TABLE IF NOT EXISTS servers ( @@ -736,6 +780,21 @@ CREATE TABLE IF NOT EXISTS ban_events ( CREATE INDEX IF NOT EXISTS idx_ban_events_server_id ON ban_events(server_id); CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at); + +CREATE TABLE IF NOT EXISTS permanent_blocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + integration TEXT NOT NULL, + status TEXT NOT NULL, + details TEXT, + message TEXT, + server_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(ip, integration) +); + +CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status); ` if _, err := db.ExecContext(ctx, createTable); err != nil { @@ -749,6 +808,12 @@ CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at) } } + if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN advanced_actions TEXT`); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + return err + } + } + return nil } @@ -762,3 +827,128 @@ func ensureDirectory(path string) error { } return os.MkdirAll(dir, 0o755) } + +// UpsertPermanentBlock records or updates a permanent block entry. +func UpsertPermanentBlock(ctx context.Context, rec PermanentBlockRecord) error { + if db == nil { + return errors.New("storage not initialised") + } + if rec.IP == "" || rec.Integration == "" { + return errors.New("ip and integration are required") + } + now := time.Now().UTC() + if rec.CreatedAt.IsZero() { + rec.CreatedAt = now + } + rec.UpdatedAt = now + if rec.Status == "" { + rec.Status = "blocked" + } + + const query = ` +INSERT INTO permanent_blocks (ip, integration, status, details, message, server_id, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(ip, integration) DO UPDATE SET + status = excluded.status, + details = excluded.details, + message = excluded.message, + server_id = excluded.server_id, + updated_at = excluded.updated_at` + + _, err := db.ExecContext(ctx, query, + rec.IP, + rec.Integration, + rec.Status, + rec.Details, + rec.Message, + rec.ServerID, + rec.CreatedAt.Format(time.RFC3339Nano), + rec.UpdatedAt.Format(time.RFC3339Nano), + ) + return err +} + +// GetPermanentBlock retrieves 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") + } + if ip == "" || integration == "" { + return PermanentBlockRecord{}, false, errors.New("ip and integration are required") + } + + row := db.QueryRowContext(ctx, ` +SELECT id, ip, integration, status, details, message, server_id, created_at, updated_at +FROM permanent_blocks +WHERE ip = ? AND integration = ?`, ip, integration) + + var rec PermanentBlockRecord + var createdAt, updatedAt sql.NullString + if err := row.Scan(&rec.ID, &rec.IP, &rec.Integration, &rec.Status, &rec.Details, &rec.Message, &rec.ServerID, &createdAt, &updatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return PermanentBlockRecord{}, false, nil + } + return PermanentBlockRecord{}, false, err + } + if createdAt.Valid { + if ts, err := time.Parse(time.RFC3339Nano, createdAt.String); err == nil { + rec.CreatedAt = ts + } + } + if updatedAt.Valid { + if ts, err := time.Parse(time.RFC3339Nano, updatedAt.String); err == nil { + rec.UpdatedAt = ts + } + } + return rec, true, nil +} + +// ListPermanentBlocks returns recent permanent block entries. +func ListPermanentBlocks(ctx context.Context, limit int) ([]PermanentBlockRecord, error) { + if db == nil { + return nil, errors.New("storage not initialised") + } + if limit <= 0 || limit > 500 { + limit = 100 + } + + rows, err := db.QueryContext(ctx, ` +SELECT id, ip, integration, status, details, message, server_id, created_at, updated_at +FROM permanent_blocks +ORDER BY updated_at DESC +LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []PermanentBlockRecord + for rows.Next() { + var rec PermanentBlockRecord + var createdAt, updatedAt sql.NullString + if err := rows.Scan(&rec.ID, &rec.IP, &rec.Integration, &rec.Status, &rec.Details, &rec.Message, &rec.ServerID, &createdAt, &updatedAt); err != nil { + return nil, err + } + if createdAt.Valid { + if ts, err := time.Parse(time.RFC3339Nano, createdAt.String); err == nil { + rec.CreatedAt = ts + } + } + if updatedAt.Valid { + if ts, err := time.Parse(time.RFC3339Nano, updatedAt.String); err == nil { + rec.UpdatedAt = ts + } + } + records = append(records, rec) + } + return records, rows.Err() +} + +// IsPermanentBlockActive 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 { + return false, err + } + return rec.Status == "blocked", nil +} diff --git a/pkg/web/advanced_actions.go b/pkg/web/advanced_actions.go new file mode 100644 index 0000000..b34dda0 --- /dev/null +++ b/pkg/web/advanced_actions.go @@ -0,0 +1,111 @@ +package web + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/swissmakers/fail2ban-ui/internal/config" + "github.com/swissmakers/fail2ban-ui/internal/integrations" + "github.com/swissmakers/fail2ban-ui/internal/storage" +) + +func evaluateAdvancedActions(ctx context.Context, settings config.AppSettings, server config.Fail2banServer, ip string) { + cfg := settings.AdvancedActions + if !cfg.Enabled || cfg.Threshold <= 0 || cfg.Integration == "" { + return + } + + count, err := storage.CountBanEventsByIP(ctx, ip, server.ID) + if err != nil { + log.Printf("⚠️ Failed to count ban events for %s: %v", ip, err) + return + } + if int(count) < cfg.Threshold { + return + } + + active, err := storage.IsPermanentBlockActive(ctx, ip, cfg.Integration) + if err != nil { + log.Printf("⚠️ Failed to check permanent block for %s: %v", ip, err) + return + } + if active { + return + } + + if err := runAdvancedIntegrationAction(ctx, "block", ip, settings, server, map[string]any{ + "reason": "automatic_threshold", + "count": count, + "threshold": cfg.Threshold, + }); err != nil { + log.Printf("⚠️ Failed to permanently block %s: %v", ip, err) + } +} + +func runAdvancedIntegrationAction(ctx context.Context, action, ip string, settings config.AppSettings, server config.Fail2banServer, details map[string]any) error { + cfg := settings.AdvancedActions + if cfg.Integration == "" { + return fmt.Errorf("no integration configured") + } + integration, ok := integrations.Get(cfg.Integration) + if !ok { + return fmt.Errorf("integration %s not registered", cfg.Integration) + } + + logger := func(format string, args ...interface{}) { + if settings.Debug { + log.Printf(format, args...) + } + } + + req := integrations.Request{ + Context: ctx, + IP: ip, + Config: cfg, + Server: server, + Logger: logger, + } + + var err error + switch action { + case "block": + err = integration.BlockIP(req) + case "unblock": + err = integration.UnblockIP(req) + default: + return fmt.Errorf("unsupported action %s", action) + } + + status := map[string]string{ + "block": "blocked", + "unblock": "unblocked", + }[action] + + message := fmt.Sprintf("%s via %s", strings.Title(action), cfg.Integration) + if err != nil { + status = "error" + message = err.Error() + } + + if details == nil { + details = map[string]any{} + } + details["action"] = action + detailsBytes, _ := json.Marshal(details) + rec := storage.PermanentBlockRecord{ + IP: ip, + Integration: cfg.Integration, + Status: status, + Message: message, + ServerID: server.ID, + Details: string(detailsBytes), + } + if err2 := storage.UpsertPermanentBlock(ctx, rec); err2 != nil { + log.Printf("⚠️ Failed to record permanent block entry: %v", err2) + } + + return err +} diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 2b23b72..0d2ea06 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -548,6 +548,8 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip log.Printf("⚠️ Failed to record ban event: %v", err) } + evaluateAdvancedActions(ctx, settings, server, ip) + // Check if country is in alert list displayCountry := country if displayCountry == "" { @@ -692,6 +694,72 @@ func ManageJailsHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"jails": jails}) } +// ListPermanentBlocksHandler exposes the permanent block log. +func ListPermanentBlocksHandler(c *gin.Context) { + limit := 100 + if limitStr := c.DefaultQuery("limit", "100"); limitStr != "" { + if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 { + limit = parsed + } + } + records, err := storage.ListPermanentBlocks(c.Request.Context(), limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"blocks": records}) +} + +// AdvancedActionsTestHandler allows manual block/unblock tests. +func AdvancedActionsTestHandler(c *gin.Context) { + var req struct { + Action string `json:"action"` + IP string `json:"ip"` + ServerID string `json:"serverId"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if req.IP == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"}) + return + } + action := strings.ToLower(req.Action) + if action == "" { + action = "block" + } + if action != "block" && action != "unblock" { + c.JSON(http.StatusBadRequest, gin.H{"error": "action must be block or unblock"}) + return + } + + settings := config.GetSettings() + server := config.Fail2banServer{} + if req.ServerID != "" { + if srv, ok := config.GetServerByID(req.ServerID); ok { + server = srv + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "server not found"}) + return + } + } + + err := runAdvancedIntegrationAction( + c.Request.Context(), + action, + req.IP, + settings, + server, + map[string]any{"manual": true}, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + 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, ... } // After updating, the Fail2ban service is restarted. diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 5a78803..ef4d58e 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -43,6 +43,8 @@ func RegisterRoutes(r *gin.Engine) { api.GET("/settings", GetSettingsHandler) api.POST("/settings", UpdateSettingsHandler) api.POST("/settings/test-email", TestEmailHandler) + api.GET("/advanced-actions/blocks", ListPermanentBlocksHandler) + api.POST("/advanced-actions/test", AdvancedActionsTestHandler) // Fail2ban servers management api.GET("/servers", ListServersHandler) diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 47dc344..234c208 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -357,6 +357,106 @@ + +
+
+
+

Advanced Actions for Recurring Offenders

+

Automatically add recurring offenders to an external firewall once they hit a specific threshold.

+
+
+ + +
+
+ +
+
+ + +
+ +
+ + +

If an IP is banned at least this many times it will be forwarded to the selected firewall integration.

+
+ +
+ + +

Choose where permanent bans should be synchronized.

+
+ + + + +
+ +
+

Permanent Block Log

+
+

No permanent blocks recorded yet.

+
+
+
+

Alert Settings

@@ -664,15 +764,11 @@ -