From c57322e38dcb73bd8ca6ca787b775f9ce776ed54 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Mon, 15 Dec 2025 21:50:19 +0100 Subject: [PATCH] Implement geoIP and whois lookups directly from fail2ban-UI --- Dockerfile | 2 +- go.mod | 9 +- go.sum | 9 ++ internal/config/settings.go | 20 ++- internal/locales/de.json | 8 ++ internal/locales/de_ch.json | 8 ++ internal/locales/en.json | 8 ++ internal/locales/es.json | 8 ++ internal/locales/fr.json | 8 ++ internal/locales/it.json | 8 ++ internal/storage/storage.go | 73 ++++++++--- pkg/web/advanced_actions.go | 6 +- pkg/web/handlers.go | 216 +++++++++++++++++++++++++++++++-- pkg/web/static/fail2ban-ui.css | 4 +- pkg/web/static/js/core.js | 2 +- pkg/web/static/js/settings.js | 22 ++++ pkg/web/templates/index.html | 26 ++++ pkg/web/websocket.go | 3 - pkg/web/whois.go | 125 +++++++++++++++++++ 19 files changed, 523 insertions(+), 42 deletions(-) create mode 100644 pkg/web/whois.go diff --git a/Dockerfile b/Dockerfile index 9bbebbf..f8371f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # ========================================= # STAGE 1: Build Fail2Ban UI Binary # ========================================= -FROM golang:1.23 AS builder +FROM golang:1.24 AS builder WORKDIR /app diff --git a/go.mod b/go.mod index 3fad333..c14fe13 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/swissmakers/fail2ban-ui -go 1.23 +go 1.24.0 + +toolchain go1.24.6 require ( github.com/gin-gonic/gin v1.10.0 @@ -26,6 +28,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/likexian/whois v1.15.6 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -35,9 +38,9 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.13.0 // indirect - golang.org/x/net v0.34.0 // indirect + golang.org/x/net v0.35.0 // indirect golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/text v0.32.0 // indirect google.golang.org/protobuf v1.36.4 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect diff --git a/go.sum b/go.sum index e9c8f32..025c3df 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,8 @@ github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/likexian/whois v1.15.6 h1:hizngFHJTNQDlhwhU+FEGyPGxy8bRnf25gHDNrSB4Ag= +github.com/likexian/whois v1.15.6/go.mod h1:vx3kt3sZ4mx4XFgpaNp3GXQCZQIzAoyrUAkRtJwoM2I= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -85,10 +87,14 @@ golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -96,8 +102,11 @@ golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= diff --git a/internal/config/settings.go b/internal/config/settings.go index 41764ac..88ad378 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -70,6 +70,11 @@ type AppSettings struct { Banaction string `json:"banaction"` // Default banning action BanactionAllports string `json:"banactionAllports"` // Allports banning action //Sender string `json:"sender"` + + // GeoIP and Whois settings + GeoIPProvider string `json:"geoipProvider"` // "maxmind" or "builtin" + GeoIPDatabasePath string `json:"geoipDatabasePath"` // Path to MaxMind database (optional) + MaxLogLines int `json:"maxLogLines"` // Maximum log lines to include (default: 50) } type AdvancedActionsConfig struct { @@ -172,9 +177,8 @@ actionban = /usr/bin/curl -X POST __CALLBACK_URL__/api/ban \ --arg jail '' \ --arg hostname '' \ --arg failures '' \ - --arg whois "$(whois || echo 'missing whois program')" \ --arg logs "$(tac | grep -wF )" \ - '{serverId: $serverId, ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, whois: $whois, logs: $logs}')" + '{serverId: $serverId, ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, logs: $logs}')" [Init] @@ -458,6 +462,9 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) { Banaction: currentSettings.Banaction, BanactionAllports: currentSettings.BanactionAllports, AdvancedActionsJSON: string(advancedBytes), + GeoIPProvider: currentSettings.GeoIPProvider, + GeoIPDatabasePath: currentSettings.GeoIPDatabasePath, + MaxLogLines: currentSettings.MaxLogLines, }, nil } @@ -577,6 +584,15 @@ func setDefaultsLocked() { if currentSettings.BanactionAllports == "" { currentSettings.BanactionAllports = "iptables-allports" } + if currentSettings.GeoIPProvider == "" { + currentSettings.GeoIPProvider = "builtin" + } + if currentSettings.GeoIPDatabasePath == "" { + currentSettings.GeoIPDatabasePath = "/usr/share/GeoIP/GeoLite2-Country.mmdb" + } + if currentSettings.MaxLogLines == 0 { + currentSettings.MaxLogLines = 50 + } if (currentSettings.AdvancedActions == AdvancedActionsConfig{}) { currentSettings.AdvancedActions = defaultAdvancedActionsConfig() diff --git a/internal/locales/de.json b/internal/locales/de.json index 22eda74..6a6805f 100644 --- a/internal/locales/de.json +++ b/internal/locales/de.json @@ -138,6 +138,14 @@ "settings.default_max_retry": "Standard-Maximalversuche", "settings.default_max_retry.description": "Anzahl der Fehler, bevor ein Host gesperrt wird.", "settings.default_max_retry_placeholder": "Geben Sie die maximale Anzahl der Versuche ein", + "settings.geoip_provider": "GeoIP-Anbieter", + "settings.geoip_provider.description": "Wählen Sie den GeoIP-Lookup-Anbieter. MaxMind erfordert eine lokale Datenbankdatei, während Built-in eine kostenlose Online-API verwendet.", + "settings.geoip_provider.maxmind": "MaxMind (Lokale Datenbank)", + "settings.geoip_provider.builtin": "Built-in (ip-api.com)", + "settings.geoip_database_path": "GeoIP-Datenbankpfad", + "settings.geoip_database_path.description": "Pfad zur MaxMind GeoLite2-Country-Datenbankdatei.", + "settings.max_log_lines": "Maximale Log-Zeilen", + "settings.max_log_lines.description": "Maximale Anzahl von Log-Zeilen, die in Ban-Benachrichtigungen enthalten sein sollen. Die relevantesten Zeilen werden automatisch ausgewählt.", "settings.ignore_ips": "IP-Adressen ignorieren", "settings.ignore_ips.description": "Durch Leerzeichen getrennte Liste von IP-Adressen, CIDR-Masken oder DNS-Hosts. Fail2ban wird keinen Host sperren, der mit einer Adresse in dieser Liste übereinstimmt.", "settings.ignore_ips_placeholder": "IP-Adressen, getrennt durch Leerzeichen", diff --git a/internal/locales/de_ch.json b/internal/locales/de_ch.json index e11dd15..2ce8fe5 100644 --- a/internal/locales/de_ch.json +++ b/internal/locales/de_ch.json @@ -138,6 +138,14 @@ "settings.default_max_retry": "Standard-Maximalversüech", "settings.default_max_retry.description": "Aazahl vo de Fähler, bevor ä Host gsperrt wird.", "settings.default_max_retry_placeholder": "Gib d'maximal Versüech ii", + "settings.geoip_provider": "GeoIP-Aabieter", + "settings.geoip_provider.description": "Wähl di GeoIP-Aabieter. MaxMind brucht e lokali Datenbankdatei, während Built-in e gratis Online-API verwendet.", + "settings.geoip_provider.maxmind": "MaxMind (Lokali Datenbank)", + "settings.geoip_provider.builtin": "Built-in (ip-api.com)", + "settings.geoip_database_path": "GeoIP-Datenbankpfad", + "settings.geoip_database_path.description": "Pfad zur MaxMind GeoLite2-Country-Datebank.", + "settings.max_log_lines": "Maximali Log-Zeile", + "settings.max_log_lines.description": "Maximali Aazahl vo Log-Zeile, wo i Ban-Benachrichtigunge enthalte si söll. Di relevanteschte Zeile werdet automatisch usgwählt.", "settings.ignore_ips": "IPs ignorierä", "settings.ignore_ips.description": "Dur Leerzeichä trennti Lischte vo IP-Adrässe, CIDR-Maske oder DNS-Hosts. Fail2ban wird kei Host sperre, wo mit ere Adrässe i dere Lischte übereistimmt.", "settings.ignore_ips_placeholder": "IPs, getrennt dur e Leerzeichä", diff --git a/internal/locales/en.json b/internal/locales/en.json index a81104c..7fc541e 100644 --- a/internal/locales/en.json +++ b/internal/locales/en.json @@ -138,6 +138,14 @@ "settings.default_max_retry": "Default Max Retry", "settings.default_max_retry.description": "Number of failures before a host gets banned.", "settings.default_max_retry_placeholder": "Enter maximum retries", + "settings.geoip_provider": "GeoIP Provider", + "settings.geoip_provider.description": "Choose the GeoIP lookup provider. MaxMind requires a local database file, while Built-in uses a free online API.", + "settings.geoip_provider.maxmind": "MaxMind (Local Database)", + "settings.geoip_provider.builtin": "Built-in (ip-api.com)", + "settings.geoip_database_path": "GeoIP Database Path", + "settings.geoip_database_path.description": "Path to the MaxMind GeoLite2-Country database file.", + "settings.max_log_lines": "Maximum Log Lines", + "settings.max_log_lines.description": "Maximum number of log lines to include in ban notifications. Most relevant lines are selected automatically.", "settings.ignore_ips": "Ignore IPs", "settings.ignore_ips.description": "Space separated list of IP addresses, CIDR masks or DNS hosts. Fail2ban will not ban a host which matches an address in this list.", "settings.ignore_ips_placeholder": "IPs to ignore, separated by spaces", diff --git a/internal/locales/es.json b/internal/locales/es.json index 3005432..73cdce3 100644 --- a/internal/locales/es.json +++ b/internal/locales/es.json @@ -138,6 +138,14 @@ "settings.default_max_retry": "Número máximo de reintentos por defecto", "settings.default_max_retry.description": "Número de fallos antes de que un host sea bloqueado.", "settings.default_max_retry_placeholder": "Introduce el número máximo de reintentos", + "settings.geoip_provider": "Proveedor de GeoIP", + "settings.geoip_provider.description": "Elija el proveedor de consulta GeoIP. MaxMind requiere un archivo de base de datos local, mientras que Built-in utiliza una API en línea gratuita.", + "settings.geoip_provider.maxmind": "MaxMind (Base de Datos Local)", + "settings.geoip_provider.builtin": "Built-in (ip-api.com)", + "settings.geoip_database_path": "Ruta de la Base de Datos GeoIP", + "settings.geoip_database_path.description": "Ruta al archivo de base de datos MaxMind GeoLite2-Country.", + "settings.max_log_lines": "Líneas de Log Máximas", + "settings.max_log_lines.description": "Número máximo de líneas de log a incluir en las notificaciones de bloqueo. Las líneas más relevantes se seleccionan automáticamente.", "settings.ignore_ips": "Ignorar IPs", "settings.ignore_ips.description": "Lista separada por espacios de direcciones IP, máscaras CIDR o hosts DNS. Fail2ban no bloqueará un host que coincida con una dirección en esta lista.", "settings.ignore_ips_placeholder": "IPs a ignorar, separadas por espacios", diff --git a/internal/locales/fr.json b/internal/locales/fr.json index dcdede8..e4c2071 100644 --- a/internal/locales/fr.json +++ b/internal/locales/fr.json @@ -138,6 +138,14 @@ "settings.default_max_retry": "Nombre maximal de réessais par défaut", "settings.default_max_retry.description": "Nombre d'échecs avant qu'un hôte ne soit banni.", "settings.default_max_retry_placeholder": "Entrez le nombre maximal de réessais", + "settings.geoip_provider": "Fournisseur GeoIP", + "settings.geoip_provider.description": "Choisissez le fournisseur de recherche GeoIP. MaxMind nécessite un fichier de base de données local, tandis que Built-in utilise une API en ligne gratuite.", + "settings.geoip_provider.maxmind": "MaxMind (Base de Données Locale)", + "settings.geoip_provider.builtin": "Built-in (ip-api.com)", + "settings.geoip_database_path": "Chemin de la Base de Données GeoIP", + "settings.geoip_database_path.description": "Chemin vers le fichier de base de données MaxMind GeoLite2-Country.", + "settings.max_log_lines": "Lignes de Log Maximales", + "settings.max_log_lines.description": "Nombre maximal de lignes de log à inclure dans les notifications de bannissement. Les lignes les plus pertinentes sont sélectionnées automatiquement.", "settings.ignore_ips": "Ignorer les IPs", "settings.ignore_ips.description": "Liste séparée par des espaces d'adresses IP, de masques CIDR ou d'hôtes DNS. Fail2ban ne bannira pas un hôte qui correspond à une adresse de cette liste.", "settings.ignore_ips_placeholder": "IPs à ignorer, séparées par des espaces", diff --git a/internal/locales/it.json b/internal/locales/it.json index 7ed02db..cbbe16a 100644 --- a/internal/locales/it.json +++ b/internal/locales/it.json @@ -138,6 +138,14 @@ "settings.default_max_retry": "Numero massimo di tentativi predefinito", "settings.default_max_retry.description": "Numero di errori prima che un host venga bannato.", "settings.default_max_retry_placeholder": "Inserisci il numero massimo di tentativi", + "settings.geoip_provider": "Provider GeoIP", + "settings.geoip_provider.description": "Scegli il provider di ricerca GeoIP. MaxMind richiede un file di database locale, mentre Built-in utilizza un'API online gratuita.", + "settings.geoip_provider.maxmind": "MaxMind (Database Locale)", + "settings.geoip_provider.builtin": "Built-in (ip-api.com)", + "settings.geoip_database_path": "Percorso Database GeoIP", + "settings.geoip_database_path.description": "Percorso al file del database MaxMind GeoLite2-Country.", + "settings.max_log_lines": "Righe di Log Massime", + "settings.max_log_lines.description": "Numero massimo di righe di log da includere nelle notifiche di ban. Le righe più rilevanti vengono selezionate automaticamente.", "settings.ignore_ips": "Ignora IP", "settings.ignore_ips.description": "Elenco separato da spazi di indirizzi IP, maschere CIDR o host DNS. Fail2ban non bannerà un host che corrisponde a un indirizzo in questo elenco.", "settings.ignore_ips_placeholder": "IP da ignorare, separate da spazi", diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 57f9f6b..0fafa50 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -70,6 +70,9 @@ type AppSettingsRecord struct { Banaction string BanactionAllports string AdvancedActionsJSON string + GeoIPProvider string + GeoIPDatabasePath string + MaxLogLines int } type ServerRecord struct { @@ -171,17 +174,17 @@ func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) { } row := db.QueryRowContext(ctx, ` -SELECT language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, default_jail_enable, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions +SELECT language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, default_jail_enable, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions, geoip_provider, geoip_database_path, max_log_lines FROM app_settings WHERE id = 1`) var ( - lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, banaction, banactionAllports, advancedActions sql.NullString - port, smtpPort, maxretry sql.NullInt64 - debug, restartNeeded, smtpTLS, bantimeInc, defaultJailEn sql.NullInt64 + lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, banaction, banactionAllports, advancedActions, geoipProvider, geoipDatabasePath sql.NullString + port, smtpPort, maxretry, maxLogLines sql.NullInt64 + debug, restartNeeded, smtpTLS, bantimeInc, defaultJailEn sql.NullInt64 ) - err := row.Scan(&lang, &port, &debug, &callback, &restartNeeded, &alerts, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &defaultJailEn, &ignoreIP, &bantime, &findtime, &maxretry, &destemail, &banaction, &banactionAllports, &advancedActions) + err := row.Scan(&lang, &port, &debug, &callback, &restartNeeded, &alerts, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &defaultJailEn, &ignoreIP, &bantime, &findtime, &maxretry, &destemail, &banaction, &banactionAllports, &advancedActions, &geoipProvider, &geoipDatabasePath, &maxLogLines) if errors.Is(err, sql.ErrNoRows) { return AppSettingsRecord{}, false, nil } @@ -212,6 +215,9 @@ WHERE id = 1`) Banaction: stringFromNull(banaction), BanactionAllports: stringFromNull(banactionAllports), AdvancedActionsJSON: stringFromNull(advancedActions), + GeoIPProvider: stringFromNull(geoipProvider), + GeoIPDatabasePath: stringFromNull(geoipDatabasePath), + MaxLogLines: intFromNull(maxLogLines), } return rec, true, nil @@ -223,9 +229,9 @@ func SaveAppSettings(ctx context.Context, rec AppSettingsRecord) error { } _, err := db.ExecContext(ctx, ` INSERT INTO app_settings ( - id, language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, default_jail_enable, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions + id, language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, default_jail_enable, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions, geoip_provider, geoip_database_path, max_log_lines ) VALUES ( - 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) ON CONFLICT(id) DO UPDATE SET language = excluded.language, port = excluded.port, @@ -248,7 +254,10 @@ INSERT INTO app_settings ( destemail = excluded.destemail, banaction = excluded.banaction, banaction_allports = excluded.banaction_allports, - advanced_actions = excluded.advanced_actions + advanced_actions = excluded.advanced_actions, + geoip_provider = excluded.geoip_provider, + geoip_database_path = excluded.geoip_database_path, + max_log_lines = excluded.max_log_lines `, rec.Language, rec.Port, boolToInt(rec.Debug), @@ -271,6 +280,9 @@ INSERT INTO app_settings ( rec.Banaction, rec.BanactionAllports, rec.AdvancedActionsJSON, + rec.GeoIPProvider, + rec.GeoIPDatabasePath, + rec.MaxLogLines, ) return err } @@ -451,7 +463,7 @@ INSERT INTO ban_events ( server_id, server_name, jail, ip, country, hostname, failures, whois, logs, occurred_at, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - result, err := db.ExecContext( + _, err := db.ExecContext( ctx, query, record.ServerID, @@ -470,12 +482,6 @@ INSERT INTO ban_events ( return err } - // Get the inserted ID - id, err := result.LastInsertId() - if err == nil { - record.ID = id - } - return nil } @@ -797,7 +803,10 @@ CREATE TABLE IF NOT EXISTS app_settings ( destemail TEXT, banaction TEXT, banaction_allports TEXT, - advanced_actions TEXT + advanced_actions TEXT, + geoip_provider TEXT, + geoip_database_path TEXT, + max_log_lines INTEGER ); CREATE TABLE IF NOT EXISTS servers ( @@ -884,6 +893,38 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status); } } + // Add geoip_provider column + if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN geoip_provider TEXT`); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + return err + } + } + + // Add geoip_database_path column + if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN geoip_database_path TEXT`); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + return err + } + } + + // Add max_log_lines column + if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN max_log_lines INTEGER`); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + return err + } + } + + // Set default values for new columns if they are NULL + if _, err := db.ExecContext(ctx, `UPDATE app_settings SET geoip_provider = 'maxmind' WHERE geoip_provider IS NULL`); err != nil { + log.Printf("Warning: Failed to set default value for geoip_provider: %v", err) + } + if _, err := db.ExecContext(ctx, `UPDATE app_settings SET geoip_database_path = '/usr/share/GeoIP/GeoLite2-Country.mmdb' WHERE geoip_database_path IS NULL`); err != nil { + log.Printf("Warning: Failed to set default value for geoip_database_path: %v", err) + } + if _, err := db.ExecContext(ctx, `UPDATE app_settings SET max_log_lines = 50 WHERE max_log_lines IS NULL OR max_log_lines = 0`); err != nil { + log.Printf("Warning: Failed to set default value for max_log_lines: %v", err) + } + return nil } diff --git a/pkg/web/advanced_actions.go b/pkg/web/advanced_actions.go index 90a4aa3..61c4a73 100644 --- a/pkg/web/advanced_actions.go +++ b/pkg/web/advanced_actions.go @@ -5,7 +5,9 @@ import ( "encoding/json" "fmt" "log" - "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" "github.com/swissmakers/fail2ban-ui/internal/config" "github.com/swissmakers/fail2ban-ui/internal/integrations" @@ -84,7 +86,7 @@ func runAdvancedIntegrationAction(ctx context.Context, action, ip string, settin "unblock": "unblocked", }[action] - message := fmt.Sprintf("%s via %s", strings.Title(action), cfg.Integration) + message := fmt.Sprintf("%s via %s", cases.Title(language.English).String(action), cfg.Integration) if err != nil && !skipLoggingIfAlreadyBlocked { status = "error" message = err.Error() diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 09451fa..af76958 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -177,7 +177,7 @@ func BanNotificationHandler(c *gin.Context) { Jail string `json:"jail" binding:"required"` Hostname string `json:"hostname"` Failures string `json:"failures"` - Whois string `json:"whois"` + Whois string `json:"whois"` // Optional for backward compatibility Logs string `json:"logs"` } @@ -585,14 +585,41 @@ func TestServerHandler(c *gin.Context) { // HandleBanNotification processes Fail2Ban notifications, checks geo-location, stores the event, and sends alerts. func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, failures, whois, logs string) error { - // Load settings to get alert countries + // Load settings to get alert countries and GeoIP provider settings := config.GetSettings() - // Lookup the country for the given IP - country, err := lookupCountry(ip) + // Perform whois lookup if not provided (backward compatibility) + var whoisData string + var err error + if whois == "" || whois == "missing whois program" { + log.Printf("Performing whois lookup for IP %s", ip) + whoisData, err = lookupWhois(ip) + if err != nil { + log.Printf("⚠️ Whois lookup failed for IP %s: %v", ip, err) + whoisData = "" + } + } else { + log.Printf("Using provided whois data for IP %s", ip) + whoisData = whois + } + + // Filter logs to show relevant lines + filteredLogs := filterRelevantLogs(logs, ip, settings.MaxLogLines) + + // Lookup the country for the given IP using configured provider + country, err := lookupCountry(ip, settings.GeoIPProvider, settings.GeoIPDatabasePath) if err != nil { log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err) - country = "" + // Try to extract country from whois as fallback + if whoisData != "" { + country = extractCountryFromWhois(whoisData) + if country != "" { + log.Printf("Extracted country %s from whois data for IP %s", country, ip) + } + } + if country == "" { + country = "" + } } event := storage.BanEventRecord{ @@ -603,8 +630,8 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip Country: country, Hostname: hostname, Failures: failures, - Whois: whois, - Logs: logs, + Whois: whoisData, + Logs: filteredLogs, OccurredAt: time.Now().UTC(), } if err := storage.RecordBanEvent(ctx, event); err != nil { @@ -630,7 +657,7 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip } // Send email notification - if err := sendBanAlert(ip, jail, hostname, failures, whois, logs, country, settings); err != nil { + if err := sendBanAlert(ip, jail, hostname, failures, whoisData, filteredLogs, country, settings); err != nil { log.Printf("❌ Failed to send alert email: %v", err) return err } @@ -639,8 +666,29 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip return nil } -// lookupCountry finds the country ISO code for a given IP using MaxMind GeoLite2 database. -func lookupCountry(ip string) (string, error) { +// lookupCountry finds the country ISO code for a given IP using the configured provider. +func lookupCountry(ip, provider, dbPath string) (string, error) { + switch provider { + case "builtin": + return lookupCountryBuiltin(ip) + case "maxmind", "": + // Default to maxmind if empty + if dbPath == "" { + dbPath = "/usr/share/GeoIP/GeoLite2-Country.mmdb" + } + return lookupCountryMaxMind(ip, dbPath) + default: + // Unknown provider, try maxmind as fallback + log.Printf("Unknown GeoIP provider '%s', falling back to MaxMind", provider) + if dbPath == "" { + dbPath = "/usr/share/GeoIP/GeoLite2-Country.mmdb" + } + return lookupCountryMaxMind(ip, dbPath) + } +} + +// lookupCountryMaxMind finds the country ISO code using MaxMind GeoLite2 database. +func lookupCountryMaxMind(ip, dbPath string) (string, error) { // Convert the IP string to net.IP parsedIP := net.ParseIP(ip) if parsedIP == nil { @@ -648,9 +696,9 @@ func lookupCountry(ip string) (string, error) { } // Open the GeoIP database - db, err := maxminddb.Open("/usr/share/GeoIP/GeoLite2-Country.mmdb") + db, err := maxminddb.Open(dbPath) if err != nil { - return "", fmt.Errorf("failed to open GeoIP database: %w", err) + return "", fmt.Errorf("failed to open GeoIP database at %s: %w", dbPath, err) } defer db.Close() @@ -670,6 +718,150 @@ func lookupCountry(ip string) (string, error) { return record.Country.ISOCode, nil } +// lookupCountryBuiltin finds the country ISO code using ip-api.com free API. +func lookupCountryBuiltin(ip string) (string, error) { + // Convert the IP string to net.IP to validate + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return "", fmt.Errorf("invalid IP address: %s", ip) + } + + // Use ip-api.com free API (no account needed, rate limited to 45 requests/minute) + url := fmt.Sprintf("http://ip-api.com/json/%s?fields=countryCode", ip) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + client := &http.Client{ + Timeout: 5 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to query ip-api.com: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("ip-api.com returned status %d", resp.StatusCode) + } + + var result struct { + CountryCode string `json:"countryCode"` + Status string `json:"status"` + Message string `json:"message"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + if result.Status == "fail" { + return "", fmt.Errorf("ip-api.com error: %s", result.Message) + } + + return result.CountryCode, nil +} + +// filterRelevantLogs filters log lines to show the most relevant ones that caused the block. +func filterRelevantLogs(logs, ip string, maxLines int) string { + if logs == "" { + return "" + } + + if maxLines <= 0 { + maxLines = 50 // Default + } + + lines := strings.Split(logs, "\n") + if len(lines) <= maxLines { + return logs // Return as-is if within limit + } + + // Priority indicators for relevant log lines + priorityPatterns := []string{ + "denied", "deny", "forbidden", "unauthorized", "failed", "failure", + "error", "403", "404", "401", "500", "502", "503", + "invalid", "rejected", "blocked", "ban", + } + + // Score each line based on relevance + type scoredLine struct { + line string + score int + index int + } + + scored := make([]scoredLine, len(lines)) + for i, line := range lines { + lineLower := strings.ToLower(line) + score := 0 + + // Check if line contains the IP + if strings.Contains(line, ip) { + score += 10 + } + + // Check for priority patterns + for _, pattern := range priorityPatterns { + if strings.Contains(lineLower, pattern) { + score += 5 + } + } + + // Recent lines get higher score (lines at the end are more recent) + score += (len(lines) - i) / 10 + + scored[i] = scoredLine{ + line: line, + score: score, + index: i, + } + } + + // Sort by score (descending) + for i := 0; i < len(scored)-1; i++ { + for j := i + 1; j < len(scored); j++ { + if scored[i].score < scored[j].score { + scored[i], scored[j] = scored[j], scored[i] + } + } + } + + // Take top N lines and sort by original index to maintain chronological order + selected := scored[:maxLines] + for i := 0; i < len(selected)-1; i++ { + for j := i + 1; j < len(selected); j++ { + if selected[i].index > selected[j].index { + selected[i], selected[j] = selected[j], selected[i] + } + } + } + + // Build result + result := make([]string, len(selected)) + for i, s := range selected { + result[i] = s.line + } + + // Remove duplicate consecutive lines + filtered := []string{} + lastLine := "" + for _, line := range result { + if line != lastLine { + filtered = append(filtered, line) + lastLine = line + } + } + + return strings.Join(filtered, "\n") +} + // shouldAlertForCountry checks if an IP’s country is in the allowed alert list. func shouldAlertForCountry(country string, alertCountries []string) bool { if len(alertCountries) == 0 || strings.Contains(strings.Join(alertCountries, ","), "ALL") { diff --git a/pkg/web/static/fail2ban-ui.css b/pkg/web/static/fail2ban-ui.css index ae4d1b7..b2c7256 100644 --- a/pkg/web/static/fail2ban-ui.css +++ b/pkg/web/static/fail2ban-ui.css @@ -180,9 +180,9 @@ mark { transition: background-color 0.2s ease; } -#backendStatus:hover { +/*#backendStatus:hover { background-color: rgba(255, 255, 255, 0.15); -} +}*/ #statusDot { width: 0.5rem; diff --git a/pkg/web/static/js/core.js b/pkg/web/static/js/core.js index 33f7b98..8f80cba 100644 --- a/pkg/web/static/js/core.js +++ b/pkg/web/static/js/core.js @@ -63,7 +63,7 @@ function showBanEventToast(event) { + '
New Block Detected
' + '
' + ' ' + escapeHtml(ip) + '' - + ' banned in ' + + ' banned in ' + ' ' + escapeHtml(jail) + '' + '
' + '
' diff --git a/pkg/web/static/js/settings.js b/pkg/web/static/js/settings.js index c16cff5..2d56d87 100644 --- a/pkg/web/static/js/settings.js +++ b/pkg/web/static/js/settings.js @@ -1,6 +1,18 @@ // Settings page functions for Fail2ban UI "use strict"; +// Handle GeoIP provider change +function onGeoIPProviderChange(provider) { + const dbPathContainer = document.getElementById('geoipDatabasePathContainer'); + if (dbPathContainer) { + if (provider === 'maxmind') { + dbPathContainer.style.display = 'block'; + } else { + dbPathContainer.style.display = 'none'; + } + } +} + function loadSettings() { showLoading(true); fetch('/api/settings') @@ -83,6 +95,13 @@ function loadSettings() { document.getElementById('bantimeIncrement').checked = data.bantimeIncrement || false; document.getElementById('defaultJailEnable').checked = data.defaultJailEnable || false; + + // GeoIP settings + const geoipProvider = data.geoipProvider || 'builtin'; + document.getElementById('geoipProvider').value = geoipProvider; + onGeoIPProviderChange(geoipProvider); + document.getElementById('geoipDatabasePath').value = data.geoipDatabasePath || '/usr/share/GeoIP/GeoLite2-Country.mmdb'; + document.getElementById('maxLogLines').value = data.maxLogLines || 50; document.getElementById('banTime').value = data.bantime || ''; document.getElementById('findTime').value = data.findtime || ''; document.getElementById('maxRetry').value = data.maxretry || ''; @@ -149,6 +168,9 @@ function saveSettings(event) { ignoreips: getIgnoreIPsArray(), banaction: document.getElementById('banaction').value, banactionAllports: document.getElementById('banactionAllports').value, + geoipProvider: document.getElementById('geoipProvider').value || 'builtin', + geoipDatabasePath: document.getElementById('geoipDatabasePath').value || '/usr/share/GeoIP/GeoLite2-Country.mmdb', + maxLogLines: parseInt(document.getElementById('maxLogLines').value, 10) || 50, smtp: smtpSettings, advancedActions: collectAdvancedActionsSettings() }; diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index d00d7af..dd3280b 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -330,12 +330,38 @@

