Skip to content

Commit 50964d7

Browse files
committed
feat(dstack-ingress): delegate a wildcard's CAA too
Delegation left wildcards with an operator-managed CAA, on the reasoning that RFC 8659 evaluates `*.example.com` at `example.com` and that is the operator's own name. It is, but that does not stop it being aliased -- the CA follows a CNAME there like anywhere else. Verified against the staging CA. With *.wb.kvin.wang CNAME wb.kvin.wang.bz.kvin.wang wb.kvin.wang CNAME wb.kvin.wang.bz.kvin.wang wb.kvin.wang.bz.kvin.wang CAA 0 issuewild "example.invalid" issuance was refused with `While processing CAA for *.wb.kvin.wang: CAA record for wb.kvin.wang prevents issuance` -- the CA following the base's alias into the delegated zone and honouring what it found there. So dnsguide asks for a fourth CNAME on wildcards, aliasing the base, and the container publishes the CAA with the issuewild tag instead of skipping it. A wildcard deployment now needs no DNS work after setup either. The base has to be aliasable, which a zone apex is not, so a wildcard served straight off its own zone is not a fit for delegation. Anything a label down is.
1 parent 7532b6b commit 50964d7

4 files changed

Lines changed: 63 additions & 40 deletions

File tree

custom-domain/dstack-ingress/README.md

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -251,25 +251,21 @@ A wildcard and its base name share a delegated target: both `*.example.com` and
251251
is fine, since it publishes the same values twice. Two deployments pointing at
252252
different gateways are not — give them separate delegation zones.
253253

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-
258-
> **Security note.** The `accounturi` CAA restricts issuance to this enclave's
259-
> ACME account and is the control that prevents forged certificates. In
260-
> delegation mode dstack-ingress cannot set it for you (no token for the served
261-
> zone), so it prints the record and verifies it. **A CAA that is confirmed
262-
> absent stops issuance** — set `ALLOW_MISSING_CAA=true` to downgrade that to a
263-
> warning. Without this CAA, anyone who can satisfy the delegated challenge
264-
> could obtain a certificate for the domain.
265-
>
266-
> Use a **dedicated** delegation zone and scope the token to only that zone — a
267-
> zone shared with other tenants lets anyone with write access to it complete
268-
> the challenge. `SET_CAA` does not affect delegation mode (this CAA path always
269-
> runs). If a certificate was previously issued with the standard DNS-plugin
270-
> authenticator, delete `/etc/letsencrypt/renewal/<domain>.conf` before enabling
271-
> delegation, otherwise `certbot renew` reuses the old plugin (which needs the
272-
> production-zone token this mode avoids).
254+
**A wildcard needs a fourth CNAME.** RFC 8659 evaluates `*.app.example.com` at
255+
`app.example.com`, so the base gets its own alias and the container publishes an
256+
`issuewild` CAA behind it:
257+
258+
```
259+
*.app.example.com CNAME app.example.com.deleg.example.net
260+
app.example.com CNAME app.example.com.deleg.example.net
261+
_dstack-app-address-wildcard.app.example.com CNAME _dstack-app-address-wildcard.app.example.com.deleg.example.net
262+
_acme-challenge.app.example.com CNAME _acme-challenge.app.example.com.deleg.example.net
263+
```
264+
265+
The base has to be a name you can alias, which rules out a zone apex — an apex
266+
carries SOA and NS records and a CNAME excludes them. `*.example.com` served
267+
straight off the `example.com` zone is therefore not a fit for delegation; a
268+
label down, `*.app.example.com`, is.
273269

274270
The records above are checked the same way tls-alpn-01 checks its own: two DoH
275271
resolvers, both CAA wire formats, and `DNS_SETUP_MODE` deciding what happens

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

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -175,21 +175,20 @@ delegation_publish() {
175175
dnsman.py set_txt --domain "$txt_name" --content "$(txt_record_value)" || return 1
176176
}
177177

