Skip to content

Commit 11da287

Browse files
committed
feat(dstack-ingress): delegate every record, not just the challenge
Delegation moved the ACME challenge out of the served domain's zone, but left three records behind that the operator still had to create by hand -- and one of them, the app-address TXT, carries the app id, which changes whenever the compose changes. So "set it up once" was never true: edit the compose, and someone has to go back to the production zone. Alias all three names into the delegation zone instead. The operator creates three CNAMEs before deploying and is then done, permanently: svc.example.com CNAME svc.example.com.deleg.net _dstack-app-address.svc.example.com CNAME _dstack-app-address.svc.example.com.deleg.net _acme-challenge.svc.example.com CNAME _acme-challenge.svc.example.com.deleg.net The container publishes what they point at, including the accounturi CAA it previously could only ask for. The gateway pointer becomes an address record rather than a CNAME, which is the one real cost here. Let's Encrypt follows a CNAME when it looks up CAA and honours what it finds at the target -- verified against the staging CA -- but a CNAME excludes every other type at that name, so a CNAME there would leave nowhere to put the CAA. It does not climb past the target either: a CAA at the delegation zone apex is ignored, also verified. An address record is what lets one name carry both. The container re-resolves GATEWAY_DOMAIN every pass, so a gateway that moves is picked up within RENEW_INTERVAL, where a CNAME used to follow it immediately. Wildcards keep the operator-managed CAA. RFC 8659 evaluates *.example.com at example.com, which is the operator's own name and cannot be aliased away. Renewal passes still verify only the challenge alias. The other two are about serving, and dns-01 never connects here, so blocking a renewal on them would turn a routing problem into an expired certificate. The first pass checks all three, because that is setup.
1 parent 1b71020 commit 11da287

5 files changed

Lines changed: 213 additions & 29 deletions

File tree

custom-domain/dstack-ingress/README.md

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -205,19 +205,42 @@ served name lives under a shared production zone (e.g. `svc.example.com` under
205205
`example.com`), that token can edit every record in the zone, which may be more
206206
privilege than you want.
207207

208-
Set `ACME_CHALLENGE_ALIAS=<delegation-zone>` to answer the DNS-01 challenge in a
209-
separate zone that your token controls, so the token never touches the served
210-
domain's zone. In this mode dstack-ingress **only** manages the challenge TXT in
211-
the delegation zone; you set the following records **once, statically**, in the
212-
served domain's production zone (the container prints the exact values on start):
208+
Set `ACME_CHALLENGE_ALIAS=<delegation-zone>` to move every name this deployment
209+
needs into a zone your token controls. You create three CNAMEs in the served
210+
domain's zone **once, before deploying**, and then never touch DNS again — not
211+
when the app id changes with the compose, not when the ACME account is
212+
recreated, not when the gateway moves:
213213

214214
```
215-
svc.example.com CNAME <GATEWAY_DOMAIN>
216-
_dstack-app-address.svc.example.com TXT "<app-id>:<port>"
217-
_acme-challenge.svc.example.com CNAME _acme-challenge.svc.example.com.<delegation-zone>
218-
svc.example.com CAA 0 issue "letsencrypt.org;validationmethods=dns-01;accounturi=<account-uri>"
215+
svc.example.com CNAME svc.example.com.deleg.example.net
216+
_dstack-app-address.svc.example.com CNAME _dstack-app-address.svc.example.com.deleg.example.net
217+
_acme-challenge.svc.example.com CNAME _acme-challenge.svc.example.com.deleg.example.net
219218
```
220219

