Compare commits

...
Author SHA1 Message Date
jakman85andClaude Opus 5.5 9a69f6906a fix(cilium): point cgroup.hostRoot at the host's real cgroup2 root
gate / gate (pull_request) Successful in 1m47s
gate / conformance (pull_request) Has been skipped
hostRoot was /run/cilium/cgroupv2 with autoMount disabled, but nothing
mounts cgroup2 there. The agent mounted one from inside its own private
cgroup namespace, so socket-LB attached to the agent's own cgroup and
host processes got no Service/LB translation. k3s-prod-1 (Gateway L2
lease holder + kube-vip VIP) therefore could not reach the Gateway VIP,
and its apiserver's OIDC authenticator has failed since 2026-09-12.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 18:07:18 -05:00
jakman85 7ef2af205d Merge pull request 'fix(node-baseline): pin the OIDC issuer to the Gateway VIP on prod nodes' (#41) from fix/node-keycloak-hosts-pin into main
gate / gate (push) Successful in 1m45s
gate / conformance (push) Has been skipped
Reviewed-on: #41
2026-10-03 22:46:38 +00:00
jakman85andClaude Opus 5.5 788eaeae62 fix(node-baseline): pin the OIDC issuer to the Gateway VIP on prod nodes
gate / gate (pull_request) Successful in 1m46s
gate / conformance (pull_request) Has been skipped
After public DNS moved to the WAN IP (#40), prod nodes resolve
keycloak.home.keithjackson.dev to a WAN address the router does not
hairpin, so the apiserver could not reach its OIDC issuer. Adds an opt-in
node_baseline_hosts_pins /etc/hosts block; prod pins keycloak to
GATEWAY_VIP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 17:39:37 -05:00
jakman85 576e1abb19 Merge pull request 'feat(dns): publish Gateway names to the WAN via in-cluster DDNS' (#40) from feat/public-dns-wan-target into main
gate / gate (push) Successful in 1m45s
gate / conformance (push) Has been skipped
Reviewed-on: #40
2026-10-03 22:25:13 +00:00
8 changed files with 198 additions and 20 deletions

No files matched your search

@@ -0,0 +1,76 @@
---
date: 2026-10-03
branch: fix/cilium-cgroup-hostroot
title: Cilium socket-LB never covered host processes — cgroup.hostRoot pointed at an unmounted path
---
## What changed
`cgroup.hostRoot` changes from `/run/cilium/cgroupv2` to `/sys/fs/cgroup` in all three parity
copies: `platform/cilium/helmrelease.yaml` (authoritative), `platform/cilium/values.yaml`
(mirror) and `infra/ansible/roles/cilium/templates/cilium-values.yaml.j2` (bootstrap).
`autoMount` stays `false`.
**The defect.** With `autoMount: false`, the Cilium chart only bind-mounts whatever exists at
`hostRoot`, and expects the operator to have mounted cgroup2 there (cilium 1.20.1 values.yaml).
The repo's rationale was "k3s provides the cgroupv2 mount". That is true only for
`/sys/fs/cgroup`. Nothing mounts cgroup2 at `/run/cilium/cgroupv2` on these nodes; it is an
empty `DirectoryOrCreate` hostPath. So the agent mounted cgroup2 there itself, from inside its
own private cgroup namespace, and that mount's root is the agent container's own leaf cgroup.
The socket-LB programs (`cil_sock4_connect` etc.) were attached to it and covered only the
agent.
**The consequence.** Every other host-netns process got no Service/LB translation:
- Service ClusterIPs were unreachable from every node's host.
- The node holding the Gateway's L2 lease could not reach the Gateway VIP (`no route to host`).
Other nodes reached it only because their packets crossed the wire to the lease holder, where
the tc datapath handles them.
- k3s-prod-1 holds both the Gateway lease and the kube-vip API VIP. Its apiserver could not reach
the OIDC issuer, logging `oidc: authenticator not initialized` since at least 2026-09-12
(899 occurrences by 2026-10-03). kubectl SSO through the VIP therefore failed, and in-cluster
OIDC clients failed on whichever calls landed on that apiserver.
- Webhooks and aggregated APIs were unaffected only because k3s runs
`--enable-aggregator-routing=true` (endpoint IPs, not ClusterIPs).
Cilium's own source (v1.20.1 `bpf/bpf_sock.c`, `__sock4_xlate_fwd`) handles exactly this case
when the hook does run: a host-netns connect to an L7-LB Service (the Gateway) is rewritten to
`127.0.0.1:<l7_lb_proxy_port>`, the local Envoy listener. The fix is to make the hook cover the
host.
## What I verified
- `cilium-dbg status --verbose` on k3s-prod-1: `Socket LB: Enabled`, `Coverage: Hostns-only`.
The live cilium-config shows `bpf-lb-sock=false`, but socket LB is implied by kube-proxy
replacement. The Gateway Service frontends `192.168.2.20:{80,443}` and
`10.43.225.79:{80,443}` map to `127.0.0.1:14337`.
- Host: `findmnt /run/cilium/cgroupv2` is empty on all three nodes, and `/sys/fs/cgroup` is
`cgroup2`.
- In the agent: `/run/cilium/cgroupv2` is a `cgroup2 none` mount, the agent's own cgroup reads
`0::/`, the mount has 0 child cgroups, and `bpftool cgroup show /run/cilium/cgroupv2` lists all
`cil_sock*` programs. k3s runs in `0::/system.slice/k3s.service`, and the agent container in
`/kubepods.slice/…/cri-containerd-3ad04cde….scope`.
- **The decisive test**, run on k3s-prod-1 in the same netns: raw TCP connect to
`192.168.2.20:443`, `10.43.225.79:443` and `10.43.59.141:8080` CONNECTED from inside the agent
container (covered cgroup) and FAILED from the host (`No route to host` / timeout).
- Blast radius: host-network pods are cilium, cilium-envoy, cilium-operator (API via
`192.168.2.9:6443`, not a Service), kube-vip (`127.0.0.1:6443`, `svc_enable=false`) and
node-exporter (no outbound). Nothing depends on host Service translation being absent; the
change only makes Service/LB addresses reachable from the host where they currently fail.
- `helm template cilium/cilium --version 1.20.1` with the new values, in the toolbox: the
DaemonSet bind-mounts hostPath `/sys/fs/cgroup` into `cilium-agent` and `clean-cilium-state`,
the cilium-config shows `cgroup-root: "/sys/fs/cgroup"`, and the init containers are unchanged
(no `mount-cgroup`).
- Toolbox gate: see the PR description.
## What I did NOT verify
- **The rollout itself.** After merge, the agent DaemonSet rolls one node at a time
(`maxUnavailable: 1`). On each node, afterwards: host TCP connects to the three targets above
must succeed. On k3s-prod-1, the apiserver's background OIDC init retry must stop logging
`authenticator not initialized` with no k3s restart.
- That the old programs, attached to the previous agent container's cgroup, disappear with it.
Expected, because cgroup BPF attachments die with the cgroup, but not observed.
- An end-to-end kubectl OIDC login (needs a browser).
- No regression test was added. A natural one is an L2 conformance check that a host-netns
connect to the Gateway VIP succeeds on the lease holder, but `conformance/**` is a sensitive
path. Left as a follow-up.
@@ -0,0 +1,45 @@
---
date: 2026-10-03
branch: fix/node-keycloak-hosts-pin
title: Pin the OIDC issuer to the Gateway VIP on prod nodes — the WAN answer does not hairpin
---
## What changed
Follow-up to PR #40 (`decisions/2026-10-03-public-dns-wan-target.md`). Public DNS for
`*.home.keithjackson.dev` now CNAMEs to the WAN IP. The prod nodes resolve through public DNS,
and the router does not hairpin, so the kube-apiserver could no longer reach its OIDC issuer
`keycloak.home.keithjackson.dev`.
- `roles/node_baseline`: a new `node_baseline_hosts_pins` list (default `[]`) and one
`blockinfile` task (tags `dns`, `hosts`). It writes the pins into `/etc/hosts` as a single
marked block. An empty list removes the block.
- `group_vars/k3s_prod/vars.yml`: pins `keycloak.{{ k3s_base_domain }}` → `192.168.2.20`, which
must stay equal to `GATEWAY_VIP` (parity comment added). It also replaces the stale "no
/etc/hosts pin is needed" paragraph with the measured reason the pin is now required.
Dev is unaffected (empty default).
## What I verified
- After PR #40 reconciled (`platform` lastAppliedRevision `576e1ab`, Healthy=True), on
k3s-prod-2 and k3s-prod-3: `getent hosts keycloak.home.keithjackson.dev` →
`136.51.115.252 wan.home.keithjackson.dev`. `curl https://keycloak…/realms/homelab/.well-known/openid-configuration`
→ exit 60, `ssl_verify_result=18` (the router's self-signed cert, not the Gateway).
- `journalctl -u k3s` (last 20 min) showed no OIDC/JWKS errors yet. The apiserver still holds
the keys it cached before the flip, so the failure is latent until a k3s restart or key rotation.
- Neither node has cloud-init (`/etc/cloud` absent), so nothing rewrites `/etc/hosts` at boot.
- `--check --diff --tags hosts --limit k3s-prod-2,k3s-prod-3`: each node gets exactly the
three-line marked block `192.168.2.20 keycloak.home.keithjackson.dev`.
- Outside-in, PR #40 works: check-host.net probes from 3 external nodes got `200 OK` from
`https://plex.home.keithjackson.dev/identity` at `136.51.115.252`.
## What I did NOT verify
- **k3s-prod-1**: Ansible cannot reach it. SSH host key verification fails (`known_hosts:94`,
ED25519 `SHA256:zgUS8OeWznzCX9hlN7XdZ8iLNkU1G2loXjtJFfEUYpE`), which is consistent with
the 2026-09-11 SSD replacement and reinstall. The operator must confirm the fingerprint
before the pin can be applied there.
- The playbook has not been applied for real. It is an operator run after merge:
`ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags hosts`.
- An end-to-end kubectl OIDC login after the pin (needs a browser).
@@ -306,19 +306,27 @@ node_baseline_dns_servers:
# k3s_base_domain, below). It reaches the issuer through the NODE resolver, i.e. through
# these PUBLIC upstreams — not through CoreDNS's split-horizon answer.
#
# THIS USED TO BE BROKEN, AND THE 2026-09-04 SUBDOMAIN SPLIT FIXED IT. While prod was
# authored to serve dev's hostnames, a prod node resolving keycloak.home.keithjackson.dev
# publicly got DEV's Gateway VIP (192.168.1.53) — the wrong cluster, on the wrong LAN, so
# kubectl OIDC SSO on prod could not work at all. Now prod's issuer is
# keycloak.prod.home.keithjackson.dev, and the public record for that name is published by
# PROD's own external-dns pointing at PROD's Gateway VIP (192.168.2.20). The public answer
# and the split-horizon answer therefore AGREE, and no /etc/hosts pin is needed.
# THE PUBLIC ANSWER IS NOW THE WAN, SO THE ISSUER IS PINNED (2026-10-03). Since
# platform/gateway/gateway.yaml set an external-dns `target`, every public name is a CNAME
# to wan.home.keithjackson.dev = the home WAN IP. The SOHO router does NOT hairpin: from the
# LAN, WAN:443 answers with the router's own self-signed placeholder cert (measured from
# k3s-prod-2/-3 2026-10-03: `curl` exit 60, ssl_verify_result=18). The apiserver would then
# be unable to fetch its issuer's discovery/JWKS, so kubectl OIDC SSO would fail on the
# next k3s restart or Keycloak key rotation. node_baseline_hosts_pins (below) pins the
# issuer host to the Gateway VIP in /etc/hosts, which glibc consults before these
# resolvers. (History: before the 2026-09-04 subdomain split the public answer was DEV's
# VIP; after it, the public answer was prod's own VIP and no pin was needed.)
#
# STILL TO VERIFY on prod bring-up (PROD-PROVISIONING.md §10): this holds only once prod's
# external-dns has actually published the record. Until then the issuer does not resolve
# from the nodes and kubectl SSO fails — the cert-based admin kubeconfig is the break-glass
# path, and it always works. Ordering, not a design gap.
# The cert-based admin kubeconfig never depends on any of this. It is the break-glass path.
node_baseline_dns_search: []
# /etc/hosts pins: node-process names that must reach the in-cluster Gateway directly rather
# than via the public (WAN, non-hairpinned) answer. See "THE PUBLIC ANSWER IS NOW THE WAN" above.
# [parity] the IP MUST equal GATEWAY_VIP in clusters/prod/kustomization.yaml (192.168.2.20),
# and the name MUST follow k3s_oidc_issuer_url's host (roles/k3s_server/defaults/main.yml).
node_baseline_hosts_pins:
- ip: "192.168.2.20"
names:
- "keycloak.{{ k3s_base_domain }}"
# --- platform hostname suffix, ANSIBLE SIDE (R-ENV-02, R-DNS-01) --------------------
# PROD SERVES ITS OWN SUBDOMAIN. [parity] MUST equal BASE_DOMAIN in clusters/prod/
@@ -51,11 +51,14 @@ hubble:
l2announcements:
enabled: true
# k3s ships its own cgroup mount; point Cilium at it instead of auto-mounting.
# Point Cilium at the host's real cgroup2 root (/sys/fs/cgroup, mounted by systemd) instead of
# auto-mounting. NOT /run/cilium/cgroupv2: nothing mounts cgroup2 there, so the agent mounted its
# OWN namespaced cgroup and socket-LB covered only itself — host processes (the apiserver) lost
# Service/LB translation. [parity] platform/cilium/helmrelease.yaml explains (2026-10-03).
cgroup:
autoMount:
enabled: false
hostRoot: /run/cilium/cgroupv2
hostRoot: /sys/fs/cgroup
# Roll the cilium agent DaemonSet ONE node at a time. [parity] must match the
# platform/cilium HelmRelease. The cilium 1.20.1 chart defaults this to 2, which on a
@@ -242,3 +242,9 @@ node_baseline_dns_search:
node_baseline_dns_options:
- timeout:2
- attempts:2
# Static /etc/hosts entries (tags: dns, hosts), as a list of {ip, names}. EMPTY by default:
# dev needs none. Prod pins its OIDC issuer host to the Gateway VIP because the public
# answer for it is the non-hairpinned WAN IP (inventory/group_vars/k3s_prod/vars.yml).
# Written as ONE marked block, so removing an entry here removes it from the node, and
# an empty list removes the block entirely.
node_baseline_hosts_pins: []
@@ -1090,3 +1090,22 @@
{% endif %}
when: node_baseline_dns_mechanism == 'resolv-conf-file'
tags: [dns]
# /etc/hosts pins — tag `dns` (and `hosts` on its own). Some node processes must reach a
# public hostname at the in-cluster Gateway, not at its public answer. On prod, the
# kube-apiserver's OIDC issuer resolves publicly to the WAN IP, which the router does not
# hairpin. glibc reads /etc/hosts before any nameserver (nsswitch `files dns`), so a pin wins
# regardless of which resolver mechanism above is in effect. One marked block: entries
# dropped from node_baseline_hosts_pins disappear from the node, and an empty list
# removes the block.
- name: Pin node-process hostnames in /etc/hosts
ansible.builtin.blockinfile:
path: /etc/hosts
marker: "# {mark} node_baseline hosts pins (Ansible, tags: hosts)"
block: |
{% for pin in node_baseline_hosts_pins %}
{{ pin.ip }} {{ pin.names | join(' ') }}
{% endfor %}
state: "{{ 'present' if node_baseline_hosts_pins | length > 0 else 'absent' }}"
backup: true
tags: [dns, hosts]
+24 -5
View File
@@ -106,14 +106,33 @@ spec:
# CiliumL2AnnouncementPolicy (reserved 10.73.0.240-.250) authored by WS-NET in Phase 2.
l2announcements:
enabled: true
# --- cgroup: k3s ships its own cgroup mount; point Cilium at it, don't auto-mount ---
# [parity] MUST equal Ansible cgroup block. Not in the deliverable's headline list, but it IS
# part of the working CNI config on k3s: reconciling without it lets Cilium try to auto-mount
# cgroupv2 and break on adopt. Kept in parity for a safe Phase-2 adoption.
# --- cgroup: point Cilium at the HOST's cgroup2 root, which systemd mounts at /sys/fs/cgroup ---
# [parity] MUST equal Ansible cgroup block (roles/cilium/templates/cilium-values.yaml.j2).
#
# FIXED 2026-10-03 — hostRoot WAS /run/cilium/cgroupv2, AND THAT SILENTLY BROKE SOCKET-LB FOR
# EVERY HOST PROCESS. With autoMount disabled, the chart only bind-mounts whatever already exists
# at hostRoot ("it's expected that users have mounted cgroup2 filesystem at the specified
# cgroup.hostRoot" — cilium 1.20.1 values.yaml). Nothing ever mounted cgroup2 at
# /run/cilium/cgroupv2 on these nodes (it is an empty DirectoryOrCreate hostPath), so the agent
# mounted one ITSELF from inside its own private cgroup namespace. That mount's root is the
# agent container's OWN leaf cgroup, so cil_sock4_connect & co. were attached there and covered
# only the agent. Every other host-netns process (the k3s apiserver in
# /system.slice/k3s.service, curl over SSH) was untranslated: Service ClusterIPs timed out from
# every node, and the node holding the Gateway's L2 lease could not reach the Gateway VIP at all
# ("no route to host"). That is why k3s-prod-1's apiserver logged `oidc: authenticator not
# initialized` from 2026-09-12 on — kubectl SSO through the kube-vip VIP failed outright.
# Measured on k3s-prod-1 before this change: TCP connect to 192.168.2.20:443, 10.43.225.79:443
# and 10.43.59.141:8080 SUCCEEDED from inside the agent container (the covered cgroup) and
# FAILED from the host, same node, same netns. Full write-up:
# decisions/2026-10-03-cilium-cgroup-hostroot.md.
#
# /sys/fs/cgroup IS a cgroup2 mount in the host's root cgroup namespace on every node (verified
# with findmnt on all three), so bind-mounting it gives the agent the real host root. autoMount
# stays off because the mount it would create already exists.
cgroup:
autoMount:
enabled: false
hostRoot: /run/cilium/cgroupv2
hostRoot: /sys/fs/cgroup
# --- Agent rollout: ONE node at a time (blast-radius bound) — 2026-09-01 incident ---
# [parity] MUST equal Ansible: updateStrategy.rollingUpdate.maxUnavailable: 1.
# The chart's own default is 2 (cilium 1.20.1 values.yaml: updateStrategy.rollingUpdate.
+4 -2
View File
@@ -41,11 +41,13 @@ hubble:
# [parity] ingress VIP announcement on the flat LAN (docs/09, OQ-1)
l2announcements:
enabled: true
# [parity] k3s provides the cgroupv2 mount; do not auto-mount
# [parity] the host's cgroup2 root is /sys/fs/cgroup (systemd mounts it); do not auto-mount.
# NOT /run/cilium/cgroupv2: nothing mounts cgroup2 there, so socket-LB attached to the agent's own
# cgroup and host processes lost Service/LB translation (2026-10-03 — see helmrelease.yaml).
cgroup:
autoMount:
enabled: false
hostRoot: /run/cilium/cgroupv2
hostRoot: /sys/fs/cgroup
# [parity] roll the agent DaemonSet ONE node at a time. Overrides the cilium 1.20.1 chart
# default of 2, which on 3 nodes lets a single bad render break 2/3 agents at once — as the
# 2026-09-01 incident did (k8sServiceHost: "" → the `config` init container fell back to the