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

@@ -1 +1 @@
1.3.15
1.3.16

View File

@@ -45,7 +45,7 @@ vrrp_instance VI_1 {
virtual_router_id {{ .RouterID }}
priority {{ .Priority }}
advert_int 2
nopreempt
{{ if .PreemptDelay }}preempt_delay {{ .PreemptDelay }}{{ else }}nopreempt{{ end }}
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
unicast_peer {
{{ .PeerIP }}
@@ -72,7 +72,7 @@ vrrp_instance VI_HB {
virtual_router_id {{ .HBRouterID }}
priority {{ .Priority }}
advert_int 2
nopreempt
{{ if .PreemptDelay }}preempt_delay {{ .PreemptDelay }}{{ else }}nopreempt{{ end }}
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
unicast_peer {
{{ .HBPeerIP }}

View File

@@ -54,8 +54,19 @@ type View struct {
HBRouterID int
// GW-Tracking
GWCheckIP string
// PreemptDelay > 0 → Node holt die VIP nach Erholung zurück (nach N Sekunden
// Stabilität). 0 → nopreempt (bleibt Backup). Siehe buildView.
PreemptDelay int
}
// preemptDelaySeconds: der bevorzugte Node (höhere Priorität = PG-Primary) holt
// die VIP erst nach dieser Wartezeit zurück — lange genug, dass ein frisch
// gebooteter/deployter Node erst wirklich bereit ist (Boot + Service-Start),
// bevor er überhaupt preempten darf. Zusammen mit dem gehärteten Health-Check
// (haproxy aktiv + :443 gebunden, keepalived-check.sh) verhindert das den
// Incident 2026-08-03 (halb-kaputter Node riss die VIP an sich).
const preemptDelaySeconds = 120
type generator struct {
pool *pgxpool.Pool
localID string
@@ -167,21 +178,21 @@ func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local
v.HBRouterID = 52
}
// State IMMER BACKUP + `nopreempt` auf BEIDEN Nodes. Die Priorität
// entscheidet die INITIAL-Election (primary=200 gewinnt), aber ein
// erholter Node reißt die VIP NIEMALS zurück.
// State IMMER BACKUP; die Priorität entscheidet, welcher Node die VIP
// bevorzugt hält (PG-Primary=200 > Standby=100). Mit preempt_delay holt
// der bevorzugte Node die VIP nach Erholung zurück (VIP-Affinität zum
// PG-Primary), aber erst nach preemptDelaySeconds Stabilität.
//
// WARUM kein Preempt (Incident 2026-08-03): eine frühere `preempt_delay`-
// Variante ließ den Prio-200-Node die VIP zurückholen, sobald keepalived
// ihn für „gesund" hielt. Die Track-Scripts (chk_edgeguard/chk_gateway)
// können aber „gesund" melden, während der eigentliche Dienst noch kaputt
// ist → der halb-kaputte Primary entriss dem funktionierenden Standby die
// VIP und hielt sie fest (Ausfall, bis der Primary hart abgeschaltet
// wurde). nopreempt verhindert genau das. VIP-Affinität zum Primary ist
// erst wieder vertretbar, wenn der Health-Check „Prozess up aber Dienst
// kaputt" zuverlässig als FAULT erkennt. Bis dahin gilt: Stabilität >
// Affinität. Promotion/PG-Failover bleibt manuell (edgeguard-ctl promote).
// Incident 2026-08-03 & Fix: eine frühere preempt_delay-Variante ließ den
// Prio-200-Node die VIP zurückholen, sobald der Health-Check ihn für
// „gesund" hielt — der prüfte aber NUR die edgeguard-api, nicht ob der Node
// wirklich Traffic bedient. Ein halb-kaputter Primary (api up, haproxy/Netz
// down) riss so die VIP an sich → Ausfall. Preempt ist wieder aktiv, WEIL
// keepalived-check.sh jetzt zusätzlich haproxy-aktiv + :443-gebunden fordert:
// ein nicht-bedienender Node geht in FAULT und kann NICHT preempten.
// Promotion/PG-Failover bleibt manuell (edgeguard-ctl promote).
v.State = "BACKUP"
v.PreemptDelay = preemptDelaySeconds
if local.PGRole == "standby" {
v.Priority = 100
} else if local.PGRole == "primary" || local.Role == "primary" {

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)
}
}
}

View File

@@ -848,6 +848,26 @@ ACQUIS
systemctl reload crowdsec 2>/dev/null \
|| systemctl restart crowdsec 2>/dev/null || true
fi
# CrowdSec-Whitelist: eigenen Management-Traffic (Backend
# api_backend = ausschließlich edgeguard-api: Admin-UI + REST-API +
# ACME) von ALLEN Scenarios ausnehmen. Die Admin-SPA feuert beim
# Laden viele /api/-Requests — normal, wurde aber als http-crawl
# gebannt (Incident 2026-08-03, Admin-IP gesperrt). IP-unabhängig.
install -d -m 0755 /etc/crowdsec/parsers/s02-enrich
# Altlast aus dem manuellen Live-Fix (2026-08-03) entfernen — sonst
# lägen zwei inhaltsgleiche Whitelist-Dateien nebeneinander.
rm -f /etc/crowdsec/parsers/s02-enrich/netcell-mgmt-whitelist.yaml
cat > /etc/crowdsec/parsers/s02-enrich/edgeguard-mgmt-whitelist.yaml <<'WL'
name: edgeguard/mgmt-ui-whitelist
description: Management-UI/REST-API/ACME (HAProxy-Backend api_backend) von CrowdSec ausnehmen - Admin-SPA-Traffic ist kein Angriff.
whitelist:
reason: edgeguard management-ui / api (backend api_backend)
expression:
- "evt.Parsed.backend_name == 'api_backend'"
WL
systemctl reload crowdsec 2>/dev/null \
|| systemctl restart crowdsec 2>/dev/null || true
fi
# ── Render initial service configs ───────────────────────────

View File

@@ -1,6 +1,30 @@
#!/bin/bash
# Keepalived health check: edgeguard-api erreichbar?
# Weight -50 → BACKUP gewinnt wenn Primary-API nicht antwortet.
# Keepalived-Health-Check (chk_edgeguard).
#
# WICHTIG (Incident 2026-08-03): Dieser Check entscheidet mit, ob ein Node die
# VIP übernehmen bzw. per Preempt zurückholen darf. Er darf NUR dann "gesund"
# (exit 0) melden, wenn der Node Traffic WIRKLICH bedienen kann — nicht nur
# "edgeguard-api-Prozess up". Sonst reißt ein frisch gebooteter / mitten im
# Deploy befindlicher Node (haproxy noch nicht bereit) die VIP an sich, obwohl
# er nichts bedient → Cluster-Ausfall, bis der Node hart abgeschaltet wird.
#
# ALLE Bedingungen müssen erfüllt sein, sonst exit 1 → Instanz geht in FAULT →
# kein (Re-)Preempt, der gesunde Peer behält/bekommt die VIP.
# 1) edgeguard-api antwortet auf den Health-Endpoint (Unix-Socket oder Loopback)?
curl -sf --max-time 2 --unix-socket /run/edgeguard/api.sock \
http://localhost/api/v1/system/health > /dev/null 2>&1 \
|| curl -sf --max-time 2 http://127.0.0.1:9443/api/v1/system/health > /dev/null 2>&1
|| curl -sf --max-time 2 http://127.0.0.1:9443/api/v1/system/health > /dev/null 2>&1 \
|| exit 1
# 2) haproxy-Prozess aktiv? Während Boot/Restart kurz false → Node bleibt in dem
# Fenster Backup (kann nicht preempten).
systemctl is-active --quiet haproxy 2>/dev/null || exit 1
# 3) haproxy hört wirklich auf dem oeffentlichen TLS-Port :443 (bindet = bedient)?
# Faengt "Prozess up, aber Config kaputt / Port nicht gebunden" ab. :443 ist
# architektur-bedingt immer gebunden (Public-TLS + ACME-Webroot + Mgmt-FQDN-
# Fallback), also ein verlaessliches Ready-Signal.
ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq ':443$' || exit 1
exit 0