220+
dstack-ingress publishes what they point at, in the delegation zone:
221+
222+
```
223+
svc.example.com.deleg.example.net A <gateway address>
224+
svc.example.com.deleg.example.net CAA 0 issue "letsencrypt.org;validationmethods=dns-01;accounturi=…"
225+
_dstack-app-address.svc.example.com.deleg.example.net TXT "<app-id>:<port>"
226+
_acme-challenge.svc.example.com.deleg.example.net TXT <challenge> (transient)
227+
```
228+
229+
The gateway pointer is an address record rather than a CNAME on purpose. Let's
230+
Encrypt follows a CNAME when it looks up CAA and honours what it finds at the
231+
target, but a CNAME excludes every other type at that name, so a CNAME there
232+
would leave nowhere to put the CAA. It does not climb past the target either —
233+
a CAA at the delegation zone apex is ignored. An address record is what lets one
234+
name carry both.
235+
236+
The cost is that the container now tracks the gateway's address itself, where a
237+
CNAME used to follow it automatically. It re-resolves `GATEWAY_DOMAIN` on every
238+
pass, so a gateway that moves is picked up within `RENEW_INTERVAL`.
239+
240+
**Wildcards keep an operator-managed CAA.** RFC 8659 evaluates `*.example.com`
241+
at `example.com`, which is your own name and cannot be aliased away, so for a
242+
wildcard the container prints the CAA and verifies it rather than publishing it.
243+
221244
> **Security note.** The `accounturi` CAA restricts issuance to this enclave's
222245
> ACME account and is the control that prevents forged certificates. In
223246
> delegation mode dstack-ingress cannot set it for you (no token for the served
@@ -236,7 +259,14 @@ svc.example.com CAA 0 issue "letsencrypt.org;validation
236259

237260
The records above are checked the same way tls-alpn-01 checks its own: two DoH
238261
resolvers, both CAA wire formats, and `DNS_SETUP_MODE` deciding what happens
239-
while they are missing. `wait` (the default) blocks until they appear, so you can
262+
while they are missing.
263+
264+
Only the `_acme-challenge` CNAME is checked on later passes. Under dns-01 the CA
265+
reads a TXT record and never connects here, so the other two records are about
266+
serving rather than issuance, and a renewal is not blocked on them — a routing
267+
problem can be fixed at any time, an expired certificate cannot. They are still
268+
checked on the first pass, where the point is to tell you whether you created
269+
them correctly. `wait` (the default) blocks until they appear, so you can
240270
start the container and create the records afterwards; `print` lists them and
241271
continues without checking; `webhook` POSTs them to `DNS_WEBHOOK_URL` for an
242272
operator service to create automatically.

custom-domain/dstack-ingress/TESTING.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,14 +212,17 @@ the provider resolves a zone by longest-suffix match on the record name. With
212212
`_acme-challenge.svc.example.com.deleg.example.com`, which lands in
213213
`example.com` — the same zone, but through the delegation code path.
214214

215-
Play the operator and create the three static records the container prints:
215+
Play the operator and create the three CNAMEs the container prints:
216216

217217
```
218-
svc.example.com CNAME <GATEWAY_DOMAIN>
219-
_dstack-app-address.svc.example.com TXT "<app-id>:443"
220-
_acme-challenge.svc.example.com CNAME _acme-challenge.svc.example.com.deleg.example.com
218+
svc.example.com CNAME svc.example.com.deleg.example.com
219+
_dstack-app-address.svc.example.com CNAME _dstack-app-address.svc.example.com.deleg.example.com
220+
_acme-challenge.svc.example.com CNAME _acme-challenge.svc.example.com.deleg.example.com
221221
```
222222

223+
Everything they point at is published by the container, so this is the whole of
224+
the operator's job — the app id can change afterwards and no DNS edit follows.
225+
223226
Then start the container with a real provider token and `ACME_STAGING=true`, and
224227
watch for: the three records verifying, `Executing (challenge-delegation):` with
225228
`--manual` and both hooks, the certificate arriving, and the challenge TXT being
@@ -251,7 +254,9 @@ These fail fast and are cheap, so run them on every change:
251254
| Scenario | Expected |
252255
|---|---|
253256
| tls-alpn-01 + a wildcard domain | Refused before any ACME call, citing RFC 8737 |
257+
| 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 |
254258
| Delegation with no CAA record at all | Blocked — unlike normal issuance, where "no CAA" means unrestricted and passes |
259+
| Delegation, later pass, gateway CNAME broken | Renewal proceeds — that record is for serving, and dns-01 does not use it |
255260
| A CAA record with `validationmethods=dns-01`, mode tls-alpn-01 | Blocked with the restriction quoted back |
256261
| TXT holding the wrong value | Reported as `want <x>, saw <y>`, not "missing" |
257262
| An instance ID the gateway does not know | The CA reports a connection error — the gateway will not route to it |

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

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,67 @@ set_txt_record() {
105105
fi
106106
}
107107

108+
# --- Full delegation --------------------------------------------------------
109+
#
110+
# The operator aliases three names into a zone this container can write, once,
111+
# before deploying. Everything those names resolve to is published here, so DNS
112+
# never needs touching again -- not when the app id changes with the compose,
113+
# not when the ACME account is recreated, not when the gateway moves.
114+
#
115+
# The gateway pointer has to be an A record rather than a CNAME. Let's Encrypt
116+
# follows a CNAME when it looks up CAA and honours what it finds at the target
117+
# (verified against the staging CA), but a CNAME excludes every other type at
118+
# that name, so a CNAME there would leave nowhere to put the CAA. It does not
119+
# climb past the target either: a CAA at the delegation zone apex is ignored.
120+
# An address record is what lets one name carry both.
121+
delegated_name() {
122+
local domain="${1#\*.}"
123+
echo "${domain}.${ACME_CHALLENGE_ALIAS}"
124+
}
125+
126+
# Resolve GATEWAY_DOMAIN to addresses. Cheap and re-run every pass, since this
127+
# is now the only thing tracking the gateway; a CNAME used to do it for us.
128+
gateway_addresses() {
129+
dnsguide.py --resolve "$GATEWAY_DOMAIN" 2>/dev/null
130+
}
131+
132+
delegation_publish() {
133+
local domain="$1" target addrs addr txt_name
134+
target=$(delegated_name "$domain")
135+
136+
addrs=$(gateway_addresses)
137+
if [ -z "$addrs" ]; then
138+
echo "Error: could not resolve $GATEWAY_DOMAIN to any address" >&2
139+
return 1
140+
fi
141+
while IFS= read -r addr; do
142+
[ -n "$addr" ] || continue
143+
dnsman.py set_a --domain "$target" --content "$addr" || return 1
144+
break
145+
done <<<"$addrs"
146+
147+
txt_name="$(txt_record_name "$domain").${ACME_CHALLENGE_ALIAS}"
148+
dnsman.py set_txt --domain "$txt_name" --content "$(txt_record_value)" || return 1
149+
}
150+
151+
# The accounturi CAA goes on the delegated name, beside the address record.
152+
#
153+
# Not for a wildcard: RFC 8659 evaluates `*.example.com` at `example.com`, which
154+
# is the operator's own name and cannot be aliased away. Wildcards keep the
155+
# operator-managed CAA.
156+
delegation_publish_caa() {
157+
local domain="$1" account_file account_uri
158+
if [[ "$domain" == \*.* ]]; then
159+
return 2
160+
fi
161+
account_file=$(get_letsencrypt_account_file) || return 1
162+
account_uri=$(jq -j '.uri' "$account_file")
163+
dnsman.py set_caa \
164+
--domain "$(delegated_name "$domain")" \
165+
--caa-tag issue \
166+
--caa-value "letsencrypt.org;validationmethods=dns-01;accounturi=$account_uri"
167+
}
168+
108169
# Challenge delegation: this container holds no token for the served domain's
109170
# zone, so the operator creates those records. dnsguide.py is what prints and
110171
# verifies operator-managed records everywhere else in this image, so use it
@@ -160,12 +221,21 @@ delegation_verify_caa() {
160221
set_caa_record() {
161222
local domain="$1"
162223

163-
# Delegation is handled separately and is deliberately NOT gated by
164-
# SET_CAA: there we cannot write the record, so verifying it is the only
165-
# protection left.
224+
# Delegation is deliberately NOT gated by SET_CAA: this record is the only
225+
# thing stopping anyone else who can satisfy the delegated challenge, so it
226+
# is not optional here.
166227
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
167-
delegation_verify_caa "$domain" || exit 1
168-
return
228+
delegation_publish_caa "$domain"
229+
case $? in
230+
0) return ;;
231+
2) # Wildcard: RFC 8659 evaluates *.example.com at example.com,
232+
# which is the operator's own name and cannot be aliased away,
233+
# so ask for it instead of publishing it.
234+
delegation_verify_caa "$domain" || exit 1
235+
return ;;
236+
*) echo "Error: could not publish the CAA record for $domain" >&2
237+
exit 1 ;;
238+
esac
169239
fi
170240

171241
if [ "$SET_CAA" != "true" ]; then
@@ -204,10 +274,30 @@ process_domain() {
204274
local domain="$1" first status
205275

206276
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
207-
# Delegation: the operator owns these. Verify them before spending an
208-
# ACME attempt that would fail on records nobody has created yet. CAA
209-
# is checked after the first issuance, once the account URI exists.
210-
if ! delegation_guide "$domain" cname,txt,challenge-cname; then
277+
# Delegation: the operator created three CNAMEs once, before deploying.
278+
# Everything they point at is published here. Which of them block
279+
# depends on the pass.
280+
#
281+
# Only the _acme-challenge alias is needed to issue: under dns-01 the CA
282+
# reads a TXT record and never connects here, so the gateway CNAME and
283+
# the app-address TXT are about serving, not about issuance. Blocking a
284+
# renewal on them would turn a routing problem -- fixable at any time --
285+
# into an expired certificate, which is not. Renewal wins.
286+
#
287+
# The first pass still checks all three, because that is setup: the
288+
# operator has just been handed a list of records to create and wants to
289+
# know whether they got them right.
290+
# Publish first: the aliases the operator created point at names that
291+
# have to exist before checking them proves anything.
292+
if ! delegation_publish "$domain"; then
293+
echo "Error: could not publish $domain into $ACME_CHALLENGE_ALIAS" >&2
294+
return 1
295+
fi
296+
local want=challenge-cname
297+
if [ "$FIRST_PASS" = "true" ]; then
298+
want=delegated
299+
fi
300+
if ! delegation_guide "$domain" "$want"; then
211301
echo "Error: required DNS records for $domain are not in place" >&2
212302
return 1
213303
fi
@@ -278,6 +368,11 @@ collect_evidence() {
278368
evidence_finalize
279369
}
280370

371+
# True until the first pass finishes. The first pass is setup and checks more
372+
# than issuance strictly needs; later passes check only what would stop a
373+
# renewal.
374+
FIRST_PASS=true
375+
281376
# Set when certificates have been renewed but not yet applied to haproxy.
282377
# Survives across passes, so a failed apply is retried on the next one.
283378
APPLY_PENDING=false
@@ -319,6 +414,7 @@ run_pass() {
319414
echo "[$(date)] Certificate apply incomplete; retrying on the next pass" >&2
320415
fi
321416
fi
417+
FIRST_PASS=false
322418
[ "$failed" -eq 0 ]
323419
}
324420

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

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,31 @@ def build_records(args: argparse.Namespace) -> List[Record]:
402402
note="tells the gateway which instance to route this hostname to",
403403
)
404404
)
405+
if "delegated" in include and args.challenge_alias:
406+
# Full delegation: every name this deployment needs is aliased into a
407+
# zone the container can write, so the operator creates these three once
408+
# and never touches DNS again -- not when the app id changes, not when
409+
# the ACME account changes, not when the gateway moves.
410+
#
411+
# Each is exact: the target holds only what we put there, so there is no
412+
# address to fall back to and a wrong target must be reported as wrong
413+
# rather than as "does not resolve".
414+
base = args.domain[2:] if args.domain.startswith("*.") else args.domain
415+
alias = args.challenge_alias
416+
for name, note in (
417+
(args.domain, "routes traffic for this hostname into the delegated zone"),
418+
(args.txt_name, "lets the container publish the gateway routing target"),
419+
(f"_acme-challenge.{base}", "delegates the ACME challenge"),
420+
):
421+
records.append(
422+
Record(
423+
type="CNAME",
424+
name=name,
425+
value=f"{name}.{alias}" if name != args.domain else f"{base}.{alias}",
426+
note=note,
427+
exact=True,
428+
)
429+
)
405430
if "challenge-cname" in include and args.challenge_alias:
406431
# Challenge delegation: the CA follows this CNAME when it looks for
407432
# _acme-challenge, so the TXT can live in a zone whose token we hold and
@@ -434,10 +459,10 @@ def build_records(args: argparse.Namespace) -> List[Record]:
434459

