refactor(rotation): port cloudflare + external-dns to cloudflare-roll mode #332

Merged
k.renner merged 1 commits from feat/port-cloudflare-rotators into master 2026-07-21 15:11:25 +00:00
2 changed files with 53 additions and 118 deletions
@@ -1,23 +1,20 @@
---
# Class-B automated rotation of the Cloudflare DNS-01 api-token used by
# cert-manager. Rolls the token's secret value in place via the Cloudflare API
# (PUT /user/tokens/{id}/value — same scope/policy, new value), then patches
# OpenBao. openbao-ns pattern (Cloudflare's public API is reachable via the
# openbao ns `world` egress). ESO syncs the cloudflare-api-token Secret (ns
# cert-manager, 10m refresh). NO Reloader needed — cert-manager reads the Secret
# fresh on each DNS-01 challenge, so no pod restart is required.
# Class-B rotation of the cert-manager Cloudflare DNS-01 api-token. Rolls the
# `Kubernetes Cert-Manager` token value in place via the secret-rotator binary
# (cloudflare-roll mode): PUT /user/tokens/{id}/value — same id/scope/policy,
# new value — then patches OpenBao. The admin token + token-id pin live in the
# shared bootstrap (secret-rotators-bootstrap/cloudflare); this job reads the
# `token-id` field (external-dns reads `external-dns-token-id`), so the two
# tokens roll independently.
#
# Tested + enabled 2026-07-19.
# NO Reloader: cert-manager reads the Secret fresh on each DNS-01 challenge, so
# existing certs are unaffected and no restart is needed. openbao-ns pattern;
# Cloudflare API reachable via the openbao ns `world` egress. ESO syncs the
# cloudflare-api-token Secret (ns cert-manager, 10m).
#
# IMPORTANT — it rolls the DEDICATED `Kubernetes Cert-Manager` token
# (id af708481…, scoped Zone Read + DNS Write on renner.uno ONLY). Staged
# verification found cert-manager had been using an unrelated 4-zone token named
# `Caddy` (renner.uno + konstantin-renner.de + nerdcave.eu + sarah-renner.de),
# shared with something outside this repo. cert-manager was switched to the
# dedicated token (dropping write access to 3 personal zones) and the bootstrap
# `token-id` pins rotation to it, so `Caddy` is never touched. If you ever
# re-point this, re-check the token-id — rolling a shared token breaks its other
# consumers (cf. the consumer-enumeration lesson in #213).
# IMPORTANT: token-id pins rotation to the DEDICATED single-zone cert-manager
# token — never the revoked 4-zone `Caddy` token it replaced. If re-pointed,
# re-verify the id (rolling a shared token breaks its other consumers).
apiVersion: v1
kind: ServiceAccount
metadata:
@@ -26,8 +23,6 @@ metadata:
labels:
app.kubernetes.io/name: cloudflare
app.kubernetes.io/component: secret-rotator
# OpenBao: auth/kubernetes/role/rotator-cloudflare binds this SA to
# rotator-cloudflare (read bootstrap + patch the api-token path).
---
apiVersion: batch/v1
kind: CronJob
@@ -38,7 +33,7 @@ metadata:
app.kubernetes.io/name: cloudflare
app.kubernetes.io/component: secret-rotator
spec:
suspend: false # tested end-to-end 2026-07-19 (rolls the dedicated cert-manager token)
suspend: false
schedule: "0 4 1 1,4,7,10 *" # quarterly, Europe/Berlin
timeZone: Europe/Berlin
concurrencyPolicy: Forbid
@@ -66,48 +61,27 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
# python:alpine ships an HTTP client + JSON via stdlib (urllib/json) —
# NO runtime package install, so it runs cleanly under the non-root
# restricted PSA (apk/apt need root and fail here).
image: python:3.13-alpine
image: gitea.renner.uno/gitea_admin/secret-rotator@sha256:978fe53cad83a8d55319f6201ecd171775a101b21aaa21b7ec3c7b14304c6a42
args: ["cloudflare-roll"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: BAO_ADDR
value: http://openbao.openbao.svc.cluster.local:8200
command: ["python3", "-c"]
args:
- |
import json, os, urllib.request as u
bao = os.environ["BAO_ADDR"]
jwt = open("/var/run/secrets/kubernetes.io/serviceaccount/token").read()
def call(method, url, headers=None, body=None, ct="application/json"):
data = json.dumps(body).encode() if body is not None else None
req = u.Request(url, data=data, method=method, headers=headers or {})
if data is not None:
req.add_header("Content-Type", ct)
with u.urlopen(req, timeout=30) as r:
return json.loads(r.read() or b"{}")
tok = call("POST", bao + "/v1/auth/kubernetes/login",
body={"role": "rotator-cloudflare", "jwt": jwt})["auth"]["client_token"]
boot = call("GET", bao + "/v1/homelab/data/infrastructure/secret-rotators-bootstrap/cloudflare",
{"X-Vault-Token": tok})["data"]["data"]
admin, tid = boot["admin-token"], boot["token-id"]
# Roll the token secret value IN PLACE (same scope/policy, new value).
resp = call("PUT", "https://api.cloudflare.com/client/v4/user/tokens/" + tid + "/value",
{"Authorization": "Bearer " + admin}, {})
if not resp.get("success"):
raise SystemExit("cloudflare roll failed: " + json.dumps(resp.get("errors")))
res = resp["result"]
new = res if isinstance(res, str) else res["value"]
call("PATCH", bao + "/v1/homelab/data/infrastructure/cloudflare",
{"X-Vault-Token": tok}, {"data": {"api-token": new}}, "application/merge-patch+json")
print("rolled cloudflare api-token (id " + tid + ") at homelab/infrastructure/cloudflare (value not logged)")
- name: BAO_ROLE
value: rotator-cloudflare
- name: BOOTSTRAP_PATH
value: homelab/infrastructure/secret-rotators-bootstrap/cloudflare
- name: BOOTSTRAP_TOKENID_FIELD
value: token-id
- name: BAO_PATH
value: homelab/infrastructure/cloudflare
resources:
requests:
cpu: 25m
memory: 48Mi
memory: 16Mi
limits:
memory: 96Mi
memory: 48Mi
@@ -1,28 +1,15 @@
---
# Class-B automated rotation of the Cloudflare api-token used by external-dns.
# Rolls the token's secret value in place via the Cloudflare API
# (PUT /user/tokens/{id}/value — same scope/policy, new value), then patches
# OpenBao. openbao-ns pattern (Cloudflare's public API is reachable via the
# openbao ns `world` egress). ESO syncs the cloudflare-api-token Secret (ns
# apps, 10m refresh).
# Class-B rotation of the external-dns Cloudflare api-token. Rolls the
# `Kubernetes External-DNS` token value in place via the secret-rotator binary
# (cloudflare-roll mode): PUT /user/tokens/{id}/value, then patches OpenBao.
# Reads `external-dns-token-id` from the shared bootstrap (a DIFFERENT field
# from cert-manager, same admin token) so the two roll independently.
#
# UNLIKE the cert-manager rotator (cloudflare.yaml), this one DOES need
# Reloader: external-dns reads CF_API_TOKEN as an env var at startup, so
# without a restart it keeps using the old value after the roll. The
# annotation lives on the external-dns Deployment (see
# kubernetes/apps/external-dns/helmrelease.yaml).
#
# Rolls the DEDICATED `Kubernetes External-DNS` token (Zone Read + DNS Write on
# renner.uno ONLY), pinned by external-dns-token-id in the shared cloudflare
# bootstrap. It deliberately does NOT reuse cert-manager's token-id field —
# the two are separate tokens and must roll independently.
#
# History worth keeping: external-dns previously rode the 4-zone `Caddy` token,
# which the repo believed was used by "something outside this repo" — it was
# not, external-dns was the consumer, identified by mapping the Secret to a
# token ID via /user/tokens/verify. Comparing secret values only proves two
# secrets differ; it cannot say WHICH token a value is. Caddy has since been
# revoked. If you ever re-point this job, re-verify the token-id the same way.
# UNLIKE cert-manager, this needs Reloader: external-dns reads CF_API_TOKEN as a
# startup env var, so the external-dns Deployment carries a reload annotation
# (kubernetes/apps/external-dns/helmrelease.yaml). Schedule is offset 30 min
# from the cert-manager roll. openbao-ns pattern; ESO syncs the
# cloudflare-api-token Secret (ns apps, 10m).
apiVersion: v1
kind: ServiceAccount
metadata:
@@ -31,8 +18,6 @@ metadata:
labels:
app.kubernetes.io/name: external-dns
app.kubernetes.io/component: secret-rotator
# OpenBao: auth/kubernetes/role/rotator-external-dns binds this SA to
# rotator-external-dns (read bootstrap + patch the apps/external-dns path).
---
apiVersion: batch/v1
kind: CronJob
@@ -43,8 +28,8 @@ metadata:
app.kubernetes.io/name: external-dns
app.kubernetes.io/component: secret-rotator
spec:
suspend: false # tested end-to-end 2026-07-19 — see PR
schedule: "30 4 1 1,4,7,10 *" # quarterly, Europe/Berlin (offset from the cert-manager roll)
suspend: false
schedule: "30 4 1 1,4,7,10 *" # quarterly, Europe/Berlin
timeZone: Europe/Berlin
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
@@ -71,51 +56,27 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
# python:alpine ships an HTTP client + JSON via stdlib (urllib/json) —
# NO runtime package install, so it runs cleanly under the non-root
# restricted PSA (apk/apt need root and fail here).
image: python:3.13-alpine
image: gitea.renner.uno/gitea_admin/secret-rotator@sha256:978fe53cad83a8d55319f6201ecd171775a101b21aaa21b7ec3c7b14304c6a42
args: ["cloudflare-roll"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: BAO_ADDR
value: http://openbao.openbao.svc.cluster.local:8200
command: ["python3", "-c"]
args:
- |
import json, os, urllib.request as u
bao = os.environ["BAO_ADDR"]
jwt = open("/var/run/secrets/kubernetes.io/serviceaccount/token").read()
def call(method, url, headers=None, body=None, ct="application/json"):
data = json.dumps(body).encode() if body is not None else None
req = u.Request(url, data=data, method=method, headers=headers or {})
if data is not None:
req.add_header("Content-Type", ct)
with u.urlopen(req, timeout=30) as r:
return json.loads(r.read() or b"{}")
tok = call("POST", bao + "/v1/auth/kubernetes/login",
body={"role": "rotator-external-dns", "jwt": jwt})["auth"]["client_token"]
boot = call("GET", bao + "/v1/homelab/data/infrastructure/secret-rotators-bootstrap/cloudflare",
{"X-Vault-Token": tok})["data"]["data"]
admin, tid = boot["admin-token"], boot["external-dns-token-id"]
# Roll the token secret value IN PLACE (same scope/policy, new value).
resp = call("PUT", "https://api.cloudflare.com/client/v4/user/tokens/" + tid + "/value",
{"Authorization": "Bearer " + admin}, {})
if not resp.get("success"):
raise SystemExit("cloudflare roll failed: " + json.dumps(resp.get("errors")))
res = resp["result"]
new = res if isinstance(res, str) else res["value"]
# A trailing newline here would surface as
# "invalid header field value for Authorization" and crash-loop
# external-dns — the API value is used verbatim, never via a file.
call("PATCH", bao + "/v1/homelab/data/apps/external-dns",
{"X-Vault-Token": tok}, {"data": {"api-token": new}}, "application/merge-patch+json")
print("rolled external-dns api-token (id " + tid + ") at homelab/apps/external-dns (value not logged)")
- name: BAO_ROLE
value: rotator-external-dns
- name: BOOTSTRAP_PATH
value: homelab/infrastructure/secret-rotators-bootstrap/cloudflare
- name: BOOTSTRAP_TOKENID_FIELD
value: external-dns-token-id
- name: BAO_PATH
value: homelab/apps/external-dns
resources:
requests:
cpu: 25m
memory: 48Mi
memory: 16Mi
limits:
memory: 96Mi
memory: 48Mi