Skip to content

Support Tailscale OAuth clients, not just API keys - #9

Open
jmeekhof wants to merge 1 commit into
jaxxstorm:mainfrom
jmeekhof:fix/tailscale-oauth
Open

Support Tailscale OAuth clients, not just API keys#9
jmeekhof wants to merge 1 commit into
jaxxstorm:mainfrom
jmeekhof:fix/tailscale-oauth

Conversation

@jmeekhof

@jmeekhof jmeekhof commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Why

Tailscale API keys expire 90 days after creation. When one expires, dnsscale doesn't fail in any way an operator would notice — it keeps running, keeps polling on its interval, and every request comes back 401. Reconciliation stops, the process stays "healthy", and the zone quietly goes stale.

I found this on a deployment where dnsscale had gone over seven months since its last successful DNS write. The key expired 90 days in and the only symptom was a 401 every 30 seconds in a log nobody was reading. Records that should have been managed had been created by hand instead, because as far as anyone could tell dnsscale just wasn't doing anything.

OAuth client credentials don't expire, and the token refreshes automatically, so the failure mode goes away entirely.

What changed

  • tailscale.oauth_client_id / tailscale.oauth_client_secret, also settable via TAILSCALE_OAUTH_CLIENT_ID / TAILSCALE_OAUTH_CLIENT_SECRET and via flags.

  • Default scope is devices:core:read and nothing else. dnsscale makes exactly one Tailscale call — GET /api/v2/tailnet/{tailnet}/devices — and never writes, so there's no reason to ask for a wider grant. tailscale.oauth_scopes can override it if you disagree.

  • Validate() requires exactly one of the two auth methods. Accepting both would leave which one actually authenticates up to the reader.

  • The API-key path now warns at startup that the key will expire, and a 401 on that path says so explicitly instead of reporting the bare status text:

    API request failed with status 401: 401 Unauthorized (Tailscale API keys expire
    90 days after creation - if this worked before, the key has likely expired; an
    OAuth client does not expire)
    

    That message is arguably the more valuable half of this PR — it's what turns a silent stall into something diagnosable.

Compatibility

Existing API-key configurations are unaffected apart from the new startup warning. Nothing is deprecated or removed.

golang.org/x/oauth2 was already in the module graph as an indirect dependency, and I pinned it at the version already present (v0.27.0) rather than letting go get upgrade it — so this does not move the go directive or the toolchain version.

Tests

Cover the auth-selection rules, and run the client-credentials exchange against a stub server asserting the grant type, the requested scope, and that the access token actually reaches the devices call (with a regression guard that the empty API key doesn't clobber the OAuth header). Plus a case pinning the improved 401 message.

go build ./..., go vet ./... and go test ./... all pass.


This is independent of #6, #7 and #8 and can be taken in any order.

Tailscale API keys expire 90 days after creation. When one expires,
dnsscale does not fail in any visible way: it keeps running, keeps
polling on its interval, and every request returns 401. Reconciliation
stops, the process stays healthy, and the zone quietly goes stale until
someone happens to read the logs.

I hit this on a deployment where dnsscale had been running for over
seven months since its last successful DNS write - the key expired 90
days in, and the only symptom was a 401 every 30 seconds.

OAuth client credentials do not expire, and the token is refreshed
automatically, so this failure mode goes away.

- tailscale.oauth_client_id / tailscale.oauth_client_secret, also
  settable as TAILSCALE_OAUTH_CLIENT_ID / TAILSCALE_OAUTH_CLIENT_SECRET
  and via flags.
- The default scope is devices:core:read and nothing else. dnsscale
  issues exactly one Tailscale call, GET /api/v2/tailnet/{tailnet}
  /devices, and never writes, so there is no reason for a wider grant.
  tailscale.oauth_scopes can override it.
- Validate() requires exactly one of the two auth methods. Accepting
  both would leave which one actually authenticates up to the reader.
- The API-key path now warns at startup that the key will expire, and a
  401 on that path says so explicitly rather than reporting the bare
  status text. That is what made this failure mode so quiet.

golang.org/x/oauth2 was already in the module graph as an indirect
dependency; it is pinned at the version already present (v0.27.0) so
this does not move the go directive or the toolchain.

Tests cover the auth-selection rules and run the client-credentials
exchange against a stub server, asserting the grant type, the requested
scope, and that the access token reaches the devices call.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant