Skip to content

Commit e574366

Browse files
authored
Merge pull request #104 from Ravenyjh/feat/acme-challenge-delegation
feat(dstack-ingress): ACME DNS-01 challenge delegation (CNAME alias) for least-privilege DNS tokens
2 parents a61cb54 + 9607449 commit e574366

6 files changed

Lines changed: 247 additions & 17 deletions

File tree

custom-domain/dstack-ingress/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,50 @@ environment:
179179
| `EVIDENCE_SERVER` | `true` | Serve evidence files at `/evidences/` on the TLS port |
180180
| `EVIDENCE_PORT` | `80` | Internal port for evidence HTTP server |
181181
| `ALPN` | | TLS ALPN protocols (e.g. `h2,http/1.1`). Only set if backends support h2c |
182+
| `ACME_CHALLENGE_ALIAS` | | Delegate the ACME DNS-01 challenge to this zone (see below) so the DNS token needs no access to the served domain's own zone |
183+
| `ACME_CHALLENGE_PROPAGATION_SECONDS` | `30` | Wait after writing the delegated challenge TXT before validation (only with `ACME_CHALLENGE_ALIAS`). Keep well under ~250s — certbot is killed after a 300s per-run timeout |
184+
| `ALLOW_MISSING_CAA` | `false` | In delegation mode, continue even if the required `accounturi` CAA cannot be confirmed. Default fails closed (see below) |
182185

183186
For DNS provider credentials, see [DNS_PROVIDERS.md](DNS_PROVIDERS.md).
184187

188+
### ACME challenge delegation (least-privilege DNS token)
189+
190+
By default the DNS token must be able to edit the **served domain's own zone**
191+
(to write `_acme-challenge`, and — with `SET_CAA` — the CAA record). If the
192+
served name lives under a shared production zone (e.g. `svc.example.com` under
193+
`example.com`), that token can edit every record in the zone, which may be more
194+
privilege than you want.
195+
196+
Set `ACME_CHALLENGE_ALIAS=<delegation-zone>` to answer the DNS-01 challenge in a
197+
separate zone that your token controls, so the token never touches the served
198+
domain's zone. In this mode dstack-ingress **only** manages the challenge TXT in
199+
the delegation zone; you set the following records **once, statically**, in the
200+
served domain's production zone (the container prints the exact values on start):
201+
202+
```
203+
svc.example.com CNAME <GATEWAY_DOMAIN>
204+
_dstack-app-address.svc.example.com TXT "<app-id>:<port>"
205+
_acme-challenge.svc.example.com CNAME _acme-challenge.svc.example.com.<delegation-zone>
206+
svc.example.com CAA 0 issue "letsencrypt.org;validationmethods=dns-01;accounturi=<account-uri>"
207+
```
208+
209+
> **Security note.** The `accounturi` CAA restricts issuance to this enclave's
210+
> ACME account and is the control that prevents forged certificates. In
211+
> delegation mode dstack-ingress cannot set it for you (no token for the served
212+
> zone), so it prints the record and verifies its presence via DoH. **If the
213+
> CAA is confirmed absent the container fails to start** (set `ALLOW_MISSING_CAA=true`
214+
> to override; a transient DoH failure only warns, it does not block). Without
215+
> this CAA, anyone who can satisfy the delegated challenge could obtain a
216+
> certificate for the domain.
217+
>
218+
> Use a **dedicated** delegation zone and scope the token to only that zone — a
219+
> zone shared with other tenants lets anyone with write access to it complete
220+
> the challenge. `SET_CAA` does not affect delegation mode (this CAA path always
221+
> runs). If a certificate was previously issued with the standard DNS-plugin
222+
> authenticator, delete `/etc/letsencrypt/renewal/<domain>.conf` before enabling
223+
> delegation, otherwise `certbot renew` reuses the old plugin (which needs the
224+
> production-zone token this mode avoids).
225+
185226
## Evidence & Attestation
186227

