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(