Skip to content

Commit 42a7bbd

Browse files
committed
fix(dstack-ingress): carry the #104 challenge delegation through the restructure
#104 added ACME DNS-01 challenge delegation to entrypoint.sh. This branch moves that file's logic into dns01.sh, so a plain rebase drops the shell half of it: set_alias_record / set_txt_record / set_caa_record kept this branch's versions and the delegation branches went with them. Port them into dns01.sh, which is where the delegation belongs anyway -- it is a dns-01 capability, and entrypoint.sh no longer holds mode-specific logic. Everything else from #104 survived the rebase untouched: the hook script, unset_txt_record, the dnsman.py action, the certman.py branch and the README section. Two of the ports are deliberate substitutions, not copies: - The CAA helpers reuse this branch's caa_tag_for() and "${domain#\*.}" instead of re-introducing caa_domain_and_tag(). Behaviour is identical for both wildcard and plain names; the second helper would only be a second place to keep them in sync. - The delegated TXT instruction prints $(txt_record_value) rather than "$APP_ID:$PORT". APP_ID is an optional override, so the original printed ":443" whenever it was unset -- which is the common case. Printing the same function that writes the record in non-delegation mode also stops the instruction drifting from what the gateway actually expects. Two more changes are integration fixes: bugs neither PR has alone, only the combination. - The delegation branch built its certbot command with CERTBOT_STAGING, which this branch renamed to ACME_STAGING. ACME_STAGING=true plus delegation would have issued *production* certificates. - It also passed --email unconditionally, which this branch made optional; with no contact address configured that is `--email ""`. It now uses the same optional-contact handling as the plugin path. Verified against a delegation run: the served zone is untouched, all four static records print with correct values, the certbot command carries --manual with both hooks plus --staging and --register-unsafely-without-email, CAA verification fails closed with exit 1, and ALLOW_MISSING_CAA=true overrides it.
1 parent 09b7d8e commit 42a7bbd

2 files changed

Lines changed: 88 additions & 2 deletions

File tree

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,12 +293,17 @@ def _build_certbot_command(self, action: str, domain: str, email: str) -> List[s
293293
f"--manual-auth-hook={hook} auth",
294294
f"--manual-cleanup-hook={hook} cleanup",
295295
"--agree-tos", "--no-eff-email",
296-
"--email", email, "-d", domain,
297296
])
297+
# Same optional-contact handling as the plugin path below.
298+
if email:
299+
base_cmd.extend(["--email", email])
300+
else:
301+
base_cmd.append("--register-unsafely-without-email")
302+
base_cmd.extend(["-d", domain])
298303
# For `renew`, certbot reuses the authenticator + hooks saved in the
299304
# renewal config from the initial `certonly`, so we don't re-specify
300305
# them here (and must not fall back to the DNS plugin).
301-
if os.environ.get("CERTBOT_STAGING", "false") == "true":
306+
if staging_enabled():
302307
base_cmd.append("--staging")
303308
masked = [a if not (i > 0 and base_cmd[i - 1] == "--email") else "<email>"
304309
for i, a in enumerate(base_cmd)]

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ EOF
7676

