package netpol import ( "net" "strings" "testing" ) // TestRender_DefaultDeny — an isolated direction with no rules renders // to a chain whose last action is "drop". func TestRender_DefaultDeny(t *testing.T) { out := Output{ Isolated: map[Isolation]struct{}{ {PodKey: "ns/web", Direction: DirIngress}: {}, }, Rules: []Rule{ // Need at least one rule to give the chain its HostIface + // PodIPs. Use an empty rule that selects the same chain. {PodKey: "ns/web", HostIface: "flock00000001", PodIPs: []net.IP{mustIP("2001:db8::1")}, Direction: DirIngress, Action: ActionAccept, Ports: []PortMatch{{}}}, }, } got := Render(out) if !strings.Contains(got, "table inet flock_netpol") { t.Fatalf("missing table:\n%s", got) } if !strings.Contains(got, "type filter hook forward") { t.Fatalf("missing base chain:\n%s", got) } if !strings.Contains(got, "drop") { t.Fatalf("expected default-deny drop in chain:\n%s", got) } // Pod chain name must be deterministic-looking (pod__ingress). if !strings.Contains(got, "_ingress {") { t.Fatalf("missing pod ingress chain:\n%s", got) } // Base chain jump anchored solely on veth — anycast must not bypass. if !strings.Contains(got, `oifname "flock00000001" jump pod_`) { t.Fatalf("missing veth-only ingress jump in base chain:\n%s", got) } // Stateful accept must be present so reply traffic for pod-initiated // outbound (e.g. ephemeral-port replies from kube-apiserver) is not // dropped by the chain's final drop. Regression guard: production hit // this when garage's k8s-discovery → apiserver replies got dropped. if !strings.Contains(got, "ct state established,related accept") { t.Fatalf("missing ct state established,related accept:\n%s", got) } } // TestRender_DualStack — dual-stack pod gets one veth-anchored jump per // direction (no per-family jump; the chain handles both). func TestRender_DualStack(t *testing.T) { out := Output{ Isolated: map[Isolation]struct{}{ {PodKey: "ns/web", Direction: DirIngress}: {}, }, Rules: []Rule{{ PodKey: "ns/web", HostIface: "f1", PodIPs: []net.IP{mustIP("2001:db8::1"), mustIP("10.0.0.1")}, Direction: DirIngress, Action: ActionAccept, Ports: []PortMatch{{Protocol: "tcp", Port: 80}}, }}, } got := Render(out) // Exactly one ingress jump line with no per-family daddr. if got != "" && strings.Count(got, `oifname "f1" jump`) != 1 { t.Fatalf("expected exactly one veth-only ingress jump:\n%s", got) } // The accept rule itself should still split per family inside the // pod chain. if !strings.Contains(got, "ip6 saddr") || !strings.Contains(got, "ip saddr") { // no peer filter set → should NOT have ip6/ip saddr filters // inside the chain. (Skip this assertion: TestRender_AllowAllPeers // covers the no-peer-filter case.) } } // TestRender_PortAndPeer — a Rule with peer + port emits a syntactically // well-formed allow line. func TestRender_PortAndPeer(t *testing.T) { out := Output{ Isolated: map[Isolation]struct{}{ {PodKey: "ns/web", Direction: DirIngress}: {}, }, Rules: []Rule{{ PodKey: "ns/web", HostIface: "f1", PodIPs: []net.IP{mustIP("2001:db8::1")}, Direction: DirIngress, Action: ActionAccept, PeerCIDRs: []*net.IPNet{mustNet("2001:db8::a/128")}, Ports: []PortMatch{{Protocol: "tcp", Port: 80}}, }}, } got := Render(out) if !strings.Contains(got, "ip6 saddr { 2001:db8::a/128 } tcp dport 80 accept") { t.Fatalf("expected ingress allow with v6 peer + tcp/80:\n%s", got) } } // TestRender_PortRange — endPort renders as "8000-8999". func TestRender_PortRange(t *testing.T) { out := Output{ Isolated: map[Isolation]struct{}{ {PodKey: "ns/web", Direction: DirIngress}: {}, }, Rules: []Rule{{ PodKey: "ns/web", HostIface: "f1", PodIPs: []net.IP{mustIP("2001:db8::1")}, Direction: DirIngress, Action: ActionAccept, PeerCIDRs: []*net.IPNet{mustNet("0.0.0.0/0"), mustNet("::/0")}, Ports: []PortMatch{{Protocol: "tcp", Port: 8000, EndPort: 8999}}, }}, } got := Render(out) if !strings.Contains(got, "tcp dport 8000-8999") { t.Fatalf("expected port range:\n%s", got) } } // TestRender_IPBlockExcept — except produces a "saddr != { … }" guard. func TestRender_IPBlockExcept(t *testing.T) { out := Output{ Isolated: map[Isolation]struct{}{ {PodKey: "ns/web", Direction: DirIngress}: {}, }, Rules: []Rule{{ PodKey: "ns/web", HostIface: "f1", PodIPs: []net.IP{mustIP("10.0.0.1")}, Direction: DirIngress, Action: ActionAccept, PeerCIDRs: []*net.IPNet{mustNet("10.0.0.0/8")}, PeerExcept: []*net.IPNet{mustNet("10.99.0.0/16")}, Ports: []PortMatch{{}}, }}, } got := Render(out) if !strings.Contains(got, "ip saddr { 10.0.0.0/8 }") { t.Fatalf("expected ipBlock cidr:\n%s", got) } if !strings.Contains(got, "ip saddr != { 10.99.0.0/16 }") { t.Fatalf("expected ipBlock except:\n%s", got) } } // TestRender_AllowAllPeers — empty PeerCIDRs/PeerExcept means "any peer"; // the rule should emit an unconditional accept (modulo port). func TestRender_AllowAllPeers(t *testing.T) { out := Output{ Isolated: map[Isolation]struct{}{ {PodKey: "ns/web", Direction: DirIngress}: {}, }, Rules: []Rule{{ PodKey: "ns/web", HostIface: "f1", PodIPs: []net.IP{mustIP("2001:db8::1")}, Direction: DirIngress, Action: ActionAccept, Ports: []PortMatch{{Protocol: "tcp", Port: 443}}, }}, } got := Render(out) if !strings.Contains(got, "tcp dport 443 accept") { t.Fatalf("expected unconditional tcp/443 allow:\n%s", got) } // Should NOT have a saddr/daddr filter (empty peers). if strings.Contains(got, "ip6 saddr {") || strings.Contains(got, "ip saddr {") { t.Fatalf("expected no peer filter:\n%s", got) } } // TestRender_Determinism — same input → byte-identical output. func TestRender_Determinism(t *testing.T) { out := Output{ Isolated: map[Isolation]struct{}{ {PodKey: "ns/web", Direction: DirIngress}: {}, {PodKey: "ns/db", Direction: DirEgress}: {}, }, Rules: []Rule{ {PodKey: "ns/web", HostIface: "f1", PodIPs: []net.IP{mustIP("2001:db8::1")}, Direction: DirIngress, Action: ActionAccept, PeerCIDRs: []*net.IPNet{mustNet("2001:db8::5/128"), mustNet("2001:db8::3/128")}, Ports: []PortMatch{{Protocol: "tcp", Port: 80}}}, {PodKey: "ns/db", HostIface: "f2", PodIPs: []net.IP{mustIP("2001:db8::2")}, Direction: DirEgress, Action: ActionAccept, PeerCIDRs: []*net.IPNet{mustNet("2001:db8::aa/128")}, Ports: []PortMatch{{}}}, }, } a := Render(out) b := Render(out) if a != b { t.Fatalf("Render not deterministic:\nA=\n%s\nB=\n%s", a, b) } // And peers in the rule must be sorted (we deliberately gave 5 then 3). if strings.Index(a, "2001:db8::3/128") > strings.Index(a, "2001:db8::5/128") { t.Fatalf("peer CIDRs not sorted within rule:\n%s", a) } } // TestRender_EgressDirection — egress rules use iifname + saddr (pod-side). func TestRender_EgressDirection(t *testing.T) { out := Output{ Isolated: map[Isolation]struct{}{ {PodKey: "ns/web", Direction: DirEgress}: {}, }, Rules: []Rule{{ PodKey: "ns/web", HostIface: "f1", PodIPs: []net.IP{mustIP("2001:db8::1")}, Direction: DirEgress, Action: ActionAccept, PeerCIDRs: []*net.IPNet{mustNet("2001:db8::aa/128")}, Ports: []PortMatch{{Protocol: "tcp", Port: 53}}, }}, } got := Render(out) // Base-chain jump for egress matches iifname only. if !strings.Contains(got, `iifname "f1" jump pod_`) { t.Fatalf("missing egress base-chain jump:\n%s", got) } // Peer filter for egress matches the *destination* (the peer is downstream). if !strings.Contains(got, "ip6 daddr { 2001:db8::aa/128 }") { t.Fatalf("expected daddr peer filter for egress:\n%s", got) } } func mustNet(s string) *net.IPNet { _, n, err := net.ParseCIDR(s) if err != nil { panic(err) } return n }