435460
def main() -> int:
436461
parser = argparse.ArgumentParser(description=__doc__)
437-
parser.add_argument("--domain", required=True)
438-
parser.add_argument("--alias-target", required=True, help="gateway domain")
439-
parser.add_argument("--txt-name", required=True)
440-
parser.add_argument("--txt-value", required=True)
462+
parser.add_argument("--domain", default="")
463+
parser.add_argument("--alias-target", default="", help="gateway domain")
464+
parser.add_argument("--txt-name", default="")
465+
parser.add_argument("--txt-value", default="")
441466
parser.add_argument("--caa-name", default="")
442467
parser.add_argument("--caa-tag", default="issue", choices=["issue", "issuewild"])
443468
parser.add_argument("--caa-value", default="")
@@ -471,15 +496,36 @@ def main() -> int:
471496
default=os.environ.get("DOH_RESOLVERS", os.environ.get("DOH_RESOLVER", DEFAULT_DOH)),
472497
help="comma-separated DoH endpoints",
473498
)
499+
parser.add_argument(
500+
"--resolve",
501+
default="",
502+
help="print the A records for a name and exit (used to publish the "
503+
"gateway address into a delegated zone)",
504+
)
474505
parser.add_argument("--json", action="store_true", help="also emit the records as JSON")
475506
parser.add_argument(
476507
"--include",
477508
default="cname,txt,caa",
478509
help="comma-separated subset of records to handle "
479-
"(cname,txt,caa,challenge-cname)",
510+
"(cname,txt,caa,challenge-cname,delegated)",
480511
)
481512
args = parser.parse_args()
482513

