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.
|
// template just emits one nft line per "leg" of the cross-product.
|
||||||
type ResolvedRule struct {
|
type ResolvedRule struct {
|
||||||
ID int64
|
ID int64
|
||||||
Action string // accept | drop | reject
|
Action string // accept | drop | reject
|
||||||
Log bool
|
Log bool
|
||||||
Name string
|
Name string
|
||||||
Priority int
|
Priority int
|
||||||
|
|
||||||
SrcIfaces []string // empty = any
|
SrcIfaces []string // empty = any
|
||||||
DstIfaces []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 }"
|
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
|
DstAddrs []string
|
||||||
Services []ResolvedService // empty = any
|
Services []ResolvedService // empty = any
|
||||||
Comment string
|
Comment string
|
||||||
@@ -192,16 +192,16 @@ type ResolvedRule struct {
|
|||||||
|
|
||||||
// ResolvedNATRule is one nat-rule joined with iface-sets.
|
// ResolvedNATRule is one nat-rule joined with iface-sets.
|
||||||
type ResolvedNATRule struct {
|
type ResolvedNATRule struct {
|
||||||
ID int64
|
ID int64
|
||||||
Kind string // dnat | snat | masquerade
|
Kind string // dnat | snat | masquerade
|
||||||
Priority int
|
Priority int
|
||||||
InIfaces []string
|
InIfaces []string
|
||||||
OutIfaces []string
|
OutIfaces []string
|
||||||
Proto string // empty = any
|
Proto string // empty = any
|
||||||
SrcCIDR string
|
SrcCIDR string
|
||||||
DstCIDR string
|
DstCIDR string
|
||||||
DPortStart, DPortEnd int
|
DPortStart, DPortEnd int
|
||||||
TargetAddr string
|
TargetAddr string
|
||||||
TargetPortStart, TargetPortEnd int
|
TargetPortStart, TargetPortEnd int
|
||||||
// L3 ist "ip" oder "ip6" — Adressfamilie der Regel (aus SrcCIDR/
|
// L3 ist "ip" oder "ip6" — Adressfamilie der Regel (aus SrcCIDR/
|
||||||
// DstCIDR/TargetAddr abgeleitet). TargetHost ist TargetAddr, bei
|
// DstCIDR/TargetAddr abgeleitet). TargetHost ist TargetAddr, bei
|
||||||
@@ -209,14 +209,14 @@ type ResolvedNATRule struct {
|
|||||||
// nft-dnat-Syntax.
|
// nft-dnat-Syntax.
|
||||||
L3 string
|
L3 string
|
||||||
TargetHost string
|
TargetHost string
|
||||||
Comment string
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolvedService is one nft (proto, dport-spec) tuple.
|
// ResolvedService is one nft (proto, dport-spec) tuple.
|
||||||
type ResolvedService struct {
|
type ResolvedService struct {
|
||||||
Proto string // tcp|udp|icmp|icmpv6
|
Proto string // tcp|udp|icmp|icmpv6
|
||||||
PortStart int // 0 = no port match
|
PortStart int // 0 = no port match
|
||||||
PortEnd int
|
PortEnd int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||||
@@ -269,6 +269,31 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
|||||||
}
|
}
|
||||||
peerRows.Close()
|
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 ──
|
// ── Lade Address-Objects + Groups → ID → ResolvedAddr-list ──
|
||||||
addrObjs, err := g.loadAddrObjects(ctx)
|
addrObjs, err := g.loadAddrObjects(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -777,15 +802,15 @@ ORDER BY priority DESC, id ASC`)
|
|||||||
out := []ResolvedRule{}
|
out := []ResolvedRule{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
id int64
|
id int64
|
||||||
name, action, com string
|
name, action, com string
|
||||||
pr int
|
pr int
|
||||||
log bool
|
log bool
|
||||||
srcZone, dstZone string
|
srcZone, dstZone string
|
||||||
srcObjID, srcGrpID *int64
|
srcObjID, srcGrpID *int64
|
||||||
dstObjID, dstGrpID *int64
|
dstObjID, dstGrpID *int64
|
||||||
srcCIDR, dstCIDR *string
|
srcCIDR, dstCIDR *string
|
||||||
svcObjID, svcGrpID *int64
|
svcObjID, svcGrpID *int64
|
||||||
)
|
)
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&id, &name, &pr, &action, &log, &com,
|
&id, &name, &pr, &action, &log, &com,
|
||||||
@@ -842,12 +867,12 @@ ORDER BY priority DESC, id ASC`)
|
|||||||
out := []ResolvedNATRule{}
|
out := []ResolvedNATRule{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
id int64
|
id int64
|
||||||
pr int
|
pr int
|
||||||
kind, com string
|
kind, com string
|
||||||
inZone, outZone, proto, srcCIDR, dstCIDR *string
|
inZone, outZone, proto, srcCIDR, dstCIDR *string
|
||||||
dpStart, dpEnd, tpStart, tpEnd int
|
dpStart, dpEnd, tpStart, tpEnd int
|
||||||
targetAddr string
|
targetAddr string
|
||||||
)
|
)
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&id, &pr, &kind, &com,
|
&id, &pr, &kind, &com,
|
||||||
@@ -861,7 +886,7 @@ ORDER BY priority DESC, id ASC`)
|
|||||||
r := ResolvedNATRule{
|
r := ResolvedNATRule{
|
||||||
ID: id, Kind: kind, Priority: pr, Comment: com,
|
ID: id, Kind: kind, Priority: pr, Comment: com,
|
||||||
DPortStart: dpStart, DPortEnd: dpEnd,
|
DPortStart: dpStart, DPortEnd: dpEnd,
|
||||||
TargetAddr: targetAddr,
|
TargetAddr: targetAddr,
|
||||||
TargetPortStart: tpStart, TargetPortEnd: tpEnd,
|
TargetPortStart: tpStart, TargetPortEnd: tpEnd,
|
||||||
}
|
}
|
||||||
if proto != nil {
|
if proto != nil {
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ table inet edgeguard {
|
|||||||
tcp dport 5432 ip6 saddr @peer_ipv6 accept
|
tcp dport 5432 ip6 saddr @peer_ipv6 accept
|
||||||
tcp dport 6379 ip saddr @peer_ipv4 accept
|
tcp dport 6379 ip saddr @peer_ipv4 accept
|
||||||
tcp dport 6379 ip6 saddr @peer_ipv6 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/...) ──
|
# ── Service-Auto-Rules (DNS/Squid/WG/...) ──
|
||||||
# Aus dem laufenden Service-State abgeleitet — Operator
|
# Aus dem laufenden Service-State abgeleitet — Operator
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ vrrp_script chk_gateway {
|
|||||||
script "/usr/lib/edgeguard/keepalived-gw-check.sh {{ .GWCheckIP }}"
|
script "/usr/lib/edgeguard/keepalived-gw-check.sh {{ .GWCheckIP }}"
|
||||||
interval 5
|
interval 5
|
||||||
weight -110
|
weight -110
|
||||||
fall 2
|
fall 5
|
||||||
rise 2
|
rise 2
|
||||||
}
|
}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -34,6 +34,7 @@ vrrp_instance VI_1 {
|
|||||||
virtual_router_id {{ .RouterID }}
|
virtual_router_id {{ .RouterID }}
|
||||||
priority {{ .Priority }}
|
priority {{ .Priority }}
|
||||||
advert_int 1
|
advert_int 1
|
||||||
|
nopreempt
|
||||||
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
|
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
|
||||||
unicast_peer {
|
unicast_peer {
|
||||||
{{ .PeerIP }}
|
{{ .PeerIP }}
|
||||||
@@ -60,6 +61,7 @@ vrrp_instance VI_HB {
|
|||||||
virtual_router_id {{ .HBRouterID }}
|
virtual_router_id {{ .HBRouterID }}
|
||||||
priority {{ .Priority }}
|
priority {{ .Priority }}
|
||||||
advert_int 1
|
advert_int 1
|
||||||
|
nopreempt
|
||||||
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
|
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
|
||||||
unicast_peer {
|
unicast_peer {
|
||||||
{{ .HBPeerIP }}
|
{{ .HBPeerIP }}
|
||||||
|
|||||||
@@ -167,17 +167,19 @@ func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local
|
|||||||
v.HBRouterID = 52
|
v.HBRouterID = 52
|
||||||
}
|
}
|
||||||
|
|
||||||
// pg_role=standby ist das härtere Signal — ein Standby-Node ist niemals
|
// State IMMER BACKUP: das Template setzt `nopreempt`, und nopreempt wirkt
|
||||||
// MASTER, auch wenn role='primary' noch aus dem Join-Prozess stammt.
|
// in keepalived NUR, wenn die Instanz im BACKUP-Zustand startet (bei state
|
||||||
// Reihenfolge: standby → BACKUP; sonst primary-Check.
|
// MASTER wird nopreempt ignoriert). Die Priorität entscheidet weiterhin die
|
||||||
|
// Initial-Election (primary=200 gewinnt), aber ein erholter Node reißt die
|
||||||
|
// VIP NICHT mehr zurück → kein Flap-Back / Split-Brain. Deckt sich mit der
|
||||||
|
// "kein Auto-Promote"-Philosophie: Promotion bleibt manuell.
|
||||||
|
// pg_role=standby ist das härtere Signal (Standby ist nie bevorzugter Node).
|
||||||
|
v.State = "BACKUP"
|
||||||
if local.PGRole == "standby" {
|
if local.PGRole == "standby" {
|
||||||
v.State = "BACKUP"
|
|
||||||
v.Priority = 100
|
v.Priority = 100
|
||||||
} else if local.PGRole == "primary" || local.Role == "primary" {
|
} else if local.PGRole == "primary" || local.Role == "primary" {
|
||||||
v.State = "MASTER"
|
|
||||||
v.Priority = 200
|
v.Priority = 200
|
||||||
} else {
|
} else {
|
||||||
v.State = "BACKUP"
|
|
||||||
v.Priority = 100
|
v.Priority = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
79
internal/keepalived/keepalived_test.go
Normal file
79
internal/keepalived/keepalived_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package keepalived
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func render(t *testing.T, v View) string {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tpl.Execute(&buf, v); err != nil {
|
||||||
|
t.Fatalf("template execute: %v", err)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split-Brain-Schutz: jede vrrp_instance MUSS `nopreempt` tragen, sonst reißt
|
||||||
|
// ein erholter Node die VIP zurück → Flapping. nopreempt wirkt nur bei state
|
||||||
|
// BACKUP — also muss auch der bevorzugte Node BACKUP starten.
|
||||||
|
func testView() View {
|
||||||
|
return View{
|
||||||
|
State: "BACKUP", Interface: "eth0", RouterID: 51, Priority: 200,
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateNopreemptOnBothInstances(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 strings.Contains(out, "state MASTER") {
|
||||||
|
t.Fatalf("kein Node darf state MASTER starten (nopreempt würde ignoriert):\n%s", out)
|
||||||
|
}
|
||||||
|
if c := strings.Count(out, "state BACKUP"); c != 2 {
|
||||||
|
t.Fatalf("erwarte state BACKUP in beiden Instanzen, gefunden: %d", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gw-Check darf nicht zu zucken (fall 5, nicht fall 2) — ein kurzer Upstream-
|
||||||
|
// Blip soll keinen Failover erzwingen.
|
||||||
|
func TestTemplateGatewayCheckNotTwitchy(t *testing.T) {
|
||||||
|
out := render(t, testView())
|
||||||
|
if !strings.Contains(out, "fall 5") {
|
||||||
|
t.Fatalf("chk_gateway sollte fall 5 nutzen:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildView: State immer BACKUP, Priorität aus pg_role.
|
||||||
|
func TestBuildViewStateAlwaysBackup(t *testing.T) {
|
||||||
|
g := &generator{localID: "n1"}
|
||||||
|
cs := &models.ClusterSettings{VRRPRouterID: 51}
|
||||||
|
pub := "89.163.205.6"
|
||||||
|
cases := []struct {
|
||||||
|
pgRole, role string
|
||||||
|
wantPrio int
|
||||||
|
}{
|
||||||
|
{"primary", "primary", 200},
|
||||||
|
{"standby", "primary", 100},
|
||||||
|
{"", "primary", 200},
|
||||||
|
{"", "", 100},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
local := &models.HANode{ID: "n1", PGRole: c.pgRole, Role: c.role, PublicIP: &pub}
|
||||||
|
v := g.buildView(cs, nil, local, nil)
|
||||||
|
if v.State != "BACKUP" {
|
||||||
|
t.Errorf("pg_role=%q role=%q: State=%q, erwarte immer BACKUP (nopreempt)", c.pgRole, c.role, v.State)
|
||||||
|
}
|
||||||
|
if v.Priority != c.wantPrio {
|
||||||
|
t.Errorf("pg_role=%q role=%q: Priority=%d, erwarte %d", c.pgRole, c.role, v.Priority, c.wantPrio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -845,6 +845,25 @@ EOSQL
|
|||||||
# atomic-write (tempfile → rename) durchführen kann.
|
# atomic-write (tempfile → rename) durchführen kann.
|
||||||
install -d -m 0755 /etc/keepalived
|
install -d -m 0755 /etc/keepalived
|
||||||
chown "$EG_USER":"$EG_USER" /etc/keepalived
|
chown "$EG_USER":"$EG_USER" /etc/keepalived
|
||||||
|
# ── keepalived systemd drop-in: Boot-Race-Fix ──────────────────
|
||||||
|
# keepalived referenziert VIP-Devices (vlanXXX), die erst von
|
||||||
|
# edgeguard-interfaces.service angelegt werden. Ohne Ordering startet
|
||||||
|
# keepalived vor den VLANs (beide nur After=network-online.target) →
|
||||||
|
# "interface vlanX doesn't exist" → permanenter CONFIG-Crash OHNE
|
||||||
|
# Auto-Recovery (keepalived bleibt nach Reboot tot). After=/Wants=
|
||||||
|
# wartet auf die Interfaces; Restart=on-failure ist das Sicherheitsnetz.
|
||||||
|
install -d /etc/systemd/system/keepalived.service.d
|
||||||
|
cat > /etc/systemd/system/keepalived.service.d/10-edgeguard.conf <<'KEEPALIVEDDROPIN'
|
||||||
|
[Unit]
|
||||||
|
After=edgeguard-interfaces.service
|
||||||
|
Wants=edgeguard-interfaces.service
|
||||||
|
StartLimitIntervalSec=0
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
KEEPALIVEDDROPIN
|
||||||
|
systemctl daemon-reload
|
||||||
sudo -n -u "$EG_USER" /usr/bin/edgeguard-ctl render-config --only=keepalived || true
|
sudo -n -u "$EG_USER" /usr/bin/edgeguard-ctl render-config --only=keepalived || true
|
||||||
if [ -f /etc/keepalived/keepalived.conf ]; then
|
if [ -f /etc/keepalived/keepalived.conf ]; then
|
||||||
systemctl enable keepalived >/dev/null 2>&1 || true
|
systemctl enable keepalived >/dev/null 2>&1 || true
|
||||||
|
|||||||
Reference in New Issue
Block a user