feat(scheduler): NTP-Sync-Alert — warnt wenn chrony keine Zeitquelle hat — v1.1.109

- runNTPSyncCheck() läuft alle 10 Minuten: ruft chronyc tracking auf,
  prüft ob Stratum 0 / ≥16 oder Reference ID 00000000 — feuert
  Warning mit Fix-Hints (1h dedupe)
- Initial-Check absichtlich NICHT beim Boot, da chrony nach dem Start
  einige Sekunden zur ersten Synchronisation braucht
- ntp.unsync-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 17:40:16 +02:00
parent b9dfab6664
commit feae18772c
6 changed files with 95 additions and 6 deletions

View File

@@ -60,7 +60,7 @@ import (
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
var version = "1.1.108"
var version = "1.1.109"
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.108"
var version = "1.1.109"
const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -16,6 +16,7 @@ import (
"log/slog"
"net"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
@@ -40,7 +41,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
var version = "1.1.108"
var version = "1.1.109"
const (
// renewTickInterval — how often we re-evaluate expiring certs.
@@ -124,6 +125,14 @@ const (
conntrackCheckInterval = 2 * time.Minute
conntrackWarnPct = 80.0
conntrackCriticalPct = 90.0
// ntpSyncCheckInterval — alle 10 Minuten chronyc tracking aufrufen.
// Keine Sync bedeutet: Uhr driftet → TLS-Cert-Prüfung schlägt fehl
// wenn die Abweichung > Toleranz des Gegenstücks (i.d.R. ±1 min),
// JWT-Ablauf inkonsistent, Cluster-Split-Brain möglich. Dedupe 1h
// damit ein kurzer Upstream-Ausfall (Reboot, DHCP-Pause) keinen
// Alert-Regen produziert.
ntpSyncCheckInterval = 10 * time.Minute
)
func main() {
@@ -221,6 +230,12 @@ func main() {
defer conntrackTick.Stop()
runConntrackCheck(ctx, alertSvc, alertDedupe)
ntpSyncTick := time.NewTicker(ntpSyncCheckInterval)
defer ntpSyncTick.Stop()
// Kein Initial-Check bei Boot: chrony braucht nach dem Start
// einige Sekunden bis zur ersten Synchronisation — ein
// sofortiger Check würde immer feuern.
for {
select {
case <-renewTick.C:
@@ -250,6 +265,8 @@ func main() {
runMemoryCheck(ctx, alertSvc, alertDedupe)
case <-conntrackTick.C:
runConntrackCheck(ctx, alertSvc, alertDedupe)
case <-ntpSyncTick.C:
runNTPSyncCheck(ctx, alertSvc, alertDedupe)
}
}
}
@@ -474,6 +491,78 @@ func runConntrackCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
}
}
// runNTPSyncCheck ruft chronyc tracking auf und feuert einen Alert wenn
// chrony keine synchronisierte Zeitquelle hat (Stratum 0 oder ≥ 16).
// Zeitdrift > ~1 Minute führt zu TLS-Handshake-Fehlern, JWT-Ablauf-
// Inkonsistenzen und möglichen Cluster-Problemen. Dedupe 1h.
func runNTPSyncCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
if a == nil || d == nil {
return
}
out, err := exec.Command("chronyc", "tracking").Output()
if err != nil {
// chrony nicht installiert oder nicht gestartet — kein Alert,
// weil wir nicht wissen ob chrony hier überhaupt erwartet wird.
return
}
synced, stratum, ref := parseChronyTrackingForAlert(string(out))
if synced {
return
}
const key = "ntp.unsync"
if !d.shouldFire(key) {
return
}
refStr := ref
if refStr == "" {
refStr = "(keine Referenz)"
}
title := fmt.Sprintf("NTP nicht synchronisiert (Stratum %d)", stratum)
desc := fmt.Sprintf(
"chrony hat keine synchronisierte Zeitquelle.\n"+
"Referenz: %s Stratum: %d\n\n"+
"Mögliche Ursachen:\n"+
" • Upstream-NTP-Server nicht erreichbar (UDP/123 blockiert?)\n"+
" • Pool-DNS-Einträge lösen nicht auf\n"+
" • chrony läuft, braucht aber noch Zeit nach Boot (warten)\n\n"+
"Prüfen: chronyc sources -v — chronyc tracking",
refStr, stratum)
if _, err := a.Fire(ctx, "ntp.unsync", alerts.SeverityWarning, title, desc); err != nil {
slog.Warn("scheduler: ntp-sync-check alert fire failed", "error", err)
}
}
// parseChronyTrackingForAlert ist eine schlanke Variante des NTP-Handler-
// Parsers: liefert nur synced/stratum/reference ohne die vollen Felder.
func parseChronyTrackingForAlert(out string) (synced bool, stratum int, reference string) {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
switch key {
case "Reference ID":
if i := strings.Index(val, "("); i >= 0 {
reference = strings.Trim(val[i:], "()")
}
if val != "00000000 ()" {
synced = true
}
case "Stratum":
fmt.Sscanf(val, "%d", &stratum)
if stratum > 0 && stratum < 16 {
synced = true
} else if stratum == 0 || stratum >= 16 {
synced = false
}
}
}
return
}
var egBackendRE = regexp.MustCompile(`^eg_backend_(\d+)$`)
// runBackendDownCheck liest HAProxy-Stats via Admin-Socket und feuert