178-
# The accounturi CAA goes on the delegated name, beside the address record.
178+
# The accounturi CAA goes on the delegated name, beside the gateway pointer.
179179
#
180-
# Not for a wildcard: RFC 8659 evaluates `*.example.com` at `example.com`, which
181-
# is the operator's own name and cannot be aliased away. Wildcards keep the
182-
# operator-managed CAA.
180+
# A wildcard is evaluated at its base (RFC 8659), so it needs the `issuewild`
181+
# tag and it needs the operator to have aliased the base as well -- dnsguide
182+
# asks for that fourth CNAME. A base that is a zone apex cannot be aliased at
183+
# all, so such a domain is not a fit for delegation; the CAA check reports the
184+
# record missing rather than the container pretending to have set it.
183185
delegation_publish_caa() {
184186
local domain="$1" account_file account_uri
185-
if [[ "$domain" == \*.* ]]; then
186-
return 2
187-
fi
188187
account_file=$(get_letsencrypt_account_file) || return 1
189188
account_uri=$(jq -j '.uri' "$account_file")
190189
dnsman.py set_caa \
191190
--domain "$(delegated_name "$domain")" \
192-
--caa-tag issue \
191+
--caa-tag "$(caa_tag_for "$domain")" \
193192
--caa-value "letsencrypt.org;validationmethods=dns-01;accounturi=$account_uri"
194193
}
195194

@@ -252,17 +251,11 @@ set_caa_record() {
252251
# thing stopping anyone else who can satisfy the delegated challenge, so it
253252
# is not optional here.
254253
if [ -n "${DELEGATION_ZONE:-}" ]; then
255-
delegation_publish_caa "$domain"
256-
case $? in
257-
0) return ;;
258-
2) # Wildcard: RFC 8659 evaluates *.example.com at example.com,
259-
# which is the operator's own name and cannot be aliased away,
260-
# so ask for it instead of publishing it.
261-
delegation_verify_caa "$domain" || exit 1
262-
return ;;
263-
*) echo "Error: could not publish the CAA record for $domain" >&2
264-
exit 1 ;;
265-
esac
254+
if ! delegation_publish_caa "$domain"; then
255+
echo "Error: could not publish the CAA record for $domain" >&2
256+
exit 1
257+
fi
258+
return
266259
fi
267260

268261
if [ "$SET_CAA" != "true" ]; then

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,11 +413,22 @@ def build_records(args: argparse.Namespace) -> List[Record]:
413413
# rather than as "does not resolve".
414414
base = args.domain[2:] if args.domain.startswith("*.") else args.domain
415415
alias = args.challenge_alias
416-
for name, note in (
416+
wanted = [
417417
(args.domain, "routes traffic for this hostname into the delegated zone"),
418418
(args.txt_name, "lets the container publish the gateway routing target"),
419419
(f"_acme-challenge.{base}", "delegates the ACME challenge"),
420-
):
420+
]
421+
if args.domain.startswith("*."):
422+
# RFC 8659 evaluates CAA for *.example.com at example.com, not at the
423+
# wildcard, so the base needs its own alias for the CAA to be
424+
# delegated too. Verified: with this in place the CA followed it and
425+
# honoured an issuewild record in the delegated zone.
426+
#
427+
# The base has to be aliasable, which a zone apex is not (SOA and
428+
# NS live there and a CNAME excludes them), so `*.example.com`
429+
# served straight off its own zone is not a fit for delegation.
430+
wanted.insert(1, (base, "delegates CAA, which a wildcard is evaluated at"))
431+
for name, note in wanted:
421432
records.append(
422433
Record(
423434
type="CNAME",

custom-domain/dstack-ingress/scripts/tests/test_dnsguide.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,29 @@ def test_absent_without_a_delegation_zone(self):
146146
self.assertEqual(dnsguide.build_records(self._args("svc.example.com", "")), [])
147147

148148

149+
class TestWildcardDelegation(unittest.TestCase):
150+
"""A wildcard is evaluated at its base, so the base needs its own alias."""
151+
152+
def _records(self, domain, txt):
153+
import argparse
154+
return dnsguide.build_records(argparse.Namespace(
155+
domain=domain, alias_target="gw", txt_name=txt, txt_value="x",
156+
caa_name="", caa_tag="issue", caa_value="", account_uri="",
157+
challenge="dns-01", challenge_alias="deleg.net", include="delegated"))
158+
159+
def test_wildcard_aliases_its_base_as_well(self):
160+
names = [r.name for r in self._records(
161+
"*.app.example.com", "_dstack-app-address-wildcard.app.example.com")]
162+
self.assertIn("app.example.com", names)
163+
self.assertEqual(len(names), 4)
164+
165+
def test_plain_domain_does_not(self):
166+
names = [r.name for r in self._records(
167+
"svc.example.com", "_dstack-app-address.svc.example.com")]
168+
self.assertNotIn("example.com", names)
169+
self.assertEqual(len(names), 3)
170+
171+
149172
class TestExactCnameCheck(unittest.TestCase):
150173
"""check_alias falls back to comparing addresses; a delegation target has none."""
151174

0 commit comments

Comments
 (0)