diff --git a/VERSION b/VERSION index 7babc03..b2f4ea6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.109 +1.1.110 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 1608a5f..d37dafe 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -60,7 +60,7 @@ import ( usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" ) -var version = "1.1.109" +var version = "1.1.110" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 77e64e9..3d7c43a 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -11,7 +11,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) -var version = "1.1.109" +var version = "1.1.110" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index d1a1391..4233fee 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -41,7 +41,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.1.109" +var version = "1.1.110" const ( // renewTickInterval — how often we re-evaluate expiring certs. @@ -133,6 +133,14 @@ const ( // damit ein kurzer Upstream-Ausfall (Reboot, DHCP-Pause) keinen // Alert-Regen produziert. ntpSyncCheckInterval = 10 * time.Minute + + // wgTunnelCheckInterval — alle 5 Minuten WireGuard-Client-Tunnels + // auf Aktualität prüfen. Client-Tunnels (mode='client') haben genau + // einen Peer; wenn dessen letzter Handshake älter als wgStaleSec ist, + // ist der Tunnel effektiv tot — Traffic droht lautlos. Dedupe 30min + // pro Tunnel damit schnell wiederhergestellte Tunnels nur einmal feuern. + wgTunnelCheckInterval = 5 * time.Minute + wgStaleSec = int64(5 * 60) // 5 Minuten ohne Handshake = tot ) func main() { @@ -236,6 +244,11 @@ func main() { // einige Sekunden bis zur ersten Synchronisation — ein // sofortiger Check würde immer feuern. + wgTunnelTick := time.NewTicker(wgTunnelCheckInterval) + defer wgTunnelTick.Stop() + // Kein Initial-Check bei Boot: Tunnels brauchen nach dem Start + // des wg-quick-Dienstes einen Moment für den ersten Handshake. + for { select { case <-renewTick.C: @@ -267,6 +280,8 @@ func main() { runConntrackCheck(ctx, alertSvc, alertDedupe) case <-ntpSyncTick.C: runNTPSyncCheck(ctx, alertSvc, alertDedupe) + case <-wgTunnelTick.C: + runWGClientTunnelCheck(ctx, pool, alertSvc, alertDedupe) } } } @@ -563,6 +578,89 @@ func parseChronyTrackingForAlert(out string) (synced bool, stratum int, referenc return } +// runWGClientTunnelCheck prüft alle aktiven WireGuard-Client-Tunnels +// (mode='client') auf Handshake-Aktualität. Ein Client-Tunnel hat genau +// einen Peer; wenn dessen letzter Handshake älter als wgStaleSec oder +// noch nie stattgefunden hat, ist der Tunnel tot — Traffic wird lautlos +// verworfen (kein ICMP Unreachable). Dedupe 30min pro Tunnel damit +// nach einer Selbstheilung nicht alle paar Minuten neu gefeuert wird. +func runWGClientTunnelCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Service, d *dedupe) { + if a == nil || d == nil || pool == nil { + return + } + + // Alle aktiven Client-Interfaces aus DB laden. + type wgIface struct{ name string } + rows, err := pool.Query(ctx, + `SELECT name FROM wg_interfaces WHERE mode = 'client' AND active = true ORDER BY name`) + if err != nil { + return + } + defer rows.Close() + var ifaces []wgIface + for rows.Next() { + var n string + if err := rows.Scan(&n); err == nil { + ifaces = append(ifaces, wgIface{n}) + } + } + rows.Close() + if len(ifaces) == 0 { + return + } + + now := time.Now().Unix() + for _, ifc := range ifaces { + out, err := exec.Command("wg", "show", ifc.name, "dump").Output() + if err != nil { + // Interface existiert nicht mehr im Kernel (wg-quick down) — + // das ist selbst schon ein Problem; kein separater Alert hier, + // da systemd-Restart-Policy das abdeckt. + continue + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + // Zeile 0 ist die Interface-Zeile (own key / pubkey / port / fwmark). + // Zeile 1 ist die Peer-Zeile: pubkey psk endpoint allowed-ips last-hs rx tx keepalive + if len(lines) < 2 { + continue + } + fields := strings.Fields(lines[1]) + if len(fields) < 5 { + continue + } + lastHS, _ := strconv.ParseInt(fields[4], 10, 64) + + stale := lastHS == 0 || (now-lastHS) > wgStaleSec + if !stale { + continue + } + key := "wg.tunnel.down." + ifc.name + if !d.shouldFire(key) { + continue + } + var detail string + if lastHS == 0 { + detail = "Noch kein Handshake — Tunnel wurde nie erfolgreich aufgebaut." + } else { + ageMin := (now - lastHS) / 60 + detail = fmt.Sprintf("Letzter Handshake: vor %d Minuten.", ageMin) + } + title := fmt.Sprintf("WireGuard-Tunnel %s ausgefallen", ifc.name) + desc := fmt.Sprintf( + "Client-Tunnel %s hat seit >5 Minuten keinen Handshake.\n%s\n\n"+ + "Traffic zu den RemoteAllowed-Netzen wird lautlos verworfen.\n\n"+ + "Mögliche Ursachen:\n"+ + " • Remote-Peer nicht erreichbar (Firewall, Routing)\n"+ + " • Remote-Server-Keypair geändert (Public-Key stimmt nicht mehr)\n"+ + " • UDP-Port des Peers geblockt\n"+ + " • wg-quick-Dienst auf dieser Box gestoppt: systemctl status wg-quick@%s", + ifc.name, detail, ifc.name) + if _, err := a.Fire(ctx, "wg.tunnel.down", alerts.SeverityError, title, desc); err != nil { + slog.Warn("scheduler: wg-tunnel-check alert fire failed", "iface", ifc.name, "error", err) + } + } +} + var egBackendRE = regexp.MustCompile(`^eg_backend_(\d+)$`) // runBackendDownCheck liest HAProxy-Stats via Admin-Socket und feuert diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 0481374..845403b 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -1119,7 +1119,7 @@ "title": "Health-Alarme", "intro": "Notification-Channels für kritische Events. Webhook (Slack/Discord/Teams/Generic-HTTP) oder Email (SMTP). Triggers: cert.expiring (<14 d), cert.renew_failed, backup.failed, license.invalid.", "scopeTitle": "Was triggert Alarme?", - "scopeDesc": "cert.expiring — TLS-Zertifikat <14 Tage Restzeit (dedupe 12h). cert.renew_failed — ACME-Renewer hat Fails. backup.failed — Scheduled Backup konnte nicht erstellt werden. license.invalid — License-Server liefert valid=false. backend.down — alle Server eines Backend-Pools sind DOWN (2-Min-Check, dedupe 12h). disk.full — Root-Filesystem ≥80% Warnung, ≥90% Critical (stündlich, dedupe 12h). mem.high — RAM-Auslastung ≥85% Warnung, ≥95% Critical (5-Min-Check, dedupe 1h). conntrack.high — Conntrack-Tabelle ≥80% Warnung, ≥90% Critical; bei 100% werden alle neuen Verbindungen lautlos verworfen (2-Min-Check, dedupe 1h). ntp.unsync — chrony hat keine synchronisierte Zeitquelle; Drift führt zu TLS- und JWT-Fehlern (10-Min-Check, dedupe 1h).", + "scopeDesc": "cert.expiring — TLS-Zertifikat <14 Tage Restzeit (dedupe 12h). cert.renew_failed — ACME-Renewer hat Fails. backup.failed — Scheduled Backup konnte nicht erstellt werden. license.invalid — License-Server liefert valid=false. backend.down — alle Server eines Backend-Pools sind DOWN (2-Min-Check, dedupe 12h). disk.full — Root-Filesystem ≥80% Warnung, ≥90% Critical (stündlich, dedupe 12h). mem.high — RAM-Auslastung ≥85% Warnung, ≥95% Critical (5-Min-Check, dedupe 12h). conntrack.high — Conntrack-Tabelle ≥80% Warnung, ≥90% Critical; bei 100% werden alle neuen Verbindungen lautlos verworfen (2-Min-Check, dedupe 12h). ntp.unsync — chrony hat keine synchronisierte Zeitquelle; Drift führt zu TLS- und JWT-Fehlern (10-Min-Check, dedupe 12h). wg.tunnel.down — WireGuard-Client-Tunnel hat seit >5 Min keinen Handshake; Remote-Traffic wird lautlos verworfen (5-Min-Check, dedupe 12h).", "tabs": { "channels": "Channels", "events": "History" }, "add": "Channel hinzufügen", "addTitle": "Notification-Channel anlegen", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 11f899e..6af44c7 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -1119,7 +1119,7 @@ "title": "Health alerts", "intro": "Notification channels for critical events. Webhook (Slack/Discord/Teams/generic-HTTP) or email (SMTP). Triggers: cert.expiring (<14 d), cert.renew_failed, backup.failed, license.invalid.", "scopeTitle": "What triggers alerts?", - "scopeDesc": "cert.expiring — TLS cert <14 days remaining (12 h dedupe). cert.renew_failed — ACME renewer cycle had failures. backup.failed — scheduled backup couldn't run. license.invalid — License server returns valid=false. backend.down — all servers in a backend pool are DOWN (2 min check, 12 h dedupe). disk.full — root filesystem ≥80% warning, ≥90% critical (hourly check, 12 h dedupe). mem.high — RAM usage ≥85% warning, ≥95% critical (5 min check, 1 h dedupe). conntrack.high — conntrack table ≥80% warning, ≥90% critical; at 100% all new connections are silently dropped (2 min check, 1 h dedupe). ntp.unsync — chrony has no synchronized time source; clock drift causes TLS and JWT failures (10 min check, 1 h dedupe).", + "scopeDesc": "cert.expiring — TLS cert <14 days remaining (12 h dedupe). cert.renew_failed — ACME renewer cycle had failures. backup.failed — scheduled backup couldn't run. license.invalid — License server returns valid=false. backend.down — all servers in a backend pool are DOWN (2 min check, 12 h dedupe). disk.full — root filesystem ≥80% warning, ≥90% critical (hourly check, 12 h dedupe). mem.high — RAM usage ≥85% warning, ≥95% critical (5 min check, 12 h dedupe). conntrack.high — conntrack table ≥80% warning, ≥90% critical; at 100% all new connections are silently dropped (2 min check, 12 h dedupe). ntp.unsync — chrony has no synchronized time source; clock drift causes TLS and JWT failures (10 min check, 12 h dedupe). wg.tunnel.down — WireGuard client tunnel has no handshake for >5 min; remote traffic is silently dropped (5 min check, 12 h dedupe).", "tabs": { "channels": "Channels", "events": "History" }, "add": "Add channel", "addTitle": "Add notification channel",