Skip to content

Commit 9607449

Browse files
Ravenyjhclaude
andcommitted
fix(dstack-ingress): address review — reachable & fail-closed CAA, wildcard CNAME, robust DoH verify
- entrypoint.sh: the delegation-mode accounturi-CAA check was unreachable in the default config (it lived after the `SET_CAA != true` early return, and SET_CAA defaults to false), so the forge-prevention control silently never ran. Move it above the SET_CAA gate (delegation CAA is independent of SET_CAA) and make it fail closed: if the required CAA is confirmed absent the container exits, with ALLOW_MISSING_CAA=true to override. A transient DoH failure only warns. - entrypoint.sh: fix the wildcard `_acme-challenge` CNAME guidance — strip `*.` so it matches the base name certbot/LE validate and the hook writes to. - entrypoint.sh: parse the DoH response with jq and match with `grep -F` (the account URI contains `/` and `.`, which must not be regex), and distinguish "confirmed absent" (fail) from "could not verify" (warn). - base.py: unset_txt_record no longer reports success for a record it could not address (no id) — counts it as failure and warns. - README: document fail-closed CAA + ALLOW_MISSING_CAA, the propagation ceiling vs certbot's 300s timeout, using a dedicated delegation zone, and deleting a stale plugin renewal conf before switching to delegation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xa1zFJs3FVP8UTsSWuS3Yj
1 parent 732d9e0 commit 9607449

3 files changed

Lines changed: 103 additions & 45 deletions

File tree

custom-domain/dstack-ingress/README.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,8 @@ environment:
180180
| `EVIDENCE_PORT` | `80` | Internal port for evidence HTTP server |
181181
| `ALPN` | | TLS ALPN protocols (e.g. `h2,http/1.1`). Only set if backends support h2c |
182182
| `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 |
183-
| `ACME_CHALLENGE_PROPAGATION_SECONDS` | `30` | Wait after writing the delegated challenge TXT before validation (only used with `ACME_CHALLENGE_ALIAS`) |
183+
| `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 |
184+
| `ALLOW_MISSING_CAA` | `false` | In delegation mode, continue even if the required `accounturi` CAA cannot be confirmed. Default fails closed (see below) |
184185

185186
For DNS provider credentials, see [DNS_PROVIDERS.md](DNS_PROVIDERS.md).
186187