187228
Evidence files are served at `https://your-domain.com/evidences/` by default (via payload inspection in HAProxy's TCP mode). They can also be accessed by the backend application through the shared `/evidences` volume.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/bin/bash
2+
# certbot --manual auth/cleanup hook for ACME DNS-01 challenge delegation.
3+
#
4+
# Instead of writing the `_acme-challenge` TXT into the served domain's own
5+
# zone (which requires a DNS token for that zone), this writes it into a
6+
# delegated zone that our token controls (ACME_CHALLENGE_ALIAS). The served
7+
# domain's zone only needs one static, operator-managed CNAME:
8+
#
9+
# _acme-challenge.<domain> CNAME _acme-challenge.<domain>.<ACME_CHALLENGE_ALIAS>
10+
#
11+
# Let's Encrypt follows that CNAME during validation and reads the TXT from the
12+
# delegated zone, so the enclave's token never needs access to the served
13+
# domain's (production) zone.
14+
#
15+
# certbot invokes this with $CERTBOT_DOMAIN and $CERTBOT_VALIDATION set, and
16+
# runs it as either: acme-dns-alias-hook.sh auth | acme-dns-alias-hook.sh cleanup
17+
set -euo pipefail
18+
19+
action="${1:-}"
20+
21+
if [ -z "${ACME_CHALLENGE_ALIAS:-}" ]; then
22+
echo "acme-dns-alias-hook: ACME_CHALLENGE_ALIAS is not set" >&2
23+
exit 1
24+
fi
25+
if [ -z "${CERTBOT_DOMAIN:-}" ]; then
26+
echo "acme-dns-alias-hook: CERTBOT_DOMAIN not set (must be invoked by certbot)" >&2
27+
exit 1
28+
fi
29+
30+
# certbot always passes the base domain (no leading "*.") in CERTBOT_DOMAIN.
31+
record="_acme-challenge.${CERTBOT_DOMAIN}.${ACME_CHALLENGE_ALIAS}"
32+
33+
case "$action" in
34+
auth)
35+
: "${CERTBOT_VALIDATION:?CERTBOT_VALIDATION not set}"
36+
echo "acme-dns-alias-hook: writing challenge TXT to delegated record $record"
37+
dnsman.py set_txt --domain "$record" --content "$CERTBOT_VALIDATION"
38+
# certbot asks Let's Encrypt to validate immediately after this hook
39+
# returns, so wait for the record to propagate on the delegated zone's
40+
# authoritative servers before returning.
41+
sleep "${ACME_CHALLENGE_PROPAGATION_SECONDS:-30}"
42+
;;
43+
cleanup)
44+
echo "acme-dns-alias-hook: removing challenge TXT $record"
45+
# Non-fatal: a failed cleanup leaves a stale TXT in the delegated zone,
46+
# which the next auth overwrites; it must not fail the whole run.
47+
dnsman.py unset_txt --domain "$record" || true
48+
;;
49+
*)
50+
echo "acme-dns-alias-hook: usage: $0 <auth|cleanup>" >&2
51+
exit 1
52+
;;
53+
esac

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,40 @@ def setup_credentials(self) -> bool:
265265

266266
def _build_certbot_command(self, action: str, domain: str, email: str) -> List[str]:
267267
"""Build certbot command using provider configuration."""
268+
certbot_cmd = self._get_certbot_command()
269+
270+
# Challenge-delegation mode: when ACME_CHALLENGE_ALIAS is set, answer the
271+
# DNS-01 challenge in a delegated zone via a manual hook instead of the
272+
# provider's certbot plugin, so our DNS token never needs access to the
273+
# served domain's own zone. See scripts/acme-dns-alias-hook.sh.
274+
if os.environ.get("ACME_CHALLENGE_ALIAS", "").strip():
275+
hook = "/scripts/acme-dns-alias-hook.sh"
276+
base_cmd = certbot_cmd + [action, "--non-interactive", "-v"]
277+
if action == "certonly":
278+
base_cmd.extend([
279+
"--manual",
280+
"--preferred-challenges=dns",
281+
f"--manual-auth-hook={hook} auth",
282+
f"--manual-cleanup-hook={hook} cleanup",
283+
"--agree-tos", "--no-eff-email",
284+
"--email", email, "-d", domain,
285+
])
286+
# For `renew`, certbot reuses the authenticator + hooks saved in the
287+
# renewal config from the initial `certonly`, so we don't re-specify
288+
# them here (and must not fall back to the DNS plugin).
289+
if os.environ.get("CERTBOT_STAGING", "false") == "true":
290+
base_cmd.append("--staging")
291+
masked = [a if not (i > 0 and base_cmd[i - 1] == "--email") else "<email>"
292+
for i, a in enumerate(base_cmd)]
293+
print(f"Executing (challenge-delegation): {' '.join(masked)}")
294+
return base_cmd
295+
268296
plugin = self.provider.CERTBOT_PLUGIN
269297
if not plugin:
270298
raise ValueError(
271299
f"No certbot plugin configured for {self.provider_type}")
272300

