feat(adguard): Traefik-synced TLS for AdGuard DoT + reloader
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 1s
renovate-ci / validate-renovate (push) Successful in 14s
ci / build (push) Successful in 1s
ci / deploy-userbot-panel (push) Skipped

- CronJob mirrors Traefik prod cert dns.forust.xyz into
  adguard-certs (cert-manager HTTP-01 is hijacked by Traefik
  acme-http router, see adguardhome/k8s/cert-sync.yaml)
- reloader for auto-restart on secret rotation
- drop retired adguard.forust.xyz from prod route
- traefik: enable kubernetesIngress, drop unused staging resolver
This commit is contained in:
forust committed 2026-09-23 14:04:14 +02:00
commit aa81f1bf8a
9 files changed
+271 -10

No files matched your search

+2
View File
@@ -62,6 +62,8 @@ spec:
metadata:
labels:
app: adguard
annotations:
reloader.stakater.com/auto: "true"
spec:
containers:
- name: adguard
+92
View File
@@ -0,0 +1,92 @@
# 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/list/watch/patch Deployment
# `adguard-deployment` (`rollout restart` issues a patch,
# `rollout status` needs list+watch).
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"]
# NOTE: list/watch cannot be combined with resourceNames (the API ignores
# the name filter for collection verbs, so the grant would be void).
# This rule is namespace-scoped to adguard, which holds a single
# Deployment; `rollout status` needs it to watch the rollout.
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["list", "watch"]
---
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
+135
View File
@@ -0,0 +1,135 @@
# 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"
+2 -2
View File
@@ -7,7 +7,7 @@ spec:
entryPoints:
- websecure
routes:
- match: Host(`adguard.forust.xyz`) || Host(`dns.forust.xyz`)
- match: Host(`dns.forust.xyz`)
kind: Rule
middlewares:
- name: crowdsec-bouncer
@@ -15,7 +15,7 @@ spec:
services:
- name: adguard-service
port: 3000
- match: (Host(`adguard.forust.xyz`) || Host(`dns.forust.xyz`)) && PathPrefix(`/dns-query`)
- match: (Host(`dns.forust.xyz`)) && PathPrefix(`/dns-query`)
kind: Rule
services:
- name: adguard-service
@@ -0,0 +1,2 @@
crds:
enabled: true
+29
View File
@@ -0,0 +1,29 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
email: bobrovod@national.shitposting.agency
server: https://acme-staging-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- http01:
ingress:
class: traefik
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
email: bobrovod@national.shitposting.agency
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
class: traefik
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: cert-manager
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: reloader
+1 -8
View File
@@ -31,7 +31,7 @@ deployment:
providers:
kubernetesIngress:
enabled: false
enabled: true
kubernetesCRD:
enabled: true
kubernetesGateway:
@@ -122,13 +122,6 @@ persistence:
path: /data
certificatesResolvers:
letsencrypt-staging:
acme:
email: bobrovod@national.shitposting.agency
storage: /data/letsencrypt/acme.json
caServer: https://acme-staging-v02.api.letsencrypt.org/directory
httpChallenge:
entryPoint: web
letsencrypt:
acme:
email: bobrovod@national.shitposting.agency