Implement geoIP and whois lookups directly from fail2ban-UI

This commit is contained in:
2025-12-15 21:50:19 +01:00
parent 3ad4821cb7
commit c57322e38d
19 changed files with 523 additions and 42 deletions

View File

@@ -1,7 +1,7 @@
# ========================================= # =========================================
# STAGE 1: Build Fail2Ban UI Binary # STAGE 1: Build Fail2Ban UI Binary
# ========================================= # =========================================
FROM golang:1.23 AS builder FROM golang:1.24 AS builder
WORKDIR /app WORKDIR /app

9
go.mod
View File

@@ -1,6 +1,8 @@
module github.com/swissmakers/fail2ban-ui module github.com/swissmakers/fail2ban-ui
go 1.23 go 1.24.0
toolchain go1.24.6
require ( require (
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
@@ -26,6 +28,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // 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/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.13.0 // 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/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 google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect

9
go.sum
View File

@@ -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/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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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= 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/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 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 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 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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/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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 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 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.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 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=

View File

@@ -70,6 +70,11 @@ type AppSettings struct {
Banaction string `json:"banaction"` // Default banning action Banaction string `json:"banaction"` // Default banning action
BanactionAllports string `json:"banactionAllports"` // Allports banning action BanactionAllports string `json:"banactionAllports"` // Allports banning action
//Sender string `json:"sender"` //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 { type AdvancedActionsConfig struct {
@@ -172,9 +177,8 @@ actionban = /usr/bin/curl -X POST __CALLBACK_URL__/api/ban \
--arg jail '<name>' \ --arg jail '<name>' \
--arg hostname '<fq-hostname>' \ --arg hostname '<fq-hostname>' \
--arg failures '<failures>' \ --arg failures '<failures>' \
--arg whois "$(whois <ip> || echo 'missing whois program')" \
--arg logs "$(tac <logpath> | grep <grepopts> -wF <ip>)" \ --arg logs "$(tac <logpath> | grep <grepopts> -wF <ip>)" \
'{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] [Init]
@@ -458,6 +462,9 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) {
Banaction: currentSettings.Banaction, Banaction: currentSettings.Banaction,
BanactionAllports: currentSettings.BanactionAllports, BanactionAllports: currentSettings.BanactionAllports,
AdvancedActionsJSON: string(advancedBytes), AdvancedActionsJSON: string(advancedBytes),
GeoIPProvider: currentSettings.GeoIPProvider,
GeoIPDatabasePath: currentSettings.GeoIPDatabasePath,
MaxLogLines: currentSettings.MaxLogLines,
}, nil }, nil
} }
@@ -577,6 +584,15 @@ func setDefaultsLocked() {
if currentSettings.BanactionAllports == "" { if currentSettings.BanactionAllports == "" {
currentSettings.BanactionAllports = "iptables-allports" 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{}) { if (currentSettings.AdvancedActions == AdvancedActionsConfig{}) {
currentSettings.AdvancedActions = defaultAdvancedActionsConfig() currentSettings.AdvancedActions = defaultAdvancedActionsConfig()

View File

@@ -138,6 +138,14 @@
"settings.default_max_retry": "Standard-Maximalversuche", "settings.default_max_retry": "Standard-Maximalversuche",
"settings.default_max_retry.description": "Anzahl der Fehler, bevor ein Host gesperrt wird.", "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.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": "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.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", "settings.ignore_ips_placeholder": "IP-Adressen, getrennt durch Leerzeichen",

View File

@@ -138,6 +138,14 @@
"settings.default_max_retry": "Standard-Maximalversüech", "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.description": "Aazahl vo de Fähler, bevor ä Host gsperrt wird.",
"settings.default_max_retry_placeholder": "Gib d'maximal Versüech ii", "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": "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.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ä", "settings.ignore_ips_placeholder": "IPs, getrennt dur e Leerzeichä",

View File

@@ -138,6 +138,14 @@
"settings.default_max_retry": "Default Max Retry", "settings.default_max_retry": "Default Max Retry",
"settings.default_max_retry.description": "Number of failures before a host gets banned.", "settings.default_max_retry.description": "Number of failures before a host gets banned.",
"settings.default_max_retry_placeholder": "Enter maximum retries", "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": "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.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", "settings.ignore_ips_placeholder": "IPs to ignore, separated by spaces",

View File

@@ -138,6 +138,14 @@
"settings.default_max_retry": "Número máximo de reintentos por defecto", "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.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.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": "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.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", "settings.ignore_ips_placeholder": "IPs a ignorar, separadas por espacios",

View File

@@ -138,6 +138,14 @@
"settings.default_max_retry": "Nombre maximal de réessais par défaut", "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.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.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": "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.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", "settings.ignore_ips_placeholder": "IPs à ignorer, séparées par des espaces",

View File

@@ -138,6 +138,14 @@
"settings.default_max_retry": "Numero massimo di tentativi predefinito", "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.description": "Numero di errori prima che un host venga bannato.",
"settings.default_max_retry_placeholder": "Inserisci il numero massimo di tentativi", "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": "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.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", "settings.ignore_ips_placeholder": "IP da ignorare, separate da spazi",

View File

@@ -70,6 +70,9 @@ type AppSettingsRecord struct {
Banaction string Banaction string
BanactionAllports string BanactionAllports string
AdvancedActionsJSON string AdvancedActionsJSON string
GeoIPProvider string
GeoIPDatabasePath string
MaxLogLines int
} }
type ServerRecord struct { type ServerRecord struct {
@@ -171,17 +174,17 @@ func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) {
} }
row := db.QueryRowContext(ctx, ` 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 FROM app_settings
WHERE id = 1`) WHERE id = 1`)
var ( var (
lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, banaction, banactionAllports, advancedActions sql.NullString lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, banaction, banactionAllports, advancedActions, geoipProvider, geoipDatabasePath sql.NullString
port, smtpPort, maxretry sql.NullInt64 port, smtpPort, maxretry, maxLogLines sql.NullInt64
debug, restartNeeded, smtpTLS, bantimeInc, defaultJailEn 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) { if errors.Is(err, sql.ErrNoRows) {
return AppSettingsRecord{}, false, nil return AppSettingsRecord{}, false, nil
} }
@@ -212,6 +215,9 @@ WHERE id = 1`)
Banaction: stringFromNull(banaction), Banaction: stringFromNull(banaction),
BanactionAllports: stringFromNull(banactionAllports), BanactionAllports: stringFromNull(banactionAllports),
AdvancedActionsJSON: stringFromNull(advancedActions), AdvancedActionsJSON: stringFromNull(advancedActions),
GeoIPProvider: stringFromNull(geoipProvider),
GeoIPDatabasePath: stringFromNull(geoipDatabasePath),
MaxLogLines: intFromNull(maxLogLines),
} }
return rec, true, nil return rec, true, nil
@@ -223,9 +229,9 @@ func SaveAppSettings(ctx context.Context, rec AppSettingsRecord) error {
} }
_, err := db.ExecContext(ctx, ` _, err := db.ExecContext(ctx, `
INSERT INTO app_settings ( 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 ( ) VALUES (
1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
) ON CONFLICT(id) DO UPDATE SET ) ON CONFLICT(id) DO UPDATE SET
language = excluded.language, language = excluded.language,
port = excluded.port, port = excluded.port,
@@ -248,7 +254,10 @@ INSERT INTO app_settings (
destemail = excluded.destemail, destemail = excluded.destemail,
banaction = excluded.banaction, banaction = excluded.banaction,
banaction_allports = excluded.banaction_allports, 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.Language,
rec.Port, rec.Port,
boolToInt(rec.Debug), boolToInt(rec.Debug),
@@ -271,6 +280,9 @@ INSERT INTO app_settings (
rec.Banaction, rec.Banaction,
rec.BanactionAllports, rec.BanactionAllports,
rec.AdvancedActionsJSON, rec.AdvancedActionsJSON,
rec.GeoIPProvider,
rec.GeoIPDatabasePath,
rec.MaxLogLines,
) )
return err 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 server_id, server_name, jail, ip, country, hostname, failures, whois, logs, occurred_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
result, err := db.ExecContext( _, err := db.ExecContext(
ctx, ctx,
query, query,
record.ServerID, record.ServerID,
@@ -470,12 +482,6 @@ INSERT INTO ban_events (
return err return err
} }
// Get the inserted ID
id, err := result.LastInsertId()
if err == nil {
record.ID = id
}
return nil return nil
} }
@@ -797,7 +803,10 @@ CREATE TABLE IF NOT EXISTS app_settings (
destemail TEXT, destemail TEXT,
banaction TEXT, banaction TEXT,
banaction_allports 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 ( 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 return nil
} }

