Skip to content

Commit 732d9e0

Browse files
Ravenyjhclaude
andcommitted
feat(dstack-ingress): ACME DNS-01 challenge delegation (CNAME alias)
Adds an opt-in `ACME_CHALLENGE_ALIAS` mode so the DNS token can be scoped to a delegated zone instead of the served domain's own (often shared, production) zone. Closes the least-privilege gap described in the issue. - certman.py: when ACME_CHALLENGE_ALIAS is set, run certbot via `--manual` with auth/cleanup hooks instead of the provider's DNS plugin; `renew` reuses the hooks saved in the renewal config. - acme-dns-alias-hook.sh: writes/removes the `_acme-challenge` TXT in the delegated zone (reusing dnsman.py / the existing provider abstraction). - dnsman.py + base.py: add `unset_txt` for challenge cleanup. - entrypoint.sh: in delegation mode the served-zone records (alias, app-address TXT, CAA) can't be written by our token, so print the exact records the operator must set statically, and for CAA verify presence (via DoH) and warn loudly if missing — instead of silently dropping the accounturi lock. - README: document the mode, required static records, and the CAA security note. Non-breaking: unset ACME_CHALLENGE_ALIAS = current behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xa1zFJs3FVP8UTsSWuS3Yj
1 parent a61cb54 commit 732d9e0

6 files changed

Lines changed: 174 additions & 2 deletions

File tree

custom-domain/dstack-ingress/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,39 @@ 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 used with `ACME_CHALLENGE_ALIAS`) |
182184

183185
For DNS provider credentials, see [DNS_PROVIDERS.md](DNS_PROVIDERS.md).
184186

187+
### ACME challenge delegation (least-privilege DNS token)
188+
189+
By default the DNS token must be able to edit the **served domain's own zone**
190+
(to write `_acme-challenge`, and — with `SET_CAA` — the CAA record). If the
191+
served name lives under a shared production zone (e.g. `svc.example.com` under
192+
`example.com`), that token can edit every record in the zone, which may be more
193+
privilege than you want.
194+
195+
Set `ACME_CHALLENGE_ALIAS=<delegation-zone>` to answer the DNS-01 challenge in a
196+
separate zone that your token controls, so the token never touches the served
197+
domain's zone. In this mode dstack-ingress **only** manages the challenge TXT in
198+
the delegation zone; you set the following records **once, statically**, in the
199+
served domain's production zone (the container prints the exact values on start):
200+
201+
```
202+
svc.example.com CNAME <GATEWAY_DOMAIN>
203+
_dstack-app-address.svc.example.com TXT "<app-id>:<port>"
204+
_acme-challenge.svc.example.com CNAME _acme-challenge.svc.example.com.<delegation-zone>
205+
svc.example.com CAA 0 issue "letsencrypt.org;validationmethods=dns-01;accounturi=<account-uri>"
206+
```
207+
208+
> **Security note.** The `accounturi` CAA restricts issuance to this enclave's
209+
> ACME account. In delegation mode dstack-ingress cannot set it for you (no
210+
> token for the served zone), so it prints the record and verifies (via DoH)
211+
> that it is present, warning loudly if not. Without this CAA, anyone who can
212+
> satisfy the delegated challenge could obtain a certificate for the domain.
213+
> Provide the token scoped only to `<delegation-zone>`.
214+
185215
## Evidence & Attestation
186216

187217
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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,24 @@ 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 record.id and not self.delete_dns_record(record.id, name):
265+
ok = False
266+
return ok
267+
250268
def set_caa_record(
251269
self,
252270
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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,13 @@ backend ${be_name}
268268

269269
set_alias_record() {
270270
local domain="$1"
271+
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
272+
echo "[challenge-delegation] Not touching ${domain}'s own zone (token is scoped to the delegated zone)."
273+
echo "[challenge-delegation] Set these in your production zone yourself (static, one-time):"
274+
echo " ${domain} CNAME ${GATEWAY_DOMAIN}"
275+
echo " _acme-challenge.${domain} CNAME _acme-challenge.${domain}.${ACME_CHALLENGE_ALIAS}"
276+
return
277+
fi
271278
echo "Setting alias record for $domain"
272279
dnsman.py set_alias \
273280
--domain "$domain" \
@@ -301,6 +308,12 @@ set_txt_record() {
301308
txt_domain="${TXT_PREFIX}.${domain}"
302309
fi
303310

311+
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
312+
echo "[challenge-delegation] Set this in your production zone yourself (static, one-time):"
313+
echo " ${txt_domain} TXT \"$APP_ID:$PORT\""
314+
return
315+
fi
316+
304317
dnsman.py set_txt \
305318
--domain "$txt_domain" \
306319
--content "$APP_ID:$PORT"
@@ -337,6 +350,30 @@ set_caa_record() {
337350
fi
338351

339352
ACCOUNT_URI=$(jq -j '.uri' "$account_file")
353+
354+
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
355+
# Delegation mode: our token has no access to the served domain's zone,
356+
# so we cannot set the accounturi CAA ourselves. This CAA is the
357+
# forge-prevention (only the enclave's ACME account may issue), so we do
358+
# NOT silently skip it — we print the exact record the operator must set,
359+
# then best-effort verify it and warn loudly if it is missing.
360+
local caa_value="letsencrypt.org;validationmethods=dns-01;accounturi=$ACCOUNT_URI"
361+
echo "[challenge-delegation] Set this CAA in your production zone yourself (static):"
362+
echo " ${caa_domain} CAA 0 ${caa_tag} \"${caa_value}\""
363+
# Verify via DNS-over-HTTPS (dig is not installed; curl is).
364+
local caa_answer
365+
caa_answer=$(curl -s --max-time 10 "https://dns.google/resolve?name=${caa_domain}&type=257" 2>/dev/null || true)
366+
if echo "$caa_answer" | grep -q "accounturi=$ACCOUNT_URI"; then
367+
echo "[challenge-delegation] Verified: accounturi CAA is present for $caa_domain"
368+
else
369+
echo "WARNING: accounturi CAA is NOT present for $caa_domain."
370+
echo "WARNING: Without it, anyone who can satisfy the DNS-01 challenge could obtain a"
371+
echo "WARNING: publicly-trusted certificate for this domain (forged TLS termination)."
372+
echo "WARNING: Set the CAA record shown above before relying on this endpoint."
373+
fi
374+
return
375+
fi
376+
340377
echo "Adding CAA record ($caa_tag) for $caa_domain, accounturi=$ACCOUNT_URI"
341378
dnsman.py set_caa \
342379
--domain "$caa_domain" \

0 commit comments

Comments
 (0)