Subdomain-only gateway routing; drop path prefixes
Collapse the two proxy shapes (path_prefix + host) to a single checkbox: a service is either port-only, or exposed at <service-name>.<gateway.domain>. Path routes and their prefix-stripping foot-guns are gone; the subdomain is always the service name (rename the service to change it). - Schema: CaddySpec is just `enable` (presence = expose). service_proxy_targets returns (expose, port, base_url); Deployment carries `subdomain` (was proxy_path/proxy_host). Registry/deploy/mesh/CLI updated in lockstep. - Generator: compute_routes emits one host route per exposed service/frontend (address = name). acme mode = one *.<domain> site with a reverse_proxy matcher per service + a file_server matcher per frontend; the :<port> site redirects to the dashboard. off mode = a HTTP control plane on :<port> (dashboard at / + /api → castle-api); other services are port-only. - Dashboard/API: castle-app and castle-api are ordinary subdomains. The dashboard derives the API base at runtime (castle-api.<domain> on a subdomain, else /api) and calls it cross-origin — castle-api already allows CORS *. Frontends build with VITE_BASE=/ (served at their subdomain root). - CLI: `service create` drops --path/--host; --no-proxy makes it port-only. - Docs/tests reworked for the model; docs use generic placeholders only (no personal host/domain/IP details).
This commit is contained in:
@@ -17,73 +17,40 @@ strategy, and any working combination is fine.
|
||||
|
||||
## The gateway is the single ingress
|
||||
|
||||
Every reachable service goes through the Caddy **gateway** (`:9000` by default). A
|
||||
gateway route maps a public **address** to a **target**. Two address shapes matter
|
||||
for DNS and TLS:
|
||||
Every gateway-reachable service goes through the Caddy **gateway**. Exposure is a
|
||||
single **checkbox** (`proxy.caddy` on a service):
|
||||
|
||||
- **path prefix** (`/foo`) — reached at `http://<node>:9000/foo/`. Shares the
|
||||
node's own name/port; needs no per-service DNS. Caddy **strips** the prefix, so
|
||||
this only suits apps that don't assume they live at the origin root.
|
||||
- **host route** (`foo.lan`, `foo.example.com`) — reached at `https://foo.…/`. A
|
||||
whole hostname proxied to the backend root, nothing stripped. This is the shape
|
||||
that gets its own DNS name and its own TLS cert.
|
||||
- **unchecked** — the service is reachable only at its own `host:port` (no gateway
|
||||
route, no DNS name).
|
||||
- **checked** — the gateway routes the whole subdomain **`<service-name>.<domain>`**
|
||||
to the backend root. The subdomain is always the service name.
|
||||
|
||||
> **Rule of thumb:** a service that needs HTTPS, a real origin, WebSockets, or
|
||||
> root-relative asset URLs wants a **host route**. Path prefixes are for simple,
|
||||
> prefix-agnostic backends. See
|
||||
> [registry.md](registry.md#path-prefix-vs-host-route--pick-by-whether-the-app-is-prefix-aware)
|
||||
> for the failure modes of putting a root-based app under a stripped prefix.
|
||||
|
||||
Only host routes are the subject of the rest of this document — they're what DNS
|
||||
and TLS act on.
|
||||
There are **no path-prefix routes**: a whole subdomain maps to the backend root, so
|
||||
root-relative asset URLs and `window.location`-derived WebSocket URLs just work
|
||||
(Caddy proxies WebSocket upgrades transparently). Each checked service gets its own
|
||||
DNS name and shares the gateway's TLS cert. The rest of this document is about
|
||||
making those subdomains **resolve** (DNS) and be **trusted** (TLS).
|
||||
|
||||
## DNS: making a name resolve to the node
|
||||
|
||||
A host route does nothing until `foo.…` resolves **to this node** on the clients
|
||||
that will use it. Castle does **not** run DNS; it relies on whatever already serves
|
||||
your LAN. Two facts shape the approach:
|
||||
A subdomain does nothing until it resolves **to this node** on the clients that
|
||||
browse it. Castle does **not** run DNS; you add one record to your **LAN's DNS
|
||||
server** — usually the router. A single **wildcard** covers every subdomain, so new
|
||||
services need no further DNS edits:
|
||||
|
||||
- **Resolve on the clients that matter.** A name only needs to resolve for the
|
||||
devices that browse it. That's usually your LAN's DHCP/DNS authority — often the
|
||||
router — not a central or mesh resolver.
|
||||
- **One wildcard beats many records.** A single wildcard entry routes every
|
||||
subdomain of a zone to the node, so each new host-routed service works with no
|
||||
further DNS edits.
|
||||
```
|
||||
address=/<sub>.<domain>/<node-ip> # dnsmasq (e.g. on the router)
|
||||
```
|
||||
|
||||
(or the equivalent wildcard `A` record in whatever your router's DNS uses). Pin
|
||||
`<node-ip>` with a **DHCP reservation** — the wildcard hardcodes it.
|
||||
|
||||
### Split-horizon: internal names, no public exposure
|
||||
|
||||
The names Castle serves resolve **only inside the LAN**. For a private zone this is
|
||||
automatic; for a public domain you own, it's deliberate split-horizon — your LAN
|
||||
resolver answers with the node's private IP, and the **public** zone has no `A`
|
||||
records for the services, so nothing is reachable from the internet.
|
||||
|
||||
### Two zone styles
|
||||
|
||||
| Zone style | Example | Who's authoritative | Wildcard record |
|
||||
|------------|---------|---------------------|-----------------|
|
||||
| **private TLD** | `*.civil.lan` | the LAN router/DHCP server (owns `.lan`) | `address=/civil.lan/<node-ip>` |
|
||||
| **subdomain of a public domain** | `*.civil.payne.io` | your public DNS host (e.g. Cloudflare), but **answered internally** by a LAN resolver | `address=/civil.payne.io/<node-ip>` |
|
||||
|
||||
Both give the same result — every `*.<zone>` name resolves to the node's LAN IP.
|
||||
The difference is which TLS modes each can use (below): a private TLD like `.lan`
|
||||
**cannot** get a publicly-trusted cert (it isn't a real domain), while a real
|
||||
subdomain can.
|
||||
|
||||
### Worked topology (this network)
|
||||
|
||||
- The **router** (`192.168.8.1`, a GL.iNet box) owns the `.lan` zone: it
|
||||
auto-registers DHCP hostnames (`civil.lan`) and answers `*.lan`. Unknown `.lan`
|
||||
names are **not** forwarded upstream — the router keeps that zone to itself. A
|
||||
`*.civil.lan` wildcard therefore lives on the **router**.
|
||||
- The router **forwards everything else** (including `*.payne.io`) to
|
||||
**wild-central**'s dnsmasq. So a `*.civil.payne.io` wildcard lives on
|
||||
**wild-central** (`/etc/dnsmasq.d/civil-payne.conf`,
|
||||
`address=/civil.payne.io/192.168.8.222`), which the router already routes to.
|
||||
`payne.io`'s **public** authority is Cloudflare, which holds no `A` records for
|
||||
these names — so `civil.payne.io` services resolve on the LAN and nowhere else.
|
||||
|
||||
Pin the node's IP with a **DHCP reservation** — the wildcard hardcodes it, so a
|
||||
drifting dynamic lease would break every host route at once.
|
||||
The names resolve **only inside the LAN**. You own the domain publicly (needed so
|
||||
DNS-01 can issue a real cert — below), but the **public** zone has no `A` records
|
||||
for the services — only your LAN's wildcard points at the node. So the services are
|
||||
trusted (real cert) yet reachable only from the LAN, never the internet.
|
||||
|
||||
## TLS: two trust modes
|
||||
|
||||
@@ -126,11 +93,10 @@ How it stays internal:
|
||||
LAN resolver answers `*.<domain>` with the private IP. Public internet sees
|
||||
nothing.
|
||||
|
||||
One `*.<domain>` site means a **single cert** covers every host route, and Caddy
|
||||
One `*.<domain>` site means a **single cert** covers every subdomain, and Caddy
|
||||
**auto-renews** it — adding a service needs no new cert and no DNS-01 round trip.
|
||||
Host-route subdomains come from the **first label of `proxy.caddy.host`**: a
|
||||
service declares `host: claw` and is published at `claw.<domain>`. Only the label
|
||||
matters (the domain is the gateway's), so services stay domain-agnostic.
|
||||
Every subdomain is the **service name** (`<name>.<domain>`), so services stay
|
||||
domain-agnostic — switching `gateway.domain` needs no service edits.
|
||||
|
||||
This is the recommended mode when you own a domain and want to reach services from
|
||||
arbitrary devices.
|
||||
@@ -150,11 +116,10 @@ origin — moving a service onto HTTPS changes its origin.
|
||||
|
||||
## Putting a service on trusted HTTPS — the recipe
|
||||
|
||||
1. **Give it a host route.** In the service's `proxy.caddy`, set `host:` to the
|
||||
subdomain **label** you want (`host: claw`), and drop any `path_prefix`. In
|
||||
`acme` mode the published name is `<label>.<gateway.domain>`.
|
||||
1. **Check the box.** Add `proxy: { caddy: {} }` to the service — it's now exposed
|
||||
at `<service-name>.<gateway.domain>` (rename the service to change the name).
|
||||
2. **Make the name resolve.** Add (or rely on) the LAN wildcard for the zone
|
||||
(§DNS). Verify: `dig +short <label>.<domain>` → the node's IP.
|
||||
(§DNS). Verify: `dig +short <name>.<domain>` → the node's IP.
|
||||
3. **Set `gateway.tls: acme`** (with `domain`/`acme_email`), plus the operational
|
||||
prerequisites (below).
|
||||
4. **Deploy & reload:** `castle deploy` regenerates the Caddyfile and reloads Caddy.
|
||||
@@ -187,14 +152,14 @@ a DNS token.
|
||||
|
||||
| You have… | Zone (DNS) | Trust (TLS) | Result |
|
||||
|-----------|-----------|-------------|--------|
|
||||
| a quick internal tool, HTTP is fine | path prefix, or a `.lan`/bare host | `off` | `http://node:9000/tool/` |
|
||||
| a quick internal tool, HTTP is fine | a `.lan` host, or none | `off` | `http://<node>:9000/` (dashboard) + services by port |
|
||||
| a node with no public domain, needs a secure context | — | `off` | reach it via `http://localhost` / direct port on the node |
|
||||
| a domain you own + any devices (phones) | `*.sub.domain` on the LAN resolver | `acme` | HTTPS, **no client setup**, internal-only |
|
||||
| a domain you own + any devices (phones) | `*.<sub>.<domain>` on the LAN resolver | `acme` | HTTPS, **no client setup**, internal-only |
|
||||
|
||||
The last row is the sweet spot for a personal LAN, and what this node runs today:
|
||||
`*.civil.payne.io` (wild-central DNS) + a Let's Encrypt wildcard via Cloudflare
|
||||
DNS-01, so e.g. `https://claw.civil.payne.io/` is trusted on any device with
|
||||
nothing to install.
|
||||
The last row is the sweet spot for a personal LAN: a `*.<sub>.<domain>` wildcard on
|
||||
the LAN resolver + a Let's Encrypt wildcard via DNS-01, so e.g.
|
||||
`https://<service>.<sub>.<domain>/` is trusted on any device with nothing to
|
||||
install.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ expose:
|
||||
internal: { port: 9001 }
|
||||
health_path: /health
|
||||
proxy:
|
||||
caddy: { path_prefix: /my-service }
|
||||
caddy: {} # expose at my-service.<gateway.domain>
|
||||
manage:
|
||||
systemd: {}
|
||||
```
|
||||
@@ -270,58 +270,44 @@ expose:
|
||||
health_path: /health # Used by health polling
|
||||
```
|
||||
|
||||
### `proxy` — How the gateway routes to it
|
||||
### `proxy` — Expose the service at a subdomain
|
||||
|
||||
`proxy.caddy` is a **checkbox**: present (and `enable: true`, the default) means the
|
||||
gateway routes **`<service-name>.<gateway.domain>`** to this service; absent means
|
||||
the service is reachable only at its own `host:port`.
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
caddy:
|
||||
path_prefix: /my-service # reachable at gateway:9000/my-service/
|
||||
host: my-service.lan # …or by hostname (whole host → backend root)
|
||||
caddy: {} # expose at <service-name>.<gateway.domain>
|
||||
```
|
||||
|
||||
Castle generates the Caddyfile from these entries. Only needed for services
|
||||
reachable through the gateway.
|
||||
The subdomain is always the service name — there's nothing to customize (rename the
|
||||
service to change it). There are **no path-prefix routes**: a whole subdomain maps
|
||||
to the backend root, so root-relative asset URLs and `window.location`-derived
|
||||
WebSocket URLs just work (the failure mode of the old prefix-stripping `handle_path`
|
||||
routes is gone). Caddy proxies WebSocket upgrades transparently.
|
||||
|
||||
**Gateway routes — one concept, three target kinds.** The gateway (`:9000`) maps
|
||||
a public **address** (a path prefix `/foo`, or a host `foo.lan`) to a **target**:
|
||||
**Gateway routes — one concept, three target kinds.** The gateway maps a public
|
||||
**address** (always a subdomain host, `<name>.<domain>`) to a **target**:
|
||||
|
||||
| Kind | Target | Declared by |
|
||||
|------|--------|-------------|
|
||||
| **proxy** | a local service on a port — Caddy `reverse_proxy localhost:PORT` | a service's `proxy.caddy` |
|
||||
| **remote** | a service on another node — `reverse_proxy host:PORT` | mesh discovery |
|
||||
| **static** | a built frontend's `dist/` — Caddy `file_server` (no process) | a `frontend` program with `build.outputs` and **no** service (implicit; served at `/<name>/`, `castle-app` at `/`) |
|
||||
| **static** | a built frontend's `dist/` — Caddy `file_server` (no process) | a `frontend` program with `build.outputs` and **no** service (auto-exposed at `<name>.<domain>`) |
|
||||
| **remote** | a service on another node | mesh discovery (out of scope of the single-node gateway) |
|
||||
|
||||
"Serving a frontend" and "proxying a service" are the same thing — a route —
|
||||
differing only in whether the target is files on disk or a live process. The
|
||||
complete table (all kinds) is shown by `castle gateway status`, the dashboard
|
||||
Gateway panel, and `GET /gateway`; the Caddyfile is generated from it.
|
||||
"Serving a frontend" and "proxying a service" are the same thing — a subdomain
|
||||
route — differing only in whether the target is files on disk or a live process.
|
||||
The table is shown by `castle gateway status`, the dashboard Gateway panel, and
|
||||
`GET /gateway`; the Caddyfile is generated from it.
|
||||
|
||||
#### Path prefix vs host route — pick by whether the app is prefix-aware
|
||||
|
||||
A `path_prefix: /foo` route is generated as Caddy `handle_path /foo/*`, which
|
||||
**strips** the prefix before proxying — the backend sees requests at `/`. That's
|
||||
right for a service that doesn't care what path it's mounted under. It **breaks**
|
||||
apps that assume they sit at the origin root, because the public path (`/foo/…`)
|
||||
and the path the backend sees (`/…`) no longer agree. Tell-tale symptoms:
|
||||
|
||||
- absolute asset URLs (`/assets/app.js`) 404 — they resolve at the gateway root,
|
||||
not under `/foo/`, and fall through to the wrong handler;
|
||||
- a **WebSocket** fails to connect: a browser app that derives its WS URL from
|
||||
`window.location` will aim at `ws://host/foo` (no trailing slash), which hits
|
||||
the `redir /foo → /foo/` rule — and a WS handshake can't follow a redirect.
|
||||
|
||||
For such an app, use a **host route** instead — `host: foo.lan`, no `path_prefix`:
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
caddy:
|
||||
host: foo.lan # whole host → backend root; nothing is stripped
|
||||
```
|
||||
|
||||
This proxies the whole hostname to the backend's root, so the public path and the
|
||||
backend path match and root-relative assets/WS URLs just work. (Caddy proxies
|
||||
WebSocket upgrades transparently in both modes — stripping, not the upgrade, is
|
||||
what bites prefix-unaware apps.)
|
||||
**The dashboard and its API.** `castle-app` (the dashboard frontend) and
|
||||
`castle-api` are just two such subdomains (`castle-app.<domain>`,
|
||||
`castle-api.<domain>`); the dashboard calls the API **cross-origin** (castle-api
|
||||
allows CORS `*`). The bare gateway port (`:9000`) redirects to the dashboard
|
||||
subdomain. On a node with **no domain** (`gateway.tls: off`), there are no
|
||||
subdomains, so `:9000` serves just the control plane — the dashboard at `/` plus a
|
||||
`/api` reverse-proxy to castle-api — and other services stay port-only.
|
||||
|
||||
#### Host routes need DNS, and the gateway is HTTP-only
|
||||
|
||||
@@ -332,7 +318,7 @@ dnsmasq wildcard routes every subdomain to the gateway, so each new host-routed
|
||||
service works with no further DNS edits:
|
||||
|
||||
```
|
||||
address=/<node>.lan/<node-ip> # e.g. address=/civil.lan/192.168.8.222
|
||||
address=/<node>.lan/<node-ip> # e.g. address=/node.lan/192.0.2.10
|
||||
```
|
||||
|
||||
Pin `<node-ip>` with a DHCP reservation — the wildcard hardcodes it.
|
||||
@@ -376,7 +362,7 @@ every browser trusts it with **zero CA install** — while the services stay
|
||||
gateway:
|
||||
port: 9000
|
||||
tls: acme
|
||||
domain: civil.payne.io # wildcard cert *.civil.payne.io; host routes → <label>.civil.payne.io
|
||||
domain: example.com # wildcard cert *.example.com; services → <name>.example.com
|
||||
acme_email: you@example.com
|
||||
acme_dns_provider: cloudflare # default
|
||||
```
|
||||
@@ -387,9 +373,9 @@ gateway:
|
||||
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||
}
|
||||
|
||||
*.civil.payne.io {
|
||||
@host_claw host claw.civil.payne.io
|
||||
handle @host_claw {
|
||||
*.example.com {
|
||||
@host_openclaw host openclaw.example.com
|
||||
handle @host_openclaw {
|
||||
reverse_proxy localhost:18789
|
||||
}
|
||||
}
|
||||
@@ -401,12 +387,10 @@ it needs **no inbound exposure and no public A records** for the services. Only
|
||||
**LAN DNS** resolves `*.<domain>` to the gateway's private IP. (HTTP-01 can't
|
||||
validate a wildcard, so DNS-01 — and thus the provider token — is mandatory here.)
|
||||
|
||||
Host-route subdomains come from the **first label of `proxy.caddy.host`**: a
|
||||
service declares `host: claw` (or a legacy `claw.civil.lan`) and is published at
|
||||
`claw.<domain>`. Only the label matters — the domain is the gateway's, so services
|
||||
stay domain-agnostic (switching `gateway.domain` needs no service edits). One
|
||||
`*.<domain>` site means a single cert covers every host route — adding a service
|
||||
needs no new cert.
|
||||
Every subdomain is the **service name**: a service checks the `proxy.caddy` box and
|
||||
is published at `<name>.<domain>`. Services stay domain-agnostic (switching
|
||||
`gateway.domain` needs no service edits). One `*.<domain>` site means a single cert
|
||||
covers every route — adding a service needs no new cert.
|
||||
|
||||
Setup (the parts castle can't do for you):
|
||||
|
||||
@@ -422,10 +406,10 @@ Setup (the parts castle can't do for you):
|
||||
CLOUDFLARE_API_TOKEN: ${secret:CLOUDFLARE_API_TOKEN}
|
||||
```
|
||||
`castle deploy` warns if the domain, this env var, or the secret is missing.
|
||||
- **LAN DNS.** Point `*.<domain>` at the gateway's private IP on your LAN
|
||||
resolver. For a `*.payne.io` subdomain that's **wild-central's dnsmasq** (the
|
||||
router already forwards `*.payne.io` there): `address=/civil.payne.io/<gateway-ip>`.
|
||||
The public zone gets no A records, so services aren't externally reachable.
|
||||
- **LAN DNS.** Add a wildcard on your LAN's DNS server (usually the router)
|
||||
pointing `*.<domain>` at the gateway's private IP — `address=/<domain>/<gateway-ip>`
|
||||
(dnsmasq) or the equivalent A record. The public zone gets no A records, so
|
||||
services aren't externally reachable.
|
||||
- **Staging first.** Set `CASTLE_ACME_STAGING=1` to use Let's Encrypt's staging CA
|
||||
(its rate limits are generous) while verifying issuance, then unset it and
|
||||
redeploy to get a browser-trusted production cert. Verify with
|
||||
@@ -577,7 +561,7 @@ services:
|
||||
internal: { port: 9001 }
|
||||
health_path: /health
|
||||
proxy:
|
||||
caddy: { path_prefix: /my-service }
|
||||
caddy: {} # expose at my-service.<gateway.domain>
|
||||
manage:
|
||||
systemd: {}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user