feat(firewall): IPv6 in Regeln + NAT (familienbewusstes nft-Rendering) — v1.2.90
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) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -138,6 +139,7 @@ type View struct {
|
|||||||
type WGSiteMasqEntry struct {
|
type WGSiteMasqEntry struct {
|
||||||
Iface string // wg interface name, e.g. "wg7"
|
Iface string // wg interface name, e.g. "wg7"
|
||||||
VPNNet string // network CIDR of the VPN subnet, e.g. "192.168.99.0/24"
|
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
|
// AutoFWRule is one auto-emitted inbound rule. Proto is "tcp" or
|
||||||
@@ -162,7 +164,11 @@ type RuleLeg struct {
|
|||||||
DstIfaces []string
|
DstIfaces []string
|
||||||
SrcAddrs []string
|
SrcAddrs []string
|
||||||
DstAddrs []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
|
// ResolvedRule has all addresses + services already expanded so the
|
||||||
@@ -195,6 +201,12 @@ type ResolvedNATRule struct {
|
|||||||
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/
|
||||||
|
// 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
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,26 +292,15 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Expand to one Leg per (rule × service); rules without a service
|
// Expand to one Leg per (rule × service × address-family). Rules
|
||||||
// produce one leg with empty Proto.
|
// without a service produce one leg-set with empty Proto.
|
||||||
for _, r := range rules {
|
for _, r := range rules {
|
||||||
if len(r.Services) == 0 {
|
if len(r.Services) == 0 {
|
||||||
view.Legs = append(view.Legs, RuleLeg{
|
view.Legs = append(view.Legs, expandFamilyLegs(r, ResolvedService{}, false)...)
|
||||||
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,
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, svc := range r.Services {
|
for _, svc := range r.Services {
|
||||||
view.Legs = append(view.Legs, RuleLeg{
|
view.Legs = append(view.Legs, expandFamilyLegs(r, svc, true)...)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,9 +324,14 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
|||||||
if wgRows.Scan(&name, &cidr) == nil {
|
if wgRows.Scan(&name, &cidr) == nil {
|
||||||
view.WGServerIfaces = append(view.WGServerIfaces, name)
|
view.WGServerIfaces = append(view.WGServerIfaces, name)
|
||||||
if _, ipNet, err := net.ParseCIDR(cidr); err == nil {
|
if _, ipNet, err := net.ParseCIDR(cidr); err == nil {
|
||||||
|
l3 := addrFamily(ipNet.String())
|
||||||
|
if l3 == "" {
|
||||||
|
l3 = "ip"
|
||||||
|
}
|
||||||
view.WGSiteMasq = append(view.WGSiteMasq, WGSiteMasqEntry{
|
view.WGSiteMasq = append(view.WGSiteMasq, WGSiteMasqEntry{
|
||||||
Iface: name,
|
Iface: name,
|
||||||
VPNNet: ipNet.String(),
|
VPNNet: ipNet.String(),
|
||||||
|
L3: l3,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,6 +441,131 @@ func isLoopback(ip string) bool {
|
|||||||
return strings.HasPrefix(ip, "127.")
|
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
|
// 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").
|
// 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
|
type addrObjMap map[int64]string
|
||||||
@@ -693,6 +824,19 @@ ORDER BY priority DESC, id ASC`)
|
|||||||
if outZone != nil {
|
if outZone != nil {
|
||||||
r.OutIfaces = zoneIfaces[*outZone]
|
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)
|
out = append(out, r)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
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
|
die Comment-Zeile angehängt — sonst frisst nft die rule
|
||||||
als Teil des # Kommentars). */ -}}
|
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}}
|
{{end}}
|
||||||
|
|
||||||
# ── DEFAULT-DROP LOGGING ───────────────────────────────────────
|
# ── DEFAULT-DROP LOGGING ───────────────────────────────────────
|
||||||
@@ -101,7 +101,7 @@ table inet edgeguard {
|
|||||||
# nach und erlauben new-state-Pakete von dort. Return-Pakete
|
# nach und erlauben new-state-Pakete von dort. Return-Pakete
|
||||||
# gehen via ct state established schon durch.
|
# gehen via ct state established schon durch.
|
||||||
{{range .NATRules}}{{if or (eq .Kind "snat") (eq .Kind "masquerade")}}{{if .SrcCIDR}}
|
{{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}}
|
{{end}}{{end}}{{end}}
|
||||||
|
|
||||||
# Auto-Forward für WireGuard-Server-Interfaces: Peer-to-Peer-
|
# 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).
|
{{/* nft-Syntax: erst L3-match (ip saddr/daddr), DANN L4 (tcp/udp dport).
|
||||||
Sonst quittiert der parser '... unexpected ip' an dieser Stelle. */}}
|
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}}
|
{{end}}{{end}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,16 +152,16 @@ table inet edgeguard {
|
|||||||
# Masquerade schreibt die Source auf die lokale Tunnel-IP um; Return-Traffic
|
# Masquerade schreibt die Source auf die lokale Tunnel-IP um; Return-Traffic
|
||||||
# findet so den Weg zurück durch den Tunnel.
|
# findet so den Weg zurück durch den Tunnel.
|
||||||
{{range .WGSiteMasq}}
|
{{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}}
|
{{end}}
|
||||||
{{range .NATRules}}{{if eq .Kind "snat"}}
|
{{range .NATRules}}{{if eq .Kind "snat"}}
|
||||||
# NAT {{.ID}} (snat{{if .Comment}} — {{.Comment}}{{end}})
|
# 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"}}
|
{{end}}{{if eq .Kind "masquerade"}}
|
||||||
# NAT {{.ID}} (masquerade{{if .Comment}} — {{.Comment}}{{end}})
|
# 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}}
|
{{end}}{{end}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user