Files
home-network-k3s-impl/platform/oauth2-proxy/README.md
T
jakman85andClaude Opus 5 81f627167d
gate / gate (pull_request) Successful in 1m48s
gate / conformance (pull_request) Has been skipped
docs(obs): record that the gate-sidecar scrape works — four places still said it did not
PR #49 (merged 21c88b0) fixed the blocked scrape and it is verified working on prod
2026-10-05: all 6 targets up == 1, health=up, lastError='', lastScrapeDuration 0.001-0.006s
against 10.0s and "context deadline exceeded" before. oauth2_proxy_* is in the TSDB for
these namespaces for the first time (5 metric names, 148 series).

Four places in the tree still asserted the pre-fix state as current, in the present tense,
in the files a reader consults first:

  platform/oauth2-proxy/podmonitor.yaml  "READ THIS BEFORE BELIEVING THE TARGETS ARE UP:
    THE SCRAPE IS CURRENTLY DENIED BY NETWORKPOLICY" and "SO THE TARGETS BELOW WILL READ
    up == 0 UNTIL AN INGRESS ALLOWANCE LANDS". The first actively instructs the reader to
    disbelieve a correct up == 1. Now a STATUS block with the post-fix numbers, with the
    diagnosis kept below it under an explicit HISTORY heading because it is why the fix is
    shaped the way it is.
  catalog/render.py:159  "THIS IS THE PREREQUISITE ONLY, AND IT IS NOT YET SCRAPED".
  catalog/render.py:1365  the same claim on the sidecar arg.
  platform/oauth2-proxy/README.md  its subsections are dated and so are honest records, but
    a reader hits the stale body long before any correction. A SUPERSEDED pointer now sits
    under the heading and an Update 2026-10-05 section carries the before/after.

The "not scraped" claims are corrected; the "a flag alone is not enough" warning is KEPT
deliberately, because that part is still true and "flag without ingress allowance" is
exactly what caused the 63.7h outage.

Two substantive errors fixed while there, not just tense. The README's "three things are
needed" listed a Service port and a ServiceMonitor — wrong, a PodMonitor targets pod ports
directly, which is why no Service exposes 44180 and the renderer needed no Service change;
following it would have sent someone to edit Service rendering for nothing. And
podmonitor.yaml deferred the fix to "the catalog/renderer, in flight on another branch" —
no such branch existed; it now names r_monitoring_ingress_policy, with a new CRAFTY section
explaining the one namespace this file cannot help.

Comments only in render.py, so the rendered output is byte-identical: no apps/ file and no
golden changes, and therefore no sensitive path and no human approval needed — the opposite
of the PR this documents.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 21:18:33 -05:00

14 KiB
Raw Blame History

platform/oauth2-proxy

oauth2-proxy — forward-auth at the Gateway for apps without usable OIDC, authenticating against Keycloak.

What this stub is

Flux HelmRelease for oauth2-proxy as the forward-auth provider for non-OIDC apps (PiHole, *arr UIs, qBittorrent, Homepage, Prometheus, Alertmanager, Hubble UI — ADR-0014). Replica count is an overlay knob (${OAUTH2_PROXY_REPLICAS}, R-ENV-02). Client/cookie secrets referenced by name, SOPS-encrypted (WS-SEC).

Contract (SSO/auth + Gateway)

Pairs with ../keycloak to realize the "SSO/auth contract": a ServiceDefinition auth: forward-auth service is protected by oauth2-proxy at the Gateway (ext-auth); auth: oidc services go native to Keycloak. Gateway ext-auth wiring is a WS-NET contract.

Enforcement (ext_authz forward-auth)

The authenticator (oauth2-proxy) is deployed and wired to Keycloak: provider=oidc, oidc-issuer-url=.../realms/homelab, --reverse-proxy, --upstream=static://202, --set-xauthrequest, cookie/redirect domain .home.keithjackson.dev, and — added here — --allowed-groups=platform-admins (R-IDP-06 floor) + --skip-provider-button (302 straight to Keycloak). The Keycloak oauth2-proxy client already carries the auth.home…/oauth2/callback redirect URI and a groups claim with full.path=false (bare platform-admins) — no realm change needed.

