refactor(rotation): port cloudflare + external-dns to cloudflare-roll mode #332
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user