Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9383b870b0 | ||
|
|
4416d361a0 | ||
|
|
58e42eb269 | ||
|
|
90f0df4c45 |
@@ -71,33 +71,16 @@ var hashSpec = []hashTable{
|
||||
|
||||
{Name: "ntp_pools", MigrationDefault: true},
|
||||
|
||||
// network_interfaces + ip_addresses werden seit 0030 repliziert —
|
||||
// VLAN/Bridge/Bond-Definitionen und Gateway-IPs müssen auf dem Secondary
|
||||
// für Failover bereitstehen. Ethernet-IPs werden im Secondary-Renderer
|
||||
// herausgefiltert (eth0 = cloud-init / Keepalived).
|
||||
// network_interfaces + ip_addresses sind BEWUSST NICHT im Drift-Hash.
|
||||
// Sie stehen in cluster_replication.go localOnlyTables, werden also NICHT
|
||||
// repliziert und sind per Design node-spezifisch (jede Node hat eigene
|
||||
// Mgmt-/Host-IPs, z.B. utm-1=.6, utm-2=.8). Würde man sie hashen, wäre
|
||||
// der config_hash zwischen zwei Nodes ZWANGSLÄUFIG dauerhaft verschieden
|
||||
// → Drift-Banner, das kein Resync je beheben kann (Resync kopiert nur
|
||||
// replizierte Tabellen). Migration 0030 wollte sie zwar replizieren,
|
||||
// localOnlyTables schließt sie aber weiter aus → wir hashen sie nicht.
|
||||
//
|
||||
// ip_addresses.interface_id ist ein node-lokaler Autoincrement-PK, der
|
||||
// zwischen zwei unabhängigen DBs divergiert (utm-1: eth0=6, utm-2: eth0=1).
|
||||
// Wir hashen daher semantisch: address + prefix + flags + interface_name
|
||||
// statt interface_id — sonst False-Positive-Drift auf logisch identischen Nodes.
|
||||
{Name: "network_interfaces"},
|
||||
{Name: "ip_addresses", CustomSQL: `
|
||||
SELECT COALESCE(md5(string_agg(rh, '|' ORDER BY rh)), '')
|
||||
FROM (
|
||||
SELECT md5(jsonb_build_object(
|
||||
'address', ia.address,
|
||||
'prefix', ia.prefix,
|
||||
'is_vip', ia.is_vip,
|
||||
'active', ia.active,
|
||||
'vip_priority', ia.vip_priority,
|
||||
'description', ia.description,
|
||||
'iface', ni.name
|
||||
)::text) AS rh
|
||||
FROM ip_addresses ia
|
||||
JOIN network_interfaces ni ON ia.interface_id = ni.id
|
||||
) sub`},
|
||||
|
||||
// static_routes, dns_settings, ntp_settings bleiben node-spezifisch.
|
||||
// static_routes, dns_settings, ntp_settings bleiben ebenfalls node-spezifisch.
|
||||
}
|
||||
|
||||
// hashSQL rendert die SHA-Input-SQL für eine Tabelle.
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- network_interfaces und ip_addresses werden in die Cluster-Replikation
|
||||
-- aufgenommen. Das ALTER PUBLICATION erfordert den Superuser (postgres),
|
||||
-- daher läuft es im postinst via `sudo -u postgres psql`, nicht hier.
|
||||
-- Diese Migration dient nur als Versions-Marker für goose.
|
||||
-- HINWEIS (korrigiert v1.2.89): Diese Migration war urspr. dafür gedacht,
|
||||
-- network_interfaces und ip_addresses in die Cluster-Replikation aufzunehmen.
|
||||
-- Das wurde NICHT umgesetzt und ist auch NICHT gewollt: beide Tabellen sind
|
||||
-- node-spezifisch (jede Node hat eigene Mgmt-/Host-IPs) und stehen weiterhin
|
||||
-- in cluster_replication.go localOnlyTables → sie werden bewusst NICHT
|
||||
-- repliziert. Sie sind auch aus dem Drift-Hash (confighash.go) entfernt,
|
||||
-- da sie sonst dauerhaften False-Positive-Drift erzeugen.
|
||||
-- Diese Migration ist ein No-op / reiner Versions-Marker für goose.
|
||||
SELECT 1;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -138,6 +139,7 @@ type View struct {
|
||||
type WGSiteMasqEntry struct {
|
||||
Iface string // wg interface name, e.g. "wg7"
|
||||
VPNNet string // network CIDR of the VPN subnet, e.g. "192.168.99.0/24"
|
||||
L3 string // "ip" oder "ip6" — Familie von VPNNet
|
||||
}
|
||||
|
||||
// AutoFWRule is one auto-emitted inbound rule. Proto is "tcp" or
|
||||
@@ -162,7 +164,11 @@ type RuleLeg struct {
|
||||
DstIfaces []string
|
||||
SrcAddrs []string
|
||||
DstAddrs []string
|
||||
Service ResolvedService // Proto="" → no service match (any)
|
||||
// L3 ist "ip" (IPv4) oder "ip6" (IPv6) für das Adress-Matching —
|
||||
// gesetzt, sobald SrcAddrs/DstAddrs nicht leer sind. Bei adresslosen
|
||||
// Regeln bleibt es "" (familienagnostisch, kein ip/ip6-Match).
|
||||
L3 string
|
||||
Service ResolvedService // Proto="" → no service match (any)
|
||||
}
|
||||
|
||||
// ResolvedRule has all addresses + services already expanded so the
|
||||
@@ -195,6 +201,12 @@ type ResolvedNATRule struct {
|
||||
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
|
||||
// IPv6 MIT Port in eckigen Klammern ("[2001:db8::1]") für korrekte
|
||||
// nft-dnat-Syntax.
|
||||
L3 string
|
||||
TargetHost string
|
||||
Comment string
|
||||
}
|
||||
|
||||
@@ -280,26 +292,15 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Expand to one Leg per (rule × service); rules without a service
|
||||
// produce one leg with empty Proto.
|
||||
// Expand to one Leg per (rule × service × address-family). Rules
|
||||
// without a service produce one leg-set with empty Proto.
|
||||
for _, r := range rules {
|
||||
if len(r.Services) == 0 {
|
||||
view.Legs = append(view.Legs, RuleLeg{
|
||||
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name,
|
||||
Comment: r.Comment,
|
||||
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
|
||||
SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs,
|
||||
})
|
||||
view.Legs = append(view.Legs, expandFamilyLegs(r, ResolvedService{}, false)...)
|
||||
continue
|
||||
}
|
||||
for _, svc := range r.Services {
|
||||
view.Legs = append(view.Legs, RuleLeg{
|
||||
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name,
|
||||
Comment: r.Comment,
|
||||
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
|
||||
SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs,
|
||||
Service: svc,
|
||||
})
|
||||
view.Legs = append(view.Legs, expandFamilyLegs(r, svc, true)...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,9 +324,14 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
if wgRows.Scan(&name, &cidr) == nil {
|
||||
view.WGServerIfaces = append(view.WGServerIfaces, name)
|
||||
if _, ipNet, err := net.ParseCIDR(cidr); err == nil {
|
||||
l3 := addrFamily(ipNet.String())
|
||||
if l3 == "" {
|
||||
l3 = "ip"
|
||||
}
|
||||
view.WGSiteMasq = append(view.WGSiteMasq, WGSiteMasqEntry{
|
||||
Iface: name,
|
||||
VPNNet: ipNet.String(),
|
||||
L3: l3,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -435,6 +441,131 @@ func isLoopback(ip string) bool {
|
||||
return strings.HasPrefix(ip, "127.")
|
||||
}
|
||||
|
||||
// addrFamily klassifiziert einen nft-Adressausdruck (host, CIDR oder
|
||||
// range "a-b") als "ip" (IPv4), "ip6" (IPv6) oder "" (unbestimmt, z.B.
|
||||
// FQDN-Platzhalter). Adressen enthalten selbst kein '-', daher trennt der
|
||||
// erste Bindestrich sicher eine Range in ihr erstes Element.
|
||||
func addrFamily(expr string) string {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexByte(expr, '-'); i > 0 {
|
||||
expr = strings.TrimSpace(expr[:i])
|
||||
}
|
||||
if i := strings.IndexByte(expr, '/'); i > 0 {
|
||||
expr = expr[:i]
|
||||
}
|
||||
ip := net.ParseIP(expr)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
if ip.To4() != nil {
|
||||
return "ip"
|
||||
}
|
||||
return "ip6"
|
||||
}
|
||||
|
||||
// splitByFamily teilt eine Liste von nft-Adressausdrücken in v4 und v6.
|
||||
// Unbestimmte (FQDN o.ä.) werden verworfen.
|
||||
func splitByFamily(exprs []string) (v4, v6 []string) {
|
||||
for _, e := range exprs {
|
||||
switch addrFamily(e) {
|
||||
case "ip":
|
||||
v4 = append(v4, e)
|
||||
case "ip6":
|
||||
v6 = append(v6, e)
|
||||
}
|
||||
}
|
||||
return v4, v6
|
||||
}
|
||||
|
||||
// serviceL3: icmp ist v4-only, icmpv6 v6-only, tcp/udp/leer agnostisch.
|
||||
func serviceL3(svc ResolvedService) string {
|
||||
switch svc.Proto {
|
||||
case "icmp":
|
||||
return "ip"
|
||||
case "icmpv6":
|
||||
return "ip6"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// natFamily ermittelt die Adressfamilie einer NAT-Regel aus ihren
|
||||
// Adressen. ok=false bei gemischten v4/v6-Adressen (ungültig → die Regel
|
||||
// muss übersprungen werden, sonst bricht `nft -f` das gesamte Ruleset).
|
||||
func natFamily(r ResolvedNATRule) (fam string, ok bool) {
|
||||
for _, a := range []string{r.SrcCIDR, r.DstCIDR, r.TargetAddr} {
|
||||
f := addrFamily(a)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
if fam == "" {
|
||||
fam = f
|
||||
} else if fam != f {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
if fam == "" {
|
||||
fam = "ip" // keine Adressen (reine iface/proto-Regel) → v4-Default
|
||||
}
|
||||
return fam, true
|
||||
}
|
||||
|
||||
// expandFamilyLegs materialisiert die nft-Zeilen für eine Regel + optional
|
||||
// einen Service, getrennt nach Adressfamilie. Adresslose Regeln ergeben eine
|
||||
// einzige familienagnostische Zeile (unverändertes v4-Verhalten, greift
|
||||
// zugleich für v6). Regeln mit Adressen werden pro Familie als separate
|
||||
// Zeile emittiert — ein nft-Paket ist immer entweder v4 oder v6.
|
||||
func expandFamilyLegs(r ResolvedRule, svc ResolvedService, hasSvc bool) []RuleLeg {
|
||||
base := RuleLeg{
|
||||
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name, Comment: r.Comment,
|
||||
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
|
||||
}
|
||||
if hasSvc {
|
||||
base.Service = svc
|
||||
}
|
||||
|
||||
if len(r.SrcAddrs) == 0 && len(r.DstAddrs) == 0 {
|
||||
// Kein Adress-Match → eine Zeile, L3 leer. Die Proto-Render-Logik
|
||||
// im Template setzt icmp/icmpv6 selbst familienkorrekt.
|
||||
return []RuleLeg{base}
|
||||
}
|
||||
|
||||
src4, src6 := splitByFamily(r.SrcAddrs)
|
||||
dst4, dst6 := splitByFamily(r.DstAddrs)
|
||||
svcFam := ""
|
||||
if hasSvc {
|
||||
svcFam = serviceL3(svc)
|
||||
}
|
||||
|
||||
var legs []RuleLeg
|
||||
for _, fam := range []string{"ip", "ip6"} {
|
||||
if svcFam != "" && svcFam != fam {
|
||||
continue // icmp nur auf v4, icmpv6 nur auf v6
|
||||
}
|
||||
srcF, dstF := src4, dst4
|
||||
if fam == "ip6" {
|
||||
srcF, dstF = src6, dst6
|
||||
}
|
||||
// Eine eingeschränkte Seite ohne Mitglied dieser Familie → die
|
||||
// Zeile würde nichts (oder Falsches) matchen → überspringen.
|
||||
if len(r.SrcAddrs) > 0 && len(srcF) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(r.DstAddrs) > 0 && len(dstF) == 0 {
|
||||
continue
|
||||
}
|
||||
leg := base
|
||||
leg.L3 = fam
|
||||
leg.SrcAddrs = srcF
|
||||
leg.DstAddrs = dstF
|
||||
legs = append(legs, leg)
|
||||
}
|
||||
return legs
|
||||
}
|
||||
|
||||
// addrObjMap is keyed by id; value is the nft expression for that
|
||||
// object (e.g. "1.2.3.4", "10.0.0.0/24", "1.2.3.4-1.2.3.10").
|
||||
type addrObjMap map[int64]string
|
||||
@@ -693,6 +824,19 @@ ORDER BY priority DESC, id ASC`)
|
||||
if outZone != nil {
|
||||
r.OutIfaces = zoneIfaces[*outZone]
|
||||
}
|
||||
fam, ok := natFamily(r)
|
||||
if !ok {
|
||||
// Gemischte v4/v6-Adressen → ungültige NAT-Regel. Überspringen
|
||||
// statt das gesamte Ruleset mit `nft -f` zu brechen.
|
||||
slog.Warn("firewall: NAT-Regel mit gemischten v4/v6-Adressen übersprungen", "id", r.ID)
|
||||
continue
|
||||
}
|
||||
r.L3 = fam
|
||||
r.TargetHost = r.TargetAddr
|
||||
if fam == "ip6" && r.TargetAddr != "" && r.TargetPortStart > 0 {
|
||||
// nft braucht [v6]:port für dnat-Targets mit Port.
|
||||
r.TargetHost = "[" + r.TargetAddr + "]"
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
|
||||
170
internal/firewall/firewall_ipv6_test.go
Normal file
170
internal/firewall/firewall_ipv6_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAddrFamily(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"1.2.3.4": "ip",
|
||||
"10.0.0.0/24": "ip",
|
||||
"1.2.3.4-1.2.3.10": "ip",
|
||||
"2001:db8::1": "ip6",
|
||||
"fd00::/64": "ip6",
|
||||
"2001:db8::1-2001:db8::5": "ip6",
|
||||
"example.com": "",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := addrFamily(in); got != want {
|
||||
t.Errorf("addrFamily(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandFamilyLegs_splitsByFamily(t *testing.T) {
|
||||
r := ResolvedRule{
|
||||
ID: 1, Action: "accept",
|
||||
SrcAddrs: []string{"10.0.0.0/24", "fd00::/64"},
|
||||
DstAddrs: []string{"1.2.3.4", "2001:db8::1"},
|
||||
}
|
||||
legs := expandFamilyLegs(r, ResolvedService{}, false)
|
||||
if len(legs) != 2 {
|
||||
t.Fatalf("want 2 legs (v4+v6), got %d", len(legs))
|
||||
}
|
||||
var v4, v6 *RuleLeg
|
||||
for i := range legs {
|
||||
switch legs[i].L3 {
|
||||
case "ip":
|
||||
v4 = &legs[i]
|
||||
case "ip6":
|
||||
v6 = &legs[i]
|
||||
}
|
||||
}
|
||||
if v4 == nil || v6 == nil {
|
||||
t.Fatalf("missing family leg: %+v", legs)
|
||||
}
|
||||
if len(v4.SrcAddrs) != 1 || v4.SrcAddrs[0] != "10.0.0.0/24" || v4.DstAddrs[0] != "1.2.3.4" {
|
||||
t.Errorf("v4 leg wrong: src=%v dst=%v", v4.SrcAddrs, v4.DstAddrs)
|
||||
}
|
||||
if len(v6.SrcAddrs) != 1 || v6.SrcAddrs[0] != "fd00::/64" || v6.DstAddrs[0] != "2001:db8::1" {
|
||||
t.Errorf("v6 leg wrong: src=%v dst=%v", v6.SrcAddrs, v6.DstAddrs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandFamilyLegs_addresslessIsAgnostic(t *testing.T) {
|
||||
legs := expandFamilyLegs(ResolvedRule{ID: 2, Action: "accept"}, ResolvedService{}, false)
|
||||
if len(legs) != 1 || legs[0].L3 != "" {
|
||||
t.Fatalf("addressless rule must be a single agnostic leg, got %d legs L3=%q", len(legs), legs[0].L3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandFamilyLegs_oneFamilyOnly(t *testing.T) {
|
||||
// src nur v4, dst nur v4 → genau eine v4-Zeile (kein leerer v6-Leg).
|
||||
r := ResolvedRule{ID: 3, Action: "drop", SrcAddrs: []string{"10.0.0.0/8"}}
|
||||
legs := expandFamilyLegs(r, ResolvedService{}, false)
|
||||
if len(legs) != 1 || legs[0].L3 != "ip" {
|
||||
t.Fatalf("v4-only rule want 1 ip leg, got %+v", legs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandFamilyLegs_icmpFamilyMatch(t *testing.T) {
|
||||
r6 := ResolvedRule{ID: 4, Action: "accept", SrcAddrs: []string{"fd00::/64"}}
|
||||
if legs := expandFamilyLegs(r6, ResolvedService{Proto: "icmpv6"}, true); len(legs) != 1 || legs[0].L3 != "ip6" {
|
||||
t.Fatalf("icmpv6+v6 want 1 ip6 leg, got %+v", legs)
|
||||
}
|
||||
if legs := expandFamilyLegs(r6, ResolvedService{Proto: "icmp"}, true); len(legs) != 0 {
|
||||
t.Fatalf("icmp on v6-only addrs want 0 legs, got %+v", legs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNatFamily(t *testing.T) {
|
||||
if _, ok := natFamily(ResolvedNATRule{SrcCIDR: "10.0.0.0/24", TargetAddr: "2001:db8::1"}); ok {
|
||||
t.Error("mixed v4/v6 NAT must be rejected (ok=false)")
|
||||
}
|
||||
if fam, ok := natFamily(ResolvedNATRule{TargetAddr: "2001:db8::1"}); !ok || fam != "ip6" {
|
||||
t.Errorf("v6 NAT: fam=%q ok=%v want ip6/true", fam, ok)
|
||||
}
|
||||
if fam, ok := natFamily(ResolvedNATRule{SrcCIDR: "10.0.0.0/24"}); !ok || fam != "ip" {
|
||||
t.Errorf("v4 NAT: fam=%q ok=%v want ip/true", fam, ok)
|
||||
}
|
||||
if fam, ok := natFamily(ResolvedNATRule{}); !ok || fam != "ip" {
|
||||
t.Errorf("addressless NAT: fam=%q ok=%v want ip/true (v4 default)", fam, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// renderView ist ein gemischter v4/v6-View, der alle geänderten
|
||||
// Template-Zweige berührt.
|
||||
func renderView(t *testing.T) string {
|
||||
t.Helper()
|
||||
view := &View{
|
||||
PeerIPv4: []string{"10.0.0.1"},
|
||||
PeerIPv6: []string{"fd00::1"},
|
||||
Legs: []RuleLeg{
|
||||
{RuleID: 1, Action: "accept", L3: "ip", SrcAddrs: []string{"10.0.0.0/24"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}},
|
||||
{RuleID: 1, Action: "accept", L3: "ip6", SrcAddrs: []string{"fd00::/64"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}},
|
||||
{RuleID: 2, Action: "accept", Service: ResolvedService{Proto: "icmpv6"}}, // adresslos, agnostisch
|
||||
},
|
||||
NATRules: []ResolvedNATRule{
|
||||
{ID: 5, Kind: "dnat", L3: "ip6", DstCIDR: "2001:db8::/64", Proto: "tcp", DPortStart: 80, TargetAddr: "fd00::2", TargetHost: "[fd00::2]", TargetPortStart: 8080},
|
||||
{ID: 6, Kind: "snat", L3: "ip6", SrcCIDR: "fd00::/64", TargetAddr: "2001:db8::99"},
|
||||
{ID: 7, Kind: "dnat", L3: "ip", DstCIDR: "1.2.3.4", Proto: "tcp", DPortStart: 80, TargetAddr: "10.0.0.5", TargetHost: "10.0.0.5", TargetPortStart: 80},
|
||||
},
|
||||
WGSiteMasq: []WGSiteMasqEntry{{Iface: "wg7", VPNNet: "fd00:99::/64", L3: "ip6"}},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, view); err != nil {
|
||||
t.Fatalf("template execute: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestTemplate_v6AndV4Render(t *testing.T) {
|
||||
out := renderView(t)
|
||||
mustContain := []string{
|
||||
"ip saddr { 10.0.0.0/24 }", // v4-Regel unverändert
|
||||
"ip6 saddr { fd00::/64 }", // v6-Regel
|
||||
"ip6 daddr 2001:db8::/64", // v6-DNAT-Match
|
||||
"dnat to [fd00::2]:8080", // v6-DNAT-Target geklammert
|
||||
"dnat to 10.0.0.5:80", // v4-DNAT-Target unverändert
|
||||
"ip6 saddr fd00::/64 snat to 2001:db8::99",
|
||||
`oifname "wg7" ip6 saddr fd00:99::/64 masquerade`,
|
||||
}
|
||||
for _, w := range mustContain {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("output missing %q\n----\n%s", w, out)
|
||||
}
|
||||
}
|
||||
// v6-Adressen dürfen NIEMALS in einem ip-saddr/daddr-Set landen.
|
||||
if strings.Contains(out, "ip saddr { fd00") || strings.Contains(out, "ip daddr { fd00") ||
|
||||
strings.Contains(out, "ip saddr { 2001") {
|
||||
t.Errorf("v6 address leaked into IPv4 match\n----\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplate_nftSyntax validiert das gerenderte Ruleset mit `nft -c -f`
|
||||
// (Check-Modus, kein Apply). Wird übersprungen, wenn nft nicht installiert
|
||||
// ist (z.B. CI ohne nft).
|
||||
func TestTemplate_nftSyntax(t *testing.T) {
|
||||
nft, err := exec.LookPath("nft")
|
||||
if err != nil {
|
||||
t.Skip("nft binary not available — skipping syntax check")
|
||||
}
|
||||
out := renderView(t)
|
||||
f, err := os.CreateTemp(t.TempDir(), "ruleset-*.nft")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.WriteString(out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
cmd := exec.Command(nft, "-c", "-f", f.Name())
|
||||
if combined, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("nft -c -f rejected the generated ruleset: %v\n%s\n----\n%s", err, combined, out)
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ table inet edgeguard {
|
||||
die Comment-Zeile angehängt — sonst frisst nft die rule
|
||||
als Teil des # Kommentars). */ -}}
|
||||
{{""}}
|
||||
{{if .SrcIfaces}}iifname { {{join .SrcIfaces ", "}} } {{end}}{{if .DstIfaces}}oifname { {{join .DstIfaces ", "}} } {{end}}{{if .SrcAddrs}}ip saddr { {{join .SrcAddrs ", "}} } {{end}}{{if .DstAddrs}}ip daddr { {{join .DstAddrs ", "}} } {{end}}{{with .Service}}{{if and (or (eq .Proto "tcp") (eq .Proto "udp")) .PortStart}}{{.Proto}} dport {{.PortStart}}{{if and .PortEnd (ne .PortEnd .PortStart)}}-{{.PortEnd}}{{end}} {{else if eq .Proto "icmp"}}ip protocol icmp {{else if eq .Proto "icmpv6"}}ip6 nexthdr icmpv6 {{end}}{{end}}{{if .Log}}log prefix "edgeguard:{{.RuleID}} " group 0 {{end}}counter {{.Action}} comment "egid:{{.RuleID}}"
|
||||
{{if .SrcIfaces}}iifname { {{join .SrcIfaces ", "}} } {{end}}{{if .DstIfaces}}oifname { {{join .DstIfaces ", "}} } {{end}}{{if .SrcAddrs}}{{.L3}} saddr { {{join .SrcAddrs ", "}} } {{end}}{{if .DstAddrs}}{{.L3}} daddr { {{join .DstAddrs ", "}} } {{end}}{{with .Service}}{{if and (or (eq .Proto "tcp") (eq .Proto "udp")) .PortStart}}{{.Proto}} dport {{.PortStart}}{{if and .PortEnd (ne .PortEnd .PortStart)}}-{{.PortEnd}}{{end}} {{else if eq .Proto "icmp"}}ip protocol icmp {{else if eq .Proto "icmpv6"}}ip6 nexthdr icmpv6 {{end}}{{end}}{{if .Log}}log prefix "edgeguard:{{.RuleID}} " group 0 {{end}}counter {{.Action}} comment "egid:{{.RuleID}}"
|
||||
{{end}}
|
||||
|
||||
# ── DEFAULT-DROP LOGGING ───────────────────────────────────────
|
||||
@@ -101,7 +101,7 @@ table inet edgeguard {
|
||||
# nach und erlauben new-state-Pakete von dort. Return-Pakete
|
||||
# gehen via ct state established schon durch.
|
||||
{{range .NATRules}}{{if or (eq .Kind "snat") (eq .Kind "masquerade")}}{{if .SrcCIDR}}
|
||||
ip saddr {{.SrcCIDR}} ct state new accept comment "auto-forward for NAT rule {{.ID}}"
|
||||
{{.L3}} saddr {{.SrcCIDR}} ct state new accept comment "auto-forward for NAT rule {{.ID}}"
|
||||
{{end}}{{end}}{{end}}
|
||||
|
||||
# Auto-Forward für WireGuard-Server-Interfaces: Peer-to-Peer-
|
||||
@@ -128,7 +128,7 @@ table inet edgeguard {
|
||||
{{""}}
|
||||
{{/* nft-Syntax: erst L3-match (ip saddr/daddr), DANN L4 (tcp/udp dport).
|
||||
Sonst quittiert der parser '... unexpected ip' an dieser Stelle. */}}
|
||||
{{if .InIfaces}}iifname { {{join .InIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}{{if .DstCIDR}}ip daddr {{.DstCIDR}} {{end}}{{if and .Proto (ne .Proto "any")}}{{.Proto}} {{else}}meta l4proto { tcp, udp } {{end}}{{if .DPortStart}}dport {{.DPortStart}}{{if and .DPortEnd (ne .DPortEnd .DPortStart)}}-{{.DPortEnd}}{{end}} {{end}}{{if .TargetAddr}}dnat to {{.TargetAddr}}{{if .TargetPortStart}}:{{.TargetPortStart}}{{if and .TargetPortEnd (ne .TargetPortEnd .TargetPortStart)}}-{{.TargetPortEnd}}{{end}}{{end}}{{end}}
|
||||
{{if .InIfaces}}iifname { {{join .InIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}{{if .DstCIDR}}{{.L3}} daddr {{.DstCIDR}} {{end}}{{if and .Proto (ne .Proto "any")}}{{.Proto}} {{else}}meta l4proto { tcp, udp } {{end}}{{if .DPortStart}}dport {{.DPortStart}}{{if and .DPortEnd (ne .DPortEnd .DPortStart)}}-{{.DPortEnd}}{{end}} {{end}}{{if .TargetAddr}}dnat to {{.TargetHost}}{{if .TargetPortStart}}:{{.TargetPortStart}}{{if and .TargetPortEnd (ne .TargetPortEnd .TargetPortStart)}}-{{.TargetPortEnd}}{{end}}{{end}}{{end}}
|
||||
{{end}}{{end}}
|
||||
}
|
||||
|
||||
@@ -152,16 +152,16 @@ table inet edgeguard {
|
||||
# Masquerade schreibt die Source auf die lokale Tunnel-IP um; Return-Traffic
|
||||
# findet so den Weg zurück durch den Tunnel.
|
||||
{{range .WGSiteMasq}}
|
||||
oifname "{{.Iface}}" ip saddr {{.VPNNet}} masquerade comment "auto: WireGuard site-to-site masquerade {{.Iface}}"
|
||||
oifname "{{.Iface}}" {{.L3}} saddr {{.VPNNet}} masquerade comment "auto: WireGuard site-to-site masquerade {{.Iface}}"
|
||||
{{end}}
|
||||
{{range .NATRules}}{{if eq .Kind "snat"}}
|
||||
# NAT {{.ID}} (snat{{if .Comment}} — {{.Comment}}{{end}})
|
||||
{{""}}
|
||||
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}{{if .TargetAddr}}snat to {{.TargetAddr}}{{end}}
|
||||
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}{{if .TargetAddr}}snat to {{.TargetAddr}}{{end}}
|
||||
{{end}}{{if eq .Kind "masquerade"}}
|
||||
# NAT {{.ID}} (masquerade{{if .Comment}} — {{.Comment}}{{end}})
|
||||
{{""}}
|
||||
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}masquerade
|
||||
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}masquerade
|
||||
{{end}}{{end}}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -26,132 +27,146 @@ import (
|
||||
// Reparatur baut die Subscription neu auf und kopiert alle geteilten
|
||||
// Tabellen frisch vom Primary (einseitig: Primary = Source of Truth).
|
||||
//
|
||||
// Der Resync MUSS auf dem Standby/Subscriber laufen (nur der hat eine
|
||||
// Subscription). Operatoren erreichen die UI aber über die VIP, die immer
|
||||
// auf den Primary zeigt. Deshalb:
|
||||
// Rollen-Erkennung: NICHT über ha_nodes.role/pg_role — die sind je Node
|
||||
// lokal und unzuverlässig (jede Node markiert sich selbst, pg_role bleibt
|
||||
// 'standalone' bis `promote`). Verlässlich ist die PUBLICATION: nur der
|
||||
// Primary hat `edgeguard_shared` (pg_publication ist für jeden DB-User
|
||||
// lesbar). Der Subscriber hat sie nicht → er ist das Resync-Ziel.
|
||||
//
|
||||
// - Auf dem Primary geklickt → Dispatch via mTLS an den Standby
|
||||
// (POST /agent/cluster/repair-replication), der dort lokal läuft.
|
||||
// - Auf dem Standby direkt geklickt → läuft lokal.
|
||||
// Ablauf:
|
||||
// - Klick auf dem Primary → Dispatch via mTLS an den Peer
|
||||
// (POST /agent/cluster/repair-replication) mit der eigenen Adresse als
|
||||
// primary_host; der Peer resynct von dort.
|
||||
// - Klick direkt auf dem Subscriber → läuft lokal (Quelle = der Peer).
|
||||
//
|
||||
// Die eigentliche Arbeit läuft — analog zum Rolling-Update — in einer
|
||||
// transienten systemd-Unit, die das bereits getestete
|
||||
// `edgeguard-ctl cluster-setup-standby <primary>` ausführt.
|
||||
// transienten systemd-Unit, die `edgeguard-ctl cluster-setup-standby
|
||||
// <primary>` ausführt.
|
||||
|
||||
const (
|
||||
repairUnitName = "edgeguard-repair-replication.service"
|
||||
repairScriptPath = "/var/lib/edgeguard/repair-replication.sh"
|
||||
repairAgentPath = "/agent/cluster/repair-replication"
|
||||
repairPubName = "edgeguard_shared" // muss zu cmd/edgeguard-ctl egPubName passen
|
||||
)
|
||||
|
||||
// validRepairHost erlaubt nur IPv4/IPv6/Hostnamen — der Wert landet in
|
||||
// einem Bash-Script das als root läuft, also strikt validieren (defense
|
||||
// in depth, auch wenn er aus ha_nodes stammt).
|
||||
// einem Bash-Script das als root läuft, also strikt validieren.
|
||||
var validRepairHost = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,253}$`)
|
||||
|
||||
// RepairReplication ist der UI-Endpoint. Läuft der lokale Node als
|
||||
// Primary, wird der Resync an den Standby-Peer delegiert; auf dem Standby
|
||||
// selbst läuft er lokal.
|
||||
// repairDispatchBody ist der Body des Agent-Dispatch: der Primary teilt
|
||||
// dem Subscriber seine Adresse mit, von der resynct werden soll.
|
||||
type repairDispatchBody struct {
|
||||
PrimaryHost string `json:"primary_host"`
|
||||
}
|
||||
|
||||
// RepairReplication ist der UI-Endpoint. Hat dieser Node die Publication
|
||||
// (= Primary), wird der Resync an den Peer delegiert; sonst (Subscriber)
|
||||
// läuft er lokal mit dem Peer als Quelle.
|
||||
func (h *ClusterHandler) RepairReplication(c *gin.Context) {
|
||||
if h.Store == nil {
|
||||
response.Internal(c, errors.New("cluster store unavailable"))
|
||||
return
|
||||
}
|
||||
all, err := h.Store.List(c.Request.Context())
|
||||
ctx := c.Request.Context()
|
||||
all, err := h.Store.List(ctx)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
local := findNode(all, h.LocalID)
|
||||
peer := findOtherPeer(all, h.LocalID)
|
||||
if peer == nil {
|
||||
response.BadRequest(c, errors.New("kein Peer-Node im Cluster — nichts zu resyncen"))
|
||||
return
|
||||
}
|
||||
|
||||
// Primary → an den Subscriber-Peer (Nicht-Primary) delegieren.
|
||||
if isPrimaryNode(local) {
|
||||
standby := findSubscriberPeer(all, h.LocalID)
|
||||
if h.nodeHasPublication(ctx) {
|
||||
// Primary → an den Subscriber-Peer delegieren, mit eigener Adresse.
|
||||
if h.Aggregator == nil {
|
||||
response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar"))
|
||||
return
|
||||
}
|
||||
if standby == nil {
|
||||
response.BadRequest(c, errors.New("kein Standby-/Subscriber-Node gefunden, an den der Resync delegiert werden könnte"))
|
||||
primaryHost := pickPrimaryHost(local)
|
||||
if primaryHost == "" || !validRepairHost.MatchString(primaryHost) {
|
||||
response.BadRequest(c, errors.New("eigene Primary-Adresse (Mgmt/Internal/Public-IP/FQDN) fehlt oder ist ungültig"))
|
||||
return
|
||||
}
|
||||
res := h.Aggregator.PostPeer(c.Request.Context(), *standby, repairAgentPath)
|
||||
body, _ := json.Marshal(repairDispatchBody{PrimaryHost: primaryHost})
|
||||
res := h.Aggregator.PostPeerWithBody(ctx, *peer, repairAgentPath, body)
|
||||
if !res.OK {
|
||||
response.Internal(c, fmt.Errorf("Resync auf %s anstoßen: %s", standby.FQDN, res.Err))
|
||||
response.Internal(c, fmt.Errorf("Resync auf %s anstoßen: %s", peer.FQDN, res.Err))
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: replication repair delegated to standby", "standby", standby.FQDN)
|
||||
slog.Info("cluster: replication repair delegated", "target", peer.FQDN, "primary_host", primaryHost)
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "cluster.repair-replication",
|
||||
standby.FQDN, gin.H{"target": "standby", "standby": standby.FQDN}, h.NodeID)
|
||||
_ = h.Audit.Log(ctx, actorOf(c), "cluster.repair-replication",
|
||||
peer.FQDN, gin.H{"target": "peer", "peer": peer.FQDN, "primary_host": primaryHost}, h.NodeID)
|
||||
}
|
||||
response.Accepted(c, gin.H{"dispatched": true, "target": "standby", "standby_fqdn": standby.FQDN})
|
||||
response.Accepted(c, gin.H{"dispatched": true, "target": "peer", "peer_fqdn": peer.FQDN})
|
||||
return
|
||||
}
|
||||
|
||||
// Standby (oder Direktzugriff) → lokal ausführen.
|
||||
host, err := h.runLocalRepair(c.Request.Context(), all)
|
||||
if err != nil {
|
||||
// Subscriber → lokal ausführen, Quelle = der Peer (Primary).
|
||||
host := pickPrimaryHost(peer)
|
||||
if err := h.startResync(ctx, host); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "cluster.repair-replication",
|
||||
_ = h.Audit.Log(ctx, actorOf(c), "cluster.repair-replication",
|
||||
host, gin.H{"target": "local", "primary": host}, h.NodeID)
|
||||
}
|
||||
response.Accepted(c, gin.H{"dispatched": true, "target": "local", "primary": host})
|
||||
}
|
||||
|
||||
// AgentRepairReplication wird vom Primary via mTLS auf dem Standby
|
||||
// aufgerufen und startet dort den lokalen Resync.
|
||||
// AgentRepairReplication wird vom Primary via mTLS auf dem Subscriber
|
||||
// aufgerufen und startet dort den lokalen Resync von primary_host.
|
||||
func (h *ClusterHandler) AgentRepairReplication(c *gin.Context) {
|
||||
if h.Store == nil {
|
||||
response.Internal(c, errors.New("cluster store unavailable"))
|
||||
return
|
||||
}
|
||||
all, err := h.Store.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
ctx := c.Request.Context()
|
||||
var body repairDispatchBody
|
||||
_ = c.ShouldBindJSON(&body) // best-effort; Fallback unten
|
||||
|
||||
host := strings.TrimSpace(body.PrimaryHost)
|
||||
if host == "" {
|
||||
// Fallback: Quelle aus ha_nodes (der andere Node).
|
||||
if all, err := h.Store.List(ctx); err == nil {
|
||||
host = pickPrimaryHost(findOtherPeer(all, h.LocalID))
|
||||
}
|
||||
}
|
||||
host, err := h.runLocalRepair(c.Request.Context(), all)
|
||||
if err != nil {
|
||||
if err := h.startResync(ctx, host); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: replication repair triggered by peer", "primary", host, "node", h.LocalID)
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), "cluster-peer", "cluster.repair-replication",
|
||||
_ = h.Audit.Log(ctx, "cluster-peer", "cluster.repair-replication",
|
||||
host, gin.H{"target": "local", "primary": host, "via": "agent"}, h.NodeID)
|
||||
}
|
||||
response.Accepted(c, gin.H{"dispatched": true, "primary": host})
|
||||
}
|
||||
|
||||
// runLocalRepair startet den Resync auf DIESEM Node. Verweigert auf dem
|
||||
// Primary (kein Subscriber). Gibt den ermittelten Primary-Host zurück.
|
||||
func (h *ClusterHandler) runLocalRepair(_ context.Context, all []models.HANode) (string, error) {
|
||||
local := findNode(all, h.LocalID)
|
||||
primary := findPrimary(all)
|
||||
|
||||
if isPrimaryNode(local) {
|
||||
return "", errors.New("dieser Node ist der Cluster-Primary — Resync läuft nur auf einem Standby/Subscriber")
|
||||
// startResync schreibt das Repair-Script und startet die transiente
|
||||
// systemd-Unit. Safety-Guard: läuft NIE auf dem Publication-Primary.
|
||||
func (h *ClusterHandler) startResync(ctx context.Context, primaryHost string) error {
|
||||
primaryHost = strings.TrimSpace(primaryHost)
|
||||
if primaryHost == "" {
|
||||
return errors.New("keine Primary-Adresse für den Resync ermittelbar")
|
||||
}
|
||||
if primary == nil {
|
||||
return "", errors.New("kein Cluster-Primary gefunden — Resync-Quelle unbekannt")
|
||||
if !validRepairHost.MatchString(primaryHost) {
|
||||
return fmt.Errorf("ungültige Primary-Adresse: %q", primaryHost)
|
||||
}
|
||||
if primary.ID == h.LocalID {
|
||||
return "", errors.New("der lokale Node ist als Primary markiert — Resync nicht möglich")
|
||||
}
|
||||
|
||||
host := pickPrimaryHost(primary)
|
||||
if host == "" {
|
||||
return "", errors.New("Primary hat keine erreichbare IP/FQDN in ha_nodes")
|
||||
}
|
||||
if !validRepairHost.MatchString(host) {
|
||||
return "", fmt.Errorf("ungültige Primary-Adresse: %q", host)
|
||||
// Niemals auf dem Primary (Publication-Quelle) resyncen — würde die
|
||||
// eigene Config mit sich selbst überschreiben bzw. ist sinnlos.
|
||||
if h.nodeHasPublication(ctx) {
|
||||
return errors.New("dieser Node ist der Publication-Primary — Resync läuft nur auf einem Subscriber")
|
||||
}
|
||||
if st := repairUnitState(); st == "activating" || st == "active" {
|
||||
return "", errors.New("Resync läuft bereits")
|
||||
return errors.New("Resync läuft bereits")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
@@ -165,10 +180,10 @@ if [ "$rc" -ne 0 ]; then
|
||||
fi
|
||||
echo "[repair] abgeschlossen — config_hash wird beim nächsten Cluster-Status neu berechnet"
|
||||
rm -f %[2]s
|
||||
`, host, repairScriptPath)
|
||||
`, primaryHost, repairScriptPath)
|
||||
|
||||
if err := os.WriteFile(repairScriptPath, []byte(script), 0o755); err != nil {
|
||||
return "", fmt.Errorf("write repair script: %w", err)
|
||||
return fmt.Errorf("write repair script: %w", err)
|
||||
}
|
||||
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", repairUnitName).Run()
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
|
||||
@@ -177,10 +192,28 @@ rm -f %[2]s
|
||||
"--collect",
|
||||
"bash", repairScriptPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("systemd-run failed: %w", err)
|
||||
return fmt.Errorf("systemd-run failed: %w", err)
|
||||
}
|
||||
slog.Info("cluster: replication repair dispatched (local)", "primary", host, "node", h.LocalID)
|
||||
return host, nil
|
||||
slog.Info("cluster: replication repair dispatched (local)", "primary", primaryHost, "node", h.LocalID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// nodeHasPublication prüft, ob dieser Node die Replikations-Publication
|
||||
// besitzt — das verlässliche Primary-Signal. pg_publication ist für jeden
|
||||
// DB-User lesbar (anders als pg_subscription).
|
||||
func (h *ClusterHandler) nodeHasPublication(ctx context.Context) bool {
|
||||
if h.Store == nil || h.Store.Pool == nil {
|
||||
return false
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
var exists bool
|
||||
if err := h.Store.Pool.QueryRow(cctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = $1)`, repairPubName,
|
||||
).Scan(&exists); err != nil {
|
||||
return false
|
||||
}
|
||||
return exists
|
||||
}
|
||||
|
||||
// repairStatusResponse spiegelt den Zustand der transienten Repair-Unit.
|
||||
@@ -195,21 +228,20 @@ type repairStatusResponse struct {
|
||||
}
|
||||
|
||||
// RepairReplicationStatus liest den Job-Zustand. Auf dem Primary wird der
|
||||
// Status vom Standby-Peer geholt (dort läuft der Job); sonst lokal.
|
||||
// Status vom Subscriber-Peer geholt (dort läuft der Job); sonst lokal.
|
||||
func (h *ClusterHandler) RepairReplicationStatus(c *gin.Context) {
|
||||
if h.Store != nil {
|
||||
if all, err := h.Store.List(c.Request.Context()); err == nil {
|
||||
local := findNode(all, h.LocalID)
|
||||
standby := findSubscriberPeer(all, h.LocalID)
|
||||
if isPrimaryNode(local) && h.Aggregator != nil && standby != nil {
|
||||
results := h.Aggregator.FanOut(c.Request.Context(),
|
||||
[]models.HANode{*standby}, repairAgentPath+"/status", h.LocalID)
|
||||
ctx := c.Request.Context()
|
||||
if h.Store != nil && h.nodeHasPublication(ctx) && h.Aggregator != nil {
|
||||
if all, err := h.Store.List(ctx); err == nil {
|
||||
if peer := findOtherPeer(all, h.LocalID); peer != nil {
|
||||
results := h.Aggregator.FanOut(ctx,
|
||||
[]models.HANode{*peer}, repairAgentPath+"/status", h.LocalID)
|
||||
if len(results) == 1 && results[0].OK && len(results[0].Data) > 0 {
|
||||
c.Data(200, "application/json", wrapEnvelope(results[0].Data))
|
||||
return
|
||||
}
|
||||
// Peer nicht erreichbar → idle zurückgeben statt Fehler,
|
||||
// damit das UI-Polling nicht hart abbricht.
|
||||
// Peer nicht erreichbar → idle statt Fehler, damit das
|
||||
// UI-Polling nicht hart abbricht.
|
||||
response.OK(c, repairStatusResponse{Phase: "idle", Log: []string{}})
|
||||
return
|
||||
}
|
||||
@@ -295,7 +327,7 @@ func localRepairStatus() repairStatusResponse {
|
||||
return out
|
||||
}
|
||||
|
||||
// findNode / findByPGRole: kleine Helfer über die ha_nodes-Liste.
|
||||
// findNode liefert die ha_nodes-Row mit der gegebenen ID.
|
||||
func findNode(nodes []models.HANode, id string) *models.HANode {
|
||||
for i := range nodes {
|
||||
if nodes[i].ID == id {
|
||||
@@ -305,32 +337,13 @@ func findNode(nodes []models.HANode, id string) *models.HANode {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPrimaryNode: ein Node gilt als Primary (Publication-Quelle), wenn
|
||||
// role ODER pg_role "primary" ist. pg_role bleibt nach cluster-setup-
|
||||
// standby auf "standalone" (nur `promote` setzt es), daher ist role das
|
||||
// verlässliche Signal — analog zur keepalived-Logik.
|
||||
func isPrimaryNode(n *models.HANode) bool {
|
||||
return n != nil && (n.Role == "primary" || n.PGRole == "primary")
|
||||
}
|
||||
|
||||
// findPrimary liefert den Primary-Node (Resync-Quelle).
|
||||
func findPrimary(nodes []models.HANode) *models.HANode {
|
||||
for i := range nodes {
|
||||
if isPrimaryNode(&nodes[i]) {
|
||||
return &nodes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findSubscriberPeer liefert den Resync-Ziel-Peer: ein anderer Node, der
|
||||
// NICHT der Primary ist (in einem 2-Node-Cluster der Standby/Subscriber).
|
||||
// findOtherPeer liefert den (einen) anderen Node im 2-Node-Cluster.
|
||||
// Bevorzugt einen online erreichbaren Peer.
|
||||
func findSubscriberPeer(nodes []models.HANode, localID string) *models.HANode {
|
||||
func findOtherPeer(nodes []models.HANode, localID string) *models.HANode {
|
||||
var fallback *models.HANode
|
||||
for i := range nodes {
|
||||
n := &nodes[i]
|
||||
if n.ID == localID || isPrimaryNode(n) {
|
||||
if n.ID == localID {
|
||||
continue
|
||||
}
|
||||
if n.Status == "online" {
|
||||
@@ -343,10 +356,13 @@ func findSubscriberPeer(nodes []models.HANode, localID string) *models.HANode {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// pickPrimaryHost wählt die beste erreichbare Adresse des Primary:
|
||||
// pickPrimaryHost wählt die beste erreichbare Adresse eines Node:
|
||||
// Mgmt-IP → Internal-IP → Public-IP → FQDN. Strippt eine etwaige
|
||||
// CIDR-Maske (inet-Spalten können "10.0.0.5/32" liefern).
|
||||
func pickPrimaryHost(n *models.HANode) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
for _, cand := range []*string{n.MgmtIP, n.InternalIP, n.PublicIP} {
|
||||
if cand != nil {
|
||||
if h := strings.TrimSpace(strings.SplitN(*cand, "/", 2)[0]); h != "" {
|
||||
|
||||
@@ -392,17 +392,13 @@ export default function ClusterPage() {
|
||||
|
||||
const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname
|
||||
|
||||
// Repair-Button: sichtbar bei Drift, für Admins, wenn ein Resync-Ziel
|
||||
// existiert — auf dem Standby (lokal) oder auf dem Primary (delegiert
|
||||
// an den Subscriber-Peer). Primary = role ODER pg_role 'primary'
|
||||
// (pg_role bleibt nach setup-standby 'standalone', role ist verlässlich).
|
||||
const isPrimaryNode = (n?: HANode | null) => !!n && (n.pg_role === 'primary' || n.role === 'primary')
|
||||
const localIsPrimary = isPrimaryNode(data?.local_node)
|
||||
const hasSubscriberPeer = data?.peers?.some(p => !isPrimaryNode(p)) ?? false
|
||||
const hasPrimary = localIsPrimary || (data?.peers?.some(isPrimaryNode) ?? false)
|
||||
// Repair-Button: sichtbar bei Drift, für Admins, sobald ein Peer
|
||||
// existiert. Welche Node Primary (Publication-Quelle) bzw. Subscriber
|
||||
// ist, entscheidet das Backend zur Laufzeit über pg_publication — die
|
||||
// UI muss das nicht raten (ha_nodes.role ist je Node lokal/unzuverlässig).
|
||||
const canRepair = !isViewer
|
||||
&& !!data?.drift_found
|
||||
&& (localIsPrimary ? hasSubscriberPeer : hasPrimary)
|
||||
&& ((data?.peers?.length ?? 0) > 0)
|
||||
|
||||
const peerColumns: ColumnsType<HANode> = [
|
||||
{
|
||||
@@ -540,13 +536,7 @@ export default function ClusterPage() {
|
||||
banner
|
||||
className="mb-16"
|
||||
message={t('cluster.driftBanner')}
|
||||
description={
|
||||
<>
|
||||
<Paragraph style={{ marginBottom: 8 }}>{t('cluster.driftBannerDesc')}</Paragraph>
|
||||
{localIsPrimary && !hasSubscriberPeer
|
||||
&& <Text type="secondary">{t('cluster.repair.noStandbyHint')}</Text>}
|
||||
</>
|
||||
}
|
||||
description={t('cluster.driftBannerDesc')}
|
||||
action={
|
||||
canRepair ? (
|
||||
<Popconfirm
|
||||
|
||||
Reference in New Issue
Block a user