Compare commits

...
Author SHA1 Message Date
jakman85andClaude Opus 5 b156ee6cfa fix(obs): open 44180 to ns monitoring so the forward-auth gate sidecars are actually scrapable
gate / gate (pull_request) Successful in 1m51s
gate / conformance (pull_request) Has been skipped
platform/oauth2-proxy/podmonitor.yaml landed 2026-10-02 and discovered six targets that
have never once scraped. Measured on prod 2026-10-04: all six read health=down with
lastError "context deadline exceeded" and lastScrapeDuration exactly 10.0s — the
configured scrapeTimeout, i.e. packets DROPPED rather than refused.

Cause: every app namespace ships a default-deny NetworkPolicy with
policyTypes: [Ingress, Egress] and NO ingress key at all (deny-all-ingress), and the only
ingress allowance is <app>-allow-gateway-ingress: fromEntities: [ingress] -> 4180.
Prometheus matched no clause. Verified identical in all six of jackett, radarr, sonarr,
qbittorrent, pihole, crafty; zero namespaces had any -allow-monitoring-scrape object.

render.py already had the right idiom — r_monitoring_ingress_policy — but it returned None
unless the service declared observability.metricsEndpoint, and NO ServiceDefinition in this
catalog declares one. It was wired in at the call site and had never emitted a single
object on any branch: correct, and entirely latent.

It now also fires for the forward-auth gate sidecar's own metrics port, keyed on
_needs_oauth2_sidecar — deliberately the same condition the scrape keys on, since
podmonitor.yaml selects pods purely by the auth-mode label, so a target exists iff the
sidecar exists. Keying on observability.metrics instead would let metrics: false produce a
discovered-but-blocked target: the same "two halves authored apart" failure this function
exists to prevent.

Rendered here rather than in platform/oauth2-proxy because that tier would have to name app
namespaces created by the sibling apps-wave-0/1 tiers — it would apply on a running cluster
and fail on a cold bootstrap. podmonitor.yaml's header records that objection; rendering
per-app dissolves it.

Five CNPs result, one per forward-auth app, each opening only 44180 to only ns monitoring.
apps/ re-rendered with the invocation it was generated by (--no-image-automation, verified
byte-identical beforehand); --emit-waves deliberately not run, so no wave kustomization moved.

Three L1 goldens are regenerated with it (+63 lines, -0): a renderer change that adds an
object necessarily drifts the goldens that pin the renderer's output. The first gate run
caught container-workload-render and egress-class-render as FAIL with exactly this CNP as
the drift. default-deny-baseline-render's golden regenerated byte-identical, which is the
evidence that the regen is faithful and not a rubber stamp.

