package firewall import ( "context" "os" "os/exec" "strings" "testing" "time" "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() // Retry: goose-Erst-Apply ist nicht concurrency-safe, wenn mehrere // guarded Test-Pakete dieselbe frische DB parallel migrieren. var mErr error for i := 0; i < 3; i++ { if mErr = database.Migrate(ctx, dsn); mErr == nil { break } time.Sleep(700 * time.Millisecond) } if mErr != nil { t.Fatalf("migrate: %v", mErr) } 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) } }