deploy: auto-reconcile public DNS CNAMEs via Cloudflare

castle deploy now manages the public zone's tunnel CNAMEs to match the current
set of public: true services — creating missing records and deleting removed
ones — instead of only printing manual 'cloudflared tunnel route dns' hints.

New core/generators/dns.py reconciles via the Cloudflare API (stdlib only). It
only ever touches records whose content is <tunnel_id>.cfargotunnel.com, so
hand-managed records in the same zone are never altered. deploy._write_tunnel_config
calls it in both branches (delete of the last public service cleans up its CNAME).
Without a token it's a no-op and the manual route-once hints are surfaced as before.

Token: ~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN, a single DNS:Edit permission
scoped to the public zone (the 'Edit zone DNS' template) — confirmed sufficient:
that one permission resolves the zone by name and edits records, no separate
Zone:Read needed. Separate from CLOUDFLARE_API_TOKEN (ACME, internal zone).

castle doctor gains a read-only probe that WARNs when the token is absent (CNAMEs
stay manual) and FAILs when it can't reach the zone/records.
This commit is contained in:
2026-07-02 11:02:34 -07:00
parent 2adc073863
commit 9028d15ec6
4 changed files with 229 additions and 12 deletions

View File

@@ -350,12 +350,72 @@ def _check_tls_exposure(config) -> list[Check]:
hint="castle service start castle-tunnel",
)
)
checks.append(_check_public_dns(config))
if not checks:
checks.append(Check(OK, "off mode — no TLS/exposure to check", detail="localhost only"))
return checks
def _check_public_dns(config) -> Check:
"""Whether Castle can manage the public CNAMEs itself.
Read-only: confirms the token exists and can reach the public zone + its DNS
records. The required permission is a single DNS:Edit (Cloudflare's 'Edit zone
DNS' template), which also grants the zone lookup — so a correctly-scoped token
passes this probe. Write itself isn't exercised (that would mutate); a
DNS:Read-only token would false-pass here but `castle deploy` then surfaces a
403 with the fix. Absent token → WARN (CNAMEs stay manual), not a failure.
"""
import urllib.error
from castle_core.generators.dns import PUBLIC_DNS_TOKEN, _api, public_dns_token
gw = config.gateway
token = public_dns_token()
if not token:
return Check(
WARN,
"public DNS not automated",
detail=f"no {PUBLIC_DNS_TOKEN} secret — CNAMEs are manual",
hint=(
f"add a Cloudflare token with DNS:Edit on {gw.public_domain} "
f"('Edit zone DNS' template) → ~/.castle/secrets/{PUBLIC_DNS_TOKEN} "
"(else route each host by hand)"
),
)
try:
zres = (_api(token, "GET", f"/zones?name={gw.public_domain}").get("result")) or []
if not zres:
return Check(
FAIL,
"public DNS token can't see the zone",
detail=f"{gw.public_domain} not visible",
hint=(
f"token needs DNS:Edit scoped to {gw.public_domain}, in that "
"zone's account ('Edit zone DNS' template)"
),
)
zid = zres[0]["id"]
_api(token, "GET", f"/zones/{zid}/dns_records?type=CNAME&per_page=1")
except urllib.error.HTTPError as e:
if e.code == 403:
return Check(
FAIL,
"public DNS token lacks DNS access",
detail="zone readable but DNS records forbidden (403)",
hint="add DNS:Edit to the token (Cloudflare 'Edit zone DNS' template)",
)
return Check(WARN, "public DNS token check inconclusive", detail=f"HTTP {e.code}")
except Exception as e: # noqa: BLE001 — never let a network hiccup fail doctor
return Check(WARN, "public DNS token check inconclusive", detail=str(e)[:60])
return Check(
OK,
"public DNS token valid",
detail=f"can reach {gw.public_domain} + its records",
)
def _check_privileged_ports() -> Check:
try:
val = int(