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:
@@ -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(
|
||||
|
||||
123
core/src/castle_core/generators/dns.py
Normal file
123
core/src/castle_core/generators/dns.py
Normal 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
|
||||
Reference in New Issue
Block a user