netpol: NetworkPolicy v1 enforcement via nftables
Build flock Image / build (push) Has been cancelled
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:
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user