That puts conformance/** in the diff, which is a sensitive path: this PR needs a non-author
approving review. The local gate cannot see that — offline it reports approval-policy green
(N/A).

Local toolbox gate: rc 3 UNDETERMINED, not 0. Every stage that ran is green and l1 went
FAIL -> pass with the goldens; the only not-run stage is gitea-settings (no
GITEA_SETTINGS_TOKEN). L2/L3 conformance is UNVERIFIED for all 8 touched requirements.

Not verified: that the scrape now succeeds. Nothing is applied until Flux reconciles main.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 16:05:44 -05:00
jakman85 422834d4e2 Merge pull request 'fix(plex): advertise the plex.direct name, not the bare VIP (server vanished from the plex.tv account)' (#47) from fix/plex-advertise-plexdirect into main
gate / gate (push) Successful in 1m46s
gate / conformance (push) Has been skipped
Reviewed-on: #47
Reviewed-by: Keith Jackson <[email protected]>
2026-10-04 07:59:14 +00:00
jakman85andClaude Opus 5 1f4552d409 fix(plex): advertise the plex.direct name, not the bare VIP (server vanished from the plex.tv account)
gate / gate (pull_request) Successful in 1m48s
gate / conformance (pull_request) Has been skipped
customConnections held two hand-typed URLs and neither could ever complete, so
Plex had nothing publishable and the server disappeared from the account:

  - https://192.168.2.24:32400 -- the server's cert is
    *.130072f20c334d6c8f63d0dfef87309b.plex.direct with NO IP SAN, so bare-IP
    https fails TLS for every client. Measured: a continuous storm of
    "CERT: incomplete TLS handshake ... certificate unknown" from real LAN
    clients arriving through the plex-l4-tcp VIP.
  - https://plex.home.keithjackson.dev:32400 -- nothing listens; the Gateway
    serves that host on 443 (measured http=000 against 192.168.2.20:32400).

With no completable connection plex.tv published nothing ("Mapped - Not
Published"), Plex fell back to Relay, and clients reported "not authorized".

Fix: advertise <dashed-ip>.<serverhash>.plex.direct -- public DNS that resolves
to the embedded address, so it points at the LAN VIP and matches the wildcard
cert. Verified with full TLS validation (no curl -k): 200 + verify OK, where
the bare-IP form fails to connect.

Declared as ADVERTISE_IP so git owns the value instead of a web form: the
image's 40-plex-first-run writes it into customConnections on every pod start,
outside the claim guard.

Does NOT fix off-LAN access -- the advertised address is RFC1918 and the WAN
tcp/32400 forward remains unverified. ManualPortMappingMode=1 with an absent
port is left as-is (no GitOps path exists for it).

Touches clusters/** -> sensitive paths -> needs the operator's
/approve-override, deliberately not self-posted (CLAUDE.md 1).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 02:53:01 -05:00
jakman85 c95b1c7121 Merge pull request 'fix(cilium): point cgroup.hostRoot at the host cgroup2 root (socket-LB never covered host processes)' (#42) from fix/cilium-cgroup-hostroot into main
gate / gate (push) Failing after 1m45s
gate / conformance (push) Has been skipped
Reviewed-on: #42
2026-10-04 07:10:31 +00:00
jakman85 ed6afc3d3f Merge pull request 'docs(decisions): record that PiHole HA is verified live, failover drill included' (#46) from docs/pihole-ha-verified into main
gate / gate (push) Successful in 1m46s
gate / conformance (push) Has been skipped
Reviewed-on: #46
2026-10-04 07:08:19 +00:00
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
19 changed files with 700 additions and 23 deletions

No files matched your search

+21
View File
@@ -244,6 +244,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): jackett-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: jackett-allow-monitoring-scrape
namespace: jackett
labels:
app.kubernetes.io/name: jackett
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: jackett
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): jackett-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
+21
View File
@@ -232,6 +232,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): pihole-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: pihole-allow-monitoring-scrape
namespace: pihole
labels:
app.kubernetes.io/name: pihole
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: pihole
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): pihole-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
+2
View File
@@ -137,6 +137,8 @@ spec:
secretKeyRef:
name: plex-claim
key: PLEX_CLAIM
- name: ADVERTISE_IP
value: ${PLEX_ADVERTISE_URL}
resources:
limits:
gpu.intel.com/i915: 1
+21
View File
@@ -319,6 +319,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): qbittorrent-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: qbittorrent-allow-monitoring-scrape
namespace: qbittorrent
labels:
app.kubernetes.io/name: qbittorrent
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: qbittorrent
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): qbittorrent-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
+21
View File
@@ -347,6 +347,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): radarr-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: radarr-allow-monitoring-scrape
namespace: radarr
labels:
app.kubernetes.io/name: radarr
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: radarr
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): radarr-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
+21
View File
@@ -345,6 +345,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: sonarr-allow-monitoring-scrape
namespace: sonarr
labels:
app.kubernetes.io/name: sonarr
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: sonarr
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
+48 -8
View File
@@ -72,14 +72,54 @@ env:
secretKeyRef:
name: plex-claim
key: PLEX_CLAIM
# ADVERTISE_IP ("Custom server access URLs") is DEFERRED, not accidentally omitted. It tells Plex
# which URL to advertise to plex.tv for REMOTE clients. The only candidate is
# https://plex.home.keithjackson.dev, but that host resolves via split-horizon DNS to the INTERNAL
# Gateway VIP (conformance/tests/l2-behavioral/dns-internal-resolution), and both its public
# reachability and whether the TLS-terminating Cilium Gateway proxies Plex's 32400 API correctly for
# remote access are UNVERIFIED. Advertising a wrong/internal-only URL breaks remote access for ALL
# clients, so per "prefer safety over completeness" it is left unset. FOLLOW-UP: once remote
# reachability through the Gateway is confirmed, set ADVERTISE_IP: "https://plex.home.keithjackson.dev:443".
# ADVERTISE_IP ("Custom server access URLs") — NOW SET. This replaces a DEFERRAL note that stood
# here until 2026-10-04, and the deferral's premise has changed: it weighed only ONE candidate,
# `https://plex.home.keithjackson.dev` through the L7 Gateway. The operator chose the NATIVE L4
# path instead on 2026-10-03 (apps/wave-0/plex-remote.yaml) so streams go straight to
# ${PLEX_L4_VIP}:32400 and bypass the Envoy hop. What was still missing was a URL whose TLS
# CERTIFICATE validates — and getting that wrong is what took the service down.
#
# THE VALUE IS A `plex.direct` NAME, NOT THE RAW VIP, AND THAT IS THE WHOLE POINT. Plex serves
# 32400 with its own certificate; measured live 2026-10-04 it carries exactly one name:
# CN = *.<serverhash>.plex.direct (one SAN, no IP SANs at all)
# so `https://<ip>:32400` can NEVER validate. `<dashed-ip>.<serverhash>.plex.direct` is Plex's own
# mechanism for this: it is PUBLIC DNS that resolves to the embedded address, so the name points at
# the LAN VIP while the wildcard cert above matches it. Measured 2026-10-04 with FULL verification
# (no `curl -k`):
# https://192-168-2-24.<hash>.plex.direct:32400/identity -> 200, TLS verify OK
# https://192.168.2.24:32400/identity -> 000, cert validation FAILED
#
# THIS FIELD EXISTS BECAUSE AUTO-DISCOVERY CANNOT WORK HERE. Left empty, Plex publishes the
# addresses it can see itself — which from a pod is the overlay IP (`10.0.2.136`), useless to a LAN
# client. The VIP has to be advertised explicitly; there is no "just clear it" fix.
#
# WHAT WENT WRONG, recorded so it is not re-introduced by hand. customConnections OVERRIDES Plex's
# own publishing, and on 2026-10-04 it held two hand-typed URLs, both uncompletable:
# - `https://192.168.2.24:32400` -> cert cannot cover an IP. Measured: a storm of
# `CERT: incomplete TLS handshake ... sslv3 alert certificate unknown` in Plex's log every few
# seconds, from REAL LAN clients arriving through the VIP.
# - `https://plex.home.keithjackson.dev:32400` -> nothing listens. The Gateway serves this host on
# 443; measured `http=000` against 192.168.2.20:32400.
# With every advertised connection dead, plex.tv had nothing publishable, sat at
# `mapping state 'Mapped - Not Published'`, fell back to Plex Relay, and THE SERVER DISAPPEARED
# FROM THE ACCOUNT — clients reported "not authorized".
#
# DECLARING IT HERE rather than in the web UI is the durable half of the fix. The image's
# /etc/cont-init.d/40-plex-first-run runs `setPref "customConnections" "${ADVERTISE_IP}"` (line 127)
# OUTSIDE the PLEX_CLAIM/PlexOnlineToken guard, and its `/.firstRunComplete` marker sits on the
# container's OVERLAY fs, not the /config PVC — so this is re-applied on EVERY pod start and git,
# not a form field, owns the value. Editing this env rolls the Deployment, which is what applies it.
#
# TWO LIMITS, stated rather than discovered later:
# - THE HASH IS SERVER IDENTITY, which is why the whole URL is a cluster-var and not built from
# ${PLEX_L4_VIP}: envsubst cannot dash an IP, and the hash is not derivable from anything in
# this tree. It is not a secret (it ships in the cert and in the published connection list), but
# if Plex reissues under a new hash this URL goes stale and must be re-read from the live cert.
# - THIS RESTORES LAN CLIENTS AND THE ACCOUNT LISTING, NOT WAN. Remote clients still need the
# off-repo tcp/32400 port-forward (runbooks/plex-remote-access.md). Once that exists, the WAN
# twin of this URL can be appended comma-separated — customConnections takes a list.
- name: ADVERTISE_IP
value: ${PLEX_ADVERTISE_URL}
# HARDWARE TRANSCODING — the host iGPU's render device (schema 1.8.0 `hardware`; R-PLAT-02).
# INTENT ONLY, AND THE MECHANISM DELIBERATELY DOES NOT LIVE HERE. This says "this workload needs an
# Intel Quick Sync render device"; it does NOT name a device path and it does NOT name a resource.
+47 -5
View File
@@ -1541,12 +1541,54 @@ def r_monitoring_ingress_policy(s: dict):
trap Longhorn's own NetworkPolicies set (platform/longhorn/networkpolicy-metrics.yaml exists
for exactly this reason), and it is a THIRD independent way for a metrics SLI to be inert.
Rendered only alongside a declared metricsEndpoint, and it opens ONLY that port to ONLY the
monitoring namespace: an allow for a port nothing serves would be a standing hole for no
Rendered only alongside a port something actually serves, and it opens ONLY that port to ONLY
the monitoring namespace: an allow for a port nothing serves would be a standing hole for no
benefit.
TWO INDEPENDENT SOURCES OF A SERVED METRICS PORT, and this used to cover only the first:
1. `observability.metricsEndpoint` — the APP's own exporter. NOTE, measured 2026-10-04: no
ServiceDefinition in this catalog declares one, so before the sidecar clause below this
function had NEVER emitted a single object on any branch. It is correct but, so far,
entirely latent — do not read its presence as evidence that scrape ingress is solved.
2. THE forward-auth GATE SIDECAR's own `/metrics` on 44180 (added 2026-10-04). Every
`_needs_oauth2_sidecar` app gets `--metrics-address=0.0.0.0:{OAUTH2_PROXY_METRICS_PORT}`
and a named `metrics` container port from `_oauth2_sidecar_container`, so the port is
served whenever the sidecar exists — unconditionally, which is what makes it safe to
open without a per-app declaration.
WHY CLAUSE 2 KEYS ON `_needs_oauth2_sidecar` AND NOT ON `observability.metrics`: it must match
the condition the SCRAPE keys on, exactly. platform/oauth2-proxy/podmonitor.yaml selects pods
by the `homelab.keithjackson.dev/auth-mode: forward-auth` POD LABEL and nothing else, so a
target exists iff the sidecar exists. Keying the allowance on `observability.metrics` instead
would let `metrics: false` produce a discovered-but-blocked target — the same "two halves
authored apart" failure this function exists to prevent, just moved one field over.
THIS CLAUSE IS A BUG FIX, and the bug was live for 63.7h. Measured on prod 2026-10-04: all 6
sidecar targets across jackett/radarr/sonarr/qbittorrent/pihole read `health=down`,
`lastError: context deadline exceeded`, `lastScrapeDuration` exactly 10.0s — the scrapeTimeout,
i.e. packets DROPPED, not refused. Each app namespace ships a `default-deny` NetworkPolicy
with `policyTypes: [Ingress, Egress]` and NO `ingress` key at all (deny-all-ingress), and the
only ingress allowance is `<app>-allow-gateway-ingress`: `fromEntities: [ingress]` -> 4180.
Prometheus matched no clause. 5 x `TargetDown` + 6 x `OAuth2ProxyGateUnobserved` had been
re-notifying at ~22 emails/day since the PodMonitor landed on 2026-10-02.
WHY THE POLICY BELONGS HERE AND NOT IN platform/oauth2-proxy: podmonitor.yaml's header records
the objection — a policy in that tier would name app namespaces created by the SIBLING
apps-wave-0/1 tiers (measured Flux graph: `oauth2-proxy` and `apps-wave-0` both
`dependsOn=[platform-config]`, no ordering between them), so it would apply on a running
cluster and fail on a cold bootstrap. Rendered here it ships in the same tier that creates the
namespace, which dissolves that ordering problem entirely.
"""
ep = s["observability"].get("metricsEndpoint")
if not ep or not s["observability"]["metrics"]:
obs = s["observability"]
ep = obs.get("metricsEndpoint")
ports: list[str] = []
if ep and obs["metrics"]:
ports.append(str(ep["port"]))
if _needs_oauth2_sidecar(s):
ports.append(str(OAUTH2_PROXY_METRICS_PORT))
if not ports:
return None
return {
"apiVersion": "cilium.io/v2",
@@ -1564,7 +1606,7 @@ def r_monitoring_ingress_policy(s: dict):
{"matchLabels": {"k8s:io.kubernetes.pod.namespace": MONITORING_NS}}
],
"toPorts": [
{"ports": [{"port": str(ep["port"]), "protocol": "TCP"}]}
{"ports": [{"port": p, "protocol": "TCP"} for p in ports]}
],
}
],
+27 -1
View File
@@ -101,7 +101,7 @@ data:
# tier that lists it), crafty (CRAFTY_L4_VIP), apps-wave-0 (nextcloud-db + semaphore-db:
# S3_BACKUP_ENDPOINT, CNPG_BACKUP_PATH; pihole + pihole-dns: PIHOLE_DNS_VIP, LAN_CIDR, plus
# GATEWAY_VIP/UNIFI_L4_VIP/CRAFTY_L4_VIP read BY pihole to author its LAN split-horizon answers;
# plex-remote: PLEX_L4_VIP),
# plex-remote: PLEX_L4_VIP; plex: PLEX_ADVERTISE_URL),
# velero (S3_BACKUP_ENDPOINT, S3_BACKUP_REGION,
# VELERO_BACKUP_BUCKET, VELERO_BACKUP_PREFIX), apps-vms (UNIFI_L4_VIP). A ${TOKEN} in an
# UNSUBSTITUTED path would ship the literal string into the cluster.
@@ -190,6 +190,32 @@ data:
# prod -> 192.168.2.24 (next free after pihole-dns .23 in prod's .20-.30 pool; see clusters/prod)
PLEX_L4_VIP: "192.168.1.57" # Plex: tcp/32400 remote access (NOT the web UI, which is L7)
# PLEX_ADVERTISE_URL — the connection URL Plex publishes to plex.tv ("Custom server access URLs",
# the ADVERTISE_IP env in catalog/plex.yaml, which the image writes into the `customConnections`
# preference on every pod start). Read catalog/plex.yaml's ADVERTISE_IP block for the full why;
# the short version is the only thing that matters when editing this line:
#
# IT MUST BE A `<dashed-ip>.<serverhash>.plex.direct` NAME, NEVER A BARE IP. Plex serves 32400
# with a `*.<serverhash>.plex.direct` wildcard certificate and NO IP SAN, so `https://<ip>:32400`
# fails TLS validation for every client. That exact mistake is what broke this service on
# 2026-10-04: with no completable connection, plex.tv published nothing, the server vanished from
# the account and clients reported "not authorized". The dashed name is public DNS that resolves
# to the embedded address, so it points at the LAN VIP AND matches the cert.
#
# WHY IT IS A WHOLE URL AND NOT BUILT FROM ${PLEX_L4_VIP}: postBuild substitution is plain
# envsubst — it cannot turn 192.168.2.24 into 192-168-2-24 — and `<serverhash>` is this server's
# certificate identity, derivable from nothing in this tree. Re-read it from the live cert
# (`openssl s_client -connect <vip>:32400 | openssl x509 -noout -subject`) if Plex ever reissues.
#
# The dashed IP here MUST track ${PLEX_L4_VIP} above. They are two halves of one address and
# nothing checks that they agree.
#
# dev -> PLACEHOLDER, non-functional. dev is decommissioned and has no claimed Plex server, so
# there is no real hash to put here; the base default only has to be a defined, correctly
# shaped string (StrictPostBuildSubstitutions makes an UNDEFINED var a hard failure).
# prod -> the real 192.168.2.24 + live server hash; see clusters/prod/kustomization.yaml.
PLEX_ADVERTISE_URL: "https://192-168-1-57.0000000000000000000000000000000f.plex.direct:32400"
# The FLAT LAN this cluster's nodes and LB VIPs live on, in CIDR form. Consumed as the ingress
# peer of the PiHole DNS CiliumNetworkPolicy (apps/wave-0/pihole-dns.yaml): a resolver reachable
# from `world` is an open resolver and an amplification reflector, so the allow is scoped to this
+10
View File
@@ -496,6 +496,16 @@ patches:
- op: replace
path: /data/PLEX_L4_VIP
value: "192.168.2.24"
# PLEX_ADVERTISE_URL — the prod VIP above in Plex's `plex.direct` form, which is the ONLY form
# that validates: the server's cert is `*.130072f20c334d6c8f63d0dfef87309b.plex.direct` with no
# IP SAN (read off the live listener 2026-10-04), so a bare `https://192.168.2.24:32400` fails
# TLS for every client — the 2026-10-04 outage. Measured with full verification, no `curl -k`:
# this URL answers /identity with 200 and TLS verify OK; the bare-IP form fails to connect.
# THE DASHED IP MUST MATCH /data/PLEX_L4_VIP ABOVE (192.168.2.24 -> 192-168-2-24); nothing
# enforces that. The hash is server identity, not a secret, and changes only if Plex reissues.
- op: replace
path: /data/PLEX_ADVERTISE_URL
value: "https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400"
# The prod flat LAN. This is the 192.168.2.0/24 segment the SOHO ROUTER at 192.168.2.1 owns —
# NOT pfSense's 192.168.1.0/24, which is a different network and not in the LAN-DNS path.
# It scopes the ingress allow for PiHole's DNS listener to LAN clients only.
@@ -171,6 +171,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): paas-demo-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: paas-demo-allow-monitoring-scrape
namespace: paas-demo
labels:
app.kubernetes.io/name: paas-demo
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: paas-demo
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): paas-demo-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
@@ -198,6 +198,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: sonarr-allow-monitoring-scrape
namespace: sonarr
labels:
app.kubernetes.io/name: sonarr
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: sonarr
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
@@ -198,6 +198,27 @@ spec:
- port: '4180'
protocol: TCP
---
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: sonarr-allow-monitoring-scrape
namespace: sonarr
labels:
app.kubernetes.io/name: sonarr
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: sonarr
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
toPorts:
- ports:
- port: '44180'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
@@ -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,150 @@
---
date: 2026-10-04
branch: fix/oauth2-sidecar-metrics-scrape-netpol
title: The forward-auth gate sidecars' /metrics was NetworkPolicy-blocked — the renderer now opens 44180 to ns monitoring
---
## What changed
`catalog/render.py`'s `r_monitoring_ingress_policy` now emits its CiliumNetworkPolicy for a
**second** reason: the forward-auth gate sidecar's own `/metrics` port (44180), keyed on
`_needs_oauth2_sidecar(s)`. Previously it fired only from `observability.metricsEndpoint`.
That matters more than it sounds, because **no ServiceDefinition in this catalog declares a
`metricsEndpoint`** (measured: zero grep hits across `catalog/*.yaml`). So the function was wired
in at the call site and had **never emitted a single object on any branch** — correct, and
entirely latent. Its presence in the tree read as "scrape ingress is handled". It was not.
Re-rendered `apps/` from that change: five new CNPs, one per forward-auth app
(`jackett`, `pihole`, `qbittorrent`, `radarr`, `sonarr`), each opening **only** 44180 to **only**
`fromEndpoints: [k8s:io.kubernetes.pod.namespace: monitoring]`.
The bug being fixed: `platform/oauth2-proxy/podmonitor.yaml` landed 2026-10-02T04:58:44Z and
discovered 6 targets that have **never once scraped successfully**. Each app namespace ships a
`default-deny` NetworkPolicy with `policyTypes: [Ingress, Egress]` and **no `ingress` key at all**
— deny-all-ingress in NetworkPolicy semantics — and the only ingress allowance is
`<app>-allow-gateway-ingress`: `fromEntities: [ingress]` to port 4180. Prometheus matched no
clause, so its packets were dropped.
Two deliberate choices, both recorded in the function's docstring:
- **Clause 2 keys on `_needs_oauth2_sidecar`, not on `observability.metrics`.** It has to match
the condition the *scrape* keys on: `podmonitor.yaml` selects pods by the
`homelab.keithjackson.dev/auth-mode: forward-auth` pod label and nothing else, so a target
exists iff the sidecar exists. Keying on `observability.metrics` would let `metrics: false`
produce a discovered-but-blocked target — the same "two halves authored apart" failure this
function exists to prevent, moved one field over.
- **The policy is rendered here, not added to `platform/oauth2-proxy/`.** podmonitor.yaml's own
header records why that tier cannot host it: it would name app namespaces created by the
*sibling* `apps-wave-0/1` tiers, so it would apply on a running cluster and fail on a cold
bootstrap. Rendered per-app it ships in the same tier that creates the namespace.
## What I verified
Measured on prod 2026-10-04, read-only, via the pinned toolbox:
- **The cause, not a guess.** All 6 sidecar targets in pool
`podMonitor/oauth2-proxy/oauth2-proxy-sidecars/0`: `health=down`,
`lastError: context deadline exceeded`, `lastScrapeDuration` exactly `10.0s` — the configured
`scrapeTimeout`. Hitting the timeout precisely means packets **dropped, not refused**.
- **The listener is fine.** jackett's `oauth2-proxy` container carries
`--metrics-address=0.0.0.0:44180` and a named port `metrics: 44180`.
- **The deny is universal.** All six namespaces (`jackett radarr sonarr qbittorrent pihole
crafty`): `default-deny` with `ingress` key absent, `nIngress=0`; the only ingress CNP allowance
is 4180 `fromEntities: [ingress]`. **Zero** namespaces had any `-allow-monitoring-scrape` object.
- **Frequency.** Single continuous episode, no flapping: `ALERTS_FOR_STATE` = 229343s (63.7h),
firing since 2026-10-02 05:09 UTC — 11 minutes after the PodMonitor was created, i.e. TargetDown's
`for: 10m`. A 15-day `query_range` shows **1 episode** per namespace, not many. Alertmanager:
`group_by: [alertname, namespace]`, `repeat_interval: 12h` on `email-warning` → 5 TargetDown
groups = 10 emails/day, plus 6 `OAuth2ProxyGateUnobserved` groups = 12/day, ~22/day total.
0 silences.
- **The re-render is deterministic and the diff is exactly the intended one.** `apps/` is
byte-identical to `render.py --all catalog -o apps --wave-layout --no-image-automation`
(12/12 SAME before my edit; the 11-file "drift" against a default render is solely the
ImageRepository/ImagePolicy pair, which `apps/` is generated without). After the edit:
5 files changed, +21 lines each, nothing else touched.
- `render.py --selftest`: **PASS**.
- **Three L1 goldens needed regenerating, and the diff is +63 lines / -0.** The first gate run
caught `container-workload-render` (R-PLAT-02) and `egress-class-render` (R-NET-05/06/07) as
FAIL with exactly the new CNP as the drift. Regenerated with the same invocations the checks
use (`render.py <fixture>`, no flags). Evidence the regen is faithful rather than a rubber
stamp: `default-deny-baseline-render`'s golden came back **byte-identical** (its fixture is not
forward-auth), and the other three gained only the one CNP, port 44180, ns monitoring.
- Toolbox gate `./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`: **rc 3 — UNDETERMINED,
NOT rc 0, and I am not reporting it as green.** Every stage that ran is green
(spec-immutability, approval-policy, decisions-log, lint, l0, render-golden, l1); `l1` went
FAIL -> pass once the goldens were regenerated. The gate's own words: "Stages that did not run:
gitea-settings" — skipped for want of `GITEA_SETTINGS_TOKEN`, which is an environment gap on
every local run and unrelated to this change. L2/L3 conformance is a `[warn]`, not a not-run
stage: UNVERIFIED for all 8 touched requirements (R-GITOPS-02 R-IDP-02 R-IDP-06 R-NET-01
R-NET-05 R-NET-06 R-NET-07 R-PLAT-02), no cluster.
- `--emit-waves` was **not** run (it re-onboards `vm-demo` + `keycloak`); only the per-service
render path was used, so no wave kustomization changed.
## This PR needs a human approval (sensitive path) — MEASURED, not predicted
CI run #230 on PR #49, head `f9446be3`: **`MERGE GATE: BLOCKED`, and `approval-policy` is the ONLY
failing stage.** Read from the job log, not from the status tick:
[skip] stage spec-immutability: SKIPPED (not run) <- sibling spec repo absent in CI; expected
[ ok ] stage decisions-log: pass
[ ok ] stage lint: pass
[ ok ] stage l0: pass
[ ok ] stage render-golden: pass
[ ok ] stage l1: pass
[fail] stage approval-policy: FAIL
[ ok ] stage gitea-settings: pass <- could NOT run locally; green in CI
[skip] stage conformance: SKIPPED (not run)
sensitive paths touched:
conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml
conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml
conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml
Exactly the three regenerated goldens, nothing else. `l1` and `render-golden` both pass in CI,
which is the independent confirmation that the regenerated goldens are right rather than merely
self-consistent with my local run.
One local-reproduction note worth recording, because it wasted a step: running
`PR_NUMBER=49 SETTINGS_API_TOKEN=... ci/approval-policy.sh` with the credential from
`git credential fill` reports `could not determine PR author — BLOCKING`. That is an **artifact**,
not a finding — `~/.git-credentials` holds a *password*, and `Authorization: token <password>`
returns 401 `user does not exist [uid: 0]`. The stage needs a real API token. I nearly reported
that 401 as the CI failure; the job log is what settled it.
Regenerating the L1 goldens means this branch touches `conformance/**`, which is in
`sensitive_paths` in `infra/gitea/approval-policy.json` ("the tests and the harness; changing them
changes what green MEANS"). So `ci/approval-policy.sh` will require an approving review from a
non-author, and the local gate **cannot** see that: it reports `approval-policy: green` because
offline it evaluates N/A.
The golden update is not optional — a renderer change that adds an object necessarily drifts the
goldens that pin the renderer's output — but it does mean the gate correctly refuses to
self-approve this one. **An agent must not clear this with `/approve-override`**, even though the
`gitea-admin` account holds `owner`: that is precisely the self-approval the stage exists to
prevent. The operator posts the override as a PR *review* of type "Comment", or approves normally.
## What I did NOT verify
- **That the scrape now succeeds.** This is a git-only change and nothing is applied until Flux
reconciles `main`. The 5 CNPs have never existed on the cluster, so `up == 1` for those targets
is **UNVERIFIED** — it is a prediction from the measured drop signature, not an observation.
After merge, confirm `up{container="oauth2-proxy"}` goes to 1 for all 6 targets and that the 11
alerts resolve. `send_resolved: true` is set, so expect ~11 resolved emails.
- **That the CNP `endpointSelector` matches pihole's pods.** It selects
`app.kubernetes.io/name: pihole`, identical to the shape of `pihole-allow-gateway-ingress`
which demonstrably works for 4180 — so this is inference from a working sibling, not a
measurement of the new object.
- **`crafty` is still unobserved and will keep firing `OAuth2ProxyGateUnobserved`.** It is
hand-managed under `platform/crafty`, outside the renderer, and has **no `--metrics-address`
at all** — so there is no port to open and this change cannot help it. Separate work.
- **Whether 44180 is reachable in the Cilium data path once allowed.** Only the policy was
reasoned about; no L2 probe was run (CI has `vars.CLUSTER_AVAILABLE` unset, so L2/L3 did not
run here either — the gate proves the L0/L1 offline subset only).
- **Why `OAuth2ProxyGateUnobserved` started at 2026-10-04 06:44** rather than 10-02 with
TargetDown. Alertmanager did not restart (TargetDown's `startsAt` is still 10-02) and the rule's
`for` is 1h, so neither explains it. Something changed on the monitoring side that morning which
I did not identify. It does not affect this fix.
- I did not re-run a port-forward to 44180 to prove the endpoint serves *today*; I confirmed the
flag and named port on the live pod spec. The 200/10208-byte measurement is podmonitor.yaml's,
from 2026-10-01.
@@ -0,0 +1,139 @@
---
date: 2026-10-04
branch: fix/plex-advertise-plexdirect
title: Plex vanished from the plex.tv account — every advertised connection was uncompletable; advertise the plex.direct name instead of the bare VIP
---
## What changed
Reported symptom: the server no longer appears on the operator's plex.tv account; `plex.home.keithjackson.dev`
shows "not authorized"; connecting straight to the VIP + port works.
**Root cause, measured: `customConnections` held two hand-typed URLs and NEITHER could ever complete,
so Plex had nothing publishable.** Because `customConnections` OVERRIDES Plex's own connection
publishing, the server ended up advertising only dead endpoints. Live `Preferences.xml`, 2026-10-04:
customConnections="https://plex.home.keithjackson.dev:32400/,https://192.168.2.24:32400/"
ManualPortMappingMode="1" ManualPortMappingPort=<ABSENT>
- `https://192.168.2.24:32400` — **TLS can never validate.** Plex serves 32400 with
`CN=*.130072f20c334d6c8f63d0dfef87309b.plex.direct` and **no IP SAN**, so a bare-IP https URL fails
for every client. Not theoretical: Plex's log carries a continuous storm of
`CERT: incomplete TLS handshake from <node> ... sslv3 alert certificate unknown`, every few seconds,
from **real LAN clients** arriving through the new `plex-l4-tcp` VIP.
- `https://plex.home.keithjackson.dev:32400` — **nothing listens.** The shared Gateway serves that host
on 443; measured `http=000` (connect failed) against `192.168.2.20:32400`.
Consequence chain, each link measured: no completable connection -> plex.tv could publish nothing
(`MyPlex: mapping state set to 'Mapped - Not Published'`, repeatedly) -> Plex fell back to **Plex Relay**
(`startRelay`, remote forward to 127.0.0.1:32401) -> **the server disappeared from the account**, and
clients reported "not authorized".
The fix advertises Plex's own `<dashed-ip>.<serverhash>.plex.direct` form, which is public DNS resolving
to the embedded address — so it points at the LAN VIP *and* matches the wildcard cert.
- **`catalog/plex.yaml`:** `ADVERTISE_IP: ${PLEX_ADVERTISE_URL}` added, replacing the long-standing
DEFERRAL note. The deferral had weighed only the L7 Gateway URL; the operator chose the native L4 path
on 2026-10-03, so the open question was never "Gateway or not" but "which URL has a valid cert".
- **`clusters/base/cluster-vars.yaml`:** new `PLEX_ADVERTISE_URL` knob (+ substitution-inventory comment
updated to name `plex: PLEX_ADVERTISE_URL`). The base default is an explicit **non-functional
placeholder** — dev is decommissioned and has no claimed server, and
`StrictPostBuildSubstitutions` makes an undefined var a hard failure, so the default only has to exist
and be correctly shaped.
- **`clusters/prod/kustomization.yaml`:** prod value
`https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400`.
- **`apps/wave-0/plex.yaml`:** re-rendered with the canonical command
(`python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation`). Two added
lines, nothing else.
**Why a whole-URL cluster-var and not something built from `${PLEX_L4_VIP}`:** postBuild substitution is
plain envsubst and cannot turn `192.168.2.24` into `192-168-2-24`, and `<serverhash>` is the server's
certificate identity, derivable from nothing in this tree. The dashed IP and `${PLEX_L4_VIP}` are two
halves of one address and **nothing checks that they agree** — noted in both files.
**SENSITIVE PATHS — this PR touches `clusters/**` (two files), so `ci/approval-policy.sh` will BLOCK it.**
It needs the operator's `/approve-override <reason>` posted as a PR **review** of type "Comment".
Deliberately not posted here: CLAUDE.md §1 forbids an agent clearing this on its own PR, and the agent
account holds `owner`, so the prohibition is a rule rather than a limit.
## What I verified
All measurements against live prod, 2026-10-04, read-only. Secrets handled per CLAUDE.md §6: preference
attributes were read by name-scoped grep, never by dumping `Preferences.xml` (it holds `PlexOnlineToken`),
and the token was reported only as `<PRESENT,len=22>`.
- **THE FIX ITSELF, with full TLS verification (no `curl -k`)** — the one measurement the whole change
rests on:
- `https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400/identity` -> **`http=200`, TLS verify OK**
- `https://192.168.2.24:32400/identity` -> **`http=000`, cert validation FAILED**
- that name resolves to `192.168.2.24` (`getent hosts`), confirming the dashed-IP mechanism works from
inside this network and is not blocked by DNS-rebinding protection on the resolver path tested.
- **The server is still claimed — this was never a lost claim.** `/identity` -> `claimed="1"` with the real
`machineIdentifier`; `PlexOnlineToken` present, `PlexOnlineUsername="jakman85"`.
- **The certificate, read off the live listener:** exactly one SAN,
`DNS:*.130072f20c334d6c8f63d0dfef87309b.plex.direct`. No IP SAN. This is why the bare-IP URL fails.
- **The publish payload Plex actually sent to plex.tv**, which is what proves the connection list was the
problem rather than a guess: `PUT https://servers.plex.tv/devices/<id>?Connection[][uri]=https://plex.home.keithjackson.dev:32400&Connection[][uri]=https://192.168.2.24:32400&Connection[][uri]=http://136.51.115.252:32400&Connection[][uri]=http://10.0.2.136:32400&httpsEnabled=1...`
— all four unreachable from a LAN client or from plex.tv.
- **`Mapped - Not Published`** at every reachability check across the whole log, and an active **Plex Relay**
session — the documented fallback when nothing is directly publishable.
- **The `certificate unknown` TLS failures come from real client traffic, not a probe.** The source
`10.0.1.78` resolves via `cilium-dbg bpf ipcache get` to **identity 1 = `reserved:host`**, i.e. LAN
clients SNAT'd through `plex-l4-tcp` (`externalTrafficPolicy: Cluster`) on prod-3.
- **The Gateway is NOT broken, and my first reading of it was wrong.** A 403 `Access denied` from
`server: envoy` on the Gateway path turned out to be an artifact of probing from inside the plex pod:
**all 16** Gateway hostnames return 403 from there, because Cilium's Gateway preserves the original
source identity and enforces the destination's ingress policy. Plex's own log shows a concurrent,
**working, signed-in** browser session through the Gateway returning 200s. The HTTPRoute is
`Accepted=True`/`ResolvedRefs=True` with no filters, and it is the only route claiming that hostname.
- **Cilium is not denying the Gateway->Plex hop:** `cilium-dbg bpf policy get 3796` shows
`Allow Ingress reserved:ingress 32400/TCP`, PROXY PORT `NONE`, with 141k packets counted. No L7/HTTP
rules and no CCNPs exist anywhere in the cluster.
- **In-cluster consumers were never affected:** Seerr's recently-added scans and watchlist syncs complete
cleanly with no auth errors; there are **zero** HTTP 401s in Plex's log (the five ` 401 ` hits are
transcoder segment numbers).
- **`ADVERTISE_IP`'s mechanism, in the running image rather than from docs:**
`/etc/cont-init.d/40-plex-first-run` line 127 does `setPref "customConnections" "${ADVERTISE_IP}"`,
outside the claim guard, and `/.firstRunComplete` (lines 9/139) is on the container overlay, not the
`/config` PVC — so it re-applies on every pod start. Editing the env rolls the Deployment, which applies it.
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
**pass**. The rc 3 is the two known environmental skips — `gitea-settings` (no local
`SETTINGS_API_TOKEN`) and `conformance` L2/L3 (`CLUSTER_AVAILABLE` unset). **Not green. Reported as rc 3.**
- **`render-golden` passing is the specific proof** that the catalog edit and the committed golden agree;
the rendered diff is exactly two lines and no other service's manifest moved.
## What I did NOT verify
- **THE OUTCOME. Nothing here has been observed to put the server back on the plex.tv account.** The
change is unreconciled: `ADVERTISE_IP` is absent from the live Deployment, the live
`customConnections` still holds the two broken URLs, and Plex has not been restarted. What is proven is
that the *new URL answers 200 with a valid certificate* and that *every currently advertised URL cannot
complete* — a necessary-and-plausibly-sufficient argument, not a demonstrated fix.
- **Whether plex.tv will publish and list the server once this lands.** plex.tv's decision is made
off-network and cannot be measured from here. A LAN-only connection may still leave the account showing
the server as remotely unreachable even when local clients work.
- **REMOTE (off-LAN) ACCESS IS NOT FIXED BY THIS, and may still be fully broken.** The advertised URL
resolves to an RFC1918 address, so it serves LAN clients only. The off-repo WAN tcp/32400 port-forward
(`runbooks/plex-remote-access.md`) is still **unverified** — `http://136.51.115.252:32400` was
unreachable from inside the LAN, but 2026-10-03 established there is no NAT hairpin here, so that is
**inconclusive**, not proof of absence. This repo has no off-LAN vantage point.
- **`ManualPortMappingMode="1"` with `ManualPortMappingPort` ABSENT is left as-is.** Manual public-port
mapping is enabled with no port set — a half-configured state I did not change and whose exact effect on
publishing I did not isolate. There is **no GitOps path** to it (2026-10-03: the image supports no
`PLEX_PREFERENCE*` of any kind), so it is an operator UI step.
- **Whether the operator intended the hand-typed URLs** to override the 2026-10-03 native-L4 design, or
typed them while debugging. I did not alter live preferences; the env var will overwrite
`customConnections` on the next pod start, which is a deliberate transfer of ownership from the web form
to git and will **silently discard** anything typed there since.
- **The `plex.direct` hash's stability.** Treated as server identity and read once from the live cert. I
did not establish what reissues it, so this URL could go stale with no alert; nothing in CI or the
conformance suite checks that the dashed IP still matches `${PLEX_L4_VIP}` or that the hash is current.
- **Resolver behaviour for the whole LAN.** `plex.direct` resolved correctly from a *pod* (cluster DNS
path). LAN clients resolve via PiHole, and dnsmasq's `stop-dns-rebind` would reject a public answer
carrying a private address. I did **not** test resolution from a LAN client, and this is the most likely
way the fix still fails.
- **The CI approval-policy outcome.** The local stage reported green, but that is an offline N/A, not a
verdict: this PR touches `clusters/**`, so CI will block pending the operator's override.
- **Why PMS restarted.** The process came up at 05:26 UTC while the pod is 2d2h old with 0 restarts, so
something restarted Plex inside the container. Unexplained and not investigated.
@@ -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
+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