39ede9130b
Build flock Image / build (push) Has been cancelled
New pkg/agent/netpol implementing standard networking.k8s.io/v1 NetworkPolicy. Pipeline: pods + policies + namespaces → Translate → Render → Apply Supports ingress + egress, all three peer types (podSelector, namespaceSelector, ipBlock with except), numeric ports + port ranges, default-deny semantics derived from PolicyTypes (or inferred from non-empty Spec.Egress when unset). Apply path is `nft -f -` shell-out — single transaction, atomic, kernel guarantees partial-failure rollback. Idempotent dedup via last-applied script. Reconcile triggers: informer events, 30s self-heal tick, every CNI ADD/DEL. Verified against the three live cluster NetPols (calico-apiserver, remote-proxies/lodge-home-assistant, storage/garage-admin-restrict). Fuzz target stitches Translate + Render with random selector and peer inputs; 21 unit tests cover the policy semantics. Named ports skip with a warn — deferred until kubelet exposes them in a form that doesn't require shadowing pod state. Dockerfile: + nftables. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
220 lines
7.0 KiB
Go
220 lines
7.0 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 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)
|
|
}
|
|
}
|
|
|
|
// TestRender_DualStack — pod with both v6 + v4 IPs gets two base-chain
|
|
// jumps.
|
|
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)
|
|
if !strings.Contains(got, "ip6 daddr 2001:db8::1") {
|
|
t.Fatalf("missing v6 jump:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "ip daddr 10.0.0.1") {
|
|
t.Fatalf("missing v4 jump:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// 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 + ip6 saddr (pod's IP).
|
|
if !strings.Contains(got, `iifname "f1" ip6 saddr 2001:db8::1`) {
|
|
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
|
|
}
|