feat(adguard): sync Traefik prod cert for dns.forust.xyz into adguard-certs
ci / lint-prettier (push) Successful in 3s
ci / lint-ruff (push) Successful in 1s
ci / lint-yaml (push) Successful in 2s
ci / lint-dockerfiles (push) Successful in 1s
ci / validate (push) Successful in 2s
ci / build (push) Skipped
ci / deploy-userbot-panel (push) Skipped
ci / lint-prettier (push) Successful in 3s
ci / lint-ruff (push) Successful in 1s
ci / lint-yaml (push) Successful in 2s
ci / lint-dockerfiles (push) Successful in 1s
ci / validate (push) Successful in 2s
ci / build (push) Skipped
ci / deploy-userbot-panel (push) Skipped
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)
This commit is contained in:
1 parent
c139d700f1
commit
1e8479b853
2 files changed
+217
No files matched your search
@@ -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"
|
||||
Reference in new issue
Block a user