@@ -206,11 +207,21 @@ svc.example.com CAA 0 issue "letsencrypt.org;validation
206207
```
207208

208209
> **Security note.** The `accounturi` CAA restricts issuance to this enclave's
209-
> ACME account. In delegation mode dstack-ingress cannot set it for you (no
210-
> token for the served zone), so it prints the record and verifies (via DoH)
211-
> that it is present, warning loudly if not. Without this CAA, anyone who can
212-
> satisfy the delegated challenge could obtain a certificate for the domain.
213-
> Provide the token scoped only to `<delegation-zone>`.
210+
> ACME account and is the control that prevents forged certificates. In
211+
> delegation mode dstack-ingress cannot set it for you (no token for the served
212+
> zone), so it prints the record and verifies its presence via DoH. **If the
213+
> CAA is confirmed absent the container fails to start** (set `ALLOW_MISSING_CAA=true`
214+
> to override; a transient DoH failure only warns, it does not block). Without
215+
> this CAA, anyone who can satisfy the delegated challenge could obtain a
216+
> certificate for the domain.
217+
>
218+
> Use a **dedicated** delegation zone and scope the token to only that zone — a
219+
> zone shared with other tenants lets anyone with write access to it complete
220+
> the challenge. `SET_CAA` does not affect delegation mode (this CAA path always
221+
> runs). If a certificate was previously issued with the standard DNS-plugin
222+
> authenticator, delete `/etc/letsencrypt/renewal/<domain>.conf` before enabling
223+
> delegation, otherwise `certbot renew` reuses the old plugin (which needs the
224+
> production-zone token this mode avoids).
214225

215226
## Evidence & Attestation
216227

custom-domain/dstack-ingress/scripts/dns_providers/base.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,11 @@ def unset_txt_record(self, name: str) -> bool:
261261
"""
262262
ok = True
263263
for record in self.get_dns_records(name, RecordType.TXT):
264-
if record.id and not self.delete_dns_record(record.id, name):
264+
if not record.id:
265+
print(f"Warning: TXT record for {name} has no id; cannot delete")
266+
ok = False
267+
continue
268+
if not self.delete_dns_record(record.id, name):
265269
ok = False
266270
return ok
267271

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

Lines changed: 81 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -269,10 +269,13 @@ backend ${be_name}
269269
set_alias_record() {
270270
local domain="$1"
271271
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
272+
# certbot validates the challenge at the base name even for a wildcard,
273+
# so the _acme-challenge CNAME must use the base domain (strip "*.").
274+
local base="${domain#\*.}"
272275
echo "[challenge-delegation] Not touching ${domain}'s own zone (token is scoped to the delegated zone)."
273276
echo "[challenge-delegation] Set these in your production zone yourself (static, one-time):"
274277
echo " ${domain} CNAME ${GATEWAY_DOMAIN}"
275-
echo " _acme-challenge.${domain} CNAME _acme-challenge.${domain}.${ACME_CHALLENGE_ALIAS}"
278+
echo " _acme-challenge.${base} CNAME _acme-challenge.${base}.${ACME_CHALLENGE_ALIAS}"
276279
return
277280
fi
278281
echo "Setting alias record for $domain"
@@ -324,61 +327,101 @@ set_txt_record() {
324327
fi
325328
}
326329

327-
set_caa_record() {
330+
# caa_domain_and_tag DOMAIN -> prints "caa_domain caa_tag" (strips a wildcard).
331+
caa_domain_and_tag() {
328332
local domain="$1"
329-
if [ "$SET_CAA" != "true" ]; then
330-
echo "Skipping CAA record setup"
331-
return
333+
if [[ "$domain" == \*.* ]]; then
334+
echo "${domain#\*.} issuewild"
335+
else
336+
echo "$domain issue"
332337
fi
338+
}
333339

334-
local ACCOUNT_URI
335-
local account_file
340+
# In delegation mode we have NO token for the served domain's zone, so we cannot
341+
# set the accounturi CAA ourselves. That CAA is the forge-prevention (only this
342+
# enclave's ACME account may issue), so this path is deliberately NOT gated by
343+
# SET_CAA and fails closed if the record is confirmed absent — otherwise anyone
344+
# who can satisfy the delegated DNS-01 challenge could obtain a cert for the
345+
# domain. Set ALLOW_MISSING_CAA=true to override (accept the risk).
346+
verify_delegation_caa() {
347+
local domain="$1"
348+
local account_file account_uri caa_domain caa_tag caa_value resp status found
336349

337350
if ! account_file=$(get_letsencrypt_account_file); then
338-
echo "Warning: Cannot set CAA record - account file not found"
339-
echo "This is not critical - certificates can still be issued without CAA records"
351+
echo "ERROR: cannot read the Let's Encrypt account file to determine the required accounturi CAA" >&2
352+
caa_fail_or_allow "$domain"
340353
return
341354
fi
355+
read -r caa_domain caa_tag < <(caa_domain_and_tag "$domain")
356+
account_uri=$(jq -j '.uri' "$account_file")
357+
caa_value="letsencrypt.org;validationmethods=dns-01;accounturi=$account_uri"
342358

343-
local caa_domain caa_tag
344-
if [[ "$domain" == \*.* ]]; then
345-
caa_domain="${domain#\*.}"
346-
caa_tag="issuewild"
347-
else
348-
caa_domain="$domain"
349-
caa_tag="issue"
359+
echo "[challenge-delegation] Set this CAA in your production zone (static, one-time):"
360+
echo " ${caa_domain} CAA 0 ${caa_tag} \"${caa_value}\""
361+
362+
# Verify via DNS-over-HTTPS (dig is not installed in the image; curl+jq are).
363+
resp=$(curl -s --max-time 10 "https://dns.google/resolve?name=${caa_domain}&type=257" 2>/dev/null || true)
364+
if [ -z "$resp" ]; then
365+
echo "WARNING: could not reach dns.google to verify the CAA (network issue) — NOT confirmed; continuing"
366+
return
367+
fi
368+
status=$(echo "$resp" | jq -r '.Status // empty' 2>/dev/null || true)
369+
if [ "$status" != "0" ]; then
370+
echo "WARNING: CAA DoH query for ${caa_domain} returned status ${status:-unknown} — NOT confirmed; continuing"
371+
return
372+
fi
373+
# Literal (grep -F) match on the CAA data fields — the account URI contains
374+
# '/' and '.', which must not be treated as regex.
375+
found=$(echo "$resp" | jq -r '.Answer // [] | .[] | .data' 2>/dev/null | grep -F "accounturi=$account_uri" || true)
376+
if [ -n "$found" ]; then
377+
echo "[challenge-delegation] Verified: accounturi CAA is present for $caa_domain"
378+
return
379+
fi
380+
echo "ERROR: the accounturi CAA is NOT present for $caa_domain." >&2
381+
echo "ERROR: without it, anyone who can satisfy the delegated DNS-01 challenge could obtain a" >&2
382+
echo "ERROR: publicly-trusted certificate for this domain (forged TLS termination)." >&2
383+
caa_fail_or_allow "$domain"
384+
}
385+
386+
caa_fail_or_allow() {
387+
local domain="$1"
388+
if [ "${ALLOW_MISSING_CAA:-false}" = "true" ]; then
389+
echo "WARNING: ALLOW_MISSING_CAA=true — continuing without a verified accounturi CAA for $domain"
390+
return 0
350391
fi
392+
echo "ERROR: refusing to continue without the accounturi CAA for $domain." >&2
393+
echo "ERROR: set the CAA record shown above, or ALLOW_MISSING_CAA=true to override." >&2
394+
exit 1
395+
}
351396

352-
ACCOUNT_URI=$(jq -j '.uri' "$account_file")
397+
set_caa_record() {
398+
local domain="$1"
353399

400+
# Delegation mode is handled separately and is NOT gated by SET_CAA.
354401
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
355-
# Delegation mode: our token has no access to the served domain's zone,
356-
# so we cannot set the accounturi CAA ourselves. This CAA is the
357-
# forge-prevention (only the enclave's ACME account may issue), so we do
358-
# NOT silently skip it — we print the exact record the operator must set,
359-
# then best-effort verify it and warn loudly if it is missing.
360-
local caa_value="letsencrypt.org;validationmethods=dns-01;accounturi=$ACCOUNT_URI"
361-
echo "[challenge-delegation] Set this CAA in your production zone yourself (static):"
362-
echo " ${caa_domain} CAA 0 ${caa_tag} \"${caa_value}\""
363-
# Verify via DNS-over-HTTPS (dig is not installed; curl is).
364-
local caa_answer
365-
caa_answer=$(curl -s --max-time 10 "https://dns.google/resolve?name=${caa_domain}&type=257" 2>/dev/null || true)
366-
if echo "$caa_answer" | grep -q "accounturi=$ACCOUNT_URI"; then
367-
echo "[challenge-delegation] Verified: accounturi CAA is present for $caa_domain"
368-
else
369-
echo "WARNING: accounturi CAA is NOT present for $caa_domain."
370-
echo "WARNING: Without it, anyone who can satisfy the DNS-01 challenge could obtain a"
371-
echo "WARNING: publicly-trusted certificate for this domain (forged TLS termination)."
372-
echo "WARNING: Set the CAA record shown above before relying on this endpoint."
373-
fi
402+
verify_delegation_caa "$domain"
403+
return
404+
fi
405+
406+
if [ "$SET_CAA" != "true" ]; then
407+
echo "Skipping CAA record setup"
408+
return
409+
fi
410+
411+
local account_file account_uri caa_domain caa_tag
412+
if ! account_file=$(get_letsencrypt_account_file); then
413+
echo "Warning: Cannot set CAA record - account file not found"
414+
echo "This is not critical - certificates can still be issued without CAA records"
374415
return
375416
fi
417+
read -r caa_domain caa_tag < <(caa_domain_and_tag "$domain")
418+
account_uri=$(jq -j '.uri' "$account_file")
376419

377-
echo "Adding CAA record ($caa_tag) for $caa_domain, accounturi=$ACCOUNT_URI"
420+
echo "Adding CAA record ($caa_tag) for $caa_domain, accounturi=$account_uri"
378421
dnsman.py set_caa \
379422
--domain "$caa_domain" \
380423
--caa-tag "$caa_tag" \
381-
--caa-value "letsencrypt.org;validationmethods=dns-01;accounturi=$ACCOUNT_URI"
424+
--caa-value "letsencrypt.org;validationmethods=dns-01;accounturi=$account_uri"
382425

383426
if [ $? -ne 0 ]; then
384427
echo "Warning: Failed to set CAA record for $domain"

0 commit comments

Comments
 (0)