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",
|
hint="castle service start castle-tunnel",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
checks.append(_check_public_dns(config))
|
||||||
|
|
||||||
if not checks:
|
if not checks:
|
||||||
checks.append(Check(OK, "off mode — no TLS/exposure to check", detail="localhost only"))
|
checks.append(Check(OK, "off mode — no TLS/exposure to check", detail="localhost only"))
|
||||||
return checks
|
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:
|
def _check_privileged_ports() -> Check:
|
||||||
try:
|
try:
|
||||||
val = int(
|
val = int(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from castle_core.generators.caddyfile import (
|
|||||||
_DNS_TOKEN_ENV,
|
_DNS_TOKEN_ENV,
|
||||||
generate_caddyfile_from_registry,
|
generate_caddyfile_from_registry,
|
||||||
)
|
)
|
||||||
|
from castle_core.generators.dns import reconcile_public_dns
|
||||||
from castle_core.generators.tunnel import (
|
from castle_core.generators.tunnel import (
|
||||||
generate_tunnel_config,
|
generate_tunnel_config,
|
||||||
public_hostnames,
|
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.
|
"""Write the cloudflared ingress config from the registry's public services.
|
||||||
|
|
||||||
No public services (or no tunnel configured) → remove any stale config and
|
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
|
leave the tunnel down. Otherwise write it and restart the tunnel service if
|
||||||
DNS route, and restart the tunnel service if it's running so it takes effect.
|
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"
|
config_path = SPECS_DIR / "cloudflared.yml"
|
||||||
content = generate_tunnel_config(registry)
|
content = generate_tunnel_config(registry)
|
||||||
if content is None:
|
if content is None:
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
config_path.unlink()
|
config_path.unlink()
|
||||||
messages.append("No public services — removed cloudflared config.")
|
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
|
return
|
||||||
|
|
||||||
config_path.write_text(content)
|
config_path.write_text(content)
|
||||||
hosts = public_hostnames(registry)
|
hosts = public_hostnames(registry)
|
||||||
messages.append(f"Tunnel config written: {config_path} ({len(hosts)} public)")
|
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
|
# Reconcile the public CNAMEs to the tunnel. Falls back to surfacing the manual
|
||||||
# exact commands rather than silently assuming they're routed.
|
# `cloudflared tunnel route dns` commands when no DNS token is configured.
|
||||||
tid = registry.node.tunnel_id
|
if not reconcile_public_dns(node.public_domain, node.tunnel_id, hosts, messages):
|
||||||
for h in hosts:
|
for h in hosts:
|
||||||
messages.append(
|
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)
|
tunnel_unit = unit_name(_TUNNEL_NAME)
|
||||||
active = subprocess.run(
|
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
|
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`,
|
Then just deploy:
|
||||||
created once per public host — `castle deploy` prints the exact command):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
castle deploy
|
castle deploy
|
||||||
cloudflared tunnel route dns <tunnel-id> <name>.pub.payne.io
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`castle deploy` regenerates `~/.castle/artifacts/specs/cloudflared.yml` from the
|
`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
|
`false` (or remove it) and redeploy to un-expose — the hostname drops out of the
|
||||||
ingress immediately.
|
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
|
## The part that isn't the tunnel
|
||||||
|
|
||||||
Reachability is the easy half. Anything public also needs, per service:
|
Reachability is the easy half. Anything public also needs, per service:
|
||||||
|
|||||||
Reference in New Issue
Block a user