diff --git a/internal/fail2ban/client.go b/internal/fail2ban/client.go index 7286b3f..0f40b3e 100644 --- a/internal/fail2ban/client.go +++ b/internal/fail2ban/client.go @@ -80,8 +80,10 @@ func ReloadFail2ban() error { return conn.Reload(context.Background()) } -// RestartFail2ban restarts the Fail2ban service using the provided server or default connector. -func RestartFail2ban(serverID string) error { +// RestartFail2ban restarts (or reloads) the Fail2ban service using the +// provided server or default connector and returns a mode string describing +// what actually happened ("restart" or "reload"). +func RestartFail2ban(serverID string) (string, error) { manager := GetManager() var ( conn Connector @@ -93,7 +95,17 @@ func RestartFail2ban(serverID string) error { conn, err = manager.DefaultConnector() } if err != nil { - return err + return "", err } - return conn.Restart(context.Background()) + // If the connector supports a detailed restart mode, use it. Otherwise + // fall back to a plain Restart() and assume "restart". + if withMode, ok := conn.(interface { + RestartWithMode(ctx context.Context) (string, error) + }); ok { + return withMode.RestartWithMode(context.Background()) + } + if err := conn.Restart(context.Background()); err != nil { + return "", err + } + return "restart", nil } diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go index 552a062..4d0da36 100644 --- a/internal/fail2ban/connector_agent.go +++ b/internal/fail2ban/connector_agent.go @@ -109,6 +109,15 @@ func (ac *AgentConnector) Restart(ctx context.Context) error { return ac.post(ctx, "/v1/actions/restart", nil, nil) } +// RestartWithMode restarts the remote agent-managed Fail2ban service and +// always reports mode "restart". Any error is propagated to the caller. +func (ac *AgentConnector) RestartWithMode(ctx context.Context) (string, error) { + if err := ac.Restart(ctx); err != nil { + return "restart", err + } + return "restart", nil +} + func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) { var resp struct { Config string `json:"config"` diff --git a/internal/fail2ban/connector_local.go b/internal/fail2ban/connector_local.go index bb8029b..f814fa4 100644 --- a/internal/fail2ban/connector_local.go +++ b/internal/fail2ban/connector_local.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os" "os/exec" "sort" "strings" @@ -163,17 +162,40 @@ func (lc *LocalConnector) Reload(ctx context.Context) error { return nil } +// RestartWithMode restarts (or reloads) the local Fail2ban instance and returns +// a mode string describing what happened: +// - "restart": systemd service was restarted and health check passed +// - "reload": configuration was reloaded via fail2ban-client and pong check passed +func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) { + // 1) Try systemd restart if systemctl is available. + if _, err := exec.LookPath("systemctl"); err == nil { + cmd := "systemctl restart fail2ban" + out, err := executeShellCommand(ctx, cmd) + if err != nil { + return "restart", fmt.Errorf("failed to restart fail2ban via systemd: %w - output: %s", + err, strings.TrimSpace(out)) + } + if err := lc.checkFail2banHealthy(ctx); err != nil { + return "restart", fmt.Errorf("fail2ban health check after systemd restart failed: %w", err) + } + return "restart", nil + } + + // 2) Fallback: no systemctl in PATH (container image without systemd, or + // non-systemd environment). Use fail2ban-client reload + ping. + if err := lc.Reload(ctx); err != nil { + return "reload", fmt.Errorf("failed to reload fail2ban via fail2ban-client (systemctl not available): %w", err) + } + if err := lc.checkFail2banHealthy(ctx); err != nil { + return "reload", fmt.Errorf("fail2ban health check after reload failed: %w", err) + } + return "reload", nil +} + // Restart implements Connector. func (lc *LocalConnector) Restart(ctx context.Context) error { - if _, container := os.LookupEnv("CONTAINER"); container { - return fmt.Errorf("restart not supported inside container; please restart fail2ban on the host") - } - cmd := "systemctl restart fail2ban" - out, err := executeShellCommand(ctx, cmd) - if err != nil { - return fmt.Errorf("failed to restart fail2ban: %w - output: %s", err, out) - } - return nil + _, err := lc.RestartWithMode(ctx) + return err } // GetFilterConfig implements Connector. @@ -247,6 +269,22 @@ func (lc *LocalConnector) buildFail2banArgs(args ...string) []string { return append(base, args...) } +// checkFail2banHealthy runs a quick `fail2ban-client ping` via the existing +// runFail2banClient helper and expects a successful pong reply. +func (lc *LocalConnector) checkFail2banHealthy(ctx context.Context) error { + out, err := lc.runFail2banClient(ctx, "ping") + trimmed := strings.TrimSpace(out) + if err != nil { + return fmt.Errorf("fail2ban ping error: %w (output: %s)", err, trimmed) + } + // Typical output is e.g. "Server replied: pong" – accept anything that + // contains "pong" case-insensitively. + if !strings.Contains(strings.ToLower(trimmed), "pong") { + return fmt.Errorf("unexpected fail2ban ping output: %s", trimmed) + } + return nil +} + // GetAllJails implements Connector. func (lc *LocalConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { return GetAllJails() diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index 4f2a09b..1154976 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -174,11 +174,43 @@ func (sc *SSHConnector) Reload(ctx context.Context) error { return err } +// RestartWithMode restarts (or reloads) the remote Fail2ban instance over SSH +// and returns a mode string describing what happened: +// - "restart": systemd service was restarted and health check passed +// - "reload": configuration was reloaded via fail2ban-client and pong check passed func (sc *SSHConnector) Restart(ctx context.Context) error { - _, err := sc.runRemoteCommand(ctx, []string{"sudo", "systemctl", "restart", "fail2ban"}) + _, err := sc.RestartWithMode(ctx) return err } +// RestartWithMode implements the detailed restart logic for SSH connectors. +func (sc *SSHConnector) RestartWithMode(ctx context.Context) (string, error) { + // First, we try systemd restart on the remote host + out, err := sc.runRemoteCommand(ctx, []string{"sudo", "systemctl", "restart", "fail2ban"}) + if err == nil { + if err := sc.checkFail2banHealthyRemote(ctx); err != nil { + return "restart", fmt.Errorf("remote fail2ban health check after systemd restart failed: %w", err) + } + return "restart", nil + } + + // Then, if systemd is not available, we fall back to fail2ban-client. + if sc.isSystemctlUnavailable(out, err) { + reloadOut, reloadErr := sc.runFail2banCommand(ctx, "reload") + if reloadErr != nil { + return "reload", fmt.Errorf("failed to reload fail2ban via fail2ban-client on remote: %w (output: %s)", + reloadErr, strings.TrimSpace(reloadOut)) + } + if err := sc.checkFail2banHealthyRemote(ctx); err != nil { + return "reload", fmt.Errorf("remote fail2ban health check after reload failed: %w", err) + } + return "reload", nil + } + + // systemctl exists but restart failed for some other reason, we surface it. + return "restart", fmt.Errorf("failed to restart fail2ban via systemd on remote: %w (output: %s)", err, out) +} + func (sc *SSHConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) { // Validate filter name jail = strings.TrimSpace(jail) @@ -308,6 +340,30 @@ func (sc *SSHConnector) runFail2banCommand(ctx context.Context, args ...string) return sc.runRemoteCommand(ctx, cmdArgs) } +// isSystemctlUnavailable tries to detect “no systemd” situations on the remote host. +func (sc *SSHConnector) isSystemctlUnavailable(output string, err error) bool { + msg := strings.ToLower(output + " " + err.Error()) + return strings.Contains(msg, "command not found") || + strings.Contains(msg, "system has not been booted with systemd") || + strings.Contains(msg, "failed to connect to bus") +} + +// checkFail2banHealthyRemote runs `sudo fail2ban-client ping` on the remote host +// and expects a successful pong reply. +func (sc *SSHConnector) checkFail2banHealthyRemote(ctx context.Context) error { + out, err := sc.runFail2banCommand(ctx, "ping") + trimmed := strings.TrimSpace(out) + if err != nil { + return fmt.Errorf("remote fail2ban ping error: %w (output: %s)", err, trimmed) + } + // Typical output is e.g. "Server replied: pong" – accept anything that + // contains "pong" case-insensitively. + if !strings.Contains(strings.ToLower(trimmed), "pong") { + return fmt.Errorf("unexpected remote fail2ban ping output: %s", trimmed) + } + return nil +} + func (sc *SSHConnector) buildFail2banArgs(args ...string) []string { if sc.server.SocketPath == "" { return args @@ -1147,12 +1203,13 @@ action = %(action_mwlg)s // Escape single quotes for safe use in a single-quoted heredoc escaped := strings.ReplaceAll(content, "'", "'\"'\"'") - // IMPORTANT: Run migration FIRST before ensuring structure + // IMPORTANT: Run migration FIRST before ensuring structure. // This is because EnsureJailLocalStructure may overwrite jail.local, // which would destroy any jail sections that need to be migrated. + // If migration fails for any reason, we SHOULD NOT overwrite jail.local, + // otherwise legacy jails would be lost. if err := sc.MigrateJailsFromJailLocalRemote(ctx); err != nil { - config.DebugLog("Warning: No migration done (may be normal if no jails to migrate): %v", err) - // Don't fail - continue with ensuring structure + return fmt.Errorf("failed to migrate legacy jails from jail.local on remote server %s: %w", sc.server.Name, err) } // Write the rebuilt content via heredoc over SSH @@ -1232,8 +1289,8 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err writeScript := fmt.Sprintf(`cat > %s <<'JAILEOF' %s JAILEOF -`, jailFilePath, escapedContent) - if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-c", writeScript}); err != nil { +'`, jailFilePath, escapedContent) + if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", writeScript}); err != nil { return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) } config.DebugLog("Migrated jail %s to %s on remote system", jailName, jailFilePath) @@ -1248,8 +1305,8 @@ JAILEOF writeLocalScript := fmt.Sprintf(`cat > %s <<'LOCALEOF' %s LOCALEOF -`, jailLocalPath, escapedDefault) - if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-c", writeLocalScript}); err != nil { +'`, jailLocalPath, escapedDefault) + if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", writeLocalScript}); err != nil { return fmt.Errorf("failed to rewrite jail.local: %w", err) } config.DebugLog("Migration completed on remote system: moved %d jails to jail.d/", migratedCount) diff --git a/internal/locales/de.json b/internal/locales/de.json index 891150c..703d6a3 100644 --- a/internal/locales/de.json +++ b/internal/locales/de.json @@ -5,6 +5,8 @@ "nav.settings": "Einstellungen", "restart_banner.message": "Fail2ban Konfiguration geändert. Um Änderungen zu übernehmen bitte ", "restart_banner.button": "Service neu starten", + "restart_banner.restart_success": "Fail2ban-Dienst wurde neu gestartet und der Healthcheck war erfolgreich", + "restart_banner.reload_success": "Fail2ban-Konfiguration wurde erfolgreich neu geladen (kein Systemd neustart)", "dashboard.title": "Dashboard", "dashboard.overview": "Aktive Jails und Blocks Übersicht", "dashboard.overview_hint": "Verwende die Suche, um gesperrte IPs zu filtern, und klicke auf ein Jail, um dessen Konfiguration zu bearbeiten.", @@ -245,6 +247,7 @@ "servers.badge.default": "Standard", "servers.badge.enabled": "Aktiv", "servers.badge.disabled": "Deaktiviert", + "servers.badge.restart_needed": "Neustart erforderlich", "servers.actions.edit": "Bearbeiten", "servers.actions.set_default": "Als Standard setzen", "servers.actions.enable": "Aktivieren", diff --git a/internal/locales/de_ch.json b/internal/locales/de_ch.json index 7fb28ca..5b1c020 100644 --- a/internal/locales/de_ch.json +++ b/internal/locales/de_ch.json @@ -5,6 +5,8 @@ "nav.settings": "Istellige", "restart_banner.message": "Fail2ban Konfiguration gänderet! Für z'übernehä, bitte: ", "restart_banner.button": "Service neu starte", + "restart_banner.restart_success": "Fail2ban-Dienst isch neu gestartet worde und dr Healthcheck het klappet", + "restart_banner.reload_success": "Fail2ban-Konfiguration isch erfolgreich neu gelade worde (Systemd neustart müglech)", "dashboard.title": "Dashboard", "dashboard.overview": "Übersicht vo de aktive Jails und Blocks", "dashboard.overview_hint": "Bruch d Suechi zum g'sperrti IPs filtere und klick ufneä Jail, zum Filter bearbeite.", @@ -245,6 +247,7 @@ "servers.badge.default": "Standard", "servers.badge.enabled": "Aktiv", "servers.badge.disabled": "Deaktiviert", + "servers.badge.restart_needed": "Brucht ä Neustart", "servers.actions.edit": "Bearbeite", "servers.actions.set_default": "Als Standard setze", "servers.actions.enable": "Aktivierä", diff --git a/internal/locales/en.json b/internal/locales/en.json index f7523b5..24670b9 100644 --- a/internal/locales/en.json +++ b/internal/locales/en.json @@ -5,6 +5,8 @@ "nav.settings": "Settings", "restart_banner.message": "Fail2ban configuration changed. To apply the changes, please ", "restart_banner.button": "Restart Service", + "restart_banner.restart_success": "Fail2ban service restarted and passed health check", + "restart_banner.reload_success": "Fail2ban configuration reloaded successfully (no systemd service restart)", "dashboard.title": "Dashboard", "dashboard.overview": "Overview active Jails and Blocks", "dashboard.overview_hint": "Use the search to filter banned IPs and click a jail to edit its configuration.", @@ -245,6 +247,7 @@ "servers.badge.default": "Default", "servers.badge.enabled": "Enabled", "servers.badge.disabled": "Disabled", + "servers.badge.restart_needed": "Restart required", "servers.actions.edit": "Edit", "servers.actions.set_default": "Set default", "servers.actions.enable": "Enable", diff --git a/internal/locales/es.json b/internal/locales/es.json index 6ccc4a4..d10068f 100644 --- a/internal/locales/es.json +++ b/internal/locales/es.json @@ -5,6 +5,8 @@ "nav.settings": "Configuración", "restart_banner.message": "¡Configuración de Fail2ban modificada. Para aplicar los cambios, por favor ", "restart_banner.button": "Reiniciar servicio", + "restart_banner.restart_success": "El servicio Fail2ban se reinició correctamente y superó la comprobación de estado", + "restart_banner.reload_success": "La configuración de Fail2ban se recargó correctamente (sin reinicio del servicio Systemd)", "dashboard.title": "Panel de control", "dashboard.overview": "Resumen de Jails y Bloqueos activos", "dashboard.overview_hint": "Usa la búsqueda para filtrar IPs bloqueadas y haz clic en un jail para editar su configuración.", @@ -245,6 +247,7 @@ "servers.badge.default": "Predeterminado", "servers.badge.enabled": "Habilitado", "servers.badge.disabled": "Deshabilitado", + "servers.badge.restart_needed": "Reinicio requerido", "servers.actions.edit": "Editar", "servers.actions.set_default": "Establecer predeterminado", "servers.actions.enable": "Habilitar", diff --git a/internal/locales/fr.json b/internal/locales/fr.json index ae59b6a..7bbb72d 100644 --- a/internal/locales/fr.json +++ b/internal/locales/fr.json @@ -5,6 +5,8 @@ "nav.settings": "Paramètres", "restart_banner.message": "Configuration Fail2ban modifiée. Pour appliquer les changements, veuillez ", "restart_banner.button": "Redémarrer le service", + "restart_banner.restart_success": "Le service Fail2ban a été redémarré et le contrôle d'état a réussi", + "restart_banner.reload_success": "La configuration Fail2ban a été rechargée avec succès (pas de redémarrage du service Systemd)", "dashboard.title": "Tableau de bord", "dashboard.overview": "Vue d'ensemble des jails et blocages actifs", "dashboard.overview_hint": "Utilisez la recherche pour filtrer les IP bloquées et cliquez sur un jail pour modifier sa configuration.", @@ -245,6 +247,7 @@ "servers.badge.default": "Par défaut", "servers.badge.enabled": "Activé", "servers.badge.disabled": "Désactivé", + "servers.badge.restart_needed": "Redémarrage requis", "servers.actions.edit": "Modifier", "servers.actions.set_default": "Définir par défaut", "servers.actions.enable": "Activer", diff --git a/internal/locales/it.json b/internal/locales/it.json index ddd5ad3..a1aa45a 100644 --- a/internal/locales/it.json +++ b/internal/locales/it.json @@ -5,6 +5,8 @@ "nav.settings": "Impostazioni", "restart_banner.message": "Configurazione di Fail2ban modificata. Per applicare le modifiche, per favore ", "restart_banner.button": "Riavvia il servizio", + "restart_banner.restart_success": "Il servizio Fail2ban è stato riavviato e il controllo di stato è riuscito", + "restart_banner.reload_success": "La configurazione di Fail2ban è stata ricaricata correttamente (senza riavvio del servizio Systemd)", "dashboard.title": "Cruscotto", "dashboard.overview": "Panoramica dei jail e dei blocchi attivi", "dashboard.overview_hint": "Usa la ricerca per filtrare le IP bloccate e fai clic su un jail per modificarne la configurazione.", @@ -245,6 +247,7 @@ "servers.badge.default": "Predefinito", "servers.badge.enabled": "Abilitato", "servers.badge.disabled": "Disabilitato", + "servers.badge.restart_needed": "Riavvio richiesto", "servers.actions.edit": "Modifica", "servers.actions.set_default": "Imposta predefinito", "servers.actions.enable": "Abilita", diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 2ebc970..bb82ca5 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -551,6 +551,28 @@ func UpsertServerHandler(c *gin.Context) { // Don't fail the request, just log the warning } else { config.DebugLog("Successfully ensured jail.local structure for server %s", server.Name) + + // If the server was just enabled, try to restart fail2ban and perform a basic health check. + if justEnabled { + if err := conn.Restart(c.Request.Context()); err != nil { + // Surface restart failures to the UI so the user sees that the service did not restart. + msg := fmt.Sprintf("failed to restart fail2ban for server %s: %v", server.Name, err) + config.DebugLog("Warning: %s", msg) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": msg, + "server": server, + }) + return + } else { + // Basic health check: attempt to fetch jail infos, which runs fail2ban-client status. + if _, err := conn.GetJailInfos(c.Request.Context()); err != nil { + config.DebugLog("Warning: fail2ban appears unhealthy on server %s after restart: %v", server.Name, err) + // Again, we log instead of failing the request to avoid breaking existing flows. + } else { + config.DebugLog("Fail2ban service appears healthy on server %s after restart", server.Name) + } + } + } } } } @@ -1905,27 +1927,28 @@ func RestartFail2banHandler(c *gin.Context) { server := conn.Server() - // Attempt to restart the fail2ban service. - restartErr := fail2ban.RestartFail2ban(server.ID) - if restartErr != nil { - // Check if running inside a 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) - } else { - // On the host, a restart error is not acceptable. - c.JSON(http.StatusInternalServerError, gin.H{"error": restartErr.Error()}) - return - } + // Attempt to restart the fail2ban service via the connector. + // Any error here means the service was not restarted, so we surface it to the UI. + mode, err := fail2ban.RestartFail2ban(server.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return } - // Only call MarkRestartDone if we either successfully restarted the service or we are in a container. + // Only call MarkRestartDone if we successfully restarted the service. if err := config.MarkRestartDone(server.ID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"message": "Fail2ban restarted successfully"}) + msg := "Fail2ban service restarted successfully" + if mode == "reload" { + msg = "Fail2ban configuration reloaded successfully (no systemd service restart)" + } + c.JSON(http.StatusOK, gin.H{ + "message": msg, + "mode": mode, + "server": server, + }) } // loadLocale loads a locale JSON file and returns a map of translations diff --git a/pkg/web/static/js/servers.js b/pkg/web/static/js/servers.js index d34ed7d..c75a72c 100644 --- a/pkg/web/static/js/servers.js +++ b/pkg/web/static/js/servers.js @@ -157,6 +157,9 @@ function renderServerManagerList() { var defaultBadge = server.isDefault ? 'Default' : ''; + var restartBadge = server.restartNeeded + ? 'Restart required' + : ''; var descriptor = []; if (server.type) { descriptor.push(server.type.toUpperCase()); @@ -178,7 +181,7 @@ function renderServerManagerList() { + '
' + '
' + '
' - + '

' + escapeHtml(server.name || server.id) + defaultBadge + statusBadge + '

' + + '

' + escapeHtml(server.name || server.id) + defaultBadge + statusBadge + restartBadge + '

' + '

' + escapeHtml(meta || server.id) + '

' + tags + '
' @@ -540,9 +543,18 @@ function restartFail2banServer(serverId) { showToast("Failed to restart Fail2ban: " + data.error, 'error'); return; } + var mode = data.mode || 'restart'; + var key, fallback; + if (mode === 'reload') { + key = 'restart_banner.reload_success'; + fallback = 'Fail2ban configuration reloaded successfully'; + } else { + key = 'restart_banner.restart_success'; + fallback = 'Fail2ban service restarted and passed health check'; + } return loadServers().then(function() { updateRestartBanner(); - showToast(t('restart_banner.success', 'Fail2ban restart triggered'), 'success'); + showToast(t(key, fallback), 'success'); return refreshData({ silent: true }); }); })