From a24e0779d2401ad4a60e6d3594654c0d92809797 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Fri, 14 Nov 2025 10:22:44 +0100 Subject: [PATCH] switch to toast instead of alert messages, implement serverIDs and restart tracking for every remote-server --- internal/config/settings.go | 212 ++++++++++++++++++--------- internal/fail2ban/client.go | 15 +- internal/fail2ban/connector_agent.go | 2 +- internal/fail2ban/connector_ssh.go | 2 +- internal/storage/storage.go | 62 +++++--- pkg/web/handlers.go | 51 ++++--- pkg/web/templates/index.html | 203 +++++++++++++++++-------- 7 files changed, 359 insertions(+), 188 deletions(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index e937159..e98f8a7 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -77,6 +77,7 @@ const ( jailDFile = "/etc/fail2ban/jail.d/ui-custom-action.conf" actionFile = "/etc/fail2ban/action.d/ui-custom-action.conf" actionCallbackPlaceholder = "__CALLBACK_URL__" + actionServerIDPlaceholder = "__SERVER_ID__" ) const fail2banActionTemplate = `[INCLUDES] @@ -95,13 +96,14 @@ norestored = 1 actionban = /usr/bin/curl -X POST __CALLBACK_URL__/api/ban \ -H "Content-Type: application/json" \ - -d "$(jq -n --arg ip '' \ + -d "$(jq -n --arg serverId '__SERVER_ID__' \ + --arg ip '' \ --arg jail '' \ --arg hostname '' \ --arg failures '' \ --arg whois "$(whois || echo 'missing whois program')" \ --arg logs "$(tac | grep -wF )" \ - '{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] @@ -128,23 +130,24 @@ var ( // Fail2banServer represents a Fail2ban instance the UI can manage. type Fail2banServer struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` // local, ssh, agent - Host string `json:"host,omitempty"` - Port int `json:"port,omitempty"` - SocketPath string `json:"socketPath,omitempty"` - LogPath string `json:"logPath,omitempty"` - SSHUser string `json:"sshUser,omitempty"` - SSHKeyPath string `json:"sshKeyPath,omitempty"` - AgentURL string `json:"agentUrl,omitempty"` - AgentSecret string `json:"agentSecret,omitempty"` - Hostname string `json:"hostname,omitempty"` - Tags []string `json:"tags,omitempty"` - IsDefault bool `json:"isDefault"` - Enabled bool `json:"enabled"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // local, ssh, agent + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + SocketPath string `json:"socketPath,omitempty"` + LogPath string `json:"logPath,omitempty"` + SSHUser string `json:"sshUser,omitempty"` + SSHKeyPath string `json:"sshKeyPath,omitempty"` + AgentURL string `json:"agentUrl,omitempty"` + AgentSecret string `json:"agentSecret,omitempty"` + Hostname string `json:"hostname,omitempty"` + Tags []string `json:"tags,omitempty"` + IsDefault bool `json:"isDefault"` + Enabled bool `json:"enabled"` + RestartNeeded bool `json:"restartNeeded"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` enabledSet bool } @@ -308,24 +311,25 @@ func applyServerRecordsLocked(records []storage.ServerRecord) { _ = json.Unmarshal([]byte(rec.TagsJSON), &tags) } server := Fail2banServer{ - ID: rec.ID, - Name: rec.Name, - Type: rec.Type, - Host: rec.Host, - Port: rec.Port, - SocketPath: rec.SocketPath, - LogPath: rec.LogPath, - SSHUser: rec.SSHUser, - SSHKeyPath: rec.SSHKeyPath, - AgentURL: rec.AgentURL, - AgentSecret: rec.AgentSecret, - Hostname: rec.Hostname, - Tags: tags, - IsDefault: rec.IsDefault, - Enabled: rec.Enabled, - CreatedAt: rec.CreatedAt, - UpdatedAt: rec.UpdatedAt, - enabledSet: true, + ID: rec.ID, + Name: rec.Name, + Type: rec.Type, + Host: rec.Host, + Port: rec.Port, + SocketPath: rec.SocketPath, + LogPath: rec.LogPath, + SSHUser: rec.SSHUser, + SSHKeyPath: rec.SSHKeyPath, + AgentURL: rec.AgentURL, + AgentSecret: rec.AgentSecret, + Hostname: rec.Hostname, + Tags: tags, + IsDefault: rec.IsDefault, + Enabled: rec.Enabled, + RestartNeeded: rec.NeedsRestart, + CreatedAt: rec.CreatedAt, + UpdatedAt: rec.UpdatedAt, + enabledSet: true, } servers = append(servers, server) } @@ -384,23 +388,24 @@ func toServerRecordsLocked() ([]storage.ServerRecord, error) { updatedAt = createdAt } records = append(records, storage.ServerRecord{ - ID: srv.ID, - Name: srv.Name, - Type: srv.Type, - Host: srv.Host, - Port: srv.Port, - SocketPath: srv.SocketPath, - LogPath: srv.LogPath, - SSHUser: srv.SSHUser, - SSHKeyPath: srv.SSHKeyPath, - AgentURL: srv.AgentURL, - AgentSecret: srv.AgentSecret, - Hostname: srv.Hostname, - TagsJSON: string(tagBytes), - IsDefault: srv.IsDefault, - Enabled: srv.Enabled, - CreatedAt: createdAt, - UpdatedAt: updatedAt, + ID: srv.ID, + Name: srv.Name, + Type: srv.Type, + Host: srv.Host, + Port: srv.Port, + SocketPath: srv.SocketPath, + LogPath: srv.LogPath, + SSHUser: srv.SSHUser, + SSHKeyPath: srv.SSHKeyPath, + AgentURL: srv.AgentURL, + AgentSecret: srv.AgentSecret, + Hostname: srv.Hostname, + TagsJSON: string(tagBytes), + IsDefault: srv.IsDefault, + Enabled: srv.Enabled, + NeedsRestart: srv.RestartNeeded, + CreatedAt: createdAt, + UpdatedAt: updatedAt, }) } return records, nil @@ -563,6 +568,9 @@ func normalizeServersLocked() { } } server.enabledSet = true + if !server.Enabled { + server.RestartNeeded = false + } if server.IsDefault && !server.Enabled { server.IsDefault = false } @@ -584,6 +592,8 @@ func normalizeServersLocked() { sort.SliceStable(currentSettings.Servers, func(i, j int) bool { return currentSettings.Servers[i].CreatedAt.Before(currentSettings.Servers[j].CreatedAt) }) + + updateGlobalRestartFlagLocked() } func generateServerID() string { @@ -595,7 +605,7 @@ func generateServerID() string { } // ensureFail2banActionFiles writes the local action files if Fail2ban is present. -func ensureFail2banActionFiles(callbackURL string) error { +func ensureFail2banActionFiles(callbackURL, serverID string) error { DebugLog("----------------------------") DebugLog("ensureFail2banActionFiles called (settings.go)") @@ -609,7 +619,7 @@ func ensureFail2banActionFiles(callbackURL string) error { if err := ensureJailDConfig(); err != nil { return err } - return writeFail2banAction(callbackURL) + return writeFail2banAction(callbackURL, serverID) } // 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. -func writeFail2banAction(callbackURL string) error { +func writeFail2banAction(callbackURL, serverID string) error { DebugLog("Running initial writeFail2banAction()") // entry point DebugLog("----------------------------") if err := os.MkdirAll(filepath.Dir(actionFile), 0o755); err != nil { 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) if err != nil { return fmt.Errorf("failed to write action file: %w", err) @@ -751,12 +761,16 @@ func cloneServer(src Fail2banServer) Fail2banServer { return dst } -func BuildFail2banActionConfig(callbackURL string) string { +func BuildFail2banActionConfig(callbackURL, serverID string) string { trimmed := strings.TrimRight(strings.TrimSpace(callbackURL), "/") if trimmed == "" { 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 { @@ -786,7 +800,7 @@ func EnsureLocalFail2banAction(server Fail2banServer) error { settingsLock.RLock() callbackURL := getCallbackURLLocked() settingsLock.RUnlock() - return ensureFail2banActionFiles(callbackURL) + return ensureFail2banActionFiles(callbackURL, server.ID) } 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. func DeleteServer(id string) error { settingsLock.Lock() @@ -998,21 +1041,43 @@ func GetSettings() AppSettings { return currentSettings } -// MarkRestartNeeded sets restartNeeded = true and saves JSON -func MarkRestartNeeded() error { +// MarkRestartNeeded marks the specified server as requiring a restart. +func MarkRestartNeeded(serverID string) error { settingsLock.Lock() 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() } -// MarkRestartDone sets restartNeeded = false and saves JSON -func MarkRestartDone() error { +// MarkRestartDone marks the specified server as no longer requiring a restart. +func MarkRestartDone(serverID string) error { settingsLock.Lock() 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() } @@ -1026,16 +1091,15 @@ func UpdateSettings(new AppSettings) (AppSettings, error) { old := currentSettings // If certain fields change, we mark reload needed - if old.BantimeIncrement != new.BantimeIncrement || + restartTriggered := old.BantimeIncrement != new.BantimeIncrement || old.IgnoreIP != new.IgnoreIP || old.Bantime != new.Bantime || old.Findtime != new.Findtime || - //old.Maxretry != new.Maxretry || - old.Maxretry != new.Maxretry { + old.Maxretry != new.Maxretry + if restartTriggered { new.RestartNeeded = true } else { - // preserve previous RestartNeeded if it was already true - new.RestartNeeded = new.RestartNeeded || old.RestartNeeded + new.RestartNeeded = anyServerNeedsRestartLocked() } new.CallbackURL = strings.TrimSpace(new.CallbackURL) @@ -1047,6 +1111,10 @@ func UpdateSettings(new AppSettings) (AppSettings, error) { } currentSettings = new setDefaultsLocked() + if currentSettings.RestartNeeded && restartTriggered { + markAllServersRestartLocked() + updateGlobalRestartFlagLocked() + } DebugLog("New settings applied: %v", currentSettings) // Log settings applied if err := persistAllLocked(); err != nil { diff --git a/internal/fail2ban/client.go b/internal/fail2ban/client.go index 50c39cd..7286b3f 100644 --- a/internal/fail2ban/client.go +++ b/internal/fail2ban/client.go @@ -80,9 +80,18 @@ func ReloadFail2ban() error { return conn.Reload(context.Background()) } -// RestartFail2ban restarts the Fail2ban service using the default connector. -func RestartFail2ban() error { - conn, err := GetManager().DefaultConnector() +// RestartFail2ban restarts the Fail2ban service using the provided server or default connector. +func RestartFail2ban(serverID string) error { + manager := GetManager() + var ( + conn Connector + err error + ) + if serverID != "" { + conn, err = manager.Connector(serverID) + } else { + conn, err = manager.DefaultConnector() + } if err != nil { return err } diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go index 2d1dc1f..4309671 100644 --- a/internal/fail2ban/connector_agent.go +++ b/internal/fail2ban/connector_agent.go @@ -63,7 +63,7 @@ func (ac *AgentConnector) Server() config.Fail2banServer { func (ac *AgentConnector) ensureAction(ctx context.Context) error { payload := map[string]any{ "name": "ui-custom-action", - "config": config.BuildFail2banActionConfig(config.GetCallbackURL()), + "config": config.BuildFail2banActionConfig(config.GetCallbackURL(), ac.server.ID), "callbackUrl": config.GetCallbackURL(), "setDefault": true, } diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index e12d255..00c19c4 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -199,7 +199,7 @@ 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) + actionConfig := config.BuildFail2banActionConfig(callbackURL, sc.server.ID) payload := base64.StdEncoding.EncodeToString([]byte(actionConfig)) script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload) // Base64 encode the entire script to avoid shell escaping issues diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 018b269..623fd42 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "time" @@ -67,23 +68,24 @@ type AppSettingsRecord struct { } type ServerRecord struct { - ID string - Name string - Type string - Host string - Port int - SocketPath string - LogPath string - SSHUser string - SSHKeyPath string - AgentURL string - AgentSecret string - Hostname string - TagsJSON string - IsDefault bool - Enabled bool - CreatedAt time.Time - UpdatedAt time.Time + ID string + Name string + Type string + Host string + Port int + SocketPath string + LogPath string + SSHUser string + SSHKeyPath string + AgentURL string + AgentSecret string + Hostname string + TagsJSON string + IsDefault bool + Enabled bool + NeedsRestart bool + CreatedAt time.Time + UpdatedAt time.Time } // BanEventRecord represents a single ban event stored in the internal database. @@ -242,7 +244,7 @@ func ListServers(ctx context.Context) ([]ServerRecord, error) { } 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 ORDER BY created_at`) if err != nil { @@ -257,7 +259,7 @@ ORDER BY created_at`) var name, serverType sql.NullString var created, updated sql.NullString var port sql.NullInt64 - var isDefault, enabled sql.NullInt64 + var isDefault, enabled, needsRestart sql.NullInt64 if err := rows.Scan( &rec.ID, @@ -275,6 +277,7 @@ ORDER BY created_at`) &tags, &isDefault, &enabled, + &needsRestart, &created, &updated, ); err != nil { @@ -295,6 +298,7 @@ ORDER BY created_at`) rec.TagsJSON = stringFromNull(tags) rec.IsDefault = intToBool(intFromNull(isDefault)) rec.Enabled = intToBool(intFromNull(enabled)) + rec.NeedsRestart = intToBool(intFromNull(needsRestart)) if created.Valid { 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, ` 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 ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )`) if err != nil { return err @@ -368,6 +372,7 @@ INSERT INTO servers ( srv.TagsJSON, boolToInt(srv.IsDefault), boolToInt(srv.Enabled), + boolToInt(srv.NeedsRestart), createdAt.Format(time.RFC3339Nano), updatedAt.Format(time.RFC3339Nano), ); err != nil { @@ -568,6 +573,7 @@ CREATE TABLE IF NOT EXISTS servers ( tags TEXT, is_default INTEGER, enabled INTEGER, + needs_restart INTEGER DEFAULT 0, created_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); ` - _, err := db.ExecContext(ctx, createTable) - return err + if _, err := db.ExecContext(ctx, createTable); err != nil { + 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 { diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 3b2545f..0939c60 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -516,7 +516,12 @@ func GetJailFilterConfigHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("GetJailFilterConfigHandler called (handlers.go)") // entry point 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 { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -532,6 +537,11 @@ func SetJailFilterConfigHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("SetJailFilterConfigHandler called (handlers.go)") // entry point 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) var req struct { @@ -542,25 +552,17 @@ func SetJailFilterConfigHandler(c *gin.Context) { return } - // Write the filter config file to /etc/fail2ban/filter.d/.conf - if err := fail2ban.SetFilterConfig(jail, req.Config); err != nil { + if err := conn.SetFilterConfig(c.Request.Context(), jail, req.Config); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - // Mark reload needed in our UI settings - // if err := config.MarkRestartNeeded(); err != nil { - // c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - // return - // } + if err := conn.Reload(c.Request.Context()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "filter saved but reload failed: " + err.Error()}) + return + } - c.JSON(http.StatusOK, gin.H{"message": "jail config updated"}) - - // Return a simple JSON response without forcing a blocking alert - // c.JSON(http.StatusOK, gin.H{ - // "message": "Filter updated, reload needed", - // "restartNeeded": true, - // }) + c.JSON(http.StatusOK, gin.H{"message": "Filter updated and fail2ban reloaded"}) } // 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()}) 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()}) return } @@ -739,17 +741,18 @@ func RestartFail2banHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("ApplyFail2banSettings called (handlers.go)") // entry point - // First we write our new settings to /etc/fail2ban/jail.local - // if err := fail2ban.ApplyFail2banSettings("/etc/fail2ban/jail.local"); err != nil { - // c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - // return - // } + conn, err := resolveConnector(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + server := conn.Server() // Attempt to restart the fail2ban service. - restartErr := fail2ban.RestartFail2ban() + restartErr := fail2ban.RestartFail2ban(server.ID) if restartErr != nil { // 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). // Log the error and continue, so we can mark the restart as done. 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. - if err := config.MarkRestartDone(); err != nil { + if err := config.MarkRestartDone(server.ID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index f6519e0..fd56a20 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -136,6 +136,48 @@ padding: 0.1em 0.2em; 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; + } @@ -154,6 +196,8 @@ +
+ @@ -821,7 +865,7 @@ getTranslationsSettingsOnPageload() ]) .then(function() { - checkRestartNeeded(); + updateRestartBanner(); return refreshData({ silent: true }); }) .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 function displayExternalIP() { fetch('https://api.ipify.org?format=json') @@ -905,18 +968,14 @@ } } - // Check if there is still a reload of the fail2ban service needed - function checkRestartNeeded() { - fetch('/api/settings') - .then(res => res.json()) - .then(data => { - if (data.restartNeeded) { - document.getElementById('restartBanner').style.display = 'block'; - } else { - document.getElementById('restartBanner').style.display = 'none'; - } - }) - .catch(err => console.error('Error checking restartNeeded:', err)); + function updateRestartBanner() { + var banner = document.getElementById('restartBanner'); + if (!banner) return; + if (currentServer && currentServer.restartNeeded) { + banner.style.display = 'block'; + } else { + banner.style.display = 'none'; + } } // Load dynamically the other pages when navigating in nav @@ -997,6 +1056,7 @@ } renderServerSelector(); renderServerSubtitle(); + updateRestartBanner(); }) .catch(function(err) { console.error('Error loading servers:', err); @@ -1005,6 +1065,7 @@ currentServer = null; renderServerSelector(); renderServerSubtitle(); + updateRestartBanner(); }); } @@ -1088,6 +1149,7 @@ } renderServerSelector(); renderServerSubtitle(); + updateRestartBanner(); refreshData(); } @@ -1643,10 +1705,10 @@ .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - alert('Error saving server: ' + data.error); + showToast('Error saving server: ' + (data.error || 'Unknown error'), 'error'); return; } - alert(t('servers.form.success', 'Server saved successfully.')); + showToast(t('servers.form.success', 'Server saved successfully.'), 'success'); var saved = data.server || {}; currentServerId = saved.id || currentServerId; return loadServers().then(function() { @@ -1660,7 +1722,7 @@ }); }) .catch(function(err) { - alert('Error saving server: ' + err); + showToast('Error saving server: ' + err, 'error'); }) .finally(function() { showLoading(false); @@ -1733,7 +1795,7 @@ .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - alert('Error saving server: ' + data.error); + showToast('Error saving server: ' + (data.error || 'Unknown error'), 'error'); return; } if (!enabled && currentServerId === serverId) { @@ -1748,7 +1810,7 @@ }); }) .catch(function(err) { - alert('Error saving server: ' + err); + showToast('Error saving server: ' + err, 'error'); }) .finally(function() { showLoading(false); @@ -1764,13 +1826,13 @@ .then(function(res) { return res.json(); }) .then(function(data) { 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; } - 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) { - alert(t('servers.actions.test_failure', 'Connection failed') + ': ' + err); + showToast(t('servers.actions.test_failure', 'Connection failed') + ': ' + err, 'error'); }) .finally(function() { showLoading(false); @@ -1784,7 +1846,7 @@ .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - alert('Error deleting server: ' + data.error); + showToast('Error deleting server: ' + (data.error || 'Unknown error'), 'error'); return; } if (currentServerId === serverId) { @@ -1796,10 +1858,12 @@ renderServerSelector(); renderServerSubtitle(); return refreshData({ silent: true }); + }).then(function() { + showToast(t('servers.actions.delete_success', 'Server removed'), 'success'); }); }) .catch(function(err) { - alert('Error deleting server: ' + err); + showToast('Error deleting server: ' + err, 'error'); }) .finally(function() { showLoading(false); @@ -1812,7 +1876,7 @@ .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - alert('Error setting default server: ' + data.error); + showToast('Error setting default server: ' + (data.error || 'Unknown error'), 'error'); return; } currentServerId = data.server ? data.server.id : serverId; @@ -1821,10 +1885,12 @@ renderServerSelector(); renderServerSubtitle(); return refreshData({ silent: true }); + }).then(function() { + showToast(t('servers.actions.set_default_success', 'Server set as default'), 'success'); }); }) .catch(function(err) { - alert('Error setting default server: ' + err); + showToast('Error setting default server: ' + err, 'error'); }) .finally(function() { showLoading(false); @@ -1910,14 +1976,14 @@ .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - alert("Error: " + data.error); + showToast("Error unbanning IP: " + data.error, 'error'); } else { - alert(data.message || "IP unbanned successfully"); + showToast(data.message || "IP unbanned successfully", 'success'); } return refreshData({ silent: true }); }) .catch(function(err) { - alert("Error: " + err); + showToast("Error: " + err, 'error'); }) .finally(function() { showLoading(false); @@ -1943,14 +2009,14 @@ .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - alert("Error loading config: " + data.error); - } else { - textArea.value = data.config; - openModal('jailConfigModal'); + showToast("Error loading config: " + data.error, 'error'); + return; } + textArea.value = data.config; + openModal('jailConfigModal'); }) .catch(function(err) { - alert("Error: " + err); + showToast("Error: " + err, 'error'); }) .finally(function() { showLoading(false); @@ -1971,15 +2037,15 @@ .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - alert("Error saving config: " + data.error); - } else { - console.log("Filter saved successfully. Restart needed? " + data.restartNeeded); - closeModal('jailConfigModal'); - document.getElementById('restartBanner').style.display = 'block'; + showToast("Error saving config: " + data.error, 'error'); + return; } + closeModal('jailConfigModal'); + showToast(t('filter_debug.save_success', 'Filter saved and reloaded'), 'success'); + return refreshData({ silent: true }); }) .catch(function(err) { - alert("Error: " + err); + showToast("Error: " + err, 'error'); }) .finally(function() { showLoading(false); @@ -1990,7 +2056,7 @@ // Fetches the full-list of all jails (from /jails/manage) and builds a list with toggle switches. function openManageJailsModal() { 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; } showLoading(true); @@ -2000,7 +2066,7 @@ .then(res => res.json()) .then(data => { if (!data.jails?.length) { - alert("No jails found."); + showToast("No jails found for this server.", 'info'); return; } @@ -2039,7 +2105,7 @@ document.getElementById('jailsList').innerHTML = html; openModal('manageJailsModal'); }) - .catch(err => alert("Error fetching jails: " + err)) + .catch(err => showToast("Error fetching jails: " + err, 'error')) .finally(() => showLoading(false)); } @@ -2063,14 +2129,17 @@ .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - alert("Error saving jail settings: " + data.error); - } else { - // A restart of fail2ban is needed, to enable or disable jails - a reload is not enough - document.getElementById('restartBanner').style.display = 'block'; + showToast("Error saving jail settings: " + data.error, 'error'); + return; } + 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) { - alert("Error: " + err); + showToast("Error: " + err, 'error'); }) .finally(function() { showLoading(false); @@ -2126,7 +2195,7 @@ document.getElementById('ignoreIP').value = data.ignoreip || ''; }) .catch(err => { - alert('Error loading settings: ' + err); + showToast('Error loading settings: ' + err, 'error'); }) .finally(() => showLoading(false)); } @@ -2173,17 +2242,20 @@ .then(res => res.json()) .then(data => { if (data.error) { - alert('Error saving settings: ' + data.error + data.details); + showToast('Error saving settings: ' + (data.error + (data.details || '')), 'error'); } else { var selectedLang = $('#languageSelect').val(); loadTranslations(selectedLang); console.log("Settings saved successfully. Restart needed? " + data.restartNeeded); + showToast(t('settings.save_success', 'Settings saved'), 'success'); 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)); } @@ -2199,7 +2271,7 @@ .then(res => res.json()) .then(data => { if (data.error) { - alert('Error loading filters: ' + data.error); + showToast('Error loading filters: ' + data.error, 'error'); return; } const select = document.getElementById('filterSelect'); @@ -2229,7 +2301,7 @@ } }) .catch(err => { - alert('Error loading filters: ' + err); + showToast('Error loading filters: ' + err, 'error'); }) .finally(() => showLoading(false)); } @@ -2244,12 +2316,12 @@ .then(res => res.json()) .then(data => { if (data.error) { - alert('Error sending test email: ' + data.error); + showToast('Error sending test email: ' + data.error, 'error'); } 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)); } @@ -2259,7 +2331,7 @@ const lines = document.getElementById('logLinesTextarea').value.split('\n'); if (!filterName) { - alert('Please select a filter.'); + showToast('Please select a filter.', 'info'); return; } @@ -2275,13 +2347,13 @@ .then(res => res.json()) .then(data => { if (data.error) { - alert('Error: ' + data.error); + showToast('Error testing filter: ' + data.error, 'error'); return; } renderTestResults(data.matches); }) .catch(err => { - alert('Error: ' + err); + showToast('Error testing filter: ' + err, 'error'); }) .finally(() => showLoading(false)); } @@ -2332,14 +2404,17 @@ .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - alert("Error: " + data.error); - } else { - document.getElementById('restartBanner').style.display = 'none'; - return refreshData({ silent: true }); + showToast("Failed to restart Fail2ban: " + data.error, 'error'); + return; } + return loadServers().then(function() { + updateRestartBanner(); + showToast(t('restart_banner.success', 'Fail2ban restart triggered'), 'success'); + return refreshData({ silent: true }); + }); }) .catch(function(err) { - alert("Error: " + err); + showToast("Failed to restart Fail2ban: " + err, 'error'); }) .finally(function() { showLoading(false);