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
# =========================================
FROM golang:1.23 AS builder
FROM golang:1.24 AS builder
WORKDIR /app

9
go.mod
View File

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

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/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=

View File

@@ -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 '<name>' \
--arg hostname '<fq-hostname>' \
--arg failures '<failures>' \
--arg whois "$(whois <ip> || echo 'missing whois program')" \
--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]
@@ -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()

View File

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

View File

@@ -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ä",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -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 IPs country is in the allowed alert list.
func shouldAlertForCountry(country string, alertCountries []string) bool {
if len(alertCountries) == 0 || strings.Contains(strings.Join(alertCountries, ","), "ALL") {

View File

@@ -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;

View File

@@ -63,7 +63,7 @@ function showBanEventToast(event) {
+ ' <div class="font-semibold text-sm">New Block Detected</div>'
+ ' <div class="text-sm mt-1">'
+ ' <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>'
+ ' </div>'
+ ' <div class="text-xs text-gray-400 mt-1">'

View File

@@ -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()
};

View File

@@ -330,12 +330,38 @@
<!-- Alert Settings Group -->
<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>
<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>
<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" />
<p class="text-xs text-red-600 mt-1 hidden" id="destEmailError"></p>
</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">
<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">

View File

@@ -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{

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