Implement unban events and API and also add it to the Recent stored events, as well some cleanups

This commit is contained in:
2025-12-16 22:22:32 +01:00
parent 792bbe1939
commit 7b5c201936
19 changed files with 813 additions and 211 deletions

View File

@@ -77,6 +77,10 @@ type AppSettings struct {
GeoIPProvider string `json:"geoipProvider"` // "maxmind" or "builtin" GeoIPProvider string `json:"geoipProvider"` // "maxmind" or "builtin"
GeoIPDatabasePath string `json:"geoipDatabasePath"` // Path to MaxMind database (optional) GeoIPDatabasePath string `json:"geoipDatabasePath"` // Path to MaxMind database (optional)
MaxLogLines int `json:"maxLogLines"` // Maximum log lines to include (default: 50) MaxLogLines int `json:"maxLogLines"` // Maximum log lines to include (default: 50)
// Email alert preferences
EmailAlertsForBans bool `json:"emailAlertsForBans"` // Enable email alerts for ban events (default: true)
EmailAlertsForUnbans bool `json:"emailAlertsForUnbans"` // Enable email alerts for unban events (default: false)
} }
type AdvancedActionsConfig struct { type AdvancedActionsConfig struct {
@@ -132,9 +136,7 @@ func normalizeAdvancedActionsConfig(cfg AdvancedActionsConfig) AdvancedActionsCo
// init paths to key-files // init paths to key-files
const ( const (
settingsFile = "fail2ban-ui-settings.json" // this file is created, relatively to where the app was started settingsFile = "fail2ban-ui-settings.json" // this file is created, relatively to where the app was started
defaultJailFile = "/etc/fail2ban/jail.conf" jailFile = "/etc/fail2ban/jail.local"
jailFile = "/etc/fail2ban/jail.local" // Path to jail.local (to override conf-values from jail.conf)
jailDFile = "/etc/fail2ban/jail.d/ui-custom-action.conf"
actionFile = "/etc/fail2ban/action.d/ui-custom-action.conf" actionFile = "/etc/fail2ban/action.d/ui-custom-action.conf"
actionCallbackPlaceholder = "__CALLBACK_URL__" actionCallbackPlaceholder = "__CALLBACK_URL__"
actionServerIDPlaceholder = "__SERVER_ID__" actionServerIDPlaceholder = "__SERVER_ID__"
@@ -184,6 +186,18 @@ actionban = /usr/bin/curl -X POST __CALLBACK_URL__/api/ban \
--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, logs: $logs}')" '{serverId: $serverId, ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, logs: $logs}')"
# Option: actionunban
# This executes a cURL request to notify our API when an IP is unbanned.
actionunban = /usr/bin/curl -X POST __CALLBACK_URL__/api/unban \
-H "Content-Type: application/json" \
-H "X-Callback-Secret: __CALLBACK_SECRET__" \
-d "$(jq -n --arg serverId '__SERVER_ID__' \
--arg ip '<ip>' \
--arg jail '<name>' \
--arg hostname '<fq-hostname>' \
'{serverId: $serverId, ip: $ip, jail: $jail, hostname: $hostname}')"
[Init] [Init]
# Default name of the chain # Default name of the chain
@@ -396,6 +410,8 @@ func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) {
currentSettings.GeoIPDatabasePath = rec.GeoIPDatabasePath currentSettings.GeoIPDatabasePath = rec.GeoIPDatabasePath
currentSettings.MaxLogLines = rec.MaxLogLines currentSettings.MaxLogLines = rec.MaxLogLines
currentSettings.CallbackSecret = rec.CallbackSecret currentSettings.CallbackSecret = rec.CallbackSecret
currentSettings.EmailAlertsForBans = rec.EmailAlertsForBans
currentSettings.EmailAlertsForUnbans = rec.EmailAlertsForUnbans
} }
func applyServerRecordsLocked(records []storage.ServerRecord) { func applyServerRecordsLocked(records []storage.ServerRecord) {
@@ -447,18 +463,26 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) {
} }
return storage.AppSettingsRecord{ return storage.AppSettingsRecord{
// Basic app settings
Language: currentSettings.Language, Language: currentSettings.Language,
Port: currentSettings.Port, Port: currentSettings.Port,
Debug: currentSettings.Debug, Debug: currentSettings.Debug,
CallbackURL: currentSettings.CallbackURL,
RestartNeeded: currentSettings.RestartNeeded, RestartNeeded: currentSettings.RestartNeeded,
// Callback settings
CallbackURL: currentSettings.CallbackURL,
CallbackSecret: currentSettings.CallbackSecret,
// Alert settings
AlertCountriesJSON: string(countryBytes), AlertCountriesJSON: string(countryBytes),
EmailAlertsForBans: currentSettings.EmailAlertsForBans,
EmailAlertsForUnbans: currentSettings.EmailAlertsForUnbans,
// SMTP settings
SMTPHost: currentSettings.SMTP.Host, SMTPHost: currentSettings.SMTP.Host,
SMTPPort: currentSettings.SMTP.Port, SMTPPort: currentSettings.SMTP.Port,
SMTPUsername: currentSettings.SMTP.Username, SMTPUsername: currentSettings.SMTP.Username,
SMTPPassword: currentSettings.SMTP.Password, SMTPPassword: currentSettings.SMTP.Password,
SMTPFrom: currentSettings.SMTP.From, SMTPFrom: currentSettings.SMTP.From,
SMTPUseTLS: currentSettings.SMTP.UseTLS, SMTPUseTLS: currentSettings.SMTP.UseTLS,
// Fail2Ban DEFAULT settings
BantimeIncrement: currentSettings.BantimeIncrement, BantimeIncrement: currentSettings.BantimeIncrement,
DefaultJailEnable: currentSettings.DefaultJailEnable, DefaultJailEnable: currentSettings.DefaultJailEnable,
// Convert IgnoreIPs array to space-separated string for storage // Convert IgnoreIPs array to space-separated string for storage
@@ -469,11 +493,11 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) {
DestEmail: currentSettings.Destemail, DestEmail: currentSettings.Destemail,
Banaction: currentSettings.Banaction, Banaction: currentSettings.Banaction,
BanactionAllports: currentSettings.BanactionAllports, BanactionAllports: currentSettings.BanactionAllports,
// Advanced features
AdvancedActionsJSON: string(advancedBytes), AdvancedActionsJSON: string(advancedBytes),
GeoIPProvider: currentSettings.GeoIPProvider, GeoIPProvider: currentSettings.GeoIPProvider,
GeoIPDatabasePath: currentSettings.GeoIPDatabasePath, GeoIPDatabasePath: currentSettings.GeoIPDatabasePath,
MaxLogLines: currentSettings.MaxLogLines, MaxLogLines: currentSettings.MaxLogLines,
CallbackSecret: currentSettings.CallbackSecret,
}, nil }, nil
} }
@@ -531,6 +555,18 @@ func setDefaultsLocked() {
if currentSettings.Language == "" { if currentSettings.Language == "" {
currentSettings.Language = "en" currentSettings.Language = "en"
} }
// Set email alert defaults
if !currentSettings.EmailAlertsForBans && !currentSettings.EmailAlertsForUnbans {
// Check if it is uninitialized by checking if we have other initialized values
// If we have a callback secret or port set, it means we've loaded from storage, so we don't override
if currentSettings.CallbackSecret == "" && currentSettings.Port == 0 {
// Uninitialized so we set defaults
currentSettings.EmailAlertsForBans = true
currentSettings.EmailAlertsForUnbans = false
}
}
// Check for PORT environment variable first - it always takes priority // Check for PORT environment variable first - it always takes priority
if portEnv := os.Getenv("PORT"); portEnv != "" { if portEnv := os.Getenv("PORT"); portEnv != "" {
if port, err := strconv.Atoi(portEnv); err == nil && port > 0 && port <= 65535 { if port, err := strconv.Atoi(portEnv); err == nil && port > 0 && port <= 65535 {

View File

@@ -110,6 +110,8 @@
"settings.destination_email_placeholder": "alerts@swissmakers.ch", "settings.destination_email_placeholder": "alerts@swissmakers.ch",
"settings.alert_countries": "Alarm-Länder", "settings.alert_countries": "Alarm-Länder",
"settings.alert_countries_description": "Wählen Sie die Länder aus, für die E-Mail-Alarme ausgelöst werden sollen, wenn eine Sperrung erfolgt.", "settings.alert_countries_description": "Wählen Sie die Länder aus, für die E-Mail-Alarme ausgelöst werden sollen, wenn eine Sperrung erfolgt.",
"settings.email_alerts_for_bans": "E-Mail-Benachrichtigungen für Sperrungen aktivieren",
"settings.email_alerts_for_unbans": "E-Mail-Benachrichtigungen für Entsperrungen aktivieren",
"settings.smtp": "SMTP-Konfiguration", "settings.smtp": "SMTP-Konfiguration",
"settings.smtp_host": "SMTP-Host", "settings.smtp_host": "SMTP-Host",
"settings.smtp_host_placeholder": "z.B. smtp.gmail.com", "settings.smtp_host_placeholder": "z.B. smtp.gmail.com",
@@ -281,6 +283,15 @@
"email.whois.no_data": "WHOIS-Daten wurden für dieses Ereignis nicht erfasst.", "email.whois.no_data": "WHOIS-Daten wurden für dieses Ereignis nicht erfasst.",
"email.logs.no_data": "Für diesen Block wurden keine Log-Einträge erfasst.", "email.logs.no_data": "Für diesen Block wurden keine Log-Einträge erfasst.",
"email.footer.text": "Diese Nachricht wurde automatisch von Fail2Ban-UI generiert", "email.footer.text": "Diese Nachricht wurde automatisch von Fail2Ban-UI generiert",
"email.unban.title": "IP-Adresse entsperrt",
"email.unban.intro": "IP-Adresse aus Fail2Ban-Jail entsperrt.",
"email.unban.subject.unbanned": "Entsperrt",
"email.unban.subject.from": "von",
"email.unban.details.unbanned_ip": "Entsperrte IP",
"email.unban.details.jail": "Jail",
"email.unban.details.hostname": "Hostname",
"email.unban.details.country": "Land",
"email.unban.details.timestamp": "Zeitstempel",
"lotr.email.title": "Ein dunkler Diener wurde verbannt", "lotr.email.title": "Ein dunkler Diener wurde verbannt",
"lotr.email.intro": "Die Wächter von Mittelerde haben eine Bedrohung erkannt und aus dem Reich verbannt.", "lotr.email.intro": "Die Wächter von Mittelerde haben eine Bedrohung erkannt und aus dem Reich verbannt.",
"lotr.email.you_shall_not_pass": "DU KANNST NICHT VORBEI", "lotr.email.you_shall_not_pass": "DU KANNST NICHT VORBEI",
@@ -289,8 +300,15 @@
"lotr.email.details.realm_protection": "Das Reich des Schutzes", "lotr.email.details.realm_protection": "Das Reich des Schutzes",
"lotr.email.details.origins": "Herkunft aus den", "lotr.email.details.origins": "Herkunft aus den",
"lotr.email.details.banished_at": "Verbannt zur", "lotr.email.details.banished_at": "Verbannt zur",
"lotr.email.unban.title": "Der festgehaltene wird wider freigelassen",
"lotr.email.unban.intro": "Die Wächter von Mittelerde haben den Zugang wiederhergestellt.",
"lotr.email.unban.details.restored_ip": "Wiederhergestellte IP",
"lotr.banished": "Aus dem Reich verbannt", "lotr.banished": "Aus dem Reich verbannt",
"lotr.realms_protected": "Geschützte Reiche", "lotr.realms_protected": "Geschützte Reiche",
"lotr.threats_banished": "Verbannte Bedrohungen" "lotr.threats_banished": "Verbannte Bedrohungen",
"toast.ban.title": "Neue Blockierung aufgetreten",
"toast.ban.action": "gesperrt in",
"toast.unban.title": "IP entsperrt",
"toast.unban.action": "entsperrt von"
} }

View File

@@ -110,6 +110,8 @@
"settings.destination_email_placeholder": "alerts@swissmakers.ch", "settings.destination_email_placeholder": "alerts@swissmakers.ch",
"settings.alert_countries": "Alarm-Länder", "settings.alert_countries": "Alarm-Länder",
"settings.alert_countries_description": "Wähl d'Länder us, für weli du per Email ä Alarm becho wetsch, wenn e Sperrig erfolgt.", "settings.alert_countries_description": "Wähl d'Länder us, für weli du per Email ä Alarm becho wetsch, wenn e Sperrig erfolgt.",
"settings.email_alerts_for_bans": "Email-Benachrichtigunge für Sperrige aktiviere",
"settings.email_alerts_for_unbans": "Email-Benachrichtigunge für Entsperrige aktiviere",
"settings.smtp": "SMTP-Konfiguration", "settings.smtp": "SMTP-Konfiguration",
"settings.smtp_host": "SMTP-Host", "settings.smtp_host": "SMTP-Host",
"settings.smtp_host_placeholder": "z.B. smtp.gmail.com", "settings.smtp_host_placeholder": "z.B. smtp.gmail.com",
@@ -281,6 +283,15 @@
"email.whois.no_data": "WHOIS-Date si für das Ereignis nid erfasst worde.", "email.whois.no_data": "WHOIS-Date si für das Ereignis nid erfasst worde.",
"email.logs.no_data": "Für de Block sind keni Log-Iiträg erfasst worde.", "email.logs.no_data": "Für de Block sind keni Log-Iiträg erfasst worde.",
"email.footer.text": "Diä Nachricht isch automatisch vom Fail2Ban-UI generiert worde", "email.footer.text": "Diä Nachricht isch automatisch vom Fail2Ban-UI generiert worde",
"email.unban.title": "IP-Adrässä entsperrt",
"email.unban.intro": "E IP-Adrässä isch usem Fail2Ban-Jail entsperrt worde.",
"email.unban.subject.unbanned": "Entsperrt",
"email.unban.subject.from": "vo",
"email.unban.details.unbanned_ip": "Entsperrti IP",
"email.unban.details.jail": "Jail",
"email.unban.details.hostname": "Hostname",
"email.unban.details.country": "Land",
"email.unban.details.timestamp": "Ziitstämpfel",
"lotr.email.title": "E dunkle Diener isch verbannt worde", "lotr.email.title": "E dunkle Diener isch verbannt worde",
"lotr.email.intro": "D Wächter vo Mittelerde hei e Bedrohig erkannt und us dim Riich verbannt.", "lotr.email.intro": "D Wächter vo Mittelerde hei e Bedrohig erkannt und us dim Riich verbannt.",
"lotr.email.you_shall_not_pass": "DU DARFSCH NID VERBII", "lotr.email.you_shall_not_pass": "DU DARFSCH NID VERBII",
@@ -289,8 +300,15 @@
"lotr.email.details.realm_protection": "S Riich vom Schutz", "lotr.email.details.realm_protection": "S Riich vom Schutz",
"lotr.email.details.origins": "Herkunft us de", "lotr.email.details.origins": "Herkunft us de",
"lotr.email.details.banished_at": "Verbannt zur", "lotr.email.details.banished_at": "Verbannt zur",
"lotr.email.unban.title": "Dr festghautnig wird wider freiglah",
"lotr.email.unban.intro": "D Wächter vo Mittelerde hei dr Zugang wiederhergstellt.",
"lotr.email.unban.details.restored_ip": "Widerhergstellti IP",
"lotr.banished": "Us em Riich verbannt", "lotr.banished": "Us em Riich verbannt",
"lotr.realms_protected": "Gschützti Riich", "lotr.realms_protected": "Gschützti Riich",
"lotr.threats_banished": "Verbannti Bedrohige" "lotr.threats_banished": "Verbannti Bedrohige",
"toast.ban.title": "Neui Blockierig ufträte",
"toast.ban.action": "gsperrt i",
"toast.unban.title": "IP entsperrt",
"toast.unban.action": "entsperrt vo"
} }

View File

@@ -110,6 +110,8 @@
"settings.destination_email_placeholder": "alerts@swissmakers.ch", "settings.destination_email_placeholder": "alerts@swissmakers.ch",
"settings.alert_countries": "Alert Countries", "settings.alert_countries": "Alert Countries",
"settings.alert_countries_description": "Choose the countries for which you want to receive email alerts when a block is triggered.", "settings.alert_countries_description": "Choose the countries for which you want to receive email alerts when a block is triggered.",
"settings.email_alerts_for_bans": "Enable email alerts for bans",
"settings.email_alerts_for_unbans": "Enable email alerts for unbans",
"settings.smtp": "SMTP Configuration", "settings.smtp": "SMTP Configuration",
"settings.smtp_host": "SMTP Host", "settings.smtp_host": "SMTP Host",
"settings.smtp_host_placeholder": "e.g., smtp.gmail.com", "settings.smtp_host_placeholder": "e.g., smtp.gmail.com",
@@ -281,6 +283,15 @@
"email.whois.no_data": "WHOIS data was not captured for this event.", "email.whois.no_data": "WHOIS data was not captured for this event.",
"email.logs.no_data": "No log entries were captured for this block.", "email.logs.no_data": "No log entries were captured for this block.",
"email.footer.text": "This message was generated automatically by Fail2Ban-UI", "email.footer.text": "This message was generated automatically by Fail2Ban-UI",
"email.unban.title": "IP Address Unbanned",
"email.unban.intro": "An IP address has been unbanned from a Fail2Ban jail.",
"email.unban.subject.unbanned": "Unbanned",
"email.unban.subject.from": "from",
"email.unban.details.unbanned_ip": "Unbanned IP",
"email.unban.details.jail": "Jail",
"email.unban.details.hostname": "Hostname",
"email.unban.details.country": "Country",
"email.unban.details.timestamp": "Timestamp",
"lotr.email.title": "A Dark Servant Has Been Banished", "lotr.email.title": "A Dark Servant Has Been Banished",
"lotr.email.intro": "The guardians of Middle-earth have detected a threat and banished it from the realm.", "lotr.email.intro": "The guardians of Middle-earth have detected a threat and banished it from the realm.",
"lotr.email.you_shall_not_pass": "YOU SHALL NOT PASS", "lotr.email.you_shall_not_pass": "YOU SHALL NOT PASS",
@@ -289,8 +300,15 @@
"lotr.email.details.realm_protection": "The Realm of Protection", "lotr.email.details.realm_protection": "The Realm of Protection",
"lotr.email.details.origins": "Origins from the", "lotr.email.details.origins": "Origins from the",
"lotr.email.details.banished_at": "Banished at the", "lotr.email.details.banished_at": "Banished at the",
"lotr.email.unban.title": "The held prisoner has been released",
"lotr.email.unban.intro": "The guardians of Middle-earth have restored access to the realm.",
"lotr.email.unban.details.restored_ip": "Restored IP",
"lotr.banished": "Banished from the realm", "lotr.banished": "Banished from the realm",
"lotr.realms_protected": "Realms Protected", "lotr.realms_protected": "Realms Protected",
"lotr.threats_banished": "Threats Banished" "lotr.threats_banished": "Threats Banished",
"toast.ban.title": "New block occurred",
"toast.ban.action": "banned in",
"toast.unban.title": "IP unblocked",
"toast.unban.action": "unblocked from"
} }