7777
set_alias_record() {
7878
local domain="$1"
79+
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
80+
# certbot validates the challenge at the base name even for a wildcard,
81+
# so the _acme-challenge CNAME must use the base domain (strip "*.").
82+
local base="${domain#\*.}"
83+
echo "[challenge-delegation] Not touching ${domain}'s own zone (token is scoped to the delegated zone)."
84+
echo "[challenge-delegation] Set these in your production zone yourself (static, one-time):"
85+
echo " ${domain} CNAME ${GATEWAY_DOMAIN}"
86+
echo " _acme-challenge.${base} CNAME _acme-challenge.${base}.${ACME_CHALLENGE_ALIAS}"
87+
return
88+
fi
7989
echo "Setting alias record for $domain"
8090
dnsman.py set_alias \
8191
--domain "$domain" \
@@ -95,6 +105,12 @@ set_txt_record() {
95105
local txt_domain
96106
txt_domain=$(txt_record_name "$domain")
97107

108+
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
109+
echo "[challenge-delegation] Set this in your production zone yourself (static, one-time):"
110+
echo " ${txt_domain} TXT \"$(txt_record_value)\""
111+
return
112+
fi
113+
98114
dnsman.py set_txt \
99115
--domain "$txt_domain" \
100116
--content "$(txt_record_value)"
@@ -105,8 +121,73 @@ set_txt_record() {
105121
fi
106122
}
107123

124+
# In delegation mode we have NO token for the served domain's zone, so we cannot
125+
# set the accounturi CAA ourselves. That CAA is the forge-prevention (only this
126+
# enclave's ACME account may issue), so this path is deliberately NOT gated by
127+
# SET_CAA and fails closed if the record is confirmed absent -- otherwise anyone
128+
# who can satisfy the delegated DNS-01 challenge could obtain a cert for the
129+
# domain. Set ALLOW_MISSING_CAA=true to override (accept the risk).
130+
verify_delegation_caa() {
131+
local domain="$1"
132+
local account_file account_uri caa_domain caa_tag caa_value resp status found
133+
134+
if ! account_file=$(get_letsencrypt_account_file); then
135+
echo "ERROR: cannot read the Let's Encrypt account file to determine the required accounturi CAA" >&2
136+
caa_fail_or_allow "$domain"
137+
return
138+
fi
139+
caa_domain="${domain#\*.}"
140+
caa_tag=$(caa_tag_for "$domain")
141+
account_uri=$(jq -j '.uri' "$account_file")
142+
caa_value="letsencrypt.org;validationmethods=dns-01;accounturi=$account_uri"
143+
144+
echo "[challenge-delegation] Set this CAA in your production zone (static, one-time):"
145+
echo " ${caa_domain} CAA 0 ${caa_tag} \"${caa_value}\""
146+
147+
# Verify via DNS-over-HTTPS (dig is not installed in the image; curl+jq are).
148+
resp=$(curl -s --max-time 10 "https://dns.google/resolve?name=${caa_domain}&type=257" 2>/dev/null || true)
149+
if [ -z "$resp" ]; then
150+
echo "WARNING: could not reach dns.google to verify the CAA (network issue) — NOT confirmed; continuing"
151+
return
152+
fi
153+
status=$(echo "$resp" | jq -r '.Status // empty' 2>/dev/null || true)
154+
if [ "$status" != "0" ]; then
155+
echo "WARNING: CAA DoH query for ${caa_domain} returned status ${status:-unknown} — NOT confirmed; continuing"
156+
return
157+
fi
158+
# Literal (grep -F) match on the CAA data fields — the account URI contains
159+
# '/' and '.', which must not be treated as regex.
160+
found=$(echo "$resp" | jq -r '.Answer // [] | .[] | .data' 2>/dev/null | grep -F "accounturi=$account_uri" || true)
161+
if [ -n "$found" ]; then
162+
echo "[challenge-delegation] Verified: accounturi CAA is present for $caa_domain"
163+
return
164+
fi
165+
echo "ERROR: the accounturi CAA is NOT present for $caa_domain." >&2
166+
echo "ERROR: without it, anyone who can satisfy the delegated DNS-01 challenge could obtain a" >&2
167+
echo "ERROR: publicly-trusted certificate for this domain (forged TLS termination)." >&2
168+
caa_fail_or_allow "$domain"
169+
}
170+
171+
caa_fail_or_allow() {
172+
local domain="$1"
173+
if [ "${ALLOW_MISSING_CAA:-false}" = "true" ]; then
174+
echo "WARNING: ALLOW_MISSING_CAA=true — continuing without a verified accounturi CAA for $domain"
175+
return 0
176+
fi
177+
echo "ERROR: refusing to continue without the accounturi CAA for $domain." >&2
178+
echo "ERROR: set the CAA record shown above, or ALLOW_MISSING_CAA=true to override." >&2
179+
exit 1
180+
}
181+
108182
set_caa_record() {
109183
local domain="$1"
184+
185+
# Delegation mode is handled separately and is NOT gated by SET_CAA.
186+
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
187+
verify_delegation_caa "$domain"
188+
return
189+
fi
190+
110191
if [ "$SET_CAA" != "true" ]; then
111192
echo "Skipping CAA record setup"
112193
return

0 commit comments

Comments
 (0)