feat(scheduler): WireGuard-Client-Tunnel-Down-Alert + dedupe-Korrekturen — v1.1.110

- runWGClientTunnelCheck() prüft alle aktiven Client-Tunnels (mode='client')
  alle 5 Min; feuert Error-Alert wenn kein Handshake seit >5 Min oder
  noch nie (12h dedupe pro Tunnel-Name)
- Dedupe-Angaben in alerts.scopeDesc korrigiert: war "1h" für mem/conntrack/ntp,
  tatsächlich 12h (shared alertDedupe) — beide Sprachen bereinigt
- wg.tunnel.down-Trigger in beiden i18n-Dateien dokumentiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-26 19:16:03 +02:00
parent feae18772c
commit 4406f9ac5b
6 changed files with 104 additions and 6 deletions

View File

@@ -1 +1 @@
1.1.109
1.1.110

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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",