Skip to content

Commit 7532b6b

Browse files
committed
feat(dstack-ingress): delegate every record, not just the challenge
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 one of them, the app-address TXT, carries the app id, which changes whenever the compose changes. So "set it up once" was never true: edit the compose and someone has to go back to the production zone or the gateway stops routing. The accounturi CAA had the mirror problem: the container could compute it but not write it, so the operator had to come back a second time, after the first issuance, once the account existed. 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".
1 parent 1b71020 commit 7532b6b

7 files changed

Lines changed: 290 additions & 48 deletions

File tree

custom-domain/dstack-ingress/README.md

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,9 @@ environment:
191191
| `EVIDENCE_SERVER` | `true` | Serve evidence files at `/evidences/` on the TLS port |
192192
| `EVIDENCE_PORT` | `80` | Internal port for evidence HTTP server |
193193
| `ALPN` | | TLS ALPN protocols (e.g. `h2,http/1.1`). Only set if backends support h2c |
194-
| `ACME_CHALLENGE_ALIAS` | | Delegate the ACME DNS-01 challenge to this zone (see below) so the DNS token needs no access to the served domain's own zone |
195-
| `ACME_CHALLENGE_PROPAGATION_SECONDS` | `30` | Wait after writing the delegated challenge TXT before validation (only with `ACME_CHALLENGE_ALIAS`). Keep well under ~250s — certbot is killed after a 300s per-run timeout |
194+
| `DELEGATION_ZONE` | | Zone this container writes into, so the DNS token needs no access to the served domain's own zone (see below). `ACME_CHALLENGE_ALIAS` is the original name and still works |
195+
| `ACME_CHALLENGE_PROPAGATION_SECONDS` | `120` | Wait after writing the delegated challenge TXT before validation. Must outlast the record TTL (60s), or a resolver still serving the previous attempt's value fails validation. Keep well under ~250s — certbot is killed after a 300s per-run timeout |
196+
| `DELEGATION_GATEWAY_RECORD` | per provider | How the delegated name points at the gateway: `cname` (Cloudflare default; follows a gateway that moves) or `a` (default elsewhere, for providers that refuse a CAA beside a CNAME) |
196197
| `ALLOW_MISSING_CAA` | `false` | In delegation mode, treat an unconfirmed `accounturi` CAA as a warning instead of a blocker. Default fails closed (see below) |
197198

198199
For DNS provider credentials, see [DNS_PROVIDERS.md](DNS_PROVIDERS.md).
@@ -205,19 +206,55 @@ served name lives under a shared production zone (e.g. `svc.example.com` under
205206
`example.com`), that token can edit every record in the zone, which may be more
206207
privilege than you want.
207208

208-
Set `ACME_CHALLENGE_ALIAS=<delegation-zone>` to answer the DNS-01 challenge in a
209-
separate zone that your token controls, so the token never touches the served
210-
domain's zone. In this mode dstack-ingress **only** manages the challenge TXT in
211-
the delegation zone; you set the following records **once, statically**, in the
212-
served domain's production zone (the container prints the exact values on start):
209+
Set `DELEGATION_ZONE=<delegation-zone>` to move every name this deployment
210+
needs into a zone your token controls. You create three CNAMEs in the served
211+
domain's zone **once, before deploying**, and then never touch DNS again — not
212+
when the app id changes with the compose, not when the ACME account is
213+
recreated, not when the gateway moves:
213214

214215
```
215-
svc.example.com CNAME <GATEWAY_DOMAIN>
216-
_dstack-app-address.svc.example.com TXT "<app-id>:<port>"
217-
_acme-challenge.svc.example.com CNAME _acme-challenge.svc.example.com.<delegation-zone>
218-
svc.example.com CAA 0 issue "letsencrypt.org;validationmethods=dns-01;accounturi=<account-uri>"
216+
svc.example.com CNAME svc.example.com.deleg.example.net
217+
_dstack-app-address.svc.example.com CNAME _dstack-app-address.svc.example.com.deleg.example.net
218+
_acme-challenge.svc.example.com CNAME _acme-challenge.svc.example.com.deleg.example.net
219219
```
220220

