Files
wild-pc/docs/tunnel-setup.md
Paul Payne 9028d15ec6 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.
2026-07-02 11:02:34 -07:00

6.0 KiB

Public exposure via Cloudflare Tunnel

Castle is LAN-only by default: services with proxy: true are reachable at <name>.<gateway.domain> (e.g. <name>.civil.payne.io) through split DNS, and nothing is on the public internet. This document sets up a per-service public toggle — flip public: true on a service and it's also reachable from the public internet at <name>.<gateway.public_domain>, via a Cloudflare Tunnel.

How it works

  • Default private. public defaults to false; a service is public only when you say so, and public: true requires proxy: true (it projects an already-routed subdomain).
  • Separate public zone. Public services publish at a different zone (<name>.pub.payne.io), so internal subdomain names never appear in public DNS.
  • The tunnel bridges public → internal. cloudflared dials outbound to Cloudflare (no inbound holes, no public IP needed) and forwards each public hostname to the gateway on :443, rewriting the Host header and TLS SNI to the internal name so Caddy routes it and its wildcard cert validates. The public surface is exactly the set of public: true services — castle deploy generates the ingress from the registry.
  • One kill switch. Stop castle-tunnel → instantly nothing is public.
foo.pub.payne.io  ──(Cloudflare edge, public cert)──▶  cloudflared (outbound)
   └─▶ https://localhost:443, Host/SNI = foo.civil.payne.io  ──▶  Caddy ──▶ service

One-time setup (owner steps — needs Cloudflare login)

# 1. Install cloudflared (Debian/Ubuntu) — it's NOT in the default repos, so add
#    Cloudflare's apt repo first:
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
  | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" \
  | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install -y cloudflared

# 2. Authenticate and create the tunnel (opens a browser to pick the zone)
cloudflared tunnel login
cloudflared tunnel create castle          # prints a tunnel UUID + writes creds JSON

# 3. Move the credentials into Castle's secret store (path the generator expects)
mkdir -p ~/.castle/secrets/cloudflared
TID=<the-uuid-from-step-2>
mv ~/.cloudflared/$TID.json ~/.castle/secrets/cloudflared/$TID.json
chmod 600 ~/.castle/secrets/cloudflared/$TID.json

# 4. Tell Castle the public zone + tunnel id (in ~/.castle/castle.yaml)
#    gateway:
#      ...
#      public_domain: pub.payne.io
#      tunnel_id: <the-uuid>

Then create the tunnel deployment at ~/.castle/deployments/castle-tunnel.yaml (manager: systemd → kind: service):

description: Cloudflare tunnel — public exposure for public:true services
manager: systemd
run:
  launcher: command
  argv:
    - cloudflared
    - tunnel
    - --no-autoupdate
    - --config
    - /home/payne/.castle/artifacts/specs/cloudflared.yml
    - run
manage:
  systemd: {}

Bring it online:

castle deploy                       # writes cloudflared.yml from public services
castle service enable castle-tunnel # start the tunnel

Using the toggle

Mark a service public in its deployments/<name>.yaml:

proxy: true      # required — the service must be routed
public: true     # also expose at <name>.pub.payne.io via the tunnel

Then just deploy:

castle deploy

castle deploy regenerates ~/.castle/artifacts/specs/cloudflared.yml from the current set of public services and restarts castle-tunnel. Flip public back to 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:

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:

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:

  • Auth. "Public" rarely means "no auth." Put login in front — Cloudflare Access at the edge, an auth proxy, or the app's own (Supabase GoTrue). A public app whose privacy matters must actually enforce it (auth-gated shell + signed storage URLs, not just row-level security).
  • Hardening. Rate-limiting / WAF (free on Cloudflare), and never expose admin or substrate-Studio surfaces.

Notes

  • Requires gateway.tls: acme (the tunnel forwards to the gateway's real-cert :443 host sites). On an off/internal gateway the origin bridge doesn't apply.
  • Cloudflare terminates TLS at the edge (it can see plaintext). For a no-third-party-in-path variant, the same public: true model can drive a self-hosted VPS + WireGuard edge instead — the toggle and generator stay; only the transport changes.