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:
@@ -45,7 +45,7 @@ vrrp_instance VI_1 {
|
|||||||
virtual_router_id {{ .RouterID }}
|
virtual_router_id {{ .RouterID }}
|
||||||
priority {{ .Priority }}
|
priority {{ .Priority }}
|
||||||
advert_int 2
|
advert_int 2
|
||||||
nopreempt
|
{{ if .PreemptDelay }}preempt_delay {{ .PreemptDelay }}{{ else }}nopreempt{{ end }}
|
||||||
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
|
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
|
||||||
unicast_peer {
|
unicast_peer {
|
||||||
{{ .PeerIP }}
|
{{ .PeerIP }}
|
||||||
@@ -72,7 +72,7 @@ vrrp_instance VI_HB {
|
|||||||
virtual_router_id {{ .HBRouterID }}
|
virtual_router_id {{ .HBRouterID }}
|
||||||
priority {{ .Priority }}
|
priority {{ .Priority }}
|
||||||
advert_int 2
|
advert_int 2
|
||||||
nopreempt
|
{{ if .PreemptDelay }}preempt_delay {{ .PreemptDelay }}{{ else }}nopreempt{{ end }}
|
||||||
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
|
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
|
||||||
unicast_peer {
|
unicast_peer {
|
||||||
{{ .HBPeerIP }}
|
{{ .HBPeerIP }}
|
||||||
|
|||||||
@@ -54,8 +54,19 @@ type View struct {
|
|||||||
HBRouterID int
|
HBRouterID int
|
||||||
// GW-Tracking
|
// GW-Tracking
|
||||||
GWCheckIP string
|
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 {
|
type generator struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
localID string
|
localID string
|
||||||
@@ -167,21 +178,21 @@ func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local
|
|||||||
v.HBRouterID = 52
|
v.HBRouterID = 52
|
||||||
}
|
}
|
||||||
|
|
||||||
// State IMMER BACKUP + `nopreempt` auf BEIDEN Nodes. Die Priorität
|
// State IMMER BACKUP; die Priorität entscheidet, welcher Node die VIP
|
||||||
// entscheidet die INITIAL-Election (primary=200 gewinnt), aber ein
|
// bevorzugt hält (PG-Primary=200 > Standby=100). Mit preempt_delay holt
|
||||||
// erholter Node reißt die VIP NIEMALS zurück.
|
// 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`-
|
// Incident 2026-08-03 & Fix: eine frühere preempt_delay-Variante ließ den
|
||||||
// Variante ließ den Prio-200-Node die VIP zurückholen, sobald keepalived
|
// Prio-200-Node die VIP zurückholen, sobald der Health-Check ihn für
|
||||||
// ihn für „gesund" hielt. Die Track-Scripts (chk_edgeguard/chk_gateway)
|
// „gesund" hielt — der prüfte aber NUR die edgeguard-api, nicht ob der Node
|
||||||
// können aber „gesund" melden, während der eigentliche Dienst noch kaputt
|
// wirklich Traffic bedient. Ein halb-kaputter Primary (api up, haproxy/Netz
|
||||||
// ist → der halb-kaputte Primary entriss dem funktionierenden Standby die
|
// down) riss so die VIP an sich → Ausfall. Preempt ist wieder aktiv, WEIL
|
||||||
// VIP und hielt sie fest (Ausfall, bis der Primary hart abgeschaltet
|
// keepalived-check.sh jetzt zusätzlich haproxy-aktiv + :443-gebunden fordert:
|
||||||
// wurde). nopreempt verhindert genau das. VIP-Affinität zum Primary ist
|
// ein nicht-bedienender Node geht in FAULT und kann NICHT preempten.
|
||||||
// erst wieder vertretbar, wenn der Health-Check „Prozess up aber Dienst
|
// Promotion/PG-Failover bleibt manuell (edgeguard-ctl promote).
|
||||||
// kaputt" zuverlässig als FAULT erkennt. Bis dahin gilt: Stabilität >
|
|
||||||
// Affinität. Promotion/PG-Failover bleibt manuell (edgeguard-ctl promote).
|
|
||||||
v.State = "BACKUP"
|
v.State = "BACKUP"
|
||||||
|
v.PreemptDelay = preemptDelaySeconds
|
||||||
if local.PGRole == "standby" {
|
if local.PGRole == "standby" {
|
||||||
v.Priority = 100
|
v.Priority = 100
|
||||||
} else if local.PGRole == "primary" || local.Role == "primary" {
|
} else if local.PGRole == "primary" || local.Role == "primary" {
|
||||||
|
|||||||
@@ -23,29 +23,46 @@ func testView() View {
|
|||||||
SrcIP: "89.163.205.6", PeerIP: "89.163.205.8", AuthPass: "edgeguard",
|
SrcIP: "89.163.205.6", PeerIP: "89.163.205.8", AuthPass: "edgeguard",
|
||||||
VIPs: []VIPEntry{{Address: "89.163.205.100", Prefix: 24, Device: "eth0"}},
|
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,
|
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`
|
// VIP-Affinität zum PG-Primary (Incident-2026-08-03-Fix): mit PreemptDelay
|
||||||
// tragen, sonst reißt ein erholter-aber-evtl-kaputter Node die VIP zurück.
|
// tragen BEIDE Instanzen `preempt_delay N` statt nopreempt, damit der
|
||||||
// nopreempt wirkt nur bei state BACKUP — also darf kein Node MASTER starten.
|
// bevorzugte Node die VIP nach Erholung zurückholt — aber erst nach N Sekunden
|
||||||
func TestTemplateNopreemptOnBothInstances(t *testing.T) {
|
// 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())
|
out := render(t, testView())
|
||||||
if n := strings.Count(out, "nopreempt"); n != 2 {
|
if c := strings.Count(out, "preempt_delay 120"); c != 2 {
|
||||||
t.Fatalf("erwarte nopreempt in VI_1 UND VI_HB (2×), gefunden: %d\n%s", n, out)
|
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") {
|
if strings.Contains(out, "nopreempt") {
|
||||||
t.Fatalf("KEIN preempt_delay erlaubt (Incident 2026-08-03):\n%s", out)
|
t.Fatalf("bei PreemptDelay>0 darf KEIN nopreempt gerendert werden:\n%s", out)
|
||||||
}
|
}
|
||||||
if strings.Contains(out, "state MASTER") {
|
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 {
|
if c := strings.Count(out, "state BACKUP"); c != 2 {
|
||||||
t.Fatalf("erwarte state BACKUP in beiden Instanzen, gefunden: %d", c)
|
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
|
// GARP muss forciert + periodic aufgefrischt werden, sonst altert die
|
||||||
// VIP-MAC am Upstream-Switch und die Failover-IP wird unerreichbar.
|
// VIP-MAC am Upstream-Switch und die Failover-IP wird unerreichbar.
|
||||||
func TestTemplateGARPRefresh(t *testing.T) {
|
func TestTemplateGARPRefresh(t *testing.T) {
|
||||||
@@ -111,5 +128,8 @@ func TestBuildViewStateAlwaysBackup(t *testing.T) {
|
|||||||
if v.Priority != c.wantPrio {
|
if v.Priority != c.wantPrio {
|
||||||
t.Errorf("pg_role=%q role=%q: Priority=%d, erwarte %d", c.pgRole, c.role, 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -848,6 +848,26 @@ ACQUIS
|
|||||||
systemctl reload crowdsec 2>/dev/null \
|
systemctl reload crowdsec 2>/dev/null \
|
||||||
|| systemctl restart crowdsec 2>/dev/null || true
|
|| systemctl restart crowdsec 2>/dev/null || true
|
||||||
fi
|
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
|
fi
|
||||||
|
|
||||||
# ── Render initial service configs ───────────────────────────
|
# ── Render initial service configs ───────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Keepalived health check: edgeguard-api erreichbar?
|
# Keepalived-Health-Check (chk_edgeguard).
|
||||||
# Weight -50 → BACKUP gewinnt wenn Primary-API nicht antwortet.
|
#
|
||||||
|
# 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 \
|
curl -sf --max-time 2 --unix-socket /run/edgeguard/api.sock \
|
||||||
http://localhost/api/v1/system/health > /dev/null 2>&1 \
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user