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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user