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,250 @@
|
||||
package netpol
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
netv1 "k8s.io/api/networking/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
)
|
||||
|
||||
// These fixtures mirror the three NetworkPolicies live in the sjc001
|
||||
// cluster on 2026-04-25. They serve as integration-shaped tests: the
|
||||
// translator + renderer must produce a sensible nft script for each.
|
||||
//
|
||||
// Source of truth (refresh by running `kubectl get netpol -A -o yaml`):
|
||||
//
|
||||
// - calico-apiserver/allow-apiserver
|
||||
// - remote-proxies/lodge-home-assistant-ingress
|
||||
// - storage/garage-admin-restrict
|
||||
|
||||
// allowApiserverPolicy: TCP/5443 ingress to apiserver=true pods, no peer
|
||||
// restriction (allow-from-anywhere on that port).
|
||||
func allowApiserverPolicy() netv1.NetworkPolicy {
|
||||
tcp := corev1.ProtocolTCP
|
||||
port := intstr.FromInt32(5443)
|
||||
return netv1.NetworkPolicy{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "calico-apiserver", Name: "allow-apiserver"},
|
||||
Spec: netv1.NetworkPolicySpec{
|
||||
PodSelector: metav1.LabelSelector{MatchLabels: map[string]string{"apiserver": "true"}},
|
||||
PolicyTypes: []netv1.PolicyType{netv1.PolicyTypeIngress},
|
||||
Ingress: []netv1.NetworkPolicyIngressRule{{
|
||||
Ports: []netv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// lodgeHomeAssistantPolicy: TCP/8080 from any pod in the `edge` namespace
|
||||
// to pods labelled app=lodge-home-assistant.
|
||||
func lodgeHomeAssistantPolicy() netv1.NetworkPolicy {
|
||||
tcp := corev1.ProtocolTCP
|
||||
port := intstr.FromInt32(8080)
|
||||
return netv1.NetworkPolicy{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "remote-proxies", Name: "lodge-home-assistant-ingress"},
|
||||
Spec: netv1.NetworkPolicySpec{
|
||||
PodSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "lodge-home-assistant"}},
|
||||
PolicyTypes: []netv1.PolicyType{netv1.PolicyTypeIngress},
|
||||
Ingress: []netv1.NetworkPolicyIngressRule{{
|
||||
From: []netv1.NetworkPolicyPeer{{
|
||||
NamespaceSelector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{"kubernetes.io/metadata.name": "edge"},
|
||||
},
|
||||
}},
|
||||
Ports: []netv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// garageAdminPolicy: complex two-rule policy.
|
||||
//
|
||||
// 1. Allow TCP/{3900, 80, 3901} from anywhere.
|
||||
// 2. Allow TCP/3903 only from pods in `edge` or `storage`.
|
||||
func garageAdminPolicy() netv1.NetworkPolicy {
|
||||
tcp := corev1.ProtocolTCP
|
||||
p3900 := intstr.FromInt32(3900)
|
||||
p80 := intstr.FromInt32(80)
|
||||
p3901 := intstr.FromInt32(3901)
|
||||
p3903 := intstr.FromInt32(3903)
|
||||
return netv1.NetworkPolicy{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "storage", Name: "garage-admin-restrict"},
|
||||
Spec: netv1.NetworkPolicySpec{
|
||||
PodSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "garage"}},
|
||||
PolicyTypes: []netv1.PolicyType{netv1.PolicyTypeIngress},
|
||||
Ingress: []netv1.NetworkPolicyIngressRule{
|
||||
{
|
||||
Ports: []netv1.NetworkPolicyPort{
|
||||
{Protocol: &tcp, Port: &p3900},
|
||||
{Protocol: &tcp, Port: &p80},
|
||||
{Protocol: &tcp, Port: &p3901},
|
||||
},
|
||||
},
|
||||
{
|
||||
From: []netv1.NetworkPolicyPeer{
|
||||
{NamespaceSelector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{"kubernetes.io/metadata.name": "edge"},
|
||||
}},
|
||||
{NamespaceSelector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{"kubernetes.io/metadata.name": "storage"},
|
||||
}},
|
||||
},
|
||||
Ports: []netv1.NetworkPolicyPort{{Protocol: &tcp, Port: &p3903}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestClusterFixture_AllowApiserver — pod selected by the policy gets
|
||||
// isolated; the rendered script accepts TCP/5443 from anywhere.
|
||||
func TestClusterFixture_AllowApiserver(t *testing.T) {
|
||||
pod := Pod{
|
||||
Namespace: "calico-apiserver",
|
||||
Name: "calico-apiserver-1",
|
||||
Labels: map[string]string{"apiserver": "true"},
|
||||
HostIface: "flock00000001",
|
||||
IPs: []net.IP{mustIP("2001:db8::1")},
|
||||
}
|
||||
out, err := Translate(Inputs{
|
||||
LocalPods: []Pod{pod},
|
||||
Policies: []netv1.NetworkPolicy{allowApiserverPolicy()},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in, _ := isolationFor(out, "calico-apiserver/calico-apiserver-1")
|
||||
if !in {
|
||||
t.Fatalf("apiserver pod should be isolated for ingress")
|
||||
}
|
||||
script := Render(out)
|
||||
if !strings.Contains(script, "tcp dport 5443 accept") {
|
||||
t.Fatalf("expected TCP/5443 allow:\n%s", script)
|
||||
}
|
||||
// No peer filter — allow-all-on-port.
|
||||
if strings.Contains(script, "ip6 saddr {") || strings.Contains(script, "ip saddr {") {
|
||||
t.Fatalf("expected no peer filter for allow-from-anywhere:\n%s", script)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClusterFixture_LodgeHomeAssistant — pod isolated; only TCP/8080
|
||||
// from edge namespace is allowed.
|
||||
func TestClusterFixture_LodgeHomeAssistant(t *testing.T) {
|
||||
pod := Pod{
|
||||
Namespace: "remote-proxies",
|
||||
Name: "lodge-home-assistant-0",
|
||||
Labels: map[string]string{"app": "lodge-home-assistant"},
|
||||
HostIface: "flock00000002",
|
||||
IPs: []net.IP{mustIP("2001:db8::2")},
|
||||
}
|
||||
traefik := PeerPod{
|
||||
Namespace: "edge", Name: "traefik-0",
|
||||
Labels: map[string]string{"app": "traefik"},
|
||||
IPs: []net.IP{mustIP("2001:db8::aa")},
|
||||
}
|
||||
stranger := PeerPod{
|
||||
Namespace: "default", Name: "random",
|
||||
Labels: map[string]string{"app": "random"},
|
||||
IPs: []net.IP{mustIP("2001:db8::bb")},
|
||||
}
|
||||
out, err := Translate(Inputs{
|
||||
LocalPods: []Pod{pod},
|
||||
PeerPods: []PeerPod{traefik, stranger},
|
||||
Namespaces: []Namespace{
|
||||
{Name: "edge", Labels: map[string]string{"kubernetes.io/metadata.name": "edge"}},
|
||||
{Name: "default", Labels: map[string]string{"kubernetes.io/metadata.name": "default"}},
|
||||
{Name: "remote-proxies", Labels: map[string]string{"kubernetes.io/metadata.name": "remote-proxies"}},
|
||||
},
|
||||
Policies: []netv1.NetworkPolicy{lodgeHomeAssistantPolicy()},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Rules) != 1 {
|
||||
t.Fatalf("expected 1 rule, got %d", len(out.Rules))
|
||||
}
|
||||
r := out.Rules[0]
|
||||
// Peer should be exactly traefik's IP, not stranger's.
|
||||
got := map[string]bool{}
|
||||
for _, c := range r.PeerCIDRs {
|
||||
got[c.IP.String()] = true
|
||||
}
|
||||
if !got["2001:db8::aa"] {
|
||||
t.Fatalf("traefik IP missing from rule: %v", got)
|
||||
}
|
||||
if got["2001:db8::bb"] {
|
||||
t.Fatalf("stranger IP leaked into rule")
|
||||
}
|
||||
script := Render(out)
|
||||
if !strings.Contains(script, "tcp dport 8080 accept") {
|
||||
t.Fatalf("expected TCP/8080 allow:\n%s", script)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClusterFixture_Garage — verifies the two-rule policy:
|
||||
//
|
||||
// 1. ports {3900, 80, 3901} accept from any peer
|
||||
// 2. port 3903 accept only from edge or storage namespaces
|
||||
func TestClusterFixture_Garage(t *testing.T) {
|
||||
pod := Pod{
|
||||
Namespace: "storage", Name: "garage-0",
|
||||
Labels: map[string]string{"app": "garage"},
|
||||
HostIface: "flock00000003",
|
||||
IPs: []net.IP{mustIP("2001:db8::3")},
|
||||
}
|
||||
storagePeer := PeerPod{
|
||||
Namespace: "storage", Name: "garage-1",
|
||||
Labels: map[string]string{"app": "garage"},
|
||||
IPs: []net.IP{mustIP("2001:db8::31")},
|
||||
}
|
||||
edgePeer := PeerPod{
|
||||
Namespace: "edge", Name: "traefik-0",
|
||||
Labels: map[string]string{"app": "traefik"},
|
||||
IPs: []net.IP{mustIP("2001:db8::41")},
|
||||
}
|
||||
stranger := PeerPod{
|
||||
Namespace: "default", Name: "random",
|
||||
Labels: map[string]string{"app": "random"},
|
||||
IPs: []net.IP{mustIP("2001:db8::ff")},
|
||||
}
|
||||
out, err := Translate(Inputs{
|
||||
LocalPods: []Pod{pod},
|
||||
PeerPods: []PeerPod{storagePeer, edgePeer, stranger},
|
||||
Namespaces: []Namespace{
|
||||
{Name: "edge", Labels: map[string]string{"kubernetes.io/metadata.name": "edge"}},
|
||||
{Name: "storage", Labels: map[string]string{"kubernetes.io/metadata.name": "storage"}},
|
||||
{Name: "default", Labels: map[string]string{"kubernetes.io/metadata.name": "default"}},
|
||||
},
|
||||
Policies: []netv1.NetworkPolicy{garageAdminPolicy()},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two ingress rules in the source policy → two Rules out (one per
|
||||
// peer set, ports inline).
|
||||
if len(out.Rules) != 2 {
|
||||
t.Fatalf("expected 2 rules (one per ingress entry), got %d", len(out.Rules))
|
||||
}
|
||||
script := Render(out)
|
||||
for _, want := range []string{
|
||||
"tcp dport 3900 accept",
|
||||
"tcp dport 80 accept",
|
||||
"tcp dport 3901 accept",
|
||||
"tcp dport 3903 accept",
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Errorf("missing %q in script:\n%s", want, script)
|
||||
}
|
||||
}
|
||||
// The 3903 rule must carry a peer filter for both edge and storage
|
||||
// peer IPs but not the stranger.
|
||||
if !strings.Contains(script, "2001:db8::31/128") || !strings.Contains(script, "2001:db8::41/128") {
|
||||
t.Fatalf("expected edge+storage peer IPs in 3903 rule:\n%s", script)
|
||||
}
|
||||
if strings.Contains(script, "2001:db8::ff/128") {
|
||||
t.Fatalf("stranger IP must not appear:\n%s", script)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user