Public names are proxied CNAMEs to the Cloudflare Tunnel since PR #156 and work from the LAN, so they should resolve publicly there too. Replace the address=/${BASE_DOMAIN}/ wildcard with explicit entries: the LAN-only names (derived from every controller:none HTTPRoute), gitea/nextcloud (off Cloudflare's 100 MB / 100 s limits), keycloak/auth, and the L4 VIPs. Add L0 check pihole-lan-names, mutation-tested. Correct docs that still described public names as CNAME to wan. and the cloudflared entrypoint comment. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Headlamp — Kubernetes dashboard (Keycloak OIDC + per-user RBAC)
Headlamp is served at https://headlamp.${BASE_DOMAIN} behind the shared Gateway
(wildcard TLS), with native Keycloak OIDC login and per-user RBAC (ADR-0014, docs/15).
How per-user RBAC works (end to end)
- A user logs in at Keycloak (
homelabrealm,headlampconfidential client, auth-code flow). - Headlamp's backend keeps the user's
id_tokenand forwards it as the bearer on every request it proxies to the k3s API server (this is Headlamp's default OIDC behaviour). - The apiserver validates that token — it is configured (
infra/ansibleroles/k3s_server) withoidc-issuer-url= this realm,oidc-client-id=kubernetes,oidc-username-claim=email,oidc-groups-claim=groups, prefixesoidc:. - RBAC is applied per user via the existing group bindings in
../keycloak/k8s-oidc-rbac.yaml:oidc:platform-admins→cluster-admin,oidc:viewers→view.
So each user gets exactly their own rights — not a shared Headlamp identity. That is why the
Headlamp ServiceAccount is not bound to cluster-admin: the chart default
clusterRoleBinding.create: true (→ cluster-admin) is overridden to false in
helmrelease.yaml. User requests ride the forwarded user token; the SA needs no cluster rights.
The audience nuance (why the client has an audience mapper)
The apiserver only accepts ID tokens whose aud contains kubernetes (oidc-client-id= kubernetes). A plain headlamp client issues aud=[headlamp], which the apiserver would reject.
The headlamp client therefore carries an oidc-audience-mapper (see
../keycloak/keycloak-realm-import.yaml) with included.client.audience: kubernetes, so tokens
carry aud=[headlamp, kubernetes]:
- Headlamp's own verifier accepts it (aud contains
headlamp), and - the apiserver accepts it (aud contains
kubernetes).
This keeps the apiserver OIDC config untouched (preferred over widening --api-audiences).
Where the OIDC config comes from (two sources, no overlap)
The container needs five env vars, and their names are load-bearing: the chart's args reference
them via $(...) (-oidc-idp-issuer-url=$(OIDC_ISSUER_URL), -oidc-client-id=$(OIDC_CLIENT_ID), …).
They must be exactly OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_ISSUER_URL, OIDC_SCOPES,
OIDC_CALLBACK_URL — not camelCase. A misnamed or missing name does not fail loudly:
Kubernetes leaves the literal string $(OIDC_ISSUER_URL) in the argument and login dies with
unsupported protocol scheme "".
| Env var | Source | Why |
|---|---|---|
OIDC_CLIENT_ID, OIDC_SCOPES, OIDC_CLIENT_SECRET |
headlamp-oidc-credentials.sops.yaml, envFrom'd by config.oidc.externalSecret |
secret, or environment-invariant |
OIDC_ISSUER_URL, OIDC_CALLBACK_URL |
helmrelease.yaml → spec.values.env, templated ${BASE_DOMAIN} |
environment-specific and public |
Exactly one source per variable. Never define the same name in both places: an explicit env:
entry silently overrides the same name arriving via envFrom, so an overlap hides which value is
actually live.
Why the URLs are not in the Secret (the 2026-09-07 prod outage)
Flux's ${BASE_DOMAIN} postBuild substitution runs over the rendered manifest text; it can never
reach inside an encrypted Secret. OIDC_ISSUER_URL and OIDC_CALLBACK_URL used to be keys in
headlamp-oidc-credentials.sops.yaml, so both were frozen at the apex home.keithjackson.dev.
Dev is the apex, so dev looked fine — but prod (BASE_DOMAIN = prod.home.keithjackson.dev) was
pointed at dev's Keycloak, 192.168.1.53, which sits on the dev LAN behind pfSense and is
unroutable from prod's 192.168.2.0/24. Every prod login failed with:
Get "https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration":
dial tcp 192.168.1.53:443: i/o timeout
An issuer URL and a callback URL are public, so they now live in cleartext in helmrelease.yaml
where substitution works. Only the client secret stays encrypted. The same root cause and the same
fix apply to Semaphore (catalog/semaphore.yaml, SEMAPHORE_OIDC_PROVIDERS).
[parity] OIDC_ISSUER_URL must equal the kube-apiserver's oidc-issuer-url, which is an
install-time k3s flag set from k3s_base_domain (infra/ansible/roles/k3s_server/defaults/main.yml,
overridden per inventory group), not from cluster-vars. Headlamp forwards the user's id_token
to the apiserver, so a mismatch 401s every request. Both derive from the same per-environment
domain — dev home.keithjackson.dev, prod prod.home.keithjackson.dev — which is what keeps them
in step; changing one without the other breaks login.
Secrets
- The confidential client secret lives SOPS-encrypted in
../keycloak/keycloak-realm-clients.sops.yaml(headlamp-client-secret, injected into the realm import via theHEADLAMP_CLIENT_SECRETplaceholder) and mirrored in this namespace'sheadlamp-oidc-credentials.sops.yaml(OIDC_CLIENT_SECRET), which the chartconfig.oidc.externalSecretenvFrom's. Same cross-namespace mirror pattern as grafana/gitea.
Break-glass
The cert-based admin in the bootstrap kubeconfig is unaffected by any of this and retains full access if Keycloak/OIDC is down — the standard break-glass path.
Files
| File | Tier | Purpose |
|---|---|---|
namespace.yaml |
platform | headlamp namespace (gateway-access=shared; platform, not app-namespace) |
helmrepository.yaml / helmrelease.yaml |
platform | Official kubernetes-sigs/headlamp chart + OIDC/RBAC values |
headlamp-oidc-credentials.sops.yaml |
platform | SOPS OIDC secret the chart envFrom's — client id/scopes/secret only; the per-env URLs live in helmrelease.yaml spec.values.env |
networkpolicy.yaml |
platform | default-deny baseline + Cilium ingress(gateway)/egress(apiserver+Keycloak) allows |
httproute.yaml |
platform-config | Gateway API HTTPRoute for headlamp.${BASE_DOMAIN} (needs Gateway CRDs) |