Skip to content

fix(dstack-ingress): verify delegated records with dnsguide, not a second checker - #107

Merged
kvinwang merged 2 commits into
mainfrom
fix/ingress-104-followups
Jul 28, 2026
Merged

fix(dstack-ingress): verify delegated records with dnsguide, not a second checker#107
kvinwang merged 2 commits into
mainfrom
fix/ingress-104-followups

Conversation

@kvinwang

Copy link
Copy Markdown
Collaborator

Follow-ups to #104, from reviewing it after it merged. Tracked in #106; this PR
covers the P1 and both P3 items. No revert needed — the mechanism in #104 is
sound, these are the sharp edges around it.

The delegated-record check was a second, weaker implementation

verify_delegation_caa() did its own DoH lookup: one resolver, grep -F over
the rdata, refuse to start if not found. Each of those has a failure mode that
dnsguide.py already handles, because tls-alpn-01 hit them first.

Negative caching. Public resolvers cache "no such record" for the zone's SOA
minimum — up to an hour. A resolver asked before the operator created the record
keeps reporting it absent long after it exists. Fail closed on that and you have
a container refusing to start over a record that is there. This was observed
during #105's testing on exactly this kind of check.

The asymmetry ran backwards.

Situation Before After
DoH unreachable / non-zero status warn, continue — no CAA protection one resolver failing does not decide it
Present but negatively cached hard fail, container will not start second resolver sees it, passes