Alert Settings

+
+ + +
+ + +

Choose the GeoIP lookup provider. MaxMind requires a local database file, while Built-in uses a free online API.

+
+ + +
+ + +

Path to the MaxMind GeoLite2-Country database file.

+
+ + +
+ + +

Maximum number of log lines to include in ban notifications. Most relevant lines are selected automatically.

+
+

diff --git a/pkg/web/websocket.go b/pkg/web/websocket.go index ec91001..960bb06 100644 --- a/pkg/web/websocket.go +++ b/pkg/web/websocket.go @@ -37,9 +37,6 @@ const ( // Send pings to peer with this period (must be less than pongWait) pingPeriod = (pongWait * 9) / 10 - - // Maximum message size allowed from peer - maxMessageSize = 512 ) var upgrader = websocket.Upgrader{ diff --git a/pkg/web/whois.go b/pkg/web/whois.go new file mode 100644 index 0000000..6bf028a --- /dev/null +++ b/pkg/web/whois.go @@ -0,0 +1,125 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on "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 ( + "fmt" + "strings" + "sync" + "time" + + "github.com/likexian/whois" +) + +var ( + whoisCache = make(map[string]cachedWhois) + whoisCacheMutex sync.RWMutex + cacheExpiry = 24 * time.Hour +) + +type cachedWhois struct { + data string + timestamp time.Time +} + +// lookupWhois performs a whois lookup for the given IP address. +// It uses caching to avoid repeated queries for the same IP. +func lookupWhois(ip string) (string, error) { + // Check cache first + whoisCacheMutex.RLock() + if cached, ok := whoisCache[ip]; ok { + if time.Since(cached.timestamp) < cacheExpiry { + whoisCacheMutex.RUnlock() + return cached.data, nil + } + } + whoisCacheMutex.RUnlock() + + // Perform whois lookup with timeout + done := make(chan string, 1) + errChan := make(chan error, 1) + + go func() { + whoisData, err := whois.Whois(ip) + if err != nil { + errChan <- err + return + } + done <- whoisData + }() + + var whoisData string + select { + case whoisData = <-done: + // Success - cache will be updated below + case err := <-errChan: + return "", fmt.Errorf("whois lookup failed: %w", err) + case <-time.After(10 * time.Second): + return "", fmt.Errorf("whois lookup timeout after 10 seconds") + } + + // Cache the result + whoisCacheMutex.Lock() + whoisCache[ip] = cachedWhois{ + data: whoisData, + timestamp: time.Now(), + } + // Clean old cache entries if cache is getting large + if len(whoisCache) > 1000 { + now := time.Now() + for k, v := range whoisCache { + if now.Sub(v.timestamp) > cacheExpiry { + delete(whoisCache, k) + } + } + } + whoisCacheMutex.Unlock() + + return whoisData, nil +} + +// extractCountryFromWhois attempts to extract country code from whois data. +// This is a fallback if GeoIP lookup fails. +func extractCountryFromWhois(whoisData string) string { + lines := strings.Split(whoisData, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + lineLower := strings.ToLower(line) + + // Look for country field + if strings.HasPrefix(lineLower, "country:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + country := strings.TrimSpace(parts[1]) + if len(country) == 2 { + return strings.ToUpper(country) + } + } + } + // Alternative format + if strings.HasPrefix(lineLower, "country code:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + country := strings.TrimSpace(parts[1]) + if len(country) == 2 { + return strings.ToUpper(country) + } + } + } + } + return "" +}