Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Network policy

Which templates every namespace composes, what they add up to, and the one path they cannot govern. Read this when adding a namespace, so its overlay allows exactly what the app needs.

Every non-control-plane namespace gets a default-deny baseline plus explicit opt-in bridges, assembled per namespace from shared templates in infra/policies/namespaces/_templates/:

TemplateWhen
netpol-default-denyAlways
netpol-allow-same-namespaceAlways
netpol-allow-from-monitoringAlways, except in monitoring itself
netpol-allow-from-ingress-internalOnly if the namespace ships an Ingress with ingressClassName: internal
netpol-allow-from-ingress-edgeOnly if the namespace ships an Ingress with ingressClassName: edge

What that composes to, for one namespace: a wall with named holes in it, and one path that goes around the wall entirely. Each edge is labelled with the template that opens it, minus the netpol- prefix every template name carries. Red is the traffic the baseline drops. The ingress-edge bridge is the loose one: traefik-edge runs on hostNetwork, so the rule that admits it matches the mesh CIDR rather than a pod identity.

any namespacepods in thesame namespacemonitoring(scrape)ingress-internal(traefik-internal, hostNetwork)ingress-edge(traefik-edge, hostNetwork)everything elseanywherefirewalld + Traefik rate limitingCNI policy never sees either Traefik's socketsits pods  allow-same-namespaceallow-from-monitoringallow-from-ingress-internalallow-from-ingress-edge  default-denyegress is never denied  








Kubernetes has no cluster-wide NetworkPolicy, so this is one overlay per namespace rather than one file. Egress is left open everywhere: the secret operators call out to their APIs, cert-manager calls ACME, and apps call whatever they call. In a single-tenant homelab the risk that matters is inbound.

One thing the baseline cannot cover: both Traefiks run with hostNetwork: true, so each shares the node’s network namespace and CNI policy enforcement never sees its sockets. The ingress-edge and ingress-internal overlays exist and are correct, but they do not govern that traffic. What actually governs it is firewalld (ansible/roles/firewall_ingress, public zone limited to 443 and the hardened SSH port), the fact that the internal ingress binds a mesh address only peers can reach, and Traefik’s own rate limiting.

The same hostNetwork is why both netpol-allow-from-ingress-edge and netpol-allow-from-ingress-internal are an ipBlock and not a namespaceSelector: those connections arrive as the ingress node’s own address, so a selector rule never matches and every request through that ingress returns 502. The block is the whole mesh CIDR from ansible/inventory/group_vars/all/network.yml, not the node’s /32. ipBlock accepts only a literal, and a /32 meant substituting an address that had to be maintained by hand in a second place. The rules therefore trust every mesh peer, not only the ingress; what bounds that is the NetBird policy in tofu/netbird, which decides which peers reach the cluster at all.

The two templates now hold the same rule, and stay separate anyway. They are two decisions that happen to agree: which one a namespace composes still records which ingress it expects traffic from, and either can change without dragging the other with it.

Pod-to-pod across namespaces

The templates above cover the two directions that recur: monitoring scraping everything, and an ingress reaching one namespace. A pod in one namespace calling a pod in another is neither, and six callers do it today. Each namespace they reach admits them with its own file. Every one of these files names the port the pod listens on, not the port its Service publishes.

Glance reaches into two namespaces.

infra/policies/namespaces/monitoring/netpol-allow-from-glance.yaml admits Glance to vmsingle on 8428 and to vlsingle on 9428, because most widgets on Glance’s cluster and network pages are an API query against one of them, and Glance is an ordinary pod that no mesh ipBlock covers. infra/policies/namespaces/gatus/netpol-allow-from-glance.yaml does the same for the Gatus API on 8080, which is where the apps page gets service health.

Open WebUI is the second caller, and reaches into three namespaces.

infra/policies/namespaces/searxng/netpol-allow-from-open-webui.yaml admits it to SearXNG on 8080, which is where its web search runs its queries. It could have gone through search.$SUB_INTERNAL.$DOMAIN instead, but that host sits behind auth-sso and a pod carries no session cookie. infra/policies/namespaces/bifrost/netpol-allow-from-open-webui.yaml admits it to Bifrost on 8080, which is its only model backend. infra/policies/namespaces/kvasir/netpol-allow-from-open-webui.yaml admits it to Kvasir on 8080, which its pipe function calls. Kvasir does compose netpol-allow-from-ingress-internal, but only for its runs page, and that path is behind auth-sso; a pod carries no session cookie, so this file is how the work gets in.

Vane is the third, and reaches the same two namespaces Open WebUI does, for the same two reasons: searxng on 8080 for results, bifrost on 8080 for the model. That is two more files, named after the caller in each of those two overlays.

Kvasir is the fourth, and reaches those same two namespaces for those same two reasons. Two more files again, in the same two overlays.

Bifrost is the fifth caller, and reaches its two in-cluster backends. infra/policies/namespaces/cli-proxy-api/netpol-allow-from-bifrost.yaml admits it to cli-proxy-api on 8317, and infra/policies/namespaces/munin/netpol-allow-from-bifrost.yaml to munin on 11434. Those two matter more than the others: neither app has an Ingress and neither composes netpol-allow-from-ingress-internal, so with the default deny in place each of these holes is the whole of that app’s reachability. That is what lets cli-proxy-api’s config.yaml ship an empty api-keys list and munin run with no credential at all.

Each is a file in its own overlay rather than a template in _templates/: each names one namespace, and a further caller should get its own file rather than a selector wide enough to hide who reads what.

The alternative was to point Glance at metrics.$SUB_INTERNAL.$DOMAIN, which needs no policy at all since the request then arrives from traefik-internal. That routes every widget out to the mesh interface and back, and leaves the dependency written down nowhere.

Rate limiting

Every namespace with an Ingress also composes the middleware-ratelimit template, a Traefik Middleware at average: 100, burst: 200, per source IP. It is basic DoS protection, not a precise budget.

Composing the template alone does nothing. Traefik only applies a Middleware to routers that name it, so the Ingress must reference it explicitly:

annotations:
  traefik.ingress.kubernetes.io/router.middlewares: <namespace>-ratelimit@kubernetescrd

Same-namespace reference only. traefik-edge’s kubernetesCRD provider does not set allowCrossNamespace, unlike traefik-internal, so a shared cross-namespace Middleware would not resolve there.

Verify a new namespace’s policy the way Pod to mesh networking does: from a pod in another namespace, confirm the connection is refused, then confirm the intended bridge works.