From 848a1d2b508b5e35e18ed791d76adcd5fba5059b Mon Sep 17 00:00:00 2001 From: chen21019 Date: Sat, 12 Sep 2026 21:48:02 +0800 Subject: [PATCH] Gate policy table ownership across Docker firewall modes --- README.md | 7 ++ internal/enforcement/firewall_vm_test.go | 89 ++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 internal/enforcement/firewall_vm_test.go diff --git a/README.md b/README.md index e1f40c7..e30d14f 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,13 @@ go build -trimpath -o bin/network-policy-manager ./cmd/network-policy-manager Run `sh scripts/validate.sh` on Unix-like systems or `pwsh -File scripts/validate.ps1` on Windows for formatting, tests, vetting, module verification, and reproducible-build checks. +On a disposable root VM only, set `PASTURESTACK_POLICY_VM_MODE` to the actual +Docker firewall mode (`nftables`, `iptables-nft`, or `iptables-legacy`) and run +`go test ./internal/enforcement -run TestOwnedPolicyTableCoexistsWithDockerFirewallOnVM`. +The test refuses an existing policy table, applies and removes only its own +table, and compares Docker-owned rules before and after. This coexistence +check does not claim a complete multi-host policy-traffic or upgrade gate. + ## Licensing The inherited root `LICENSE` is preserved byte-for-byte and contains the Apache License 2.0 text. Go toolchain notices used for reproducible builds are included under `LICENSES/`. See `ORIGIN.md` for the preservation and release boundary. diff --git a/internal/enforcement/firewall_vm_test.go b/internal/enforcement/firewall_vm_test.go new file mode 100644 index 0000000..5c0e0d1 --- /dev/null +++ b/internal/enforcement/firewall_vm_test.go @@ -0,0 +1,89 @@ +package enforcement + +import ( + "bytes" + "context" + "fmt" + "net/netip" + "os" + "os/exec" + "strings" + "testing" +) + +// Opt-in isolated-VM gate: policy owns only its table, regardless of which +// firewall frontend Docker and Network Plugin Manager use on the host. +func TestOwnedPolicyTableCoexistsWithDockerFirewallOnVM(t *testing.T) { + mode := os.Getenv("PASTURESTACK_POLICY_VM_MODE") + if mode == "" { + t.Skip("set PASTURESTACK_POLICY_VM_MODE on an isolated root VM") + } + if os.Geteuid() != 0 { + t.Fatal("firewall integration test requires root") + } + containers, err := exec.Command("docker", "ps", "-aq").CombinedOutput() + if err != nil || len(bytes.TrimSpace(containers)) != 0 { + t.Fatalf("refusing nonempty or uninspectable Docker host: %v: %s", err, containers) + } + if tableExists(context.Background(), "nft") { + t.Fatal("refusing to replace a pre-existing policy table") + } + driver, err := exec.Command("docker", "info", "--format", "{{.FirewallBackend.Driver}}").CombinedOutput() + if err != nil { + t.Fatalf("inspect Docker firewall driver: %v: %s", err, driver) + } + var snapshot []string + switch mode { + case "nftables": + if strings.TrimSpace(string(driver)) != "nftables" { + t.Fatalf("expected native Docker firewall, got %q", driver) + } + snapshot = []string{"nft", "list", "table", "ip", "docker-bridges"} + case "iptables-nft", "iptables-legacy": + if strings.TrimSpace(string(driver)) != "iptables" { + t.Fatalf("expected Docker iptables firewall, got %q", driver) + } + command := mode + if out, err := exec.Command(command, "-t", "nat", "-S", "DOCKER").CombinedOutput(); err != nil { + t.Fatalf("Docker does not own %s NAT: %v: %s", mode, err, out) + } + snapshot = []string{command + "-save"} + default: + t.Fatalf("unsupported VM test mode %q", mode) + } + readDocker := func() []byte { + out, err := exec.Command(snapshot[0], snapshot[1:]...).CombinedOutput() + if err != nil { + t.Fatalf("read Docker firewall snapshot: %v: %s", err, out) + } + return out + } + before := readDocker() + backend := NFTBackend{} + t.Cleanup(func() { + if err := backend.Cleanup(context.Background()); err != nil { + t.Errorf("cleanup test-owned policy table: %v", err) + } + }) + plan := FirewallPlan{Subnet: netip.MustParsePrefix("198.18.250.0/24"), DefaultAction: "allow"} + for iteration := 0; iteration < 2; iteration++ { + if err := backend.Apply(context.Background(), plan); err != nil { + t.Fatalf("apply policy iteration %d: %v", iteration, err) + } + if !tableExists(context.Background(), "nft") { + t.Fatalf("policy table absent after apply %d", iteration) + } + if after := readDocker(); !bytes.Equal(before, after) { + t.Fatalf("Docker-owned rules changed after policy apply %d: %s", iteration, fmt.Sprint(snapshot)) + } + } + if err := backend.Cleanup(context.Background()); err != nil { + t.Fatalf("cleanup policy table: %v", err) + } + if tableExists(context.Background(), "nft") { + t.Fatal("test-owned policy table remains after cleanup") + } + if after := readDocker(); !bytes.Equal(before, after) { + t.Fatalf("Docker-owned rules changed after policy cleanup: %s", fmt.Sprint(snapshot)) + } +}