Skip to content

feat(dstack-ingress): delegate every record, so DNS is configured once - #108

Open
kvinwang wants to merge 5 commits into
mainfrom
feat/full-delegation
Open

feat(dstack-ingress): delegate every record, so DNS is configured once#108
kvinwang wants to merge 5 commits into
mainfrom
feat/full-delegation

Conversation

@kvinwang

@kvinwang kvinwang commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Follows #107. Breaking: ACME_CHALLENGE_ALIAS becomes DELEGATION_ZONE, and the
records the operator creates change shape.

Delegation exists so the DNS token in the enclave never touches the served
domain's zone. It got the token out; it did not get the operator out. This
finishes the job.

Before

Setting up svc.example.com, with a delegation zone and a token scoped to it:

  1. Create three records in the production zone:

    svc.example.com                      CNAME  <GATEWAY_DOMAIN>
    _dstack-app-address.svc.example.com  TXT    "<app-id>:443"
    _acme-challenge.svc.example.com      CNAME  _acme-challenge.svc.example.com.deleg.example.net
    

    The app id has to be looked up, and the container is not running yet to print
    it for you.

  2. Deploy, and wait for the first certificate.

  3. Come back and create a fourth record, which the container prints only now
    because it names an ACME account that did not exist until step 2:

    svc.example.com  CAA  0 issue "letsencrypt.org;validationmethods=dns-01;accounturi=…"
    
  4. Later, whenever the app id moves — it defaults to the hash of
    app-compose.json, so adding an environment variable is enough — go back and
    update the TXT from step 1. Nothing alerts you: the certificate keeps
    renewing, only traffic stops.

Wildcards additionally kept their CAA in the production zone permanently, since
*.example.com is evaluated at example.com.

After

  1. Create three CNAMEs in the production zone, before deploying anything.
    Nothing here needs a value you have to look up:

    svc.example.com                      CNAME  svc.example.com.deleg.example.net
    _dstack-app-address.svc.example.com  CNAME  _dstack-app-address.svc.example.com.deleg.example.net
    _acme-challenge.svc.example.com      CNAME  _acme-challenge.svc.example.com.deleg.example.net
    
  2. Deploy.

That is the whole of it. There is no step 3, and no later. The container
publishes everything those names point at — the gateway pointer, the app-address
TXT, the challenge, and the accounturi CAA — into the zone it holds a token for,
and keeps them current. An app id that moves, an ACME account that is recreated,
a gateway that changes address: none of them reach the production zone any more.

A wildcard takes one more CNAME, aliasing its base, because RFC 8659
evaluates *.app.example.com at app.example.com:

*.app.example.com                            CNAME  app.example.com.deleg.example.net
app.example.com                              CNAME  app.example.com.deleg.example.net
_dstack-app-address-wildcard.app.example.com CNAME  _dstack-app-address-wildcard.app.example.com.deleg.example.net
_acme-challenge.app.example.com              CNAME  _acme-challenge.app.example.com.deleg.example.net

With that in place the CAA is published for wildcards too. The base has to be a
name you can alias, which a zone apex is not, so a wildcard served straight off
its own zone is not a fit for delegation.

Setting it up

Same as before, minus the second visit:

- CHALLENGE_TYPE=dns-01
- DOMAIN=svc.example.com
- GATEWAY_DOMAIN=_.dstack-prod5.phala.network
- TARGET_ENDPOINT=app:80
- DELEGATION_ZONE=deleg.example.net          # was ACME_CHALLENGE_ALIAS
- CLOUDFLARE_API_TOKEN=<scoped to deleg.example.net only>

Keep /etc/letsencrypt on a volume. The ACME account lives there and the CAA
names it, so losing it means the published CAA no longer matches and issuance
stops.

Evidence for the DNS and CA behaviour this rests on — which records the CA reads
through a CNAME, and why the gateway pointer is a CNAME on Cloudflare and an
address record elsewhere — is in the comments below.

