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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user