Erster Schritt des golangci-lint-Rollouts (non-blocking): 44 misspell + 4 staticcheck automatisch behoben (32 Dateien, nur Tippfehler/mechanisch). build+test grün. Kein Runtime-Change → kein Deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
184 lines
6.5 KiB
Go
184 lines
6.5 KiB
Go
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, agnostic
|
|
},
|
|
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)
|
|
}
|
|
}
|