The operator ruled the implementation repo's main (50c8719) the latest and accepted state. Sync the spec to it. Accepted changes are reflected: one bare-metal environment, TrueNAS bulk storage, R2 off-cluster backup, CloudNativePG, single-node VPN gateway with Cilium fail-closed, inbound WireGuard, per-app auth sidecars, Renovate, the CI gate as approver, and the pm-agent. New ADR-0017..0025; ADR-0016 corrected and accepted; R-* IDs unchanged, eight amended. Where the implementation has simply not met a requirement, the requirement stands and the shortfall is recorded in docs/16-known-gaps.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
13 KiB
03 — Networking, Isolation & Egress
Serves: R-NET-01..07, R-SEC-02. Related: ADR-0002 (Cilium), ADR-0015 (VPN egress steering), ADR-0020 (single gateway, Cilium fail-closed), ADR-0021 (inbound remote access).
This is the most safety-critical spec. Get default-deny and the VPN kill-switch right; everything else is convenience.
Principles
- Deny by default, allow by exception. No service may talk to another unless a rule explicitly permits it, at the port/protocol level (R-NET-01/02).
- Egress is a declared property. Each service declares an egress class; the platform routes it accordingly (R-NET-05/07).
- VPN means VPN-only. A VPN-class service must never leak to the plain internet, including during tunnel failure (R-NET-06, kill-switch).
- Isolation is auditable. An operator can prove what a service can and cannot reach (R-SEC-03).
CNI: Cilium
Cilium replaces the default k3s CNI and provides:
- NetworkPolicy / CiliumNetworkPolicy for L3/L4 (and optional L7) enforcement.
- kube-proxy replacement (eBPF) for efficient service routing.
- Egress Gateway to force selected pods' egress through a chosen node/identity/SNAT IP — the building block for WAN/VPN path selection.
- Hubble for flow observability — the audit and troubleshooting tool for isolation.
- Gateway API implementation for north-south ingress (see 09).
Segmentation model
- Namespaces as trust zones. Each service (or tightly-coupled service group) gets its own namespace. Namespace = isolation boundary.
- Egress classes as zones:
internetandvpnare first-class labels that drive both network policy and egress routing. - Platform namespace(s) (Flux, cert-manager, monitoring, storage) are isolated from application namespaces; only the specific flows they need (e.g. metrics scraping) are opened.
Platform label-key convention. The spec fixes label values (e.g. internet|vpn,
oidc|forward-auth|native|none); the label keys the platform uses are:
homelab.keithjackson.dev/egress-class, homelab.keithjackson.dev/auth-mode, and the supporting
homelab.keithjackson.dev/{app-namespace,baseline}. These keys are the contract that network
policies, Kyverno rules, and renderers key off (values per this doc, 13,
and 15).
Default-deny baseline (R-NET-01)
Every namespace gets a baseline policy set applied by GitOps at namespace creation:
- Deny all ingress and deny all egress by default.
- Then explicitly allow only:
- DNS to the in-cluster resolver (required for anything to function).
- Declared intra-cluster dependencies (source → destination → port).
- The declared egress class path (internet or VPN).
- Observability scraping from the monitoring namespace (metrics/logs).
A workload with no explicit allow rules can do nothing except resolve DNS. This is the intended starting posture for every onboarded service.
Explicit allow rules (R-NET-02)
Service dependencies from the catalog compile into allow rules. Example intent (illustrative, not final manifest):
seerr → sonarr tcp/8989 (Seerr requests to Sonarr)
seerr → radarr tcp/7878 (Seerr requests to Radarr)
seerr → plex tcp/32400 (Seerr talks to Plex)
Rules are declared once in the service's catalog entry (its dependencies) and rendered into
CiliumNetworkPolicies by the platform. Reviewers see the intent, not hand-written policy YAML.
Egress routing: two paths, one physical WAN (R-NET-04/05 — resolved by OQ-1)
The environment has one physical commercial internet WAN and no plans for additional ISP uplinks or physical WANs (OQ-1). "Multiple WANs" from the original intent is therefore realized not as multi-ISP routing but as two egress paths selected by routing rules:
| Profile | Meaning | Mechanism |
|---|---|---|
internet |
Out the single commercial WAN | Normal pod egress via node → SOHO edge router → internet |
vpn |
Routed through the in-cluster VPN tunnel | Steered to the shared VPN egress gateway (ADR-0015, ADR-0020) |
- Physical multi-WAN (multi-ISP) is out of scope. With a single WAN there is nothing to policy-
route across; the
internetpath is simply the default route out. - The edge router is a dumb SOHO device — no VLANs, no VPN, no services, no API/IaC. Therefore all segmentation and all VPN handling live in-cluster (Cilium policy + the VPN gateway), not on the edge. The edge's only jobs are NAT out and a few manual port-forwards that are not web ingress: WireGuard udp/51820 and Plex tcp/32400 (09). Public web ingress uses an outbound-only Cloudflare Tunnel from in-cluster cloudflared (ADR-0016), so the edge does not forward 80/443.
- The service-facing contract is unchanged: a service still just declares
egress: internet|vpn.
VPN egress model: node-level tunnel + Cilium Egress Gateway (R-NET-05/06 — ADR-0015, ADR-0020; provider ADR-0013)
VPN-class services: sonarr, radarr, jackett, qbittorrent. Per OQ-3 the design is a single,
shared VPN connection — not a per-service sidecar — so that network rules are enforced outside
the service. The steering mechanism was settled by ADR-0015 after the original in-cluster-Gluetun
approach proved un-steerable (a Gluetun pod's tun0 is invisible to the node datapath, and
hostNetwork Gluetun hijacks the node's own default route).
Default is the WAN; the VPN is strictly opt-in. Every pod — and the nodes themselves — egress the
local/private WAN by default and never the VPN. Only services labeled egress-class=vpn are steered to
the tunnel, and the kill-switch blackholes only that class. The two classes fail in opposite
directions: internet fails open (WAN, never blackholed); vpn fails closed (blackhole, never leaks
to WAN). See the ADR-0015 invariants — implementation must not route the default through the VPN or blackhole
the wrong class.
The model:
- Tunnel on the node, not in a pod. Exactly one labeled egress-gateway node runs a host OpenVPN
systemd service — PIA over OpenVPN-UDP (provider/protocol per ADR-0013; "non-TCP or better"),
Ansible-managed host infra (like k3s itself), NOT a Flux workload. It is split-tunnel: it
creates
tun0but does not set the node's default route (route-noexec+ policy routing), so the node's own traffic stays on the WAN — only what Cilium points attun0uses the VPN. The tunnel is on the cluster nodes, not the edge router (dumb SOHO edge, per OQ-1). vpn-class pods are steered by Cilium Egress Gateway. ACiliumEgressGatewayPolicyselects pods labeledegress-class=vpnand egresses them via the gateway node'stun0(native eBPF SNAT to the tunnel IP). No client-pod changes, no sidecar, no overlay. They have no independent egress path; the service is just labeledegress: vpnand cannot leak on its own. Only public destinations are tunnelled; private ranges (the cluster, the LAN, bulk storage) stay on the local datapath.- Kill-switch (fail-closed) — Cilium's egress datapath (ADR-0020). Steered traffic is forwarded in
eBPF, and Cilium drops it whenever the gateway's tunnel interface cannot be used. If the tunnel is
down,
vpnpods have no route to the plain internet;internet-class services are unaffected. Steered pod traffic never reaches netfilter, so a host nftables table on the gateway is a narrow backstop only: it covers node-originated traffic sourced from a tunnel address. - Re-steering across reconnects. Cilium resolves the tunnel interface once and does not follow its re-creation, so every reconnect is signalled back to Cilium from the gateway node, without writing to GitOps state. A reconnect Cilium was not told about raises an alert (10).
- Port forwarding for qBittorrent: PIA NAT-PMP, re-solved host-side (a small pf/natpmp helper on the gateway node that publishes the assigned forwarded port into qBittorrent) — kept hands-off. Alert if PF acquisition fails (see known gaps).
- Resilience: one gateway, no failover (ADR-0020). Cilium OSS resolves the gateway selector to a
single node, so a second labeled node is not a standby: it silently takes the role, and the public
egress IP, by name order. Label exactly one node. Losing it is a
vpn-class outage, fail-closed: no leak and no service. Alert on tunnel state (10). - Prerequisite: the PIA username/password +
.ovpnon the gateway node via Ansible vault (07) — the tunnel is host infra, so its creds are host-side (not a cluster SOPS Secret). Validate end-to-end (tunnel + steering + kill-switch + PF) by conformance test (14).
Kill-switch is an acceptance gate: with the tunnel forcibly down, every vpn service must fail
closed (no packets to the plain internet), while internet-class is unaffected. Tested, not assumed.
Remote access (inbound): WireGuard (ADR-0021)
The operator's devices reach the home network through a WireGuard listener on udp/51820. This is
the opposite direction to vpn egress and shares nothing with it: vpn egress sends pods out
through the provider tunnel; remote access lets clients in. It is the "VPN" in "LAN/VPN-only".
- Host network, every node. A Flux-managed listener runs in the host network namespace on all nodes. A pod-networked server would give every client that pod's Cilium identity, and every Gateway hostname would refuse it. A listener on each node makes loss of the forwarded node a one-line router change, with no new client profiles.
- A deliberate exception to default-deny. A host-network workload has no Cilium endpoint, so no network policy constrains it. The boundary around the port is WireGuard's peer authentication: a packet from an unknown key gets no reply. The exception applies to this platform component only; application namespaces may not use the host network.
- Authorization is reviewable. Peers are public keys in plain text in git, so granting or revoking a device is a reviewed change. The server's private key is SOPS-encrypted.
- Reach equals a LAN machine. An enrolled device can reach the whole LAN, including the nodes'
control-plane ports, and egresses through the house WAN. A lost device must be revoked promptly.
Clients stay
internetclass and are never steered to the provider tunnel.
Ingress (north-south) — summary
North-south exposure is handled by Gateway API implemented by Cilium, with TLS from
cert-manager and DNS from ExternalDNS. Detailed in 09-dns-and-ingress.md.
Exposure is a declared property: internal (LAN/VPN-only) vs public (published through the
Cloudflare Tunnel, ADR-0016).
Observability of the network (supports R-SEC-03, R-OBS-04)
- Hubble provides flow visibility to verify default-deny and troubleshoot allow rules.
- Alert on: VPN tunnel down, egress-gateway unhealthy, unexpected denied flows spiking, policy
reconciliation errors. Wired into
10-observability-and-alerting.md.
Acceptance gates for this spec
- A freshly onboarded service with no rules can reach nothing but DNS (default-deny proven via Hubble + a connectivity probe).
- Declared dependencies work; undeclared ones are blocked.
- A
vpnservice's public egress IP is the VPN's, not the home IP (verified). - Kill-switch: tunnel down ⇒
vpnservice has no internet path;internetservices unaffected. - Changing a service's
egressclass in git flips its path with no workload rearchitecting.
Resolved
- OQ-1 — one commercial WAN, no multi-ISP plans; dumb SOHO edge (no VLAN/VPN/services). All segmentation + VPN in-cluster; edge does NAT + port-forward only. Multi-ISP routing out of scope. Amended 2026-11-08 (ADR-0016): 80/443 are no longer forwarded; public web ingress is a Cloudflare Tunnel.
- OQ-3 — PIA over OpenVPN-UDP (ADR-0013); egress is a node-level host tunnel steered by Cilium Egress Gateway and failing closed in Cilium's datapath (ADR-0015, ADR-0020), rules enforced outside the service.
Open questions
- Which PF-enabled PIA region to use, and the port-sync helper wiring for qBittorrent.