View File

@@ -110,6 +110,8 @@
"settings.destination_email_placeholder": "alerts@swissmakers.ch", "settings.destination_email_placeholder": "alerts@swissmakers.ch",
"settings.alert_countries": "Países para alerta", "settings.alert_countries": "Países para alerta",
"settings.alert_countries_description": "Elige los países para los que deseas recibir alertas por correo electrónico cuando se produzca un bloqueo.", "settings.alert_countries_description": "Elige los países para los que deseas recibir alertas por correo electrónico cuando se produzca un bloqueo.",
"settings.email_alerts_for_bans": "Activar alertas por email para bloqueos",
"settings.email_alerts_for_unbans": "Activar alertas por email para desbloqueos",
"settings.smtp": "Configuración SMTP", "settings.smtp": "Configuración SMTP",
"settings.smtp_host": "Host SMTP", "settings.smtp_host": "Host SMTP",
"settings.smtp_host_placeholder": "p.ej., smtp.gmail.com", "settings.smtp_host_placeholder": "p.ej., smtp.gmail.com",
@@ -281,6 +283,15 @@
"email.whois.no_data": "No se capturaron datos WHOIS para este evento.", "email.whois.no_data": "No se capturaron datos WHOIS para este evento.",
"email.logs.no_data": "No se capturaron entradas de registro para este bloqueo.", "email.logs.no_data": "No se capturaron entradas de registro para este bloqueo.",
"email.footer.text": "Este mensaje fue generado automáticamente por Fail2Ban-UI", "email.footer.text": "Este mensaje fue generado automáticamente por Fail2Ban-UI",
"email.unban.title": "Dirección IP desbloqueada",
"email.unban.intro": "Una dirección IP ha sido desbloqueada de una prisión Fail2Ban.",
"email.unban.subject.unbanned": "Desbloqueado",
"email.unban.subject.from": "de",
"email.unban.details.unbanned_ip": "IP desbloqueada",
"email.unban.details.jail": "Prisión",
"email.unban.details.hostname": "Nombre de host",
"email.unban.details.country": "País",
"email.unban.details.timestamp": "Marca de tiempo",
"lotr.email.title": "Un siervo oscuro ha sido desterrado", "lotr.email.title": "Un siervo oscuro ha sido desterrado",
"lotr.email.intro": "Los guardianes de la Tierra Media han detectado una amenaza y la han desterrado del reino.", "lotr.email.intro": "Los guardianes de la Tierra Media han detectado una amenaza y la han desterrado del reino.",
"lotr.email.you_shall_not_pass": "NO PASARÁS", "lotr.email.you_shall_not_pass": "NO PASARÁS",
@@ -289,7 +300,14 @@
"lotr.email.details.realm_protection": "El reino de la protección", "lotr.email.details.realm_protection": "El reino de la protección",
"lotr.email.details.origins": "Orígenes de las", "lotr.email.details.origins": "Orígenes de las",
"lotr.email.details.banished_at": "Desterrado a las", "lotr.email.details.banished_at": "Desterrado a las",
"lotr.email.unban.title": "El prisionero detenido ha sido liberado",
"lotr.email.unban.intro": "Los guardianes de la Tierra Media han restaurado el acceso al reino.",
"lotr.email.unban.details.restored_ip": "IP restaurada",
"lotr.banished": "Desterrado del reino", "lotr.banished": "Desterrado del reino",
"lotr.realms_protected": "Reinos protegidos", "lotr.realms_protected": "Reinos protegidos",
"lotr.threats_banished": "Amenazas desterradas" "lotr.threats_banished": "Amenazas desterradas",
"toast.ban.title": "Nuevo bloqueo ocurrido",
"toast.ban.action": "bloqueado en",
"toast.unban.title": "IP desbloqueada",
"toast.unban.action": "desbloqueada de"
} }

