diff --git a/internal/firewall/firewall_e2e_test.go b/internal/firewall/firewall_e2e_test.go new file mode 100644 index 0000000..66fd84a --- /dev/null +++ b/internal/firewall/firewall_e2e_test.go @@ -0,0 +1,132 @@ +package firewall + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + + "git.netcell-it.de/projekte/edgeguard-native/internal/database" +) + +// TestE2E_IPv6Render fährt den ECHTEN Generator gegen eine Test-DB: +// alle Migrations + v4/v6-Seed + RenderToString + nft -c. Nur aktiv, wenn +// EG_FWTEST_DSN gesetzt ist (sonst Skip — `go test ./...` bleibt DB-frei). +func TestE2E_IPv6Render(t *testing.T) { + dsn := os.Getenv("EG_FWTEST_DSN") + if dsn == "" { + t.Skip("set EG_FWTEST_DSN to run the firewall end-to-end test") + } + ctx := context.Background() + if err := database.Migrate(ctx, dsn); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := database.Open(ctx, dsn) + if err != nil { + t.Fatalf("open: %v", err) + } + defer pool.Close() + + for _, tbl := range []string{ + "firewall_nat_rules", "firewall_rules", + "firewall_address_group_members", "firewall_address_groups", + "firewall_address_objects", "network_interfaces", + } { + if _, err := pool.Exec(ctx, "DELETE FROM "+tbl); err != nil { + t.Fatalf("clean %s: %v", tbl, err) + } + } + + mustExec := func(sql string, args ...any) { + t.Helper() + if _, err := pool.Exec(ctx, sql, args...); err != nil { + t.Fatalf("seed failed (%s): %v", sql, err) + } + } + insID := func(sql string, args ...any) int64 { + t.Helper() + var id int64 + if err := pool.QueryRow(ctx, sql, args...).Scan(&id); err != nil { + t.Fatalf("seed-id failed (%s): %v", sql, err) + } + return id + } + + mustExec(`INSERT INTO network_interfaces (name,type,role) VALUES ('eth0','ethernet','wan'),('eth1','ethernet','lan')`) + + v4net := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v4net','network','10.0.0.0/24') RETURNING id`) + v6net := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6net','network','2001:db8:1::/64') RETURNING id`) + v6host := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6host','host','2001:db8:2::5') RETURNING id`) + v6range := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6range','range','2001:db8:3::1-2001:db8:3::9') RETURNING id`) + + // Gemischte Gruppe (v4 + v6) → muss in zwei Familien-Zeilen splitten. + grp := insID(`INSERT INTO firewall_address_groups (name) VALUES ('mixed') RETURNING id`) + mustExec(`INSERT INTO firewall_address_group_members (group_id,object_id) VALUES ($1,$2),($1,$3)`, grp, v4net, v6net) + + httpsSvc := insID(`INSERT INTO firewall_services (name,proto,port_start,port_end,builtin,description) VALUES ('t-https','tcp',443,443,false,'') + ON CONFLICT (name) DO UPDATE SET proto=excluded.proto RETURNING id`) + var pingV6 int64 + _ = pool.QueryRow(ctx, `SELECT id FROM firewall_services WHERE proto='icmpv6' LIMIT 1`).Scan(&pingV6) + + // (1) gemischte Gruppe + tcp443 → je eine ip- und ip6-Zeile. + mustExec(`INSERT INTO firewall_rules (name,action,src_zone,src_address_group_id,service_object_id) VALUES ('mixed-https','accept','any',$1,$2)`, grp, httpsSvc) + // (2) v6-host + icmpv6 → eine ip6-Zeile. + if pingV6 != 0 { + mustExec(`INSERT INTO firewall_rules (name,action,src_address_object_id,service_object_id) VALUES ('v6-ping','accept',$1,$2)`, v6host, pingV6) + } + // (3) v6-range src + v6net dst (kein Service). + mustExec(`INSERT INTO firewall_rules (name,action,src_address_object_id,dst_address_object_id) VALUES ('v6-range','drop',$1,$2)`, v6range, v6net) + + // (a) v6-DNAT mit Port → dnat to [..]:port. + mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_dst_cidr,match_dport_start,target_addr,target_port_start) VALUES ('v6-dnat','dnat','tcp','2001:db8:9::/64',80,'2001:db8:9::2',8080)`) + // (b) v4-DNAT (Regression). + mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_dst_cidr,match_dport_start,target_addr,target_port_start) VALUES ('v4-dnat','dnat','tcp','1.2.3.4',80,'10.0.0.5',80)`) + // (c) gemischte Familie (v4 src, v6 target) → MUSS übersprungen werden. + mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_src_cidr,target_addr) VALUES ('mixed-snat','snat','any','10.0.0.0/24','2001:db8::99')`) + + out, err := New(pool).RenderToString(ctx) + if err != nil { + t.Fatalf("render: %v", err) + } + + for _, w := range []string{ + "ip saddr { 10.0.0.0/24 }", + "ip6 saddr { 2001:db8:1::/64 }", + "ip6 nexthdr icmpv6", + "ip6 saddr { 2001:db8:3::1-2001:db8:3::9 }", + "dnat to [2001:db8:9::2]:8080", + "dnat to 10.0.0.5:80", + } { + if !strings.Contains(out, w) { + t.Errorf("rendered output missing %q\n----\n%s", w, out) + } + } + if strings.Contains(out, "2001:db8::99") { + t.Errorf("mixed-family NAT rule was not skipped\n----\n%s", out) + } + + nft, err := exec.LookPath("nft") + if err != nil { + t.Skip("nft not in PATH — skipping syntax check") + } + f, err := os.CreateTemp(t.TempDir(), "e2e-*.nft") + if err != nil { + t.Fatal(err) + } + _, _ = f.WriteString(out) + f.Close() + 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()) + } + if combined, err := cmd.CombinedOutput(); err != nil { + msg := string(combined) + if strings.Contains(msg, "Operation not permitted") || strings.Contains(msg, "password is required") { + t.Skipf("nft -c needs root (no usable sudo): %s", strings.TrimSpace(msg)) + } + t.Fatalf("nft -c rejected the real-rendered ruleset: %v\n%s\n----\n%s", err, combined, out) + } +}