From 5d9b6bfeec456cf6c090761ad9641062353069f6 Mon Sep 17 00:00:00 2001 From: Donavan Fritz Date: Sat, 25 Apr 2026 09:32:08 -0500 Subject: [PATCH] netpol: anchor base-chain jump on veth only, not pod IP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous base-chain jump matched iifname/oifname AND saddr/daddr == pod eth0 IP. Anycast traffic has the anycast IP as daddr, not the pod's eth0 unicast — so anycast packets skipped the policy chain entirely and fell through to the forward chain's policy=accept. The veth uniquely belongs to one pod. Anything traversing it is to or from that pod by definition (anycast, unicast, future overlay routes). Match on iifname/oifname alone; let the pod-side chain's accept lines + trailing drop be the policy. Validated end-to-end on host001: anycast nginx pod with default-deny ingress NetPol now correctly drops traffic from any peer; adding an allow-from-podSelector rule unblocks only the matched peer. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- deploy/install.yaml | 3 +++ pkg/agent/netpol/reconciler_test.go | 2 +- pkg/agent/netpol/render.go | 39 ++++++++++------------------- pkg/agent/netpol/render_test.go | 30 +++++++++++----------- 4 files changed, 33 insertions(+), 41 deletions(-) diff --git a/deploy/install.yaml b/deploy/install.yaml index 9b3f708..8368f7b 100644 --- a/deploy/install.yaml +++ b/deploy/install.yaml @@ -119,6 +119,9 @@ rules: - apiGroups: ["networking.k8s.io"] resources: ["networkpolicies"] verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "watch"] - apiGroups: [""] resources: ["nodes/status"] verbs: ["patch"] diff --git a/pkg/agent/netpol/reconciler_test.go b/pkg/agent/netpol/reconciler_test.go index bc7fd36..89c56ba 100644 --- a/pkg/agent/netpol/reconciler_test.go +++ b/pkg/agent/netpol/reconciler_test.go @@ -126,7 +126,7 @@ func TestReconciler_PolicyIsolatesLocalPod(t *testing.T) { if !strings.Contains(got, "drop") { t.Fatalf("expected default-deny drop:\n%s", got) } - if !strings.Contains(got, `oifname "flock00000001"`) { + if !strings.Contains(got, `oifname "flock00000001" jump pod_`) { t.Fatalf("expected base-chain jump anchored on veth:\n%s", got) } } diff --git a/pkg/agent/netpol/render.go b/pkg/agent/netpol/render.go index 9775ceb..bfc76f6 100644 --- a/pkg/agent/netpol/render.go +++ b/pkg/agent/netpol/render.go @@ -247,35 +247,22 @@ func writePortMatch(sb *strings.Builder, p PortMatch) { } // writeBaseJump emits one line per (pod, direction) chain in the base -// `forward` chain. The match is anchored on the host-side veth name so -// the rule only fires for traffic that genuinely crosses this pod's veth. +// `forward` chain. The match is anchored on the host-side veth name — +// the veth uniquely belongs to one pod, so anything traversing it is +// to/from that pod by definition. // -// We additionally constrain on the pod's address (saddr for egress, daddr -// for ingress) so a packet that somehow hits the wrong veth — e.g. during -// a CNI ADD race — won't be policy-evaluated against the wrong pod. +// We deliberately don't filter on the pod's eth0 address: the pod can +// also receive traffic addressed to its anycast IP (or any other host +// route the operator has installed via flock-agent), and policy must +// apply uniformly to all of it. func writeBaseJump(sb *strings.Builder, c chain) { - v6, v4 := splitIPFamily(c.podIPs) - emit := func(family string, ip net.IP) { - if ip == nil { - return - } - var iface, addrField, addrStr string - if c.direction == DirEgress { - iface = "iifname" - addrField = family + " saddr" - } else { - iface = "oifname" - addrField = family + " daddr" - } - if family == "ip" { - addrStr = ip.To4().String() - } else { - addrStr = ip.To16().String() - } - fmt.Fprintf(sb, "\t\t%s \"%s\" %s %s jump %s\n", iface, c.hostIface, addrField, addrStr, c.name) + var iface string + if c.direction == DirEgress { + iface = "iifname" + } else { + iface = "oifname" } - emit("ip6", v6) - emit("ip", v4) + fmt.Fprintf(sb, "\t\t%s \"%s\" jump %s\n", iface, c.hostIface, c.name) } // splitFamily partitions CIDRs into (v6, v4) lists, preserving order diff --git a/pkg/agent/netpol/render_test.go b/pkg/agent/netpol/render_test.go index e013197..cdb233f 100644 --- a/pkg/agent/netpol/render_test.go +++ b/pkg/agent/netpol/render_test.go @@ -35,17 +35,14 @@ func TestRender_DefaultDeny(t *testing.T) { if !strings.Contains(got, "_ingress {") { t.Fatalf("missing pod ingress chain:\n%s", got) } - // Base chain jump anchored on veth + pod IP. - if !strings.Contains(got, `oifname "flock00000001"`) { - t.Fatalf("missing veth match in base chain:\n%s", got) - } - if !strings.Contains(got, "ip6 daddr 2001:db8::1") { - t.Fatalf("missing pod IP match in base 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) } } -// TestRender_DualStack — pod with both v6 + v4 IPs gets two base-chain -// jumps. +// 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{}{ @@ -59,11 +56,16 @@ func TestRender_DualStack(t *testing.T) { }}, } got := Render(out) - if !strings.Contains(got, "ip6 daddr 2001:db8::1") { - t.Fatalf("missing v6 jump:\n%s", got) + // 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) } - if !strings.Contains(got, "ip daddr 10.0.0.1") { - t.Fatalf("missing v4 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.) } } @@ -200,8 +202,8 @@ func TestRender_EgressDirection(t *testing.T) { }}, } got := Render(out) - // Base-chain jump for egress matches iifname + ip6 saddr (pod's IP). - if !strings.Contains(got, `iifname "f1" ip6 saddr 2001:db8::1`) { + // 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).