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(

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,26 +201,32 @@ 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
# 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} (route once: cloudflared tunnel route dns {tid} {h})"
f" public: {h} "
f"(route once: cloudflared tunnel route dns {node.tunnel_id} {h})"
)
tunnel_unit = unit_name(_TUNNEL_NAME)

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

View File

@@ -89,12 +89,10 @@ proxy: true # required — the service must be routed
public: true # also expose at <name>.pub.payne.io via the tunnel
```
Then deploy and route DNS (a CNAME `<name>.pub.payne.io → <tunnel>.cfargotunnel.com`,
created once per public host — `castle deploy` prints the exact command):
Then just deploy:
```bash
castle deploy
cloudflared tunnel route dns <tunnel-id> <name>.pub.payne.io
```
`castle deploy` regenerates `~/.castle/artifacts/specs/cloudflared.yml` from the
@@ -102,6 +100,35 @@ current set of public services and restarts `castle-tunnel`. Flip `public` back
`false` (or remove it) and redeploy to un-expose — the hostname drops out of the
ingress immediately.
### DNS: automatic (with a token) or manual
Each public host needs a CNAME `<name>.pub.payne.io → <tunnel>.cfargotunnel.com`.
Castle can manage these for you. Create a Cloudflare API token with a single
**`DNS:Edit`** permission scoped to the **public zone** — this is exactly
Cloudflare's built-in *"Edit zone DNS"* template. That one permission is
sufficient: it resolves the zone by name *and* creates/deletes records (no
separate `Zone:Read` is needed — verified against `domain0.org`). An account-owned
token (`cfat_…`) works (that's what this was confirmed with). Drop it into the
secret store:
```bash
printf %s "<token>" > ~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN
chmod 600 ~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN
```
With it present, every `castle deploy` **reconciles** the public CNAMEs against the
current public set — creating missing ones and deleting removed ones — and prints a
one-line summary. It only ever touches records pointing at *this* tunnel
(`<tunnel_id>.cfargotunnel.com`), so hand-managed records in the same zone are safe.
This token is separate from `CLOUDFLARE_API_TOKEN` (which is the ACME token for the
internal zone); the public zone is usually a different zone/account.
Without the token, `castle deploy` instead prints the exact command to run per host:
```bash
cloudflared tunnel route dns <tunnel-id> <name>.pub.payne.io
```
## The part that isn't the tunnel
Reachability is the easy half. Anything public also needs, per service: