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 {