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.
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.
publicdefaults tofalse; a service is public only when you say so, andpublic: truerequiresproxy: 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.
cloudflareddials 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 ofpublic: trueservices —wildpc applygenerates 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.iolands in zonepayne.io,x.other.orginother.org). It reconciles across every zone theCLOUDFLARE_PUBLIC_DNS_TOKENcan see, so the token must haveDNS:Editon 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'sCLOUDFLARE_API_TOKENto haveDNS:Editon 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-io → https://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:443host sites). On anoff(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: truemodel can drive a self-hosted VPS + WireGuard edge instead — the toggle and generator stay; only the transport changes.