Skip to content

Commit 0a91c91

Browse files
committed
fix(dstack-ingress): make DELEGATION_GATEWAY_RECORD actually switch, and mean
what it says Two problems with the gateway pointer, both from review. Switching the setting did not switch the record. set_a_record only inspects A records and set_cname_record only CNAMEs, so each leaves the other type in place -- and DNS forbids a CNAME beside anything else, so the new record then fails to create. Publishing now clears the other type first, and switching either way was checked against Cloudflare: CNAME to A removes the CNAME and leaves only the address, and back again. `cname` did not guarantee a CNAME. The branch called set_alias, and "alias" is provider-defined: Linode overrides it to create an A record deliberately, since Linode refuses a CAA beside a CNAME. So on Linode the setting said cname and produced an address record. The branch now calls set_cname explicitly, which no provider redefines. unset_txt_record generalises to unset_records(name, type) for the clearing step; the TXT wrapper stays for the challenge hook. Also spell out the rename in the README rather than leaving it to the changelog. ACME_CHALLENGE_ALIAS is not accepted, and a deployment that keeps it does not fail loudly -- it silently leaves delegation mode and starts writing to the served domain's zone, which is the one thing delegation exists to prevent.
1 parent 677599f commit 0a91c91

4 files changed

Lines changed: 48 additions & 13 deletions

File tree

custom-domain/dstack-ingress/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,12 @@ served name lives under a shared production zone (e.g. `svc.example.com` under
205205
`example.com`), that token can edit every record in the zone, which may be more
206206
privilege than you want.
207207

208+
> **Renamed.** This was `ACME_CHALLENGE_ALIAS`, from when the challenge was the
209+
> only thing delegated. The old name is **not** accepted — a deployment still
210+
> setting it silently leaves delegation mode and starts writing to the served
211+
> domain's zone, which is what delegation exists to avoid. Rename it when
212+
> upgrading.
213+
208214
Set `DELEGATION_ZONE=<delegation-zone>` to move every name this deployment
209215
needs into a zone your token controls. You create three CNAMEs in the served
210216
domain's zone **once, before deploying**, and then never touch DNS again — not

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,17 @@ delegation_publish() {
148148
local domain="$1" target txt_name
149149
target=$(delegated_name "$domain")
150150

151+
# The set_* helpers only look at the type they are about to write, so a
152+
# leftover record of the other type stays and blocks the new one -- DNS
153+
# forbids a CNAME beside anything else. Clear it first, so switching
154+
# DELEGATION_GATEWAY_RECORD either way actually takes effect.
151155
case "$(delegation_gateway_record)" in
152156
cname)
153-
dnsman.py set_alias --domain "$target" --content "$GATEWAY_DOMAIN" || return 1
157+
dnsman.py unset --domain "$target" --type A >/dev/null 2>&1 || true
158+
# set_cname, not set_alias: some providers redefine "alias" to mean
159+
# an address record (Linode does, precisely to dodge the CAA/CNAME
160+
# conflict), and this branch has to mean what it says.
161+
dnsman.py set_cname --domain "$target" --content "$GATEWAY_DOMAIN" || return 1
154162
;;
155163
a)
156164
# For providers that refuse a CAA beside a CNAME. Costs the
@@ -163,6 +171,7 @@ delegation_publish() {
163171
return 1
164172
fi
165173
addr=$(echo "$addrs" | head -1)
174+
dnsman.py unset --domain "$target" --type CNAME >/dev/null 2>&1 || true
166175
dnsman.py set_a --domain "$target" --content "$addr" || return 1
167176
;;
168177
*)

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

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -257,40 +257,48 @@ def zone_is_resolvable(self, name: str) -> bool:
257257
"""
258258
return True
259259

260-
def unset_txt_record(self, name: str) -> bool:
261-
"""Delete all TXT records for a name.
260+
def unset_records(self, name: str, record_type: RecordType) -> bool:
261+
"""Delete every record of one type at a name.
262262
263-
Used to clean up ACME DNS-01 challenge records (e.g. from a certbot
264-
--manual cleanup hook). Missing records are treated as success.
263+
Used to clean up an ACME challenge, and to clear a record type before
264+
publishing a different one at the same name -- the set_* helpers only
265+
look at the type they are about to write, so a leftover CNAME would
266+
otherwise block an A record and vice versa.
265267
266268
Args:
267269
name: The record name
270+
record_type: Which type to remove
268271
269272
Returns:
270273
True if all matching records were removed (or none existed).
271274
"""
272-
# get_dns_records returns [] both for "this name has no TXT records" and
273-
# for "the zone could not be resolved", so an empty list on its own would
274-
# report a failed cleanup as a success. Ask whether the zone resolves
275-
# before believing the emptiness.
276-
records = self.get_dns_records(name, RecordType.TXT)
275+
# get_dns_records returns [] both for "this name has no such records"
276+
# and for "the zone could not be resolved", so an empty list on its own
277+
# would report a failed cleanup as a success. Ask whether the zone
278+
# resolves before believing the emptiness.
279+
records = self.get_dns_records(name, record_type)
277280
if not records and not self.zone_is_resolvable(name):
278281
print(
279-
f"Error: cannot delete TXT records for {name}: zone lookup failed",
282+
f"Error: cannot delete {record_type.value} records for {name}: "
283+
f"zone lookup failed",
280284
file=sys.stderr,
281285
)
282286
return False
283287

284288
ok = True
285289
for record in records:
286290
if not record.id:
287-
print(f"Warning: TXT record for {name} has no id; cannot delete")
291+
print(f"Warning: {record_type.value} record for {name} has no id; cannot delete")
288292
ok = False
289293
continue
290294
if not self.delete_dns_record(record.id, name):
291295
ok = False
292296
return ok
293297

298+
def unset_txt_record(self, name: str) -> bool:
299+
"""Delete all TXT records for a name."""
300+
return self.unset_records(name, RecordType.TXT)
301+
294302
def set_caa_record(
295303
self,
296304
name: str,

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env python3
22

33
from dns_providers import DNSProviderFactory
4+
from dns_providers.base import RecordType
45
import argparse
56
import os
67
import sys
@@ -14,10 +15,11 @@ def main():
1415
)
1516
parser.add_argument(
1617
"action",
17-
choices=["provider", "set_cname", "set_alias", "set_a", "set_txt", "unset_txt", "set_caa"],
18+
choices=["provider", "set_cname", "set_alias", "set_a", "set_txt", "unset_txt", "unset", "set_caa"],
1819
help="Action to perform",
1920
)
2021
parser.add_argument("--domain", default="", help="Domain name")
22+
parser.add_argument("--type", default="", help="Record type, for unset")
2123
parser.add_argument("--provider", help="DNS provider (cloudflare, linode)")
2224
# Zone ID is now handled internally by each provider
2325
parser.add_argument(
@@ -83,6 +85,16 @@ def main():
8385
sys.exit(1)
8486
print(f"Successfully set A record for {args.domain}")
8587

88+
elif args.action == "unset":
89+
if not args.type:
90+
print("Error: --type is required for unset", file=sys.stderr)
91+
sys.exit(1)
92+
success = provider.unset_records(args.domain, RecordType[args.type.upper()])
93+
if not success:
94+
print(f"Failed to unset {args.type} records for {args.domain}", file=sys.stderr)
95+
sys.exit(1)
96+
print(f"Successfully unset {args.type} records for {args.domain}")
97+
8698
elif args.action == "unset_txt":
8799
success = provider.unset_txt_record(args.domain)
88100
if not success:

0 commit comments

Comments
 (0)