Implement X-Callback-Secret for validating API requests

This commit is contained in:
2025-12-15 23:16:48 +01:00
parent c57322e38d
commit 53bb0eb79d
14 changed files with 159 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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