# 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" # Compare content digests only (never filenames: identical # content under different paths must hash equal). NEW_HASH="$(sha256sum "$TMP/new.crt" | cut -d' ' -f1)$(sha256sum "$TMP/new.key" | cut -d' ' -f1)" LIVE_HASH="$(sha256sum "$TMP/live.crt" | cut -d' ' -f1)$(sha256sum "$TMP/live.key" | 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"