194 lines
8.1 KiB
Markdown
194 lines
8.1 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
|
|
```
|
|
|
|
### 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:
|
|
|
|
```yaml
|
|
# 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-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
|
|
`: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.
|