Files
wild-pc/docs/tunnel-setup.md
Paul Payne 05b28cb584 Rename Castle -> Wild PC across the repo
Repo-side rename only (Phases 1-3 of the migration plan); the live box
(~/.castle, systemd units, /data/castle, domains) is a separate cutover.

- Slug `castle` -> `wildpc`: CLI command, module names (wildpc_core/cli/api),
  dist names, entry point `wildpc = wildpc_cli.main:main`.
- Identifiers: CastleConfig/NATSClient/DirError/MDNS -> Wildpc*.
- Env/constants: CASTLE_* -> WILDPC_*; ~/.castle -> ~/.wildpc, castle.yaml ->
  wildpc.yaml, /data/castle -> /data/wildpc.
- Systemd UNIT_PREFIX castle- -> wildpc-; own programs castle-api/gateway/etc.
- Display prose "Castle" -> "Wild PC" in docs, agent-guide files, README, frontend.
- Package dirs and bootstrap yaml renamed via git mv; lockfiles regenerated;
  redundant nested uv.lock files dropped (workspace root lock is authoritative).

Tests: core 273, cli 47, wildpc-api 120 all pass. Frontend type-checks + builds.
Fixed a stale test fixture (secret_env_path kind arg) broken pre-rename.
2026-07-18 22:55:08 -07:00

8.1 KiB

Public exposure via Cloudflare Tunnel

Wild PC 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 — wildpc apply generates the ingress from the registry.
  • One kill switch. Stop wildpc-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 wildpc          # prints a tunnel UUID + writes creds JSON

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

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

Then create the tunnel deployment at ~/.wildpc/deployments/wildpc-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/.wildpc/artifacts/specs/cloudflared.yml
    - run
manage:
  systemd: {}

Bring it online:

wildpc apply                       # writes cloudflared.yml from public services
wildpc apply wildpc-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:

wildpc apply

wildpc apply regenerates ~/.wildpc/artifacts/specs/cloudflared.yml from the current set of public services and restarts wildpc-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. Wild PC 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>" > ~/.wildpc/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN
chmod 600 ~/.wildpc/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN

With it present, every wildpc 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, wildpc apply instead prints the exact command to run per host:

cloudflared tunnel route dns <tunnel-id> <name>.pub.payne.io

Publishing on a different domain (public_host)

By default a public deployment is projected at <name>.<gateway.public_domain>public_domain is the default public zone. To publish a specific deployment on a different domain, or at an apex (e.g. payne.io, which can't be expressed as <name>.<zone>), set an exact public_host on the deployment:

# deployments/statics/payne-io.yaml
manager: caddy
program: payne-io
root: public
reach: public
public_host: payne.io      # exact FQDN — overrides <name>.<public_domain>

public_host is an exact hostname (no scheme/port/path) and only applies with reach: public. When set:

  • Tunnel ingress maps that hostname to the tunnel; the origin still bridges to the deployment's internal host (<name>.<gateway.domain>), so Caddy routes it and the internal wildcard cert validates — same as the default path.
  • Public DNS reconcile routes the CNAME into whichever accessible Cloudflare zone is the longest suffix of the host (so payne.io lands in zone payne.io, x.other.org in other.org). It reconciles across every zone the CLOUDFLARE_PUBLIC_DNS_TOKEN can see, so the token must have DNS:Edit on each target zone. Cloudflare flattens the apex CNAME automatically.
  • LAN-direct HTTPS. The gateway also serves the custom host directly as its own Caddy site, obtaining that host's cert via DNS-01 (the global acme_dns). This requires the gateway's CLOUDFLARE_API_TOKEN to have DNS:Edit on the host's zone too, and LAN DNS to resolve the host to this node — for an apex add e.g. address=/payne.io/<node-ip> on the LAN resolver (the *.<gateway.domain> wildcard doesn't cover a foreign apex).

A deployment with public_host publishes even if no node-wide public_domain is configured. Prerequisites in one place, for payne-iohttps://payne.io:

need why
CLOUDFLARE_PUBLIC_DNS_TOKEN has DNS:Edit on payne.io the proxied apex CNAME → tunnel
CLOUDFLARE_API_TOKEN (gateway) has DNS:Edit on payne.io the LAN-direct apex cert (DNS-01)
LAN DNS address=/payne.io/<node-ip> LAN browsers resolve the apex to the gateway

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 (plain-HTTP) 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.