Intro
After Part 19, OpenBao had a proper login: Google, through Cloudflare Access,
with short-lived tokens. Headlamp, my Kubernetes UI, still wanted a pasted
ServiceAccount token from kubectl create token. I wanted the same Google
login there.
It looks like the same job, but it isn’t. OpenBao checks the ID token itself. Headlamp doesn’t: it forwards your token to the Kubernetes API server, so kube-apiserver is the one that has to trust Cloudflare Access.
TL;DR: A second Cloudflare Access SaaS app (OIDC) for Headlamp, a structured
AuthenticationConfigurationon kube-apiserver that trusts that app’s tokens and maps my email to useroidc:<email>, and a ClusterRoleBinding for that user. The API server change went out to the three k0s controllers one at a time, with etcd, lease and controller count checks in between. SSO works, and the token login is still there for when Cloudflare or Google isn’t.
The Situation
- k0s v1.36.4 with three controllers. kube-apiserver runs as a host process
managed by k0s, not as a static pod, and its flags come from the k0s
ClusterConfig(spec.api.extraArgs) - Headlamp 0.45.0 (chart 0.45.0), reachable on the tailnet at
https://headlamp.<tailnet>.ts.net, plus a LAN hostname - Login before: a short-lived token for the
headlamp-adminServiceAccount, bound tocluster-admin - Cloudflare Access already set up with Google as an identity provider (Part 19)
Setup
How the token flows
browser -> Headlamp -> Cloudflare Access (OIDC app "Headlamp") -> Google
browser <- Headlamp <- ID token (aud = Headlamp client ID, email = me)
Headlamp -> kube-apiserver: Authorization: Bearer <ID token>
kube-apiserver: issuer and audience match? -> user oidc:<email> -> RBAC
I created a second SaaS app in Cloudflare Access rather than reusing
OpenBao’s. Each app has its own client ID, which becomes the token’s audience,
so a token issued for OpenBao can’t be replayed against the API server. The
app has one redirect URL, https://headlamp.<tailnet>.ts.net/oidc-callback,
because Headlamp accepts a single callback URL. SSO therefore works on the
tailnet hostname, which reaches it from anywhere anyway.
kube-apiserver: structured authentication
Kubernetes now has a config file for this (--authentication-config), instead
of the old --oidc-* flags:
apiVersion: apiserver.config.k8s.io/v1
kind: AuthenticationConfiguration
jwt:
- issuer:
url: https://<team>.cloudflareaccess.com/cdn-cgi/access/sso/oidc/<client-id>
audiences:
- <client-id>
claimMappings:
username:
claim: email
prefix: "oidc:"
The oidc: prefix keeps these users apart from anything else named after an
email address. Certificates and ServiceAccount tokens are untouched: this
only adds a trusted issuer.
In the repo, k0sctl.yaml gets the flag and uploads the file to the three
controllers:
spec:
k0s:
config:
spec:
api:
extraArgs:
authentication-config: /etc/k0s/authentication-config.yaml
hosts:
- role: controller
files:
- name: apiserver-authentication-config
src: k0s/authentication-config.yaml
dstDir: /etc/k0s
perm: "0644"
RBAC
The user name the API server sees is oidc:<my email>. One binding gives it
the same rights the old token had:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: headlamp-oidc-admin
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: oidc:<my email>
Giving a new identity cluster-admin and changing how the API server
authenticates are the two parts of this I treated as needing an explicit
yes. The change was ready and staged, and only went out after I approved it.
Headlamp: settings from OpenBao
The chart can read its OIDC settings from an existing Secret. The client ID,
secret, issuer and callback URL went into OpenBao at secret/headlamp/oidc
(the client secret straight from the clipboard into stdin, never printed),
and an ExternalSecret renders the keys Headlamp expects:
target:
name: headlamp-oidc
template:
data:
OIDC_CLIENT_ID: "{{ .client_id }}"
OIDC_CLIENT_SECRET: "{{ .client_secret }}"
OIDC_ISSUER_URL: "{{ .issuer }}"
OIDC_SCOPES: "openid,email,profile"
OIDC_CALLBACK_URL: "{{ .callback_url }}"
One chart quirk: with an external secret, the chart only passes
-oidc-callback-url if config.oidc.callbackURL is non-empty in the values,
even though the actual value comes from the Secret. So the values hold a dummy
string just to switch the flag on.
Rolling it out without losing the API
kube-apiserver only reads --authentication-config at start, so each
controller’s k0s needs a restart. With three controllers and etcd on each,
restarting them together would take the API down. One at a time, it never
goes away. Per controller:
- Write
/etc/k0s/authentication-config.yaml. - Back up
/etc/k0s/k0s.yaml, then add theextraArgsentry underapi:. systemctl restart k0scontroller.- Check that kube-apiserver runs with the flag and answers:
--authentication-config=/etc/k0s/authentication-config.yaml
apiserver http 401
401 is the healthy answer here: anonymous access is off, so an
unauthenticated /readyz is refused by a running server.
- Before moving on, check that k0s counts three controllers again, etcd is healthy, and all controller leases are fresh:
count=3
[+]etcd ok
[+]etcd-readiness ok
Controller-01 at 04:42 UTC, controller-02 at 04:43, controller-03 at 04:44. The rollback for each one was a single command: restore the backup and restart.
Issue 1: The rollout was interrupted by a host going down
Symptom
Writing the Headlamp settings into OpenBao failed before anything was written:
error: unable to upgrade connection: error dialing backend:
dial tcp 192.168.101.51:10250: connect: no route to host
Cause
That address is a worker on pve-02, and pve-02 had lost power. That’s Part 21.
Fix
Wait. The API server change needs all three controllers healthy, and controller-02 lives on pve-02. Once pve-02 was back and controller-02 had renewed its lease, the rollout went ahead.
Result
The Headlamp login page now has Sign In (Cloudflare Access, then Google) and Use A Token side by side. Signing in lands in Headlamp as me, with the Flux view from Part 17.
Break-glass is intact on three levels:
| If this fails | Still works |
|---|---|
| Cloudflare or Google | Headlamp’s Use A Token with a headlamp-admin ServiceAccount token (checked: the API server still returns 200 for one) |
| Headlamp | kubectl through the Tailscale API proxy |
| Tailscale | kubectl on the LAN to the API VIP with the admin client certificate |
Lessons
- Know who validates the token. OpenBao validates OIDC tokens itself; Headlamp passes them on, so the API server has to trust the issuer.
- One app per audience. A separate Cloudflare Access app gives Headlamp’s tokens their own audience, so they can’t be used anywhere else.
- Structured authentication beats
--oidc-*flags. One file, versioned in the repo, with room for more issuers later. - Restart quorum members one at a time and check between. The controller count, etcd health and leases told me when it was safe to touch the next one.
- Changes that grant power deserve an explicit yes. Staging everything and approving the last step separately cost a few minutes and nothing else.
- Keep a way in that doesn’t depend on the new way in. SSO is the daily path; the token, Tailscale and the LAN certificate are the fallbacks.
Series
This post is part of Homelab Rebuild:
- Part 1: Upgrading Proxmox VE 8 to 9
- Part 2: Starting Over When the IaC Is Broken
- Part 3: When Tailscale Breaks Your Cilium Gateway
- Part 4: Living on the Tailnet
- Part 5: Secrets Without Secrets in Git
- Part 6: Certificates, DNS and a Private CA
- Part 7: Self-Hosted CI and Getting Back to IaC
- Part 8: Stateful Workloads on Unequal Hardware
- Part 9: Going Public Safely with Cloudflare Tunnel
- Part 10: When the Hypervisor Went Quiet
- Part 11: Hardening the Cluster in Five Layers
- Part 12: Observability with Coroot, and Losing pve-01
- Part 13: The Host That Kept Losing Power
- Part 14: Observability, Take Two with OpenTelemetry
- Part 15: Requests and Limits for Everything
- Part 16: Handing the Cluster to FluxCD
- Part 17: Making the Guardrails Earn Their Keep
- Part 18: When the API Server Outgrew Its VM
- Part 19: Logging In to OpenBao with Cloudflare Access and Google
- Part 20: Headlamp SSO, or Teaching kube-apiserver to Trust Cloudflare Access (this post)
- Part 21: pve-02 Loses Power, and Pi-hole Has Nowhere to Go
- Part 22: Turning Four Days of Fixes into AI Runbooks
- Part 23: microVM Sandboxes on Proxmox