From cf400165d34948bdad05f4687f40ef885568c010 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 28 Jul 2026 21:22:39 -0700 Subject: [PATCH] docs(dstack-ingress): correct what delegation does about CAA 677599f removed the fail-closed CAA gate -- once the container publishes the delegated CAA itself, an absent record means "not published yet" rather than "nobody can create it", so check_caa follows RFC 8659 here as it does everywhere else. That commit updated the README and left TESTING.md describing the old design, in two places: - the negative table still promised "delegation with no CAA record at all: blocked". A 2.3 test run reports the opposite on every first pass, which is the documented behaviour and not a defect. - the walkthrough still told the tester to create the CAA by hand and said the container blocks until they do. It publishes the record itself; there is nothing to wait for. Both now say what the code does, with the reasoning for the change kept so the next reader does not re-derive it as a bug -- and the row that does carry the weight, a delegated CAA forbidding the CA, is called out as the one that proves reachability through the operator's CNAME. The dnsman.py set_caa snippet is kept, repurposed for planting a CAA by hand in the negative case, which is still the only convenient way to write a structured CAA through Cloudflare's API. --- custom-domain/dstack-ingress/TESTING.md | 46 ++++++++++++++++++++----- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/custom-domain/dstack-ingress/TESTING.md b/custom-domain/dstack-ingress/TESTING.md index c1250e8..d4a9b6d 100644 --- a/custom-domain/dstack-ingress/TESTING.md +++ b/custom-domain/dstack-ingress/TESTING.md @@ -229,16 +229,26 @@ watch for: the three records verifying, `Executing (challenge-delegation):` with cleaned up afterwards (query `_acme-challenge..` at the authoritative nameserver — it should be gone). -The CAA step comes last, once the ACME account exists and its URI is known. It -blocks until you create the record it prints. Cloudflare's API wants CAA as -structured fields rather than one string, so most quick DNS scripts cannot write -it; the image's own `dnsman.py set_caa` can, and running it in a one-off -container is the easiest way to play the operator here: +The CAA comes last and needs nothing from you: it names the ACME account, so the +account has to exist first, and the container publishes it into the delegation +zone itself once it does. Expect `Adding CAA record for . with tag +issue` on the first pass — `issuewild` for a wildcard, on the base name. Confirm +it at the authoritative nameserver, and confirm it resolves *through* the +operator's CNAME, since that is what the CA follows: + +```bash +dig +short CAA @ +``` + +To plant a CAA by hand — for the negative case below, or to check that the +container replaces it — note that Cloudflare's API wants CAA as structured +fields rather than one string, so most quick DNS scripts cannot write it. The +image's own `dnsman.py set_caa` can, in a one-off container: ```bash docker run --rm --env-file .env --entrypoint dnsman.py \ - set_caa --domain svc.example.com --caa-tag issue \ - --caa-value 'letsencrypt.org;validationmethods=dns-01;accounturi=' + set_caa --domain svc.example.com.deleg.example.com --caa-tag issue \ + --caa-value 'example.com' ``` **What this does not test.** The whole point of delegation is that a token scoped @@ -255,7 +265,7 @@ These fail fast and are cheap, so run them on every change: |---|---| | tls-alpn-01 + a wildcard domain | Refused before any ACME call, citing RFC 8737 | | 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 | -| Delegation with no CAA record at all | Blocked — unlike normal issuance, where "no CAA" means unrestricted and passes | +| Delegation with no CAA record at all | Proceeds, reporting `no CAA record set; issuance is unrestricted` — see below | | Delegation, later pass, gateway CNAME broken | Renewal proceeds — that record is for serving, and dns-01 does not use it | | A CAA record with `validationmethods=dns-01`, mode tls-alpn-01 | Blocked with the restriction quoted back | | TXT holding the wrong value | Reported as `want , saw `, not "missing" | @@ -264,6 +274,26 @@ These fail fast and are cheap, so run them on every change: The last one is worth keeping: it is the cross-tenant impersonation defence, and it is observable rather than merely argued. +**Why "no CAA at all" is not a blocking case.** It used to be. When the operator +had to create the CAA record, its absence was the state that mattered: nothing +then stopped anyone else who could satisfy the delegated challenge, and the +container held no token to create it. Once the container began publishing the +delegated CAA itself, absence stopped meaning that — it means "not published +yet", and the next pass fixes it — so the gate was removed along with +`ALLOW_MISSING_CAA` and dnsguide's `--caa-required`. `check_caa` now follows +RFC 8659 for delegation as it always did elsewhere: an absent CAA record set +forbids nothing. + +The neighbouring row still holds, and it is the one that carries the weight: a +CAA in the delegated zone that forbids the CA **does** block, before any ACME +attempt, which is what proves the published record is reachable through the +operator's CNAME. Expect the reason quoted back, e.g. `issue allows +'example.com', not letsencrypt.org`. + +Note also that the first pass on a fresh deployment necessarily runs before any +CAA exists: the record names the ACME account, so the account has to exist +first. `process_domain` issues, writes the CAA, then issues again. + ## Traps **Start from a clean zone.** Records left over from an earlier run change which