View File

@@ -110,6 +110,8 @@
"settings.destination_email_placeholder": "alerts@swissmakers.ch", "settings.destination_email_placeholder": "alerts@swissmakers.ch",
"settings.alert_countries": "Pays d'alerte", "settings.alert_countries": "Pays d'alerte",
"settings.alert_countries_description": "Choisissez les pays pour lesquels vous souhaitez recevoir des alertes par email lors d'un blocage.", "settings.alert_countries_description": "Choisissez les pays pour lesquels vous souhaitez recevoir des alertes par email lors d'un blocage.",
"settings.email_alerts_for_bans": "Activer les alertes email pour les bannissements",
"settings.email_alerts_for_unbans": "Activer les alertes email pour les débannissements",
"settings.smtp": "Configuration SMTP", "settings.smtp": "Configuration SMTP",
"settings.smtp_host": "Hôte SMTP", "settings.smtp_host": "Hôte SMTP",
"settings.smtp_host_placeholder": "par exemple, smtp.gmail.com", "settings.smtp_host_placeholder": "par exemple, smtp.gmail.com",
@@ -281,6 +283,15 @@
"email.whois.no_data": "Les données WHOIS n'ont pas été capturées pour cet événement.", "email.whois.no_data": "Les données WHOIS n'ont pas été capturées pour cet événement.",
"email.logs.no_data": "Aucune entrée de journal n'a été capturée pour ce blocage.", "email.logs.no_data": "Aucune entrée de journal n'a été capturée pour ce blocage.",
"email.footer.text": "Ce message a été généré automatiquement par Fail2Ban-UI", "email.footer.text": "Ce message a été généré automatiquement par Fail2Ban-UI",
"email.unban.title": "Adresse IP débannie",
"email.unban.intro": "Une adresse IP a été débannie d'une prison Fail2Ban.",
"email.unban.subject.unbanned": "Débanni",
"email.unban.subject.from": "de",
"email.unban.details.unbanned_ip": "IP débannie",
"email.unban.details.jail": "Prison",
"email.unban.details.hostname": "Nom d'hôte",
"email.unban.details.country": "Pays",
"email.unban.details.timestamp": "Horodatage",
"lotr.email.title": "Un serviteur des ténèbres a été banni", "lotr.email.title": "Un serviteur des ténèbres a été banni",
"lotr.email.intro": "Les gardiens de la Terre du Milieu ont détecté une menace et l'ont bannie du royaume.", "lotr.email.intro": "Les gardiens de la Terre du Milieu ont détecté une menace et l'ont bannie du royaume.",
"lotr.email.you_shall_not_pass": "TU NE PASSERAS PAS", "lotr.email.you_shall_not_pass": "TU NE PASSERAS PAS",
@@ -289,7 +300,14 @@
"lotr.email.details.realm_protection": "Le royaume de la protection", "lotr.email.details.realm_protection": "Le royaume de la protection",
"lotr.email.details.origins": "Origines des", "lotr.email.details.origins": "Origines des",
"lotr.email.details.banished_at": "Banni à", "lotr.email.details.banished_at": "Banni à",
"lotr.email.unban.title": "Le détenu a été libéré",
"lotr.email.unban.intro": "Les gardiens de la Terre du Milieu ont restauré l'accès au royaume.",
"lotr.email.unban.details.restored_ip": "IP restaurée",
"lotr.banished": "Banni du royaume", "lotr.banished": "Banni du royaume",
"lotr.realms_protected": "Royaumes protégés", "lotr.realms_protected": "Royaumes protégés",
"lotr.threats_banished": "Menaces bannies" "lotr.threats_banished": "Menaces bannies",
"toast.ban.title": "Nouveau blocage survenu",
"toast.ban.action": "banni dans",
"toast.unban.title": "IP débloquée",
"toast.unban.action": "débloquée de"
} }

View File

@@ -110,6 +110,8 @@
"settings.destination_email_placeholder": "alerts@swissmakers.ch", "settings.destination_email_placeholder": "alerts@swissmakers.ch",
"settings.alert_countries": "Paesi per allarme", "settings.alert_countries": "Paesi per allarme",
"settings.alert_countries_description": "Seleziona i paesi per i quali desideri ricevere allarmi via email quando si verifica un blocco.", "settings.alert_countries_description": "Seleziona i paesi per i quali desideri ricevere allarmi via email quando si verifica un blocco.",
"settings.email_alerts_for_bans": "Abilita allarmi email per i ban",
"settings.email_alerts_for_unbans": "Abilita allarmi email per gli unban",
"settings.smtp": "Configurazione SMTP", "settings.smtp": "Configurazione SMTP",
"settings.smtp_host": "Host SMTP", "settings.smtp_host": "Host SMTP",
"settings.smtp_host_placeholder": "es. smtp.gmail.com", "settings.smtp_host_placeholder": "es. smtp.gmail.com",
@@ -281,6 +283,15 @@
"email.whois.no_data": "I dati WHOIS non sono stati acquisiti per questo evento.", "email.whois.no_data": "I dati WHOIS non sono stati acquisiti per questo evento.",
"email.logs.no_data": "Nessuna voce di log è stata acquisita per questo blocco.", "email.logs.no_data": "Nessuna voce di log è stata acquisita per questo blocco.",
"email.footer.text": "Questo messaggio è stato generato automaticamente da Fail2Ban-UI", "email.footer.text": "Questo messaggio è stato generato automaticamente da Fail2Ban-UI",
"email.unban.title": "Indirizzo IP sbannato",
"email.unban.intro": "Un indirizzo IP è stato sbannato da una prigione Fail2Ban.",
"email.unban.subject.unbanned": "Sbannato",
"email.unban.subject.from": "da",
"email.unban.details.unbanned_ip": "IP sbannato",
"email.unban.details.jail": "Prigione",
"email.unban.details.hostname": "Nome host",
"email.unban.details.country": "Paese",
"email.unban.details.timestamp": "Timestamp",
"lotr.email.title": "Un servitore oscuro è stato bandito", "lotr.email.title": "Un servitore oscuro è stato bandito",
"lotr.email.intro": "I guardiani della Terra di Mezzo hanno rilevato una minaccia e l'hanno bandita dal regno.", "lotr.email.intro": "I guardiani della Terra di Mezzo hanno rilevato una minaccia e l'hanno bandita dal regno.",
"lotr.email.you_shall_not_pass": "NON PASSERAI", "lotr.email.you_shall_not_pass": "NON PASSERAI",
@@ -289,7 +300,14 @@
"lotr.email.details.realm_protection": "Il regno della protezione", "lotr.email.details.realm_protection": "Il regno della protezione",
"lotr.email.details.origins": "Origini dalle", "lotr.email.details.origins": "Origini dalle",
"lotr.email.details.banished_at": "Bandito alle", "lotr.email.details.banished_at": "Bandito alle",
"lotr.email.unban.title": "Il detenuto è stato rilasciato",
"lotr.email.unban.intro": "I guardiani della Terra di Mezzo hanno ripristinato l'accesso al regno.",
"lotr.email.unban.details.restored_ip": "IP ripristinato",
"lotr.banished": "Bandito dal regno", "lotr.banished": "Bandito dal regno",
"lotr.realms_protected": "Regni protetti", "lotr.realms_protected": "Regni protetti",
"lotr.threats_banished": "Minacce bandite" "lotr.threats_banished": "Minacce bandite",
"toast.ban.title": "Nuovo blocco verificato",
"toast.ban.action": "bannato in",
"toast.unban.title": "IP sbloccato",
"toast.unban.action": "sbloccato da"
} }

View File

