switch to toast instead of alert messages, implement serverIDs and restart tracking for every remote-server

This commit is contained in:
2025-11-14 10:22:44 +01:00
parent bff920e5b3
commit a24e0779d2
7 changed files with 359 additions and 188 deletions

View File

@@ -77,6 +77,7 @@ const (
jailDFile = "/etc/fail2ban/jail.d/ui-custom-action.conf" jailDFile = "/etc/fail2ban/jail.d/ui-custom-action.conf"
actionFile = "/etc/fail2ban/action.d/ui-custom-action.conf" actionFile = "/etc/fail2ban/action.d/ui-custom-action.conf"
actionCallbackPlaceholder = "__CALLBACK_URL__" actionCallbackPlaceholder = "__CALLBACK_URL__"
actionServerIDPlaceholder = "__SERVER_ID__"
) )
const fail2banActionTemplate = `[INCLUDES] const fail2banActionTemplate = `[INCLUDES]
@@ -95,13 +96,14 @@ norestored = 1
actionban = /usr/bin/curl -X POST __CALLBACK_URL__/api/ban \ actionban = /usr/bin/curl -X POST __CALLBACK_URL__/api/ban \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$(jq -n --arg ip '<ip>' \ -d "$(jq -n --arg serverId '__SERVER_ID__' \
--arg ip '<ip>' \
--arg jail '<name>' \ --arg jail '<name>' \
--arg hostname '<fq-hostname>' \ --arg hostname '<fq-hostname>' \
--arg failures '<failures>' \ --arg failures '<failures>' \
--arg whois "$(whois <ip> || echo 'missing whois program')" \ --arg whois "$(whois <ip> || echo 'missing whois program')" \
--arg logs "$(tac <logpath> | grep <grepopts> -wF <ip>)" \ --arg logs "$(tac <logpath> | grep <grepopts> -wF <ip>)" \
'{ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, whois: $whois, logs: $logs}')" '{serverId: $serverId, ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, whois: $whois, logs: $logs}')"
[Init] [Init]
@@ -143,6 +145,7 @@ type Fail2banServer struct {
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
IsDefault bool `json:"isDefault"` IsDefault bool `json:"isDefault"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
RestartNeeded bool `json:"restartNeeded"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
@@ -323,6 +326,7 @@ func applyServerRecordsLocked(records []storage.ServerRecord) {
Tags: tags, Tags: tags,
IsDefault: rec.IsDefault, IsDefault: rec.IsDefault,
Enabled: rec.Enabled, Enabled: rec.Enabled,
RestartNeeded: rec.NeedsRestart,
CreatedAt: rec.CreatedAt, CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt, UpdatedAt: rec.UpdatedAt,
enabledSet: true, enabledSet: true,
@@ -399,6 +403,7 @@ func toServerRecordsLocked() ([]storage.ServerRecord, error) {
TagsJSON: string(tagBytes), TagsJSON: string(tagBytes),
IsDefault: srv.IsDefault, IsDefault: srv.IsDefault,
Enabled: srv.Enabled, Enabled: srv.Enabled,
NeedsRestart: srv.RestartNeeded,
CreatedAt: createdAt, CreatedAt: createdAt,
UpdatedAt: updatedAt, UpdatedAt: updatedAt,
}) })
@@ -563,6 +568,9 @@ func normalizeServersLocked() {
} }
} }
server.enabledSet = true server.enabledSet = true
if !server.Enabled {
server.RestartNeeded = false
}
if server.IsDefault && !server.Enabled { if server.IsDefault && !server.Enabled {
server.IsDefault = false server.IsDefault = false
} }
@@ -584,6 +592,8 @@ func normalizeServersLocked() {
sort.SliceStable(currentSettings.Servers, func(i, j int) bool { sort.SliceStable(currentSettings.Servers, func(i, j int) bool {
return currentSettings.Servers[i].CreatedAt.Before(currentSettings.Servers[j].CreatedAt) return currentSettings.Servers[i].CreatedAt.Before(currentSettings.Servers[j].CreatedAt)
}) })
updateGlobalRestartFlagLocked()
} }
func generateServerID() string { func generateServerID() string {
@@ -595,7 +605,7 @@ func generateServerID() string {
} }
// ensureFail2banActionFiles writes the local action files if Fail2ban is present. // ensureFail2banActionFiles writes the local action files if Fail2ban is present.
func ensureFail2banActionFiles(callbackURL string) error { func ensureFail2banActionFiles(callbackURL, serverID string) error {
DebugLog("----------------------------") DebugLog("----------------------------")
DebugLog("ensureFail2banActionFiles called (settings.go)") DebugLog("ensureFail2banActionFiles called (settings.go)")
@@ -609,7 +619,7 @@ func ensureFail2banActionFiles(callbackURL string) error {
if err := ensureJailDConfig(); err != nil { if err := ensureJailDConfig(); err != nil {
return err return err
} }
return writeFail2banAction(callbackURL) return writeFail2banAction(callbackURL, serverID)
} }
// setupGeoCustomAction checks and replaces the default action in jail.local with our from fail2ban-UI // setupGeoCustomAction checks and replaces the default action in jail.local with our from fail2ban-UI
@@ -725,14 +735,14 @@ action_mwlg = %(action_)s
} }
// writeFail2banAction creates or updates the action file with the AlertCountries. // writeFail2banAction creates or updates the action file with the AlertCountries.
func writeFail2banAction(callbackURL string) error { func writeFail2banAction(callbackURL, serverID string) error {
DebugLog("Running initial writeFail2banAction()") // entry point DebugLog("Running initial writeFail2banAction()") // entry point
DebugLog("----------------------------") DebugLog("----------------------------")
if err := os.MkdirAll(filepath.Dir(actionFile), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(actionFile), 0o755); err != nil {
return fmt.Errorf("failed to ensure action.d directory: %w", err) return fmt.Errorf("failed to ensure action.d directory: %w", err)
} }
actionConfig := BuildFail2banActionConfig(callbackURL) actionConfig := BuildFail2banActionConfig(callbackURL, serverID)
err := os.WriteFile(actionFile, []byte(actionConfig), 0644) err := os.WriteFile(actionFile, []byte(actionConfig), 0644)
if err != nil { if err != nil {
return fmt.Errorf("failed to write action file: %w", err) return fmt.Errorf("failed to write action file: %w", err)
@@ -751,12 +761,16 @@ func cloneServer(src Fail2banServer) Fail2banServer {
return dst return dst
} }
func BuildFail2banActionConfig(callbackURL string) string { func BuildFail2banActionConfig(callbackURL, serverID string) string {
trimmed := strings.TrimRight(strings.TrimSpace(callbackURL), "/") trimmed := strings.TrimRight(strings.TrimSpace(callbackURL), "/")
if trimmed == "" { if trimmed == "" {
trimmed = "http://127.0.0.1:8080" trimmed = "http://127.0.0.1:8080"
} }
return strings.ReplaceAll(fail2banActionTemplate, actionCallbackPlaceholder, trimmed) if serverID == "" {
serverID = "local"
}
config := strings.ReplaceAll(fail2banActionTemplate, actionCallbackPlaceholder, trimmed)
return strings.ReplaceAll(config, actionServerIDPlaceholder, serverID)
} }
func getCallbackURLLocked() string { func getCallbackURLLocked() string {
@@ -786,7 +800,7 @@ func EnsureLocalFail2banAction(server Fail2banServer) error {
settingsLock.RLock() settingsLock.RLock()
callbackURL := getCallbackURLLocked() callbackURL := getCallbackURLLocked()
settingsLock.RUnlock() settingsLock.RUnlock()
return ensureFail2banActionFiles(callbackURL) return ensureFail2banActionFiles(callbackURL, server.ID)
} }
func serverByIDLocked(id string) (Fail2banServer, bool) { func serverByIDLocked(id string) (Fail2banServer, bool) {
@@ -934,6 +948,35 @@ func clearDefaultLocked() {
} }
} }
func setServerRestartFlagLocked(serverID string, value bool) bool {
for idx := range currentSettings.Servers {
if currentSettings.Servers[idx].ID == serverID {
currentSettings.Servers[idx].RestartNeeded = value
return true
}
}
return false
}
func anyServerNeedsRestartLocked() bool {
for _, srv := range currentSettings.Servers {
if srv.RestartNeeded {
return true
}
}
return false
}
func updateGlobalRestartFlagLocked() {
currentSettings.RestartNeeded = anyServerNeedsRestartLocked()
}
func markAllServersRestartLocked() {
for idx := range currentSettings.Servers {
currentSettings.Servers[idx].RestartNeeded = true
}
}
// DeleteServer removes a server by ID. // DeleteServer removes a server by ID.
func DeleteServer(id string) error { func DeleteServer(id string) error {
settingsLock.Lock() settingsLock.Lock()
@@ -998,21 +1041,43 @@ func GetSettings() AppSettings {
return currentSettings return currentSettings
} }
// MarkRestartNeeded sets restartNeeded = true and saves JSON // MarkRestartNeeded marks the specified server as requiring a restart.
func MarkRestartNeeded() error { func MarkRestartNeeded(serverID string) error {
settingsLock.Lock() settingsLock.Lock()
defer settingsLock.Unlock() defer settingsLock.Unlock()
currentSettings.RestartNeeded = true if serverID == "" {
return fmt.Errorf("server id must be provided")
}
if !setServerRestartFlagLocked(serverID, true) {
return fmt.Errorf("server %s not found", serverID)
}
updateGlobalRestartFlagLocked()
if err := persistServersLocked(); err != nil {
return err
}
return persistAppSettingsLocked() return persistAppSettingsLocked()
} }
// MarkRestartDone sets restartNeeded = false and saves JSON // MarkRestartDone marks the specified server as no longer requiring a restart.
func MarkRestartDone() error { func MarkRestartDone(serverID string) error {
settingsLock.Lock() settingsLock.Lock()
defer settingsLock.Unlock() defer settingsLock.Unlock()
currentSettings.RestartNeeded = false if serverID == "" {
return fmt.Errorf("server id must be provided")
}
if !setServerRestartFlagLocked(serverID, false) {
return fmt.Errorf("server %s not found", serverID)
}
updateGlobalRestartFlagLocked()
if err := persistServersLocked(); err != nil {
return err
}
return persistAppSettingsLocked() return persistAppSettingsLocked()
} }
@@ -1026,16 +1091,15 @@ func UpdateSettings(new AppSettings) (AppSettings, error) {
old := currentSettings old := currentSettings
// If certain fields change, we mark reload needed // If certain fields change, we mark reload needed
if old.BantimeIncrement != new.BantimeIncrement || restartTriggered := old.BantimeIncrement != new.BantimeIncrement ||
old.IgnoreIP != new.IgnoreIP || old.IgnoreIP != new.IgnoreIP ||
old.Bantime != new.Bantime || old.Bantime != new.Bantime ||
old.Findtime != new.Findtime || old.Findtime != new.Findtime ||
//old.Maxretry != new.Maxretry || old.Maxretry != new.Maxretry
old.Maxretry != new.Maxretry { if restartTriggered {
new.RestartNeeded = true new.RestartNeeded = true
} else { } else {
// preserve previous RestartNeeded if it was already true new.RestartNeeded = anyServerNeedsRestartLocked()
new.RestartNeeded = new.RestartNeeded || old.RestartNeeded
} }
new.CallbackURL = strings.TrimSpace(new.CallbackURL) new.CallbackURL = strings.TrimSpace(new.CallbackURL)
@@ -1047,6 +1111,10 @@ func UpdateSettings(new AppSettings) (AppSettings, error) {
} }
currentSettings = new currentSettings = new
setDefaultsLocked() setDefaultsLocked()
if currentSettings.RestartNeeded && restartTriggered {
markAllServersRestartLocked()
updateGlobalRestartFlagLocked()
}
DebugLog("New settings applied: %v", currentSettings) // Log settings applied DebugLog("New settings applied: %v", currentSettings) // Log settings applied
if err := persistAllLocked(); err != nil { if err := persistAllLocked(); err != nil {

View File

@@ -80,9 +80,18 @@ func ReloadFail2ban() error {
return conn.Reload(context.Background()) return conn.Reload(context.Background())
} }
// RestartFail2ban restarts the Fail2ban service using the default connector. // RestartFail2ban restarts the Fail2ban service using the provided server or default connector.
func RestartFail2ban() error { func RestartFail2ban(serverID string) error {
conn, err := GetManager().DefaultConnector() manager := GetManager()
var (
conn Connector
err error
)
if serverID != "" {
conn, err = manager.Connector(serverID)
} else {
conn, err = manager.DefaultConnector()
}
if err != nil { if err != nil {
return err return err
} }

View File

@@ -63,7 +63,7 @@ func (ac *AgentConnector) Server() config.Fail2banServer {
func (ac *AgentConnector) ensureAction(ctx context.Context) error { func (ac *AgentConnector) ensureAction(ctx context.Context) error {
payload := map[string]any{ payload := map[string]any{
"name": "ui-custom-action", "name": "ui-custom-action",
"config": config.BuildFail2banActionConfig(config.GetCallbackURL()), "config": config.BuildFail2banActionConfig(config.GetCallbackURL(), ac.server.ID),
"callbackUrl": config.GetCallbackURL(), "callbackUrl": config.GetCallbackURL(),
"setDefault": true, "setDefault": true,
} }

View File

@@ -199,7 +199,7 @@ func (sc *SSHConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEve
func (sc *SSHConnector) ensureAction(ctx context.Context) error { func (sc *SSHConnector) ensureAction(ctx context.Context) error {
callbackURL := config.GetCallbackURL() callbackURL := config.GetCallbackURL()
actionConfig := config.BuildFail2banActionConfig(callbackURL) actionConfig := config.BuildFail2banActionConfig(callbackURL, sc.server.ID)
payload := base64.StdEncoding.EncodeToString([]byte(actionConfig)) payload := base64.StdEncoding.EncodeToString([]byte(actionConfig))
script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload) script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload)
// Base64 encode the entire script to avoid shell escaping issues // Base64 encode the entire script to avoid shell escaping issues

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time" "time"
@@ -82,6 +83,7 @@ type ServerRecord struct {
TagsJSON string TagsJSON string
IsDefault bool IsDefault bool
Enabled bool Enabled bool
NeedsRestart bool
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -242,7 +244,7 @@ func ListServers(ctx context.Context) ([]ServerRecord, error) {
} }
rows, err := db.QueryContext(ctx, ` rows, err := db.QueryContext(ctx, `
SELECT id, name, type, host, port, socket_path, log_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, created_at, updated_at SELECT id, name, type, host, port, socket_path, log_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, needs_restart, created_at, updated_at
FROM servers FROM servers
ORDER BY created_at`) ORDER BY created_at`)
if err != nil { if err != nil {
@@ -257,7 +259,7 @@ ORDER BY created_at`)
var name, serverType sql.NullString var name, serverType sql.NullString
var created, updated sql.NullString var created, updated sql.NullString
var port sql.NullInt64 var port sql.NullInt64
var isDefault, enabled sql.NullInt64 var isDefault, enabled, needsRestart sql.NullInt64
if err := rows.Scan( if err := rows.Scan(
&rec.ID, &rec.ID,
@@ -275,6 +277,7 @@ ORDER BY created_at`)
&tags, &tags,
&isDefault, &isDefault,
&enabled, &enabled,
&needsRestart,
&created, &created,
&updated, &updated,
); err != nil { ); err != nil {
@@ -295,6 +298,7 @@ ORDER BY created_at`)
rec.TagsJSON = stringFromNull(tags) rec.TagsJSON = stringFromNull(tags)
rec.IsDefault = intToBool(intFromNull(isDefault)) rec.IsDefault = intToBool(intFromNull(isDefault))
rec.Enabled = intToBool(intFromNull(enabled)) rec.Enabled = intToBool(intFromNull(enabled))
rec.NeedsRestart = intToBool(intFromNull(needsRestart))
if created.Valid { if created.Valid {
if t, err := time.Parse(time.RFC3339Nano, created.String); err == nil { if t, err := time.Parse(time.RFC3339Nano, created.String); err == nil {
@@ -334,9 +338,9 @@ func ReplaceServers(ctx context.Context, servers []ServerRecord) error {
stmt, err := tx.PrepareContext(ctx, ` stmt, err := tx.PrepareContext(ctx, `
INSERT INTO servers ( INSERT INTO servers (
id, name, type, host, port, socket_path, log_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, created_at, updated_at id, name, type, host, port, socket_path, log_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, needs_restart, created_at, updated_at
) VALUES ( ) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)`) )`)
if err != nil { if err != nil {
return err return err
@@ -368,6 +372,7 @@ INSERT INTO servers (
srv.TagsJSON, srv.TagsJSON,
boolToInt(srv.IsDefault), boolToInt(srv.IsDefault),
boolToInt(srv.Enabled), boolToInt(srv.Enabled),
boolToInt(srv.NeedsRestart),
createdAt.Format(time.RFC3339Nano), createdAt.Format(time.RFC3339Nano),
updatedAt.Format(time.RFC3339Nano), updatedAt.Format(time.RFC3339Nano),
); err != nil { ); err != nil {
@@ -568,6 +573,7 @@ CREATE TABLE IF NOT EXISTS servers (
tags TEXT, tags TEXT,
is_default INTEGER, is_default INTEGER,
enabled INTEGER, enabled INTEGER,
needs_restart INTEGER DEFAULT 0,
created_at TEXT, created_at TEXT,
updated_at TEXT updated_at TEXT
); );
@@ -591,8 +597,18 @@ CREATE INDEX IF NOT EXISTS idx_ban_events_server_id ON ban_events(server_id);
CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at); CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at);
` `
_, err := db.ExecContext(ctx, createTable) if _, err := db.ExecContext(ctx, createTable); err != nil {
return err 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
}
}
return nil
} }
func ensureDirectory(path string) error { func ensureDirectory(path string) error {

View File

@@ -516,7 +516,12 @@ func GetJailFilterConfigHandler(c *gin.Context) {
config.DebugLog("----------------------------") config.DebugLog("----------------------------")
config.DebugLog("GetJailFilterConfigHandler called (handlers.go)") // entry point config.DebugLog("GetJailFilterConfigHandler called (handlers.go)") // entry point
jail := c.Param("jail") jail := c.Param("jail")
cfg, err := fail2ban.GetFilterConfig(jail) conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cfg, err := conn.GetFilterConfig(c.Request.Context(), jail)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -532,6 +537,11 @@ func SetJailFilterConfigHandler(c *gin.Context) {
config.DebugLog("----------------------------") config.DebugLog("----------------------------")
config.DebugLog("SetJailFilterConfigHandler called (handlers.go)") // entry point config.DebugLog("SetJailFilterConfigHandler called (handlers.go)") // entry point
jail := c.Param("jail") jail := c.Param("jail")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Parse JSON body (containing the new filter content) // Parse JSON body (containing the new filter content)
var req struct { var req struct {
@@ -542,25 +552,17 @@ func SetJailFilterConfigHandler(c *gin.Context) {
return return
} }
// Write the filter config file to /etc/fail2ban/filter.d/<jail>.conf if err := conn.SetFilterConfig(c.Request.Context(), jail, req.Config); err != nil {
if err := fail2ban.SetFilterConfig(jail, req.Config); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
// Mark reload needed in our UI settings if err := conn.Reload(c.Request.Context()); err != nil {
// if err := config.MarkRestartNeeded(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "filter saved but reload failed: " + err.Error()})
// c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return
// return }
// }
c.JSON(http.StatusOK, gin.H{"message": "jail config updated"}) c.JSON(http.StatusOK, gin.H{"message": "Filter updated and fail2ban reloaded"})
// Return a simple JSON response without forcing a blocking alert
// c.JSON(http.StatusOK, gin.H{
// "message": "Filter updated, reload needed",
// "restartNeeded": true,
// })
} }
// ManageJailsHandler returns a list of all jails (from jail.local and jail.d) // ManageJailsHandler returns a list of all jails (from jail.local and jail.d)
@@ -602,7 +604,7 @@ func UpdateJailManagementHandler(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update jail settings: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update jail settings: " + err.Error()})
return return
} }
if err := config.MarkRestartNeeded(); err != nil { if err := config.MarkRestartNeeded(conn.Server().ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -739,17 +741,18 @@ func RestartFail2banHandler(c *gin.Context) {
config.DebugLog("----------------------------") config.DebugLog("----------------------------")
config.DebugLog("ApplyFail2banSettings called (handlers.go)") // entry point config.DebugLog("ApplyFail2banSettings called (handlers.go)") // entry point
// First we write our new settings to /etc/fail2ban/jail.local conn, err := resolveConnector(c)
// if err := fail2ban.ApplyFail2banSettings("/etc/fail2ban/jail.local"); err != nil { if err != nil {
// c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
// return return
// } }
server := conn.Server()
// Attempt to restart the fail2ban service. // Attempt to restart the fail2ban service.
restartErr := fail2ban.RestartFail2ban() restartErr := fail2ban.RestartFail2ban(server.ID)
if restartErr != nil { if restartErr != nil {
// Check if running inside a container. // Check if running inside a container.
if _, container := os.LookupEnv("CONTAINER"); container { if _, container := os.LookupEnv("CONTAINER"); container && server.Type == "local" {
// In a container, the restart command may fail (since fail2ban runs on the host). // In a container, the restart command may fail (since fail2ban runs on the host).
// Log the error and continue, so we can mark the restart as done. // Log the error and continue, so we can mark the restart as done.
log.Printf("Warning: restart failed inside container (expected behavior): %v", restartErr) log.Printf("Warning: restart failed inside container (expected behavior): %v", restartErr)
@@ -761,7 +764,7 @@ func RestartFail2banHandler(c *gin.Context) {
} }
// Only call MarkRestartDone if we either successfully restarted the service or we are in a container. // Only call MarkRestartDone if we either successfully restarted the service or we are in a container.
if err := config.MarkRestartDone(); err != nil { if err := config.MarkRestartDone(server.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }

View File

@@ -136,6 +136,48 @@
padding: 0.1em 0.2em; padding: 0.1em 0.2em;
border-radius: 0.25em; border-radius: 0.25em;
} }
/* Toast notifications */
#toast-container {
position: fixed;
top: 1.5rem;
right: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 10000;
pointer-events: none;
}
.toast {
min-width: 240px;
max-width: 360px;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
color: #fff;
font-weight: 500;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
opacity: 0;
transform: translateY(-6px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast-success {
background-color: #047857;
}
.toast-error {
background-color: #b91c1c;
}
.toast-info {
background-color: #1d4ed8;
}
</style> </style>
</head> </head>
@@ -154,6 +196,8 @@
</div> </div>
</div> </div>
<div id="toast-container"></div>
<!-- ******************************************************************* --> <!-- ******************************************************************* -->
<!-- Navigation START --> <!-- Navigation START -->
<!-- ******************************************************************* --> <!-- ******************************************************************* -->
@@ -821,7 +865,7 @@
getTranslationsSettingsOnPageload() getTranslationsSettingsOnPageload()
]) ])
.then(function() { .then(function() {
checkRestartNeeded(); updateRestartBanner();
return refreshData({ silent: true }); return refreshData({ silent: true });
}) })
.catch(function(err) { .catch(function(err) {
@@ -854,6 +898,25 @@
} }
} }
function showToast(message, type) {
var container = document.getElementById('toast-container');
if (!container || !message) return;
var toast = document.createElement('div');
var variant = type || 'info';
toast.className = 'toast toast-' + variant;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(function() {
toast.classList.add('show');
});
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() {
toast.remove();
}, 300);
}, 5000);
}
// Fetch and display own external IP for webUI // Fetch and display own external IP for webUI
function displayExternalIP() { function displayExternalIP() {
fetch('https://api.ipify.org?format=json') fetch('https://api.ipify.org?format=json')
@@ -905,18 +968,14 @@
} }
} }
// Check if there is still a reload of the fail2ban service needed function updateRestartBanner() {
function checkRestartNeeded() { var banner = document.getElementById('restartBanner');
fetch('/api/settings') if (!banner) return;
.then(res => res.json()) if (currentServer && currentServer.restartNeeded) {
.then(data => { banner.style.display = 'block';
if (data.restartNeeded) {
document.getElementById('restartBanner').style.display = 'block';
} else { } else {
document.getElementById('restartBanner').style.display = 'none'; banner.style.display = 'none';
} }
})
.catch(err => console.error('Error checking restartNeeded:', err));
} }
// Load dynamically the other pages when navigating in nav // Load dynamically the other pages when navigating in nav
@@ -997,6 +1056,7 @@
} }
renderServerSelector(); renderServerSelector();
renderServerSubtitle(); renderServerSubtitle();
updateRestartBanner();
}) })
.catch(function(err) { .catch(function(err) {
console.error('Error loading servers:', err); console.error('Error loading servers:', err);
@@ -1005,6 +1065,7 @@
currentServer = null; currentServer = null;
renderServerSelector(); renderServerSelector();
renderServerSubtitle(); renderServerSubtitle();
updateRestartBanner();
}); });
} }
@@ -1088,6 +1149,7 @@
} }
renderServerSelector(); renderServerSelector();
renderServerSubtitle(); renderServerSubtitle();
updateRestartBanner();
refreshData(); refreshData();
} }
@@ -1643,10 +1705,10 @@
.then(function(res) { return res.json(); }) .then(function(res) { return res.json(); })
.then(function(data) { .then(function(data) {
if (data.error) { if (data.error) {
alert('Error saving server: ' + data.error); showToast('Error saving server: ' + (data.error || 'Unknown error'), 'error');
return; return;
} }
alert(t('servers.form.success', 'Server saved successfully.')); showToast(t('servers.form.success', 'Server saved successfully.'), 'success');
var saved = data.server || {}; var saved = data.server || {};
currentServerId = saved.id || currentServerId; currentServerId = saved.id || currentServerId;
return loadServers().then(function() { return loadServers().then(function() {
@@ -1660,7 +1722,7 @@
}); });
}) })
.catch(function(err) { .catch(function(err) {
alert('Error saving server: ' + err); showToast('Error saving server: ' + err, 'error');
}) })
.finally(function() { .finally(function() {
showLoading(false); showLoading(false);
@@ -1733,7 +1795,7 @@
.then(function(res) { return res.json(); }) .then(function(res) { return res.json(); })
.then(function(data) { .then(function(data) {
if (data.error) { if (data.error) {
alert('Error saving server: ' + data.error); showToast('Error saving server: ' + (data.error || 'Unknown error'), 'error');
return; return;
} }
if (!enabled && currentServerId === serverId) { if (!enabled && currentServerId === serverId) {
@@ -1748,7 +1810,7 @@
}); });
}) })
.catch(function(err) { .catch(function(err) {
alert('Error saving server: ' + err); showToast('Error saving server: ' + err, 'error');
}) })
.finally(function() { .finally(function() {
showLoading(false); showLoading(false);
@@ -1764,13 +1826,13 @@
.then(function(res) { return res.json(); }) .then(function(res) { return res.json(); })
.then(function(data) { .then(function(data) {
if (data.error) { if (data.error) {
alert(t(data.messageKey || 'servers.actions.test_failure', data.error)); showToast(t(data.messageKey || 'servers.actions.test_failure', data.error), 'error');
return; return;
} }
alert(t(data.messageKey || 'servers.actions.test_success', data.message || 'Connection successful')); showToast(t(data.messageKey || 'servers.actions.test_success', data.message || 'Connection successful'), 'success');
}) })
.catch(function(err) { .catch(function(err) {
alert(t('servers.actions.test_failure', 'Connection failed') + ': ' + err); showToast(t('servers.actions.test_failure', 'Connection failed') + ': ' + err, 'error');
}) })
.finally(function() { .finally(function() {
showLoading(false); showLoading(false);
@@ -1784,7 +1846,7 @@
.then(function(res) { return res.json(); }) .then(function(res) { return res.json(); })
.then(function(data) { .then(function(data) {
if (data.error) { if (data.error) {
alert('Error deleting server: ' + data.error); showToast('Error deleting server: ' + (data.error || 'Unknown error'), 'error');
return; return;
} }
if (currentServerId === serverId) { if (currentServerId === serverId) {
@@ -1796,10 +1858,12 @@
renderServerSelector(); renderServerSelector();
renderServerSubtitle(); renderServerSubtitle();
return refreshData({ silent: true }); return refreshData({ silent: true });
}).then(function() {
showToast(t('servers.actions.delete_success', 'Server removed'), 'success');
}); });
}) })
.catch(function(err) { .catch(function(err) {
alert('Error deleting server: ' + err); showToast('Error deleting server: ' + err, 'error');
}) })
.finally(function() { .finally(function() {
showLoading(false); showLoading(false);
@@ -1812,7 +1876,7 @@
.then(function(res) { return res.json(); }) .then(function(res) { return res.json(); })
.then(function(data) { .then(function(data) {
if (data.error) { if (data.error) {
alert('Error setting default server: ' + data.error); showToast('Error setting default server: ' + (data.error || 'Unknown error'), 'error');
return; return;
} }
currentServerId = data.server ? data.server.id : serverId; currentServerId = data.server ? data.server.id : serverId;
@@ -1821,10 +1885,12 @@
renderServerSelector(); renderServerSelector();
renderServerSubtitle(); renderServerSubtitle();
return refreshData({ silent: true }); return refreshData({ silent: true });
}).then(function() {
showToast(t('servers.actions.set_default_success', 'Server set as default'), 'success');
}); });
}) })
.catch(function(err) { .catch(function(err) {
alert('Error setting default server: ' + err); showToast('Error setting default server: ' + err, 'error');
}) })
.finally(function() { .finally(function() {
showLoading(false); showLoading(false);
@@ -1910,14 +1976,14 @@
.then(function(res) { return res.json(); }) .then(function(res) { return res.json(); })
.then(function(data) { .then(function(data) {
if (data.error) { if (data.error) {
alert("Error: " + data.error); showToast("Error unbanning IP: " + data.error, 'error');
} else { } else {
alert(data.message || "IP unbanned successfully"); showToast(data.message || "IP unbanned successfully", 'success');
} }
return refreshData({ silent: true }); return refreshData({ silent: true });
}) })
.catch(function(err) { .catch(function(err) {
alert("Error: " + err); showToast("Error: " + err, 'error');
}) })
.finally(function() { .finally(function() {
showLoading(false); showLoading(false);
@@ -1943,14 +2009,14 @@
.then(function(res) { return res.json(); }) .then(function(res) { return res.json(); })
.then(function(data) { .then(function(data) {
if (data.error) { if (data.error) {
alert("Error loading config: " + data.error); showToast("Error loading config: " + data.error, 'error');
} else { return;
}
textArea.value = data.config; textArea.value = data.config;
openModal('jailConfigModal'); openModal('jailConfigModal');
}
}) })
.catch(function(err) { .catch(function(err) {
alert("Error: " + err); showToast("Error: " + err, 'error');
}) })
.finally(function() { .finally(function() {
showLoading(false); showLoading(false);
@@ -1971,15 +2037,15 @@
.then(function(res) { return res.json(); }) .then(function(res) { return res.json(); })
.then(function(data) { .then(function(data) {
if (data.error) { if (data.error) {
alert("Error saving config: " + data.error); showToast("Error saving config: " + data.error, 'error');
} else { return;
console.log("Filter saved successfully. Restart needed? " + data.restartNeeded);
closeModal('jailConfigModal');
document.getElementById('restartBanner').style.display = 'block';
} }
closeModal('jailConfigModal');
showToast(t('filter_debug.save_success', 'Filter saved and reloaded'), 'success');
return refreshData({ silent: true });
}) })
.catch(function(err) { .catch(function(err) {
alert("Error: " + err); showToast("Error: " + err, 'error');
}) })
.finally(function() { .finally(function() {
showLoading(false); showLoading(false);
@@ -1990,7 +2056,7 @@
// Fetches the full-list of all jails (from /jails/manage) and builds a list with toggle switches. // Fetches the full-list of all jails (from /jails/manage) and builds a list with toggle switches.
function openManageJailsModal() { function openManageJailsModal() {
if (!currentServerId) { if (!currentServerId) {
alert(t('servers.selector.none', 'Please add and select a Fail2ban server first.')); showToast(t('servers.selector.none', 'Please add and select a Fail2ban server first.'), 'info');
return; return;
} }
showLoading(true); showLoading(true);
@@ -2000,7 +2066,7 @@
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
if (!data.jails?.length) { if (!data.jails?.length) {
alert("No jails found."); showToast("No jails found for this server.", 'info');
return; return;
} }
@@ -2039,7 +2105,7 @@
document.getElementById('jailsList').innerHTML = html; document.getElementById('jailsList').innerHTML = html;
openModal('manageJailsModal'); openModal('manageJailsModal');
}) })
.catch(err => alert("Error fetching jails: " + err)) .catch(err => showToast("Error fetching jails: " + err, 'error'))
.finally(() => showLoading(false)); .finally(() => showLoading(false));
} }
@@ -2063,14 +2129,17 @@
.then(function(res) { return res.json(); }) .then(function(res) { return res.json(); })
.then(function(data) { .then(function(data) {
if (data.error) { if (data.error) {
alert("Error saving jail settings: " + data.error); showToast("Error saving jail settings: " + data.error, 'error');
} else { return;
// A restart of fail2ban is needed, to enable or disable jails - a reload is not enough
document.getElementById('restartBanner').style.display = 'block';
} }
showToast(t('jails.manage.save_success', 'Jail settings saved. Please restart Fail2ban.'), 'info');
return loadServers().then(function() {
updateRestartBanner();
return refreshData({ silent: true });
});
}) })
.catch(function(err) { .catch(function(err) {
alert("Error: " + err); showToast("Error: " + err, 'error');
}) })
.finally(function() { .finally(function() {
showLoading(false); showLoading(false);
@@ -2126,7 +2195,7 @@
document.getElementById('ignoreIP').value = data.ignoreip || ''; document.getElementById('ignoreIP').value = data.ignoreip || '';
}) })
.catch(err => { .catch(err => {
alert('Error loading settings: ' + err); showToast('Error loading settings: ' + err, 'error');
}) })
.finally(() => showLoading(false)); .finally(() => showLoading(false));
} }
@@ -2173,17 +2242,20 @@
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
if (data.error) { if (data.error) {
alert('Error saving settings: ' + data.error + data.details); showToast('Error saving settings: ' + (data.error + (data.details || '')), 'error');
} else { } else {
var selectedLang = $('#languageSelect').val(); var selectedLang = $('#languageSelect').val();
loadTranslations(selectedLang); loadTranslations(selectedLang);
console.log("Settings saved successfully. Restart needed? " + data.restartNeeded); console.log("Settings saved successfully. Restart needed? " + data.restartNeeded);
showToast(t('settings.save_success', 'Settings saved'), 'success');
if (data.restartNeeded) { if (data.restartNeeded) {
document.getElementById('restartBanner').style.display = 'block'; loadServers().then(function() {
updateRestartBanner();
});
} }
} }
}) })
.catch(err => alert('Error: ' + err)) .catch(err => showToast('Error saving settings: ' + err, 'error'))
.finally(() => showLoading(false)); .finally(() => showLoading(false));
} }
@@ -2199,7 +2271,7 @@
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
if (data.error) { if (data.error) {
alert('Error loading filters: ' + data.error); showToast('Error loading filters: ' + data.error, 'error');
return; return;
} }
const select = document.getElementById('filterSelect'); const select = document.getElementById('filterSelect');
@@ -2229,7 +2301,7 @@
} }
}) })
.catch(err => { .catch(err => {
alert('Error loading filters: ' + err); showToast('Error loading filters: ' + err, 'error');
}) })
.finally(() => showLoading(false)); .finally(() => showLoading(false));
} }
@@ -2244,12 +2316,12 @@
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
if (data.error) { if (data.error) {
alert('Error sending test email: ' + data.error); showToast('Error sending test email: ' + data.error, 'error');
} else { } else {
alert('Test email sent successfully!'); showToast('Test email sent successfully!', 'success');
} }
}) })
.catch(error => alert('Error: ' + error)) .catch(error => showToast('Error sending test email: ' + error, 'error'))
.finally(() => showLoading(false)); .finally(() => showLoading(false));
} }
@@ -2259,7 +2331,7 @@
const lines = document.getElementById('logLinesTextarea').value.split('\n'); const lines = document.getElementById('logLinesTextarea').value.split('\n');
if (!filterName) { if (!filterName) {
alert('Please select a filter.'); showToast('Please select a filter.', 'info');
return; return;
} }
@@ -2275,13 +2347,13 @@
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
if (data.error) { if (data.error) {
alert('Error: ' + data.error); showToast('Error testing filter: ' + data.error, 'error');
return; return;
} }
renderTestResults(data.matches); renderTestResults(data.matches);
}) })
.catch(err => { .catch(err => {
alert('Error: ' + err); showToast('Error testing filter: ' + err, 'error');
}) })
.finally(() => showLoading(false)); .finally(() => showLoading(false));
} }
@@ -2332,14 +2404,17 @@
.then(function(res) { return res.json(); }) .then(function(res) { return res.json(); })
.then(function(data) { .then(function(data) {
if (data.error) { if (data.error) {
alert("Error: " + data.error); showToast("Failed to restart Fail2ban: " + data.error, 'error');
} else { return;
document.getElementById('restartBanner').style.display = 'none';
return refreshData({ silent: true });
} }
return loadServers().then(function() {
updateRestartBanner();
showToast(t('restart_banner.success', 'Fail2ban restart triggered'), 'success');
return refreshData({ silent: true });
});
}) })
.catch(function(err) { .catch(function(err) {
alert("Error: " + err); showToast("Failed to restart Fail2ban: " + err, 'error');
}) })
.finally(function() { .finally(function() {
showLoading(false); showLoading(false);