feat(ha): VIP-Preempt zurück zum PG-Primary — mit gehärtetem Health-Gate — v1.3.16

Preempt-Rückkehr (preempt_delay 120) wieder aktiv: der bevorzugte Node
(PG-Primary, Prio 200) holt die VIP nach Erholung zurück. Der Incident
2026-08-03 (halb-kaputter Node riss die VIP an sich) wird verhindert, weil
keepalived-check.sh jetzt zusätzlich fordert:
  - haproxy-Prozess aktiv
  - :443 gebunden (bedient wirklich Traffic)
Ein nicht-bedienender Node geht damit in FAULT und kann NICHT (mehr) preempten.

Außerdem: CrowdSec-Management-Whitelist (Backend api_backend) fest ins postinst
gebacken (Admin-SPA-Traffic wird nie mehr als http-crawl gebannt, IP-unabhängig,
Incident-Root-Fix) + Altlast netcell-mgmt-whitelist.yaml wird aufgeräumt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-08-03 13:39:15 +02:00
parent d395e3ea68
commit 0846eaa05b
6 changed files with 104 additions and 29 deletions

View File

@@ -23,29 +23,46 @@ func testView() View {
SrcIP: "89.163.205.6", PeerIP: "89.163.205.8", AuthPass: "edgeguard",
VIPs: []VIPEntry{{Address: "89.163.205.100", Prefix: 24, Device: "eth0"}},
HBInterface: "ens19", HBSrcIP: "169.254.0.1", HBPeerIP: "169.254.0.2", HBRouterID: 52,
GWCheckIP: "89.163.205.1",
GWCheckIP: "89.163.205.1", PreemptDelay: 120,
}
}
// Split-Brain-Schutz (Incident 2026-08-03): BEIDE Instanzen MÜSSEN `nopreempt`
// tragen, sonst reißt ein erholter-aber-evtl-kaputter Node die VIP zurück.
// nopreempt wirkt nur bei state BACKUP — also darf kein Node MASTER starten.
func TestTemplateNopreemptOnBothInstances(t *testing.T) {
// VIP-Affinität zum PG-Primary (Incident-2026-08-03-Fix): mit PreemptDelay
// tragen BEIDE Instanzen `preempt_delay N` statt nopreempt, damit der
// bevorzugte Node die VIP nach Erholung zurückholt — aber erst nach N Sekunden
// Stabilität. Kein Node darf `state MASTER` starten (sonst kein sauberes
// Election). Preempt ist nur sicher, WEIL keepalived-check.sh haproxy-Bereit-
// schaft (aktiv + :443) mitprüft (siehe dortiger Kommentar).
func TestTemplatePreemptDelayOnBothInstances(t *testing.T) {
out := render(t, testView())
if n := strings.Count(out, "nopreempt"); n != 2 {
t.Fatalf("erwarte nopreempt in VI_1 UND VI_HB (2×), gefunden: %d\n%s", n, out)
if c := strings.Count(out, "preempt_delay 120"); c != 2 {
t.Fatalf("erwarte preempt_delay 120 in VI_1 UND VI_HB (2×), gefunden: %d\n%s", c, out)
}
if strings.Contains(out, "preempt_delay") {
t.Fatalf("KEIN preempt_delay erlaubt (Incident 2026-08-03):\n%s", out)
if strings.Contains(out, "nopreempt") {
t.Fatalf("bei PreemptDelay>0 darf KEIN nopreempt gerendert werden:\n%s", out)
}
if strings.Contains(out, "state MASTER") {
t.Fatalf("kein Node darf state MASTER starten (nopreempt würde ignoriert):\n%s", out)
t.Fatalf("kein Node darf state MASTER starten:\n%s", out)
}
if c := strings.Count(out, "state BACKUP"); c != 2 {
t.Fatalf("erwarte state BACKUP in beiden Instanzen, gefunden: %d", c)
}
}
// Ohne PreemptDelay (==0) fällt das Template auf nopreempt zurück (Node bleibt
// Backup, keine VIP-Rückkehr) — der sichere Default, falls Preempt je aus soll.
func TestTemplateFallsBackToNopreempt(t *testing.T) {
v := testView()
v.PreemptDelay = 0
out := render(t, v)
if c := strings.Count(out, "nopreempt"); c != 2 {
t.Fatalf("erwarte nopreempt in beiden Instanzen (2×) bei PreemptDelay=0, gefunden: %d\n%s", c, out)
}
if strings.Contains(out, "preempt_delay") {
t.Fatalf("bei PreemptDelay=0 darf KEIN preempt_delay gerendert werden:\n%s", out)
}
}
// GARP muss forciert + periodic aufgefrischt werden, sonst altert die
// VIP-MAC am Upstream-Switch und die Failover-IP wird unerreichbar.
func TestTemplateGARPRefresh(t *testing.T) {
@@ -111,5 +128,8 @@ func TestBuildViewStateAlwaysBackup(t *testing.T) {
if v.Priority != c.wantPrio {
t.Errorf("pg_role=%q role=%q: Priority=%d, erwarte %d", c.pgRole, c.role, v.Priority, c.wantPrio)
}
if v.PreemptDelay != 120 {
t.Errorf("pg_role=%q role=%q: PreemptDelay=%d, erwarte 120", c.pgRole, c.role, v.PreemptDelay)
}
}
}