diff --git a/VERSION b/VERSION index 407695f..b54183a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.100 \ No newline at end of file +1.2.101 \ No newline at end of file diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index 6c3f7f3..000764e 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -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 { diff --git a/internal/firewall/ruleset.nft.tpl b/internal/firewall/ruleset.nft.tpl index 2104bad..a1acac0 100644 --- a/internal/firewall/ruleset.nft.tpl +++ b/internal/firewall/ruleset.nft.tpl @@ -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 diff --git a/internal/keepalived/keepalived.conf.tpl b/internal/keepalived/keepalived.conf.tpl index 9bcee4c..98e6c1b 100644 --- a/internal/keepalived/keepalived.conf.tpl +++ b/internal/keepalived/keepalived.conf.tpl @@ -16,7 +16,7 @@ vrrp_script chk_gateway { script "/usr/lib/edgeguard/keepalived-gw-check.sh {{ .GWCheckIP }}" interval 5 weight -110 - fall 2 + fall 5 rise 2 } {{ end }} @@ -34,6 +34,7 @@ vrrp_instance VI_1 { virtual_router_id {{ .RouterID }} priority {{ .Priority }} advert_int 1 + nopreempt {{ if .SrcIP }} unicast_src_ip {{ .SrcIP }} unicast_peer { {{ .PeerIP }} @@ -60,6 +61,7 @@ vrrp_instance VI_HB { virtual_router_id {{ .HBRouterID }} priority {{ .Priority }} advert_int 1 + nopreempt {{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }} unicast_peer { {{ .HBPeerIP }} diff --git a/internal/keepalived/keepalived.go b/internal/keepalived/keepalived.go index 1d3ccce..6b49ac8 100644 --- a/internal/keepalived/keepalived.go +++ b/internal/keepalived/keepalived.go @@ -167,17 +167,19 @@ func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local v.HBRouterID = 52 } - // pg_role=standby ist das härtere Signal — ein Standby-Node ist niemals - // MASTER, auch wenn role='primary' noch aus dem Join-Prozess stammt. - // Reihenfolge: standby → BACKUP; sonst primary-Check. + // State IMMER BACKUP: das Template setzt `nopreempt`, und nopreempt wirkt + // in keepalived NUR, wenn die Instanz im BACKUP-Zustand startet (bei state + // 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" { - v.State = "BACKUP" v.Priority = 100 } else if local.PGRole == "primary" || local.Role == "primary" { - v.State = "MASTER" v.Priority = 200 } else { - v.State = "BACKUP" v.Priority = 100 } diff --git a/internal/keepalived/keepalived_test.go b/internal/keepalived/keepalived_test.go new file mode 100644 index 0000000..abb6ea1 --- /dev/null +++ b/internal/keepalived/keepalived_test.go @@ -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) + } + } +} diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index 7f317be..405a647 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -845,6 +845,25 @@ EOSQL # atomic-write (tempfile → rename) durchführen kann. install -d -m 0755 /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 if [ -f /etc/keepalived/keepalived.conf ]; then systemctl enable keepalived >/dev/null 2>&1 || true