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"
GeoIPDatabasePath string `json:"geoipDatabasePath"` // Path to MaxMind database (optional)
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 {
@@ -132,9 +136,7 @@ func normalizeAdvancedActionsConfig(cfg AdvancedActionsConfig) AdvancedActionsCo
// init paths to key-files
const (
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" // Path to jail.local (to override conf-values from jail.conf)
jailDFile = "/etc/fail2ban/jail.d/ui-custom-action.conf"
jailFile = "/etc/fail2ban/jail.local"
actionFile = "/etc/fail2ban/action.d/ui-custom-action.conf"
actionCallbackPlaceholder = "__CALLBACK_URL__"
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>)" \
'{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]
# Default name of the chain
@@ -396,6 +410,8 @@ func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) {
currentSettings.GeoIPDatabasePath = rec.GeoIPDatabasePath
currentSettings.MaxLogLines = rec.MaxLogLines
currentSettings.CallbackSecret = rec.CallbackSecret
currentSettings.EmailAlertsForBans = rec.EmailAlertsForBans
currentSettings.EmailAlertsForUnbans = rec.EmailAlertsForUnbans
}
func applyServerRecordsLocked(records []storage.ServerRecord) {
@@ -447,18 +463,26 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) {
}
return storage.AppSettingsRecord{
// Basic app settings
Language: currentSettings.Language,
Port: currentSettings.Port,
Debug: currentSettings.Debug,
CallbackURL: currentSettings.CallbackURL,
RestartNeeded: currentSettings.RestartNeeded,
// Callback settings
CallbackURL: currentSettings.CallbackURL,
CallbackSecret: currentSettings.CallbackSecret,
// Alert settings
AlertCountriesJSON: string(countryBytes),
EmailAlertsForBans: currentSettings.EmailAlertsForBans,
EmailAlertsForUnbans: currentSettings.EmailAlertsForUnbans,
// SMTP settings
SMTPHost: currentSettings.SMTP.Host,
SMTPPort: currentSettings.SMTP.Port,
SMTPUsername: currentSettings.SMTP.Username,
SMTPPassword: currentSettings.SMTP.Password,
SMTPFrom: currentSettings.SMTP.From,
SMTPUseTLS: currentSettings.SMTP.UseTLS,
// Fail2Ban DEFAULT settings
BantimeIncrement: currentSettings.BantimeIncrement,
DefaultJailEnable: currentSettings.DefaultJailEnable,
// Convert IgnoreIPs array to space-separated string for storage
@@ -469,11 +493,11 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) {
DestEmail: currentSettings.Destemail,
Banaction: currentSettings.Banaction,
BanactionAllports: currentSettings.BanactionAllports,
// Advanced features
AdvancedActionsJSON: string(advancedBytes),
GeoIPProvider: currentSettings.GeoIPProvider,
GeoIPDatabasePath: currentSettings.GeoIPDatabasePath,
MaxLogLines: currentSettings.MaxLogLines,
CallbackSecret: currentSettings.CallbackSecret,
}, nil
}
@@ -531,6 +555,18 @@ func setDefaultsLocked() {
if currentSettings.Language == "" {
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
if portEnv := os.Getenv("PORT"); portEnv != "" {
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.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.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_host": "SMTP-Host",
"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.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.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.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",
@@ -289,8 +300,15 @@
"lotr.email.details.realm_protection": "Das Reich des Schutzes",
"lotr.email.details.origins": "Herkunft aus den",
"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.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.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.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_host": "SMTP-Host",
"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.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.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.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",
@@ -289,8 +300,15 @@
"lotr.email.details.realm_protection": "S Riich vom Schutz",
"lotr.email.details.origins": "Herkunft us de",
"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.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.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.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_host": "SMTP Host",
"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.logs.no_data": "No log entries were captured for this block.",
"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.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",
@@ -289,8 +300,15 @@
"lotr.email.details.realm_protection": "The Realm of Protection",
"lotr.email.details.origins": "Origins from 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.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.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.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_host": "Host SMTP",
"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.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.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.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",
@@ -289,7 +300,14 @@
"lotr.email.details.realm_protection": "El reino de la protección",
"lotr.email.details.origins": "Orígenes de 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.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.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.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_host": "Hôte SMTP",
"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.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.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.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",
@@ -289,7 +300,14 @@
"lotr.email.details.realm_protection": "Le royaume de la protection",
"lotr.email.details.origins": "Origines des",
"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.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.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.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_host": "Host SMTP",
"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.logs.no_data": "Nessuna voce di log è stata acquisita per questo blocco.",
"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.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",
@@ -289,7 +300,14 @@
"lotr.email.details.realm_protection": "Il regno della protezione",
"lotr.email.details.origins": "Origini dalle",
"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.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 {
// Basic app settings
Language string
Port int
Debug bool
CallbackURL string
RestartNeeded bool
// Callback settings
CallbackURL string
CallbackSecret string
// Alert settings
AlertCountriesJSON string
EmailAlertsForBans bool
EmailAlertsForUnbans bool
// SMTP settings
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
SMTPFrom string
SMTPUseTLS bool
// Fail2Ban DEFAULT settings
BantimeIncrement bool
DefaultJailEnable bool
IgnoreIP string // Stored as space-separated string, converted to array in AppSettings
@@ -69,11 +77,11 @@ type AppSettingsRecord struct {
DestEmail string
Banaction string
BanactionAllports string
// Advanced features
AdvancedActionsJSON string
GeoIPProvider string
GeoIPDatabasePath string
MaxLogLines int
CallbackSecret string
}
type ServerRecord struct {
@@ -97,7 +105,7 @@ type ServerRecord struct {
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 {
ID int64 `json:"id"`
ServerID string `json:"serverId"`
@@ -109,6 +117,7 @@ type BanEventRecord struct {
Failures string `json:"failures"`
Whois string `json:"whois"`
Logs string `json:"logs"`
EventType string `json:"eventType"`
OccurredAt time.Time `json:"occurredAt"`
CreatedAt time.Time `json:"createdAt"`
}
@@ -175,17 +184,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, 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
WHERE id = 1`)
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
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) {
return AppSettingsRecord{}, false, nil
}
@@ -194,18 +203,26 @@ WHERE id = 1`)
}
rec := AppSettingsRecord{
// Basic app settings
Language: stringFromNull(lang),
Port: intFromNull(port),
Debug: intToBool(intFromNull(debug)),
CallbackURL: stringFromNull(callback),
RestartNeeded: intToBool(intFromNull(restartNeeded)),
// Callback settings
CallbackURL: stringFromNull(callback),
CallbackSecret: stringFromNull(callbackSecret),
// Alert settings
AlertCountriesJSON: stringFromNull(alerts),
EmailAlertsForBans: intToBool(intFromNull(emailAlertsForBans)),
EmailAlertsForUnbans: intToBool(intFromNull(emailAlertsForUnbans)),
// SMTP settings
SMTPHost: stringFromNull(smtpHost),
SMTPPort: intFromNull(smtpPort),
SMTPUsername: stringFromNull(smtpUser),
SMTPPassword: stringFromNull(smtpPass),
SMTPFrom: stringFromNull(smtpFrom),
SMTPUseTLS: intToBool(intFromNull(smtpTLS)),
// Fail2Ban DEFAULT settings
BantimeIncrement: intToBool(intFromNull(bantimeInc)),
DefaultJailEnable: intToBool(intFromNull(defaultJailEn)),
IgnoreIP: stringFromNull(ignoreIP),
@@ -215,11 +232,11 @@ WHERE id = 1`)
DestEmail: stringFromNull(destemail),
Banaction: stringFromNull(banaction),
BanactionAllports: stringFromNull(banactionAllports),
// Advanced features
AdvancedActionsJSON: stringFromNull(advancedActions),
GeoIPProvider: stringFromNull(geoipProvider),
GeoIPDatabasePath: stringFromNull(geoipDatabasePath),
MaxLogLines: intFromNull(maxLogLines),
CallbackSecret: stringFromNull(callbackSecret),
}
return rec, true, nil
@@ -231,16 +248,19 @@ 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, 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 (
1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
) ON CONFLICT(id) DO UPDATE SET
language = excluded.language,
port = excluded.port,
debug = excluded.debug,
callback_url = excluded.callback_url,
restart_needed = excluded.restart_needed,
callback_url = excluded.callback_url,
callback_secret = excluded.callback_secret,
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_port = excluded.smtp_port,
smtp_username = excluded.smtp_username,
@@ -259,14 +279,16 @@ INSERT INTO app_settings (
advanced_actions = excluded.advanced_actions,
geoip_provider = excluded.geoip_provider,
geoip_database_path = excluded.geoip_database_path,
max_log_lines = excluded.max_log_lines,
callback_secret = excluded.callback_secret
max_log_lines = excluded.max_log_lines
`, rec.Language,
rec.Port,
boolToInt(rec.Debug),
rec.CallbackURL,
boolToInt(rec.RestartNeeded),
rec.CallbackURL,
rec.CallbackSecret,
rec.AlertCountriesJSON,
boolToInt(rec.EmailAlertsForBans),
boolToInt(rec.EmailAlertsForUnbans),
rec.SMTPHost,
rec.SMTPPort,
rec.SMTPUsername,
@@ -286,7 +308,6 @@ INSERT INTO app_settings (
rec.GeoIPProvider,
rec.GeoIPDatabasePath,
rec.MaxLogLines,
rec.CallbackSecret,
)
return err
}
@@ -462,10 +483,16 @@ func RecordBanEvent(ctx context.Context, record BanEventRecord) error {
record.OccurredAt = now
}
// Default to 'ban' if event type is not set
eventType := record.EventType
if eventType == "" {
eventType = "ban"
}
const query = `
INSERT INTO ban_events (
server_id, server_name, jail, ip, country, hostname, failures, whois, logs, occurred_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
server_id, server_name, jail, ip, country, hostname, failures, whois, logs, event_type, occurred_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := db.ExecContext(
ctx,
@@ -479,6 +506,7 @@ INSERT INTO ban_events (
record.Failures,
record.Whois,
record.Logs,
eventType,
record.OccurredAt.UTC(),
record.CreatedAt.UTC(),
)
@@ -500,7 +528,7 @@ func ListBanEvents(ctx context.Context, serverID string, limit int, since time.T
}
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
WHERE 1=1`
@@ -526,6 +554,7 @@ WHERE 1=1`
var results []BanEventRecord
for rows.Next() {
var rec BanEventRecord
var eventType sql.NullString
if err := rows.Scan(
&rec.ID,
&rec.ServerID,
@@ -537,11 +566,18 @@ WHERE 1=1`
&rec.Failures,
&rec.Whois,
&rec.Logs,
&eventType,
&rec.OccurredAt,
&rec.CreatedAt,
); err != nil {
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)
}
@@ -786,18 +822,26 @@ func ensureSchema(ctx context.Context) error {
const createTable = `
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
-- Basic app settings
language TEXT,
port INTEGER,
debug INTEGER,
callback_url TEXT,
restart_needed INTEGER,
-- Callback settings
callback_url TEXT,
callback_secret TEXT,
-- Alert settings
alert_countries TEXT,
email_alerts_for_bans INTEGER DEFAULT 1,
email_alerts_for_unbans INTEGER DEFAULT 0,
-- SMTP settings
smtp_host TEXT,
smtp_port INTEGER,
smtp_username TEXT,
smtp_password TEXT,
smtp_from TEXT,
smtp_use_tls INTEGER,
-- Fail2Ban DEFAULT settings
bantime_increment INTEGER,
default_jail_enable INTEGER,
ignore_ip TEXT,
@@ -807,11 +851,11 @@ CREATE TABLE IF NOT EXISTS app_settings (
destemail TEXT,
banaction TEXT,
banaction_allports TEXT,
-- Advanced features
advanced_actions TEXT,
geoip_provider TEXT,
geoip_database_path TEXT,
max_log_lines INTEGER,
callback_secret TEXT
max_log_lines INTEGER
);
CREATE TABLE IF NOT EXISTS servers (
@@ -846,6 +890,7 @@ CREATE TABLE IF NOT EXISTS ban_events (
failures TEXT,
whois TEXT,
logs TEXT,
event_type TEXT NOT NULL DEFAULT 'ban',
occurred_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
}
// Backfill needs_restart column for existing databases that predate it.
if _, err := db.ExecContext(ctx, `ALTER TABLE servers ADD COLUMN needs_restart INTEGER DEFAULT 0`); err != nil {
if !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") {
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)
}
// NOTE: Database migrations for feature releases
// For this version, we start with a fresh schema. Future feature releases
// that require database schema changes should add migration logic here.
// 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
// }
// }
_ = strings.Contains // Keep strings import for migration example above
return nil
}

View File

@@ -261,6 +261,81 @@ func BanNotificationHandler(c *gin.Context) {
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.
func ListBanEventsHandler(c *gin.Context) {
serverID := c.Query("serverId")
@@ -658,6 +733,7 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
Failures: failures,
Whois: whoisData,
Logs: filteredLogs,
EventType: "ban",
OccurredAt: time.Now().UTC(),
}
if err := storage.RecordBanEvent(ctx, event); err != nil {
@@ -682,6 +758,12 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
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
if err := sendBanAlert(ip, jail, hostname, failures, whoisData, filteredLogs, country, settings); err != nil {
log.Printf("❌ Failed to send alert email: %v", err)
@@ -692,6 +774,93 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
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.
func lookupCountry(ip, provider, dbPath string) (string, error) {
switch provider {
@@ -2320,7 +2489,11 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set
// Get translations
var subject string
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 {
subject = fmt.Sprintf("[Fail2Ban] %s: %s %s %s %s", jail,
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
if isLOTRMode {
title = getEmailTranslation(lang, "lotr.email.title")
if title == "lotr.email.title" {
title = "A Dark Servant Has Been Banished"
}
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")
logsTitle = getEmailTranslation(lang, "email.ban.logs_title")
footerText = getEmailTranslation(lang, "lotr.email.footer")
if footerText == "lotr.email.footer" {
footerText = "May the servers be protected. One ban to rule them all."
}
} else {
title = getEmailTranslation(lang, "email.ban.title")
intro = getEmailTranslation(lang, "email.ban.intro")
@@ -2364,34 +2528,15 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set
if isLOTRMode {
// Transform labels to LOTR terminology
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")
if jailLabel == "lotr.email.details.realm_protection" {
jailLabel = "The Realm of Protection"
}
countryLabelKey := getEmailTranslation(lang, "lotr.email.details.origins")
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 != "" {
countryLabel = fmt.Sprintf("%s %s", countryLabelKey, country)
} else {
countryLabel = fmt.Sprintf("%s Unknown", countryLabelKey)
}
}
timestampLabel := getEmailTranslation(lang, "lotr.email.details.banished_at")
if timestampLabel == "lotr.email.details.banished_at" {
timestampLabel = "Banished at the"
}
details = []emailDetail{
{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)
}
// *******************************************************************
// * 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 : *
// *******************************************************************

View File

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

View File

@@ -26,6 +26,10 @@
display: none;
}
#serverManagerList {
min-height: 480px;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
@@ -168,6 +172,18 @@ mark {
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 */
#backendStatus {
display: flex;

View File

@@ -46,24 +46,29 @@ function showBanEventToast(event) {
var container = document.getElementById('toast-container');
if (!container || !event) return;
var isUnban = event.eventType === 'unban';
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 jail = event.jail || 'Unknown Jail';
var server = event.serverName || event.serverId || 'Unknown Server';
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 = ''
+ '<div class="flex items-start gap-3">'
+ ' <div class="flex-shrink-0 mt-1">'
+ ' <i class="fas fa-shield-alt text-red-500"></i>'
+ ' <i class="' + icon + '"></i>'
+ ' </div>'
+ ' <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">'
+ ' <span class="font-mono font-semibold">' + escapeHtml(ip) + '</span>'
+ ' <span> banned in </span>'
+ ' <span> ' + action + ' </span>'
+ ' <span class="font-semibold">' + escapeHtml(jail) + '</span>'
+ ' </div>'
+ ' <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) {
// Check if event already exists (prevent duplicates)
// Only check by ID if both events have IDs
@@ -95,16 +95,21 @@ function addBanEventFromWebSocket(event) {
return e.id === event.id;
});
} 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) {
return e.ip === event.ip &&
e.jail === event.jail &&
e.eventType === event.eventType &&
e.occurredAt === event.occurredAt;
});
}
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
latestBanEvents.unshift(event);
@@ -121,7 +126,7 @@ function addBanEventFromWebSocket(event) {
// Refresh dashboard data (summary, stats, insights) and re-render
refreshDashboardData();
} 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) {
if (data.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 });
})
.catch(function(err) {
@@ -761,12 +765,19 @@ function renderLogOverviewContent() {
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>';
}
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 += ''
+ ' <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">' + serverCell + '</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="px-2 py-2 whitespace-nowrap">'
+ ' <div class="flex gap-2">'

View File

@@ -260,7 +260,7 @@ function openManageJailsModal() {
const jsEscapedJailName = jail.jailName.replace(/'/g, "\\'");
return ''
+ '<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">'
+ ' <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"'
+ ' ></div>'
+ ' <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>'
+ ' </label>'
+ ' </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() {
showLoading(true);
fetch('/api/settings')
@@ -78,6 +104,11 @@ function loadSettings() {
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');
for (let i = 0; i < select.options.length; i++) {
select.options[i].selected = false;
@@ -174,6 +205,8 @@ function saveSettings(event) {
callbackUrl: callbackUrl,
callbackSecret: document.getElementById('callbackSecret').value.trim(),
alertCountries: selectedCountries.length > 0 ? selectedCountries : ["ALL"],
emailAlertsForBans: document.getElementById('emailAlertsForBans').checked,
emailAlertsForUnbans: document.getElementById('emailAlertsForUnbans').checked,
bantimeIncrement: document.getElementById('bantimeIncrement').checked,
defaultJailEnable: document.getElementById('defaultJailEnable').checked,
bantime: document.getElementById('banTime').value.trim(),

View File

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

View File

@@ -419,10 +419,42 @@ body.lotr-mode .bg-gray-50 {
}
body.lotr-mode .text-blue-600 {
color: var(--lotr-gold);
color: var(--lotr-gold) !important;
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 */
body.lotr-mode table {
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);
}
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 {
color: var(--lotr-text-dark) !important;
font-weight: 700;
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 */
body.lotr-mode ::-webkit-scrollbar {
width: 14px;
@@ -744,6 +835,52 @@ body.lotr-mode input[type="radio"] {
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 */
body.lotr-mode label {
color: var(--lotr-text-dark);

View File

@@ -341,9 +341,24 @@
<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>
<!-- Email Alert Preferences -->
<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>
<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" />
<p class="text-xs text-red-600 mt-1 hidden" id="destEmailError"></p>
</div>
@@ -579,40 +594,40 @@
</div>
<!-- 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>
<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>
<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 />
</div>
<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>
<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="465" disabled>465 (Not Supported)</option>
</select>
</div>
<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>
<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 />
</div>
<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>
<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 />
</div>
<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>
<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 />
</div>
<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>
</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>
<!-- 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
func (c *Client) readPump() {
defer func() {