514+
if not args.resolve:
515+
missing = [n for n, v in (("--domain", args.domain),
516+
("--alias-target", args.alias_target),
517+
("--txt-name", args.txt_name),
518+
("--txt-value", args.txt_value)) if not v]
519+
if missing:
520+
parser.error(f"{', '.join(missing)} required unless --resolve is given")
521+
522+
if args.resolve:
523+
# Address lookup for callers that need to publish an A record rather
524+
# than a CNAME. Same resolvers, same union semantics as everything else.
525+
for addr in query_union(args.resolve, RR_A, args.resolver):
526+
print(addr)
527+
return 0
528+
483529
if not args.caa_name:
484530
args.caa_name = args.domain.lstrip("*.")
485531

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def main():
1414
)
1515
parser.add_argument(
1616
"action",
17-
choices=["set_cname", "set_alias", "set_txt", "unset_txt", "set_caa"],
17+
choices=["set_cname", "set_alias", "set_a", "set_txt", "unset_txt", "set_caa"],
1818
help="Action to perform",
1919
)
2020
parser.add_argument("--domain", required=True, help="Domain name")
@@ -67,6 +67,13 @@ def main():
6767
sys.exit(1)
6868
print(f"Successfully set TXT record for {args.domain}")
6969

70+
elif args.action == "set_a":
71+
success = provider.set_a_record(args.domain, args.content)
72+
if not success:
73+
print(f"Failed to set A record for {args.domain}", file=sys.stderr)
74+
sys.exit(1)
75+
print(f"Successfully set A record for {args.domain}")
76+
7077
elif args.action == "unset_txt":
7178
success = provider.unset_txt_record(args.domain)
7279
if not success:

0 commit comments

Comments
 (0)