Skip to content

Commit f483f73

Browse files
committed
refactor(dstack-ingress): drop the old delegation variable, pick the gateway
record type per provider DELEGATION_ZONE is now the only name; ACME_CHALLENGE_ALIAS is gone rather than deprecated. The feature is days old and the published image does not carry it, so there is nobody to keep compatible with, and one name is worth more than two. The gateway pointer's record type now follows the provider instead of defaulting to CNAME everywhere. A CNAME is better -- DNS follows a gateway that moves -- but the same name has to carry the CAA too, which RFC 1034 forbids. Cloudflare allows the pair and Let's Encrypt honours the CAA there; providers that enforce the standard reject it, and would have failed at the CAA write with the old default. Only Cloudflare has been tested, so only Cloudflare gets `cname` and everything else gets `a`, which is legal everywhere. Operators who know their provider allows the pair can set DELEGATION_GATEWAY_RECORD=cname. dnsman.py grew a `provider` action so the shell can ask. Verified that wildcards work end to end, which was not obvious: a wildcard CNAME copies its target verbatim rather than expanding it (RFC 4592), so every subdomain lands on one concrete delegated name. `*.wc.kvin.wang` issued against the staging CA, and a.wc / xyz.wc both resolve through that name to the gateway.
1 parent 67af827 commit f483f73

6 files changed

Lines changed: 59 additions & 34 deletions

File tree

custom-domain/dstack-ingress/README.md

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ environment:
193193
| `ALPN` | | TLS ALPN protocols (e.g. `h2,http/1.1`). Only set if backends support h2c |
194194
| `DELEGATION_ZONE` | | Zone this container writes into, so the DNS token needs no access to the served domain's own zone (see below). `ACME_CHALLENGE_ALIAS` is the original name and still works |
195195
| `ACME_CHALLENGE_PROPAGATION_SECONDS` | `120` | Wait after writing the delegated challenge TXT before validation. Must outlast the record TTL (60s), or a resolver still serving the previous attempt's value fails validation. Keep well under ~250s — certbot is killed after a 300s per-run timeout |
196-
| `DELEGATION_GATEWAY_RECORD` | `cname` | How the delegated name points at the gateway: `cname` (follows a gateway that moves) or `a` (for providers that refuse a CAA beside a CNAME) |
196+
| `DELEGATION_GATEWAY_RECORD` | per provider | How the delegated name points at the gateway: `cname` (Cloudflare default; follows a gateway that moves) or `a` (default elsewhere, for providers that refuse a CAA beside a CNAME) |
197197
| `ALLOW_MISSING_CAA` | `false` | In delegation mode, treat an unconfirmed `accounturi` CAA as a warning instead of a blocker. Default fails closed (see below) |
198198