View File

@@ -5,7 +5,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "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/config"
"github.com/swissmakers/fail2ban-ui/internal/integrations" "github.com/swissmakers/fail2ban-ui/internal/integrations"
@@ -84,7 +86,7 @@ func runAdvancedIntegrationAction(ctx context.Context, action, ip string, settin
"unblock": "unblocked", "unblock": "unblocked",
}[action] }[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 { if err != nil && !skipLoggingIfAlreadyBlocked {
status = "error" status = "error"
message = err.Error() message = err.Error()

View File

@@ -177,7 +177,7 @@ func BanNotificationHandler(c *gin.Context) {
Jail string `json:"jail" binding:"required"` Jail string `json:"jail" binding:"required"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
Failures string `json:"failures"` Failures string `json:"failures"`
Whois string `json:"whois"` Whois string `json:"whois"` // Optional for backward compatibility
Logs string `json:"logs"` Logs string `json:"logs"`
} }
@@ -585,15 +585,42 @@ func TestServerHandler(c *gin.Context) {
// HandleBanNotification processes Fail2Ban notifications, checks geo-location, stores the event, and sends alerts. // 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 { 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() settings := config.GetSettings()
// Lookup the country for the given IP // Perform whois lookup if not provided (backward compatibility)
country, err := lookupCountry(ip) 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 { if err != nil {
log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err) log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err)
// Try to extract country from whois as fallback
if whoisData != "" {
country = extractCountryFromWhois(whoisData)
if country != "" {
log.Printf("Extracted country %s from whois data for IP %s", country, ip)
}
}
if country == "" {
country = "" country = ""
} }
}
event := storage.BanEventRecord{ event := storage.BanEventRecord{
ServerID: server.ID, ServerID: server.ID,
@@ -603,8 +630,8 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
Country: country, Country: country,
Hostname: hostname, Hostname: hostname,
Failures: failures, Failures: failures,
Whois: whois, Whois: whoisData,
Logs: logs, Logs: filteredLogs,
OccurredAt: time.Now().UTC(), OccurredAt: time.Now().UTC(),
} }
if err := storage.RecordBanEvent(ctx, event); err != nil { if err := storage.RecordBanEvent(ctx, event); err != nil {
@@ -630,7 +657,7 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
} }
// Send email notification // 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) log.Printf("❌ Failed to send alert email: %v", err)
return err return err
} }
@@ -639,8 +666,29 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
return nil return nil
} }
// lookupCountry finds the country ISO code for a given IP using MaxMind GeoLite2 database. // lookupCountry finds the country ISO code for a given IP using the configured provider.
func lookupCountry(ip string) (string, error) { 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 // Convert the IP string to net.IP
parsedIP := net.ParseIP(ip) parsedIP := net.ParseIP(ip)
if parsedIP == nil { if parsedIP == nil {
@@ -648,9 +696,9 @@ func lookupCountry(ip string) (string, error) {
} }
// Open the GeoIP database // Open the GeoIP database
db, err := maxminddb.Open("/usr/share/GeoIP/GeoLite2-Country.mmdb") db, err := maxminddb.Open(dbPath)
if err != nil { 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() defer db.Close()
@@ -670,6 +718,150 @@ func lookupCountry(ip string) (string, error) {
return record.Country.ISOCode, nil 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 IPs country is in the allowed alert list. // shouldAlertForCountry checks if an IPs country is in the allowed alert list.
func shouldAlertForCountry(country string, alertCountries []string) bool { func shouldAlertForCountry(country string, alertCountries []string) bool {
if len(alertCountries) == 0 || strings.Contains(strings.Join(alertCountries, ","), "ALL") { if len(alertCountries) == 0 || strings.Contains(strings.Join(alertCountries, ","), "ALL") {

View File

@@ -180,9 +180,9 @@ mark {
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
} }
#backendStatus:hover { /*#backendStatus:hover {
background-color: rgba(255, 255, 255, 0.15); background-color: rgba(255, 255, 255, 0.15);
} }*/
#statusDot { #statusDot {
width: 0.5rem; width: 0.5rem;

View File

@@ -63,7 +63,7 @@ function showBanEventToast(event) {
+ ' <div class="font-semibold text-sm">New Block Detected</div>' + ' <div class="font-semibold text-sm">New Block Detected</div>'
+ ' <div class="text-sm mt-1">' + ' <div class="text-sm mt-1">'
+ ' <span class="font-mono font-semibold">' + escapeHtml(ip) + '</span>' + ' <span class="font-mono font-semibold">' + escapeHtml(ip) + '</span>'
+ ' <span class="text-gray-500"> banned in </span>' + ' <span> banned in </span>'
+ ' <span class="font-semibold">' + escapeHtml(jail) + '</span>' + ' <span class="font-semibold">' + escapeHtml(jail) + '</span>'
+ ' </div>' + ' </div>'
+ ' <div class="text-xs text-gray-400 mt-1">' + ' <div class="text-xs text-gray-400 mt-1">'

View File

@@ -1,6 +1,18 @@
// Settings page functions for Fail2ban UI // Settings page functions for Fail2ban UI
"use strict"; "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() { function loadSettings() {
showLoading(true); showLoading(true);
fetch('/api/settings') fetch('/api/settings')
@@ -83,6 +95,13 @@ function loadSettings() {
document.getElementById('bantimeIncrement').checked = data.bantimeIncrement || false; document.getElementById('bantimeIncrement').checked = data.bantimeIncrement || false;
document.getElementById('defaultJailEnable').checked = data.defaultJailEnable || 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('banTime').value = data.bantime || '';
document.getElementById('findTime').value = data.findtime || ''; document.getElementById('findTime').value = data.findtime || '';
document.getElementById('maxRetry').value = data.maxretry || ''; document.getElementById('maxRetry').value = data.maxretry || '';
@@ -149,6 +168,9 @@ function saveSettings(event) {
ignoreips: getIgnoreIPsArray(), ignoreips: getIgnoreIPsArray(),
banaction: document.getElementById('banaction').value, banaction: document.getElementById('banaction').value,
banactionAllports: document.getElementById('banactionAllports').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, smtp: smtpSettings,
advancedActions: collectAdvancedActionsSettings() advancedActions: collectAdvancedActionsSettings()
}; };

View File

@@ -330,12 +330,38 @@
<!-- Alert Settings Group --> <!-- Alert Settings Group -->
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.alert">Alert Settings</h3> <h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.alert">Alert Settings</h3>
<div class="mb-4"> <div class="mb-4">
<label for="destEmail" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.destination_email">Destination Email (Alerts Receiver)</label> <label for="destEmail" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.destination_email">Destination Email (Alerts Receiver)</label>
<input type="email" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="destEmail" <input type="email" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="destEmail"
data-i18n-placeholder="settings.destination_email_placeholder" placeholder="alerts@swissmakers.ch" /> data-i18n-placeholder="settings.destination_email_placeholder" placeholder="alerts@swissmakers.ch" />
<p class="text-xs text-red-600 mt-1 hidden" id="destEmailError"></p> <p class="text-xs text-red-600 mt-1 hidden" id="destEmailError"></p>
</div> </div>
<!-- GeoIP Provider -->
<div class="mb-4">
<label for="geoipProvider" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.geoip_provider">GeoIP Provider</label>
<select id="geoipProvider" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" onchange="onGeoIPProviderChange(this.value)">
<option value="builtin" data-i18n="settings.geoip_provider.builtin">Built-in (ip-api.com)</option>
<option value="maxmind" data-i18n="settings.geoip_provider.maxmind">MaxMind (Local Database)</option>
</select>
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.geoip_provider.description">Choose the GeoIP lookup provider. MaxMind requires a local database file, while Built-in uses a free online API.</p>
</div>
<!-- GeoIP Database Path (shown only for MaxMind) -->
<div id="geoipDatabasePathContainer" class="mb-4">
<label for="geoipDatabasePath" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.geoip_database_path">GeoIP Database Path</label>
<input type="text" id="geoipDatabasePath" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="/usr/share/GeoIP/GeoLite2-Country.mmdb">
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.geoip_database_path.description">Path to the MaxMind GeoLite2-Country database file.</p>
</div>
<!-- Max Log Lines -->
<div class="mb-4">
<label for="maxLogLines" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.max_log_lines">Maximum Log Lines</label>
<input type="number" id="maxLogLines" min="1" max="500" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="50">
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.max_log_lines.description">Maximum number of log lines to include in ban notifications. Most relevant lines are selected automatically.</p>
</div>
<div class="mb-4"> <div class="mb-4">
<label for="alertCountries" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.alert_countries">Alert Countries</label> <label for="alertCountries" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.alert_countries">Alert Countries</label>
<p class="text-sm text-gray-500 mb-2" data-i18n="settings.alert_countries_description"> <p class="text-sm text-gray-500 mb-2" data-i18n="settings.alert_countries_description">

View File

@@ -37,9 +37,6 @@ const (
// Send pings to peer with this period (must be less than pongWait) // Send pings to peer with this period (must be less than pongWait)
pingPeriod = (pongWait * 9) / 10 pingPeriod = (pongWait * 9) / 10
// Maximum message size allowed from peer
maxMessageSize = 512
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{

125
pkg/web/whois.go Normal file
View File

@@ -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 ""
}