273301
# Use Python module execution to ensure same environment
274-
certbot_cmd = self._get_certbot_command()
275302
base_cmd = certbot_cmd + [action, "-a",
276303
plugin, "--non-interactive", "-v"]
277304

custom-domain/dstack-ingress/scripts/dns_providers/base.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,28 @@ def set_txt_record(self, name: str, content: str, ttl: int = 60) -> bool:
247247
)
248248
return self.create_dns_record(new_record)
249249

250+
def unset_txt_record(self, name: str) -> bool:
251+
"""Delete all TXT records for a name.
252+
253+
Used to clean up ACME DNS-01 challenge records (e.g. from a certbot
254+
--manual cleanup hook). Missing records are treated as success.
255+
256+
Args:
257+
name: The record name
258+
259+
Returns:
260+
True if all matching records were removed (or none existed).
261+
"""
262+
ok = True
263+
for record in self.get_dns_records(name, RecordType.TXT):
264+
if not record.id:
265+
print(f"Warning: TXT record for {name} has no id; cannot delete")
266+
ok = False
267+
continue
268+
if not self.delete_dns_record(record.id, name):
269+
ok = False
270+
return ok
271+
250272
def set_caa_record(
251273
self,
252274
name: str,

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", "set_caa"],
17+
choices=["set_cname", "set_alias", "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 == "unset_txt":
71+
success = provider.unset_txt_record(args.domain)
72+
if not success:
73+
print(f"Failed to unset TXT record for {args.domain}", file=sys.stderr)
74+
sys.exit(1)
75+
print(f"Successfully unset TXT record for {args.domain}")
76+
7077
elif args.action == "set_caa":
7178
if not args.caa_tag or not args.caa_value:
7279
print(

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

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,16 @@ backend ${be_name}
268268

269269
set_alias_record() {
270270
local domain="$1"
271+
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
272+
# certbot validates the challenge at the base name even for a wildcard,
273+
# so the _acme-challenge CNAME must use the base domain (strip "*.").
274+
local base="${domain#\*.}"
275+
echo "[challenge-delegation] Not touching ${domain}'s own zone (token is scoped to the delegated zone)."
276+
echo "[challenge-delegation] Set these in your production zone yourself (static, one-time):"
277+
echo " ${domain} CNAME ${GATEWAY_DOMAIN}"
278+
echo " _acme-challenge.${base} CNAME _acme-challenge.${base}.${ACME_CHALLENGE_ALIAS}"
279+
return
280+
fi
271281
echo "Setting alias record for $domain"
272282
dnsman.py set_alias \
273283
--domain "$domain" \
@@ -301,6 +311,12 @@ set_txt_record() {
301311
txt_domain="${TXT_PREFIX}.${domain}"
302312
fi
303313

314+
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
315+
echo "[challenge-delegation] Set this in your production zone yourself (static, one-time):"
316+
echo " ${txt_domain} TXT \"$APP_ID:$PORT\""
317+
return
318+
fi
319+
304320
dnsman.py set_txt \
305321
--domain "$txt_domain" \
306322
--content "$APP_ID:$PORT"
@@ -311,37 +327,101 @@ set_txt_record() {
311327
fi
312328
}
313329

330+
# caa_domain_and_tag DOMAIN -> prints "caa_domain caa_tag" (strips a wildcard).
331+
caa_domain_and_tag() {
332+
local domain="$1"
333+
if [[ "$domain" == \*.* ]]; then
334+
echo "${domain#\*.} issuewild"
335+
else
336+
echo "$domain issue"
337+
fi
338+
}
339+
340+
# In delegation mode we have NO token for the served domain's zone, so we cannot
341+
# set the accounturi CAA ourselves. That CAA is the forge-prevention (only this
342+
# enclave's ACME account may issue), so this path is deliberately NOT gated by
343+
# SET_CAA and fails closed if the record is confirmed absent — otherwise anyone
344+
# who can satisfy the delegated DNS-01 challenge could obtain a cert for the
345+
# domain. Set ALLOW_MISSING_CAA=true to override (accept the risk).
346+
verify_delegation_caa() {
347+
local domain="$1"
348+
local account_file account_uri caa_domain caa_tag caa_value resp status found
349+
350+
if ! account_file=$(get_letsencrypt_account_file); then
351+
echo "ERROR: cannot read the Let's Encrypt account file to determine the required accounturi CAA" >&2
352+
caa_fail_or_allow "$domain"
353+
return
354+
fi
355+
read -r caa_domain caa_tag < <(caa_domain_and_tag "$domain")
356+
account_uri=$(jq -j '.uri' "$account_file")
357+
caa_value="letsencrypt.org;validationmethods=dns-01;accounturi=$account_uri"
358+
359+
echo "[challenge-delegation] Set this CAA in your production zone (static, one-time):"
360+
echo " ${caa_domain} CAA 0 ${caa_tag} \"${caa_value}\""
361+
362+
# Verify via DNS-over-HTTPS (dig is not installed in the image; curl+jq are).
363+
resp=$(curl -s --max-time 10 "https://dns.google/resolve?name=${caa_domain}&type=257" 2>/dev/null || true)
364+
if [ -z "$resp" ]; then
365+
echo "WARNING: could not reach dns.google to verify the CAA (network issue) — NOT confirmed; continuing"
366+
return
367+
fi
368+
status=$(echo "$resp" | jq -r '.Status // empty' 2>/dev/null || true)
369+
if [ "$status" != "0" ]; then
370+
echo "WARNING: CAA DoH query for ${caa_domain} returned status ${status:-unknown} — NOT confirmed; continuing"
371+
return
372+
fi
373+
# Literal (grep -F) match on the CAA data fields — the account URI contains
374+
# '/' and '.', which must not be treated as regex.
375+
found=$(echo "$resp" | jq -r '.Answer // [] | .[] | .data' 2>/dev/null | grep -F "accounturi=$account_uri" || true)
376+
if [ -n "$found" ]; then
377+
echo "[challenge-delegation] Verified: accounturi CAA is present for $caa_domain"
378+
return
379+
fi
380+
echo "ERROR: the accounturi CAA is NOT present for $caa_domain." >&2
381+
echo "ERROR: without it, anyone who can satisfy the delegated DNS-01 challenge could obtain a" >&2
382+
echo "ERROR: publicly-trusted certificate for this domain (forged TLS termination)." >&2
383+
caa_fail_or_allow "$domain"
384+
}
385+
386+
caa_fail_or_allow() {
387+
local domain="$1"
388+
if [ "${ALLOW_MISSING_CAA:-false}" = "true" ]; then
389+
echo "WARNING: ALLOW_MISSING_CAA=true — continuing without a verified accounturi CAA for $domain"
390+
return 0
391+
fi
392+
echo "ERROR: refusing to continue without the accounturi CAA for $domain." >&2
393+
echo "ERROR: set the CAA record shown above, or ALLOW_MISSING_CAA=true to override." >&2
394+
exit 1
395+
}
396+
314397
set_caa_record() {
315398
local domain="$1"
399+
400+
# Delegation mode is handled separately and is NOT gated by SET_CAA.
401+
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
402+
verify_delegation_caa "$domain"
403+
return
404+
fi
405+
316406
if [ "$SET_CAA" != "true" ]; then
317407
echo "Skipping CAA record setup"
318408
return
319409
fi
320410

321-
local ACCOUNT_URI
322-
local account_file
323-
411+
local account_file account_uri caa_domain caa_tag
324412
if ! account_file=$(get_letsencrypt_account_file); then
325413
echo "Warning: Cannot set CAA record - account file not found"
326414
echo "This is not critical - certificates can still be issued without CAA records"
327415
return
328416
fi
417+
read -r caa_domain caa_tag < <(caa_domain_and_tag "$domain")
418+
account_uri=$(jq -j '.uri' "$account_file")
329419

330-
local caa_domain caa_tag
331-
if [[ "$domain" == \*.* ]]; then
332-
caa_domain="${domain#\*.}"
333-
caa_tag="issuewild"
334-
else
335-
caa_domain="$domain"
336-
caa_tag="issue"
337-
fi
338-
339-
ACCOUNT_URI=$(jq -j '.uri' "$account_file")
340-
echo "Adding CAA record ($caa_tag) for $caa_domain, accounturi=$ACCOUNT_URI"
420+
echo "Adding CAA record ($caa_tag) for $caa_domain, accounturi=$account_uri"
341421
dnsman.py set_caa \
342422
--domain "$caa_domain" \
343423
--caa-tag "$caa_tag" \
344-
--caa-value "letsencrypt.org;validationmethods=dns-01;accounturi=$ACCOUNT_URI"
424+
--caa-value "letsencrypt.org;validationmethods=dns-01;accounturi=$account_uri"
345425

346426
if [ $? -ne 0 ]; then
347427
echo "Warning: Failed to set CAA record for $domain"

0 commit comments

Comments
 (0)