Files
flock/pkg/agent/netpol/render_test.go
T
Donavan Fritz 5d9b6bfeec
Build flock Image / build (push) Has been cancelled
netpol: anchor base-chain jump on veth only, not pod IP
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) <noreply@anthropic.com>
2026-04-25 09:32:08 -05:00

222 lines
7.3 KiB
Go

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_<hex>_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)
}
}
// 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
}