199199
For DNS provider credentials, see [DNS_PROVIDERS.md](DNS_PROVIDERS.md).
@@ -227,18 +227,24 @@ _dstack-app-address.svc.example.com.deleg.example.net TXT "<app-id>:<port>"
227227
_acme-challenge.svc.example.com.deleg.example.net TXT <challenge> (transient)
228228
```
229229

230-
A CNAME and a CAA on the same name is a Cloudflare allowance, not a standard
231-
one — RFC 1034 says a CNAME excludes every other type at its name. It works
232-
because the resolver stops at the first name in the chain that carries a CAA,
233-
and Let's Encrypt honours what it finds there: with a CAA that forbids it,
234-
the staging CA refuses with `While processing CAA for svc.example.com: CAA
235-
record for svc.example.com prevents issuance`.
236-
237-
Providers that enforce the standard will reject the CAA beside the CNAME. Set
238-
`DELEGATION_GATEWAY_RECORD=a` there: the container resolves `GATEWAY_DOMAIN`
239-
itself and publishes an address record, which can carry the CAA. The cost is
240-
that a gateway which moves is then only picked up on the next pass, where a
241-
CNAME follows it immediately.
230+
The gateway pointer and the CAA share a name, which RFC 1034 does not allow —
231+
a CNAME excludes every other type at its name. **Cloudflare allows the pair
232+
anyway**, and Let's Encrypt honours the CAA it finds there: with a CAA that
233+
forbids it, the staging CA refuses with `While processing CAA for
234+
svc.example.com: CAA record for svc.example.com prevents issuance`.
235+
236+
Providers that enforce the standard will reject the CAA beside the CNAME, so
237+
they get an address record instead — `A` and `CAA` coexist legally. The default
238+
follows the provider:
239+
240+
| Provider | Gateway pointer | Gateway that moves |
241+
|---|---|---|
242+
| `cloudflare` | `CNAME` to `GATEWAY_DOMAIN` | followed by DNS, immediately |
243+
| everything else | `A`, re-resolved each pass | picked up within `RENEW_INTERVAL` |
244+
245+
`DELEGATION_GATEWAY_RECORD=cname\|a` overrides it. Only Cloudflare has been
246+
tested; if another provider accepts a CAA beside a CNAME, setting `cname` there
247+
gets the better behaviour.
242248

243249
A wildcard and its base name share a delegated target: both `*.example.com` and
244250
`example.com` map to `example.com.<delegation-zone>`. One deployment serving both

custom-domain/dstack-ingress/scripts/acme-dns-alias-hook.sh

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,7 @@ set -euo pipefail
1818

1919
action="${1:-}"
2020

21-
# DELEGATION_ZONE is the current name; ACME_CHALLENGE_ALIAS is the original one
22-
# and still works. certbot runs this as a fresh process, so resolve both here
23-
# rather than relying on the caller.
24-
zone="${DELEGATION_ZONE:-${ACME_CHALLENGE_ALIAS:-}}"
21+
zone="${DELEGATION_ZONE:-}"
2522
if [ -z "$zone" ]; then
2623
echo "acme-dns-alias-hook: DELEGATION_ZONE is not set" >&2
2724
exit 1

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ def _build_certbot_command(self, action: str, domain: str, email: str) -> List[s
283283
# DNS-01 challenge in a delegated zone via a manual hook instead of the
284284
# provider's certbot plugin, so our DNS token never needs access to the
285285
# served domain's own zone. See scripts/acme-dns-alias-hook.sh.
286-
if os.environ.get("DELEGATION_ZONE", "").strip() or os.environ.get("ACME_CHALLENGE_ALIAS", "").strip():
286+
if os.environ.get("DELEGATION_ZONE", "").strip():
287287
hook = "/scripts/acme-dns-alias-hook.sh"
288288
base_cmd = certbot_cmd + [action, "--non-interactive", "-v"]
289289
if action == "certonly":

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

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,8 @@ source /scripts/functions.sh
1111
source /scripts/haproxy-lib.sh
1212
source /scripts/evidence-lib.sh
1313

14-
# The zone this container writes into. ACME_CHALLENGE_ALIAS was the original
15-
# name, from when the challenge was the only thing delegated; it now decides the
16-
# target of every record, so DELEGATION_ZONE is what it is called. The old name
17-
# still works.
18-
DELEGATION_ZONE=${DELEGATION_ZONE:-${DELEGATION_ZONE:-}}
14+
# The zone this container writes into, and the switch that turns delegation on.
15+
DELEGATION_ZONE=${DELEGATION_ZONE:-}
1916
export DELEGATION_ZONE
2017

2118
# ACME_EMAIL / ACME_STAGING are normalised by entrypoint.sh and exported. The
@@ -119,13 +116,29 @@ set_txt_record() {
119116
# never needs touching again -- not when the app id changes with the compose,
120117
# not when the ACME account is recreated, not when the gateway moves.
121118
#
122-
# The gateway pointer stays a CNAME, so a gateway that moves is followed
123-
# automatically. That the same name can also carry the CAA is a Cloudflare
124-
# allowance rather than a standard one -- RFC 1034 says a CNAME excludes every
125-
# other type at its name -- but Let's Encrypt honours it: the resolver stops at
126-
# the first name in the chain that has a CAA, and the staging CA refuses to
127-
# issue when that record forbids it. Providers that enforce the standard will
128-
# reject the CAA here; see DELEGATION_GATEWAY_RECORD.
119+
# How the delegated name points at the gateway.
120+
#
121+
# A CNAME is better -- a gateway that moves is followed automatically -- but the
122+
# same name also has to carry the CAA, and RFC 1034 says a CNAME excludes every
123+
# other type at its name. Cloudflare allows the pair anyway, and Let's Encrypt
124+
# honours the CAA it finds there (verified against the staging CA). Providers
125+
# that enforce the standard reject it, so they get an address record instead:
126+
# A and CAA coexist legally, at the cost of re-resolving GATEWAY_DOMAIN once a
127+
# pass rather than letting DNS follow it.
128+
#
129+
# Default per provider, since only Cloudflare is known to allow the pair.
130+
# DELEGATION_GATEWAY_RECORD overrides it either way.
131+
delegation_gateway_record() {
132+
if [ -n "${DELEGATION_GATEWAY_RECORD:-}" ]; then
133+
echo "$DELEGATION_GATEWAY_RECORD"
134+
return
135+
fi
136+
case "$(dnsman.py provider 2>/dev/null)" in
137+
cloudflare) echo cname ;;
138+
*) echo a ;;
139+
esac
140+
}
141+
129142
delegated_name() {
130143
local domain="${1#\*.}"
131144
echo "${domain}.${DELEGATION_ZONE}"
@@ -135,7 +148,7 @@ delegation_publish() {
135148
local domain="$1" target txt_name
136149
target=$(delegated_name "$domain")
137150

138-
case "${DELEGATION_GATEWAY_RECORD:-cname}" in
151+
case "$(delegation_gateway_record)" in
139152
cname)
140153
dnsman.py set_alias --domain "$target" --content "$GATEWAY_DOMAIN" || return 1
141154
;;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ def main() -> int:
470470
parser.add_argument("--challenge", default="tls-alpn-01")
471471
parser.add_argument(
472472
"--challenge-alias",
473-
default=os.environ.get("DELEGATION_ZONE", os.environ.get("ACME_CHALLENGE_ALIAS", "")),
473+
default=os.environ.get("DELEGATION_ZONE", ""),
474474
help="delegation zone for the _acme-challenge CNAME (dns-01 delegation)",
475475
)
476476
parser.add_argument(

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ def main():
1414
)
1515
parser.add_argument(
1616
"action",
17-
choices=["set_cname", "set_alias", "set_a", "set_txt", "unset_txt", "set_caa"],
17+
choices=["provider", "set_cname", "set_alias", "set_a", "set_txt", "unset_txt", "set_caa"],
1818
help="Action to perform",
1919
)
20-
parser.add_argument("--domain", required=True, help="Domain name")
20+
parser.add_argument("--domain", default="", help="Domain name")
2121
parser.add_argument("--provider", help="DNS provider (cloudflare, linode)")
2222
# Zone ID is now handled internally by each provider
2323
parser.add_argument(
@@ -30,6 +30,15 @@ def main():
3030

3131
args = parser.parse_args()
3232

33+
if args.action == "provider":
34+
# Which provider is in use, so callers can adapt to what it allows.
35+
print(DNSProviderFactory._detect_provider_type())
36+
return
37+
38+
if not args.domain:
39+
print("Error: --domain is required", file=sys.stderr)
40+
sys.exit(1)
41+
3342
try:
3443
# Create DNS provider instance
3544
provider = DNSProviderFactory.create_provider(args.provider)

0 commit comments

Comments
 (0)