Skip to content

Commit 470ab26

Browse files
authored
Merge pull request #108 from Dstack-TEE/feat/full-delegation
feat(dstack-ingress): delegate every record, so DNS is configured once
2 parents 1b71020 + 90db698 commit 470ab26

10 files changed

Lines changed: 336 additions & 191 deletions

File tree

custom-domain/dstack-ingress/README.md

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,8 @@ 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 |
196-
| `ALLOW_MISSING_CAA` | `false` | In delegation mode, treat an unconfirmed `accounturi` CAA as a warning instead of a blocker. Default fails closed (see below) |
194+
| `DELEGATION_ZONE` | | Zone this container writes into, so the DNS token needs no access to the served domain's own zone (see below) |
195+
| `DELEGATION_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 |
197196

198197
For DNS provider credentials, see [DNS_PROVIDERS.md](DNS_PROVIDERS.md).
199198

@@ -205,38 +204,66 @@ served name lives under a shared production zone (e.g. `svc.example.com` under
205204
`example.com`), that token can edit every record in the zone, which may be more
206205
privilege than you want.
207206

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):
207+
> **Renamed.** This was `ACME_CHALLENGE_ALIAS`, from when the challenge was the
208+
> only thing delegated. The old name is **not** accepted — a deployment still
209+
> setting it silently leaves delegation mode and starts writing to the served
210+
> domain's zone, which is what delegation exists to avoid. Rename it when
211+
> upgrading.
212+
213+
Set `DELEGATION_ZONE=<delegation-zone>` to move every name this deployment
214+
needs into a zone your token controls. You create three CNAMEs in the served
215+
domain's zone **once, before deploying**, and then never touch DNS again — not
216+
when the app id moves, not when the ACME account is recreated, not when the
217+
gateway moves:
218+
219+
```
220+
svc.example.com CNAME svc.example.com.deleg.example.net
221+
_dstack-app-address.svc.example.com CNAME _dstack-app-address.svc.example.com.deleg.example.net
222+
_acme-challenge.svc.example.com CNAME _acme-challenge.svc.example.com.deleg.example.net
223+
```
224+
225+
dstack-ingress publishes what they point at, in the delegation zone:
226+
227+
```
228+
svc.example.com.deleg.example.net CNAME <GATEWAY_DOMAIN>
229+
svc.example.com.deleg.example.net CAA 0 issue "letsencrypt.org;validationmethods=dns-01;accounturi=…"
230+
_dstack-app-address.svc.example.com.deleg.example.net TXT "<app-id>:<port>"
231+
_acme-challenge.svc.example.com.deleg.example.net TXT <challenge> (transient)
232+
```
233+
234+
The gateway pointer and the CAA share a name, which DNS does not allow for a
235+
CNAME. Which form works is a property of the provider — Cloudflare tolerates the
236+
pair, Linode publishes an address record instead — and `set_alias_record` in the
237+
provider layer is where that choice is made. Delegation asks for an alias and
238+
takes what it gets. A provider that needs a form other than CNAME overrides that
239+
method, as Linode does.
240+
241+
**A wildcard needs a fourth CNAME.** RFC 8659 evaluates `*.app.example.com` at
242+
`app.example.com`, so the base gets its own alias and the container publishes an
243+
`issuewild` CAA behind it:
213244

214245
```
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>"
246+
*.app.example.com CNAME app.example.com.deleg.example.net
247+
app.example.com CNAME app.example.com.deleg.example.net
248+
_dstack-app-address-wildcard.app.example.com CNAME _dstack-app-address-wildcard.app.example.com.deleg.example.net
249+
_acme-challenge.app.example.com CNAME _acme-challenge.app.example.com.deleg.example.net
219250
```
220251

221-
> **Security note.** The `accounturi` CAA restricts issuance to this enclave's
222-
> ACME account and is the control that prevents forged certificates. In
223-
> delegation mode dstack-ingress cannot set it for you (no token for the served
224-
> zone), so it prints the record and verifies it. **A CAA that is confirmed
225-
> absent stops issuance** — set `ALLOW_MISSING_CAA=true` to downgrade that to a
226-
> warning. Without this CAA, anyone who can satisfy the delegated challenge
227-
> could obtain a certificate for the domain.
228-
>
229-
> Use a **dedicated** delegation zone and scope the token to only that zone — a
230-
> zone shared with other tenants lets anyone with write access to it complete
231-
> the challenge. `SET_CAA` does not affect delegation mode (this CAA path always
232-
> runs). If a certificate was previously issued with the standard DNS-plugin
233-
> authenticator, delete `/etc/letsencrypt/renewal/<domain>.conf` before enabling
234-
> delegation, otherwise `certbot renew` reuses the old plugin (which needs the
235-
> production-zone token this mode avoids).
252+
The base has to be a name you can alias, which rules out a zone apex — an apex
253+
carries SOA and NS records and a CNAME excludes them. `*.example.com` served
254+
straight off the `example.com` zone is therefore not a fit for delegation; a
255+
label down, `*.app.example.com`, is.
236256

237257
The records above are checked the same way tls-alpn-01 checks its own: two DoH
238258
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
259+
while they are missing.
260+
261+
Only the `_acme-challenge` CNAME is checked on later passes. Under dns-01 the CA
262+
reads a TXT record and never connects here, so the other two records are about
263+
serving rather than issuance, and a renewal is not blocked on them — a routing
264+
problem can be fixed at any time, an expired certificate cannot. They are still
265+
checked on the first pass, where the point is to tell you whether you created
266+
them correctly. `wait` (the default) blocks until they appear, so you can
240267
start the container and create the records afterwards; `print` lists them and
241268
continues without checking; `webhook` POSTs them to `DNS_WEBHOOK_URL` for an
242269
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 "${DELEGATION_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)