From 1e8479b8533d5e2d20ae5a0d1abbdfbb7b87be9c Mon Sep 17 00:00:00 2001 From: mr-forust Date: Wed, 23 Sep 2026 13:39:38 +0200 Subject: [PATCH] feat(adguard): sync Traefik prod cert for dns.forust.xyz into adguard-certs CronJob adguard-cert-sync (daily 03:17) copies the public cert/key for dns.forust.xyz from Traefik acme.json into Secret adguard-certs, which AdGuard mounts for DNS-over-TLS on :853. - least-privilege RBAC: read pods/exec in ns traefik, get/update/patch Secret adguard-certs and get/patch adguard-deployment in ns adguard - script selects the PROD resolver entry only, matches main domain or SANs, compares sha256 hashes, patches the secret and restarts the deployment ONLY on change; exits non-zero and touches nothing when Traefik holds no cert yet (HTTP-01 currently cannot complete) --- adguardhome/k8s/cert-sync-rbac.yaml | 84 ++++++++++++++++++ adguardhome/k8s/cert-sync.yaml | 133 ++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 adguardhome/k8s/cert-sync-rbac.yaml create mode 100644 adguardhome/k8s/cert-sync.yaml diff --git a/adguardhome/k8s/cert-sync-rbac.yaml b/adguardhome/k8s/cert-sync-rbac.yaml new file mode 100644 index 0000000..c82e3a5 --- /dev/null +++ b/adguardhome/k8s/cert-sync-rbac.yaml @@ -0,0 +1,84 @@ +# Least-privilege RBAC for the adguard TLS cert sync CronJob (see cert-sync.yaml). +# +# The job runs as ServiceAccount `adguard-cert-sync` (namespace adguard) and needs: +# * namespace traefik: list/get pods (locate the running Traefik pod by label) +# and create pods/exec (read-only `cat` of /data/letsencrypt/acme.json). +# It never writes anything in namespace traefik. +# * namespace adguard: get/update/patch Secret `adguard-certs` (the only +# secret it may touch) and get/patch Deployment `adguard-deployment` +# (`kubectl rollout restart` issues a patch; `rollout status` reads). +apiVersion: v1 +kind: ServiceAccount +metadata: + name: adguard-cert-sync + namespace: adguard + labels: + app: adguard +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: adguard-cert-sync + namespace: adguard + labels: + app: adguard +rules: + - apiGroups: [""] + resources: ["secrets"] + resourceNames: ["adguard-certs"] + verbs: ["get", "update", "patch"] + - apiGroups: ["apps"] + resources: ["deployments"] + resourceNames: ["adguard-deployment"] + verbs: ["get", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: adguard-cert-sync + namespace: adguard + labels: + app: adguard +subjects: + - kind: ServiceAccount + name: adguard-cert-sync + namespace: adguard +roleRef: + kind: Role + name: adguard-cert-sync + apiGroup: rbac.authorization.k8s.io +--- +# Read-only access to the Traefik pod (acme.json lives on its /data volume). +# The RoleBinding references a ServiceAccount from namespace adguard, +# which is allowed: the binding lives in namespace traefik and only +# grants rights inside namespace traefik. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: adguard-cert-sync + namespace: traefik + labels: + app: adguard +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] + - apiGroups: [""] + resources: ["pods/exec"] + verbs: ["create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: adguard-cert-sync + namespace: traefik + labels: + app: adguard +subjects: + - kind: ServiceAccount + name: adguard-cert-sync + namespace: adguard +roleRef: + kind: Role + name: adguard-cert-sync + apiGroup: rbac.authorization.k8s.io diff --git a/adguardhome/k8s/cert-sync.yaml b/adguardhome/k8s/cert-sync.yaml new file mode 100644 index 0000000..8239707 --- /dev/null +++ b/adguardhome/k8s/cert-sync.yaml @@ -0,0 +1,133 @@ +# AdGuard TLS cert sync: copy Traefik's public certificate for dns.forust.xyz +# (used by DNS-over-TLS on :853) from Traefik's acme.json into Secret +# `adguard-certs`, restarting the AdGuard Deployment only when it changed. +# +# Why this exists: cert-manager Certificate objects cannot be used here. +# Traefik's acme-http@internal router hijacks every HTTP-01 challenge path, +# so the prod ClusterIssuer can never complete an order for this host. +# Traefik itself keeps renewing the cert via its own ACME stack; this job +# mirrors the resulting public cert/key into the secret AdGuard mounts. +# +# Safety properties (all enforced by the script, not by convention): +# * selects the PROD resolver entry only (`.letsencrypt`), never staging; +# * matches by main domain OR SAN list (Traefik stores the bundled cert +# under the router's first domain, e.g. adguard.forust.xyz); +# * compares sha256 hashes and patches the Secret ONLY on change; +# * restarts the Deployment ONLY when the Secret was patched; +# * exits non-zero and touches nothing when Traefik has no cert yet, +# when the Secret is missing, or when the payload fails PEM checks. +# +# Manual apply: +# kubectl apply -f adguardhome/k8s/cert-sync-rbac.yaml +# kubectl apply -f adguardhome/k8s/cert-sync.yaml +# Force a run (safe: idempotent, read-only when already in sync): +# kubectl create job -n adguard --from=cronjob/adguard-cert-sync sync-now +apiVersion: batch/v1 +kind: CronJob +metadata: + name: adguard-cert-sync + namespace: adguard + labels: + app: adguard +spec: + schedule: "17 3 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 2 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + activeDeadlineSeconds: 300 + template: + metadata: + labels: + app: adguard + spec: + serviceAccountName: adguard-cert-sync + restartPolicy: OnFailure + containers: + - name: cert-sync + image: dtzar/helm-kubectl:3.19.1 + imagePullPolicy: IfNotPresent + resources: + requests: + cpu: "50m" + memory: "64Mi" + limits: + cpu: "200m" + memory: "256Mi" + env: + - name: SYNC_DOMAIN + value: "dns.forust.xyz" + - name: SYNC_RESOLVER + value: "letsencrypt" + command: + - /bin/sh + - -c + - | + set -eu + DOMAIN="${SYNC_DOMAIN:?}" + RESOLVER="${SYNC_RESOLVER:?}" + NS="adguard" + SECRET="adguard-certs" + DEPLOY="adguard-deployment" + + echo "== 1. locate running traefik pod ==" + POD="$(kubectl get pods -n traefik -l app.kubernetes.io/name=traefik \ + --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')" + if [ -z "${POD:-}" ]; then + echo "ERROR: no running traefik pod found, leaving secret untouched" + exit 1 + fi + echo "traefik pod: $POD" + + echo "== 2. fetch ${DOMAIN} cert/key from acme.json (resolver ${RESOLVER}) ==" + TMP="$(mktemp -d)" + trap 'rm -rf "$TMP"' EXIT INT TERM + kubectl exec -n traefik "$POD" -- cat /data/letsencrypt/acme.json > "$TMP/acme.json" + jq -r --arg r "$RESOLVER" --arg d "$DOMAIN" \ + '.[$r].Certificates // [] | map(select(.domain.main == $d or ((.domain.sans // []) | index($d))))' \ + "$TMP/acme.json" \ + | jq -r '.[0] // empty | .certificate // empty' > "$TMP/new.crt.b64" + jq -r --arg r "$RESOLVER" --arg d "$DOMAIN" \ + '.[$r].Certificates // [] | map(select(.domain.main == $d or ((.domain.sans // []) | index($d))))' \ + "$TMP/acme.json" \ + | jq -r '.[0] // empty | .key // empty' > "$TMP/new.key.b64" + if [ ! -s "$TMP/new.crt.b64" ] || [ ! -s "$TMP/new.key.b64" ]; then + echo "ERROR: no certificate for ${DOMAIN} under resolver ${RESOLVER} in acme.json." + echo "HINT: Traefik has not issued it (check HTTP-01 reachability and DNS records)." + echo "Leaving secret ${SECRET} untouched." + exit 1 + fi + base64 -d "$TMP/new.crt.b64" > "$TMP/new.crt" + base64 -d "$TMP/new.key.b64" > "$TMP/new.key" + grep -q "BEGIN CERTIFICATE" "$TMP/new.crt" || { echo "ERROR: payload is not a PEM certificate"; exit 1; } + grep -q "BEGIN .*PRIVATE KEY" "$TMP/new.key" || { echo "ERROR: payload is not a PEM private key"; exit 1; } + echo "fetched PEM cert/key for ${DOMAIN} (sanity checks passed)" + + echo "== 3. compare with live secret ${SECRET} ==" + if ! kubectl -n "$NS" get secret "$SECRET" >/dev/null 2>&1; then + echo "ERROR: secret $NS/${SECRET} does not exist, refusing to create it implicitly." + echo "HINT: bootstrap it once, then re-run this job." + exit 1 + fi + kubectl -n "$NS" get secret "$SECRET" -o jsonpath='{.data.tls\.crt}' \ + | base64 -d > "$TMP/live.crt" + kubectl -n "$NS" get secret "$SECRET" -o jsonpath='{.data.tls\.key}' \ + | base64 -d > "$TMP/live.key" + NEW_HASH="$(sha256sum "$TMP/new.crt" "$TMP/new.key" | sha256sum | cut -d' ' -f1)" + LIVE_HASH="$(sha256sum "$TMP/live.crt" "$TMP/live.key" | sha256sum | cut -d' ' -f1)" + if [ "$NEW_HASH" = "$LIVE_HASH" ]; then + echo "secret ${SECRET} already holds the current ${DOMAIN} cert, nothing to do" + exit 0 + fi + echo "cert differs, patching secret ${SECRET}" + + echo "== 4. update secret and restart ${DEPLOY} ==" + CRT_B64="$(base64 "$TMP/new.crt" | tr -d '\n')" + KEY_B64="$(base64 "$TMP/new.key" | tr -d '\n')" + kubectl -n "$NS" patch secret "$SECRET" --type=merge \ + -p '{"data":{"tls.crt":"'"$CRT_B64"'","tls.key":"'"$KEY_B64"'"}}' + echo "secret patched, restarting deployment" + kubectl -n "$NS" rollout restart "deploy/${DEPLOY}" + kubectl -n "$NS" rollout status "deploy/${DEPLOY}" --timeout=180s + echo "sync complete"