Files
wild-pc/docs/tunnel-setup.md
Paul Payne 9abef4ac34 docs+install: sweep deploy/start → castle apply
Update the README, AGENTS.md, install.sh, and docs/ for the converge
model: one `castle apply [name] [--plan]` replaces `deploy && start` and
the per-kind enable/disable/install/start/stop verbs. CLI references now
list apply + restart (the imperative bounce) + logs; recipes use
`castle apply`; "turn it off" is documented as `enabled: false` + apply.
install.sh's next-steps collapse to a single `castle apply`.

Verified: no retired-verb command instructions remain in any markdown;
install.sh syntax OK; live `castle apply --plan` reports converged;
doctor all-green; core 129 / cli 31 / api 59 pass.
2026-07-02 11:54:31 -07:00

151 lines
6.0 KiB
Markdown

# 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 apply` 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)
```bash
# 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):
```yaml
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:
```bash
castle apply # writes cloudflared.yml from public services
castle apply castle-tunnel # start the tunnel
```
## Using the toggle
Mark a service public in its `deployments/<name>.yaml`:
```yaml
proxy: true # required — the service must be routed
public: true # also expose at <name>.pub.payne.io via the tunnel
```
Then just deploy:
```bash
castle apply
```
`castle apply` 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:
```bash
printf %s "<token>" > ~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN
chmod 600 ~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN
```
With it present, every `castle apply` **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 apply` 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:
- **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.