Mechanism. Forward-auth is an Envoy ext_authz HTTP filter that calls oauth2-proxy's /oauth2/auth decision endpoint: 202 for a valid session in platform-admins (request passes to the upstream, X-Auth-Request-* identity headers forwarded), 401/403 otherwise. With failure_mode_allow: false the gateway fails closed — a denied or errored check never reaches the upstream UI (R-IDP-02). The filter + oauth2-proxy cluster live in forward-auth-cec.yaml.

Why this is NOT wired into the shared Gateway (Cilium 1.16.5 constraint)

The shared Gateway's Envoy program is a machine-owned, constantly-regenerated CiliumEnvoyConfig (gateway/cilium-gateway-shared, ownerReferences->Gateway, regenerated on every HTTPRoute change). Cilium 1.16.5 exposes no supported hook to inject an ext_authz filter into that generated listener, and native Gateway-API ExternalAuth (GEP-1494) ships in no released Cilium version yet (PoC only). So there is no clean per-route ext_authz on the shared Gateway today.

Options (PM decision):

# Approach Pros Cons
A Interim self-authored CEC — a dedicated ext_authz HTTPS listener on its own L2 VIP (forward-auth-cec.yaml), protected hosts moved off the shared Gateway and DNS repointed. Works on 1.16.5 today; per-route opt-in; reuses the wildcard TLS via SDS; verified below. Hand-authored TLS+routes; a 2nd ingress VIP + DNS; per-app maintenance; not Gateway-API-native.
B oauth2-proxy in reverse-proxy mode per app — app HTTPRoute backendRef -> a per-app oauth2-proxy (--upstream=http://app.svc). Fully Gateway-API-native (just a backendRef swap); robust; no CEC. One oauth2-proxy instance/config per app; more pods; not the single-proxy ext_authz model.
C Upgrade Cilium to a release with native ExternalAuth (GEP-1494) and gate routes with an HTTPRoute filter. Cleanest, truly per-route, Crafty-friendly. Not released yet; large jump from 1.16.5; must keep the Ansible-bootstrap/Flux parity contract in lock-step.

Recommendation: ship the oauth2-proxy hardening now (done); adopt A as the interim enforcement for the sensitive UIs when the Phase-2 L2 VIP pool lands; track C as the long-term target and drop A once released.

Opt-in (per route)

A route is protected only by being listed as a virtualHost on the ext_authz listener — apps not listed keep hitting the shared Gateway. To protect an app (pihole today; Crafty next): add its Service + EDS cluster + virtualHost to forward-auth-cec.yaml, remove its HTTPRoute from the shared Gateway, and repoint its DNS at the ext_authz VIP. The ServiceDefinition already declares auth.mode: forward-auth + groups: [platform-admins] (rendered onto the HTTPRoute annotations) — the opt-in signal. Ready to extend to Crafty and the other non-OIDC UIs (PiHole/*arr/qBittorrent/Homepage).

Proof (in-cluster, cilium-envoy v1.30.8, 2026-08-28)

A throwaway standalone cilium-envoy pod ran the exact ext_authz stanza from forward-auth-cec.yaml against the live oauth2-proxy Service, with a no-filter control route on the same backend:

  • Control (:8081, no ext_authz) -> 200 CONTROL-OK.
  • Protected (:8080, ext_authz), unauthenticated -> 403; body is oauth2-proxy's sign-in page, not the upstream (UPSTREAM-OK never served). oauth2-proxy's own access log recorded the /oauth2/auth call from the envoy pod. failure_mode_allow: false confirmed fail-closed.

This proves the R-IDP-02 property (unauthenticated cannot reach the upstream) on this exact Envoy. The allow path (valid platform-admins session -> 202 -> upstream) needs a real browser round-trip.

Browser test (for the human, once forward-auth-cec.yaml is deployed)

  1. Incognito -> https://pihole.home.keithjackson.dev -> expect a 302 to https://keycloak.home.keithjackson.dev/realms/homelab/... (not the PiHole UI).
  2. Log in as a platform-admins user (MFA/OTP enforced) -> land back on the PiHole UI.
  3. Log in as a non-platform-admins user -> expect 403 (authenticated but not authorized).
  4. Confirm the session cookie is .home.keithjackson.dev-scoped (single sign-on across the other protected UIs).

Observability: what this component can and cannot see (measured 2026-09-30)

SUPERSEDED — read "Update 2026-10-05" at the end of this section first. The gate sidecars are scraped now and the TSDB does hold oauth2_proxy_* for them. The two subsections below are kept as the dated record of how this was diagnosed, not as current state.

servicemonitor.yaml scrapes this namespace's deployment and nothing else. On prod that is two targets, job="oauth2-proxy", both up=1. The complete set of oauth2-proxy-specific series in the TSDB is:

count by (__name__) ({__name__=~"oauth2_proxy.*"})
  oauth2_proxy_requests_in_flight => 2

The per-app gate sidecars are not scraped, and there is nothing to scrape. Six apps run an oauth2-proxy container of their own — crafty, jackett, pihole, qbittorrent, radarr, sonarr — each with --http-address=0.0.0.0:4180 and no --metrics-address, so oauth2-proxy's metrics listener never starts. They declare only a http:4180 container port, no metrics port exists on their Services, and kubectl get servicemonitors,podmonitors -A selects none of them. Any alert written against oauth2_proxy_requests_total{namespace="jackett"} would be structurally inert.

This matters because the gate's characteristic failure is invisible by construction: an unauthenticated XHR gets a 302 to Keycloak rather than a 401, the browser follows it against a live SSO session with no prompt, and the call resolves 200 carrying an HTML page where the app expected JSON. Nothing in the request path reports an error. On 2026-09-30 jackett spent a burst of requests in exactly that state, re-established a session, and lost it 26 seconds into a 5-minute session, while jackett-down sat at 1 replica available the whole time.

auth-visibility-alerts.yaml therefore alerts on the gap, not on the incident: OAuth2ProxyGateUnobserved compares kube_pod_container_info{container="oauth2-proxy"} against up{container="oauth2-proxy"} — two already-scraped families — and names every namespace running a gate that nothing observes. It fires for six namespaces today and clears per-namespace as each one gains a scrape. runbooks/oauth2-proxy-auth-failures.md is the by-hand substitute meanwhile, and it reads the sidecar logs out of Loki (Alloy collects them; 72h retention).

To close the gap for an app, three things are needed together, on that app's own manifest: --metrics-address=0.0.0.0:44180 on the sidecar, a metrics containerPort plus the matching Service port, and a ServiceMonitor selecting it. Only then does the auth-churn rule — sustained code="302" as a share of oauth2_proxy_requests_total, held across a window longer than the session lifetime — become writable rather than decorative.

Update 2026-10-01: the listener exists; the scrape path does not

PR #28 added --metrics-address=0.0.0.0:44180 and a named metrics container port to the five fleet sidecars (jackett, radarr, sonarr, qbittorrent, pihole). podmonitor.yaml in this directory discovers them by the pod label homelab.keithjackson.dev/auth-mode: forward-auth — a PodMonitor rather than a ServiceMonitor because no Service exposes 44180 and adding one would mean changing the renderer.

The endpoint is real: port-forwarded straight to the jackett pod, /metrics returns http=200, 10208 bytes, exporting oauth2_proxy_requests_in_flight, oauth2_proxy_requests_total (with code labels) and oauth2_proxy_response_duration_seconds. That is verified by payload, not by the flag appearing in args — a distinction worth keeping, because the two claims came apart here.

The scrape is nevertheless dropped. From a pod in ns monitoring, both :44180 and :4180 on the jackett pod time out; the shared proxy's :44180 answers. The only difference is that ns oauth2-proxy has no NetworkPolicy at all, while each app namespace has a default-deny whose policyTypes include Ingress with no ingress rules (deny-all) plus a single CNP admitting fromEntities: [ingress] to port 4180 — the Gateway's Envoy and nothing else. Prometheus matches no clause.

So the targets read up == 0 and the TSDB is still empty for those namespaces. This is the same bug and the same fingerprint as platform/longhorn/networkpolicy-metrics.yaml documents for Longhorn, and the same narrow fix applies: ns monitoring → the gate pod label → TCP 44180 only. It cannot ship from this directory, because oauth2-proxy and apps-wave-0 are siblings in the Flux graph (both dependsOn: [platform-config]), so a NetworkPolicy naming ns jackett would apply on today's cluster and fail on a cold rebuild. It belongs with each app's own rendered policy.

Consequence for auth-visibility-alerts.yaml: its unless clause requires up == 1, not merely a present up series. A discovered-but-failing target publishes up with value 0, so the bare form would have cleared OAuth2ProxyGateUnobserved the moment the PodMonitor landed — reporting the gates as observed while nothing was being scraped. The unit test pins that scenario; deleting the == 1 fails it and nothing else.

Update 2026-10-05: the scrape works

PR #49 (merged 21c88b0) added the missing third piece. catalog/render.py's r_monitoring_ingress_policy now also fires for the gate sidecar's metrics port, keyed on _needs_oauth2_sidecar, rendering <app>-allow-monitoring-scrape per app: ns monitoring → that app's endpointSelector → TCP 44180 only. Five objects, one each for jackett, pihole, qbittorrent, radarr, sonarr.

Measured on prod 2026-10-05 after Flux applied it (apps-wave-0 21:37:01Z, apps-wave-1 21:37:46Z):

before after
target health (6) down up
lastError context deadline exceeded ''
lastScrapeDuration 10.0s — exactly the scrapeTimeout 0.001–0.006s
up 0 × 6 1 × 6
count by (__name__) ({__name__=~"oauth2_proxy_.*",
                      namespace=~"jackett|radarr|sonarr|qbittorrent|pihole"})
  oauth2_proxy_requests_in_flight              => 6
  oauth2_proxy_requests_total                  => 16
  oauth2_proxy_response_duration_seconds_bucket => 108
  oauth2_proxy_response_duration_seconds_sum   => 9
  oauth2_proxy_response_duration_seconds_count => 9

sum by (namespace) (oauth2_proxy_requests_total)
  qbittorrent => 35072    radarr => 23560    sonarr => 1284

oauth2_proxy_requests_total is absent for jackett and pihole simply because that counter is not created until the gate has served its first request.

So the auth-churn rule is now writable rather than decorative — sustained code="302" as a share of oauth2_proxy_requests_total, held across a window longer than the session lifetime. That is the rule the 2026-09-30 incident above could not be caught by. It is still unwritten.

Alert outcome: all 5 TargetDown resolved, and 5 of 6 OAuth2ProxyGateUnobserved with them. At repeat_interval: 12h with group_by: [alertname, namespace], those 11 groups had been re-notifying at roughly 22 emails/day since 2026-10-02; that drops to 2/day.

crafty still fires, and that is correct. It is a hand-maintained Deployment under platform/crafty, outside the renderer, whose oauth2-proxy container has no --metrics-address at all — no listener, so no port to open and nothing for the PodMonitor's pod-label selector to match. Closing it needs the flag and a metrics containerPort on crafty's own manifest first, then a hand-written allowance beside it. Open follow-up work.

The paragraph above headed "To close the gap for an app" is superseded: a Service port and a ServiceMonitor are not required. A PodMonitor targets pod ports directly, which is why no Service exposes 44180 and why the renderer needed no Service change. The three things actually needed are --metrics-address + named container port, a PodMonitor, and the monitoring-ingress allowance.