diff --git a/VERSION b/VERSION index 92ee6ac..287e5b8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.15 \ No newline at end of file +1.3.16 \ No newline at end of file diff --git a/internal/keepalived/keepalived.conf.tpl b/internal/keepalived/keepalived.conf.tpl index 9e0676f..b303323 100644 --- a/internal/keepalived/keepalived.conf.tpl +++ b/internal/keepalived/keepalived.conf.tpl @@ -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 }} diff --git a/internal/keepalived/keepalived.go b/internal/keepalived/keepalived.go index efe6b76..4501ea1 100644 --- a/internal/keepalived/keepalived.go +++ b/internal/keepalived/keepalived.go @@ -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" { diff --git a/internal/keepalived/keepalived_test.go b/internal/keepalived/keepalived_test.go index 77811d7..4493ac4 100644 --- a/internal/keepalived/keepalived_test.go +++ b/internal/keepalived/keepalived_test.go @@ -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) + } } } diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index ead225b..9674bcf 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -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 ─────────────────────────── diff --git a/packaging/scripts/keepalived-check.sh b/packaging/scripts/keepalived-check.sh index 698b9eb..c6e147e 100644 --- a/packaging/scripts/keepalived-check.sh +++ b/packaging/scripts/keepalived-check.sh @@ -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