@@ -48,18 +48,26 @@ func intFromNull(ni sql.NullInt64) int {
} }
type AppSettingsRecord struct { type AppSettingsRecord struct {
// Basic app settings
Language string Language string
Port int Port int
Debug bool Debug bool
CallbackURL string
RestartNeeded bool RestartNeeded bool
// Callback settings
CallbackURL string
CallbackSecret string
// Alert settings
AlertCountriesJSON string AlertCountriesJSON string
EmailAlertsForBans bool
EmailAlertsForUnbans bool
// SMTP settings
SMTPHost string SMTPHost string
SMTPPort int SMTPPort int
SMTPUsername string SMTPUsername string
SMTPPassword string SMTPPassword string
SMTPFrom string SMTPFrom string
SMTPUseTLS bool SMTPUseTLS bool
// Fail2Ban DEFAULT settings
BantimeIncrement bool BantimeIncrement bool
DefaultJailEnable bool DefaultJailEnable bool
IgnoreIP string // Stored as space-separated string, converted to array in AppSettings IgnoreIP string // Stored as space-separated string, converted to array in AppSettings
@@ -69,11 +77,11 @@ type AppSettingsRecord struct {
DestEmail string DestEmail string
Banaction string Banaction string
BanactionAllports string BanactionAllports string
// Advanced features
AdvancedActionsJSON string AdvancedActionsJSON string
GeoIPProvider string GeoIPProvider string
GeoIPDatabasePath string GeoIPDatabasePath string
MaxLogLines int MaxLogLines int
CallbackSecret string
} }
type ServerRecord struct { type ServerRecord struct {
@@ -97,7 +105,7 @@ type ServerRecord struct {
UpdatedAt time.Time UpdatedAt time.Time
} }
// BanEventRecord represents a single ban event stored in the internal database. // BanEventRecord represents a single ban or unban event stored in the internal database.
type BanEventRecord struct { type BanEventRecord struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ServerID string `json:"serverId"` ServerID string `json:"serverId"`
@@ -109,6 +117,7 @@ type BanEventRecord struct {
Failures string `json:"failures"` Failures string `json:"failures"`
Whois string `json:"whois"` Whois string `json:"whois"`
Logs string `json:"logs"` Logs string `json:"logs"`
EventType string `json:"eventType"`
OccurredAt time.Time `json:"occurredAt"` OccurredAt time.Time `json:"occurredAt"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
@@ -175,17 +184,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, geoip_provider, geoip_database_path, max_log_lines, callback_secret SELECT language, port, debug, restart_needed, callback_url, callback_secret, alert_countries, email_alerts_for_bans, email_alerts_for_unbans, 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, geoipProvider, geoipDatabasePath, callbackSecret sql.NullString lang, callback, callbackSecret, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, banaction, banactionAllports, advancedActions, geoipProvider, geoipDatabasePath sql.NullString
port, smtpPort, maxretry, maxLogLines sql.NullInt64 port, smtpPort, maxretry, maxLogLines sql.NullInt64
debug, restartNeeded, smtpTLS, bantimeInc, defaultJailEn sql.NullInt64 debug, restartNeeded, smtpTLS, bantimeInc, defaultJailEn, emailAlertsForBans, emailAlertsForUnbans 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, &geoipProvider, &geoipDatabasePath, &maxLogLines, &callbackSecret) err := row.Scan(&lang, &port, &debug, &restartNeeded, &callback, &callbackSecret, &alerts, &emailAlertsForBans, &emailAlertsForUnbans, &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
} }
@@ -194,18 +203,26 @@ WHERE id = 1`)
} }
rec := AppSettingsRecord{ rec := AppSettingsRecord{
// Basic app settings
Language: stringFromNull(lang), Language: stringFromNull(lang),
Port: intFromNull(port), Port: intFromNull(port),
Debug: intToBool(intFromNull(debug)), Debug: intToBool(intFromNull(debug)),
CallbackURL: stringFromNull(callback),
RestartNeeded: intToBool(intFromNull(restartNeeded)), RestartNeeded: intToBool(intFromNull(restartNeeded)),
// Callback settings
CallbackURL: stringFromNull(callback),
CallbackSecret: stringFromNull(callbackSecret),
// Alert settings
AlertCountriesJSON: stringFromNull(alerts), AlertCountriesJSON: stringFromNull(alerts),
EmailAlertsForBans: intToBool(intFromNull(emailAlertsForBans)),
EmailAlertsForUnbans: intToBool(intFromNull(emailAlertsForUnbans)),
// SMTP settings
SMTPHost: stringFromNull(smtpHost), SMTPHost: stringFromNull(smtpHost),
SMTPPort: intFromNull(smtpPort), SMTPPort: intFromNull(smtpPort),
SMTPUsername: stringFromNull(smtpUser), SMTPUsername: stringFromNull(smtpUser),
SMTPPassword: stringFromNull(smtpPass), SMTPPassword: stringFromNull(smtpPass),
SMTPFrom: stringFromNull(smtpFrom), SMTPFrom: stringFromNull(smtpFrom),
SMTPUseTLS: intToBool(intFromNull(smtpTLS)), SMTPUseTLS: intToBool(intFromNull(smtpTLS)),
// Fail2Ban DEFAULT settings
BantimeIncrement: intToBool(intFromNull(bantimeInc)), BantimeIncrement: intToBool(intFromNull(bantimeInc)),
DefaultJailEnable: intToBool(intFromNull(defaultJailEn)), DefaultJailEnable: intToBool(intFromNull(defaultJailEn)),
IgnoreIP: stringFromNull(ignoreIP), IgnoreIP: stringFromNull(ignoreIP),
@@ -215,11 +232,11 @@ WHERE id = 1`)
DestEmail: stringFromNull(destemail), DestEmail: stringFromNull(destemail),
Banaction: stringFromNull(banaction), Banaction: stringFromNull(banaction),
BanactionAllports: stringFromNull(banactionAllports), BanactionAllports: stringFromNull(banactionAllports),
// Advanced features
AdvancedActionsJSON: stringFromNull(advancedActions), AdvancedActionsJSON: stringFromNull(advancedActions),
GeoIPProvider: stringFromNull(geoipProvider), GeoIPProvider: stringFromNull(geoipProvider),
GeoIPDatabasePath: stringFromNull(geoipDatabasePath), GeoIPDatabasePath: stringFromNull(geoipDatabasePath),
MaxLogLines: intFromNull(maxLogLines), MaxLogLines: intFromNull(maxLogLines),
CallbackSecret: stringFromNull(callbackSecret),
} }
return rec, true, nil return rec, true, nil
@@ -231,16 +248,19 @@ 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, geoip_provider, geoip_database_path, max_log_lines, callback_secret id, language, port, debug, restart_needed, callback_url, callback_secret, alert_countries, email_alerts_for_bans, email_alerts_for_unbans, 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,
debug = excluded.debug, debug = excluded.debug,
callback_url = excluded.callback_url,
restart_needed = excluded.restart_needed, restart_needed = excluded.restart_needed,
callback_url = excluded.callback_url,
callback_secret = excluded.callback_secret,
alert_countries = excluded.alert_countries, alert_countries = excluded.alert_countries,
email_alerts_for_bans = excluded.email_alerts_for_bans,
email_alerts_for_unbans = excluded.email_alerts_for_unbans,
smtp_host = excluded.smtp_host, smtp_host = excluded.smtp_host,
smtp_port = excluded.smtp_port, smtp_port = excluded.smtp_port,
smtp_username = excluded.smtp_username, smtp_username = excluded.smtp_username,
@@ -259,14 +279,16 @@ INSERT INTO app_settings (
advanced_actions = excluded.advanced_actions, advanced_actions = excluded.advanced_actions,
geoip_provider = excluded.geoip_provider, geoip_provider = excluded.geoip_provider,
geoip_database_path = excluded.geoip_database_path, geoip_database_path = excluded.geoip_database_path,
max_log_lines = excluded.max_log_lines, max_log_lines = excluded.max_log_lines
callback_secret = excluded.callback_secret
`, rec.Language, `, rec.Language,
rec.Port, rec.Port,
boolToInt(rec.Debug), boolToInt(rec.Debug),
rec.CallbackURL,
boolToInt(rec.RestartNeeded), boolToInt(rec.RestartNeeded),
rec.CallbackURL,
rec.CallbackSecret,
rec.AlertCountriesJSON, rec.AlertCountriesJSON,
boolToInt(rec.EmailAlertsForBans),
boolToInt(rec.EmailAlertsForUnbans),
rec.SMTPHost, rec.SMTPHost,
rec.SMTPPort, rec.SMTPPort,
rec.SMTPUsername, rec.SMTPUsername,
@@ -286,7 +308,6 @@ INSERT INTO app_settings (
rec.GeoIPProvider, rec.GeoIPProvider,
rec.GeoIPDatabasePath, rec.GeoIPDatabasePath,
rec.MaxLogLines, rec.MaxLogLines,
rec.CallbackSecret,
) )
return err return err
} }
@@ -462,10 +483,16 @@ func RecordBanEvent(ctx context.Context, record BanEventRecord) error {
record.OccurredAt = now record.OccurredAt = now
} }
// Default to 'ban' if event type is not set
eventType := record.EventType
if eventType == "" {
eventType = "ban"
}
const query = ` const query = `
INSERT INTO ban_events ( 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, event_type, occurred_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := db.ExecContext( _, err := db.ExecContext(
ctx, ctx,
@@ -479,6 +506,7 @@ INSERT INTO ban_events (
record.Failures, record.Failures,
record.Whois, record.Whois,
record.Logs, record.Logs,
eventType,
record.OccurredAt.UTC(), record.OccurredAt.UTC(),
record.CreatedAt.UTC(), record.CreatedAt.UTC(),
) )
@@ -500,7 +528,7 @@ func ListBanEvents(ctx context.Context, serverID string, limit int, since time.T
} }
baseQuery := ` baseQuery := `
SELECT id, server_id, server_name, jail, ip, country, hostname, failures, whois, logs, occurred_at, created_at SELECT id, server_id, server_name, jail, ip, country, hostname, failures, whois, logs, event_type, occurred_at, created_at
FROM ban_events FROM ban_events
WHERE 1=1` WHERE 1=1`
@@ -526,6 +554,7 @@ WHERE 1=1`
var results []BanEventRecord var results []BanEventRecord
for rows.Next() { for rows.Next() {
var rec BanEventRecord var rec BanEventRecord
var eventType sql.NullString
if err := rows.Scan( if err := rows.Scan(
&rec.ID, &rec.ID,
&rec.ServerID, &rec.ServerID,
@@ -537,11 +566,18 @@ WHERE 1=1`
&rec.Failures, &rec.Failures,
&rec.Whois, &rec.Whois,
&rec.Logs, &rec.Logs,
&eventType,
&rec.OccurredAt, &rec.OccurredAt,
&rec.CreatedAt, &rec.CreatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
// Default to 'ban' if event_type is NULL (for backward compatibility)
if eventType.Valid {
rec.EventType = eventType.String
} else {
rec.EventType = "ban"
}
results = append(results, rec) results = append(results, rec)
} }
@@ -786,18 +822,26 @@ func ensureSchema(ctx context.Context) error {
const createTable = ` const createTable = `
CREATE TABLE IF NOT EXISTS app_settings ( CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
-- Basic app settings
language TEXT, language TEXT,
port INTEGER, port INTEGER,
debug INTEGER, debug INTEGER,
callback_url TEXT,
restart_needed INTEGER, restart_needed INTEGER,
-- Callback settings
callback_url TEXT,
callback_secret TEXT,
-- Alert settings
alert_countries TEXT, alert_countries TEXT,
email_alerts_for_bans INTEGER DEFAULT 1,
email_alerts_for_unbans INTEGER DEFAULT 0,
-- SMTP settings
smtp_host TEXT, smtp_host TEXT,
smtp_port INTEGER, smtp_port INTEGER,
smtp_username TEXT, smtp_username TEXT,
smtp_password TEXT, smtp_password TEXT,
smtp_from TEXT, smtp_from TEXT,
smtp_use_tls INTEGER, smtp_use_tls INTEGER,
-- Fail2Ban DEFAULT settings
bantime_increment INTEGER, bantime_increment INTEGER,
default_jail_enable INTEGER, default_jail_enable INTEGER,
ignore_ip TEXT, ignore_ip TEXT,
@@ -807,11 +851,11 @@ CREATE TABLE IF NOT EXISTS app_settings (
destemail TEXT, destemail TEXT,
banaction TEXT, banaction TEXT,
banaction_allports TEXT, banaction_allports TEXT,
-- Advanced features
advanced_actions TEXT, advanced_actions TEXT,
geoip_provider TEXT, geoip_provider TEXT,
geoip_database_path TEXT, geoip_database_path TEXT,
max_log_lines INTEGER, max_log_lines INTEGER
callback_secret TEXT
); );
CREATE TABLE IF NOT EXISTS servers ( CREATE TABLE IF NOT EXISTS servers (
@@ -846,6 +890,7 @@ CREATE TABLE IF NOT EXISTS ban_events (
failures TEXT, failures TEXT,
whois TEXT, whois TEXT,
logs TEXT, logs TEXT,
event_type TEXT NOT NULL DEFAULT 'ban',
occurred_at DATETIME NOT NULL, occurred_at DATETIME NOT NULL,
created_at DATETIME NOT NULL created_at DATETIME NOT NULL
); );
@@ -873,69 +918,16 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status);
return err return err
} }
// Backfill needs_restart column for existing databases that predate it. // NOTE: Database migrations for feature releases
if _, err := db.ExecContext(ctx, `ALTER TABLE servers ADD COLUMN needs_restart INTEGER DEFAULT 0`); err != nil { // For this version, we start with a fresh schema. Future feature releases
if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { // that require database schema changes should add migration logic here.
return err // Example migration pattern:
} // if _, err := db.ExecContext(ctx, `ALTER TABLE table_name ADD COLUMN new_column TYPE DEFAULT value`); err != nil {
} // if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
// return err
// Backfill banaction columns for existing databases that predate them. // }
if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN banaction TEXT`); err != nil { // }
if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { _ = strings.Contains // Keep strings import for migration example above
return err
}
}
if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN banaction_allports TEXT`); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
return err
}
}
if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN advanced_actions TEXT`); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
return err
}
}
// 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
}
}
// Add callback_secret column
if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN callback_secret TEXT`); 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

@@ -261,6 +261,81 @@ func BanNotificationHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Ban notification processed successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Ban notification processed successfully"})
} }
// UnbanNotificationHandler processes incoming unban notifications from Fail2Ban.
func UnbanNotificationHandler(c *gin.Context) {
// Validate callback secret
settings := config.GetSettings()
providedSecret := c.GetHeader("X-Callback-Secret")
expectedSecret := settings.CallbackSecret
// Use constant-time comparison to prevent timing attacks
if expectedSecret == "" {
log.Printf("⚠️ Callback secret not configured, rejecting request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Callback secret not configured"})
return
}
if providedSecret == "" {
log.Printf("⚠️ Missing X-Callback-Secret header in request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing X-Callback-Secret header"})
return
}
// Constant-time comparison
if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(expectedSecret)) != 1 {
log.Printf("⚠️ Invalid callback secret in request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid callback secret"})
return
}
var request struct {
ServerID string `json:"serverId"`
IP string `json:"ip" binding:"required"`
Jail string `json:"jail" binding:"required"`
Hostname string `json:"hostname"`
}
body, _ := io.ReadAll(c.Request.Body)
config.DebugLog("📩 Incoming Unban Notification: %s\n", string(body))
// Rebind body so Gin can parse it again
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
// Parse JSON request body
if err := c.ShouldBindJSON(&request); err != nil {
var verr validator.ValidationErrors
if errors.As(err, &verr) {
for _, fe := range verr {
log.Printf("❌ Validation error: Field '%s' violated rule '%s'", fe.Field(), fe.ActualTag())
}
} else {
log.Printf("❌ JSON parsing error: %v", err)
}
log.Printf("Raw JSON: %s", string(body))
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
log.Printf("✅ Parsed Unban Request - IP: %s, Jail: %s, Hostname: %s",
request.IP, request.Jail, request.Hostname)
server, err := resolveServerForNotification(request.ServerID, request.Hostname)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Handle the Fail2Ban notification
if err := HandleUnbanNotification(c.Request.Context(), server, request.IP, request.Jail, request.Hostname, "", ""); err != nil {
log.Printf("❌ Failed to process unban notification: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process unban notification: " + err.Error()})
return
}
// Respond with success
c.JSON(http.StatusOK, gin.H{"message": "Unban notification processed successfully"})
}
// ListBanEventsHandler returns stored ban events from the internal database. // ListBanEventsHandler returns stored ban events from the internal database.
func ListBanEventsHandler(c *gin.Context) { func ListBanEventsHandler(c *gin.Context) {
serverID := c.Query("serverId") serverID := c.Query("serverId")
@@ -658,6 +733,7 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
Failures: failures, Failures: failures,
Whois: whoisData, Whois: whoisData,
Logs: filteredLogs, Logs: filteredLogs,
EventType: "ban",
OccurredAt: time.Now().UTC(), OccurredAt: time.Now().UTC(),
} }
if err := storage.RecordBanEvent(ctx, event); err != nil { if err := storage.RecordBanEvent(ctx, event); err != nil {
@@ -682,6 +758,12 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
return nil return nil
} }
// Check if email alerts for bans are enabled
if !settings.EmailAlertsForBans {
log.Printf("❌ Email alerts for bans are disabled. No alert sent for IP %s", ip)
return nil
}
// Send email notification // Send email notification
if err := sendBanAlert(ip, jail, hostname, failures, whoisData, filteredLogs, 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)
@@ -692,6 +774,93 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
return nil return nil
} }
// HandleUnbanNotification processes Fail2Ban unban notifications, stores the event, and sends alerts.
func HandleUnbanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, whois, country string) error {
// Load settings to get alert countries and GeoIP provider
settings := config.GetSettings()
// Perform whois lookup if not provided
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
}
// Lookup the country for the given IP if not provided
if country == "" {
country, err = lookupCountry(ip, settings.GeoIPProvider, settings.GeoIPDatabasePath)
if err != nil {
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 = ""
}
}
}
event := storage.BanEventRecord{
ServerID: server.ID,
ServerName: server.Name,
Jail: jail,
IP: ip,
Country: country,
Hostname: hostname,
Failures: "",
Whois: whoisData,
Logs: "",
EventType: "unban",
OccurredAt: time.Now().UTC(),
}
if err := storage.RecordBanEvent(ctx, event); err != nil {
log.Printf("⚠️ Failed to record unban event: %v", err)
}
// Broadcast unban event to WebSocket clients
if wsHub != nil {
wsHub.BroadcastUnbanEvent(event)
}
// Check if email alerts for unbans are enabled
if !settings.EmailAlertsForUnbans {
log.Printf("❌ Email alerts for unbans are disabled. No alert sent for IP %s", ip)
return nil
}
// Check if country is in alert list
displayCountry := country
if displayCountry == "" {
displayCountry = "UNKNOWN"
}
if !shouldAlertForCountry(country, settings.AlertCountries) {
log.Printf("❌ IP %s belongs to %s, which is NOT in alert countries (%v). No alert sent.", ip, displayCountry, settings.AlertCountries)
return nil
}
// Send email notification
if err := sendUnbanAlert(ip, jail, hostname, whoisData, country, settings); err != nil {
log.Printf("❌ Failed to send unban alert email: %v", err)
return err
}
log.Printf("✅ Email alert sent for unbanned IP %s (%s)", ip, displayCountry)
return nil
}
// lookupCountry finds the country ISO code for a given IP using the configured provider. // lookupCountry finds the country ISO code for a given IP using the configured provider.
func lookupCountry(ip, provider, dbPath string) (string, error) { func lookupCountry(ip, provider, dbPath string) (string, error) {
switch provider { switch provider {
@@ -2320,7 +2489,11 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set
// Get translations // Get translations
var subject string var subject string
if isLOTRMode { if isLOTRMode {
subject = fmt.Sprintf("[Middle-earth] The Dark Lord's Servant Has Been Banished: %s from %s", ip, hostname) subject = fmt.Sprintf("[Middle-earth] %s: %s %s %s",
getEmailTranslation(lang, "lotr.email.title"),
ip,
getEmailTranslation(lang, "email.ban.subject.from"),
hostname)
} else { } else {
subject = fmt.Sprintf("[Fail2Ban] %s: %s %s %s %s", jail, subject = fmt.Sprintf("[Fail2Ban] %s: %s %s %s %s", jail,
getEmailTranslation(lang, "email.ban.subject.banned"), getEmailTranslation(lang, "email.ban.subject.banned"),
@@ -2337,19 +2510,10 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set
var title, intro, whoisTitle, logsTitle, footerText string var title, intro, whoisTitle, logsTitle, footerText string
if isLOTRMode { if isLOTRMode {
title = getEmailTranslation(lang, "lotr.email.title") title = getEmailTranslation(lang, "lotr.email.title")
if title == "lotr.email.title" {
title = "A Dark Servant Has Been Banished"
}
intro = getEmailTranslation(lang, "lotr.email.intro") intro = getEmailTranslation(lang, "lotr.email.intro")
if intro == "lotr.email.intro" {
intro = "The guardians of Middle-earth have detected a threat and banished it from the realm."
}
whoisTitle = getEmailTranslation(lang, "email.ban.whois_title") whoisTitle = getEmailTranslation(lang, "email.ban.whois_title")
logsTitle = getEmailTranslation(lang, "email.ban.logs_title") logsTitle = getEmailTranslation(lang, "email.ban.logs_title")
footerText = getEmailTranslation(lang, "lotr.email.footer") footerText = getEmailTranslation(lang, "lotr.email.footer")
if footerText == "lotr.email.footer" {
footerText = "May the servers be protected. One ban to rule them all."
}
} else { } else {
title = getEmailTranslation(lang, "email.ban.title") title = getEmailTranslation(lang, "email.ban.title")
intro = getEmailTranslation(lang, "email.ban.intro") intro = getEmailTranslation(lang, "email.ban.intro")
@@ -2364,34 +2528,15 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set
if isLOTRMode { if isLOTRMode {
// Transform labels to LOTR terminology // Transform labels to LOTR terminology
bannedIPLabel := getEmailTranslation(lang, "lotr.email.details.dark_servant_location") bannedIPLabel := getEmailTranslation(lang, "lotr.email.details.dark_servant_location")
if bannedIPLabel == "lotr.email.details.dark_servant_location" {
bannedIPLabel = "The Dark Servant's Location"
}
jailLabel := getEmailTranslation(lang, "lotr.email.details.realm_protection") jailLabel := getEmailTranslation(lang, "lotr.email.details.realm_protection")
if jailLabel == "lotr.email.details.realm_protection" {
jailLabel = "The Realm of Protection"
}
countryLabelKey := getEmailTranslation(lang, "lotr.email.details.origins") countryLabelKey := getEmailTranslation(lang, "lotr.email.details.origins")
var countryLabel string var countryLabel string
if countryLabelKey == "lotr.email.details.origins" {
// Use default English format
if country != "" {
countryLabel = fmt.Sprintf("Origins from the %s Lands", country)
} else {
countryLabel = "Origins from Unknown Lands"
}
} else {
// Use translated label and append country
if country != "" { if country != "" {
countryLabel = fmt.Sprintf("%s %s", countryLabelKey, country) countryLabel = fmt.Sprintf("%s %s", countryLabelKey, country)
} else { } else {
countryLabel = fmt.Sprintf("%s Unknown", countryLabelKey) countryLabel = fmt.Sprintf("%s Unknown", countryLabelKey)
} }
}
timestampLabel := getEmailTranslation(lang, "lotr.email.details.banished_at") timestampLabel := getEmailTranslation(lang, "lotr.email.details.banished_at")
if timestampLabel == "lotr.email.details.banished_at" {
timestampLabel = "Banished at the"
}
details = []emailDetail{ details = []emailDetail{
{Label: bannedIPLabel, Value: ip}, {Label: bannedIPLabel, Value: ip},
@@ -2428,6 +2573,87 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set
return sendEmail(settings.Destemail, subject, body, settings) return sendEmail(settings.Destemail, subject, body, settings)
} }
// *******************************************************************
// * sendUnbanAlert Function : *
// *******************************************************************
func sendUnbanAlert(ip, jail, hostname, whois, country string, settings config.AppSettings) error {
lang := settings.Language
if lang == "" {
lang = "en"
}
isLOTRMode := isLOTRModeActive(settings.AlertCountries)
// Get translations
var subject string
if isLOTRMode {
subject = fmt.Sprintf("[Middle-earth] %s: %s %s %s",
getEmailTranslation(lang, "lotr.email.unban.title"),
ip,
getEmailTranslation(lang, "email.unban.subject.from"),
hostname)
} else {
subject = fmt.Sprintf("[Fail2Ban] %s: %s %s %s %s", jail,
getEmailTranslation(lang, "email.unban.subject.unbanned"),
ip,
getEmailTranslation(lang, "email.unban.subject.from"),
hostname)
}
// Determine email style and LOTR mode
emailStyle := getEmailStyle()
isModern := emailStyle == "modern"
// Get translations - use LOTR translations if in LOTR mode
var title, intro, whoisTitle, footerText string
if isLOTRMode {
title = getEmailTranslation(lang, "lotr.email.unban.title")
intro = getEmailTranslation(lang, "lotr.email.unban.intro")
whoisTitle = getEmailTranslation(lang, "email.ban.whois_title")
footerText = getEmailTranslation(lang, "lotr.email.footer")
} else {
title = getEmailTranslation(lang, "email.unban.title")
intro = getEmailTranslation(lang, "email.unban.intro")
whoisTitle = getEmailTranslation(lang, "email.ban.whois_title")
footerText = getEmailTranslation(lang, "email.footer.text")
}
supportEmail := "support@swissmakers.ch"
// Format details - use shared keys for common fields, LOTR-specific only for restored_ip
var details []emailDetail
if isLOTRMode {
details = []emailDetail{
{Label: getEmailTranslation(lang, "lotr.email.unban.details.restored_ip"), Value: ip},
{Label: getEmailTranslation(lang, "email.unban.details.jail"), Value: jail},
{Label: getEmailTranslation(lang, "email.unban.details.hostname"), Value: hostname},
{Label: getEmailTranslation(lang, "email.unban.details.country"), Value: country},
{Label: getEmailTranslation(lang, "email.unban.details.timestamp"), Value: time.Now().UTC().Format(time.RFC3339)},
}
} else {
details = []emailDetail{
{Label: getEmailTranslation(lang, "email.unban.details.unbanned_ip"), Value: ip},
{Label: getEmailTranslation(lang, "email.unban.details.jail"), Value: jail},
{Label: getEmailTranslation(lang, "email.unban.details.hostname"), Value: hostname},
{Label: getEmailTranslation(lang, "email.unban.details.country"), Value: country},
{Label: getEmailTranslation(lang, "email.unban.details.timestamp"), Value: time.Now().UTC().Format(time.RFC3339)},
}
}
whoisHTML := formatWhoisForEmail(whois, lang, isModern)
var body string
if isLOTRMode {
// Use LOTR-themed email template
body = buildLOTREmailBody(title, intro, details, whoisHTML, "", whoisTitle, "", footerText)
} else if isModern {
body = buildModernEmailBody(title, intro, details, whoisHTML, "", whoisTitle, "", footerText)
} else {
body = buildClassicEmailBody(title, intro, details, whoisHTML, "", whoisTitle, "", footerText, supportEmail)
}
return sendEmail(settings.Destemail, subject, body, settings)
}
// ******************************************************************* // *******************************************************************
// * TestEmailHandler to send test-mail : * // * TestEmailHandler to send test-mail : *
// ******************************************************************* // *******************************************************************

View File

@@ -69,6 +69,7 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) {
// Handle Fail2Ban notifications // Handle Fail2Ban notifications
api.POST("/ban", BanNotificationHandler) api.POST("/ban", BanNotificationHandler)
api.POST("/unban", UnbanNotificationHandler)
// Internal database overview // Internal database overview
api.GET("/events/bans", ListBanEventsHandler) api.GET("/events/bans", ListBanEventsHandler)

View File

@@ -26,6 +26,10 @@
display: none; display: none;
} }
#serverManagerList {
min-height: 480px;
}
/* Custom scrollbar */ /* Custom scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
@@ -168,6 +172,18 @@ mark {
box-shadow: 0 15px 20px -3px rgba(0, 0, 0, 0.15); box-shadow: 0 15px 20px -3px rgba(0, 0, 0, 0.15);
} }
.toast-unban-event {
background-color: #14532d;
pointer-events: auto;
cursor: pointer;
}
.toast-unban-event:hover {
background-color: #166534;
transform: translateY(-2px);
box-shadow: 0 15px 20px -3px rgba(0, 0, 0, 0.15);
}
/* Backend Status Indicator */ /* Backend Status Indicator */
#backendStatus { #backendStatus {
display: flex; display: flex;

View File

@@ -46,24 +46,29 @@ function showBanEventToast(event) {
var container = document.getElementById('toast-container'); var container = document.getElementById('toast-container');
if (!container || !event) return; if (!container || !event) return;
var isUnban = event.eventType === 'unban';
var toast = document.createElement('div'); var toast = document.createElement('div');
toast.className = 'toast toast-ban-event'; toast.className = isUnban ? 'toast toast-unban-event' : 'toast toast-ban-event';
var ip = event.ip || 'Unknown IP'; var ip = event.ip || 'Unknown IP';
var jail = event.jail || 'Unknown Jail'; var jail = event.jail || 'Unknown Jail';
var server = event.serverName || event.serverId || 'Unknown Server'; var server = event.serverName || event.serverId || 'Unknown Server';
var country = event.country || 'UNKNOWN'; var country = event.country || 'UNKNOWN';
var title = isUnban ? t('toast.unban.title', 'IP unblocked') : t('toast.ban.title', 'New block occurred');
var action = isUnban ? t('toast.unban.action', 'unblocked from') : t('toast.ban.action', 'banned in');
var icon = isUnban ? 'fas fa-check-circle text-green-400' : 'fas fa-shield-alt text-red-500';
toast.innerHTML = '' toast.innerHTML = ''
+ '<div class="flex items-start gap-3">' + '<div class="flex items-start gap-3">'
+ ' <div class="flex-shrink-0 mt-1">' + ' <div class="flex-shrink-0 mt-1">'
+ ' <i class="fas fa-shield-alt text-red-500"></i>' + ' <i class="' + icon + '"></i>'
+ ' </div>' + ' </div>'
+ ' <div class="flex-1 min-w-0">' + ' <div class="flex-1 min-w-0">'
+ ' <div class="font-semibold text-sm">New block occurred</div>' + ' <div class="font-semibold text-sm">' + title + '</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> banned in </span>' + ' <span> ' + action + ' </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

@@ -85,7 +85,7 @@ function fetchBanEventsData() {
}); });
} }
// Add new ban event from WebSocket // Add new ban or unban event from WebSocket
function addBanEventFromWebSocket(event) { function addBanEventFromWebSocket(event) {
// Check if event already exists (prevent duplicates) // Check if event already exists (prevent duplicates)
// Only check by ID if both events have IDs // Only check by ID if both events have IDs
@@ -95,16 +95,21 @@ function addBanEventFromWebSocket(event) {
return e.id === event.id; return e.id === event.id;
}); });
} else { } else {
// If no ID, check by IP, jail, and occurredAt timestamp // If no ID, check by IP, jail, eventType, and occurredAt timestamp
exists = latestBanEvents.some(function(e) { exists = latestBanEvents.some(function(e) {
return e.ip === event.ip && return e.ip === event.ip &&
e.jail === event.jail && e.jail === event.jail &&
e.eventType === event.eventType &&
e.occurredAt === event.occurredAt; e.occurredAt === event.occurredAt;
}); });
} }
if (!exists) { if (!exists) {
console.log('Adding new ban event from WebSocket:', event); // Ensure eventType is set (default to 'ban' for backward compatibility)
if (!event.eventType) {
event.eventType = 'ban';
}
console.log('Adding new event from WebSocket:', event);
// Prepend to the beginning of the array // Prepend to the beginning of the array
latestBanEvents.unshift(event); latestBanEvents.unshift(event);
@@ -121,7 +126,7 @@ function addBanEventFromWebSocket(event) {
// Refresh dashboard data (summary, stats, insights) and re-render // Refresh dashboard data (summary, stats, insights) and re-render
refreshDashboardData(); refreshDashboardData();
} else { } else {
console.log('Skipping duplicate ban event:', event); console.log('Skipping duplicate event:', event);
} }
} }
@@ -453,9 +458,8 @@ function unbanIP(jail, ip) {
.then(function(data) { .then(function(data) {
if (data.error) { if (data.error) {
showToast("Error unbanning IP: " + data.error, 'error'); showToast("Error unbanning IP: " + data.error, 'error');
} else {
showToast(data.message || "IP unbanned successfully", 'success');
} }
// Don't show success toast here - the WebSocket unban event will show a proper toast
return refreshData({ silent: true }); return refreshData({ silent: true });
}) })
.catch(function(err) { .catch(function(err) {
@@ -761,12 +765,19 @@ function renderLogOverviewContent() {
if (event.ip && recurringMap[event.ip]) { if (event.ip && recurringMap[event.ip]) {
ipCell += ' <span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">' + t('logs.badge.recurring', 'Recurring') + '</span>'; ipCell += ' <span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">' + t('logs.badge.recurring', 'Recurring') + '</span>';
} }
var eventType = event.eventType || 'ban';
var eventTypeBadge = '';
if (eventType === 'unban') {
eventTypeBadge = ' <span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">Unban</span>';
} else {
eventTypeBadge = ' <span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">Ban</span>';
}
html += '' html += ''
+ ' <tr class="hover:bg-gray-50">' + ' <tr class="hover:bg-gray-50">'
+ ' <td class="px-2 py-2 whitespace-nowrap">' + escapeHtml(formatDateTime(event.occurredAt || event.createdAt)) + '</td>' + ' <td class="px-2 py-2 whitespace-nowrap">' + escapeHtml(formatDateTime(event.occurredAt || event.createdAt)) + '</td>'
+ ' <td class="px-2 py-2 whitespace-nowrap">' + serverCell + '</td>' + ' <td class="px-2 py-2 whitespace-nowrap">' + serverCell + '</td>'
+ ' <td class="hidden sm:table-cell px-2 py-2 whitespace-nowrap">' + jailCell + '</td>' + ' <td class="hidden sm:table-cell px-2 py-2 whitespace-nowrap">' + jailCell + '</td>'
+ ' <td class="px-2 py-2 whitespace-nowrap">' + ipCell + '</td>' + ' <td class="px-2 py-2 whitespace-nowrap">' + ipCell + eventTypeBadge + '</td>'
+ ' <td class="hidden md:table-cell px-2 py-2 whitespace-nowrap">' + escapeHtml(event.country || '—') + '</td>' + ' <td class="hidden md:table-cell px-2 py-2 whitespace-nowrap">' + escapeHtml(event.country || '—') + '</td>'
+ ' <td class="px-2 py-2 whitespace-nowrap">' + ' <td class="px-2 py-2 whitespace-nowrap">'
+ ' <div class="flex gap-2">' + ' <div class="flex gap-2">'

View File

@@ -260,7 +260,7 @@ function openManageJailsModal() {
const jsEscapedJailName = jail.jailName.replace(/'/g, "\\'"); const jsEscapedJailName = jail.jailName.replace(/'/g, "\\'");
return '' return ''
+ '<div class="flex items-center justify-between gap-3 p-3 bg-gray-50">' + '<div class="flex items-center justify-between gap-3 p-3 bg-gray-50">'
+ ' <span class="text-sm font-medium flex-1">' + escapedJailName + '</span>' + ' <span class="text-sm font-medium flex-1 text-gray-900">' + escapedJailName + '</span>'
+ ' <div class="flex items-center gap-3">' + ' <div class="flex items-center gap-3">'
+ ' <button' + ' <button'
+ ' type="button"' + ' type="button"'
@@ -282,7 +282,7 @@ function openManageJailsModal() {
+ ' class="w-11 h-6 bg-gray-200 rounded-full peer-focus:ring-4 peer-focus:ring-blue-300 peer-checked:bg-blue-600 transition-colors"' + ' class="w-11 h-6 bg-gray-200 rounded-full peer-focus:ring-4 peer-focus:ring-blue-300 peer-checked:bg-blue-600 transition-colors"'
+ ' ></div>' + ' ></div>'
+ ' <span' + ' <span'
+ ' class="absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform peer-checked:translate-x-5"' + ' class="absolute left-1 top-1/2 -translate-y-1/2 bg-white w-4 h-4 rounded-full transition-transform peer-checked:translate-x-5"'
+ ' ></span>' + ' ></span>'
+ ' </label>' + ' </label>'
+ ' </div>' + ' </div>'

View File

@@ -13,6 +13,32 @@ function onGeoIPProviderChange(provider) {
} }
} }
// Update email fields state based on checkbox preferences
function updateEmailFieldsState() {
const emailAlertsForBans = document.getElementById('emailAlertsForBans').checked;
const emailAlertsForUnbans = document.getElementById('emailAlertsForUnbans').checked;
const emailEnabled = emailAlertsForBans || emailAlertsForUnbans;
// Get all email-related fields
const emailFields = [
document.getElementById('destEmail'),
document.getElementById('smtpHost'),
document.getElementById('smtpPort'),
document.getElementById('smtpUsername'),
document.getElementById('smtpPassword'),
document.getElementById('smtpFrom'),
document.getElementById('smtpUseTLS'),
document.getElementById('sendTestEmailBtn')
];
// Enable/disable all email fields
emailFields.forEach(field => {
if (field) {
field.disabled = !emailEnabled;
}
});
}
function loadSettings() { function loadSettings() {
showLoading(true); showLoading(true);
fetch('/api/settings') fetch('/api/settings')
@@ -78,6 +104,11 @@ function loadSettings() {
document.getElementById('destEmail').value = data.destemail || ''; document.getElementById('destEmail').value = data.destemail || '';
// Load email alert preferences
document.getElementById('emailAlertsForBans').checked = data.emailAlertsForBans !== undefined ? data.emailAlertsForBans : true;
document.getElementById('emailAlertsForUnbans').checked = data.emailAlertsForUnbans !== undefined ? data.emailAlertsForUnbans : false;
updateEmailFieldsState();
const select = document.getElementById('alertCountries'); const select = document.getElementById('alertCountries');
for (let i = 0; i < select.options.length; i++) { for (let i = 0; i < select.options.length; i++) {
select.options[i].selected = false; select.options[i].selected = false;
@@ -174,6 +205,8 @@ function saveSettings(event) {
callbackUrl: callbackUrl, callbackUrl: callbackUrl,
callbackSecret: document.getElementById('callbackSecret').value.trim(), callbackSecret: document.getElementById('callbackSecret').value.trim(),
alertCountries: selectedCountries.length > 0 ? selectedCountries : ["ALL"], alertCountries: selectedCountries.length > 0 ? selectedCountries : ["ALL"],
emailAlertsForBans: document.getElementById('emailAlertsForBans').checked,
emailAlertsForUnbans: document.getElementById('emailAlertsForUnbans').checked,
bantimeIncrement: document.getElementById('bantimeIncrement').checked, bantimeIncrement: document.getElementById('bantimeIncrement').checked,
defaultJailEnable: document.getElementById('defaultJailEnable').checked, defaultJailEnable: document.getElementById('defaultJailEnable').checked,
bantime: document.getElementById('banTime').value.trim(), bantime: document.getElementById('banTime').value.trim(),

View File

@@ -93,6 +93,9 @@ class WebSocketManager {
case 'ban_event': case 'ban_event':
this.handleBanEvent(message.data); this.handleBanEvent(message.data);
break; break;
case 'unban_event':
this.handleBanEvent(message.data); // Use same handler for unban events
break;
case 'heartbeat': case 'heartbeat':
this.handleHeartbeat(message); this.handleHeartbeat(message);
break; break;

View File

@@ -419,10 +419,42 @@ body.lotr-mode .bg-gray-50 {
} }
body.lotr-mode .text-blue-600 { body.lotr-mode .text-blue-600 {
color: var(--lotr-gold); color: var(--lotr-gold) !important;
font-weight: 600; font-weight: 600;
} }
/* Text colors in LOTR mode for better visibility */
body.lotr-mode .bg-gray-50 .text-gray-900,
body.lotr-mode .bg-gray-50 .text-sm,
body.lotr-mode .bg-gray-50 .text-sm.font-medium {
color: var(--lotr-text-dark) !important;
}
/* Open insights button styling */
body.lotr-mode .border-blue-200 {
border-color: var(--lotr-gold) !important;
}
body.lotr-mode .hover\:bg-blue-50:hover {
background-color: rgba(212, 175, 55, 0.1) !important;
}
/* Logpath results styling */
body.lotr-mode #logpathResults,
body.lotr-mode pre#logpathResults {
background: var(--lotr-warm-parchment) !important;
color: var(--lotr-text-dark) !important;
border: 2px solid var(--lotr-stone-gray) !important;
}
body.lotr-mode #logpathResults.text-red-600 {
color: var(--lotr-fire-red) !important;
}
body.lotr-mode #logpathResults.text-yellow-600 {
color: var(--lotr-dark-gold) !important;
}
/* Tables */ /* Tables */
body.lotr-mode table { body.lotr-mode table {
border-collapse: separate; border-collapse: separate;
@@ -570,12 +602,71 @@ body.lotr-mode .select2-container--default .select2-selection--multiple .select2
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
} }
body.lotr-mode .select2-container--default .select2-selection--multiple > .select2-selection--inline > input.select2-search__field {
border: none !important;
box-shadow: none !important;
}
body.lotr-mode .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { body.lotr-mode .select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
color: var(--lotr-text-dark) !important; color: var(--lotr-text-dark) !important;
font-weight: 700; font-weight: 700;
margin-right: 6px; margin-right: 6px;
} }
/* Select2 Dropdown Results Styling */
body.lotr-mode .select2-results__options {
background: var(--lotr-warm-parchment) !important;
border: 2px solid var(--lotr-stone-gray) !important;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
}
body.lotr-mode .select2-results__option {
background: transparent !important;
color: var(--lotr-text-dark) !important;
padding: 8px 12px;
border-bottom: 1px solid var(--lotr-stone-gray);
}
body.lotr-mode .select2-results__option:last-child {
border-bottom: none;
}
body.lotr-mode .select2-results__option--highlighted,
body.lotr-mode .select2-results__option[aria-selected="true"] {
background: linear-gradient(135deg, var(--lotr-gold) 0%, var(--lotr-dark-gold) 100%) !important;
color: var(--lotr-text-dark) !important;
font-weight: 600;
}
body.lotr-mode .select2-results__option--highlighted[aria-selected="true"] {
background: linear-gradient(135deg, var(--lotr-bright-gold) 0%, var(--lotr-gold) 100%) !important;
}
/* Ignore IP Tags Styling */
body.lotr-mode .ignore-ip-tag {
background: linear-gradient(135deg, var(--lotr-warm-parchment) 0%, var(--lotr-dark-parchment) 100%) !important;
color: var(--lotr-text-dark) !important;
border: 2px solid var(--lotr-stone-gray) !important;
font-weight: 600;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
body.lotr-mode .ignore-ip-tag button {
color: var(--lotr-text-dark) !important;
font-weight: 700;
font-size: 1.1em;
line-height: 1;
padding: 0 2px;
transition: all 0.2s ease;
}
body.lotr-mode .ignore-ip-tag button:hover {
color: var(--lotr-fire-red) !important;
transform: scale(1.2);
text-shadow: 0 0 4px rgba(193, 18, 31, 0.5);
}
/* Scrollbar Styling */ /* Scrollbar Styling */
body.lotr-mode ::-webkit-scrollbar { body.lotr-mode ::-webkit-scrollbar {
width: 14px; width: 14px;
@@ -744,6 +835,52 @@ body.lotr-mode input[type="radio"] {
cursor: pointer; cursor: pointer;
} }
/* Toggle Switch Styling for LOTR Mode */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer {
position: relative;
align-items: center;
}
/* Toggle switch track - default state */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer > div.w-11 {
background: var(--lotr-stone-gray) !important;
border: 2px solid var(--lotr-brown) !important;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3) !important;
position: relative;
}
/* Toggle switch track - checked state (using peer-checked) */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer input.peer:checked ~ div {
background: linear-gradient(135deg, var(--lotr-bright-gold) 0%, var(--lotr-gold) 100%) !important;
border-color: var(--lotr-gold) !important;
box-shadow:
0 0 10px rgba(212, 175, 55, 0.5),
inset 0 2px 4px rgba(255, 255, 255, 0.3) !important;
}
/* Toggle switch focus ring */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer input.peer:focus ~ div {
box-shadow:
0 0 0 4px rgba(212, 175, 55, 0.3),
inset 0 2px 4px rgba(0, 0, 0, 0.3) !important;
}
/* Toggle switch thumb (the circle) - properly centered */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer > span.absolute {
background: var(--lotr-parchment) !important;
border: 2px solid var(--lotr-brown) !important;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.3),
inset 0 1px 2px rgba(255, 255, 255, 0.5) !important;
top: 50% !important;
transform: translateY(-50%) !important;
margin-top: 0 !important;
}
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer input.peer:checked ~ span {
transform: translateX(1.25rem) translateY(-50%) !important;
}
/* Labels */ /* Labels */
body.lotr-mode label { body.lotr-mode label {
color: var(--lotr-text-dark); color: var(--lotr-text-dark);

View File

@@ -341,9 +341,24 @@
<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>
<!-- Email Alert Preferences -->
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.email_alerts">Email Alert Preferences</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="checkbox" id="emailAlertsForBans" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" onchange="updateEmailFieldsState()">
<span class="ml-2 text-sm text-gray-700" data-i18n="settings.email_alerts_for_bans">Enable email alerts for bans</span>
</label>
<label class="flex items-center">
<input type="checkbox" id="emailAlertsForUnbans" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" onchange="updateEmailFieldsState()">
<span class="ml-2 text-sm text-gray-700" data-i18n="settings.email_alerts_for_unbans">Enable email alerts for unbans</span>
</label>
</div>
</div>
<div class="mb-4" id="emailFieldsContainer">
<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 disabled:bg-gray-100 disabled:cursor-not-allowed" 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>
@@ -579,40 +594,40 @@
</div> </div>
<!-- SMTP Configuration Group --> <!-- SMTP Configuration Group -->
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6" id="smtpFieldsContainer">
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.smtp">SMTP Configuration</h3> <h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.smtp">SMTP Configuration</h3>
<div class="mb-4"> <div class="mb-4">
<label for="smtpHost" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_host">SMTP Host</label> <label for="smtpHost" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_host">SMTP Host</label>
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="smtpHost" <input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" id="smtpHost"
data-i18n-placeholder="settings.smtp_host_placeholder" placeholder="e.g., smtp.gmail.com" required /> data-i18n-placeholder="settings.smtp_host_placeholder" placeholder="e.g., smtp.gmail.com" required />
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="smtpPort" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_port">SMTP Port</label> <label for="smtpPort" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_port">SMTP Port</label>
<select id="smtpPort" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"> <select id="smtpPort" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed">
<option value="587" selected>587 (Recommended - STARTTLS)</option> <option value="587" selected>587 (Recommended - STARTTLS)</option>
<option value="465" disabled>465 (Not Supported)</option> <option value="465" disabled>465 (Not Supported)</option>
</select> </select>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="smtpUsername" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_username">SMTP Username</label> <label for="smtpUsername" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_username">SMTP Username</label>
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="smtpUsername" <input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" id="smtpUsername"
data-i18n-placeholder="settings.smtp_username_placeholder" placeholder="e.g., user@example.com" required /> data-i18n-placeholder="settings.smtp_username_placeholder" placeholder="e.g., user@example.com" required />
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="smtpPassword" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_password">SMTP Password</label> <label for="smtpPassword" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_password">SMTP Password</label>
<input type="password" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="smtpPassword" <input type="password" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" id="smtpPassword"
data-i18n-placeholder="settings.smtp_password_placeholder" placeholder="Enter SMTP Password" required /> data-i18n-placeholder="settings.smtp_password_placeholder" placeholder="Enter SMTP Password" required />
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="smtpFrom" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_sender">Sender Email</label> <label for="smtpFrom" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_sender">Sender Email</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="smtpFrom" <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 disabled:bg-gray-100 disabled:cursor-not-allowed" id="smtpFrom"
data-i18n-placeholder="settings.smtp_sender_placeholder" placeholder="noreply@swissmakers.ch" required /> data-i18n-placeholder="settings.smtp_sender_placeholder" placeholder="noreply@swissmakers.ch" required />
</div> </div>
<div class="flex items-center mb-4"> <div class="flex items-center mb-4">
<input type="checkbox" id="smtpUseTLS" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out"> <input type="checkbox" id="smtpUseTLS" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed">
<label for="smtpUseTLS" class="ml-2 block text-sm text-gray-700" data-i18n="settings.smtp_tls">Use TLS (Recommended)</label> <label for="smtpUseTLS" class="ml-2 block text-sm text-gray-700" data-i18n="settings.smtp_tls">Use TLS (Recommended)</label>
</div> </div>
<button type="button" class="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700 transition-colors" onclick="sendTestEmail()" data-i18n="settings.send_test_email">Send Test Email</button> <button type="button" class="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed" onclick="sendTestEmail()" id="sendTestEmailBtn" data-i18n="settings.send_test_email">Send Test Email</button>
</div> </div>
<!-- Fail2Ban Configuration Group --> <!-- Fail2Ban Configuration Group -->

View File

@@ -169,6 +169,25 @@ func (h *Hub) BroadcastBanEvent(event storage.BanEventRecord) {
} }
} }
// BroadcastUnbanEvent broadcasts an unban event to all connected clients
func (h *Hub) BroadcastUnbanEvent(event storage.BanEventRecord) {
message := map[string]interface{}{
"type": "unban_event",
"data": event,
}
data, err := json.Marshal(message)
if err != nil {
log.Printf("Error marshaling unban event: %v", err)
return
}
select {
case h.broadcast <- data:
default:
log.Printf("Broadcast channel full, dropping unban event")
}
}
// readPump pumps messages from the WebSocket connection to the hub // readPump pumps messages from the WebSocket connection to the hub
func (c *Client) readPump() { func (c *Client) readPump() {
defer func() { defer func() {