Skip to content

Commit 677599f

Browse files
committed
refactor(dstack-ingress): remove what the delegation rework left behind
Publishing the CAA from inside the container made a chain of things dead without anything failing, which is how they survived. A review found: - delegation_verify_caa, and with it the only caller of ALLOW_MISSING_CAA and of dnsguide's --caa-required / --caa-advisory. Wildcards were the last user; once the container published their CAA too, the whole path was unreachable. The README still advertised ALLOW_MISSING_CAA. - check_caa's require_present parameter and its three tests. The semantics it existed for -- "an absent CAA must block, because we cannot create it" -- stopped being true when we started creating it. Absence now just means "not published yet", and the next pass fixes it. - Two test classes defined twice over. Python shadows the earlier definition silently, unittest counts the later one, and the total still goes up, so duplicated blocks look like passing tests. A guard now walks the file's AST and fails on any repeated class or method name; it was checked against a deliberately duplicated block. - Stale naming: --challenge-alias and args.challenge_alias outlived the variable rename, and ACME_CHALLENGE_PROPAGATION_SECONDS with them. Both now say delegation. The README still claimed ACME_CHALLENGE_ALIAS "still works", which had not been true since it was removed. Also reject contradictory --include values. `delegated` is a shape rather than a record kind -- it replaces the per-kind records instead of adding to them -- so `--include cname,delegated` would emit two CNAMEs claiming the same name. Unknown values are now rejected too, instead of being silently ignored.
1 parent 1d58942 commit 677599f

5 files changed

Lines changed: 51 additions & 115 deletions

File tree

