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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user