Skip to content

Commit de3f6c0

Browse files
committed
feat(dstack-ingress): delegate a wildcard's CAA too, via a fourth alias
I had written wildcards off: RFC 8659 evaluates *.example.com at example.com, and I took "that is the operator's own name" to mean it could not be delegated. It can -- the operator aliases the base as well, and the CA follows that CNAME like any other. 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`, which is the CA following the base's alias into the delegated zone and honouring what it found. So dnsguide asks for a fourth CNAME on wildcards, and the container publishes the CAA with the issuewild tag rather than skipping it. The limit is narrower than "wildcards": it is zone apexes. *.example.com under the example.com zone still cannot have its base aliased, because an apex carries SOA and NS and a CNAME excludes them. *.app.example.com can. Where it cannot, the CAA check reports the record missing rather than the container pretending to have set it.
1 parent f483f73 commit de3f6c0

4 files changed

Lines changed: 64 additions & 24 deletions

File tree

custom-domain/dstack-ingress/README.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +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.
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+
This only works where the base is **not a zone apex**. `*.example.com` under the
266+
`example.com` zone cannot have `example.com` aliased — an apex carries SOA and NS
267+
records, which a CNAME excludes — so there the CAA stays operator-managed and the
268+
check reports it missing until you create it.
257269

258270
> **Security note.** The `accounturi` CAA restricts issuance to this enclave's
259271
> ACME account and is the control that prevents forged certificates. In

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. Where the base is a zone apex the operator cannot
183+
# alias it (an apex carries SOA and NS, which a CNAME excludes), and the check
184+
# below reports the CAA as missing rather than 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: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,11 +413,23 @@ 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+
# Only possible when the base is not a zone apex -- an apex carries
428+
# SOA and NS, and a CNAME excludes them. `*.example.com` under the
429+
# `example.com` zone therefore keeps an operator-managed CAA;
430+
# `*.app.example.com` does not.
431+
wanted.insert(1, (base, "delegates CAA, which a wildcard is evaluated at"))
432+
for name, note in wanted:
421433
records.append(
422434
Record(
423435
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)