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() // `nft -c` liest die Kernel-Ruleset-Cache via netlink → braucht root. // Als nicht-root via sudo -n versuchen; klappt das nicht, skip statt fail // (auf den Nodes rendert/prüft edgeguard ohnehin als root). var cmd *exec.Cmd if os.Geteuid() == 0 { cmd = exec.Command(nft, "-c", "-f", f.Name()) } else { cmd = exec.Command("sudo", "-n", nft, "-c", "-f", f.Name()) } combined, err := cmd.CombinedOutput() if err != nil { msg := string(combined) if strings.Contains(msg, "Operation not permitted") || strings.Contains(msg, "a password is required") || strings.Contains(msg, "may not run sudo") { t.Skipf("nft -c needs root (no usable sudo): %s", strings.TrimSpace(msg)) } t.Fatalf("nft -c -f rejected the generated ruleset: %v\n%s\n----\n%s", err, combined, out) } }