From eb2f90504aa8968f9ac3cf585712d1a2f4c82460 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Fri, 24 Jul 2026 21:51:46 +0200 Subject: [PATCH 01/25] Remove second (fallback) storage init, this is no more used since the last few changes --- cmd/server/main.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 6539f74..79bd5a0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -43,9 +43,6 @@ func main() { web.SetBasePathFromEnv() auth.SetSessionCookiePath(web.CookiePath()) - if err := storage.Init(""); err != nil { - log.Fatalf("Failed to initialise storage: %v", err) - } defer func() { if err := storage.Close(); err != nil { log.Printf("warning: failed to close storage: %v", err) From e7982063159eb76c6e7bb8f799cd9ef32d13ff3c Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Fri, 24 Jul 2026 22:54:58 +0200 Subject: [PATCH 02/25] Add option to configure custom reverse tunnel port for ssh-connector --- internal/shared/server.go | 1 + internal/storage/storage.go | 14 ++++++++++---- internal/storage/storage_test.go | 1 + pkg/web/locales/ca.json | 7 +++++++ pkg/web/locales/de.json | 7 +++++++ pkg/web/locales/de_ch.json | 7 +++++++ pkg/web/locales/en.json | 7 +++++++ pkg/web/locales/es.json | 7 +++++++ pkg/web/locales/fr.json | 7 +++++++ pkg/web/locales/it.json | 7 +++++++ pkg/web/locales/ja.json | 7 +++++++ pkg/web/locales/zh.json | 7 +++++++ pkg/web/static/js/servers.js | 22 +++++++++++++++++++++- pkg/web/templates/index.html | 11 +++++++++-- 14 files changed, 105 insertions(+), 7 deletions(-) diff --git a/internal/shared/server.go b/internal/shared/server.go index 116e586..8b6df7c 100644 --- a/internal/shared/server.go +++ b/internal/shared/server.go @@ -40,6 +40,7 @@ type Fail2banServer struct { IsDefault bool `json:"isDefault"` Enabled bool `json:"enabled"` ReverseTunnelEnabled bool `json:"reverseTunnelEnabled,omitempty"` + TunnelPort int `json:"tunnelPort,omitempty"` RestartNeeded bool `json:"restartNeeded"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 1791daf..a0df44b 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -236,6 +236,7 @@ type ServerRecord struct { IsDefault bool Enabled bool ReverseTunnelEnabled bool + TunnelPort int NeedsRestart bool CreatedAt time.Time UpdatedAt time.Time @@ -592,7 +593,7 @@ func ListServers(ctx context.Context) ([]ServerRecord, error) { } rows, err := db.QueryContext(ctx, ` -SELECT id, name, type, host, port, socket_path, config_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, reverse_tunnel, needs_restart, created_at, updated_at +SELECT id, name, type, host, port, socket_path, config_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, reverse_tunnel, tunnel_port, needs_restart, created_at, updated_at FROM servers ORDER BY created_at`) if err != nil { @@ -606,7 +607,7 @@ ORDER BY created_at`) var host, socket, configPath, sshUser, sshKey, agentURL, agentSecret, hostname, tags sql.NullString var name, serverType sql.NullString var created, updated sql.NullString - var port sql.NullInt64 + var port, tunnelPort sql.NullInt64 var isDefault, enabled, reverseTunnel, needsRestart sql.NullInt64 if err := rows.Scan( @@ -626,6 +627,7 @@ ORDER BY created_at`) &isDefault, &enabled, &reverseTunnel, + &tunnelPort, &needsRestart, &created, &updated, @@ -648,6 +650,7 @@ ORDER BY created_at`) rec.IsDefault = intToBool(intFromNull(isDefault)) rec.Enabled = intToBool(intFromNull(enabled)) rec.ReverseTunnelEnabled = intToBool(intFromNull(reverseTunnel)) + rec.TunnelPort = intFromNull(tunnelPort) rec.NeedsRestart = intToBool(intFromNull(needsRestart)) if created.Valid { @@ -688,9 +691,9 @@ func ReplaceServers(ctx context.Context, servers []ServerRecord) error { stmt, err := tx.PrepareContext(ctx, ` INSERT INTO servers ( - id, name, type, host, port, socket_path, config_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, reverse_tunnel, needs_restart, created_at, updated_at + id, name, type, host, port, socket_path, config_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, reverse_tunnel, tunnel_port, needs_restart, created_at, updated_at ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )`) if err != nil { return err @@ -723,6 +726,7 @@ INSERT INTO servers ( boolToInt(srv.IsDefault), boolToInt(srv.Enabled), boolToInt(srv.ReverseTunnelEnabled), + srv.TunnelPort, boolToInt(srv.NeedsRestart), createdAt.Format(time.RFC3339Nano), updatedAt.Format(time.RFC3339Nano), @@ -1577,6 +1581,7 @@ CREATE TABLE IF NOT EXISTS servers ( is_default INTEGER, enabled INTEGER, reverse_tunnel INTEGER DEFAULT 0, + tunnel_port INTEGER DEFAULT 0, needs_restart INTEGER DEFAULT 0, created_at TEXT, updated_at TEXT @@ -1643,6 +1648,7 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_updated_at ON permanent_blocks(update `ALTER TABLE servers ADD COLUMN reverse_tunnel INTEGER DEFAULT 0`, `ALTER TABLE app_settings ADD COLUMN event_retention_days INTEGER DEFAULT 180`, `ALTER TABLE ban_events ADD COLUMN event_type TEXT NOT NULL DEFAULT 'ban'`, + `ALTER TABLE servers ADD COLUMN tunnel_port INTEGER DEFAULT 0`, } if _, err := db.ExecContext(ctx, createTables); err != nil { diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 336f469..c6dcad8 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -151,6 +151,7 @@ func TestReplaceServersRoundTrip(t *testing.T) { IsDefault: true, Enabled: true, ReverseTunnelEnabled: true, + TunnelPort: 8443, CreatedAt: time.Date(2026, 5, 27, 14, 30, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 5, 27, 15, 0, 0, 0, time.UTC), } diff --git a/pkg/web/locales/ca.json b/pkg/web/locales/ca.json index 95801d8..36f020a 100644 --- a/pkg/web/locales/ca.json +++ b/pkg/web/locales/ca.json @@ -565,6 +565,11 @@ "servers.form.agent_url_placeholder": "https://amfitrió:9700", "servers.form.agent_secret": "Secret de l'Agent", "servers.form.agent_secret_placeholder": "token secret compartit", + "servers.form.reverse_tunnel": "Activa el túnel invers per als esdeveniments", + "servers.form.reverse_tunnel_help": "Crea automàticament un túnel SSH invers (-R :localhost:) perquè els esdeveniments de callback de fail2ban al servidor remot puguin arribar a aquesta interfície. El fitxer d'acció remot utilitza automàticament http://localhost:, de manera que l'URL de callback global pot continuar sent una adreça pública per als servidors accessibles directament.", + "servers.form.tunnel_port": "Port del túnel", + "servers.form.tunnel_port_placeholder": "buit = port del servidor", + "servers.form.tunnel_port_help": "Port on l'amfitrió remot escolta els callbacks (vinculat pel túnel). Deixeu-ho buit per utilitzar el port del servidor de la UI. Els callbacks entrants es reenvien a través del túnel al port del servidor de la UI. Ha d'estar entre 1024 i 65535: els usuaris remots sense privilegis no poden vincular ports per sota de 1024.", "servers.form.tags": "Etiquetes", "servers.form.tags_placeholder": "etiquetes,separades,per,comes", "servers.form.set_default": "Defineix com a servidor per defecte", @@ -590,6 +595,7 @@ "servers.errors.agent_invalid_url": "L'URL de l'agent no és vàlida.", "servers.errors.agent_unreachable": "No es pot contactar amb el servei de l'agent.", "servers.errors.agent_request_failed": "La petició a l'agent ha fallat.", + "servers.errors.invalid_tunnel_port": "El port del túnel ha d'estar entre 1024 i 65535 (buit = port del servidor).", "servers.jail_local_warning": "Avís: jail.local no està gestionat per Fail2ban-UI. Moveu cada jail al seu propi fitxer a jail.d/ i suprimiu jail.local perquè Fail2ban-UI el pugui tornar a crear (premeu una vegada Desar a la pàgina de configuració per escriure el fitxer). Vegeu la documentació per a temes de permisos.", "servers.actions.restart": "Reinicia Fail2ban", "servers.actions.reload": "Recarrega Fail2ban", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "Ja existeix un connector local amb aquesta ruta de socket.", "servers.validation.duplicate_config_path": "Ja existeix un connector local amb aquesta ruta de configuració.", "servers.validation.agent_required": "Per als servidors API Agent, l'URL de l'agent i el secret de l'agent són obligatoris.", + "servers.validation.tunnel_port_range": "El port del túnel ha d'estar entre 1024 i 65535.", "servers.card.socket_path": "Ruta del socket", "servers.card.config_path": "Ruta de configuració", "servers.card.server_id": "ID del servidor", diff --git a/pkg/web/locales/de.json b/pkg/web/locales/de.json index 3ce5c00..76fd01c 100644 --- a/pkg/web/locales/de.json +++ b/pkg/web/locales/de.json @@ -565,6 +565,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "Agent-Secret", "servers.form.agent_secret_placeholder": "gemeinsames Geheimnis", + "servers.form.reverse_tunnel": "Reverse-Tunnel für Events aktivieren", + "servers.form.reverse_tunnel_help": "Erstellt automatisch einen Reverse-SSH-Tunnel (-R :localhost:), damit Fail2ban-Callback-Events vom Remote-Server diese UI erreichen. Die Action-Datei auf dem Remote-Server verwendet automatisch http://localhost:, die globale Callback-URL kann daher eine öffentliche Adresse für direkt erreichbare Server bleiben.", + "servers.form.tunnel_port": "Tunnel-Port", + "servers.form.tunnel_port_placeholder": "leer = Server-Port", + "servers.form.tunnel_port_help": "Port, auf dem der Remote-Host auf Callbacks lauscht (durch den Tunnel gebunden). Leer lassen, um den Server-Port der UI zu verwenden. Eingehende Callbacks werden durch den Tunnel an den Server-Port der UI weitergeleitet. Muss zwischen 1024 und 65535 liegen: unprivilegierte Benutzer können auf dem Remote-Host keine Ports unter 1024 binden.", "servers.form.tags": "Tags", "servers.form.tags_placeholder": "kommagetrennte Tags", "servers.form.set_default": "Als Standardserver setzen", @@ -590,6 +595,7 @@ "servers.errors.agent_invalid_url": "Agent-URL ist ungültig.", "servers.errors.agent_unreachable": "Agent ist nicht erreichbar.", "servers.errors.agent_request_failed": "Agent-Anfrage ist fehlgeschlagen.", + "servers.errors.invalid_tunnel_port": "Der Tunnel-Port muss zwischen 1024 und 65535 liegen (leer = Server-Port).", "servers.jail_local_warning": "Warnung: jail.local wird nicht von Fail2ban-UI verwaltet. Verschiebe jeden Jail in eine eigene Datei unter jail.d/ und lösche jail.local, damit Fail2ban-UI sie neu erstellen kann (einmal auf der Einstellungen-Seite speichern, um die Datei zu schreiben). Siehe Dokumentation für Berechtigungen.", "servers.actions.restart": "Fail2ban neu starten", "servers.actions.reload": "Fail2ban neu laden", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "Ein lokaler Connector mit diesem Socket-Pfad existiert bereits.", "servers.validation.duplicate_config_path": "Ein lokaler Connector mit diesem Konfigurationspfad existiert bereits.", "servers.validation.agent_required": "Für API-Agent-Server sind Agent-URL und Agent-Secret erforderlich.", + "servers.validation.tunnel_port_range": "Der Tunnel-Port muss zwischen 1024 und 65535 liegen.", "servers.card.socket_path": "Socket-Pfad", "servers.card.config_path": "Konfigurationspfad", "servers.card.server_id": "Server-ID", diff --git a/pkg/web/locales/de_ch.json b/pkg/web/locales/de_ch.json index 702cde0..3997fcf 100644 --- a/pkg/web/locales/de_ch.json +++ b/pkg/web/locales/de_ch.json @@ -565,6 +565,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "Agent-Secret", "servers.form.agent_secret_placeholder": "teilts Geheimnis", + "servers.form.reverse_tunnel": "Reverse-Tunnel für Events aktivierä", + "servers.form.reverse_tunnel_help": "Ersteut automatisch ä Reverse-SSH-Tunnu (-R :localhost:), damit Fail2ban-Callback-Events chöi vom Remote-Server z UI erreiche. D Action-Datei uf em Remote-Server verwändet automatisch http://localhost:, die globali Callback-URL cha daher ä öffentlichi Adresse für direkt erreichbare Server blibe.", + "servers.form.tunnel_port": "Tunnel-Port", + "servers.form.tunnel_port_placeholder": "läär = Server-Port", + "servers.form.tunnel_port_help": "Port, uf dem dr Remote-Host ufs Callbacks loost (düre Tunnel bunde). Läär la, um dr Server-Port vom UI z verwände. igehendi Callbacks wärde düre Tunneu a Server-Port vom UI weitergleitet. Mues zwüsche 1024 und 65535 lige: unprivilegierti Benutzer chöi ufem Remote-Host keini Ports unger 1024 binde.", "servers.form.tags": "Tags", "servers.form.tags_placeholder": "Komma-trennte Tags", "servers.form.set_default": "Als Standard-Server setze", @@ -590,6 +595,7 @@ "servers.errors.agent_invalid_url": "D Agent-URL isch ungültig.", "servers.errors.agent_unreachable": "Dr Agent isch nid erreichbar.", "servers.errors.agent_request_failed": "Agent-Afrag isch fehlgschlage.", + "servers.errors.invalid_tunnel_port": "Dr Tunnel-Port muess zwüsche 1024 und 65535 sii (leer = Server-Port).", "servers.jail_local_warning": "Achtung: z jail.local wird nid vom Fail2ban-UI verwaltet. Verschieb jede Jail in ä eigeni Datei under jail.d/ und lösch z jail.local, damit Fail2ban-UI z file neu cha erstelle. Lueg d Doku a betreffend Berechtigunge.", "servers.actions.restart": "Fail2ban neu starte", "servers.actions.reload": "Fail2ban neu lade", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "Es git scho en lokale Connector mit däm Socket-Pfad.", "servers.validation.duplicate_config_path": "Es git scho en lokale Connector mit däm Konfigurationspfad.", "servers.validation.agent_required": "Füre API-Agent-Server si d Agent-URL und z Agent-Secret erforderlich.", + "servers.validation.tunnel_port_range": "Dr Tunnel-Port muess zwüsche 1024 und 65535 sii.", "servers.card.socket_path": "Socket-Pfad", "servers.card.config_path": "Konfigurationspfad", "servers.card.server_id": "Server-ID", diff --git a/pkg/web/locales/en.json b/pkg/web/locales/en.json index 36a7b38..e64cf78 100644 --- a/pkg/web/locales/en.json +++ b/pkg/web/locales/en.json @@ -568,6 +568,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "Agent Secret", "servers.form.agent_secret_placeholder": "shared secret token", + "servers.form.reverse_tunnel": "Enable reverse tunnel for events", + "servers.form.reverse_tunnel_help": "Automatically creates a reverse SSH tunnel (-R :localhost:) so fail2ban callback events on the remote server can reach this UI server. The remote action file automatically posts to http://localhost:, so the global Callback URL can stay a public address for directly reachable servers.", + "servers.form.tunnel_port": "Tunnel Port", + "servers.form.tunnel_port_placeholder": "empty = server port", + "servers.form.tunnel_port_help": "Port the remote host listens on for callbacks (bound by the tunnel). Leave empty to use the UI server port. Incoming callbacks are forwarded through the tunnel to the UI server port. Must be 1024-65535: unprivileged remote users cannot bind ports below 1024.", "servers.form.tags": "Tags", "servers.form.tags_placeholder": "comma,separated,tags", "servers.form.set_default": "Set as default server", @@ -593,6 +598,7 @@ "servers.errors.agent_invalid_url": "Agent URL is invalid.", "servers.errors.agent_unreachable": "Cannot reach agent service.", "servers.errors.agent_request_failed": "Agent request failed.", + "servers.errors.invalid_tunnel_port": "Tunnel port must be between 1024 and 65535 (empty = server port).", "servers.jail_local_warning": "Warning: jail.local is not managed by Fail2ban-UI. Move each jail into its own file under jail.d/ and delete jail.local so Fail2ban-UI can recreate it (hit once save on the settings page to write the file). See docs for permissions.", "servers.actions.restart": "Restart Fail2ban", "servers.actions.reload": "Reload Fail2ban", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "A local connector with this socket path already exists.", "servers.validation.duplicate_config_path": "A local connector with this configuration path already exists.", "servers.validation.agent_required": "Agent URL and Agent Secret are required for API Agent servers.", + "servers.validation.tunnel_port_range": "Tunnel port must be between 1024 and 65535.", "servers.card.socket_path": "Socket path", "servers.card.config_path": "Configuration path", "servers.card.server_id": "Server-ID", diff --git a/pkg/web/locales/es.json b/pkg/web/locales/es.json index 53d166a..f5fb000 100644 --- a/pkg/web/locales/es.json +++ b/pkg/web/locales/es.json @@ -565,6 +565,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "Secreto del agente", "servers.form.agent_secret_placeholder": "token compartido", + "servers.form.reverse_tunnel": "Habilitar túnel inverso para eventos", + "servers.form.reverse_tunnel_help": "Crea automáticamente un túnel SSH inverso (-R :localhost:) para que los eventos de callback de fail2ban en el servidor remoto puedan llegar a esta interfaz. El archivo de acción remoto usa automáticamente http://localhost:, por lo que la URL de callback global puede seguir siendo una dirección pública para los servidores accesibles directamente.", + "servers.form.tunnel_port": "Puerto del túnel", + "servers.form.tunnel_port_placeholder": "vacío = puerto del servidor", + "servers.form.tunnel_port_help": "Puerto en el que el host remoto escucha los callbacks (vinculado por el túnel). Déjelo vacío para usar el puerto del servidor de la UI. Los callbacks entrantes se reenvían a través del túnel al puerto del servidor de la UI. Debe estar entre 1024 y 65535: los usuarios remotos sin privilegios no pueden vincular puertos por debajo de 1024.", "servers.form.tags": "Etiquetas", "servers.form.tags_placeholder": "etiquetas separadas por comas", "servers.form.set_default": "Establecer como servidor predeterminado", @@ -590,6 +595,7 @@ "servers.errors.agent_invalid_url": "La URL del agente no es válida.", "servers.errors.agent_unreachable": "No se puede conectar con el servicio del agente.", "servers.errors.agent_request_failed": "La solicitud al agente falló.", + "servers.errors.invalid_tunnel_port": "El puerto del túnel debe estar entre 1024 y 65535 (vacío = puerto del servidor).", "servers.jail_local_warning": "Advertencia: jail.local no está gestionado por Fail2ban-UI. Mueva cada jail a su propio archivo en jail.d/ y elimine jail.local para que Fail2ban-UI pueda recrearlo (pulse una vez en la página de configuración para escribir el archivo). Consulte la documentación para permisos.", "servers.actions.restart": "Reiniciar Fail2ban", "servers.actions.reload": "Recargar Fail2ban", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "Ya existe un conector local con esta ruta de socket.", "servers.validation.duplicate_config_path": "Ya existe un conector local con esta ruta de configuración.", "servers.validation.agent_required": "La URL del agente y el secreto del agente son obligatorios para servidores API Agent.", + "servers.validation.tunnel_port_range": "El puerto del túnel debe estar entre 1024 y 65535.", "servers.card.socket_path": "Ruta del socket", "servers.card.config_path": "Ruta de configuración", "servers.card.server_id": "ID del servidor", diff --git a/pkg/web/locales/fr.json b/pkg/web/locales/fr.json index ee715f2..5140751 100644 --- a/pkg/web/locales/fr.json +++ b/pkg/web/locales/fr.json @@ -565,6 +565,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "Secret de l'agent", "servers.form.agent_secret_placeholder": "jeton partagé", + "servers.form.reverse_tunnel": "Activer le tunnel inverse pour les événements", + "servers.form.reverse_tunnel_help": "Crée automatiquement un tunnel SSH inverse (-R :localhost:) afin que les événements de callback de fail2ban sur le serveur distant puissent atteindre cette interface. Le fichier d'action distant utilise automatiquement http://localhost:, l'URL de callback globale peut donc rester une adresse publique pour les serveurs directement accessibles.", + "servers.form.tunnel_port": "Port du tunnel", + "servers.form.tunnel_port_placeholder": "vide = port du serveur", + "servers.form.tunnel_port_help": "Port sur lequel l'hôte distant écoute les callbacks (lié par le tunnel). Laisser vide pour utiliser le port du serveur de l'UI. Les callbacks entrants sont transférés à travers le tunnel vers le port du serveur de l'UI. Doit être compris entre 1024 et 65535 : les utilisateurs distants non privilégiés ne peuvent pas lier de ports inférieurs à 1024.", "servers.form.tags": "Étiquettes", "servers.form.tags_placeholder": "étiquettes séparées par des virgules", "servers.form.set_default": "Définir comme serveur par défaut", @@ -590,6 +595,7 @@ "servers.errors.agent_invalid_url": "L'URL de l'agent est invalide.", "servers.errors.agent_unreachable": "Le service agent est inaccessible.", "servers.errors.agent_request_failed": "La requête vers l'agent a échoué.", + "servers.errors.invalid_tunnel_port": "Le port du tunnel doit être compris entre 1024 et 65535 (vide = port du serveur).", "servers.jail_local_warning": "Attention : jail.local n'est pas géré par Fail2ban-UI. Déplacez chaque jail dans son propre fichier sous jail.d/ et supprimez jail.local pour que Fail2ban-UI puisse le recréer (cliquez une fois sur la page de configuration pour écrire le fichier). Consultez la documentation pour les permissions.", "servers.actions.restart": "Redémarrer Fail2ban", "servers.actions.reload": "Recharger Fail2ban", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "Un connecteur local avec ce chemin de socket existe déjà.", "servers.validation.duplicate_config_path": "Un connecteur local avec ce chemin de configuration existe déjà.", "servers.validation.agent_required": "L'URL de l'agent et le secret de l'agent sont requis pour les serveurs API Agent.", + "servers.validation.tunnel_port_range": "Le port du tunnel doit être compris entre 1024 et 65535.", "servers.card.socket_path": "Chemin du socket", "servers.card.config_path": "Chemin de configuration", "servers.card.server_id": "ID du serveur", diff --git a/pkg/web/locales/it.json b/pkg/web/locales/it.json index 9e4533a..72ca085 100644 --- a/pkg/web/locales/it.json +++ b/pkg/web/locales/it.json @@ -565,6 +565,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "Segreto dell'agente", "servers.form.agent_secret_placeholder": "token condiviso", + "servers.form.reverse_tunnel": "Abilita tunnel inverso per gli eventi", + "servers.form.reverse_tunnel_help": "Crea automaticamente un tunnel SSH inverso (-R :localhost:) affinché gli eventi di callback di fail2ban sul server remoto possano raggiungere questa interfaccia. Il file di azione remoto usa automaticamente http://localhost:, quindi l'URL di callback globale può restare un indirizzo pubblico per i server raggiungibili direttamente.", + "servers.form.tunnel_port": "Porta del tunnel", + "servers.form.tunnel_port_placeholder": "vuoto = porta del server", + "servers.form.tunnel_port_help": "Porta su cui l'host remoto ascolta i callback (vincolata dal tunnel). Lasciare vuoto per usare la porta del server della UI. I callback in arrivo vengono inoltrati attraverso il tunnel alla porta del server della UI. Deve essere compresa tra 1024 e 65535: gli utenti remoti senza privilegi non possono vincolare porte inferiori a 1024.", "servers.form.tags": "Tag", "servers.form.tags_placeholder": "tag separati da virgole", "servers.form.set_default": "Imposta come server predefinito", @@ -590,6 +595,7 @@ "servers.errors.agent_invalid_url": "L'URL dell'agente non è valido.", "servers.errors.agent_unreachable": "Impossibile raggiungere il servizio agente.", "servers.errors.agent_request_failed": "La richiesta all'agente non è riuscita.", + "servers.errors.invalid_tunnel_port": "La porta del tunnel deve essere compresa tra 1024 e 65535 (vuoto = porta del server).", "servers.jail_local_warning": "Attenzione: jail.local non è gestito da Fail2ban-UI. Sposta ogni jail in un file proprio sotto jail.d/ ed elimina jail.local in modo che Fail2ban-UI possa ricrearlo (salva una volta la pagina delle impostazioni per scrivere il file). Consulta la documentazione per i permessi.", "servers.actions.restart": "Riavvia Fail2ban", "servers.actions.reload": "Ricarica Fail2ban", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "Esiste già un connettore locale con questo percorso socket.", "servers.validation.duplicate_config_path": "Esiste già un connettore locale con questo percorso di configurazione.", "servers.validation.agent_required": "Per i server API Agent sono obbligatori URL agente e secret agente.", + "servers.validation.tunnel_port_range": "La porta del tunnel deve essere compresa tra 1024 e 65535.", "servers.card.socket_path": "Percorso socket", "servers.card.config_path": "Percorso configurazione", "servers.card.server_id": "ID server", diff --git a/pkg/web/locales/ja.json b/pkg/web/locales/ja.json index 1cd4ac5..c895bbd 100644 --- a/pkg/web/locales/ja.json +++ b/pkg/web/locales/ja.json @@ -568,6 +568,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "エージェントシークレット", "servers.form.agent_secret_placeholder": "共有シークレットトークン", + "servers.form.reverse_tunnel": "イベント用リバーストンネルを有効化", + "servers.form.reverse_tunnel_help": "リバースSSHトンネル(-R <トンネルポート>:localhost:<サーバーポート>)を自動的に作成し、リモートサーバー上のfail2banコールバックイベントがこのUIサーバーに届くようにします。リモートのアクションファイルは自動的に http://localhost:<トンネルポート> へ送信するため、グローバルのコールバックURLは直接到達可能なサーバー向けの公開アドレスのままにできます。", + "servers.form.tunnel_port": "トンネルポート", + "servers.form.tunnel_port_placeholder": "空欄 = サーバーポート", + "servers.form.tunnel_port_help": "リモートホストがコールバックを受け付けるポートです(トンネルによってバインドされます)。空欄の場合はUIのサーバーポートを使用します。受信したコールバックはトンネル経由でUIのサーバーポートに転送されます。1024〜65535の範囲で指定してください。権限のないリモートユーザーは1024未満のポートをバインドできません。", "servers.form.tags": "タグ", "servers.form.tags_placeholder": "カンマ区切りのタグ", "servers.form.set_default": "デフォルトサーバーに設定", @@ -593,6 +598,7 @@ "servers.errors.agent_invalid_url": "エージェントURLが無効です。", "servers.errors.agent_unreachable": "エージェントサービスに接続できません。", "servers.errors.agent_request_failed": "エージェントへのリクエストに失敗しました。", + "servers.errors.invalid_tunnel_port": "トンネルポートは1024〜65535の範囲で指定してください(空欄 = サーバーポート)。", "servers.jail_local_warning": "警告: jail.localはFail2ban-UIで管理されていません。各Jailをjail.d/以下の個別ファイルに移動し、jail.localを削除してFail2ban-UIが再作成できるようにしてください(設定ページで一度保存してください)。権限についてはドキュメントを参照してください。", "servers.actions.restart": "Fail2banを再起動", "servers.actions.reload": "Fail2banをリロード", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "このソケットパスを持つローカルコネクタが既に存在します。", "servers.validation.duplicate_config_path": "この設定パスを持つローカルコネクタが既に存在します。", "servers.validation.agent_required": "APIエージェントサーバーにはエージェントURLとエージェントシークレットが必須です。", + "servers.validation.tunnel_port_range": "トンネルポートは1024〜65535の範囲で指定してください。", "servers.card.socket_path": "ソケットパス", "servers.card.config_path": "設定パス", "servers.card.server_id": "サーバーID", diff --git a/pkg/web/locales/zh.json b/pkg/web/locales/zh.json index f8ce2be..cdfbc95 100644 --- a/pkg/web/locales/zh.json +++ b/pkg/web/locales/zh.json @@ -568,6 +568,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "代理密钥", "servers.form.agent_secret_placeholder": "共享密钥令牌", + "servers.form.reverse_tunnel": "为事件启用反向隧道", + "servers.form.reverse_tunnel_help": "自动创建反向 SSH 隧道(-R <隧道端口>:localhost:<服务器端口>),使远程服务器上的 fail2ban 回调事件能够到达此 UI 服务器。远程操作文件会自动使用 http://localhost:<隧道端口>,因此全局回调 URL 可以继续保持为供可直接访问服务器使用的公共地址。", + "servers.form.tunnel_port": "隧道端口", + "servers.form.tunnel_port_placeholder": "留空 = 服务器端口", + "servers.form.tunnel_port_help": "远程主机监听回调的端口(由隧道绑定)。留空则使用 UI 的服务器端口。传入的回调会通过隧道转发到 UI 的服务器端口。必须在 1024-65535 之间:无特权的远程用户无法绑定 1024 以下的端口。", "servers.form.tags": "标签", "servers.form.tags_placeholder": "逗号,分隔,标签", "servers.form.set_default": "设为默认服务器", @@ -593,6 +598,7 @@ "servers.errors.agent_invalid_url": "Agent URL 无效。", "servers.errors.agent_unreachable": "无法连接到 Agent 服务。", "servers.errors.agent_request_failed": "Agent 请求失败。", + "servers.errors.invalid_tunnel_port": "隧道端口必须在 1024 至 65535 之间(留空 = 服务器端口)。", "servers.jail_local_warning": "警告:jail.local 不由 Fail2ban-UI 管理。将每个 jail 移动到 jail.d/ 下的独立文件中,并删除 jail.local,以便 Fail2ban-UI 可以重新创建它(在设置页面点击一次保存以写入文件)。请参阅文档了解权限要求。", "servers.actions.restart": "重启 Fail2ban", "servers.actions.reload": "重新加载 Fail2ban", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "已存在使用此 socket 路径的本地连接器。", "servers.validation.duplicate_config_path": "已存在使用此配置路径的本地连接器。", "servers.validation.agent_required": "API 代理服务器需要代理 URL 和代理密钥。", + "servers.validation.tunnel_port_range": "隧道端口必须在 1024 至 65535 之间。", "servers.card.socket_path": "Socket 路径", "servers.card.config_path": "配置路径", "servers.card.server_id": "服务器 ID", diff --git a/pkg/web/static/js/servers.js b/pkg/web/static/js/servers.js index 86178b4..6556e7c 100644 --- a/pkg/web/static/js/servers.js +++ b/pkg/web/static/js/servers.js @@ -314,6 +314,8 @@ function resetServerForm() { document.getElementById('serverDefault').checked = false; document.getElementById('serverEnabled').checked = false; document.getElementById('serverReverseTunnel').checked = false; + document.getElementById('serverTunnelPort').value = ''; + onReverseTunnelToggle(); populateSSHKeySelect(sshKeysCache || [], ''); onServerTypeChange('local'); } @@ -338,6 +340,8 @@ function editServer(serverId) { document.getElementById('serverDefault').checked = !!server.isDefault; document.getElementById('serverEnabled').checked = !!server.enabled; document.getElementById('serverReverseTunnel').checked = !!server.reverseTunnelEnabled; + document.getElementById('serverTunnelPort').value = server.tunnelPort || ''; + onReverseTunnelToggle(); onServerTypeChange(server.type || 'local'); if ((server.type || 'local') === 'ssh') { loadSSHKeys().then(function(keys) { @@ -346,6 +350,16 @@ function editServer(serverId) { } } +function onReverseTunnelToggle() { + var group = document.getElementById('serverTunnelPortGroup'); + if (!group) return; + if (document.getElementById('serverReverseTunnel').checked) { + group.classList.remove('hidden'); + } else { + group.classList.add('hidden'); + } +} + function onServerTypeChange(type) { document.querySelectorAll('[data-server-fields]').forEach(function(el) { var values = (el.getAttribute('data-server-fields') || '').split(/\s+/); @@ -455,8 +469,13 @@ function submitServerForm(event) { ? document.getElementById('serverTags').value.split(',').map(function(tag) { return tag.trim(); }).filter(Boolean) : [], enabled: document.getElementById('serverEnabled').checked, - reverseTunnelEnabled: document.getElementById('serverReverseTunnel').checked + reverseTunnelEnabled: document.getElementById('serverReverseTunnel').checked, + tunnelPort: document.getElementById('serverTunnelPort').value ? parseInt(document.getElementById('serverTunnelPort').value, 10) : 0 }; + if (payload.type === 'ssh' && payload.tunnelPort && (payload.tunnelPort < 1024 || payload.tunnelPort > 65535)) { + showToast(t('servers.validation.tunnel_port_range', 'Tunnel port must be between 1024 and 65535.'), 'error'); + return; + } var nameKey = normalizeNameForCompare(payload.name); if (!nameKey) { showToast(t('servers.validation.name_required', 'Server name is required.'), 'error'); @@ -512,6 +531,7 @@ function submitServerForm(event) { delete payload.sshUser; delete payload.sshKeyPath; delete payload.reverseTunnelEnabled; + delete payload.tunnelPort; } if (payload.type !== 'agent') { delete payload.agentUrl; diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index cecf546..990f864 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -1457,12 +1457,19 @@

- +

- Automatically creates a reverse SSH tunnel (-R port:localhost:port) so fail2ban callback events on the remote server can reach this UI server. The tunnel port is derived from the Callback URL setting. + Automatically creates a reverse SSH tunnel (-R <tunnel port>:localhost:<server port>) so fail2ban callback events on the remote server can reach this UI server. The remote action file automatically posts to http://localhost:<tunnel port>, so the global Callback URL can stay a public address for directly reachable servers.

+
From 1c989f356d7fbe83c32053290e017d10fbadd11c Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Fri, 24 Jul 2026 22:57:12 +0200 Subject: [PATCH 03/25] Add a health-check tunnel function and as well change detection handler --- internal/fail2ban/manager.go | 79 ++++++++++++++++++++++++++++++++++++ pkg/web/handlers.go | 17 +++++++- pkg/web/secret_masking.go | 1 + 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/internal/fail2ban/manager.go b/internal/fail2ban/manager.go index 0c14c0b..92d579a 100644 --- a/internal/fail2ban/manager.go +++ b/internal/fail2ban/manager.go @@ -84,8 +84,11 @@ type Manager struct { mu sync.RWMutex connectors map[string]Connector defaultServerID string + tunnelMonStop chan struct{} } +const tunnelCheckInterval = 45 * time.Second + var ( managerOnce sync.Once managerInst *Manager @@ -105,6 +108,7 @@ func (m *Manager) ReloadFromServers(servers []shared.Fail2banServer) error { m.mu.Lock() defer m.mu.Unlock() + old := m.connectors connectors := make(map[string]Connector) defaultID := pickDefaultServerID(servers) @@ -112,6 +116,9 @@ func (m *Manager) ReloadFromServers(servers []shared.Fail2banServer) error { if !srv.Enabled { continue } + if oldSSH, ok := old[srv.ID].(*SSHConnector); ok && sshTunnelConfigChanged(oldSSH, srv) { + _ = oldSSH.Close() + } conn, err := newConnectorForServer(srv) if err != nil { return fmt.Errorf("failed to initialise connector for %s (%s): %w", srv.Name, srv.ID, err) @@ -119,11 +126,83 @@ func (m *Manager) ReloadFromServers(servers []shared.Fail2banServer) error { connectors[srv.ID] = conn } + // Tear down tunneled masters of servers that were removed or disabled. + for id, conn := range old { + if _, still := connectors[id]; still { + continue + } + if oldSSH, ok := conn.(*SSHConnector); ok && oldSSH.tunnelPort > 0 { + _ = oldSSH.Close() + } + } + m.connectors = connectors m.defaultServerID = defaultID + m.syncTunnelMonitorLocked() return nil } +// Starts or stops the tunnel health monitor +func (m *Manager) syncTunnelMonitorLocked() { + hasTunnels := false + for _, conn := range m.connectors { + if sc, ok := conn.(*SSHConnector); ok && sc.tunnelPort > 0 { + hasTunnels = true + break + } + } + switch { + case hasTunnels && m.tunnelMonStop == nil: + m.tunnelMonStop = make(chan struct{}) + go m.tunnelMonitorLoop(m.tunnelMonStop) + log.Printf("reverse tunnel health monitor started (interval %s)", tunnelCheckInterval) + case !hasTunnels && m.tunnelMonStop != nil: + close(m.tunnelMonStop) + m.tunnelMonStop = nil + log.Printf("reverse tunnel health monitor stopped (no tunneled servers)") + } +} + +// Periodically verifies reverse-tunnel SSH masters and re-establishes dead ones. +func (m *Manager) tunnelMonitorLoop(stop <-chan struct{}) { + ticker := time.NewTicker(tunnelCheckInterval) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + m.CheckTunnels(ctx) + cancel() + } + } +} + +// Runs the reverse-tunnel health check for every SSH connector with an active tunnel +func (m *Manager) CheckTunnels(ctx context.Context) { + m.mu.RLock() + var tunneled []*SSHConnector + for _, conn := range m.connectors { + if sc, ok := conn.(*SSHConnector); ok && sc.tunnelPort > 0 { + tunneled = append(tunneled, sc) + } + } + m.mu.RUnlock() + + var wg sync.WaitGroup + for _, sc := range tunneled { + wg.Add(1) + go func(sc *SSHConnector) { + defer wg.Done() + checkCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + sc.CheckTunnelHealth(checkCtx) + }(sc) + } + wg.Wait() +} + func pickDefaultServerID(servers []shared.Fail2banServer) string { var fallback string for _, srv := range servers { diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 3fff632..d838b3d 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -1249,22 +1249,35 @@ func UpsertServerHandler(c *gin.Context) { server, err := config.UpsertServer(req) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + resp := gin.H{"error": err.Error()} + if errors.Is(err, config.ErrInvalidTunnelPort) { + resp["messageKey"] = "servers.errors.invalid_tunnel_port" + } + c.JSON(http.StatusBadRequest, resp) return } // Check if server was just enabled (transition from disabled to enabled) justEnabled := wasDisabled && server.Enabled + tunnelChanged := wasEnabled && oldServer.Enabled && server.Enabled && + (oldServer.ReverseTunnelEnabled != server.ReverseTunnelEnabled || oldServer.TunnelPort != server.TunnelPort) if err := config.ReloadFail2banManager(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if justEnabled && (server.Type == "ssh" || server.Type == "agent") { + if (justEnabled || tunnelChanged) && (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) } + if tunnelChanged { + if conn, err := fail2ban.GetManager().Connector(server.ID); err == nil { + if err := conn.Reload(c.Request.Context()); err != nil { + config.DebugLog("Warning: failed to reload fail2ban on server %s after tunnel change: %v", server.Name, err) + } + } + } } // Ensures the jail.local structure is properly initialized for newly enabled/added servers diff --git a/pkg/web/secret_masking.go b/pkg/web/secret_masking.go index 3f3ea05..fea4932 100644 --- a/pkg/web/secret_masking.go +++ b/pkg/web/secret_masking.go @@ -122,6 +122,7 @@ func stripServerConnectionDetails(servers []shared.Fail2banServer) []shared.Fail servers[i].SSHKeyPath = "" servers[i].AgentURL = "" servers[i].AgentSecret = "" + servers[i].TunnelPort = 0 } return servers } From 4a31ece9332da776e6b477e0859e4bf8094bc10f Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Fri, 24 Jul 2026 23:02:24 +0200 Subject: [PATCH 04/25] Include the custom reverse-tunnel port to the ssh-connector and implement helper function to rewrite callback on-the-fly --- internal/config/settings.go | 22 ++- internal/config/settings_validation_test.go | 16 ++ internal/fail2ban/connector_ssh.go | 166 +++++++++++++++----- internal/fail2ban/connector_ssh_test.go | 141 +++++++++++++++++ 4 files changed, 309 insertions(+), 36 deletions(-) create mode 100644 internal/fail2ban/connector_ssh_test.go diff --git a/internal/config/settings.go b/internal/config/settings.go index b4b7354..5370e14 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -505,6 +505,7 @@ func applyServerRecordsLocked(records []storage.ServerRecord) { IsDefault: rec.IsDefault, Enabled: rec.Enabled, ReverseTunnelEnabled: rec.ReverseTunnelEnabled, + TunnelPort: rec.TunnelPort, RestartNeeded: rec.NeedsRestart, CreatedAt: rec.CreatedAt, UpdatedAt: rec.UpdatedAt, @@ -630,6 +631,7 @@ func toServerRecordsLocked() ([]storage.ServerRecord, error) { IsDefault: srv.IsDefault, Enabled: srv.Enabled, ReverseTunnelEnabled: srv.ReverseTunnelEnabled, + TunnelPort: srv.TunnelPort, NeedsRestart: srv.RestartNeeded, CreatedAt: createdAt, UpdatedAt: updatedAt, @@ -1183,7 +1185,18 @@ func GetDefaultServer() Fail2banServer { return Fail2banServer{} } -// Adds or updates a Fail2ban server. +// ErrInvalidTunnelPort is returned when a reverse-tunnel port is outside the +// unprivileged range. Handlers match it to attach a localized message key. +var ErrInvalidTunnelPort = errors.New("tunnelPort must be between 1024 and 65535 (empty = server port)") + +// Reject reverse-tunnel ports outside the unprivileged range +func validateTunnelPort(port int) error { + if port == 0 || (port >= 1024 && port <= 65535) { + return nil + } + return fmt.Errorf("%w, got %d", ErrInvalidTunnelPort, port) +} + func UpsertServer(input Fail2banServer) (Fail2banServer, error) { settingsLock.Lock() defer settingsLock.Unlock() @@ -1218,6 +1231,13 @@ func UpsertServer(input Fail2banServer) (Fail2banServer, error) { input.SocketPath = normalizePathValue(input.SocketPath) input.ConfigPath = "" } + if input.Type == "ssh" { + if err := validateTunnelPort(input.TunnelPort); err != nil { + return Fail2banServer{}, err + } + } else { + input.TunnelPort = 0 + } if input.Name == "" { input.Name = "Fail2ban Server " + input.ID } diff --git a/internal/config/settings_validation_test.go b/internal/config/settings_validation_test.go index d6cce2b..622a795 100644 --- a/internal/config/settings_validation_test.go +++ b/internal/config/settings_validation_test.go @@ -124,3 +124,19 @@ func TestFail2banActionConfigEscapesPercent(t *testing.T) { } } } + +func TestValidateTunnelPort(t *testing.T) { + t.Parallel() + valid := []int{0, 1024, 8080, 65535} + for _, port := range valid { + if err := validateTunnelPort(port); err != nil { + t.Fatalf("validateTunnelPort(%d) = %v, want nil", port, err) + } + } + invalid := []int{-1, 1, 80, 443, 1023, 65536} + for _, port := range invalid { + if err := validateTunnelPort(port); err == nil { + t.Fatalf("validateTunnelPort(%d) = nil, want error", port) + } + } +} diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index 055f6e1..ffa4362 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -24,7 +24,6 @@ import ( "errors" "fmt" "log" - "net/url" "os" "os/exec" "path/filepath" @@ -32,6 +31,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" @@ -49,6 +49,10 @@ type SSHConnector struct { pathCached bool pathMutex sync.RWMutex tunnelPort int + forwardPort int + tunnelMu sync.Mutex + tunnelWasUp bool + closed atomic.Bool } const sshEnsureActionScript = `python3 - <<'PY' @@ -87,21 +91,10 @@ func NewSSHConnector(server shared.Fail2banServer) (Connector, error) { } conn := &SSHConnector{server: server} - // Parse tunnel port from callback URL when reverse tunnel is enabled if server.ReverseTunnelEnabled { - callbackURL := mustProvider().CallbackURL() - parsedURL, err := url.Parse(callbackURL) - if err == nil { - conn.tunnelPort = callbackTunnelPort(parsedURL) - } - if conn.tunnelPort == 0 { - log.Printf("warning: reverse tunnel enabled for server %s but no usable port could be derived from callback URL %q - tunnel disabled", server.Name, callbackURL) - } else { - if host := parsedURL.Hostname(); host != "localhost" && host != "127.0.0.1" && host != "::1" { - log.Printf("warning: reverse tunnel for server %s forwards localhost:%d, but the callback URL points to host %q - the remote fail2ban will bypass the tunnel unless the callback URL host is localhost", server.Name, conn.tunnelPort, host) - } - debugf("Reverse tunnel enabled for server %s, will use -R %d:localhost:%d", server.Name, conn.tunnelPort, conn.tunnelPort) - } + conn.tunnelPort = resolveTunnelPort(server) + conn.forwardPort = uiServerPort() + debugf("Reverse tunnel enabled for server %s, will use -R %d:localhost:%d", server.Name, conn.tunnelPort, conn.forwardPort) } // Use a timeout context to prevent hanging if SSH server isn't ready yet @@ -115,19 +108,23 @@ func NewSSHConnector(server shared.Fail2banServer) (Connector, error) { return conn, nil } -// callbackTunnelPort derives the port the reverse tunnel has to forward from -// the callback URL: the explicit port if present, otherwise the scheme default. -func callbackTunnelPort(u *url.URL) int { - if p, err := strconv.Atoi(u.Port()); err == nil && p > 0 && p <= 65535 { +// Return the UI's configured listen port ("Server Port" setting) +func uiServerPort() int { + if p := mustProvider().ServerPort(); p > 0 { return p } - switch u.Scheme { - case "https": - return 443 - case "http": - return 80 + return 8080 +} + +// Return the port bound on the remote host for the reverse tunnel +func resolveTunnelPort(server shared.Fail2banServer) int { + if server.TunnelPort >= 1024 && server.TunnelPort <= 65535 { + return server.TunnelPort } - return 0 + if server.TunnelPort != 0 { + log.Printf("warning: invalid tunnelPort %d for server %s, falling back to the UI port", server.TunnelPort, server.Name) + } + return uiServerPort() } // ========================================================================= @@ -162,6 +159,89 @@ func (sc *SSHConnector) warmControlMaster(ctx context.Context) { } } +// Verifies the SSH ControlMaster if the reverse tunnel is alive (ssh -O check) and re-establish when it is not. +func (sc *SSHConnector) CheckTunnelHealth(ctx context.Context) { + if sc.tunnelPort == 0 || sc.closed.Load() { + return + } + if !sc.tunnelMu.TryLock() { + return + } + defer sc.tunnelMu.Unlock() + + check := exec.CommandContext(ctx, "ssh", "-O", "check", "-o", "ControlPath="+sc.controlPath(), sc.sshTarget()) + if err := check.Run(); err == nil { + if !sc.tunnelWasUp { + log.Printf("reverse tunnel for server %s is up (port %d)", sc.server.Name, sc.tunnelPort) + } + sc.tunnelWasUp = true + return + } + + if sc.tunnelWasUp { + log.Printf("reverse tunnel master for server %s is down, re-establishing", sc.server.Name) + } + sc.tunnelWasUp = false + if sc.closed.Load() { + return + } + + sc.warmControlMaster(ctx) + if sc.closed.Load() { + sc.exitControlMaster() + return + } + recheck := exec.CommandContext(ctx, "ssh", "-O", "check", "-o", "ControlPath="+sc.controlPath(), sc.sshTarget()) + if err := recheck.Run(); err == nil { + log.Printf("reverse tunnel for server %s re-established (port %d)", sc.server.Name, sc.tunnelPort) + sc.tunnelWasUp = true + } else { + debugf("reverse tunnel for server %s still down after re-dial: %v", sc.server.Name, err) + } +} + +// Terminates the SSH ControlMaster via its local control socket +func (sc *SSHConnector) exitControlMaster() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // -O -> exit only talks to the local control socket. + cmd := exec.CommandContext(ctx, "ssh", "-O", "exit", "-o", "ControlPath="+sc.controlPath(), sc.sshTarget()) + if out, err := cmd.CombinedOutput(); err != nil { + msg := strings.ToLower(string(out)) + if !strings.Contains(msg, "no such file") && !strings.Contains(msg, "control socket connect") { + debugf("failed to close SSH control master for %s: %v (%s)", sc.server.Name, err, strings.TrimSpace(string(out))) + } + return + } + debugf("closed SSH control master for %s", sc.server.Name) +} + +func (sc *SSHConnector) Close() error { + sc.closed.Store(true) + if !sc.tunnelMu.TryLock() { + return nil + } + defer sc.tunnelMu.Unlock() + sc.exitControlMaster() + return nil +} + +// Report whether the replacement server config requires tearing down the existing ControlMaster +func sshTunnelConfigChanged(old *SSHConnector, srv shared.Fail2banServer) bool { + newTunnel := srv.Type == "ssh" && srv.ReverseTunnelEnabled + if old.tunnelPort == 0 { + return newTunnel + } + if !newTunnel { + return true + } + if resolveTunnelPort(srv) != old.tunnelPort { + return true + } + o := old.server + return srv.Host != o.Host || srv.Port != o.Port || srv.SSHUser != o.SSHUser || srv.SSHKeyPath != o.SSHKeyPath +} + // 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) @@ -296,8 +376,7 @@ func (sc *SSHConnector) SetFilterConfig(ctx context.Context, filterName, content func (sc *SSHConnector) ensureAction(ctx context.Context) error { p := mustProvider() - callbackURL := p.CallbackURL() - actionConfig := p.BuildFail2banActionConfig(callbackURL, sc.server.ID, p.CallbackSecret()) + actionConfig := p.BuildFail2banActionConfig(sc.actionCallbackURL(), sc.server.ID, p.CallbackSecret()) payload := base64.StdEncoding.EncodeToString([]byte(actionConfig)) script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload) scriptB64 := base64.StdEncoding.EncodeToString([]byte(script)) @@ -476,6 +555,26 @@ func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string) } } +// Returns the callback URL to embed in this server's action file. +// The loopback tunnel endpoint when a reverse tunnel is active, otherwise the global callback URL +func (sc *SSHConnector) actionCallbackURL() string { + if sc.tunnelPort > 0 { + return fmt.Sprintf("http://localhost:%d", sc.tunnelPort) + } + return mustProvider().CallbackURL() +} + +func (sc *SSHConnector) controlPath() string { + return fmt.Sprintf("/tmp/ssh_control_%s_%s", sc.server.ID, strings.ReplaceAll(sc.server.Host, ".", "_")) +} + +func (sc *SSHConnector) sshTarget() string { + if sc.server.SSHUser != "" { + return fmt.Sprintf("%s@%s", sc.server.SSHUser, sc.server.Host) + } + return sc.server.Host +} + func (sc *SSHConnector) buildSSHArgs(command []string) []string { args := []string{"-o", "BatchMode=yes"} args = append(args, @@ -490,7 +589,6 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string { "-o", "LogLevel=ERROR", ) } - controlPath := fmt.Sprintf("/tmp/ssh_control_%s_%s", sc.server.ID, strings.ReplaceAll(sc.server.Host, ".", "_")) // ControlPersist=0 keeps the master SSH process (and with it the reverse // tunnel) alive indefinitely; without a tunnel it expires after 300s. controlPersist := "ControlPersist=300" @@ -499,11 +597,13 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string { } args = append(args, "-o", "ControlMaster=auto", - "-o", fmt.Sprintf("ControlPath=%s", controlPath), + "-o", fmt.Sprintf("ControlPath=%s", sc.controlPath()), "-o", controlPersist, ) if sc.tunnelPort > 0 { - tunnelArg := fmt.Sprintf("%d:localhost:%d", sc.tunnelPort, sc.tunnelPort) + // Remote side binds tunnelPort (the action file posts there); the + // UI side forwards to the port the HTTP server actually listens on. + tunnelArg := fmt.Sprintf("%d:localhost:%d", sc.tunnelPort, sc.forwardPort) args = append(args, "-R", tunnelArg) debugf("SSH reverse tunnel enabled: -R %s with %s (indefinite)", tunnelArg, controlPersist) } @@ -513,11 +613,7 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string { if sc.server.Port > 0 { args = append(args, "-p", strconv.Itoa(sc.server.Port)) } - target := sc.server.Host - if sc.server.SSHUser != "" { - target = fmt.Sprintf("%s@%s", sc.server.SSHUser, target) - } - args = append(args, target) + args = append(args, sc.sshTarget()) args = append(args, command...) return args } diff --git a/internal/fail2ban/connector_ssh_test.go b/internal/fail2ban/connector_ssh_test.go new file mode 100644 index 0000000..25e5f02 --- /dev/null +++ b/internal/fail2ban/connector_ssh_test.go @@ -0,0 +1,141 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/agpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fail2ban + +import ( + "testing" + + "github.com/swissmakers/fail2ban-ui/internal/shared" +) + +func TestResolveTunnelPort(t *testing.T) { + SetProvider(testProvider{}) + defer SetProvider(noopProvider{}) + + cases := []struct { + name string + srv shared.Fail2banServer + want int + }{ + {"explicit port", shared.Fail2banServer{Name: "s", TunnelPort: 9443}, 9443}, + {"zero falls back to UI port", shared.Fail2banServer{Name: "s"}, 8080}, + {"privileged port falls back to UI port", shared.Fail2banServer{Name: "s", TunnelPort: 80}, 8080}, + {"out of range falls back to UI port", shared.Fail2banServer{Name: "s", TunnelPort: 70000}, 8080}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := resolveTunnelPort(tc.srv); got != tc.want { + t.Fatalf("resolveTunnelPort(%+v) = %d, want %d", tc.srv.TunnelPort, got, tc.want) + } + }) + } +} + +func TestResolveTunnelPortNoopProviderFallback(t *testing.T) { + SetProvider(noopProvider{}) + defer SetProvider(noopProvider{}) + + if got := resolveTunnelPort(shared.Fail2banServer{Name: "s"}); got != 8080 { + t.Fatalf("resolveTunnelPort with noop provider = %d, want 8080", got) + } +} + +func TestActionCallbackURL(t *testing.T) { + SetProvider(testProvider{}) + defer SetProvider(noopProvider{}) + + tunneled := &SSHConnector{server: shared.Fail2banServer{Name: "s"}, tunnelPort: 9443} + if got := tunneled.actionCallbackURL(); got != "http://localhost:9443" { + t.Fatalf("tunneled actionCallbackURL = %q, want http://localhost:9443", got) + } + + direct := &SSHConnector{server: shared.Fail2banServer{Name: "s"}} + if got := direct.actionCallbackURL(); got != "http://127.0.0.1:8080" { + t.Fatalf("direct actionCallbackURL = %q, want provider callback URL", got) + } +} + +func TestBuildSSHArgsReverseTunnelForwardTarget(t *testing.T) { + SetProvider(testProvider{}) + defer SetProvider(noopProvider{}) + + findR := func(args []string) string { + for i, a := range args { + if a == "-R" && i+1 < len(args) { + return args[i+1] + } + } + return "" + } + + custom := &SSHConnector{ + server: shared.Fail2banServer{Host: "10.0.0.1", SSHUser: "f2b"}, + tunnelPort: 18080, + forwardPort: 8080, + } + if got := findR(custom.buildSSHArgs([]string{"true"})); got != "18080:localhost:8080" { + t.Fatalf("-R arg = %q, want 18080:localhost:8080", got) + } + + deflt := &SSHConnector{ + server: shared.Fail2banServer{Host: "10.0.0.1", SSHUser: "f2b"}, + tunnelPort: 8080, + forwardPort: 8080, + } + if got := findR(deflt.buildSSHArgs([]string{"true"})); got != "8080:localhost:8080" { + t.Fatalf("-R arg = %q, want 8080:localhost:8080", got) + } + + noTunnel := &SSHConnector{server: shared.Fail2banServer{Host: "10.0.0.1", SSHUser: "f2b"}} + if got := findR(noTunnel.buildSSHArgs([]string{"true"})); got != "" { + t.Fatalf("unexpected -R arg %q for connector without tunnel", got) + } +} + +func TestSSHTunnelConfigChanged(t *testing.T) { + SetProvider(testProvider{}) + defer SetProvider(noopProvider{}) + + base := shared.Fail2banServer{ + Type: "ssh", Host: "10.0.0.1", Port: 22, SSHUser: "f2b", SSHKeyPath: "/config/.ssh/id", + ReverseTunnelEnabled: true, TunnelPort: 9443, + } + tunneled := &SSHConnector{server: base, tunnelPort: 9443} + + cases := []struct { + name string + old *SSHConnector + mutate func(shared.Fail2banServer) shared.Fail2banServer + want bool + }{ + {"unchanged", tunneled, func(s shared.Fail2banServer) shared.Fail2banServer { return s }, false}, + {"no old tunnel, tunnel newly enabled", &SSHConnector{server: base}, func(s shared.Fail2banServer) shared.Fail2banServer { return s }, true}, + {"no old tunnel, tunnel stays off", &SSHConnector{server: base}, func(s shared.Fail2banServer) shared.Fail2banServer { s.ReverseTunnelEnabled = false; return s }, false}, + {"tunnel disabled", tunneled, func(s shared.Fail2banServer) shared.Fail2banServer { s.ReverseTunnelEnabled = false; return s }, true}, + {"type changed", tunneled, func(s shared.Fail2banServer) shared.Fail2banServer { s.Type = "agent"; return s }, true}, + {"port changed", tunneled, func(s shared.Fail2banServer) shared.Fail2banServer { s.TunnelPort = 9444; return s }, true}, + {"host changed", tunneled, func(s shared.Fail2banServer) shared.Fail2banServer { s.Host = "10.0.0.2"; return s }, true}, + {"ssh user changed", tunneled, func(s shared.Fail2banServer) shared.Fail2banServer { s.SSHUser = "other"; return s }, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := sshTunnelConfigChanged(tc.old, tc.mutate(base)); got != tc.want { + t.Fatalf("sshTunnelConfigChanged = %v, want %v", got, tc.want) + } + }) + } +} From 16df1146a151d7843539b37862bb1f22f2202a84 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Fri, 24 Jul 2026 23:04:14 +0200 Subject: [PATCH 05/25] Make ServerPort from settings via shared bridge available to our providers / agent and the ssh connector --- internal/config/fail2ban_bridge.go | 6 ++++++ internal/fail2ban/connector_agent_test.go | 1 + internal/fail2ban/provider.go | 3 +++ 3 files changed, 10 insertions(+) diff --git a/internal/config/fail2ban_bridge.go b/internal/config/fail2ban_bridge.go index f5f8cfb..d2caf7b 100644 --- a/internal/config/fail2ban_bridge.go +++ b/internal/config/fail2ban_bridge.go @@ -38,6 +38,12 @@ func (fail2banRuntime) CallbackSecret() string { return currentSettings.CallbackSecret } +func (fail2banRuntime) ServerPort() int { + settingsLock.RLock() + defer settingsLock.RUnlock() + return currentSettings.Port +} + func (fail2banRuntime) BuildFail2banActionConfig(callbackURL, serverID, secret string) string { return BuildFail2banActionConfig(callbackURL, serverID, secret) } diff --git a/internal/fail2ban/connector_agent_test.go b/internal/fail2ban/connector_agent_test.go index d66a3db..d8c8de4 100644 --- a/internal/fail2ban/connector_agent_test.go +++ b/internal/fail2ban/connector_agent_test.go @@ -34,6 +34,7 @@ type testProvider struct{} func (testProvider) DebugLog(format string, v ...interface{}) {} func (testProvider) CallbackURL() string { return "http://127.0.0.1:8080" } func (testProvider) CallbackSecret() string { return "test-secret" } +func (testProvider) ServerPort() int { return 8080 } func (testProvider) BuildFail2banActionConfig(callbackURL, serverID, secret string) string { return "" } diff --git a/internal/fail2ban/provider.go b/internal/fail2ban/provider.go index 0c25a04..664912f 100644 --- a/internal/fail2ban/provider.go +++ b/internal/fail2ban/provider.go @@ -23,6 +23,7 @@ type Provider interface { DebugLog(format string, v ...interface{}) CallbackURL() string CallbackSecret() string + ServerPort() int BuildFail2banActionConfig(callbackURL, serverID, secret string) string BuildJailLocalContent() string } @@ -61,6 +62,8 @@ func (noopProvider) CallbackURL() string { return "" } func (noopProvider) CallbackSecret() string { return "" } +func (noopProvider) ServerPort() int { return 0 } + func (noopProvider) BuildFail2banActionConfig(callbackURL, serverID, secret string) string { return "" } From d8e2a904f12a412fcf2b387050e877d5951b64f2 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Fri, 24 Jul 2026 23:05:16 +0200 Subject: [PATCH 06/25] Update reverse tunnel documentation and how the callback works with it enabled --- docs/configuration.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f7a6f12..df70e5c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,9 +68,13 @@ With a subpath: ### Reverse SSH tunnel for callbacks -SSH-connected servers can enable **reverse tunnel for events** (server form). The UI then opens a reverse tunnel (`ssh -R :localhost:`) alongside the SSH control connection so callbacks reach the UI even when the managed host cannot connect to it directly (NAT, firewall). The port is derived from `CALLBACK_URL` (explicit port, otherwise 443/80 by scheme). +SSH-connected servers can enable **reverse tunnel for events** (server form). The UI then opens a reverse tunnel (`ssh -R :localhost:`) alongside the SSH control connection so callbacks reach the UI even when the managed host cannot connect to it directly (NAT, firewall, isolated DMZ without outbound access). Two ports are involved: the **tunnel port** is where the remote host listens locally for the fail2ban callbacks, and the **server port** is where the UI's HTTP API listens -> the tunnel carries connections from the former to the latter. -The tunnel is only used if `CALLBACK_URL` points to `localhost`/`127.0.0.1` - the remote Fail2Ban sends its callbacks to that URL, which the tunnel forwards to the UI. With a public callback URL the callbacks bypass the tunnel; the UI logs a warning in that case. Note that a localhost callback URL applies globally, so mixing tunneled and non-tunneled remote servers is not possible. +Each server has its own tunnel port setting. Leave it empty to use the UI bind port (`PORT`, default 8080). If set, it must be between 1024 and 65535: the unprivileged SSH service account on the managed host cannot bind ports below 1024, and `ssh` only reports such a failure as a stderr warning while the connection itself stays up. The same applies when the chosen port is already occupied on the remote host, pick a free unprivileged port in that case. The tunnel port only affects the remote side; the tunnel always forwards to the UI's configured server port, so a custom tunnel port does not need to match it. + +For tunneled servers, the UI writes `http://localhost:` into the remote action file, so the callbacks travel through the tunnel regardless of the global `CALLBACK_URL`. This means mixed setups work: keep `CALLBACK_URL` pointing at a public address for directly reachable servers while tunneled servers use the loopback URL. Plain `http` is correct inside the tunnel, the transport is already SSH-encrypted, so TLS (and `CALLBACK_INSECURE_TLS`) is irrelevant for tunneled servers. + +The UI checks every tunnel's SSH master connection every 45 seconds and automatically re-establishes it when it has died (for example after a short network outage), so callback delivery resumes without waiting for the next UI-triggered command. Changing a server's tunnel settings tears down the old connection and builds a new one immediately. ## Privacy and telemetry controls From b9cb0e184af7a6bd8bc3ab1a534792b604df1426 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Sat, 25 Jul 2026 15:16:59 +0200 Subject: [PATCH 07/25] Fix bug where one failed jail, that includes a filter shared with a other jail causes general fails on enabeling disabeling other jails --- internal/fail2ban/connector_ssh.go | 162 +++++++++------------ internal/fail2ban/filter_management.go | 2 +- internal/fail2ban/jail_management.go | 169 +++++++++++++++------- internal/fail2ban/jail_management_test.go | 120 +++++++++++++++ pkg/web/handlers.go | 92 +++++++----- pkg/web/jail_reload_errors_test.go | 64 ++++++++ pkg/web/locales/ca.json | 3 +- pkg/web/locales/de.json | 3 +- pkg/web/locales/de_ch.json | 3 +- pkg/web/locales/en.json | 3 +- pkg/web/locales/es.json | 3 +- pkg/web/locales/fr.json | 3 +- pkg/web/locales/it.json | 3 +- pkg/web/locales/ja.json | 3 +- pkg/web/locales/zh.json | 3 +- pkg/web/static/js/core.js | 4 +- pkg/web/static/js/dashboard.js | 18 +++ pkg/web/static/js/jails.js | 8 +- 18 files changed, 467 insertions(+), 199 deletions(-) create mode 100644 internal/fail2ban/jail_management_test.go create mode 100644 pkg/web/jail_reload_errors_test.go diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index ffa4362..460f5f1 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -742,7 +742,26 @@ func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { jailDPath := filepath.Join(fail2banPath, "jail.d") var allJails []JailInfo - processedJails := make(map[string]bool) + jailIndex := make(map[string]int) + jailSource := make(map[string]string) + + addJails := func(content, fileType string) { + for _, jail := range parseJailConfigContent(content) { + if jail.JailName == "" || jail.JailName == "DEFAULT" { + continue + } + idx, seen := jailIndex[jail.JailName] + switch { + case !seen: + jailIndex[jail.JailName] = len(allJails) + jailSource[jail.JailName] = fileType + allJails = append(allJails, jail) + case fileType == "local" || jailSource[jail.JailName] == fileType: + allJails[idx].Enabled = jail.Enabled + jailSource[jail.JailName] = fileType + } + } + } readAllScript := fmt.Sprintf(`python3 << 'PYEOF' import os @@ -804,7 +823,7 @@ PYEOF`, jailDPath) debugf("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) } - var currentFile string + var currentFile, currentType string var currentContent strings.Builder inFile := false @@ -812,34 +831,22 @@ PYEOF`, jailDPath) for _, line := range lines { if strings.HasPrefix(line, "FILE_START:") { if inFile && currentFile != "" { - content := currentContent.String() - jails := parseJailConfigContent(content) - for _, jail := range jails { - if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { - allJails = append(allJails, jail) - processedJails[jail.JailName] = true - } - } + addJails(currentContent.String(), currentType) } parts := strings.SplitN(line, ":", 3) if len(parts) == 3 { currentFile = parts[1] + currentType = parts[2] currentContent.Reset() inFile = true } } else if line == "FILE_END" { if inFile && currentFile != "" { - content := currentContent.String() - jails := parseJailConfigContent(content) - for _, jail := range jails { - if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { - allJails = append(allJails, jail) - processedJails[jail.JailName] = true - } - } + addJails(currentContent.String(), currentType) } inFile = false currentFile = "" + currentType = "" currentContent.Reset() } else if inFile { if currentContent.Len() > 0 { @@ -850,14 +857,7 @@ PYEOF`, jailDPath) } if inFile && currentFile != "" { - content := currentContent.String() - jails := parseJailConfigContent(content) - for _, jail := range jails { - if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { - allJails = append(allJails, jail) - processedJails[jail.JailName] = true - } - } + addJails(currentContent.String(), currentType) } return allJails, nil @@ -866,12 +866,28 @@ PYEOF`, jailDPath) func (sc *SSHConnector) getAllJailsFallback(ctx context.Context, jailDPath string) ([]JailInfo, error) { var allJails []JailInfo processedFiles := make(map[string]bool) - processedJails := make(map[string]bool) + jailIndex := make(map[string]int) + addJails := func(content string, isLocal bool) { + for _, jail := range parseJailConfigContent(content) { + if jail.JailName == "" || jail.JailName == "DEFAULT" { + continue + } + idx, seen := jailIndex[jail.JailName] + switch { + case !seen: + jailIndex[jail.JailName] = len(allJails) + allJails = append(allJails, jail) + case isLocal: + allJails[idx].Enabled = jail.Enabled + } + } + } localFiles, err := sc.listRemoteFiles(ctx, jailDPath, ".local") if err != nil { debugf("Failed to list .local files in jail.d on server %s: %v", sc.server.Name, err) } else { + sort.Strings(localFiles) for _, filePath := range localFiles { filename := filepath.Base(filePath) baseName := strings.TrimSuffix(filename, ".local") @@ -885,14 +901,7 @@ func (sc *SSHConnector) getAllJailsFallback(ctx context.Context, jailDPath strin debugf("Failed to read jail file %s on server %s: %v", filePath, sc.server.Name, err) continue } - - jails := parseJailConfigContent(content) - for _, jail := range jails { - if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { - allJails = append(allJails, jail) - processedJails[jail.JailName] = true - } - } + addJails(content, true) } } @@ -900,6 +909,7 @@ func (sc *SSHConnector) getAllJailsFallback(ctx context.Context, jailDPath strin if err != nil { debugf("Failed to list .conf files in jail.d on server %s: %v", sc.server.Name, err) } else { + sort.Strings(confFiles) for _, filePath := range confFiles { filename := filepath.Base(filePath) baseName := strings.TrimSuffix(filename, ".conf") @@ -913,14 +923,7 @@ func (sc *SSHConnector) getAllJailsFallback(ctx context.Context, jailDPath strin debugf("Failed to read jail file %s on server %s: %v", filePath, sc.server.Name, err) continue } - - jails := parseJailConfigContent(content) - for _, jail := range jails { - if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { - allJails = append(allJails, jail) - processedJails[jail.JailName] = true - } - } + addJails(content, false) } } return allJails, nil @@ -930,78 +933,51 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map fail2banPath := sc.getFail2banPath(ctx) jailDPath := filepath.Join(fail2banPath, "jail.d") - // Update each jail in its own .local file for jailName, enabled := range updates { jailName = strings.TrimSpace(jailName) if jailName == "" { debugf("Skipping empty jail name in updates map") continue } + if err := ValidateJailName(jailName); err != nil { + return fmt.Errorf("invalid jail name in updates map: %w", err) + } localPath := filepath.Join(jailDPath, jailName+".local") confPath := filepath.Join(jailDPath, jailName+".conf") - - combinedScript := fmt.Sprintf(` - if [ ! -f "%s" ]; then + findScript := fmt.Sprintf(` + files=$(grep -lxF '[%s]' %s/*.local 2>/dev/null || true) + if [ -z "$files" ]; then if [ -f "%s" ]; then cp "%s" "%s" else echo "[%s]" > "%s" fi + files="%s" fi - cat "%s" - `, localPath, confPath, confPath, localPath, jailName, localPath, localPath) + echo "$files" + `, jailName, jailDPath, confPath, confPath, localPath, jailName, localPath, localPath) - content, err := sc.runRemoteCommand(ctx, []string{combinedScript}) + fileList, err := sc.runRemoteCommand(ctx, []string{findScript}) if err != nil { - return fmt.Errorf("failed to ensure and read .local file for jail %s: %w", jailName, err) + return fmt.Errorf("failed to locate .local files for jail %s: %w", jailName, err) } - lines := strings.Split(content, "\n") - var outputLines []string - var foundEnabled bool - var currentJail string - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { - currentJail = strings.Trim(trimmed, "[]") - outputLines = append(outputLines, line) - } else if strings.HasPrefix(strings.ToLower(trimmed), "enabled") { - if currentJail == jailName { - outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) - foundEnabled = true - } else { - outputLines = append(outputLines, line) - } - } else { - outputLines = append(outputLines, line) + for _, jailFilePath := range strings.Fields(fileList) { + if !strings.HasPrefix(jailFilePath, jailDPath+"/") || !strings.HasSuffix(jailFilePath, ".local") { + debugf("Skipping unexpected jail file path from remote: %s", jailFilePath) + continue } - } - - if !foundEnabled { - var newLines []string - for i, line := range outputLines { - newLines = append(newLines, line) - if strings.TrimSpace(line) == fmt.Sprintf("[%s]", jailName) { - newLines = append(newLines, fmt.Sprintf("enabled = %t", enabled)) - if i+1 < len(outputLines) { - newLines = append(newLines, outputLines[i+1:]...) - } - break - } + content, err := sc.runRemoteCommand(ctx, []string{fmt.Sprintf("cat %q", jailFilePath)}) + if err != nil { + return fmt.Errorf("failed to read jail .local file %s: %w", jailFilePath, err) } - if len(newLines) > len(outputLines) { - outputLines = newLines - } else { - outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) + newContent := rewriteJailEnabled(content, jailName, enabled) + cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailFilePath, strings.TrimSuffix(newContent, "\n")) + if _, err := sc.runRemoteCommand(ctx, []string{cmd}); err != nil { + return fmt.Errorf("failed to write jail .local file %s: %w", jailFilePath, err) } - } - - newContent := strings.Join(outputLines, "\n") - cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, newContent) - if _, err := sc.runRemoteCommand(ctx, []string{cmd}); err != nil { - return fmt.Errorf("failed to write jail .local file %s: %w", localPath, err) + debugf("Updated jail %s: enabled = %t (file: %s)", jailName, enabled, jailFilePath) } } return nil diff --git a/internal/fail2ban/filter_management.go b/internal/fail2ban/filter_management.go index 2faa873..273aa86 100644 --- a/internal/fail2ban/filter_management.go +++ b/internal/fail2ban/filter_management.go @@ -359,7 +359,7 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b continue } - // Check for end of [DEFAULT] section (next section starts) + // Check for end of [DEFAULT] section if inDefaultSection && strings.HasPrefix(trimmed, "[") { inDefaultSection = false result.WriteString(originalLine) diff --git a/internal/fail2ban/jail_management.go b/internal/fail2ban/jail_management.go index cf1d5da..cd92191 100644 --- a/internal/fail2ban/jail_management.go +++ b/internal/fail2ban/jail_management.go @@ -185,9 +185,12 @@ func DiscoverJailsFromFiles(configPath string) ([]JailInfo, error) { var allJails []JailInfo processedFiles := make(map[string]bool) + jailIndex := make(map[string]int) processedJails := make(map[string]bool) - // Parse .local files + // Parse .local files (sorted) + // Fail2ban reads jail.d lexically with last-wins semantics, so a jail defined in several .local files takes its + // state from the LAST file -> mirror that here or the UI would show a state the daemon does not have. for _, filePath := range files { if !strings.HasSuffix(filePath, ".local") { continue @@ -210,7 +213,13 @@ func DiscoverJailsFromFiles(configPath string) ([]JailInfo, error) { } for _, jail := range jails { - if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { + if jail.JailName == "" || jail.JailName == "DEFAULT" { + continue + } + if idx, seen := jailIndex[jail.JailName]; seen { + allJails[idx].Enabled = jail.Enabled + } else { + jailIndex[jail.JailName] = len(allJails) allJails = append(allJails, jail) processedJails[jail.JailName] = true } @@ -424,73 +433,127 @@ func UpdateJailEnabledStates(updates map[string]bool, configPath string) error { } debugf("Processing jail: %s, enabled: %t", jailName, enabled) - if err := ensureJailLocalFile(jailName, configPath); err != nil { - return fmt.Errorf("failed to ensure .local file for jail %s: %w", jailName, err) - } - jailFilePath, err := resolveWithinDir(jailDPath, jailName, ".local") + definingFiles, err := jailFilesDefining(jailName, jailDPath) if err != nil { return err } - debugf("Jail file path: %s", jailFilePath) - content, err := os.ReadFile(jailFilePath) + if len(definingFiles) == 0 { + if err := ensureJailLocalFile(jailName, configPath); err != nil { + return fmt.Errorf("failed to ensure .local file for jail %s: %w", jailName, err) + } + jailFilePath, err := resolveWithinDir(jailDPath, jailName, ".local") + if err != nil { + return err + } + definingFiles = []string{jailFilePath} + } + for _, jailFilePath := range definingFiles { + if err := setJailEnabledInFile(jailFilePath, jailName, enabled); err != nil { + return err + } + debugf("Updated jail %s: enabled = %t (file: %s)", jailName, enabled, jailFilePath) + } + } + return nil +} + +// Returns all jail.d/*.local files (sorted) containing a [jailName] section +func jailFilesDefining(jailName, jailDPath string) ([]string, error) { + entries, err := os.ReadDir(jailDPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read jail.d directory %s: %w", jailDPath, err) + } + sectionHeader := fmt.Sprintf("[%s]", jailName) + var files []string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".local") { + continue + } + path := filepath.Join(jailDPath, entry.Name()) + content, err := os.ReadFile(path) if err != nil { - return fmt.Errorf("failed to read jail .local file %s: %w", jailFilePath, err) + debugf("Skipping unreadable jail file %s: %v", path, err) + continue } - var lines []string - if len(content) > 0 { - lines = strings.Split(string(content), "\n") - } else { - lines = []string{fmt.Sprintf("[%s]", jailName)} + for _, line := range strings.Split(string(content), "\n") { + if strings.TrimSpace(line) == sectionHeader { + files = append(files, path) + break + } } - var outputLines []string - var foundEnabled bool - var currentJail string + } + return files, nil +} - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { - currentJail = strings.Trim(trimmed, "[]") - outputLines = append(outputLines, line) - } else if strings.HasPrefix(strings.ToLower(trimmed), "enabled") { - if currentJail == jailName { - outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) - foundEnabled = true - } else { - outputLines = append(outputLines, line) - } +// Rewrites (or inserts) the enabled line of the [jailName] section in one file +func setJailEnabledInFile(jailFilePath, jailName string, enabled bool) error { + content, err := os.ReadFile(jailFilePath) + if err != nil { + return fmt.Errorf("failed to read jail .local file %s: %w", jailFilePath, err) + } + newContent := rewriteJailEnabled(string(content), jailName, enabled) + if err := os.WriteFile(jailFilePath, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) + } + return nil +} + +// Rewrites (or inserts) the enabled line of the [jailName] section in the given file content +// Shared by the local and SSH connectors +func rewriteJailEnabled(content, jailName string, enabled bool) string { + var lines []string + if len(content) > 0 { + lines = strings.Split(content, "\n") + } else { + lines = []string{fmt.Sprintf("[%s]", jailName)} + } + var outputLines []string + var foundEnabled bool + var currentJail string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + currentJail = strings.Trim(trimmed, "[]") + outputLines = append(outputLines, line) + } else if strings.HasPrefix(strings.ToLower(trimmed), "enabled") { + if currentJail == jailName { + outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) + foundEnabled = true } else { outputLines = append(outputLines, line) } + } else { + outputLines = append(outputLines, line) } - if !foundEnabled { - var newLines []string - for i, line := range outputLines { - newLines = append(newLines, line) - if strings.TrimSpace(line) == fmt.Sprintf("[%s]", jailName) { - // Insert enabled line after the section header - newLines = append(newLines, fmt.Sprintf("enabled = %t", enabled)) - if i+1 < len(outputLines) { - newLines = append(newLines, outputLines[i+1:]...) - } - break + } + if !foundEnabled { + var newLines []string + for i, line := range outputLines { + newLines = append(newLines, line) + if strings.TrimSpace(line) == fmt.Sprintf("[%s]", jailName) { + // Insert enabled line after the section header + newLines = append(newLines, fmt.Sprintf("enabled = %t", enabled)) + if i+1 < len(outputLines) { + newLines = append(newLines, outputLines[i+1:]...) } - } - if len(newLines) > len(outputLines) { - outputLines = newLines - } else { - outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) + break } } - newContent := strings.Join(outputLines, "\n") - if !strings.HasSuffix(newContent, "\n") { - newContent += "\n" - } - if err := os.WriteFile(jailFilePath, []byte(newContent), 0644); err != nil { - return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) + if len(newLines) > len(outputLines) { + outputLines = newLines + } else { + outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) } - debugf("Updated jail %s: enabled = %t (file: %s)", jailName, enabled, jailFilePath) } - return nil + newContent := strings.Join(outputLines, "\n") + if !strings.HasSuffix(newContent, "\n") { + newContent += "\n" + } + return newContent } // Returns the full jail configuration from /etc/fail2ban/jail.d/{jailName}.local diff --git a/internal/fail2ban/jail_management_test.go b/internal/fail2ban/jail_management_test.go new file mode 100644 index 0000000..a99b9b2 --- /dev/null +++ b/internal/fail2ban/jail_management_test.go @@ -0,0 +1,120 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/agpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fail2ban + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A jail defined in two jail.d files, the UI's own .local (disabled) and a custom file (enabled) that fail2ban +// reads later (lexical last-wins). +func setupDuplicateJailDir(t *testing.T) string { + t.Helper() + configPath := t.TempDir() + jailD := filepath.Join(configPath, "jail.d") + if err := os.MkdirAll(jailD, 0o755); err != nil { + t.Fatal(err) + } + write := func(name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(jailD, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + write("recidive.local", "[recidive]\nenabled = false\n") + write("swissmakers-recidive.local", "[recidive]\nenabled = true\nfilter = recidive\nlogpath = /var/log/fail2ban.log\n") + write("apache-auth.local", "[apache-auth]\nenabled = true\n") + return configPath +} + +func TestDiscoverJailsFromFilesLastWins(t *testing.T) { + configPath := setupDuplicateJailDir(t) + + jails, err := DiscoverJailsFromFiles(configPath) + if err != nil { + t.Fatalf("DiscoverJailsFromFiles: %v", err) + } + byName := map[string]bool{} + for _, j := range jails { + byName[j.JailName] = j.Enabled + } + // swissmakers-recidive.local is read after recidive.local by fail2ban, so + // the effective state is enabled -> the UI must report the same. + if enabled, ok := byName["recidive"]; !ok || !enabled { + t.Fatalf("recidive enabled = %v (found=%v), want true (last-wins)", enabled, ok) + } + if enabled, ok := byName["apache-auth"]; !ok || !enabled { + t.Fatalf("apache-auth enabled = %v (found=%v), want true", enabled, ok) + } +} + +func TestUpdateJailEnabledStatesWritesAllDefiningFiles(t *testing.T) { + configPath := setupDuplicateJailDir(t) + jailD := filepath.Join(configPath, "jail.d") + + if err := UpdateJailEnabledStates(map[string]bool{"recidive": false}, configPath); err != nil { + t.Fatalf("UpdateJailEnabledStates: %v", err) + } + + for _, file := range []string{"recidive.local", "swissmakers-recidive.local"} { + content, err := os.ReadFile(filepath.Join(jailD, file)) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + if !strings.Contains(string(content), "enabled = false") { + t.Fatalf("%s does not contain 'enabled = false':\n%s", file, content) + } + if strings.Contains(string(content), "enabled = true") { + t.Fatalf("%s still contains the old enabled = true line:\n%s", file, content) + } + } + // Other settings in the custom file must survive the rewrite. + content, _ := os.ReadFile(filepath.Join(jailD, "swissmakers-recidive.local")) + if !strings.Contains(string(content), "logpath = /var/log/fail2ban.log") { + t.Fatalf("swissmakers-recidive.local lost unrelated settings:\n%s", content) + } + + // The UI list must reflect the now truly disabled jail. + jails, err := DiscoverJailsFromFiles(configPath) + if err != nil { + t.Fatalf("DiscoverJailsFromFiles: %v", err) + } + for _, j := range jails { + if j.JailName == "recidive" && j.Enabled { + t.Fatal("recidive still reported enabled after disabling") + } + } +} + +func TestUpdateJailEnabledStatesCreatesFileWhenUndefined(t *testing.T) { + configPath := setupDuplicateJailDir(t) + jailD := filepath.Join(configPath, "jail.d") + + if err := UpdateJailEnabledStates(map[string]bool{"sshd": true}, configPath); err != nil { + t.Fatalf("UpdateJailEnabledStates: %v", err) + } + content, err := os.ReadFile(filepath.Join(jailD, "sshd.local")) + if err != nil { + t.Fatalf("sshd.local not created: %v", err) + } + if !strings.Contains(string(content), "[sshd]") || !strings.Contains(string(content), "enabled = true") { + t.Fatalf("sshd.local content unexpected:\n%s", content) + } +} diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index d838b3d..44daa4b 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -1566,7 +1566,18 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip // 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 { settings := config.GetSettings() - country = resolveCountry(ip, country, settings) + if country == "" || whois == "" { + if storedCountry, storedWhois, err := storage.LatestBanEnrichmentForIP(ctx, ip); err == nil { + if country == "" { + country = storedCountry + } + if whois == "" { + whois = storedWhois + } + } else { + log.Printf("WARNING: failed to look up stored enrichment for IP %s: %v", ip, err) + } + } event := storage.BanEventRecord{ ServerID: server.ID, ServerName: server.Name, @@ -2810,15 +2821,6 @@ func getJailNames(jails map[string]bool) []string { return names } -func contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} - func splitLogpaths(raw string) []string { var out []string for line := range strings.SplitSeq(raw, "\n") { @@ -2832,17 +2834,19 @@ func splitLogpaths(raw string) []string { return out } -var jailErrorPattern = regexp.MustCompile(`Errors in jail '([^']+)'`) +var jailErrorPatterns = []*regexp.Regexp{ + regexp.MustCompile(`Errors in jail '([^']+)'`), + regexp.MustCompile(`Have not found any log file for (\S+) jail`), +} func parseJailErrorsFromReloadOutput(output string) []string { var problematicJails []string - lines := strings.Split(output, "\n") - - for _, line := range lines { - if strings.Contains(line, "Errors in jail") && strings.Contains(line, "Skipping") { - matches := jailErrorPattern.FindStringSubmatch(line) - if len(matches) > 1 { - problematicJails = append(problematicJails, matches[1]) + for _, line := range strings.Split(output, "\n") { + for _, pattern := range jailErrorPatterns { + for _, matches := range pattern.FindAllStringSubmatch(line, -1) { + if len(matches) > 1 { + problematicJails = append(problematicJails, matches[1]) + } } } } @@ -2982,7 +2986,10 @@ func UpdateJailManagementHandler(c *gin.Context) { } } - // If problematic jails are found, disables them // TODO: @matthias we need to further enhance this + if detailedErrorOutput != "" { + errMsg = strings.TrimSpace(detailedErrorOutput) + } + if len(problematicJails) > 0 { config.DebugLog("Found %d problematic jail(s) in reload output: %v", len(problematicJails), problematicJails) @@ -2991,30 +2998,35 @@ func UpdateJailManagementHandler(c *gin.Context) { disableUpdate[jailName] = false } - for jailName := range enabledJails { - if contains(problematicJails, jailName) { - disableUpdate[jailName] = false - } - } - - if len(disableUpdate) > 0 { - if disableErr := conn.UpdateJailEnabledStates(c.Request.Context(), disableUpdate); disableErr != nil { - config.DebugLog("Error disabling problematic jails: %v", disableErr) - } else { - // Reload again after disabling - if reloadErr2 := conn.Reload(c.Request.Context()); reloadErr2 != nil { - config.DebugLog("Error: failed to reload fail2ban after disabling problematic jails: %v", reloadErr2) + if disableErr := conn.UpdateJailEnabledStates(c.Request.Context(), disableUpdate); disableErr != nil { + config.DebugLog("Error disabling problematic jails: %v", disableErr) + } else if reloadErr2 := conn.Reload(c.Request.Context()); reloadErr2 != nil { + config.DebugLog("Error: failed to reload fail2ban after disabling problematic jails: %v", reloadErr2) + } else { + // Recovered by disabling the offenders only + var revertedToggled []string + for _, jailName := range problematicJails { + if enabledJails[jailName] { + revertedToggled = append(revertedToggled, jailName) } } + if len(revertedToggled) > 0 { + c.JSON(http.StatusOK, gin.H{ + "error": fmt.Sprintf("Jail '%s' was enabled but caused a reload error: %s. It has been automatically disabled.", strings.Join(revertedToggled, "', '"), errMsg), + "autoDisabled": true, + "enabledJails": revertedToggled, + "disabledJails": problematicJails, + }) + return + } + config.DebugLog("Disabled unrelated broken jail(s) %v; requested change kept", problematicJails) + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("Your change was applied. Unrelated jail '%s' has a broken configuration and was automatically disabled (%s).", strings.Join(problematicJails, "', '"), errMsg), + "messageKey": "jails.manage.offender_disabled", + "disabledJails": problematicJails, + }) + return } - - for _, jailName := range problematicJails { - enabledJails[jailName] = true - } - } - - if detailedErrorOutput != "" { - errMsg = strings.TrimSpace(detailedErrorOutput) } if len(enabledJails) > 0 { diff --git a/pkg/web/jail_reload_errors_test.go b/pkg/web/jail_reload_errors_test.go new file mode 100644 index 0000000..5ef8a8d --- /dev/null +++ b/pkg/web/jail_reload_errors_test.go @@ -0,0 +1,64 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/agpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package web + +import ( + "reflect" + "testing" +) + +func TestParseJailErrorsFromReloadOutput(t *testing.T) { + cases := []struct { + name string + output string + want []string + }{ + { + "missing log file (production case)", + "2026-07-25 10:59:46,905 fail2ban [399]: ERROR Failed during configuration: Have not found any log file for recidive jail", + []string{"recidive"}, + }, + { + "errors in jail with skipping (legacy format)", + "ERROR Errors in jail 'apache-badbots'. Skipping...", + []string{"apache-badbots"}, + }, + { + "errors in jail without skipping", + "ERROR Errors in jail 'sshd'.", + []string{"sshd"}, + }, + { + "multiple jails deduplicated", + "Have not found any log file for recidive jail\nErrors in jail 'recidive'. Skipping\nHave not found any log file for sshd jail", + []string{"recidive", "sshd"}, + }, + { + "no jail-specific errors", + "ERROR Unable to read the filter 'broken'", + []string{}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseJailErrorsFromReloadOutput(tc.output) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("parseJailErrorsFromReloadOutput = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/pkg/web/locales/ca.json b/pkg/web/locales/ca.json index 36f020a..887d44f 100644 --- a/pkg/web/locales/ca.json +++ b/pkg/web/locales/ca.json @@ -760,5 +760,6 @@ "logs.timeline.series_unbans": "Desbans", "logs.timeline.suggestions_empty": "No s'han trobat períodes semblants.", "logs.timeline.suggestions_title": "Períodes passats semblants", - "logs.timeline.title": "Activitat de bans i desbans" + "logs.timeline.title": "Activitat de bans i desbans", + "jails.manage.offender_disabled": "El vostre canvi s'ha aplicat. El jail no relacionat '{jail}' té una configuració defectuosa i s'ha desactivat automàticament." } diff --git a/pkg/web/locales/de.json b/pkg/web/locales/de.json index 76fd01c..98ba6d5 100644 --- a/pkg/web/locales/de.json +++ b/pkg/web/locales/de.json @@ -760,5 +760,6 @@ "logs.timeline.series_unbans": "Entsperrungen", "logs.timeline.suggestions_empty": "Keine ähnlichen Zeiträume gefunden.", "logs.timeline.suggestions_title": "Ähnliche frühere Zeiträume", - "logs.timeline.title": "Ban- & Unban-Aktivität" + "logs.timeline.title": "Ban- & Unban-Aktivität", + "jails.manage.offender_disabled": "Ihre Änderung wurde übernommen. Das nicht betroffene Jail '{jail}' hat eine fehlerhafte Konfiguration und wurde automatisch deaktiviert." } diff --git a/pkg/web/locales/de_ch.json b/pkg/web/locales/de_ch.json index 3997fcf..da3129a 100644 --- a/pkg/web/locales/de_ch.json +++ b/pkg/web/locales/de_ch.json @@ -760,5 +760,6 @@ "logs.timeline.series_unbans": "Entsperrungen", "logs.timeline.suggestions_empty": "Keine ähnlichen Zeiträume gefunden.", "logs.timeline.suggestions_title": "Ähnliche frühere Zeiträume", - "logs.timeline.title": "Ban- & Unban-Aktivität" + "logs.timeline.title": "Ban- & Unban-Aktivität", + "jails.manage.offender_disabled": "Dini Änderig isch übernoh worde. Z unbeteiligte Jail '{jail}' het e fählerhafti Konfiguration und isch automatisch deaktiviert worde." } diff --git a/pkg/web/locales/en.json b/pkg/web/locales/en.json index e64cf78..49b0207 100644 --- a/pkg/web/locales/en.json +++ b/pkg/web/locales/en.json @@ -760,5 +760,6 @@ "logs.timeline.series_unbans": "Unbans", "logs.timeline.suggestions_empty": "No similar periods found.", "logs.timeline.suggestions_title": "Similar past periods", - "logs.timeline.title": "Ban & unban activity" + "logs.timeline.title": "Ban & unban activity", + "jails.manage.offender_disabled": "Your change was applied. Unrelated jail '{jail}' has a broken configuration and was automatically disabled." } diff --git a/pkg/web/locales/es.json b/pkg/web/locales/es.json index f5fb000..570d8c1 100644 --- a/pkg/web/locales/es.json +++ b/pkg/web/locales/es.json @@ -760,5 +760,6 @@ "logs.timeline.series_unbans": "Desbaneos", "logs.timeline.suggestions_empty": "No se encontraron períodos similares.", "logs.timeline.suggestions_title": "Períodos pasados similares", - "logs.timeline.title": "Actividad de baneos y desbaneos" + "logs.timeline.title": "Actividad de baneos y desbaneos", + "jails.manage.offender_disabled": "Su cambio se ha aplicado. El jail no relacionado '{jail}' tiene una configuración defectuosa y se ha desactivado automáticamente." } diff --git a/pkg/web/locales/fr.json b/pkg/web/locales/fr.json index 5140751..9a88c34 100644 --- a/pkg/web/locales/fr.json +++ b/pkg/web/locales/fr.json @@ -760,5 +760,6 @@ "logs.timeline.series_unbans": "Débannissements", "logs.timeline.suggestions_empty": "Aucune période similaire trouvée.", "logs.timeline.suggestions_title": "Périodes passées similaires", - "logs.timeline.title": "Activité de bannissement et débannissement" + "logs.timeline.title": "Activité de bannissement et débannissement", + "jails.manage.offender_disabled": "Votre modification a été appliquée. Le jail non concerné '{jail}' a une configuration défectueuse et a été désactivé automatiquement." } diff --git a/pkg/web/locales/it.json b/pkg/web/locales/it.json index 72ca085..a4260c8 100644 --- a/pkg/web/locales/it.json +++ b/pkg/web/locales/it.json @@ -760,5 +760,6 @@ "logs.timeline.series_unbans": "Unban", "logs.timeline.suggestions_empty": "Nessun periodo simile trovato.", "logs.timeline.suggestions_title": "Periodi passati simili", - "logs.timeline.title": "Attività di ban e unban" + "logs.timeline.title": "Attività di ban e unban", + "jails.manage.offender_disabled": "La tua modifica è stata applicata. Il jail non correlato '{jail}' ha una configurazione difettosa ed è stato disabilitato automaticamente." } diff --git a/pkg/web/locales/ja.json b/pkg/web/locales/ja.json index c895bbd..e4ba589 100644 --- a/pkg/web/locales/ja.json +++ b/pkg/web/locales/ja.json @@ -760,5 +760,6 @@ "logs.timeline.series_unbans": "BAN解除", "logs.timeline.suggestions_empty": "類似した期間は見つかりませんでした。", "logs.timeline.suggestions_title": "類似した過去の期間", - "logs.timeline.title": "BAN・BAN解除のアクティビティ" + "logs.timeline.title": "BAN・BAN解除のアクティビティ", + "jails.manage.offender_disabled": "変更は適用されました。無関係のジェイル「{jail}」の設定に問題があるため、自動的に無効化されました。" } diff --git a/pkg/web/locales/zh.json b/pkg/web/locales/zh.json index cdfbc95..26679da 100644 --- a/pkg/web/locales/zh.json +++ b/pkg/web/locales/zh.json @@ -760,5 +760,6 @@ "logs.timeline.series_unbans": "解封", "logs.timeline.suggestions_empty": "未找到相似的时段。", "logs.timeline.suggestions_title": "相似的历史时段", - "logs.timeline.title": "封禁与解封活动" + "logs.timeline.title": "封禁与解封活动", + "jails.manage.offender_disabled": "您的更改已应用。无关的监狱 '{jail}' 配置有误,已被自动禁用。" } diff --git a/pkg/web/static/js/core.js b/pkg/web/static/js/core.js index afe9e56..02e54f6 100644 --- a/pkg/web/static/js/core.js +++ b/pkg/web/static/js/core.js @@ -83,7 +83,7 @@ function showBanEventToast(event) { var ip = event.ip || 'Unknown IP'; var jail = event.jail || 'Unknown Jail'; var server = event.serverName || event.serverId || 'Unknown Server'; - var country = event.country || 'UNKNOWN'; + var country = event.country || ''; var title = isUnban ? t('toast.unban.title', 'IP unblocked') : t('toast.ban.title', 'New block occurred'); var action = isUnban ? t('toast.unban.action', 'unblocked from') : t('toast.ban.action', 'banned in'); @@ -102,7 +102,7 @@ function showBanEventToast(event) { + ' ' + escapeHtml(jail) + '' + '
' + '
' - + ' ' + escapeHtml(server) + ' - ' + escapeHtml(country) + + ' ' + escapeHtml(server) + (country ? ' - ' + escapeHtml(country) : '') + '
' + ' ' + '