221+
dstack-ingress publishes what they point at, in the delegation zone:
222+
223+
```
224+
svc.example.com.deleg.example.net CNAME <GATEWAY_DOMAIN>
225+
svc.example.com.deleg.example.net CAA 0 issue "letsencrypt.org;validationmethods=dns-01;accounturi=…"
226+
_dstack-app-address.svc.example.com.deleg.example.net TXT "<app-id>:<port>"
227+
_acme-challenge.svc.example.com.deleg.example.net TXT <challenge> (transient)
228+
```
229+
230+
The gateway pointer and the CAA share a name, which RFC 1034 does not allow —
231+
a CNAME excludes every other type at its name. **Cloudflare allows the pair
232+
anyway**, and Let's Encrypt honours the CAA it finds there: with a CAA that
233+
forbids it, the staging CA refuses with `While processing CAA for
234+
svc.example.com: CAA record for svc.example.com prevents issuance`.
235+
236+
Providers that enforce the standard will reject the CAA beside the CNAME, so
237+
they get an address record instead — `A` and `CAA` coexist legally. The default
238+
follows the provider:
239+
240+
| Provider | Gateway pointer | Gateway that moves |
241+
|---|---|---|
242+
| `cloudflare` | `CNAME` to `GATEWAY_DOMAIN` | followed by DNS, immediately |
243+
| everything else | `A`, re-resolved each pass | picked up within `RENEW_INTERVAL` |
244+
245+
`DELEGATION_GATEWAY_RECORD=cname\|a` overrides it. Only Cloudflare has been
246+
tested; if another provider accepts a CAA beside a CNAME, setting `cname` there
247+
gets the better behaviour.
248+
249+
A wildcard and its base name share a delegated target: both `*.example.com` and
250+
`example.com` map to `example.com.<delegation-zone>`. One deployment serving both
251+
is fine, since it publishes the same values twice. Two deployments pointing at
252+
different gateways are not — give them separate delegation zones.
253+
254+
**Wildcards keep an operator-managed CAA.** RFC 8659 evaluates `*.example.com`
255+
at `example.com`, which is your own name and cannot be aliased away, so for a
256+
wildcard the container prints the CAA and verifies it rather than publishing it.
257+
221258
> **Security note.** The `accounturi` CAA restricts issuance to this enclave's
222259
> ACME account and is the control that prevents forged certificates. In
223260
> delegation mode dstack-ingress cannot set it for you (no token for the served
@@ -236,7 +273,14 @@ svc.example.com CAA 0 issue "letsencrypt.org;validation
236273

237274
The records above are checked the same way tls-alpn-01 checks its own: two DoH
238275
resolvers, both CAA wire formats, and `DNS_SETUP_MODE` deciding what happens
239-
while they are missing. `wait` (the default) blocks until they appear, so you can
276+
while they are missing.
277+
278+
Only the `_acme-challenge` CNAME is checked on later passes. Under dns-01 the CA
279+
reads a TXT record and never connects here, so the other two records are about
280+
serving rather than issuance, and a renewal is not blocked on them — a routing
281+
problem can be fixed at any time, an expired certificate cannot. They are still
282+
checked on the first pass, where the point is to tell you whether you created
283+
them correctly. `wait` (the default) blocks until they appear, so you can
240284
start the container and create the records afterwards; `print` lists them and
241285
continues without checking; `webhook` POSTs them to `DNS_WEBHOOK_URL` for an
242286
operator service to create automatically.

custom-domain/dstack-ingress/TESTING.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,25 +201,28 @@ first issuance.
201201

202202
### Challenge delegation (dns-01)
203203

204-
`ACME_CHALLENGE_ALIAS` answers the DNS-01 challenge in a zone the container's
204+
`DELEGATION_ZONE` answers the DNS-01 challenge in a zone the container's
205205
token controls, so the token needs no access to the served domain's own zone.
206206
Testing it looks like it needs a second registrar account. It does not.
207207

208208
Any name under a zone you already control works as the delegation zone, because
209209
the provider resolves a zone by longest-suffix match on the record name. With
210-
`ACME_CHALLENGE_ALIAS=deleg.example.com` and a served domain of
210+
`DELEGATION_ZONE=deleg.example.com` and a served domain of
211211
`svc.example.com`, the challenge record is
212212
`_acme-challenge.svc.example.com.deleg.example.com`, which lands in
213213
`example.com` — the same zone, but through the delegation code path.
214214

215-
Play the operator and create the three static records the container prints:
215+
Play the operator and create the three CNAMEs the container prints:
216216

217217
```
218-
svc.example.com CNAME <GATEWAY_DOMAIN>
219-
_dstack-app-address.svc.example.com TXT "<app-id>:443"
220-
_acme-challenge.svc.example.com CNAME _acme-challenge.svc.example.com.deleg.example.com
218+
svc.example.com CNAME svc.example.com.deleg.example.com
219+
_dstack-app-address.svc.example.com CNAME _dstack-app-address.svc.example.com.deleg.example.com
220+
_acme-challenge.svc.example.com CNAME _acme-challenge.svc.example.com.deleg.example.com
221221
```
222222

223+
Everything they point at is published by the container, so this is the whole of
224+
the operator's job — the app id can change afterwards and no DNS edit follows.
225+
223226
Then start the container with a real provider token and `ACME_STAGING=true`, and
224227
watch for: the three records verifying, `Executing (challenge-delegation):` with
225228
`--manual` and both hooks, the certificate arriving, and the challenge TXT being
@@ -251,7 +254,9 @@ These fail fast and are cheap, so run them on every change:
251254
| Scenario | Expected |
252255
|---|---|
253256
| tls-alpn-01 + a wildcard domain | Refused before any ACME call, citing RFC 8737 |
257+
| Delegation, CAA in the delegated zone changed to forbid the CA | Blocked before any ACME attempt — proves the published CAA is reachable through the CNAME |
254258
| Delegation with no CAA record at all | Blocked — unlike normal issuance, where "no CAA" means unrestricted and passes |
259+
| Delegation, later pass, gateway CNAME broken | Renewal proceeds — that record is for serving, and dns-01 does not use it |
255260
| A CAA record with `validationmethods=dns-01`, mode tls-alpn-01 | Blocked with the restriction quoted back |
256261
| TXT holding the wrong value | Reported as `want <x>, saw <y>`, not "missing" |
257262
| An instance ID the gateway does not know | The CA reports a connection error — the gateway will not route to it |

custom-domain/dstack-ingress/scripts/acme-dns-alias-hook.sh

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
#
44
# Instead of writing the `_acme-challenge` TXT into the served domain's own
55
# zone (which requires a DNS token for that zone), this writes it into a
6-
# delegated zone that our token controls (ACME_CHALLENGE_ALIAS). The served
6+
# delegated zone that our token controls (DELEGATION_ZONE). The served
77
# domain's zone only needs one static, operator-managed CNAME:
88
#
9-
# _acme-challenge.<domain> CNAME _acme-challenge.<domain>.<ACME_CHALLENGE_ALIAS>
9+
# _acme-challenge.<domain> CNAME _acme-challenge.<domain>.<DELEGATION_ZONE>
1010
#
1111
# Let's Encrypt follows that CNAME during validation and reads the TXT from the
1212
# delegated zone, so the enclave's token never needs access to the served
@@ -18,8 +18,9 @@ set -euo pipefail
1818

1919
action="${1:-}"
2020

21-
if [ -z "${ACME_CHALLENGE_ALIAS:-}" ]; then
22-
echo "acme-dns-alias-hook: ACME_CHALLENGE_ALIAS is not set" >&2
21+
zone="${DELEGATION_ZONE:-}"
22+
if [ -z "$zone" ]; then
23+
echo "acme-dns-alias-hook: DELEGATION_ZONE is not set" >&2
2324
exit 1
2425
fi
2526
if [ -z "${CERTBOT_DOMAIN:-}" ]; then
@@ -28,17 +29,24 @@ if [ -z "${CERTBOT_DOMAIN:-}" ]; then
2829
fi
2930

3031
# certbot always passes the base domain (no leading "*.") in CERTBOT_DOMAIN.
31-
record="_acme-challenge.${CERTBOT_DOMAIN}.${ACME_CHALLENGE_ALIAS}"
32+
record="_acme-challenge.${CERTBOT_DOMAIN}.${zone}"
3233

3334
case "$action" in
3435
auth)
3536
: "${CERTBOT_VALIDATION:?CERTBOT_VALIDATION not set}"
3637
echo "acme-dns-alias-hook: writing challenge TXT to delegated record $record"
3738
dnsman.py set_txt --domain "$record" --content "$CERTBOT_VALIDATION"
3839
# certbot asks Let's Encrypt to validate immediately after this hook
39-
# returns, so wait for the record to propagate on the delegated zone's
40-
# authoritative servers before returning.
41-
sleep "${ACME_CHALLENGE_PROPAGATION_SECONDS:-30}"
40+
# returns, so wait for the record to propagate before returning.
41+
#
42+
# This has to outlast the record's own TTL, not just the time the write
43+
# takes. dnsman.py publishes TXT with a 60s TTL, so a resolver that saw
44+
# the *previous* challenge value keeps serving it for up to that long --
45+
# which is exactly the situation on the second of the two issuance
46+
# attempts. At 30s Let's Encrypt's multi-perspective check fails with
47+
# "During secondary validation: Incorrect TXT record found". The dns-01
48+
# plugin path waits 120s for the same reason.
49+
sleep "${ACME_CHALLENGE_PROPAGATION_SECONDS:-120}"
4250
;;
4351
cleanup)
4452
echo "acme-dns-alias-hook: removing challenge TXT $record"

custom-domain/dstack-ingress/scripts/certman.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,11 +279,11 @@ def _build_certbot_command(self, action: str, domain: str, email: str) -> List[s
279279
"""Build certbot command using provider configuration."""
280280
certbot_cmd = self._get_certbot_command()
281281

282-
# Challenge-delegation mode: when ACME_CHALLENGE_ALIAS is set, answer the
282+
# Challenge-delegation mode: when DELEGATION_ZONE is set, answer the
283283
# DNS-01 challenge in a delegated zone via a manual hook instead of the
284284
# provider's certbot plugin, so our DNS token never needs access to the
285285
# served domain's own zone. See scripts/acme-dns-alias-hook.sh.
286-
if os.environ.get("ACME_CHALLENGE_ALIAS", "").strip():
286+
if os.environ.get("DELEGATION_ZONE", "").strip():
287287
hook = "/scripts/acme-dns-alias-hook.sh"
288288
base_cmd = certbot_cmd + [action, "--non-interactive", "-v"]
289289
if action == "certonly":

0 commit comments

Comments
 (0)