mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
Implement X-Callback-Secret for validating API requests
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -55,6 +56,7 @@ type AppSettings struct {
|
||||
AlertCountries []string `json:"alertCountries"`
|
||||
SMTP SMTPSettings `json:"smtp"`
|
||||
CallbackURL string `json:"callbackUrl"`
|
||||
CallbackSecret string `json:"callbackSecret"`
|
||||
AdvancedActions AdvancedActionsConfig `json:"advancedActions"`
|
||||
|
||||
Servers []Fail2banServer `json:"servers"`
|
||||
@@ -136,6 +138,7 @@ const (
|
||||
actionFile = "/etc/fail2ban/action.d/ui-custom-action.conf"
|
||||
actionCallbackPlaceholder = "__CALLBACK_URL__"
|
||||
actionServerIDPlaceholder = "__SERVER_ID__"
|
||||
actionSecretPlaceholder = "__CALLBACK_SECRET__"
|
||||
)
|
||||
|
||||
// jailLocalBanner is the standard banner for jail.local files
|
||||
@@ -172,6 +175,7 @@ norestored = 1
|
||||
|
||||
actionban = /usr/bin/curl -X POST __CALLBACK_URL__/api/ban \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Callback-Secret: __CALLBACK_SECRET__" \
|
||||
-d "$(jq -n --arg serverId '__SERVER_ID__' \
|
||||
--arg ip '<ip>' \
|
||||
--arg jail '<name>' \
|
||||
@@ -388,6 +392,10 @@ func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) {
|
||||
currentSettings.AdvancedActions = adv
|
||||
}
|
||||
}
|
||||
currentSettings.GeoIPProvider = rec.GeoIPProvider
|
||||
currentSettings.GeoIPDatabasePath = rec.GeoIPDatabasePath
|
||||
currentSettings.MaxLogLines = rec.MaxLogLines
|
||||
currentSettings.CallbackSecret = rec.CallbackSecret
|
||||
}
|
||||
|
||||
func applyServerRecordsLocked(records []storage.ServerRecord) {
|
||||
@@ -465,6 +473,7 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) {
|
||||
GeoIPProvider: currentSettings.GeoIPProvider,
|
||||
GeoIPDatabasePath: currentSettings.GeoIPDatabasePath,
|
||||
MaxLogLines: currentSettings.MaxLogLines,
|
||||
CallbackSecret: currentSettings.CallbackSecret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -542,6 +551,10 @@ func setDefaultsLocked() {
|
||||
currentSettings.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d", currentSettings.Port)
|
||||
}
|
||||
}
|
||||
// Generate callback secret if not set (only generate once, never regenerate)
|
||||
if currentSettings.CallbackSecret == "" {
|
||||
currentSettings.CallbackSecret = generateCallbackSecret()
|
||||
}
|
||||
if currentSettings.AlertCountries == nil {
|
||||
currentSettings.AlertCountries = []string{"ALL"}
|
||||
}
|
||||
@@ -667,7 +680,7 @@ func normalizeServersLocked() {
|
||||
hostname, _ := os.Hostname()
|
||||
currentSettings.Servers = []Fail2banServer{{
|
||||
ID: "local",
|
||||
Name: "Local Fail2ban",
|
||||
Name: "Fail2ban",
|
||||
Type: "local",
|
||||
SocketPath: "/var/run/fail2ban/fail2ban.sock",
|
||||
LogPath: "/var/log/fail2ban.log",
|
||||
@@ -994,7 +1007,8 @@ func writeFail2banAction(callbackURL, serverID string) error {
|
||||
return fmt.Errorf("fail2ban is not installed: /etc/fail2ban/action.d directory does not exist. Please install fail2ban package first")
|
||||
}
|
||||
|
||||
actionConfig := BuildFail2banActionConfig(callbackURL, serverID)
|
||||
settings := GetSettings()
|
||||
actionConfig := BuildFail2banActionConfig(callbackURL, serverID, settings.CallbackSecret)
|
||||
err := os.WriteFile(actionFile, []byte(actionConfig), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write action file: %w", err)
|
||||
@@ -1013,7 +1027,7 @@ func cloneServer(src Fail2banServer) Fail2banServer {
|
||||
return dst
|
||||
}
|
||||
|
||||
func BuildFail2banActionConfig(callbackURL, serverID string) string {
|
||||
func BuildFail2banActionConfig(callbackURL, serverID, secret string) string {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(callbackURL), "/")
|
||||
if trimmed == "" {
|
||||
trimmed = "http://127.0.0.1:8080"
|
||||
@@ -1021,8 +1035,43 @@ func BuildFail2banActionConfig(callbackURL, serverID string) string {
|
||||
if serverID == "" {
|
||||
serverID = "local"
|
||||
}
|
||||
if secret == "" {
|
||||
// If secret is empty, get it from settings (should be generated by setDefaultsLocked)
|
||||
settings := GetSettings()
|
||||
secret = settings.CallbackSecret
|
||||
// Last resort: if still empty, generate one (shouldn't happen)
|
||||
if secret == "" {
|
||||
secret = generateCallbackSecret()
|
||||
}
|
||||
}
|
||||
config := strings.ReplaceAll(fail2banActionTemplate, actionCallbackPlaceholder, trimmed)
|
||||
return strings.ReplaceAll(config, actionServerIDPlaceholder, serverID)
|
||||
config = strings.ReplaceAll(config, actionServerIDPlaceholder, serverID)
|
||||
config = strings.ReplaceAll(config, actionSecretPlaceholder, secret)
|
||||
return config
|
||||
}
|
||||
|
||||
// generateCallbackSecret generates a 42-character random secret using crypto/rand.
|
||||
func generateCallbackSecret() string {
|
||||
// Generate 32 random bytes (256 bits of entropy)
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
// Fallback to hex encoding if crypto/rand fails (shouldn't happen)
|
||||
fallbackBytes := make([]byte, 21) // 21 bytes = 42 hex chars
|
||||
if _, err := rand.Read(fallbackBytes); err != nil {
|
||||
// Last resort: use time-based seed (not ideal but better than nothing)
|
||||
return fmt.Sprintf("%042x", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(fallbackBytes)
|
||||
}
|
||||
// Use base64 URL-safe encoding, which gives us 43 chars for 32 bytes
|
||||
// We need exactly 42, so we'll truncate the last character (which is padding anyway)
|
||||
encoded := base64.URLEncoding.EncodeToString(bytes)
|
||||
// Base64 URL encoding of 32 bytes = 43 chars, take first 42
|
||||
if len(encoded) >= 42 {
|
||||
return encoded[:42]
|
||||
}
|
||||
// If somehow shorter, pad with random hex
|
||||
return encoded + hex.EncodeToString(bytes)[:42-len(encoded)]
|
||||
}
|
||||
|
||||
func getCallbackURLLocked() string {
|
||||
|
||||
@@ -61,9 +61,10 @@ func (ac *AgentConnector) Server() config.Fail2banServer {
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) ensureAction(ctx context.Context) error {
|
||||
settings := config.GetSettings()
|
||||
payload := map[string]any{
|
||||
"name": "ui-custom-action",
|
||||
"config": config.BuildFail2banActionConfig(config.GetCallbackURL(), ac.server.ID),
|
||||
"config": config.BuildFail2banActionConfig(config.GetCallbackURL(), ac.server.ID, settings.CallbackSecret),
|
||||
"callbackUrl": config.GetCallbackURL(),
|
||||
"setDefault": true,
|
||||
}
|
||||
|
||||
@@ -244,7 +244,8 @@ func (sc *SSHConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEve
|
||||
|
||||
func (sc *SSHConnector) ensureAction(ctx context.Context) error {
|
||||
callbackURL := config.GetCallbackURL()
|
||||
actionConfig := config.BuildFail2banActionConfig(callbackURL, sc.server.ID)
|
||||
settings := config.GetSettings()
|
||||
actionConfig := config.BuildFail2banActionConfig(callbackURL, sc.server.ID, settings.CallbackSecret)
|
||||
payload := base64.StdEncoding.EncodeToString([]byte(actionConfig))
|
||||
script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload)
|
||||
// Base64 encode the entire script to avoid shell escaping issues
|
||||
|
||||
@@ -103,6 +103,9 @@
|
||||
"settings.callback_url": "Fail2ban Callback-URL",
|
||||
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
|
||||
"settings.callback_url_hint": "Diese URL wird von allen Fail2Ban-Instanzen verwendet, um die Ban-Payloads an Fail2Ban UI zu senden. Für lokale Installationen verwenden Sie denselben Port wie Fail2Ban UI (z.B. http://127.0.0.1:8080). Für Reverse-Proxy-Setups verwenden Sie falls möglich den TLS-verschlüsselten Endpunkt (z.B. https://fail2ban.example.com).",
|
||||
"settings.callback_secret": "Fail2ban Callback-URL Secret",
|
||||
"settings.callback_secret_placeholder": "Automatisch generiertes 42-Zeichen-Secret",
|
||||
"settings.callback_secret.description": "Dieses Secret dient der Authentifizierung von Ban-API-Anfragen. Es wird automatisch in die Fail2ban-Action-Konfiguration eingefügt.",
|
||||
"settings.destination_email": "Ziel-E-Mail (Alarmempfänger)",
|
||||
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
|
||||
"settings.alert_countries": "Alarm-Länder",
|
||||
|
||||
@@ -103,6 +103,9 @@
|
||||
"settings.callback_url": "Fail2ban Callback-URL",
|
||||
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
|
||||
"settings.callback_url_hint": "Diä URL wird vo aune Fail2Ban-Instanze brucht, zum Ban-Payloads a Fail2Ban UI z sende. Für lokali Installatione bruchts de gliich Port wie z Fail2Ban UI (z.B. http://127.0.0.1:8080). Für Reverse-Proxy-Setups sött dr TLS-verschlüssleti Endpunkt wenn müglech brücht wärde (auso z.B. https://fail2ban.example.com).",
|
||||
"settings.callback_secret": "Fail2ban Callback-URL Secret",
|
||||
"settings.callback_secret_placeholder": "Automatisch generierts 42-Zeiche-Secret",
|
||||
"settings.callback_secret.description": "Zur Authentifizierig vo Ban-Benachrichtigungsafroge. Es wird outomatisch id Fail2ban-Action-Konfiguration inkludiert.",
|
||||
"settings.destination_email": "Ziiu-Email (Alarmempfänger)",
|
||||
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
|
||||
"settings.alert_countries": "Alarm-Länder",
|
||||
|
||||
@@ -103,6 +103,9 @@
|
||||
"settings.callback_url": "Fail2ban Callback URL",
|
||||
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
|
||||
"settings.callback_url_hint": "This URL is used by all Fail2Ban instances to send ban alerts back to Fail2Ban UI. For local deployments, use the same port as Fail2Ban UI (e.g., http://127.0.0.1:8080). For reverse proxy setups, use your TLS-encrypted endpoint (e.g., https://fail2ban.example.com).",
|
||||
"settings.callback_secret": "Fail2ban Callback URL Secret",
|
||||
"settings.callback_secret_placeholder": "Auto-generated 42-character secret",
|
||||
"settings.callback_secret.description": "This secret is used to authenticate ban API requests. It is automatically included to the fail2ban action configuration.",
|
||||
"settings.destination_email": "Destination Email (Alerts Receiver)",
|
||||
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
|
||||
"settings.alert_countries": "Alert Countries",
|
||||
|
||||
@@ -103,6 +103,9 @@
|
||||
"settings.callback_url": "URL de retorno de Fail2ban",
|
||||
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
|
||||
"settings.callback_url_hint": "Esta URL es utilizada por todas las instancias de Fail2Ban para enviar alertas de bloqueo a Fail2Ban UI. Para implementaciones locales, use el mismo puerto que Fail2Ban UI (ej. http://127.0.0.1:8080). Para configuraciones de proxy inverso, use su endpoint cifrado TLS (ej. https://fail2ban.example.com).",
|
||||
"settings.callback_secret": "Secret de URL de Callback de Fail2ban",
|
||||
"settings.callback_secret_placeholder": "Secret de 42 caracteres generado automáticamente",
|
||||
"settings.callback_secret.description": "Este secret se genera automáticamente y se utiliza para autenticar las solicitudes de notificación de bloqueo. Está incluido en la configuración de acción de fail2ban.",
|
||||
"settings.destination_email": "Correo electrónico de destino (receptor de alertas)",
|
||||
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
|
||||
"settings.alert_countries": "Países para alerta",
|
||||
|
||||
@@ -103,6 +103,9 @@
|
||||
"settings.callback_url": "URL de rappel Fail2ban",
|
||||
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
|
||||
"settings.callback_url_hint": "Cette URL est utilisée par toutes les instances Fail2Ban pour envoyer les alertes de bannissement à Fail2Ban UI. Pour les déploiements locaux, utilisez le même port que Fail2Ban UI (p. ex. http://127.0.0.1:8080). Pour les configurations de reverse proxy, utilisez votre point de terminaison chiffré TLS (p. ex. https://fail2ban.example.com).",
|
||||
"settings.callback_secret": "Secret d'URL de Callback Fail2ban",
|
||||
"settings.callback_secret_placeholder": "Secret de 42 caractères généré automatiquement",
|
||||
"settings.callback_secret.description": "Ce secret est généré automatiquement et utilisé pour authentifier les demandes de notification de bannissement. Il est inclus dans la configuration d'action de fail2ban.",
|
||||
"settings.destination_email": "Email de destination (récepteur des alertes)",
|
||||
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
|
||||
"settings.alert_countries": "Pays d'alerte",
|
||||
|
||||
@@ -103,6 +103,9 @@
|
||||
"settings.callback_url": "URL di callback Fail2ban",
|
||||
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
|
||||
"settings.callback_url_hint": "Questo URL viene utilizzato da tutte le istanze Fail2Ban per inviare gli avvisi di ban a Fail2Ban UI. Per le distribuzioni locali, utilizzare la stessa porta di Fail2Ban UI (es. http://127.0.0.1:8080). Per le configurazioni di reverse proxy, utilizzare il proprio endpoint crittografato TLS (es. https://fail2ban.example.com).",
|
||||
"settings.callback_secret": "Secret URL di Callback Fail2ban",
|
||||
"settings.callback_secret_placeholder": "Secret di 42 caratteri generato automaticamente",
|
||||
"settings.callback_secret.description": "Questo secret viene generato automaticamente e utilizzato per autenticare le richieste di notifica di ban. È incluso nella configurazione dell'azione di fail2ban.",
|
||||
"settings.destination_email": "Email di destinazione (ricevente allarmi)",
|
||||
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
|
||||
"settings.alert_countries": "Paesi per allarme",
|
||||
|
||||
@@ -73,6 +73,7 @@ type AppSettingsRecord struct {
|
||||
GeoIPProvider string
|
||||
GeoIPDatabasePath string
|
||||
MaxLogLines int
|
||||
CallbackSecret string
|
||||
}
|
||||
|
||||
type ServerRecord struct {
|
||||
@@ -174,17 +175,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
|
||||
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
|
||||
FROM app_settings
|
||||
WHERE id = 1`)
|
||||
|
||||
var (
|
||||
lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, banaction, banactionAllports, advancedActions, geoipProvider, geoipDatabasePath sql.NullString
|
||||
port, smtpPort, maxretry, maxLogLines sql.NullInt64
|
||||
debug, restartNeeded, smtpTLS, bantimeInc, defaultJailEn sql.NullInt64
|
||||
lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, banaction, banactionAllports, advancedActions, geoipProvider, geoipDatabasePath, callbackSecret sql.NullString
|
||||
port, smtpPort, maxretry, maxLogLines sql.NullInt64
|
||||
debug, restartNeeded, smtpTLS, bantimeInc, defaultJailEn sql.NullInt64
|
||||
)
|
||||
|
||||
err := row.Scan(&lang, &port, &debug, &callback, &restartNeeded, &alerts, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &defaultJailEn, &ignoreIP, &bantime, &findtime, &maxretry, &destemail, &banaction, &banactionAllports, &advancedActions, &geoipProvider, &geoipDatabasePath, &maxLogLines)
|
||||
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)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AppSettingsRecord{}, false, nil
|
||||
}
|
||||
@@ -218,6 +219,7 @@ WHERE id = 1`)
|
||||
GeoIPProvider: stringFromNull(geoipProvider),
|
||||
GeoIPDatabasePath: stringFromNull(geoipDatabasePath),
|
||||
MaxLogLines: intFromNull(maxLogLines),
|
||||
CallbackSecret: stringFromNull(callbackSecret),
|
||||
}
|
||||
|
||||
return rec, true, nil
|
||||
@@ -229,9 +231,9 @@ func SaveAppSettings(ctx context.Context, rec AppSettingsRecord) error {
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO app_settings (
|
||||
id, language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, default_jail_enable, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions, geoip_provider, geoip_database_path, max_log_lines
|
||||
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
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
) ON CONFLICT(id) DO UPDATE SET
|
||||
language = excluded.language,
|
||||
port = excluded.port,
|
||||
@@ -257,7 +259,8 @@ 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
|
||||
max_log_lines = excluded.max_log_lines,
|
||||
callback_secret = excluded.callback_secret
|
||||
`, rec.Language,
|
||||
rec.Port,
|
||||
boolToInt(rec.Debug),
|
||||
@@ -283,6 +286,7 @@ INSERT INTO app_settings (
|
||||
rec.GeoIPProvider,
|
||||
rec.GeoIPDatabasePath,
|
||||
rec.MaxLogLines,
|
||||
rec.CallbackSecret,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -806,7 +810,8 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
||||
advanced_actions TEXT,
|
||||
geoip_provider TEXT,
|
||||
geoip_database_path TEXT,
|
||||
max_log_lines INTEGER
|
||||
max_log_lines INTEGER,
|
||||
callback_secret TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
@@ -914,6 +919,13 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status);
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
Reference in New Issue
Block a user