Skip to content

feat(dstack-ingress): ACME DNS-01 challenge delegation (CNAME alias) for least-privilege DNS tokens - #104

Merged
kvinwang merged 2 commits into
Dstack-TEE:mainfrom
Ravenyjh:feat/acme-challenge-delegation
Jul 28, 2026
Merged

feat(dstack-ingress): ACME DNS-01 challenge delegation (CNAME alias) for least-privilege DNS tokens#104
kvinwang merged 2 commits into
Dstack-TEE:mainfrom
Ravenyjh:feat/acme-challenge-delegation

Conversation

@Ravenyjh

Copy link
Copy Markdown
Contributor

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-challenge TXT, plus the A/alias record and — with SET_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 — when ACME_CHALLENGE_ALIAS is set, run certbot via --manual with auth/cleanup hooks instead of the provider's DNS plugin. renew reuses the authenticator + hooks saved in the renewal config.
  • scripts/acme-dns-alias-hook.sh (new) — writes/removes the _acme-challenge TXT in the delegation zone, reusing dnsman.py / the existing dns_providers abstraction.
  • dnsman.py + dns_providers/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 it prints the exact records the operator must set statically. For CAA it does not silently skip: it prints the required accounturi CAA 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 accounturi CAA (only this enclave's ACME account may issue) normally auto-set by entrypoint.sh cannot 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_ALIAS unset, 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

Ravenyjh and others added 2 commits July 27, 2026 19:13
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
@kvinwang
kvinwang merged commit e574366 into Dstack-TEE:main Jul 28, 2026
kvinwang added a commit that referenced this pull request Jul 28, 2026
…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.
@kvinwang

Copy link
Copy Markdown
Collaborator

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:

  • _get_zone_info does longest-suffix matching, so _acme-challenge.svc.example.com.deleg.example.net resolves to the delegation zone and not to the served zone whose name it happens to contain — the case I most expected to be wrong;
  • the hook computes the right record name, finds dnsman.py on PATH, and gets the venv interpreter;
  • a failed auth exits non-zero, so certbot does see the challenge fail.

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 dnsguide.py from #105 already solves this properly), unset_txt_record reports success when the zone lookup failed, and the delegated issuance path itself has still never been executed end to end.

One small fix already landed in #105: the delegated TXT instruction printed "$APP_ID:$PORT", but APP_ID is an optional override rather than the app id itself, so with it unset — the normal case — the operator was told to create a record with the value ":443". It now prints $(txt_record_value), the same function that writes the record when delegation is off, so the instruction cannot drift from what the gateway expects.

#105 also moved this feature's shell half into scripts/dns01.sh: that branch splits entrypoint.sh into one script per challenge type, and delegation is a dns-01 capability. The hook, unset_txt_record, the dnsman.py action, the certman.py branch and the README section are unchanged. Two integration fixes were needed where the two PRs met — the delegation branch read CERTBOT_STAGING, which #105 renamed to ACME_STAGING (so ACME_STAGING=true plus delegation would have issued production certificates), and it passed --email unconditionally, which #105 made optional.

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.

kvinwang added a commit that referenced this pull request Jul 28, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants