test(firewall): End-to-End-IPv6-Test (echte DB + Generator + nft -c)
Guarded per EG_FWTEST_DSN (sonst skip). Migrations + v4/v6-Adressobjekte/Gruppe/icmpv6/v6-DNAT seeden, echten Generator.RenderToString laufen lassen, Output mit nft -c (via sudo) validieren. Verifiziert u.a.: gemischte Adressgruppe splittet in ip+ip6, gemischte v4/v6-NAT wird übersprungen, v6-DNAT-Target [..]:port. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
132
internal/firewall/firewall_e2e_test.go
Normal file
132
internal/firewall/firewall_e2e_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user