custom-domain/dstack-ingress/README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,9 @@ environment:
191191
| `EVIDENCE_SERVER` | `true` | Serve evidence files at `/evidences/` on the TLS port |
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 |
194-
| `DELEGATION_ZONE` | | Zone this container writes into, so the DNS token needs no access to the served domain's own zone (see below). `ACME_CHALLENGE_ALIAS` is the original name and still works |
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 |
194+
| `DELEGATION_ZONE` | | Zone this container writes into, so the DNS token needs no access to the served domain's own zone (see below) |
195+
| `DELEGATION_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 |
196196
| `DELEGATION_GATEWAY_RECORD` | per provider | How the delegated name points at the gateway: `cname` (Cloudflare default; follows a gateway that moves) or `a` (default elsewhere, for providers that refuse a CAA beside a CNAME) |
197-
| `ALLOW_MISSING_CAA` | `false` | In delegation mode, treat an unconfirmed `accounturi` CAA as a warning instead of a blocker. Default fails closed (see below) |
198197

199198
For DNS provider credentials, see [DNS_PROVIDERS.md](DNS_PROVIDERS.md).
200199

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ case "$action" in
4646
# attempts. At 30s Let's Encrypt's multi-perspective check fails with
4747
# "During secondary validation: Incorrect TXT record found". The dns-01
4848
# plugin path waits 120s for the same reason.
49-
sleep "${ACME_CHALLENGE_PROPAGATION_SECONDS:-120}"
49+
sleep "${DELEGATION_PROPAGATION_SECONDS:-120}"
5050
;;
5151
cleanup)
5252
echo "acme-dns-alias-hook: removing challenge TXT $record"

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

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -214,36 +214,12 @@ delegation_guide() {
214214
--caa-name "$caa_domain" \
215215
--caa-tag "$caa_tag" \
216216
--challenge dns-01 \
217-
--challenge-alias "$DELEGATION_ZONE" \
217+
--delegation-zone "$DELEGATION_ZONE" \
218218
--mode "${DNS_SETUP_MODE:-wait}" \
219219
--include "$include" \
220220
"$@"
221221
}
222222

223-
# The accounturi CAA is what stops anyone else who can satisfy the delegated
224-
# challenge from getting a certificate for this name, and we cannot write it, so
225-
# a confirmed-absent record is fatal. ALLOW_MISSING_CAA downgrades it to a
226-
# warning for operators who accept that risk.
227-
delegation_verify_caa() {
228-
local domain="$1" account_file account_uri
229-
if ! account_file=$(get_letsencrypt_account_file); then
230-
echo "ERROR: cannot read the ACME account file to determine the required CAA" >&2
231-
return 1
232-
fi
233-
account_uri=$(jq -j '.uri' "$account_file")
234-
# Absent is the state to block on here, not "absent means unrestricted":
235-
# we cannot create this record, so nothing else protects the name.
236-
local gate=(--caa-required)
237-
if [ "${ALLOW_MISSING_CAA:-false}" = "true" ]; then
238-
gate=(--caa-required --caa-advisory)
239-
fi
240-
241-
delegation_guide "$domain" caa \
242-
--caa-value "letsencrypt.org;validationmethods=dns-01;accounturi=$account_uri" \
243-
--account-uri "$account_uri" \
244-
"${gate[@]}"
245-
}
246-
247223
set_caa_record() {
248224
local domain="$1"
249225

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

Lines changed: 25 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -274,19 +274,12 @@ def check_caa(
274274
want_method: str,
275275
account_uri: str,
276276
resolvers: str,
277-
require_present: bool = False,
278277
) -> Tuple[bool, str]:
279278
"""Check CAA for domain, walking up to the closest ancestor that has one.
280279
281280
An absent CAA record set means every CA may issue (RFC 8659), so "no CAA
282-
anywhere" is normally a pass: we are asking whether *we* may issue, and
283-
nothing forbids it.
284-
285-
`require_present` inverts that for challenge delegation, where the question
286-
is different. There the record is not a formality but the only thing stopping
287-
someone else who can satisfy the delegated challenge from getting a
288-
certificate for this name, and we hold no token to create it ourselves. An
289-
unrestricted name is exactly the state that must block.
281+
anywhere" is a pass: we are asking whether *we* may issue, and nothing
282+
forbids it.
290283
"""
291284
labels = _fqdn(domain).split(".")
292285
for i in range(len(labels) - 1):
@@ -320,11 +313,6 @@ def check_caa(
320313
reasons.append(reason)
321314
return False, f"CAA at {candidate} does not permit issuance: {'; '.join(reasons)}"
322315

323-
if require_present:
324-
return False, (
325-
"no CAA record set, so any account that can satisfy the delegated "
326-
"challenge could obtain a certificate for this name"
327-
)
328316
return True, "ok (no CAA record set; issuance is unrestricted)"
329317

330318

@@ -357,8 +345,6 @@ def verify_once(
357345
caa_method: str,
358346
account_uri: str,
359347
resolvers: str,
360-
caa_advisory: bool = False,
361-
caa_required: bool = False,
362348
) -> List[Tuple[str, bool, str]]:
363349
results = []
364350
for rec in records:
@@ -371,18 +357,27 @@ def verify_once(
371357
else:
372358
ok, why = check_alias(rec, resolvers)
373359
results.append((f"CNAME {rec.name}", ok, why))
374-
ok, why = check_caa(
375-
domain, caa_tag, caa_method, account_uri, resolvers, caa_required
376-
)
377-
if not ok and caa_advisory:
378-
results.append((f"CAA {domain}", True, f"WARNING: {why} (continuing: CAA is advisory)"))
379-
else:
380-
results.append((f"CAA {domain}", ok, why))
360+
ok, why = check_caa(domain, caa_tag, caa_method, account_uri, resolvers)
361+
results.append((f"CAA {domain}", ok, why))
381362
return results
382363

383364

365+
# `delegated` is a shape, not a record kind: it replaces the per-kind records
366+
# rather than adding to them. Asking for both would emit two CNAMEs for the same
367+
# name -- one at the gateway, one at the delegation zone -- so refuse instead.
368+
PER_KIND = {"cname", "txt", "caa", "challenge-cname"}
369+
370+
384371
def build_records(args: argparse.Namespace) -> List[Record]:
385372
include = {part.strip() for part in args.include.split(",") if part.strip()}
373+
unknown = include - PER_KIND - {"delegated"}
374+
if unknown:
375+
raise SystemExit(f"unknown --include value(s): {', '.join(sorted(unknown))}")
376+
if "delegated" in include and include & (PER_KIND - {"caa"}):
377+
raise SystemExit(
378+
"--include delegated cannot be combined with cname/txt/challenge-cname: "
379+
"delegated already covers them, and both would claim the same names"
380+
)
386381
records = []
387382
if "cname" in include:
388383
records.append(
@@ -402,7 +397,7 @@ def build_records(args: argparse.Namespace) -> List[Record]:
402397
note="tells the gateway which instance to route this hostname to",
403398
)
404399
)
405-
if "delegated" in include and args.challenge_alias:
400+
if "delegated" in include and args.delegation_zone:
406401
# Full delegation: every name this deployment needs is aliased into a
407402
# zone the container can write, so the operator creates these three once
408403
# and never touches DNS again -- not when the app id changes, not when
@@ -412,7 +407,7 @@ def build_records(args: argparse.Namespace) -> List[Record]:
412407
# address to fall back to and a wrong target must be reported as wrong
413408
# rather than as "does not resolve".
414409
base = args.domain[2:] if args.domain.startswith("*.") else args.domain
415-
alias = args.challenge_alias
410+
alias = args.delegation_zone
416411
wanted = [
417412
(args.domain, "routes traffic for this hostname into the delegated zone"),
418413
(args.txt_name, "lets the container publish the gateway routing target"),
@@ -438,7 +433,7 @@ def build_records(args: argparse.Namespace) -> List[Record]:
438433
exact=True,
439434
)
440435
)
441-
if "challenge-cname" in include and args.challenge_alias:
436+
if "challenge-cname" in include and args.delegation_zone:
442437
# Challenge delegation: the CA follows this CNAME when it looks for
443438
# _acme-challenge, so the TXT can live in a zone whose token we hold and
444439
# the served zone never needs one. certbot validates at the base name
@@ -448,7 +443,7 @@ def build_records(args: argparse.Namespace) -> List[Record]:
448443
Record(
449444
type="CNAME",
450445
name=f"_acme-challenge.{base}",
451-
value=f"_acme-challenge.{base}.{args.challenge_alias}",
446+
value=f"_acme-challenge.{base}.{args.delegation_zone}",
452447
note="delegates the ACME challenge to a zone this container can write",
453448
exact=True,
454449
)
@@ -480,20 +475,9 @@ def main() -> int:
480475
parser.add_argument("--account-uri", default="")
481476
parser.add_argument("--challenge", default="tls-alpn-01")
482477
parser.add_argument(
483-
"--challenge-alias",
478+
"--delegation-zone",
484479
default=os.environ.get("DELEGATION_ZONE", ""),
485-
help="delegation zone for the _acme-challenge CNAME (dns-01 delegation)",
486-
)
487-
parser.add_argument(
488-
"--caa-required",
489-
action="store_true",
490-
help="treat an absent CAA record set as a failure (challenge delegation: "
491-
"the record is the only protection and we cannot create it)",
492-
)
493-
parser.add_argument(
494-
"--caa-advisory",
495-
action="store_true",
496-
help="report a failing CAA check as a warning instead of blocking",
480+
help="zone this deployment is delegated into (dns-01 delegation)",
497481
)
498482
parser.add_argument(
499483
"--mode",
@@ -566,8 +550,7 @@ def main() -> int:
566550
try:
567551
results = verify_once(
568552
records, args.domain.lstrip("*."), args.caa_tag, args.challenge,
569-
args.account_uri, args.resolver, args.caa_advisory,
570-
args.caa_required,
553+
args.account_uri, args.resolver,
571554
)
572555
except ResolveError as exc:
573556
print(f"[dns-check #{attempt}] resolver problem: {exc}", flush=True)

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

Lines changed: 22 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def _args(self, domain, alias):
128128
domain=domain, alias_target="gw.example.net",
129129
txt_name="_dstack-app-address." + domain.lstrip("*."),
130130
txt_value="appid:443", caa_name="", caa_tag="issue", caa_value="",
131-
account_uri="", challenge="dns-01", challenge_alias=alias,
131+
account_uri="", challenge="dns-01", delegation_zone=alias,
132132
include="challenge-cname",
133133
)
134134

@@ -154,7 +154,7 @@ def _records(self, domain, txt):
154154
return dnsguide.build_records(argparse.Namespace(
155155
domain=domain, alias_target="gw", txt_name=txt, txt_value="x",
156156
caa_name="", caa_tag="issue", caa_value="", account_uri="",
157-
challenge="dns-01", challenge_alias="deleg.net", include="delegated"))
157+
challenge="dns-01", delegation_zone="deleg.net", include="delegated"))
158158

159159
def test_wildcard_aliases_its_base_as_well(self):
160160
names = [r.name for r in self._records(
@@ -199,48 +199,6 @@ def test_reports_absence_without_claiming_it_does_not_resolve(self):
199199
self.assertIn("no CNAME record found", why)
200200

201201

202-
class TestCaaRequiredForDelegation(unittest.TestCase):
203-
"""Delegation inverts the usual CAA question.
204-
205-
Normally we ask "does anything forbid us from issuing", and no CAA at all
206-
means no. Under challenge delegation the record is the only thing stopping
207-
someone else who can satisfy the delegated challenge, and we cannot create
208-
it, so its absence has to block.
209-
"""
210-
211-
def _no_caa(self):
212-
saved = dnsguide.query_union
213-
dnsguide.query_union = lambda name, rr, r: []
214-
self.addCleanup(lambda: setattr(dnsguide, "query_union", saved))
215-
216-
def test_absent_caa_passes_by_default(self):
217-
self._no_caa()
218-
ok, why = dnsguide.check_caa("app.example.com", "issue", "dns-01", "", "r")
219-
self.assertTrue(ok)
220-
self.assertIn("unrestricted", why)
221-
222-
def test_absent_caa_blocks_when_required(self):
223-
self._no_caa()
224-
ok, why = dnsguide.check_caa(
225-
"app.example.com", "issue", "dns-01", "", "r", require_present=True
226-
)
227-
self.assertFalse(ok)
228-
self.assertIn("no CAA record set", why)
229-
230-
def test_present_and_matching_passes_when_required(self):
231-
saved = dnsguide.query_union
232-
dnsguide.query_union = lambda name, rr, r: (
233-
['0 issue "letsencrypt.org;validationmethods=dns-01;accounturi=https://acme/1"']
234-
if name == "app.example.com" else []
235-
)
236-
self.addCleanup(lambda: setattr(dnsguide, "query_union", saved))
237-
ok, _ = dnsguide.check_caa(
238-
"app.example.com", "issue", "dns-01", "https://acme/1", "r",
239-
require_present=True,
240-
)
241-
self.assertTrue(ok)
242-
243-
244202
class TestTxtNormalisation(unittest.TestCase):
245203
def test_strips_quotes(self):
246204
self.assertEqual(dnsguide._unquote_txt('"abc:443"'), "abc:443")
@@ -283,5 +241,25 @@ def test_caa_omitted_without_value(self):
283241
self.assertEqual(types, ["CNAME", "TXT"])
284242

285243

244+
class TestNoShadowedDefinitions(unittest.TestCase):
245+
"""A duplicated class or method silently shadows the earlier one.
246+
247+
Python does not complain, unittest does not notice, and the count still
248+
goes up -- so a merge that lands the same block twice leaves dead tests
249+
that look like passing ones. This caught exactly that.
250+
"""
251+
252+
def test_every_definition_is_unique(self):
253+
import ast, collections, pathlib
254+
255+
tree = ast.parse(pathlib.Path(__file__).read_text())
256+
names = [n.name for n in tree.body if isinstance(n, ast.ClassDef)]
257+
for cls in (n for n in tree.body if isinstance(n, ast.ClassDef)):
258+
names += [f"{cls.name}.{m.name}" for m in cls.body
259+
if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef))]
260+
dupes = [n for n, c in collections.Counter(names).items() if c > 1]
261+
self.assertEqual(dupes, [], f"defined more than once: {dupes}")
262+
263+
286264
if __name__ == "__main__":
287265
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)