feat(dstack-ingress): ACME DNS-01 challenge delegation (CNAME alias) for least-privilege DNS tokens - #104
Conversation
Adds an opt-in `ACME_CHALLENGE_ALIAS` mode so the DNS token can be scoped to a delegated zone instead of the served domain's own (often shared, production) zone. Closes the least-privilege gap described in the issue. - certman.py: when ACME_CHALLENGE_ALIAS is set, run certbot via `--manual` with auth/cleanup hooks instead of the provider's DNS plugin; `renew` reuses the hooks saved in the renewal config. - acme-dns-alias-hook.sh: writes/removes the `_acme-challenge` TXT in the delegated zone (reusing dnsman.py / the existing provider abstraction). - dnsman.py + base.py: add `unset_txt` for challenge cleanup. - entrypoint.sh: in delegation mode the served-zone records (alias, app-address TXT, CAA) can't be written by our token, so print the exact records the operator must set statically, and for CAA verify presence (via DoH) and warn loudly if missing — instead of silently dropping the accounturi lock. - README: document the mode, required static records, and the CAA security note. Non-breaking: unset ACME_CHALLENGE_ALIAS = current behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xa1zFJs3FVP8UTsSWuS3Yj
…ldcard CNAME, robust DoH verify - entrypoint.sh: the delegation-mode accounturi-CAA check was unreachable in the default config (it lived after the `SET_CAA != true` early return, and SET_CAA defaults to false), so the forge-prevention control silently never ran. Move it above the SET_CAA gate (delegation CAA is independent of SET_CAA) and make it fail closed: if the required CAA is confirmed absent the container exits, with ALLOW_MISSING_CAA=true to override. A transient DoH failure only warns. - entrypoint.sh: fix the wildcard `_acme-challenge` CNAME guidance — strip `*.` so it matches the base name certbot/LE validate and the hook writes to. - entrypoint.sh: parse the DoH response with jq and match with `grep -F` (the account URI contains `/` and `.`, which must not be regex), and distinguish "confirmed absent" (fail) from "could not verify" (warn). - base.py: unset_txt_record no longer reports success for a record it could not address (no id) — counts it as failure and warns. - README: document fail-closed CAA + ALLOW_MISSING_CAA, the propagation ceiling vs certbot's 300s timeout, using a dedicated delegation zone, and deleting a stale plugin renewal conf before switching to delegation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xa1zFJs3FVP8UTsSWuS3Yj
…restructure #104 added ACME DNS-01 challenge delegation to entrypoint.sh. This branch moves that file's logic into dns01.sh, so a plain rebase drops the shell half of it: set_alias_record / set_txt_record / set_caa_record kept this branch's versions and the delegation branches went with them. Port them into dns01.sh, which is where the delegation belongs anyway -- it is a dns-01 capability, and entrypoint.sh no longer holds mode-specific logic. Everything else from #104 survived the rebase untouched: the hook script, unset_txt_record, the dnsman.py action, the certman.py branch and the README section. Two of the ports are deliberate substitutions, not copies: - The CAA helpers reuse this branch's caa_tag_for() and "${domain#\*.}" instead of re-introducing caa_domain_and_tag(). Behaviour is identical for both wildcard and plain names; the second helper would only be a second place to keep them in sync. - The delegated TXT instruction prints $(txt_record_value) rather than "$APP_ID:$PORT". APP_ID is an optional override, so the original printed ":443" whenever it was unset -- which is the common case. Printing the same function that writes the record in non-delegation mode also stops the instruction drifting from what the gateway actually expects. Two more changes are integration fixes: bugs neither PR has alone, only the combination. - The delegation branch built its certbot command with CERTBOT_STAGING, which this branch renamed to ACME_STAGING. ACME_STAGING=true plus delegation would have issued *production* certificates. - It also passed --email unconditionally, which this branch made optional; with no contact address configured that is `--email ""`. It now uses the same optional-contact handling as the plugin path. Verified against a delegation run: the served zone is untouched, all four static records print with correct values, the certbot command carries --manual with both hooks plus --staging and --register-unsafely-without-email, CAA verification fails closed with exit 1, and ALLOW_MISSING_CAA=true overrides it.
|
Post-merge note, @Ravenyjh — I merged this before reviewing it properly, which was my mistake, so here is the review after the fact. The mechanism holds up; I verified the parts that worried me most:
Three things came out of it, tracked in #106: the CAA verification is single-resolver and fails closed (a stale negative cache means the container refuses to start, and One small fix already landed in #105: the delegated TXT instruction printed #105 also moved this feature's shell half into Thanks for this — the shared-production-zone problem is real, and delegation covers the wildcard and multi-instance cases that tls-alpn-01 structurally cannot. |
Routing the delegation checks through dnsguide silently changed what the CAA check means. dnsguide asks "does anything forbid us from issuing", and under RFC 8659 an absent CAA record set forbids nothing, so it passes. Delegation asks the opposite question: that record is the only thing stopping anyone else who can satisfy the delegated challenge from getting a certificate for the name, and we hold no token to create it, so its absence is exactly the state that has to block. The end-to-end run caught it -- with no CAA at all the check reported "ok (no CAA record set; issuance is unrestricted)" and let issuance proceed, where #104 would have refused to start. The unit tests did not, because they only ever asserted on records that exist. `--caa-required` inverts the rule for this one caller. ALLOW_MISSING_CAA still downgrades it to a warning, so the escape hatch behaves as documented. It is now better than the original in one respect: under DNS_SETUP_MODE wait the container waits for the operator to create the record rather than failing on the first look, and still fails after DNS_SETUP_TIMEOUT. TESTING.md documents how to exercise delegation without a second registrar account -- any name under a zone you already control works, because the provider resolves zones by longest-suffix match -- along with what that substitution does not cover.
Implements #103.
Problem
dstack-ingress issues certs via ACME DNS-01, which needs a DNS token that can edit the served domain's own zone (
_acme-challengeTXT, plus the A/alias record and — withSET_CAA— the CAA). When the served name lives under a shared production zone, that token can edit the whole zone, which is more privilege than an enclave should hold. Cloudflare tokens can't scope below zone level, and subdomain zones are Enterprise-only.What this adds
Opt-in
ACME_CHALLENGE_ALIAS=<delegation-zone>: answer the DNS-01 challenge in a separate zone the token controls, so the token never touches the served domain's zone.certman.py— whenACME_CHALLENGE_ALIASis set, run certbot via--manualwith auth/cleanup hooks instead of the provider's DNS plugin.renewreuses the authenticator + hooks saved in the renewal config.scripts/acme-dns-alias-hook.sh(new) — writes/removes the_acme-challengeTXT in the delegation zone, reusingdnsman.py/ the existingdns_providersabstraction.dnsman.py+dns_providers/base.py— addunset_txtfor challenge cleanup.entrypoint.sh— in delegation mode the served-zone records (alias, app-address TXT, CAA) can't be written by our token, so it prints the exact records the operator must set statically. For CAA it does not silently skip: it prints the requiredaccounturiCAA and verifies its presence (via DoH), warning loudly if absent — since that CAA is the forge-prevention.README.md— documents the mode, the required static records, and the security note.Security note
The
accounturiCAA (only this enclave's ACME account may issue) normally auto-set byentrypoint.shcannot be set by us in delegation mode (no token for the served zone). Rather than silently dropping it (which would let anyone who satisfies the delegated challenge obtain a cert for the domain), the feature prints the exact record and verifies/warns. The delegation token should be scoped only to<delegation-zone>.Compatibility
Non-breaking: with
ACME_CHALLENGE_ALIASunset, behavior is unchanged.Testing
Python (
py_compile) and bash (bash -n) syntax checks pass. This is the DNS/orchestration path; happy to add an e2e case (Let's Encrypt staging + a delegated test zone) if you'd like.🤖 Generated with Claude Code
https://claude.ai/code/session_01Xa1zFJs3FVP8UTsSWuS3Yj