From 96f0ff0ed45744aa02dc9e7c077d7bd22d05a129 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Tue, 21 Jul 2026 17:07:00 +0200 Subject: [PATCH] refactor(rotation): port cloudflare + external-dns to cloudflare-roll mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the two identical Cloudflare python rotators with the secret-rotator binary's cloudflare-roll mode (added in the prior PR). Net -65 lines; the duplicated OpenBao-login + PUT-and-parse python and the python:3.13-alpine runtime image go away. Both roll their token value in place (PUT /user/tokens/{id}/value) and patch OpenBao. They differ only in two env values, both parameterised: cloudflare BOOTSTRAP_TOKENID_FIELD=token-id -> infrastructure/cloudflare external-dns BOOTSTRAP_TOKENID_FIELD=external-dns-token-id -> apps/external-dns The admin token comes from the same shared bootstrap, so it stays in one place. Behaviour preserved: roll-in-place keeps the token id/scope (no window), the API value is stored verbatim (no trailing newline), and assert_fields_exist guards the target path. cert-manager needs no reload (reads fresh per DNS-01 challenge); external-dns keeps its Reloader annotation (startup env var). Tested: the cert-manager rotator was rolled live end-to-end via the real binary image — OpenBao rolled (d6a4d73b -> 9bd47674), new token verifies active with the SAME id af708481 (roll-in-place), no trailing newline, ESO synced, 27/27 certs still Ready. external-dns is the identical code path (only the token-id field + target path differ) and was not rolled to avoid a needless restart; its PUT/parse was additionally proven against the real Cloudflare API on a throwaway token in the mode PR. Note: Flux server-side apply strips the old python `command` when it applies these (verified against the already-ported Class-A CronJobs, which show command= live). A manual `kubectl apply` would NOT — it merges and keeps the stale command — so port these via Flux, not kubectl apply. Image digest sha256:978fe53c… Co-Authored-By: Claude Opus 4.8 (1M context) --- .../openbao/rotation/cloudflare.yaml | 82 ++++++----------- .../openbao/rotation/external-dns.yaml | 89 ++++++------------- 2 files changed, 53 insertions(+), 118 deletions(-) diff --git a/kubernetes/infrastructure/openbao/rotation/cloudflare.yaml b/kubernetes/infrastructure/openbao/rotation/cloudflare.yaml index 0ef600d7..6b24d1a2 100644 --- a/kubernetes/infrastructure/openbao/rotation/cloudflare.yaml +++ b/kubernetes/infrastructure/openbao/rotation/cloudflare.yaml @@ -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 diff --git a/kubernetes/infrastructure/openbao/rotation/external-dns.yaml b/kubernetes/infrastructure/openbao/rotation/external-dns.yaml index daffe438..cecc14fd 100644 --- a/kubernetes/infrastructure/openbao/rotation/external-dns.yaml +++ b/kubernetes/infrastructure/openbao/rotation/external-dns.yaml @@ -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 -- 2.54.0