Wire format. grep -F "accounturi=…" matches presentation-form rdata, which
is what dns.google returns. Adding a second resolver to fix the first problem
would have quietly broken it: Cloudflare answers CAA in RFC 3597 generic hex
(\# 47 00 05 69 73 73 75 65 …), the literal match fails, and — failing closed —
that is an outage.

dnsguide.py queries two resolvers with union quorum, parses both encodings, and
is unit-tested against strings real resolvers actually returned. Delegation now
calls it, and stops printing its own record list, since printing
operator-managed records is what that tool exists for.

What that needed

challenge-cname — the _acme-challenge CNAME. 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 was reported as
"hostname does not resolve yet".

--caa-advisory — so ALLOW_MISSING_CAA=true still downgrades a failing CAA
check to a warning.

What it gains

DNS_SETUP_MODE now applies to delegation. wait (the default) blocks until the
records appear, so the operator can start the container and create them
afterwards rather than having it fail on the first look; webhook POSTs them to
an automation endpoint with an HMAC and a TDX quote.

The routing records are also verified before the first issuance instead of
after, so a first deployment no longer spends an ACME attempt — and one of Let's
Encrypt's five failed validations per hostname per hour — on records nobody has
created yet.

unset_txt_record reported success when the zone lookup failed

get_dns_records() returns [] both for "this name has no TXT records" and for
"the zone could not be resolved", so the cleanup iterated nothing and returned
True:

acme-dns-alias-hook: removing challenge TXT _acme-challenge.svc.example.com.deleg.example.net
Error: Could not find zone for domain _acme-challenge.svc.example.com.deleg.example.net
Successfully unset TXT record for _acme-challenge.svc.example.com.deleg.example.net

Exit code 0. The hook discards cleanup failures on purpose, so the impact is a
stale TXT in the delegation zone — but the log stated the opposite of what
happened. Providers can now answer zone_is_resolvable(); the base class stays
optimistic so providers that do not model zones are unaffected.

_ensure_zone_id fell back to a stale cached zone

zone_info = self._get_zone_info(domain)
if zone_info:
    self.zone_id, self.zone_domain = zone_info
return self.zone_id      # previous zone's id when the lookup failed

A failed lookup returned whichever zone was cached last, so a write meant for one
zone could land in another. Not currently reachable — dnsman.py runs as a fresh
process per call — but delegation is precisely the feature that makes "which zone
are we writing to" a security property.

Verification

Container run in delegation mode, DNS_SETUP_MODE=wait:

  CNAME  svc.kvin.wang
         -> gw-t.kvin.wang
  TXT    _dstack-app-address.svc.kvin.wang
         -> 5bb4ff9a…:443
  CNAME  _acme-challenge.svc.kvin.wang
         -> _acme-challenge.svc.kvin.wang.acme-deleg.example.net
            (delegates the ACME challenge to a zone this container can write)

[dns-check #1] WAIT CNAME svc.kvin.wang: hostname does not resolve yet
[dns-check #1] WAIT TXT _dstack-app-address.svc.kvin.wang: no TXT record found (checked 2 resolver(s))
[dns-check #1] WAIT CNAME _acme-challenge.svc.kvin.wang: no CNAME record found
[dns-check #1] OK   CAA svc.kvin.wang: ok (no CAA record set; issuance is unrestricted)

All four records with correct values, two resolvers, and the delegation CNAME
reporting its own absence accurately rather than borrowing the address-based
message.

Six new unit tests cover the delegation record (target, wildcard base name,
absent without a delegation zone) and the exact CNAME check (accepts the target,
rejects a different one, reports absence). 24 tests pass, plus the sanitizer
suite.

Still not covered, and unchanged by this PR: a real delegated issuance —
hook writes the TXT, Let's Encrypt follows the CNAME, validation passes. That
needs a delegation zone. TESTING.md describes the setup.

Not in this PR

set_txt_record deletes every TXT at a name before writing. That is correct for
the routing records and wrong for challenges, which is invisible today because
process_domain passes one -d per certbot invocation. It stops being invisible
when one certificate covers several identifiers validating at the same name —
example.com plus *.example.com, or #86's SAN support. Left for whoever lands
#86, noted in #106.

…cond checker

Challenge delegation (#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 #106.
Copilot AI review requested due to automatic review settings July 28, 2026 09:11

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.

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.
@kvinwang

kvinwang commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Test report — delegated issuance, end to end

The path #104 shipped without executing has now been run: hook writes the TXT
into the delegated name, Let's Encrypt follows the CNAME, validation passes, a
certificate comes back.

Scope, stated up front: this covers issuance. The serving path — gateway
reads the app-address TXT and routes to a CVM — was not exercised, and dns-01
does not need it for issuance, since the CA never connects to the server. See
"Still not covered" at the end for exactly what that leaves open.

Substituting for a delegation zone

No second registrar account was needed. The provider resolves a zone by
longest-suffix match on the record name, so any name under a zone you already
control works: with ACME_CHALLENGE_ALIAS=deleg.kvin.wang and a served domain
of svc2.kvin.wang, the challenge record is
_acme-challenge.svc2.kvin.wang.deleg.kvin.wang, which lands in kvin.wang
the same zone, but through the delegation code path.

What that does not test: the point of delegation is that a token scoped to
only the delegation zone is enough. Sharing one zone exercises every line of the
mechanism and leaves that property unverified. Documented in TESTING.md.

The run

Real Cloudflare credentials, Let's Encrypt staging, the three static records
created by hand as the operator would.

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

Executing (challenge-delegation): certbot certonly --non-interactive -v --manual
  --preferred-challenges=dns --manual-auth-hook=/scripts/acme-dns-alias-hook.sh auth
  --manual-cleanup-hook=/scripts/acme-dns-alias-hook.sh cleanup --agree-tos
  --no-eff-email --register-unsafely-without-email -d svc2.kvin.wang --staging

✓ Certificate obtained successfully for svc2.kvin.wang
issuer=C = US, O = Let's Encrypt, CN = (STAGING) Baloney Bulgur YE2
subject=CN = svc2.kvin.wang
notBefore=Jul 28 08:41:16 2026 GMT   notAfter=Oct 26 08:41:15 2026 GMT

The challenge TXT was gone from the authoritative nameserver afterwards, so the
cleanup hook ran and worked. Evidence generated: acme-account.json,
cert-svc2.kvin.wang.pem, quote.json, sha256sum.txt.

Only the _acme-challenge CNAME participates in issuance. The other two records
are for serving, and the checks on them mean less than the "all records verified"
line suggests: check_alias confirms a CNAME exists with the expected target
without resolving that target, and check_txt confirms the value matches what
the container computed. Both were true in this run and neither implies anything
routes — see below.

It caught a regression this PR had introduced

With no CAA record on the name at all, the check reported

OK   CAA svc2.kvin.wang: ok (no CAA record set; issuance is unrestricted)

and let issuance through. #104 would have refused to start.

Routing the delegation checks through dnsguide had silently changed the
question. dnsguide asks "does anything forbid us from issuing", and under
RFC 8659 an absent CAA record set forbids nothing. Delegation asks the opposite:
that record is the only thing stopping anyone else who can satisfy the delegated
challenge, and we cannot create it, so its absence is exactly what must block.

Fixed in 4d06340 with --caa-required. The unit tests had not caught it because
they only ever asserted on records that exist; three more now cover the absent
case in both directions.

After the fix, with the CAA still missing:

[dns-check #7] WAIT CAA svc2.kvin.wang: no CAA record set, so any account that can
               satisfy the delegated challenge could obtain a certificate for this name

and once the record was created, without restarting the container:

[dns-check #9] all records verified:
    OK   CAA svc2.kvin.wang: ok (from svc2.kvin.wang)
Combined PEM created: /etc/haproxy/certs/svc2.kvin.wang.pem
[09:41:13] Next certificate check in 43200s

That is better than the original behaviour: under DNS_SETUP_MODE=wait the
container waits for the operator instead of failing on the first look, and still
fails after DNS_SETUP_TIMEOUT.

Suites

27 unit tests (up from 18: six for the delegation record and exact CNAME check,
three for the required-CAA rule), the sanitizer suite, bash -n and ast.parse
across all scripts.

Still not covered

  • The serving path. No gateway and no CVM were involved. The app-address TXT
    held a local simulator's app id, which no gateway knows, and the
    svc2.kvin.wang CNAME pointed at a gateway hostname that does not currently
    resolve — a dangling target, which check_alias accepts because it matches on
    the CNAME record rather than resolving it. Neither mattered for issuance, and
    neither was tested. A run that proves traffic flows needs a real gateway and a
    real CVM, as in feat(dstack-ingress): TLS-ALPN-01 mode that needs no DNS credentials #105's tls-alpn-01 testing.
  • A token scoped to only the delegation zone, per above.
  • Renewal through the delegated path. certbot reuses the authenticator and
    hooks from the renewal config, which the README already warns about when
    migrating from the plugin authenticator, but that reuse has not been
    exercised.

@kvinwang

Copy link
Copy Markdown
Collaborator Author

Second test report — the serving path, on real hardware

The earlier report covered issuance only. This run adds what it left out: a real
dstack-gateway, a real TDX CVM, and traffic actually flowing from the public
internet to the app.

Setup mirrors #105's tls-alpn-01 testing — TDX host running dstack-vmm,
dstack-kms and dstack-gateway, port 443 redirected to the gateway, Let's
Encrypt staging, real Cloudflare credentials delivered as an encrypted env
var. The delegation zone is still a name under the same zone, which exercises the
whole mechanism but not the token-scoping property; that caveat is unchanged.

Issuance, in a CVM

The three operator records were created by hand, with the real app id this
time (948a4385410a22c5a6ce784c5a114dc4fdc2c4c6), not a simulator's:

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

Executing (challenge-delegation): certbot certonly … --manual
  --manual-auth-hook=…/acme-dns-alias-hook.sh auth
  --manual-cleanup-hook=…/acme-dns-alias-hook.sh cleanup … --staging

✓ Certificate obtained successfully for svc3.kvin.wang

The CAA gate then blocked, as intended:

[dns-check #1] WAIT CAA svc3.kvin.wang: no CAA record set, so any account that can
               satisfy the delegated challenge could obtain a certificate for this name

and released once the record existed, without a restart.

Traffic

$ curl -k https://svc3.kvin.wang/
Hostname: 34aea221199b
IP: 172.18.0.2
RemoteAddr: 172.18.0.3:51476
GET / HTTP/1.1
Host: svc3.kvin.wang

That is the whoami backend inside the CVM, reached over the public internet.
The certificate served is the one delegation obtained — (STAGING) Artificial Amaranth YE1, CN = svc3.kvin.wang.

The gateway resolved the app-address TXT and load-balanced on it:

connecting to app 948a4385410a22c5a6ce784c5a114dc4fdc2c4c6:
  [AddressInfo { ip: 10.44.0.14, instance_id: "f636a0b3…" },
   AddressInfo { ip: 10.44.0.13, instance_id: "edab4775…" }]

and the CVM's own HAProxy logged the arrival from the gateway's WireGuard
address:

10.44.0.1:1026 [28/Jul/2026:10:19:11.392] tls_in~ be_upstream/app1 335/0/477 529

An incidental confirmation of something #105 documented: the first few
connections timed out because that instance list still held a stale instance
from an earlier deployment
, and the gateway raced both. This is exactly why
tls-alpn-01 has to pin the instance id — under dns-01 the app id is the right
choice, and racing replicas is the feature, but it is also why a challenge
answered by one specific instance cannot be routed that way.

Attestation, over the same path

Fetched through the gateway from https://svc3.kvin.wang/evidences/:

sha256sum -c            →  acme-account.json: OK,  cert-svc3.kvin.wang.pem: OK
report_data[:64]        =  06ef7e5cac8bc97a2b51e5fe23b1a1be2a0acfeaaf46802050c05b1eb43fc9b5
sha256(sha256sum.txt)   =  06ef7e5cac8bc97a2b51e5fe23b1a1be2a0acfeaaf46802050c05b1eb43fc9b5
live certificate SHA-256 fingerprint == the evidence copy
dcap-qvl against Intel collateral: status = UpToDate, advisory = []

So the chain closes for a delegation-issued certificate too: Intel root → quote →
report_datasha256sum.txt → the certificate actually on the wire.

Snags worth recording

Nothing to do with this PR, but they cost time and would cost the next person the
same:

  • vmm-cli compose has no --allowed-envs; an encrypted env var is only
    injected if allowed_envs names it, which means editing app-compose.json by
    hand. Since the app id is derived from that file, doing so changes the app id
    and the app-address TXT has to follow.
  • --env-file expects KEY=VALUE lines, not JSON, and silently yields nothing
    for the wrong format — the failure surfaces much later as
    CLOUDFLARE_API_TOKEN environment variable is required inside the container.
  • deploy --kms-url sets both the URL used host-side to fetch the encryption key
    and the URL written into the guest config. Passing a host-only address makes
    the CVM reboot-loop on Failed to get app keys from KMS. Deploying without it
    and then running update-env --kms-url https://localhost:13501 keeps the two
    separate.

Still not covered

  • A token scoped to only the delegation zone.
  • Renewal through the delegated path.

@kvinwang
kvinwang merged commit 1b71020 into main Jul 28, 2026
6 checks passed
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