fix(cluster): keepalived Split-Brain + Boot-Race behoben — v1.2.101

Ursache der „WireGuard reißt immer wieder ab"-Abrisse war NICHT die UniFi,
sondern keepalived-Flapping im HA-Cluster: die VIP 89.163.205.100 (an der die
UniFi-Site-to-Site hängt) wanderte bei ~17 VRRP-Wahlen/Tag zwischen utm-1/utm-2
→ Tunnel-Abriss bei jeder Wahl.

Drei Bugs:
1) Firewall ließ VRRP (IP-Proto 112) zwischen den Cluster-Peers NICHT zu
   (policy drop). Adverts überlebten nur via conntrack-Reverse-Matching → bei
   conntrack-Ablauf gedroppt → Peer promotet sich → Split-Brain.
   Fix: ruleset.nft.tpl erlaubt `ip/ip6 ... vrrp saddr @peer_ipv4/6`;
   firewall.go nimmt zusätzlich hb_src_ip/hb_peer_ip aus cluster_settings ins
   Peer-Set (deckt den Heartbeat-Pfad 169.254.0.x ab).
2) Kein nopreempt → erholter Node riss die VIP sofort zurück (Flap-Back);
   aggressiver gw-Check (fall 2 → 10s-Blip = Failover).
   Fix: keepalived.conf.tpl mit `nopreempt` in VI_1+VI_HB, chk_gateway fall 2→5;
   keepalived.go setzt State immer BACKUP (nopreempt wirkt nur in BACKUP),
   Priorität 200/100 aus pg_role bleibt → deckt sich mit „manuelles Promote".
3) keepalived-Boot-Race: Unit startete vor vlan500 (nur After=network-online)
   → „interface vlan500 doesn't exist" → permanenter CONFIG-Crash ohne Recovery
   (keepalived nach Reboot tot). Fix: postinst legt Drop-in mit
   After=/Wants=edgeguard-interfaces.service + Restart=on-failure an.

Neuer Test internal/keepalived/keepalived_test.go (nopreempt/BACKUP/fall).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-06-07 17:49:04 +02:00
parent 91e51890dd
commit a2450a759c
7 changed files with 175 additions and 41 deletions

View File

