netpol: NetworkPolicy v1 enforcement via nftables
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>
This commit is contained in:
Donavan Fritz
2026-04-25 09:25:58 -05:00
parent 71e584cf96
commit 39ede9130b
16 changed files with 2698 additions and 2 deletions
+160
View File
@@ -0,0 +1,160 @@
package netpol
import (
"context"
"io"
"log/slog"
"net"
"strings"
"sync"
"sync/atomic"
"testing"
corev1 "k8s.io/api/core/v1"
netv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// fakeApplier captures Apply calls for assertion. Drop-in for *Applier in
// tests because Reconciler depends only on the (Apply, Clear) pair.
type fakeApplier struct {
mu sync.Mutex
calls []string
last string
err error
}
func (f *fakeApplier) Apply(_ context.Context, script string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.err != nil {
return f.err
}
if script == f.last {
return nil // de-dup like the real Applier
}
f.last = script
f.calls = append(f.calls, script)
return nil
}
func (f *fakeApplier) Clear(_ context.Context) error { return nil }
func (f *fakeApplier) lastScript() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.last
}
func (f *fakeApplier) callCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.calls)
}
// applierIface is satisfied by *Applier and *fakeApplier; we narrow
// Reconciler to this in tests by adapting via a tiny wrapper.
type applierIface interface {
Apply(context.Context, string) error
Clear(context.Context) error
}
// reconcileOnce drives one pass synchronously without spinning a goroutine.
func reconcileOnce(t *testing.T, world *World, local LocalPodSource, app applierIface) {
t.Helper()
in := Inputs{
LocalPods: local(),
PeerPods: world.snapshotPeerPods(),
Namespaces: world.snapshotNamespaces(),
Policies: world.snapshotPolicies(),
}
out, err := Translate(in, nil)
if err != nil {
t.Fatal(err)
}
if err := app.Apply(context.Background(), Render(out)); err != nil {
t.Fatal(err)
}
}
// silentLogger returns a slog.Logger discarding everything — keeps test
// output tidy.
func silentLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}))
}
func TestReconciler_NoIsolatedPods_ShortScript(t *testing.T) {
world := NewWorld(silentLogger())
local := func() []Pod { return nil }
app := &fakeApplier{}
reconcileOnce(t, world, local, app)
got := app.lastScript()
if !strings.Contains(got, "table inet flock_netpol") {
t.Fatalf("missing table:\n%s", got)
}
// Without any isolated pods the base chain has policy accept and no
// jumps. That's the desired "open" state.
if strings.Contains(got, "jump pod_") {
t.Fatalf("unexpected jump in open state:\n%s", got)
}
}
func TestReconciler_PolicyIsolatesLocalPod(t *testing.T) {
world := NewWorld(silentLogger())
// Seed a default-deny policy in ns1.
world.onPolicy(&netv1.NetworkPolicy{
ObjectMeta: metav1.ObjectMeta{Namespace: "ns1", Name: "deny-all"},
Spec: netv1.NetworkPolicySpec{
PodSelector: metav1.LabelSelector{},
PolicyTypes: []netv1.PolicyType{netv1.PolicyTypeIngress},
},
}, false)
local := func() []Pod {
return []Pod{{
Namespace: "ns1", Name: "web",
Labels: map[string]string{"app": "web"},
HostIface: "flock00000001",
IPs: []net.IP{mustIP("2001:db8::1")},
}}
}
app := &fakeApplier{}
reconcileOnce(t, world, local, app)
got := app.lastScript()
if !strings.Contains(got, "_ingress {") {
t.Fatalf("expected pod ingress chain:\n%s", got)
}
if !strings.Contains(got, "drop") {
t.Fatalf("expected default-deny drop:\n%s", got)
}
if !strings.Contains(got, `oifname "flock00000001"`) {
t.Fatalf("expected base-chain jump anchored on veth:\n%s", got)
}
}
func TestReconciler_DedupesIdenticalRender(t *testing.T) {
world := NewWorld(silentLogger())
local := func() []Pod {
return []Pod{{
Namespace: "ns1", Name: "web", HostIface: "f1",
IPs: []net.IP{mustIP("2001:db8::1")},
}}
}
app := &fakeApplier{}
reconcileOnce(t, world, local, app)
reconcileOnce(t, world, local, app)
reconcileOnce(t, world, local, app)
if got := app.callCount(); got != 1 {
t.Fatalf("expected 1 unique apply, got %d", got)
}
}
func TestReconciler_OnChangeFiresTrigger(t *testing.T) {
world := NewWorld(silentLogger())
var triggered atomic.Int32
world.OnChange(func() { triggered.Add(1) })
world.onNamespace(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}, false)
world.onPolicy(&netv1.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Namespace: "foo", Name: "p"}}, false)
if triggered.Load() != 2 {
t.Fatalf("expected 2 OnChange calls, got %d", triggered.Load())
}
}