Copilot AI review requested due to automatic review settings July 28, 2026 11:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kvinwang kvinwang changed the title feat(dstack-ingress): delegate every record, not just the challenge feat(dstack-ingress): delegate every record, not just the challenge [blocked on #109] Jul 28, 2026
@kvinwang kvinwang changed the title feat(dstack-ingress): delegate every record, not just the challenge [blocked on #109] feat(dstack-ingress): delegate every record, not just the challenge Jul 28, 2026
@kvinwang
kvinwang force-pushed the feat/full-delegation branch from de3f6c0 to 7532b6b Compare July 28, 2026 14:27
@kvinwang kvinwang changed the title feat(dstack-ingress): delegate every record, not just the challenge feat(dstack-ingress): delegate every record, so DNS is configured once Jul 28, 2026
kvinwang added 2 commits July 28, 2026 07:34
Challenge delegation moved the ACME challenge out of the served domain's
zone, but left three records behind that the operator still had to create
by hand, and two reasons to come back to them.

The accounturi CAA needs a second visit: the container can compute it but
not write it, and it names an ACME account that does not exist until the
first issuance has succeeded.

The app-address TXT carries the app id, which by default is the hash of
app-compose.json and so moves when the compose does -- add an environment
variable and the gateway stops routing until someone updates the record.
It can be pinned with `vmm-cli deploy --app-id`, which is how an app is
upgraded in place, so this is avoidable rather than inevitable. But it is
the default, and it fails silently: the certificate keeps renewing, only
traffic stops.

Alias all three names into the delegation zone instead. The operator
creates three CNAMEs before deploying and is then done, permanently:

    svc.example.com                      CNAME  svc.example.com.deleg.net
    _dstack-app-address.svc.example.com  CNAME  _dstack-app-address.svc.example.com.deleg.net
    _acme-challenge.svc.example.com      CNAME  _acme-challenge.svc.example.com.deleg.net

The container publishes what they point at, including the CAA.

The gateway pointer and the CAA end up on one name, which RFC 1034 does
not allow -- a CNAME excludes every other type at its name. Cloudflare
allows the pair anyway and Let's Encrypt honours the CAA it finds there,
verified against the staging CA: a CAA that forbids the CA stops issuance
with `While processing CAA for svc.example.com: CAA record for
svc.example.com prevents issuance`. The resolver stops at the first name
in the chain carrying a CAA rather than following to the end, so a CAA at
the delegation zone apex is *not* consulted -- also verified.

Providers that enforce the standard reject the pair, so the record type
follows the provider: Cloudflare gets a CNAME and keeps DNS following a
gateway that moves; everything else gets an address record, which can
carry the CAA legally, at the cost of re-resolving GATEWAY_DOMAIN once a
pass. Only Cloudflare has been tested, so only Cloudflare gets the
optimistic default; DELEGATION_GATEWAY_RECORD overrides either way.

The variable is now DELEGATION_ZONE. ACME_CHALLENGE_ALIAS was accurate
when the challenge was the only thing delegated and is not any more; it
is removed rather than deprecated, since the feature is days old and the
published image does not carry it.

Renewal passes verify only the challenge alias. The other two are about
serving, and dns-01 never connects here, so blocking a renewal on them
would turn a routing problem into an expired certificate. The first pass
checks all three, because that is setup.

The delegated challenge's propagation wait also had to go from 30s to
120s: it must outlast the record's own 60s TTL, or the second of the two
issuance attempts fails Let's Encrypt's multi-perspective check with
"During secondary validation: Incorrect TXT record found".
Delegation left wildcards with an operator-managed CAA, on the reasoning
that RFC 8659 evaluates `*.example.com` at `example.com` and that is the
operator's own name. It is, but that does not stop it being aliased --
the CA follows a CNAME there like anywhere else.

Verified against the staging CA. With

    *.wb.kvin.wang  CNAME  wb.kvin.wang.bz.kvin.wang
    wb.kvin.wang    CNAME  wb.kvin.wang.bz.kvin.wang
    wb.kvin.wang.bz.kvin.wang  CAA  0 issuewild "example.invalid"

issuance was refused with `While processing CAA for *.wb.kvin.wang: CAA
record for wb.kvin.wang prevents issuance` -- the CA following the base's
alias into the delegated zone and honouring what it found there.

So dnsguide asks for a fourth CNAME on wildcards, aliasing the base, and
the container publishes the CAA with the issuewild tag instead of
skipping it. A wildcard deployment now needs no DNS work after setup
either.

The base has to be aliasable, which a zone apex is not, so a wildcard
served straight off its own zone is not a fit for delegation. Anything a
label down is.
@kvinwang
kvinwang force-pushed the feat/full-delegation branch from 50964d7 to 1d58942 Compare July 28, 2026 14:34
@kvinwang

Copy link
Copy Markdown
Collaborator Author

What the CA actually does with CAA behind a CNAME

The design rests on this, so each row is a real issuance against Let's Encrypt
staging with a CAA that forbids the CA, checking whether it is refused.

Layout Verdict
CAA directly on the name (control) refused
Name is a CNAME; CAA on the target refused — the CA follows and honours it
Name is a CNAME; target is also a CNAME; CAA at the delegation zone apex issued — the CA does not climb past the target
Wildcard; CAA on the base's CNAME target, issuewild refusedWhile processing CAA for *.wb.kvin.wang: CAA record for wb.kvin.wang prevents issuance

So the CAA has to sit on the name the alias points at, beside the gateway
pointer. That puts a CNAME and a CAA on one name, which RFC 1034 does not allow.
Cloudflare allows it anyway and the CA reads it.

Providers that enforce the standard reject the pair, so the record type follows
the provider:

Provider Gateway pointer Gateway that moves
cloudflare CNAME followed by DNS, immediately
everything else A, re-resolved each pass picked up within RENEW_INTERVAL

Only Cloudflare has been tested, so only Cloudflare gets the optimistic default.
DELEGATION_GATEWAY_RECORD overrides it either way.

Two smaller fixes that came out of testing

Renewal is no longer blocked on records it does not need. Under dns-01 the CA
reads a TXT record and never connects here, so the gateway CNAME and the
app-address TXT are about serving, not issuance. Blocking a renewal on them turns
a routing problem — fixable at any time — into an expired certificate, which is
not. Later passes verify only the challenge alias; the first pass still checks
everything, because that is setup.

The delegated challenge's propagation wait goes from 30s to 120s. It has to
outlast the record's own 60s TTL, not just the write: on the second of the two
issuance attempts a resolver still serving the previous challenge value fails
Let's Encrypt's multi-perspective check with During secondary validation: Incorrect TXT record found. The dns-01 plugin path already waited 120s.

Verification

End to end against staging with real Cloudflare credentials, delegation zone
starting empty, three CNAMEs created by hand:

Successfully set alias record for cc3.kvin.wang.cz3.kvin.wang
Successfully set TXT record for _dstack-app-address.cc3.kvin.wang.cz3.kvin.wang

[dns-check #1] all records verified:
    OK   CNAME cc3.kvin.wang: ok (CNAME)
    OK   CNAME _dstack-app-address.cc3.kvin.wang: ok (CNAME)
    OK   CNAME _acme-challenge.cc3.kvin.wang: ok (CNAME)

✓ Certificate obtained successfully for cc3.kvin.wang
Successfully set CAA record for cc3.kvin.wang.cz3.kvin.wang

Renewal works with the published CAA in place — the case that had never been
exercised before, since the record is only written after a successful issuance
and the second attempt always reports nothing to renew:

# restart, account unchanged: …/acme/acct/318178554
    OK   CAA cc3.kvin.wang: ok (from cc3.kvin.wang)
✓ Certificate renewal completed

A wildcard issues, and every subdomain resolves through one delegated name —
a wildcard CNAME copies its target verbatim rather than expanding it (RFC 4592):

subject=CN = *.wc.kvin.wang,  SAN: DNS:*.wc.kvin.wang

a.wc.kvin.wang.   CNAME  wc.kvin.wang.wz.kvin.wang.
xyz.wc.kvin.wang. CNAME  wc.kvin.wang.wz.kvin.wang.
wc.kvin.wang.wz.kvin.wang. CNAME gwz.kvin.wang. → A 72.52.116.133

App id churn is gone. Changing the TXT in the delegation zone is immediately
visible from the production name with no edit there:

_dstack-app-address.fd1.kvin.wang  →  ['NEWAPPID9999:443']

29 unit tests, the sanitizer suite, bash -n and ast.parse across all scripts.

Still not covered

  • A token scoped to only the delegation zone. Testing used a name under a zone
    the token already owned, which exercises every line of the mechanism but not
    the scoping property it exists for.
  • A gateway that actually changes address mid-deployment.
  • Any provider other than Cloudflare.

kvinwang added 3 commits July 28, 2026 08:08
Publishing the CAA from inside the container made a chain of things dead
without anything failing, which is how they survived. A review found:

- delegation_verify_caa, and with it the only caller of ALLOW_MISSING_CAA
  and of dnsguide's --caa-required / --caa-advisory. Wildcards were the
  last user; once the container published their CAA too, the whole path
  was unreachable. The README still advertised ALLOW_MISSING_CAA.

- check_caa's require_present parameter and its three tests. The
  semantics it existed for -- "an absent CAA must block, because we
  cannot create it" -- stopped being true when we started creating it.
  Absence now just means "not published yet", and the next pass fixes it.

- Two test classes defined twice over. Python shadows the earlier
  definition silently, unittest counts the later one, and the total still
  goes up, so duplicated blocks look like passing tests. A guard now
  walks the file's AST and fails on any repeated class or method name;
  it was checked against a deliberately duplicated block.

- Stale naming: --challenge-alias and args.challenge_alias outlived the
  variable rename, and ACME_CHALLENGE_PROPAGATION_SECONDS with them. Both
  now say delegation. The README still claimed ACME_CHALLENGE_ALIAS
  "still works", which had not been true since it was removed.

Also reject contradictory --include values. `delegated` is a shape rather
than a record kind -- it replaces the per-kind records instead of adding
to them -- so `--include cname,delegated` would emit two CNAMEs claiming
the same name. Unknown values are now rejected too, instead of being
silently ignored.
…and mean

what it says

Two problems with the gateway pointer, both from review.

Switching the setting did not switch the record. set_a_record only
inspects A records and set_cname_record only CNAMEs, so each leaves the
other type in place -- and DNS forbids a CNAME beside anything else, so
the new record then fails to create. Publishing now clears the other type
first, and switching either way was checked against Cloudflare: CNAME to
A removes the CNAME and leaves only the address, and back again.

`cname` did not guarantee a CNAME. The branch called set_alias, and
"alias" is provider-defined: Linode overrides it to create an A record
deliberately, since Linode refuses a CAA beside a CNAME. So on Linode the
setting said cname and produced an address record. The branch now calls
set_cname explicitly, which no provider redefines.

unset_txt_record generalises to unset_records(name, type) for the
clearing step; the TXT wrapper stays for the challenge hook.

Also spell out the rename in the README rather than leaving it to the
changelog. ACME_CHALLENGE_ALIAS is not accepted, and a deployment that
keeps it does not fail loudly -- it silently leaves delegation mode and
starts writing to the served domain's zone, which is the one thing
delegation exists to prevent.
… as it already did

DELEGATION_GATEWAY_RECORD reimplemented an extension point that was
already there. set_alias_record means "point this name at that target, in
whatever form this provider allows beside a CAA" -- Linode overrides it to
publish an address record, with a docstring saying exactly why: it refuses
a CAA next to a CNAME, which is the conflict delegation runs into.

So there were two mechanisms deciding the same thing: the per-provider
override, and a `case cloudflare) cname ;; *) a ;;` of my own a layer up.
They agreed on Linode by coincidence and would have disagreed on any
provider that added an override later, since the shell branch never
consulted it.

Worse, the previous commit made the `cname` branch call set_cname
specifically to bypass Linode's override -- treating the correct
provider-specific behaviour as the bug.

Delegation now asks for an alias and takes what it gets. Gone with the
switch: the provider-detection action, the set_a action, dnsguide's
--resolve, and the README section explaining which providers get which.
A provider that needs a form other than CNAME overrides set_alias_record,
which is one place to look instead of two.

The cross-type clearing that review found stays, moved to where it
belongs: set_alias_record removes address records before writing a CNAME,
Linode's override removes a CNAME before writing an address record. Every
caller benefits, not just delegation, and the rule sits next to the
knowledge of which form this provider uses.

Route53 is worth a look separately -- it takes the base CNAME path, and
if it also refuses a CAA beside a CNAME it needs the same override Linode
has. Untested either way.
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