@@ -177,14 +177,14 @@ type RuleLeg struct {
// template just emits one nft line per "leg" of the cross-product.
type ResolvedRule struct {
ID int64
Action string // accept | drop | reject
Action string // accept | drop | reject
Log bool
Name string
Priority int
SrcIfaces []string // empty = any
DstIfaces []string // empty = any
SrcAddrs []string // each is an nft expression like "1.2.3.4" or "10.0.0.0/24" or "{ 1.2.3.4, 5.6.7.8 }"
SrcIfaces []string // empty = any
DstIfaces []string // empty = any
SrcAddrs []string // each is an nft expression like "1.2.3.4" or "10.0.0.0/24" or "{ 1.2.3.4, 5.6.7.8 }"
DstAddrs []string
Services []ResolvedService // empty = any
Comment string
@@ -192,16 +192,16 @@ type ResolvedRule struct {
// ResolvedNATRule is one nat-rule joined with iface-sets.
type ResolvedNATRule struct {
ID int64
Kind string // dnat | snat | masquerade
Priority int
InIfaces []string
OutIfaces []string
Proto string // empty = any
SrcCIDR string
DstCIDR string
DPortStart, DPortEnd int
TargetAddr string
ID int64
Kind string // dnat | snat | masquerade
Priority int
InIfaces []string
OutIfaces []string
Proto string // empty = any
SrcCIDR string
DstCIDR string
DPortStart, DPortEnd int
TargetAddr string
TargetPortStart, TargetPortEnd int
// L3 ist "ip" oder "ip6" — Adressfamilie der Regel (aus SrcCIDR/
// DstCIDR/TargetAddr abgeleitet). TargetHost ist TargetAddr, bei
@@ -209,14 +209,14 @@ type ResolvedNATRule struct {
// nft-dnat-Syntax.
L3 string
TargetHost string
Comment string
Comment string
}
// ResolvedService is one nft (proto, dport-spec) tuple.
type ResolvedService struct {
Proto string // tcp|udp|icmp|icmpv6
PortStart int // 0 = no port match
PortEnd int
Proto string // tcp|udp|icmp|icmpv6
PortStart int // 0 = no port match
PortEnd int
}
func (g *Generator) loadView(ctx context.Context) (*View, error) {
@@ -269,6 +269,31 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
}
peerRows.Close()
// ── Heartbeat-IPs aus cluster_settings ins Peer-Set ──
// Der VRRP-Heartbeat (VI_HB) läuft über hb_src_ip/hb_peer_ip (z.B.
// 169.254.0.1/.2) — diese stehen NICHT in ha_nodes. Ohne sie würde die
// VRRP-Accept-Regel den Heartbeat-Pfad nicht abdecken. Best-effort:
// fehlt cluster_settings (Single-Node), bleibt es bei den ha_nodes-IPs.
var hbSrc, hbPeer *string
if err := g.Pool.QueryRow(ctx,
`SELECT hb_src_ip, hb_peer_ip FROM cluster_settings WHERE id = 1`).
Scan(&hbSrc, &hbPeer); err == nil {
for _, ip := range []*string{hbSrc, hbPeer} {
if ip == nil {
continue
}
parsed := net.ParseIP(*ip)
if parsed == nil {
continue
}
if parsed.To4() != nil {
view.PeerIPv4 = append(view.PeerIPv4, parsed.String())
} else {
view.PeerIPv6 = append(view.PeerIPv6, parsed.String())
}
}
}
// ── Lade Address-Objects + Groups → ID → ResolvedAddr-list ──
addrObjs, err := g.loadAddrObjects(ctx)
if err != nil {
@@ -777,15 +802,15 @@ ORDER BY priority DESC, id ASC`)
out := []ResolvedRule{}
for rows.Next() {
var (
id int64
name, action, com string
pr int
log bool
srcZone, dstZone string
srcObjID, srcGrpID *int64
dstObjID, dstGrpID *int64
srcCIDR, dstCIDR *string
svcObjID, svcGrpID *int64
id int64
name, action, com string
pr int
log bool
srcZone, dstZone string
srcObjID, srcGrpID *int64
dstObjID, dstGrpID *int64
srcCIDR, dstCIDR *string
svcObjID, svcGrpID *int64
)
if err := rows.Scan(
&id, &name, &pr, &action, &log, &com,
@@ -842,12 +867,12 @@ ORDER BY priority DESC, id ASC`)
out := []ResolvedNATRule{}
for rows.Next() {
var (
id int64
pr int
kind, com string
id int64
pr int
kind, com string
inZone, outZone, proto, srcCIDR, dstCIDR *string
dpStart, dpEnd, tpStart, tpEnd int
targetAddr string
dpStart, dpEnd, tpStart, tpEnd int
targetAddr string
)
if err := rows.Scan(
&id, &pr, &kind, &com,
@@ -861,7 +886,7 @@ ORDER BY priority DESC, id ASC`)
r := ResolvedNATRule{
ID: id, Kind: kind, Priority: pr, Comment: com,
DPortStart: dpStart, DPortEnd: dpEnd,
TargetAddr: targetAddr,
TargetAddr: targetAddr,
TargetPortStart: tpStart, TargetPortEnd: tpEnd,
}
if proto != nil {

View File

@@ -55,6 +55,13 @@ table inet edgeguard {
tcp dport 5432 ip6 saddr @peer_ipv6 accept
tcp dport 6379 ip saddr @peer_ipv4 accept
tcp dport 6379 ip6 saddr @peer_ipv6 accept
# Cluster-internal: VRRP-Advertisements (keepalived VIP-Failover, Proto 112).
# OHNE diese Regel überleben Adverts nur via conntrack-Reverse-Matching —
# läuft ein conntrack-Eintrag ab/wird geflusht, werden Adverts gedroppt →
# der Peer promotet sich → VIP-Flapping/Split-Brain. peer_ipv4/6 enthält
# Public- UND Heartbeat-IPs (ha_nodes + cluster_settings.hb_*).
ip protocol vrrp ip saddr @peer_ipv4 accept
ip6 nexthdr vrrp ip6 saddr @peer_ipv6 accept
# ── Service-Auto-Rules (DNS/Squid/WG/...) ──
# Aus dem laufenden Service-State abgeleitet — Operator