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.
…cond checker Challenge delegation (Dstack-TEE#104) grew its own CAA check: one DoH resolver, `grep -F` over the rdata, refuse to start if the record is not found. Three problems, all of which dnsguide.py already handles because tls-alpn-01 hit them first. Public resolvers cache *negative* answers for the zone's SOA minimum, up to an hour, so a resolver asked before the operator created the record keeps reporting it absent long after it exists. Combined with failing closed, that is a container refusing to start over a record that is there. The failure asymmetry ran the wrong way, too: an unreachable resolver warned and continued -- no CAA protection at all -- while a stale cache was fatal. The cheap failure was treated as serious and the serious one as cosmetic. And `grep -F` only matches presentation-form rdata, which is what dns.google returns. Adding a second resolver for the first problem would have quietly broken it, because Cloudflare answers CAA in RFC 3597 generic hex; a literal match fails, and failing closed turns that into an outage. dnsguide.py queries two resolvers with union quorum, parses both wire formats, and is unit-tested against strings real resolvers returned. Use it. Delegation also stops printing its own record list, since printing operator-managed records is what that tool is for. Two additions were needed: - `challenge-cname`, the `_acme-challenge` CNAME that makes delegation work. It gets an exact-match check rather than the gateway CNAME's: check_alias falls back to comparing addresses so a flattened apex ALIAS is not rejected, but a delegation target holds only a TXT, so there is no address and its absence would be reported as "hostname does not resolve yet". - `--caa-advisory`, so ALLOW_MISSING_CAA still downgrades a failing CAA check to a warning. Delegation gains DNS_SETUP_MODE from the same change. `wait` is the useful one: the operator can start the container and create the records while it waits, instead of the container failing on the first look. The routing records are now verified *before* the first issuance rather than after, so a first deployment no longer spends an ACME attempt on records nobody has created yet. Two smaller fixes in the same area: - `unset_txt_record` returned success when the zone lookup failed, because `get_dns_records` returns `[]` for both "no records" and "could not look". Observed as `Error: Could not find zone` immediately followed by `Successfully unset TXT record`, exit 0. - `_ensure_zone_id` returned the *previously cached* zone id when a lookup failed, so a write meant for one zone could land in another. Not currently reachable — `dnsman.py` runs fresh per call — but delegation is precisely the feature that makes "which zone are we writing to" a security property. Refs Dstack-TEE#106.
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