mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
switch to toast instead of alert messages, implement serverIDs and restart tracking for every remote-server
This commit is contained in:
@@ -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 '<ip>' \
|
||||
-d "$(jq -n --arg serverId '__SERVER_ID__' \
|
||||
--arg ip '<ip>' \
|
||||
--arg jail '<name>' \
|
||||
--arg hostname '<fq-hostname>' \
|
||||
--arg failures '<failures>' \
|
||||
--arg whois "$(whois <ip> || echo 'missing whois program')" \
|
||||
--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]
|
||||
|
||||
@@ -143,6 +145,7 @@ type Fail2banServer struct {
|
||||
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"`
|
||||
|
||||
@@ -323,6 +326,7 @@ func applyServerRecordsLocked(records []storage.ServerRecord) {
|
||||
Tags: tags,
|
||||
IsDefault: rec.IsDefault,
|
||||
Enabled: rec.Enabled,
|
||||
RestartNeeded: rec.NeedsRestart,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
UpdatedAt: rec.UpdatedAt,
|
||||
enabledSet: true,
|
||||
@@ -399,6 +403,7 @@ func toServerRecordsLocked() ([]storage.ServerRecord, error) {
|
||||
TagsJSON: string(tagBytes),
|
||||
IsDefault: srv.IsDefault,
|
||||
Enabled: srv.Enabled,
|
||||
NeedsRestart: srv.RestartNeeded,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -82,6 +83,7 @@ type ServerRecord struct {
|
||||
TagsJSON string
|
||||
IsDefault bool
|
||||
Enabled bool
|
||||
NeedsRestart bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -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)
|
||||
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 {
|
||||
|
||||
@@ -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/<jail>.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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -154,6 +196,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<!-- ******************************************************************* -->
|
||||
<!-- Navigation START -->
|
||||
<!-- ******************************************************************* -->
|
||||
@@ -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';
|
||||
function updateRestartBanner() {
|
||||
var banner = document.getElementById('restartBanner');
|
||||
if (!banner) return;
|
||||
if (currentServer && currentServer.restartNeeded) {
|
||||
banner.style.display = 'block';
|
||||
} 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
|
||||
@@ -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 {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user