From 732d9e0db02741d02a152fc3d063676409df3836 Mon Sep 17 00:00:00 2001 From: Ravenyjh Date: Mon, 27 Jul 2026 19:13:47 +0800 Subject: [PATCH 1/2] feat(dstack-ingress): ACME DNS-01 challenge delegation (CNAME alias) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Xa1zFJs3FVP8UTsSWuS3Yj --- custom-domain/dstack-ingress/README.md | 30 +++++++++++ .../scripts/acme-dns-alias-hook.sh | 53 +++++++++++++++++++ .../dstack-ingress/scripts/certman.py | 29 +++++++++- .../scripts/dns_providers/base.py | 18 +++++++ .../dstack-ingress/scripts/dnsman.py | 9 +++- .../dstack-ingress/scripts/entrypoint.sh | 37 +++++++++++++ 6 files changed, 174 insertions(+), 2 deletions(-) create mode 100755 custom-domain/dstack-ingress/scripts/acme-dns-alias-hook.sh diff --git a/custom-domain/dstack-ingress/README.md b/custom-domain/dstack-ingress/README.md index 03befed6..25534116 100644 --- a/custom-domain/dstack-ingress/README.md +++ b/custom-domain/dstack-ingress/README.md @@ -179,9 +179,39 @@ environment: | `EVIDENCE_SERVER` | `true` | Serve evidence files at `/evidences/` on the TLS port | | `EVIDENCE_PORT` | `80` | Internal port for evidence HTTP server | | `ALPN` | | TLS ALPN protocols (e.g. `h2,http/1.1`). Only set if backends support h2c | +| `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 | +| `ACME_CHALLENGE_PROPAGATION_SECONDS` | `30` | Wait after writing the delegated challenge TXT before validation (only used with `ACME_CHALLENGE_ALIAS`) | For DNS provider credentials, see [DNS_PROVIDERS.md](DNS_PROVIDERS.md). +### ACME challenge delegation (least-privilege DNS token) + +By default the DNS token must be able to edit the **served domain's own zone** +(to write `_acme-challenge`, and — with `SET_CAA` — the CAA record). If the +served name lives under a shared production zone (e.g. `svc.example.com` under +`example.com`), that token can edit every record in the zone, which may be more +privilege than you want. + +Set `ACME_CHALLENGE_ALIAS=` to answer the DNS-01 challenge in a +separate zone that your token controls, so the token never touches the served +domain's zone. In this mode dstack-ingress **only** manages the challenge TXT in +the delegation zone; you set the following records **once, statically**, in the +served domain's production zone (the container prints the exact values on start): + +``` +svc.example.com CNAME +_dstack-app-address.svc.example.com TXT ":" +_acme-challenge.svc.example.com CNAME _acme-challenge.svc.example.com. +svc.example.com CAA 0 issue "letsencrypt.org;validationmethods=dns-01;accounturi=" +``` + +> **Security note.** The `accounturi` CAA restricts issuance to this enclave's +> ACME account. In delegation mode dstack-ingress cannot set it for you (no +> token for the served zone), so it prints the record and verifies (via DoH) +> that it is present, warning loudly if not. Without this CAA, anyone who can +> satisfy the delegated challenge could obtain a certificate for the domain. +> Provide the token scoped only to ``. + ## Evidence & Attestation 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. diff --git a/custom-domain/dstack-ingress/scripts/acme-dns-alias-hook.sh b/custom-domain/dstack-ingress/scripts/acme-dns-alias-hook.sh new file mode 100755 index 00000000..751ec2b3 --- /dev/null +++ b/custom-domain/dstack-ingress/scripts/acme-dns-alias-hook.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# certbot --manual auth/cleanup hook for ACME DNS-01 challenge delegation. +# +# Instead of writing the `_acme-challenge` TXT into the served domain's own +# zone (which requires a DNS token for that zone), this writes it into a +# delegated zone that our token controls (ACME_CHALLENGE_ALIAS). The served +# domain's zone only needs one static, operator-managed CNAME: +# +# _acme-challenge. CNAME _acme-challenge.. +# +# Let's Encrypt follows that CNAME during validation and reads the TXT from the +# delegated zone, so the enclave's token never needs access to the served +# domain's (production) zone. +# +# certbot invokes this with $CERTBOT_DOMAIN and $CERTBOT_VALIDATION set, and +# runs it as either: acme-dns-alias-hook.sh auth | acme-dns-alias-hook.sh cleanup +set -euo pipefail + +action="${1:-}" + +if [ -z "${ACME_CHALLENGE_ALIAS:-}" ]; then + echo "acme-dns-alias-hook: ACME_CHALLENGE_ALIAS is not set" >&2 + exit 1 +fi +if [ -z "${CERTBOT_DOMAIN:-}" ]; then + echo "acme-dns-alias-hook: CERTBOT_DOMAIN not set (must be invoked by certbot)" >&2 + exit 1 +fi + +# certbot always passes the base domain (no leading "*.") in CERTBOT_DOMAIN. +record="_acme-challenge.${CERTBOT_DOMAIN}.${ACME_CHALLENGE_ALIAS}" + +case "$action" in + auth) + : "${CERTBOT_VALIDATION:?CERTBOT_VALIDATION not set}" + echo "acme-dns-alias-hook: writing challenge TXT to delegated record $record" + dnsman.py set_txt --domain "$record" --content "$CERTBOT_VALIDATION" + # certbot asks Let's Encrypt to validate immediately after this hook + # returns, so wait for the record to propagate on the delegated zone's + # authoritative servers before returning. + sleep "${ACME_CHALLENGE_PROPAGATION_SECONDS:-30}" + ;; + cleanup) + echo "acme-dns-alias-hook: removing challenge TXT $record" + # Non-fatal: a failed cleanup leaves a stale TXT in the delegated zone, + # which the next auth overwrites; it must not fail the whole run. + dnsman.py unset_txt --domain "$record" || true + ;; + *) + echo "acme-dns-alias-hook: usage: $0 " >&2 + exit 1 + ;; +esac diff --git a/custom-domain/dstack-ingress/scripts/certman.py b/custom-domain/dstack-ingress/scripts/certman.py index 52126d23..d12527f8 100644 --- a/custom-domain/dstack-ingress/scripts/certman.py +++ b/custom-domain/dstack-ingress/scripts/certman.py @@ -265,13 +265,40 @@ def setup_credentials(self) -> bool: def _build_certbot_command(self, action: str, domain: str, email: str) -> List[str]: """Build certbot command using provider configuration.""" + certbot_cmd = self._get_certbot_command() + + # Challenge-delegation mode: when ACME_CHALLENGE_ALIAS is set, answer the + # DNS-01 challenge in a delegated zone via a manual hook instead of the + # provider's certbot plugin, so our DNS token never needs access to the + # served domain's own zone. See scripts/acme-dns-alias-hook.sh. + if os.environ.get("ACME_CHALLENGE_ALIAS", "").strip(): + hook = "/scripts/acme-dns-alias-hook.sh" + base_cmd = certbot_cmd + [action, "--non-interactive", "-v"] + if action == "certonly": + base_cmd.extend([ + "--manual", + "--preferred-challenges=dns", + f"--manual-auth-hook={hook} auth", + f"--manual-cleanup-hook={hook} cleanup", + "--agree-tos", "--no-eff-email", + "--email", email, "-d", domain, + ]) + # For `renew`, certbot reuses the authenticator + hooks saved in the + # renewal config from the initial `certonly`, so we don't re-specify + # them here (and must not fall back to the DNS plugin). + if os.environ.get("CERTBOT_STAGING", "false") == "true": + base_cmd.append("--staging") + masked = [a if not (i > 0 and base_cmd[i - 1] == "--email") else "" + for i, a in enumerate(base_cmd)] + print(f"Executing (challenge-delegation): {' '.join(masked)}") + return base_cmd + plugin = self.provider.CERTBOT_PLUGIN if not plugin: raise ValueError( f"No certbot plugin configured for {self.provider_type}") # Use Python module execution to ensure same environment - certbot_cmd = self._get_certbot_command() base_cmd = certbot_cmd + [action, "-a", plugin, "--non-interactive", "-v"] diff --git a/custom-domain/dstack-ingress/scripts/dns_providers/base.py b/custom-domain/dstack-ingress/scripts/dns_providers/base.py index 608cf16b..f602217b 100644 --- a/custom-domain/dstack-ingress/scripts/dns_providers/base.py +++ b/custom-domain/dstack-ingress/scripts/dns_providers/base.py @@ -247,6 +247,24 @@ def set_txt_record(self, name: str, content: str, ttl: int = 60) -> bool: ) return self.create_dns_record(new_record) + def unset_txt_record(self, name: str) -> bool: + """Delete all TXT records for a name. + + Used to clean up ACME DNS-01 challenge records (e.g. from a certbot + --manual cleanup hook). Missing records are treated as success. + + Args: + name: The record name + + Returns: + True if all matching records were removed (or none existed). + """ + ok = True + for record in self.get_dns_records(name, RecordType.TXT): + if record.id and not self.delete_dns_record(record.id, name): + ok = False + return ok + def set_caa_record( self, name: str, diff --git a/custom-domain/dstack-ingress/scripts/dnsman.py b/custom-domain/dstack-ingress/scripts/dnsman.py index 98d8392c..20dfc360 100755 --- a/custom-domain/dstack-ingress/scripts/dnsman.py +++ b/custom-domain/dstack-ingress/scripts/dnsman.py @@ -14,7 +14,7 @@ def main(): ) parser.add_argument( "action", - choices=["set_cname", "set_alias", "set_txt", "set_caa"], + choices=["set_cname", "set_alias", "set_txt", "unset_txt", "set_caa"], help="Action to perform", ) parser.add_argument("--domain", required=True, help="Domain name") @@ -67,6 +67,13 @@ def main(): sys.exit(1) print(f"Successfully set TXT record for {args.domain}") + elif args.action == "unset_txt": + success = provider.unset_txt_record(args.domain) + if not success: + print(f"Failed to unset TXT record for {args.domain}", file=sys.stderr) + sys.exit(1) + print(f"Successfully unset TXT record for {args.domain}") + elif args.action == "set_caa": if not args.caa_tag or not args.caa_value: print( diff --git a/custom-domain/dstack-ingress/scripts/entrypoint.sh b/custom-domain/dstack-ingress/scripts/entrypoint.sh index 2f1eed97..f3bc2745 100644 --- a/custom-domain/dstack-ingress/scripts/entrypoint.sh +++ b/custom-domain/dstack-ingress/scripts/entrypoint.sh @@ -268,6 +268,13 @@ backend ${be_name} set_alias_record() { local domain="$1" + if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then + echo "[challenge-delegation] Not touching ${domain}'s own zone (token is scoped to the delegated zone)." + echo "[challenge-delegation] Set these in your production zone yourself (static, one-time):" + echo " ${domain} CNAME ${GATEWAY_DOMAIN}" + echo " _acme-challenge.${domain} CNAME _acme-challenge.${domain}.${ACME_CHALLENGE_ALIAS}" + return + fi echo "Setting alias record for $domain" dnsman.py set_alias \ --domain "$domain" \ @@ -301,6 +308,12 @@ set_txt_record() { txt_domain="${TXT_PREFIX}.${domain}" fi + if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then + echo "[challenge-delegation] Set this in your production zone yourself (static, one-time):" + echo " ${txt_domain} TXT \"$APP_ID:$PORT\"" + return + fi + dnsman.py set_txt \ --domain "$txt_domain" \ --content "$APP_ID:$PORT" @@ -337,6 +350,30 @@ set_caa_record() { fi ACCOUNT_URI=$(jq -j '.uri' "$account_file") + + if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then + # Delegation mode: our token has no access to the served domain's zone, + # so we cannot set the accounturi CAA ourselves. This CAA is the + # forge-prevention (only the enclave's ACME account may issue), so we do + # NOT silently skip it — we print the exact record the operator must set, + # then best-effort verify it and warn loudly if it is missing. + local caa_value="letsencrypt.org;validationmethods=dns-01;accounturi=$ACCOUNT_URI" + echo "[challenge-delegation] Set this CAA in your production zone yourself (static):" + echo " ${caa_domain} CAA 0 ${caa_tag} \"${caa_value}\"" + # Verify via DNS-over-HTTPS (dig is not installed; curl is). + local caa_answer + caa_answer=$(curl -s --max-time 10 "https://dns.google/resolve?name=${caa_domain}&type=257" 2>/dev/null || true) + if echo "$caa_answer" | grep -q "accounturi=$ACCOUNT_URI"; then + echo "[challenge-delegation] Verified: accounturi CAA is present for $caa_domain" + else + echo "WARNING: accounturi CAA is NOT present for $caa_domain." + echo "WARNING: Without it, anyone who can satisfy the DNS-01 challenge could obtain a" + echo "WARNING: publicly-trusted certificate for this domain (forged TLS termination)." + echo "WARNING: Set the CAA record shown above before relying on this endpoint." + fi + return + fi + echo "Adding CAA record ($caa_tag) for $caa_domain, accounturi=$ACCOUNT_URI" dnsman.py set_caa \ --domain "$caa_domain" \ From 9607449e773eb8bd59d786eeb346ea8197a2b498 Mon Sep 17 00:00:00 2001 From: Ravenyjh Date: Mon, 27 Jul 2026 19:23:39 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(dstack-ingress):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20reachable=20&=20fail-closed=20CAA,=20wildcard=20CNA?= =?UTF-8?q?ME,=20robust=20DoH=20verify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - entrypoint.sh: the delegation-mode accounturi-CAA check was unreachable in the default config (it lived after the `SET_CAA != true` early return, and SET_CAA defaults to false), so the forge-prevention control silently never ran. Move it above the SET_CAA gate (delegation CAA is independent of SET_CAA) and make it fail closed: if the required CAA is confirmed absent the container exits, with ALLOW_MISSING_CAA=true to override. A transient DoH failure only warns. - entrypoint.sh: fix the wildcard `_acme-challenge` CNAME guidance — strip `*.` so it matches the base name certbot/LE validate and the hook writes to. - entrypoint.sh: parse the DoH response with jq and match with `grep -F` (the account URI contains `/` and `.`, which must not be regex), and distinguish "confirmed absent" (fail) from "could not verify" (warn). - base.py: unset_txt_record no longer reports success for a record it could not address (no id) — counts it as failure and warns. - README: document fail-closed CAA + ALLOW_MISSING_CAA, the propagation ceiling vs certbot's 300s timeout, using a dedicated delegation zone, and deleting a stale plugin renewal conf before switching to delegation. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Xa1zFJs3FVP8UTsSWuS3Yj --- custom-domain/dstack-ingress/README.md | 23 +++- .../scripts/dns_providers/base.py | 6 +- .../dstack-ingress/scripts/entrypoint.sh | 119 ++++++++++++------ 3 files changed, 103 insertions(+), 45 deletions(-) diff --git a/custom-domain/dstack-ingress/README.md b/custom-domain/dstack-ingress/README.md index 25534116..ef16d2ea 100644 --- a/custom-domain/dstack-ingress/README.md +++ b/custom-domain/dstack-ingress/README.md @@ -180,7 +180,8 @@ environment: | `EVIDENCE_PORT` | `80` | Internal port for evidence HTTP server | | `ALPN` | | TLS ALPN protocols (e.g. `h2,http/1.1`). Only set if backends support h2c | | `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 | -| `ACME_CHALLENGE_PROPAGATION_SECONDS` | `30` | Wait after writing the delegated challenge TXT before validation (only used with `ACME_CHALLENGE_ALIAS`) | +| `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 | +| `ALLOW_MISSING_CAA` | `false` | In delegation mode, continue even if the required `accounturi` CAA cannot be confirmed. Default fails closed (see below) | For DNS provider credentials, see [DNS_PROVIDERS.md](DNS_PROVIDERS.md). @@ -206,11 +207,21 @@ svc.example.com CAA 0 issue "letsencrypt.org;validation ``` > **Security note.** The `accounturi` CAA restricts issuance to this enclave's -> ACME account. In delegation mode dstack-ingress cannot set it for you (no -> token for the served zone), so it prints the record and verifies (via DoH) -> that it is present, warning loudly if not. Without this CAA, anyone who can -> satisfy the delegated challenge could obtain a certificate for the domain. -> Provide the token scoped only to ``. +> ACME account and is the control that prevents forged certificates. In +> delegation mode dstack-ingress cannot set it for you (no token for the served +> zone), so it prints the record and verifies its presence via DoH. **If the +> CAA is confirmed absent the container fails to start** (set `ALLOW_MISSING_CAA=true` +> to override; a transient DoH failure only warns, it does not block). Without +> this CAA, anyone who can satisfy the delegated challenge could obtain a +> certificate for the domain. +> +> Use a **dedicated** delegation zone and scope the token to only that zone — a +> zone shared with other tenants lets anyone with write access to it complete +> the challenge. `SET_CAA` does not affect delegation mode (this CAA path always +> runs). If a certificate was previously issued with the standard DNS-plugin +> authenticator, delete `/etc/letsencrypt/renewal/.conf` before enabling +> delegation, otherwise `certbot renew` reuses the old plugin (which needs the +> production-zone token this mode avoids). ## Evidence & Attestation diff --git a/custom-domain/dstack-ingress/scripts/dns_providers/base.py b/custom-domain/dstack-ingress/scripts/dns_providers/base.py index f602217b..e8e89570 100644 --- a/custom-domain/dstack-ingress/scripts/dns_providers/base.py +++ b/custom-domain/dstack-ingress/scripts/dns_providers/base.py @@ -261,7 +261,11 @@ def unset_txt_record(self, name: str) -> bool: """ ok = True for record in self.get_dns_records(name, RecordType.TXT): - if record.id and not self.delete_dns_record(record.id, name): + if not record.id: + print(f"Warning: TXT record for {name} has no id; cannot delete") + ok = False + continue + if not self.delete_dns_record(record.id, name): ok = False return ok diff --git a/custom-domain/dstack-ingress/scripts/entrypoint.sh b/custom-domain/dstack-ingress/scripts/entrypoint.sh index f3bc2745..c1c88e34 100644 --- a/custom-domain/dstack-ingress/scripts/entrypoint.sh +++ b/custom-domain/dstack-ingress/scripts/entrypoint.sh @@ -269,10 +269,13 @@ backend ${be_name} set_alias_record() { local domain="$1" if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then + # certbot validates the challenge at the base name even for a wildcard, + # so the _acme-challenge CNAME must use the base domain (strip "*."). + local base="${domain#\*.}" echo "[challenge-delegation] Not touching ${domain}'s own zone (token is scoped to the delegated zone)." echo "[challenge-delegation] Set these in your production zone yourself (static, one-time):" echo " ${domain} CNAME ${GATEWAY_DOMAIN}" - echo " _acme-challenge.${domain} CNAME _acme-challenge.${domain}.${ACME_CHALLENGE_ALIAS}" + echo " _acme-challenge.${base} CNAME _acme-challenge.${base}.${ACME_CHALLENGE_ALIAS}" return fi echo "Setting alias record for $domain" @@ -324,61 +327,101 @@ set_txt_record() { fi } -set_caa_record() { +# caa_domain_and_tag DOMAIN -> prints "caa_domain caa_tag" (strips a wildcard). +caa_domain_and_tag() { local domain="$1" - if [ "$SET_CAA" != "true" ]; then - echo "Skipping CAA record setup" - return + if [[ "$domain" == \*.* ]]; then + echo "${domain#\*.} issuewild" + else + echo "$domain issue" fi +} - local ACCOUNT_URI - local account_file +# In delegation mode we have NO token for the served domain's zone, so we cannot +# set the accounturi CAA ourselves. That CAA is the forge-prevention (only this +# enclave's ACME account may issue), so this path is deliberately NOT gated by +# SET_CAA and fails closed if the record is confirmed absent — otherwise anyone +# who can satisfy the delegated DNS-01 challenge could obtain a cert for the +# domain. Set ALLOW_MISSING_CAA=true to override (accept the risk). +verify_delegation_caa() { + local domain="$1" + local account_file account_uri caa_domain caa_tag caa_value resp status found if ! account_file=$(get_letsencrypt_account_file); then - echo "Warning: Cannot set CAA record - account file not found" - echo "This is not critical - certificates can still be issued without CAA records" + echo "ERROR: cannot read the Let's Encrypt account file to determine the required accounturi CAA" >&2 + caa_fail_or_allow "$domain" return fi + read -r caa_domain caa_tag < <(caa_domain_and_tag "$domain") + account_uri=$(jq -j '.uri' "$account_file") + caa_value="letsencrypt.org;validationmethods=dns-01;accounturi=$account_uri" - local caa_domain caa_tag - if [[ "$domain" == \*.* ]]; then - caa_domain="${domain#\*.}" - caa_tag="issuewild" - else - caa_domain="$domain" - caa_tag="issue" + echo "[challenge-delegation] Set this CAA in your production zone (static, one-time):" + echo " ${caa_domain} CAA 0 ${caa_tag} \"${caa_value}\"" + + # Verify via DNS-over-HTTPS (dig is not installed in the image; curl+jq are). + resp=$(curl -s --max-time 10 "https://dns.google/resolve?name=${caa_domain}&type=257" 2>/dev/null || true) + if [ -z "$resp" ]; then + echo "WARNING: could not reach dns.google to verify the CAA (network issue) — NOT confirmed; continuing" + return + fi + status=$(echo "$resp" | jq -r '.Status // empty' 2>/dev/null || true) + if [ "$status" != "0" ]; then + echo "WARNING: CAA DoH query for ${caa_domain} returned status ${status:-unknown} — NOT confirmed; continuing" + return + fi + # Literal (grep -F) match on the CAA data fields — the account URI contains + # '/' and '.', which must not be treated as regex. + found=$(echo "$resp" | jq -r '.Answer // [] | .[] | .data' 2>/dev/null | grep -F "accounturi=$account_uri" || true) + if [ -n "$found" ]; then + echo "[challenge-delegation] Verified: accounturi CAA is present for $caa_domain" + return + fi + echo "ERROR: the accounturi CAA is NOT present for $caa_domain." >&2 + echo "ERROR: without it, anyone who can satisfy the delegated DNS-01 challenge could obtain a" >&2 + echo "ERROR: publicly-trusted certificate for this domain (forged TLS termination)." >&2 + caa_fail_or_allow "$domain" +} + +caa_fail_or_allow() { + local domain="$1" + if [ "${ALLOW_MISSING_CAA:-false}" = "true" ]; then + echo "WARNING: ALLOW_MISSING_CAA=true — continuing without a verified accounturi CAA for $domain" + return 0 fi + echo "ERROR: refusing to continue without the accounturi CAA for $domain." >&2 + echo "ERROR: set the CAA record shown above, or ALLOW_MISSING_CAA=true to override." >&2 + exit 1 +} - ACCOUNT_URI=$(jq -j '.uri' "$account_file") +set_caa_record() { + local domain="$1" + # Delegation mode is handled separately and is NOT gated by SET_CAA. if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then - # Delegation mode: our token has no access to the served domain's zone, - # so we cannot set the accounturi CAA ourselves. This CAA is the - # forge-prevention (only the enclave's ACME account may issue), so we do - # NOT silently skip it — we print the exact record the operator must set, - # then best-effort verify it and warn loudly if it is missing. - local caa_value="letsencrypt.org;validationmethods=dns-01;accounturi=$ACCOUNT_URI" - echo "[challenge-delegation] Set this CAA in your production zone yourself (static):" - echo " ${caa_domain} CAA 0 ${caa_tag} \"${caa_value}\"" - # Verify via DNS-over-HTTPS (dig is not installed; curl is). - local caa_answer - caa_answer=$(curl -s --max-time 10 "https://dns.google/resolve?name=${caa_domain}&type=257" 2>/dev/null || true) - if echo "$caa_answer" | grep -q "accounturi=$ACCOUNT_URI"; then - echo "[challenge-delegation] Verified: accounturi CAA is present for $caa_domain" - else - echo "WARNING: accounturi CAA is NOT present for $caa_domain." - echo "WARNING: Without it, anyone who can satisfy the DNS-01 challenge could obtain a" - echo "WARNING: publicly-trusted certificate for this domain (forged TLS termination)." - echo "WARNING: Set the CAA record shown above before relying on this endpoint." - fi + verify_delegation_caa "$domain" + return + fi + + if [ "$SET_CAA" != "true" ]; then + echo "Skipping CAA record setup" + return + fi + + local account_file account_uri caa_domain caa_tag + if ! account_file=$(get_letsencrypt_account_file); then + echo "Warning: Cannot set CAA record - account file not found" + echo "This is not critical - certificates can still be issued without CAA records" return fi + read -r caa_domain caa_tag < <(caa_domain_and_tag "$domain") + account_uri=$(jq -j '.uri' "$account_file") - echo "Adding CAA record ($caa_tag) for $caa_domain, accounturi=$ACCOUNT_URI" + echo "Adding CAA record ($caa_tag) for $caa_domain, accounturi=$account_uri" dnsman.py set_caa \ --domain "$caa_domain" \ --caa-tag "$caa_tag" \ - --caa-value "letsencrypt.org;validationmethods=dns-01;accounturi=$ACCOUNT_URI" + --caa-value "letsencrypt.org;validationmethods=dns-01;accounturi=$account_uri" if [ $? -ne 0 ]; then echo "Warning: Failed to set CAA record for $domain"