Skip to content

Commit ca39932

Browse files
committed
fix(dstack-ingress): keep the delegated gateway pointer a CNAME
The A record was my mistake. I reasoned from RFC 1034 -- a CNAME excludes every other type at its name, so the delegated name could not hold both the gateway pointer and the CAA -- and forgot that this repo's own testing had already disproved it: Cloudflare allows the two together, and Let's Encrypt refuses to issue when that CAA forbids it, which means it reads it. Confirmed again for the delegated shape specifically: with `svc.example.com` aliased to a name that carries both a CNAME to the gateway and a CAA, the staging CA reported `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 that has a CAA rather than following to the end. So the gateway pointer stays a CNAME and a gateway that moves is followed automatically, with no address tracking. DELEGATION_GATEWAY_RECORD=a remains for providers that enforce the standard and refuse the CAA. Also raise the delegated challenge's propagation wait 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 waits 120s for the same reason.
1 parent 11da287 commit ca39932

3 files changed

Lines changed: 58 additions & 41 deletions

File tree

custom-domain/dstack-ingress/README.md

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,8 @@ environment:
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 |
194194
| `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 |
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` | `cname` | How the delegated name points at the gateway: `cname` (follows a gateway that moves) or `a` (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).
@@ -220,22 +221,24 @@ _acme-challenge.svc.example.com CNAME _acme-challenge.svc.example.com.dele
220221
dstack-ingress publishes what they point at, in the delegation zone:
221222

222223
```
223-
svc.example.com.deleg.example.net A <gateway address>
224-
svc.example.com.deleg.example.net CAA 0 issue "letsencrypt.org;validationmethods=dns-01;accounturi=…"
225-
_dstack-app-address.svc.example.com.deleg.example.net TXT "<app-id>:<port>"
226-
_acme-challenge.svc.example.com.deleg.example.net TXT <challenge> (transient)
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)
227228
```
228229

229-
The gateway pointer is an address record rather than a CNAME on purpose. Let's
230-
Encrypt follows a CNAME when it looks up CAA and honours what it finds at the
231-
target, but a CNAME excludes every other type at that name, so a CNAME there
232-
would leave nowhere to put the CAA. It does not climb past the target either —
233-
a CAA at the delegation zone apex is ignored. An address record is what lets one
234-
name carry both.
235-
236-
The cost is that the container now tracks the gateway's address itself, where a
237-
CNAME used to follow it automatically. It re-resolves `GATEWAY_DOMAIN` on every
238-
pass, so a gateway that moves is picked up within `RENEW_INTERVAL`.
230+
A CNAME and a CAA on the same name is a Cloudflare allowance, not a standard
231+
one — RFC 1034 says a CNAME excludes every other type at its name. It works
232+
because the resolver stops at the first name in the chain that carries a CAA,
233+
and Let's Encrypt honours what it finds there: with a CAA that forbids it,
234+
the staging CA refuses with `While processing CAA for svc.example.com: CAA
235+
record for svc.example.com prevents issuance`.
236+
237+
Providers that enforce the standard will reject the CAA beside the CNAME. Set
238+
`DELEGATION_GATEWAY_RECORD=a` there: the container resolves `GATEWAY_DOMAIN`
239+
itself and publishes an address record, which can carry the CAA. The cost is
240+
that a gateway which moves is then only picked up on the next pass, where a
241+
CNAME follows it immediately.
239242

240243
**Wildcards keep an operator-managed CAA.** RFC 8659 evaluates `*.example.com`
241244
at `example.com`, which is your own name and cannot be aliased away, so for a

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,16 @@ case "$action" in
3636
echo "acme-dns-alias-hook: writing challenge TXT to delegated record $record"
3737
dnsman.py set_txt --domain "$record" --content "$CERTBOT_VALIDATION"
3838
# 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}"
39+
# returns, so wait for the record to propagate before returning.
40+
#
41+
# This has to outlast the record's own TTL, not just the time the write
42+
# takes. dnsman.py publishes TXT with a 60s TTL, so a resolver that saw
43+
# the *previous* challenge value keeps serving it for up to that long --
44+
# which is exactly the situation on the second of the two issuance
45+
# attempts. At 30s Let's Encrypt's multi-perspective check fails with
46+
# "During secondary validation: Incorrect TXT record found". The dns-01
47+
# plugin path waits 120s for the same reason.
48+
sleep "${ACME_CHALLENGE_PROPAGATION_SECONDS:-120}"
4249
;;
4350
cleanup)
4451
echo "acme-dns-alias-hook: removing challenge TXT $record"

custom-domain/dstack-ingress/scripts/dns01.sh

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -112,37 +112,44 @@ set_txt_record() {
112112
# never needs touching again -- not when the app id changes with the compose,
113113
# not when the ACME account is recreated, not when the gateway moves.
114114
#
115-
# The gateway pointer has to be an A record rather than a CNAME. Let's Encrypt
116-
# follows a CNAME when it looks up CAA and honours what it finds at the target
117-
# (verified against the staging CA), but a CNAME excludes every other type at
118-
# that name, so a CNAME there would leave nowhere to put the CAA. It does not
119-
# climb past the target either: a CAA at the delegation zone apex is ignored.
120-
# An address record is what lets one name carry both.
115+
# The gateway pointer stays a CNAME, so a gateway that moves is followed
116+
# automatically. That the same name can also carry the CAA is a Cloudflare
117+
# allowance rather than a standard one -- RFC 1034 says a CNAME excludes every
118+
# other type at its name -- but Let's Encrypt honours it: the resolver stops at
119+
# the first name in the chain that has a CAA, and the staging CA refuses to
120+
# issue when that record forbids it. Providers that enforce the standard will
121+
# reject the CAA here; see DELEGATION_GATEWAY_RECORD.
121122
delegated_name() {
122123
local domain="${1#\*.}"
123124
echo "${domain}.${ACME_CHALLENGE_ALIAS}"
124125
}
125126

126-
# Resolve GATEWAY_DOMAIN to addresses. Cheap and re-run every pass, since this
127-
# is now the only thing tracking the gateway; a CNAME used to do it for us.
128-
gateway_addresses() {
129-
dnsguide.py --resolve "$GATEWAY_DOMAIN" 2>/dev/null
130-
}
131-
132127
delegation_publish() {
133-
local domain="$1" target addrs addr txt_name
128+
local domain="$1" target txt_name
134129
target=$(delegated_name "$domain")
135130

136-
addrs=$(gateway_addresses)
137-
if [ -z "$addrs" ]; then
138-
echo "Error: could not resolve $GATEWAY_DOMAIN to any address" >&2
139-
return 1
140-
fi
141-
while IFS= read -r addr; do
142-
[ -n "$addr" ] || continue
143-
dnsman.py set_a --domain "$target" --content "$addr" || return 1
144-
break
145-
done <<<"$addrs"
131+
case "${DELEGATION_GATEWAY_RECORD:-cname}" in
132+
cname)
133+
dnsman.py set_alias --domain "$target" --content "$GATEWAY_DOMAIN" || return 1
134+
;;
135+
a)
136+
# For providers that refuse a CAA beside a CNAME. Costs the
137+
# automatic following of a gateway that moves: the address is
138+
# re-resolved once per pass instead.
139+
local addrs addr
140+
addrs=$(dnsguide.py --resolve "$GATEWAY_DOMAIN" 2>/dev/null)
141+
if [ -z "$addrs" ]; then
142+
echo "Error: could not resolve $GATEWAY_DOMAIN to any address" >&2
143+
return 1
144+
fi
145+
addr=$(echo "$addrs" | head -1)
146+
dnsman.py set_a --domain "$target" --content "$addr" || return 1
147+
;;
148+
*)
149+
echo "Error: invalid DELEGATION_GATEWAY_RECORD (expected cname or a)" >&2
150+
return 1
151+
;;
152+
esac
146153

147154
txt_name="$(txt_record_name "$domain").${ACME_CHALLENGE_ALIAS}"
148155
dnsman.py set_txt --domain "$txt_name" --content "$(txt_record_value)" || return 1

0 commit comments

Comments
 (0)