Files
home-network-k3s-impl/platform/vpn-egress/egress-steering.yaml
T
jakman85andClaude Opus 5 d1a6628a97
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
feat(ha): the CNI dials the control-plane VIP — kube-vip becomes a CNI prerequisite
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]>
2026-09-06 22:17:30 -05:00

84 lines
5.3 KiB
YAML

---
# ============================================================================
# Cilium Egress Gateway steering for `egress-class=vpn` pods — ADR-0015.
# Serves: R-NET-04, R-NET-05, R-NET-06, R-NET-07.
# R-NET-04 added 2026-09-06: this file IS the R-NET-04 datapath — the rule that selects between the
# two egress paths. Omitting it made `run.sh --status` report R-NET-04 as "TEST-ONLY / impl none"
# even though the implementation is right here.
#
# ##########################################################################
# ## LIVE (2026-08-24): wired into kustomization.yaml, reconciled by Flux ##
# ## and conformance-green (egress-class + vpn-killswitch pass on dev). ##
# ##########################################################################
#
# This policy steers vpn-class pods out the gateway node's host `tun0` interface. It REQUIRES the
# Ansible-managed host OpenVPN split-tunnel service on a node labeled
# `homelab.keithjackson.dev/egress-gateway=true` — deploy that role BEFORE this policy reconciles, or
# vpn-class pods are SNAT'd to a non-existent interface. The tunnel is live and validated end-to-end;
# if tun0 is ever down, the host nftables kill-switch fails vpn-class CLOSED (see below). ADR-0015
# chose this mechanism (node tunnel + Cilium Egress Gateway); this file IS that datapath.
#
# ---------------------------------------------------------------------------
# PREREQUISITES CONFIRMED (2026-08-24):
# * Cilium Egress Gateway is ENABLED per ADR-0002 — platform/cilium/helmrelease.yaml and
# platform/cilium/values.yaml both set (all [parity] with the Ansible bootstrap render):
# egressGateway.enabled: true
# bpf.masquerade: true (Egress Gateway REQUIRES eBPF masquerade)
# kubeProxyReplacement: true
# No Cilium HelmRelease change is needed for this policy. (If any of the three were false this
# would be an ESCALATION, not a silent HelmRelease edit — they are all true, so we proceed.)
#
# INTERFACE CONTRACT (shared verbatim with the parallel Ansible host-tunnel role — ADR-0015):
# * Gateway node label ...... homelab.keithjackson.dev/egress-gateway=true (applied by Ansible)
# * Host tunnel interface .... tun0 (split-tunnel OpenVPN)
# * Client selection ......... homelab.keithjackson.dev/egress-class=vpn (Kyverno-enforced)
# ---------------------------------------------------------------------------
apiVersion: cilium.io/v2
kind: CiliumEgressGatewayPolicy
metadata:
name: vpn-egress-steering
annotations:
homelab.keithjackson.dev/requirements: R-NET-04,R-NET-05,R-NET-06,R-NET-07
homelab.keithjackson.dev/adr: ADR-0015
spec:
# --- Client selection (INVARIANTS 1 & 2) --------------------------------
# ONLY pods explicitly opted into the vpn class are matched. Default/internet-class pods and node
# traffic are never selected, so they stay on the local/private-WAN path (invariant 1: default is
# the local path). Because ONLY these flows are steered, tun0 never becomes a default route for the
# node or for any other pod (invariant 2). The selector is intentionally cluster-wide (no namespace
# scope) so the class label alone is the contract — every namespace's vpn-class pods route here.
selectors:
- podSelector:
matchLabels:
homelab.keithjackson.dev/egress-class: vpn
# --- What gets tunneled (INVARIANT 1: only PUBLIC internet leaves via the VPN) ------------------
# destinationCIDRs is the full internet; excludedCIDRs carves out everything that must stay LOCAL:
# all RFC1918 private ranges (which also cover the Cilium cluster-pool pod CIDR 10.0.0.0/8, the k3s
# service/pod ranges, and the LAN 192.168.x/10.73.x node network). So intra-cluster east-west,
# pod->service, and LAN traffic are NOT SNAT'd out tun0 — only genuinely public destinations are.
# Excluded flows fall through to Cilium's normal local datapath (invariant 1).
destinationCIDRs:
- "0.0.0.0/0"
excludedCIDRs:
- "10.0.0.0/8" # RFC1918 + Cilium cluster-pool pod CIDR (CLUSTER_POD_CIDR) + k3s service net
- "172.16.0.0/12" # RFC1918
- "192.168.0.0/16" # RFC1918 (home LAN / node network family)
# --- Where it egresses --------------------------------------------------
# The gateway node (Ansible-labeled) SNATs matched traffic out its host split-tunnel `tun0`. When
# tun0 is absent/down, Cilium has no egress interface here; the host nftables kill-switch (Ansible,
# ADR-0015) is the authoritative fail-closed backstop that DROPS this vpn-class traffic — scoped to
# vpn-class only, so default/internet-class is unaffected (invariants 3 & 4: vpn fails CLOSED,
# internet fails OPEN).
egressGateway:
nodeSelector:
matchLabels:
homelab.keithjackson.dev/egress-gateway: "true"
interface: tun0
# ---------------------------------------------------------------------------
# DONE (2026-08-24): enabled + validated + Gluetun retired.
# * Host tun0 split-tunnel is up on the egress-gateway node; host nftables kill-switch in place.
# * This file is wired into platform/vpn-egress/kustomization.yaml and reconciled by Flux.
# * `egress-class` (R-NET-05/07) and `vpn-killswitch` (R-NET-06) conformance tests PASS live.
# * The Gluetun Deployment was retired per ADR-0015 ("Gluetun is dropped as the gateway").
# ---------------------------------------------------------------------------