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

@@ -27,6 +27,7 @@ from castle_core.generators.caddyfile import (
_DNS_TOKEN_ENV,
generate_caddyfile_from_registry,
)
from castle_core.generators.dns import reconcile_public_dns
from castle_core.generators.tunnel import (
generate_tunnel_config,
public_hostnames,
@@ -200,27 +201,33 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
"""Write the cloudflared ingress config from the registry's public services.
No public services (or no tunnel configured) → remove any stale config and
leave the tunnel down. Otherwise write it, list the hostnames that still need a
DNS route, and restart the tunnel service if it's running so it takes effect.
leave the tunnel down. Otherwise write it and restart the tunnel service if
it's running so it takes effect. Either way the public CNAMEs are reconciled to
match the current public set (create missing, delete removed) when a DNS token
is configured; without one, the manual route-once commands are surfaced instead.
"""
node = registry.node
config_path = SPECS_DIR / "cloudflared.yml"
content = generate_tunnel_config(registry)
if content is None:
if config_path.exists():
config_path.unlink()
messages.append("No public services — removed cloudflared config.")
# Still reconcile so any CNAMEs castle created earlier are cleaned up.
reconcile_public_dns(node.public_domain, node.tunnel_id, [], messages)
return
config_path.write_text(content)
hosts = public_hostnames(registry)
messages.append(f"Tunnel config written: {config_path} ({len(hosts)} public)")
# DNS is not automatic: each public host needs a CNAME the tunnel. Surface the
# exact commands rather than silently assuming they're routed.
tid = registry.node.tunnel_id
for h in hosts:
messages.append(
f" public: {h} (route once: cloudflared tunnel route dns {tid} {h})"
)
# Reconcile the public CNAMEs to the tunnel. Falls back to surfacing the manual
# `cloudflared tunnel route dns` commands when no DNS token is configured.
if not reconcile_public_dns(node.public_domain, node.tunnel_id, hosts, messages):
for h in hosts:
messages.append(
f" public: {h} "
f"(route once: cloudflared tunnel route dns {node.tunnel_id} {h})"
)
tunnel_unit = unit_name(_TUNNEL_NAME)
active = subprocess.run(

View File

@@ -0,0 +1,123 @@
"""Reconcile public DNS (Cloudflare CNAMEs) for tunnel-exposed services.
Castle owns the CNAMEs in the public zone that point at its Cloudflare tunnel:
on deploy it creates one per public service and deletes any that point at this
tunnel but no longer correspond to a public service. It **only ever touches
records whose content is `<tunnel_id>.cfargotunnel.com`** — never other records in
the zone — so a hand-managed A/CNAME in the same zone is safe.
Needs a Cloudflare API token with **DNS:Edit** on the public zone (Cloudflare's
"Edit zone DNS" template — that single permission both resolves the zone by name
and edits records; no separate Zone:Read is needed), stored at
`~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN`. Absent → this is a no-op and the
caller falls back to surfacing the manual `cloudflared tunnel route dns` hints.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from castle_core.config import SECRETS_DIR
_API = "https://api.cloudflare.com/client/v4"
# Secret holding a Cloudflare token scoped to the PUBLIC zone (Zone:DNS:Edit).
# Distinct from CLOUDFLARE_API_TOKEN (ACME, the internal civil zone) because the
# public zone is typically a separate zone/account.
PUBLIC_DNS_TOKEN = "CLOUDFLARE_PUBLIC_DNS_TOKEN"
def public_dns_token() -> str | None:
"""The public-zone DNS token from secrets, or None if not configured."""
path = SECRETS_DIR / PUBLIC_DNS_TOKEN
if path.exists():
return path.read_text().strip() or None
return None
def _api(token: str, method: str, path: str, body: dict | None = None) -> dict:
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{_API}{path}", data=data, method=method)
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310 (fixed API host)
return json.loads(resp.read())
def reconcile_public_dns(
public_domain: str | None,
tunnel_id: str | None,
desired_hosts: list[str],
messages: list[str],
token: str | None = None,
) -> bool:
"""Make the public zone's tunnel CNAMEs exactly `desired_hosts`.
Creates missing CNAMEs (proxied → the tunnel) and deletes castle-managed ones
(content == `<tunnel_id>.cfargotunnel.com`) not in `desired_hosts`. Never
touches records pointing elsewhere.
Returns True if reconciliation was attempted (a token was configured) — the
caller then suppresses the manual route hints — or False if skipped (no token /
no tunnel / no public domain), so the caller can fall back to those hints.
"""
token = token or public_dns_token()
if not (token and public_domain and tunnel_id):
return False
target = f"{tunnel_id}.cfargotunnel.com"
try:
zres = (_api(token, "GET", f"/zones?name={public_domain}").get("result")) or []
if not zres:
messages.append(
f"Warning: DNS token can't see zone '{public_domain}' — public "
"CNAMEs not reconciled. The token needs DNS:Edit (Cloudflare's "
f"'Edit zone DNS' template) scoped to {public_domain}."
)
return False
zone_id = zres[0]["id"]
# Castle-managed set = existing CNAMEs whose content is our tunnel.
recs = _api(
token, "GET", f"/zones/{zone_id}/dns_records?type=CNAME&per_page=100"
).get("result") or []
managed = {r["name"]: r["id"] for r in recs if r.get("content") == target}
desired = set(desired_hosts)
created = sorted(desired - set(managed))
removed = sorted(set(managed) - desired)
for host in created:
_api(
token,
"POST",
f"/zones/{zone_id}/dns_records",
{"type": "CNAME", "name": host, "content": target, "proxied": True},
)
for host in removed:
_api(token, "DELETE", f"/zones/{zone_id}/dns_records/{managed[host]}")
if created or removed:
parts = []
if created:
parts.append(f"+{len(created)} ({', '.join(created)})")
if removed:
parts.append(f"-{len(removed)} ({', '.join(removed)})")
messages.append(f"Public DNS reconciled: {' '.join(parts)}")
else:
messages.append(f"Public DNS up to date ({len(desired)} CNAME(s)).")
return True
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")[:200]
hint = ""
if e.code == 403:
# Reads can succeed while writes 403 — the token has DNS:Read but not
# DNS:Edit. Point at the fix rather than the raw body.
hint = (
" → the token needs DNS:Edit (write), not just read — use "
"Cloudflare's 'Edit zone DNS' template."
)
messages.append(f"Warning: public DNS reconcile failed (HTTP {e.code}): {body}{hint}")
return True # token was present; don't also print stale manual hints
except Exception as e: # noqa: BLE001 — DNS is best-effort; never fail a deploy
messages.append(f"Warning: public DNS reconcile failed: {e}")
return True