feat(external-dns): automated Cloudflare token rotation #314

Merged
k.renner merged 1 commits from feat/external-dns-token-rotation into master 2026-07-19 12:36:35 +00:00
Member

Closes the last follow-up from the Caddy-token audit. external-dns was the only Cloudflare consumer with no rotation automation; now that it holds a dedicated single-zone token, it can roll in place like the cert-manager one.

Already tested end-to-end and enabled (suspend: false), same as #310.

What it does

PUT /user/tokens/{id}/value — keeps scope and policy, changes the value. Then patches OpenBao apps/external-dns.

Unlike the cert-manager rotator, this one needs Reloader. cert-manager re-reads its Secret on each DNS-01 challenge; external-dns reads CF_API_TOKEN as a startup env var, so without a restart it keeps using a value that is already invalid. This job exercises the annotation added in #311 — and it works:

12:30:02  reloader: Changes detected in 'cloudflare-api-token' ...; updated 'external-dns' of type 'Deployment'
t+10s     Reloader restarted it automatically: external-dns-6c8d4654f9-lkjjx

No manual restart — which is the whole point, since the cutover in #311 did need one.

Token isolation

Reuses the existing cloudflare bootstrap so the admin-token exists in exactly one place and is rolled once, but pins a separate id so the two roll independently:

Field Token Rolled by
token-id Kubernetes Cert-Manager rotator-cloudflare
external-dns-token-id Kubernetes External-DNS this job

Verified by hash before/after: external-dns's token changed, cert-manager's did not, and all 27 Certificates stayed True.

Worth being explicit about the limit of the isolation: because the bootstrap is shared, this policy can read the same admin-token as rotator-cloudflare, so both rotators are equally privileged toward Cloudflare. The isolation that still holds is on the target paths — this policy can patch only apps/external-dns, never infrastructure/cloudflare. That tradeoff is documented in the policy file; the alternative was a second copy of the admin token to keep in sync.

Test evidence

  • job completed in 5s; OpenBao patched
  • ESO synced; Reloader restarted the Deployment unprompted in 10s
  • external-dns resumed: All records are already up to date, 0 error lines
  • stored value verifies as active id=93affa8e and carries no trailing newline — the failure mode that crash-looped external-dns during the #311 cutover (invalid header field value for Authorization). This job passes the API value through verbatim rather than via a file, so that class of bug cannot recur here.
  • refreshInterval 1h → 10m, matching the rest of the rotation fleet

Bootstrap applied

bao policy write rotator-external-dns .../policies/rotator-external-dns.hcl
bao write auth/kubernetes/role/rotator-external-dns \
  bound_service_account_names=external-dns-secret-rotator \
  bound_service_account_namespaces=openbao policies=rotator-external-dns ttl=15m
bao kv patch .../secret-rotators-bootstrap/cloudflare external-dns-token-id=<id>

The SA and CronJob were applied by hand for the test and will be adopted by Flux on merge (same names).

🤖 Generated with Claude Code

Closes the last follow-up from the Caddy-token audit. external-dns was the only Cloudflare consumer with **no** rotation automation; now that it holds a dedicated single-zone token, it can roll in place like the cert-manager one. Already tested end-to-end and enabled (`suspend: false`), same as #310. ## What it does `PUT /user/tokens/{id}/value` — keeps scope and policy, changes the value. Then patches OpenBao `apps/external-dns`. **Unlike the cert-manager rotator, this one needs Reloader.** cert-manager re-reads its Secret on each DNS-01 challenge; external-dns reads `CF_API_TOKEN` as a startup env var, so without a restart it keeps using a value that is already invalid. This job exercises the annotation added in #311 — and it works: ``` 12:30:02 reloader: Changes detected in 'cloudflare-api-token' ...; updated 'external-dns' of type 'Deployment' t+10s Reloader restarted it automatically: external-dns-6c8d4654f9-lkjjx ``` No manual restart — which is the whole point, since the cutover in #311 did need one. ## Token isolation Reuses the existing cloudflare bootstrap so the admin-token exists in exactly one place and is rolled once, but pins a **separate** id so the two roll independently: | Field | Token | Rolled by | |---|---|---| | `token-id` | `Kubernetes Cert-Manager` | `rotator-cloudflare` | | `external-dns-token-id` | `Kubernetes External-DNS` | this job | Verified by hash before/after: external-dns's token changed, **cert-manager's did not**, and all 27 Certificates stayed `True`. Worth being explicit about the limit of the isolation: because the bootstrap is shared, this policy can read the same admin-token as `rotator-cloudflare`, so both rotators are equally privileged toward Cloudflare. The isolation that still holds is on the **target** paths — this policy can patch only `apps/external-dns`, never `infrastructure/cloudflare`. That tradeoff is documented in the policy file; the alternative was a second copy of the admin token to keep in sync. ## Test evidence - job completed in 5s; OpenBao patched - ESO synced; Reloader restarted the Deployment unprompted in 10s - external-dns resumed: `All records are already up to date`, **0** error lines - stored value verifies as `active id=93affa8e` and carries **no trailing newline** — the failure mode that crash-looped external-dns during the #311 cutover (`invalid header field value for Authorization`). This job passes the API value through verbatim rather than via a file, so that class of bug cannot recur here. - `refreshInterval` 1h → 10m, matching the rest of the rotation fleet ## Bootstrap applied ``` bao policy write rotator-external-dns .../policies/rotator-external-dns.hcl bao write auth/kubernetes/role/rotator-external-dns \ bound_service_account_names=external-dns-secret-rotator \ bound_service_account_namespaces=openbao policies=rotator-external-dns ttl=15m bao kv patch .../secret-rotators-bootstrap/cloudflare external-dns-token-id=<id> ``` The SA and CronJob were applied by hand for the test and will be adopted by Flux on merge (same names). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude-bot added 1 commit 2026-07-19 12:31:30 +00:00
external-dns was the last Cloudflare consumer without rotation automation. It
now holds a dedicated single-zone token, so it can roll like the cert-manager
one: PUT /user/tokens/{id}/value keeps scope and policy, changes the value.

Unlike the cert-manager rotator, this needs Reloader — external-dns reads
CF_API_TOKEN as a startup env var. The annotation added in #311 is exercised
by this job and confirmed working: Reloader restarted the Deployment 10s after
the Secret changed, with no manual intervention.

Reuses the existing cloudflare bootstrap so the admin-token lives in one place,
but pins a SEPARATE token-id (external-dns-token-id) so the two tokens roll
independently. Verified: rolling external-dns left the cert-manager token
untouched and all 27 Certificates True.

ExternalSecret refresh 1h -> 10m to match the rest of the rotation fleet.

Tested end-to-end: job completed in 5s, OpenBao patched, ESO synced, Reloader
restarted the pod automatically, external-dns resumed with 0 errors, and the
stored value verifies as the correct active token with no trailing newline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
k.renner merged commit 1637a178b5 into master 2026-07-19 12:36:35 +00:00
Sign in to join this conversation.