OPERATOR DECISION 2026-09-06: "use the VIP as intended — a virtual address to reach
the cluster and provide fault tolerant HA. Without a change it will not work in HA."
THE DEFECT. Cilium's kube-proxy replacement dialled a SINGLE NODE. Re-confirmed live
before changing anything: all three prod cilium-agent pods carried
KUBERNETES_SERVICE_HOST=192.168.2.10 — k3s-prod-1's own address — while the kube-vip
VIP 192.168.2.9 was Running on all three nodes with a held lease. Lose prod-1 and the
CNI's control-plane path is dead on an "HA" cluster. clusters/prod's own comment said
the value must be the VIP and NOT .10, then called it "Not yet decided" — I treated
that stale comment as an open question when the evidence already settled it.
SPLIT, NOT REPOINT, because k3s_api_host had two consumers with opposite needs:
k3s_api_host unchanged — the FOUNDER's IP: the cold-bootstrap join
target and a tls-san. infra/tofu/outputs.tf GENERATES this
name, so renaming would ripple into tofu and the runbooks.
k3s_apiserver_endpoint NEW = {{ kube_vip_address }} — the HA address; what Cilium
consumes. Derived, not restated, so an environment overrides
ONE line and the VIP, the SAN and the CNI endpoint move
together. Prod needed no new override.
THE SECOND HAZARD, WHICH MY BRIEF DID NOT NAME and the agent found: pointing Cilium at
the VIP breaks a COLD bootstrap for the same reason the join would — site.yml ran
node-baseline -> k3s -> cilium, so Cilium would install against an address nothing
announced yet. That is the 2026-09-01 deadlock shape. Fixed by SEQUENCE, not value:
site.yml now imports kube-vip.yml BETWEEN k3s and cilium. It cannot go earlier
(kube-vip is a k3s auto-deploy manifest, so it needs a cluster) and it works there
because the DaemonSet is hostNetwork with KUBERNETES_SERVICE_HOST=127.0.0.1 — needing
neither CNI nor kube-proxy. roles/kube_vip gained a read-only `wait_for` on the VIP so
a cold run stops THERE with a named cause instead of surfacing as unreachable agents.
Also fixed a pre-existing bug that only mattered once this play joined site.yml: the
role's read-only interface probe ran without `check_mode: false`, so under --check it
was skipped and the assert fired with a misleading "set kube_vip_interface explicitly".
VERIFIED BY ME, not just reported:
renders dev K3S_API_HOST=192.168.1.49 prod=192.168.2.9 (each environment's VIP)
cilium template consumes k3s_apiserver_endpoint, not k3s_api_host
cold join still `--server https://{{ k3s_api_host }}:6443` — the founder
order site.yml: node-baseline -> k3s -> kube-vip -> cilium -> verify -> flux
gate full CI gate PASS
Agent-side, additionally: in-memory render of cilium-values.yaml.j2 per host showing
k8sServiceHost=the VIP with the join still on the founder; k3s_tls_sans renders
['.10','.9','127.0.0.1','.11','.12'] so NO cert reissue and NO k3s restart is involved
— .9 already answers under full CA verification; and --check --diff of kube-vip.yml
against prod reported 6 ok / 0 changed, i.e. the role edits change nothing about the
kube-vip prod already runs.
ha-control-plane-as-code 92 -> 112 assertions, turning two escalations into machine
checks: the Cilium template must use the endpoint var; the endpoint must derive from
kube_vip_address and k3s_api_host must not; per environment K3S_API_HOST must equal
that environment's VIP and be none of its node IPs (the [parity] contract, now
machine-checked rather than a comment); site.yml must order k3s -> kube-vip -> cilium.
Every new assertion mutation-tested and bites. One honest negative recorded: reverting
only the BASE default is not caught, because both overlays patch the key — the check
reads effective per-environment values, so the base default is unguarded.
APPLY ORDER, and this is the operator's step — nothing was applied:
PROD FIRST, and it is the low-risk direction (prod is the broken one). Push and let
Flux reconcile platform/cilium: the value change rolls the cilium-agent DaemonSet
ONE NODE AT A TIME (maxUnavailable: 1 — the knob that bounded the 2026-09-01
incident), it will not proceed past an unready pod, existing datapath survives an
agent restart, and .9 is already live before the change lands.
DEV SEPARATELY, operator present. Same mechanics but live dev agents dial
192.168.1.50, and dev is mid-DNS-migration on dev-live. Do not fold it into that.
ANSIBLE: nothing to run for either live cluster; the Ansible half matters only for a
cold build. Remove the now-redundant standalone kube-vip.yml step from any runbook.
NEW NAMED GAP, unwritten: the L2 test cannot assert the running agents' live
KUBERNETES_SERVICE_HOST, which is the drift the L0 check cannot see. Recorded in that
test's gap list.
Refs R-PLAT-01, R-NET-01, R-ENV-02.
Co-Authored-By: Claude Opus 5 <[email protected]>
platform/vpn-egress — VPN egress steering (Phase 3, ADR-0015)
vpn-class services (sonarr, radarr, jackett, qbittorrent) egress only through a PIA VPN
tunnel; internet-class services go out the normal WAN. Serves R-NET-05/06/07.
Egress is realized per ADR-0015
(spec docs/03): a node-level host OpenVPN split-tunnel on a labeled egress-gateway node, steered
by a Cilium Egress Gateway policy, with a host nftables kill-switch. There is no in-cluster
gateway pod — the tunnel is host infrastructure, not a Flux workload.
STATUS: live + conformance-green. The node tunnel was validated end-to-end; the
egress-class(R-NET-05/07) andvpn-killswitch(R-NET-06) conformance tests pass against the live cluster.
How it works (ADR-0015)
Three layers, two owners:
- Tunnel — host OpenVPN (Ansible). A labeled egress-gateway node runs a host OpenVPN systemd
service (PIA over OpenVPN-UDP — provider/protocol still per ADR-0013). It is split-tunnel
(
route-noexec+ policy routing): it createstun0but never becomes the node default route, so the node's own traffic (control plane, image pulls, other pods) stays on the WAN. Managed by the Ansible roleinfra/ansible/roles/vpn_egress_gateway/, like k3s itself — not a cluster workload. - Steering — Cilium Egress Gateway (GitOps).
egress-steering.yamlholds aCiliumEgressGatewayPolicythat selects pods labeledhomelab.keithjackson.dev/egress-class=vpn(Kyverno-enforced) and SNATs them out the gateway node'stun0. Native Cilium eBPF — no client-pod changes, no sidecar, no overlay.excludedCIDRskeeps RFC1918 / cluster / LAN traffic on the local datapath; only public destinations leave via the tunnel. - Kill-switch — host nftables (Ansible). A host nftables rule on the gateway node DROPS the steered
vpn-class traffic whenevertun0is down (fail-closed, R-NET-06), scoped to vpn-class only sointernet-class and node traffic are unaffected.
Secrets — host-side now
PIA credentials + the .ovpn live on the gateway node via Ansible vault (see spec docs/07), not
a cluster SOPS Secret. SOPS still governs in-cluster secrets; VPN egress simply no longer needs one.
The pia-credentials.sops.yaml and pia-custom-ovpn.sops.yaml secrets in this directory are now
UNUSED — leftovers from the retired Gluetun pod, retained pending a separate cleanup change.
Pieces in this directory
namespace.yaml— thevpn-egressnamespace (now holds only the Cilium policy + the retained, unused secrets; no privileged gateway pod under ADR-0015).egress-steering.yaml— theCiliumEgressGatewayPolicy(the steering datapath).pia-credentials.sops.yaml,pia-custom-ovpn.sops.yaml— unused, retained pending cleanup.
History (brief)
ADR-0013 originally placed the VPN in a single shared Gluetun pod and deferred the steering
mechanism. Implementation proved that a Gluetun-in-a-pod tunnel cannot be a Cilium Egress Gateway
target: the policy SNATs out a node interface, but the pod's tun0 lives in the pod netns, and
running Gluetun hostNetwork to expose tun0 on the node hijacks the node's default route (breaking
k3s/etcd/kubelet). ADR-0015 resolved this by moving the tunnel to the node (host OpenVPN
split-tunnel) and steering with Cilium's native egress gateway. See ADR-0015 for the full rationale.
Port forwarding (qBittorrent) — follow-up
PIA assigns a forwarded port that can change on reconnect. Under ADR-0015 this is re-solved host-side (a small pf/natpmp helper on the gateway node that publishes the assigned port to the app) — Phase 6, alongside the qBittorrent app.