diff --git a/cli/src/castle_cli/commands/doctor.py b/cli/src/castle_cli/commands/doctor.py index 66275cf..7beb49e 100644 --- a/cli/src/castle_cli/commands/doctor.py +++ b/cli/src/castle_cli/commands/doctor.py @@ -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( diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 50879f7..18ffbed 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -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( diff --git a/core/src/castle_core/generators/dns.py b/core/src/castle_core/generators/dns.py new file mode 100644 index 0000000..18724ce --- /dev/null +++ b/core/src/castle_core/generators/dns.py @@ -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 `.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 == `.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 diff --git a/docs/tunnel-setup.md b/docs/tunnel-setup.md index 716a640..9d48d61 100644 --- a/docs/tunnel-setup.md +++ b/docs/tunnel-setup.md @@ -89,12 +89,10 @@ proxy: true # required — the service must be routed public: true # also expose at .pub.payne.io via the tunnel ``` -Then deploy and route DNS (a CNAME `.pub.payne.io → .cfargotunnel.com`, -created once per public host — `castle deploy` prints the exact command): +Then just deploy: ```bash castle deploy -cloudflared tunnel route dns .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 `.pub.payne.io → .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 "" > ~/.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 +(`.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 .pub.payne.io +``` + ## The part that isn't the tunnel Reachability is the easy half. Anything public also needs, per service: