From 25ec98161fc3b8127c9d18f794eb3a1f4ccdf0a0 Mon Sep 17 00:00:00 2001 From: Debian Date: Thu, 18 Jun 2026 15:44:43 +0200 Subject: [PATCH] =?UTF-8?q?fix(chrony):=20kein=20Multi-bindaddress=20?= =?UTF-8?q?=E2=80=94=20NTP-Server=20bediente=20nur=20eine=20VIP=20?= =?UTF-8?q?=E2=80=94=20v1.2.109?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chrony honoriert nur EINE bindaddress pro Adressfamilie. Der Generator emittierte aber eine bindaddress PRO Listen-IP (mehrere VLAN-/Cluster-VIPs) → chrony band nur die letzte (10.0.50.1), alle anderen Clients (z. B. auf 10.0.5.1) erreichten den NTP-Server NICHT. Ein Restart hilft nicht (Config-Bug, nicht stale binding). Fix: chrony.cfg.tpl emittiert KEIN bindaddress mehr → bind-all; WER bedient wird, regeln die allow-ACL + die nftables-Regeln (UDP/123 nur auf den Listen-IPs/VIPs offen, nicht öffentlich). Zugleich failover-robust: chrony bedient automatisch jede VIP, die der Node gerade hält, ohne Restart bei Master-Wechsel. Test: internal/chrony/chrony_test.go (kein bindaddress, allow vorhanden; port 0 bei serve_clients=false). Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION | 2 +- internal/chrony/chrony.cfg.tpl | 16 +++++----- internal/chrony/chrony_test.go | 53 ++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 internal/chrony/chrony_test.go diff --git a/VERSION b/VERSION index 34b910f..802f957 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.108 \ No newline at end of file +1.2.109 \ No newline at end of file diff --git a/internal/chrony/chrony.cfg.tpl b/internal/chrony/chrony.cfg.tpl index 0446b72..b643ac3 100644 --- a/internal/chrony/chrony.cfg.tpl +++ b/internal/chrony/chrony.cfg.tpl @@ -13,14 +13,16 @@ {{end}} # ── Listen-Bind ──────────────────────────────────────────────── -# Wenn nichts ausser localhost gebound ist, lassen wir bindaddress -# weg (chrony default = alle Interfaces). Sonst explizite bindaddress -# pro IP. Mit serve_clients=false wird port 0 → kein Listen-Socket -# (= reiner Client). +# KEIN bindaddress: chrony honoriert nur EINE bindaddress pro Adress- +# familie — bei mehreren Listen-IPs (z. B. mehrere VLAN-/Cluster-VIPs) +# würde nur die letzte gebunden, alle anderen NTP-Clients liefen ins +# Leere. Stattdessen lauscht chrony auf allen Interfaces; WER bedient +# wird, regeln die allow-ACL UNTEN + die nftables-Regeln (UDP/123 wird +# nur auf den konfigurierten Listen-IPs/VIPs geöffnet, nicht öffentlich). +# Bonus: failover-robust — chrony bedient automatisch jede VIP, die der +# Node gerade hält, ohne Restart bei Master-Wechsel. +# serve_clients=false → port 0 → kein Listen-Socket (reiner Client). {{if .Settings.ServeClients}} -{{- range .ListenAddresses}} -bindaddress {{.}} -{{- end}} {{- range .AllowACLs}} allow {{.}} {{- end}} diff --git a/internal/chrony/chrony_test.go b/internal/chrony/chrony_test.go new file mode 100644 index 0000000..97705f3 --- /dev/null +++ b/internal/chrony/chrony_test.go @@ -0,0 +1,53 @@ +package chrony + +import ( + "bytes" + "strings" + "testing" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +func render(t *testing.T, v View) string { + t.Helper() + var b bytes.Buffer + if err := tpl.Execute(&b, v); err != nil { + t.Fatalf("execute: %v", err) + } + return b.String() +} + +// Mehrere Listen-IPs (VLAN-/Cluster-VIPs): chrony honoriert nur EINE +// bindaddress pro Adressfamilie → wir dürfen GAR KEINE bindaddress emittieren, +// sondern bind-all + allow-ACL. Sonst würde nur eine VIP gebunden und alle +// anderen NTP-Clients liefen ins Leere (Regressions-Schutz). +func TestRender_NoBindaddress_ServesAllVIPs(t *testing.T) { + v := View{ + Settings: &models.NTPSettings{ServeClients: true, MakestepSecs: 1, MakestepLimit: 3}, + AllowACLs: []string{"10.0.0.0/8", "192.168.0.0/16"}, + ListenAddresses: []string{"10.0.5.1", "10.0.20.1", "10.10.20.1", "10.0.50.1"}, + } + out := render(t, v) + // Auf die DIREKTIVE prüfen (Zeilenanfang), nicht aufs Wort — der + // erklärende Kommentar im Template enthält „bindaddress" absichtlich. + if strings.Contains(out, "\nbindaddress ") { + t.Fatalf("chrony darf KEIN bindaddress emittieren (nur eine pro Familie wird gebunden):\n%s", out) + } + for _, acl := range []string{"allow 10.0.0.0/8", "allow 192.168.0.0/16"} { + if !strings.Contains(out, acl) { + t.Fatalf("erwarte %q im Output:\n%s", acl, out) + } + } +} + +// serve_clients=false → reiner Client: port 0, kein Listen-Socket, kein allow. +func TestRender_NoServeClients_Port0(t *testing.T) { + v := View{Settings: &models.NTPSettings{ServeClients: false, MakestepSecs: 1, MakestepLimit: 3}} + out := render(t, v) + if !strings.Contains(out, "port 0") { + t.Fatalf("erwarte 'port 0' bei serve_clients=false:\n%s", out) + } + if strings.Contains(out, "\nallow ") { + t.Fatalf("kein allow bei serve_clients=false:\n%s", out) + } +}