From 9383b870b0c05eafafc99f00cadc3937902bf273 Mon Sep 17 00:00:00 2001 From: Debian Date: Thu, 4 Jun 2026 21:04:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(firewall):=20IPv6=20in=20Regeln=20+=20NAT?= =?UTF-8?q?=20(familienbewusstes=20nft-Rendering)=20=E2=80=94=20v1.2.90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher rendete das Template alle Adress-Matches als 'ip saddr/daddr' (v4-only); ein v6-Eintrag hätte 'nft -f' (und damit das ganze Ruleset) gebrochen. Jetzt: - Adressausdrücke werden je Eintrag als v4/v6 klassifiziert (addrFamily). - Regeln mit Adressen werden pro Familie als separate nft-Zeile gerendert (ip vs ip6 saddr/daddr); adresslose Regeln bleiben eine familienagnostische Zeile (v4-Verhalten unverändert). - icmp nur auf v4-, icmpv6 nur auf v6-Zeilen. - NAT familienbewusst inkl. v6-DNAT-Target [..]:port; gemischte v4/v6-NAT-Regeln werden übersprungen (statt nft -f zu brechen) + geloggt. - WireGuard site-to-site Masquerade v6-fähig. Eingabeseite war bereits v6-fähig (validateAddrObjValue/validateRule via net.ParseIP/ParseCIDR; Service-Proto-CHECK erlaubt icmpv6; Builtin PING-v6). Neue Unit-Tests (firewall_ipv6_test.go) inkl. optionalem 'nft -c'-Syntaxcheck. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION | 2 +- internal/firewall/firewall.go | 176 +++++++++++++++++++++--- internal/firewall/firewall_ipv6_test.go | 170 +++++++++++++++++++++++ internal/firewall/ruleset.nft.tpl | 12 +- 4 files changed, 337 insertions(+), 23 deletions(-) create mode 100644 internal/firewall/firewall_ipv6_test.go diff --git a/VERSION b/VERSION index bab6a90..7da1aa7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.89 \ No newline at end of file +1.2.90 \ No newline at end of file diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index 572fd61..2c40265 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -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() diff --git a/internal/firewall/firewall_ipv6_test.go b/internal/firewall/firewall_ipv6_test.go new file mode 100644 index 0000000..dc5141f --- /dev/null +++ b/internal/firewall/firewall_ipv6_test.go @@ -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) + } +} diff --git a/internal/firewall/ruleset.nft.tpl b/internal/firewall/ruleset.nft.tpl index faa1547..9d994d8 100644 --- a/internal/firewall/ruleset.nft.tpl +++ b/internal/firewall/ruleset.nft.tpl @@ -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}} } }