Retire gateway.tls=internal; host routes use a bare subdomain label
acme mode fully replaced the internal-CA path (which required installing a private CA on every device — the exact pain, esp. on Android, that acme avoids). Remove `internal` entirely and simplify how services declare host routes. - Generator (caddyfile.py): drop the `tls internal` branch — modes are now `off` | `acme`. In acme mode the published subdomain is the **first DNS label** of `proxy.caddy.host` (a bare `claw`, or a legacy `claw.civil.lan`, both → `claw.<domain>`), so services stay domain-agnostic and the declared value is authoritative again (no more silent service-name override). Shared `_host_matcher_block` reused by off-mode and the acme wildcard site. - castle-api: delete `GET /gateway/ca.crt`, `_gateway_ca_pem`, `_ca_fingerprint` and the now-unused imports; drop `ca_fingerprint` from `GatewayInfo` (keep `tls`). - Dashboard: remove the CA-cert download button + unused imports; drop `ca_fingerprint` from the `GatewayInfo` type. - Tests: replace TestCaddyfileTlsInternal with an off-mode class (keeps the runner-agnostic host-route coverage); acme tests assert first-label derivation incl. label-wins-over-service-name; drop the castle-api CA-endpoint test. - Docs: registry.md + dns-and-tls.md — two-mode tables (off|acme), remove the internal sections/CA-download, document the bare-label host convention; note a domain-less node stays on `off`.
This commit is contained in:
@@ -85,37 +85,28 @@ subdomain can.
|
||||
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.
|
||||
|
||||
## TLS: three trust modes
|
||||
## TLS: two trust modes
|
||||
|
||||
`gateway.tls` (in `castle.yaml`) picks how host routes are served. It's a per-node
|
||||
choice; the modes are mutually exclusive.
|
||||
choice.
|
||||
|
||||
| `gateway.tls` | What the browser gets | Client setup | Use when |
|
||||
|---------------|-----------------------|--------------|----------|
|
||||
| `off` *(default)* | plain HTTP on `:9000` | none | you don't need HTTPS; localhost-only tools |
|
||||
| `internal` | HTTPS from Caddy's **local CA** | **install & trust the CA** on every device | LAN with a private `.lan` zone, few devices you control |
|
||||
| `acme` | HTTPS from a **real Let's Encrypt wildcard** | **nothing** | you own a domain; multiple devices (phones, etc.) |
|
||||
| `off` *(default)* | plain HTTP on `:9000` | none | you don't need HTTPS; a node with no public domain |
|
||||
| `acme` | HTTPS from a **real Let's Encrypt wildcard** | **nothing** | you own a domain; any/multiple devices (phones, etc.) |
|
||||
|
||||
### `off` — plain HTTP
|
||||
|
||||
The gateway generates `auto_https off` and listens on a bare `:9000`. Reach it at
|
||||
`http://<node>:9000/`. Simple, but a non-`localhost` HTTP page is **not** a browser
|
||||
"secure context" (see below), and there's no encryption.
|
||||
"secure context" (see below), and there's no encryption. For a node with no public
|
||||
domain, this is the mode — reach secure-context apps via `http://localhost` /
|
||||
direct ports on the node itself.
|
||||
|
||||
### `internal` — Caddy's local CA
|
||||
|
||||
Each host route becomes its own `tls internal` HTTPS site, signed by a CA Caddy
|
||||
generates on the node. Browsers get a real secure context — but only if they
|
||||
**trust that private CA**, which means distributing the root cert to every device's
|
||||
system/browser trust store. That's the catch: some platforms (notably Android
|
||||
browsers, and Firefox everywhere, which uses its own store) make installing a
|
||||
custom CA painful or impossible. Castle helps by exposing the public root at
|
||||
`GET /gateway/ca.crt` with a dashboard download button — but the per-device trust
|
||||
step is unavoidable, and it's why `internal` doesn't scale past a handful of
|
||||
machines you fully control.
|
||||
|
||||
Good fit: a `.lan` zone (which can't get a public cert anyway) with a couple of
|
||||
trusted laptops.
|
||||
> A private-CA option (Caddy's `tls internal`) existed but was removed: it required
|
||||
> installing a custom root CA on every device, which some platforms (Android
|
||||
> browsers; Firefox, which uses its own store) make painful — the exact problem
|
||||
> `acme` solves without any client setup.
|
||||
|
||||
### `acme` — real Let's Encrypt wildcard via DNS-01
|
||||
|
||||
@@ -137,8 +128,9 @@ How it stays internal:
|
||||
|
||||
One `*.<domain>` site means a **single cert** covers every host route, and Caddy
|
||||
**auto-renews** it — adding a service needs no new cert and no DNS-01 round trip.
|
||||
Host-route subdomains are derived from the **service name**: a service opts into a
|
||||
host route with `proxy.caddy.host`, and it's published at `<service>.<domain>`.
|
||||
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.
|
||||
|
||||
This is the recommended mode when you own a domain and want to reach services from
|
||||
arbitrary devices.
|
||||
@@ -150,28 +142,28 @@ Beyond eavesdropping protection, HTTPS unlocks browser capabilities gated to a
|
||||
built on them (device identity, end-to-end crypto). Browsers treat only `https://`
|
||||
and `http://localhost` as secure — a plain-HTTP page on a LAN hostname is **not**,
|
||||
so such apps break there. That's the concrete reason to move a host route to
|
||||
`internal` or `acme` rather than leaving it on `off`.
|
||||
`acme` rather than leaving it on `off`.
|
||||
|
||||
Note: a host served over HTTPS has its own **origin** (`https://foo.example`, no
|
||||
port). An app that allowlists origins, or an OAuth/token flow, must include the new
|
||||
origin — moving a service between modes changes its origin.
|
||||
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:` (drop any
|
||||
`path_prefix`). The literal host value is used as-is in `internal` mode; in
|
||||
`acme` mode the published name is derived as `<service>.<domain>`.
|
||||
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>`.
|
||||
2. **Make the name resolve.** Add (or rely on) the LAN wildcard for the zone
|
||||
(§DNS). Verify: `dig +short <service>.<zone>` → the node's IP.
|
||||
3. **Pick a trust mode** on the gateway (`gateway.tls`), plus the operational
|
||||
prerequisites for it (below).
|
||||
(§DNS). Verify: `dig +short <label>.<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.
|
||||
5. **Update the app's origin allowlist** if it has one (§secure context).
|
||||
|
||||
## Operational prerequisites
|
||||
|
||||
Both HTTPS modes need the gateway to bind privileged ports; `acme` also needs a
|
||||
plugin-enabled Caddy and a DNS token.
|
||||
`acme` needs the gateway to bind privileged ports, plus a plugin-enabled Caddy and
|
||||
a DNS token.
|
||||
|
||||
- **Bind `:443`/`:80`.** Caddy serves HTTPS on `:443` (and redirects `:80`). A
|
||||
user-level gateway can't bind privileged ports under `NoNewPrivileges`, so lower
|
||||
@@ -195,16 +187,16 @@ plugin-enabled Caddy and a DNS token.
|
||||
|
||||
| You have… | Zone (DNS) | Trust (TLS) | Result |
|
||||
|-----------|-----------|-------------|--------|
|
||||
| a quick internal tool, HTTP is fine | path prefix or `.lan` host | `off` | `http://node:9000/tool/` |
|
||||
| a `.lan` LAN, a couple of trusted machines | `*.node.lan` on the router | `internal` | HTTPS, install the CA per device |
|
||||
| a domain you own + many devices (phones) | `*.sub.domain` on the LAN resolver | `acme` | HTTPS, **no client setup**, internal-only |
|
||||
| a quick internal tool, HTTP is fine | path prefix, or a `.lan`/bare host | `off` | `http://node:9000/tool/` |
|
||||
| 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 |
|
||||
|
||||
The last row is the sweet spot for a multi-device 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://openclaw.civil.payne.io/` is trusted on any
|
||||
device with nothing to install.
|
||||
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.
|
||||
|
||||
## See also
|
||||
|
||||
- [registry.md — `proxy`, gateway routes, and the `gateway.tls` modes](registry.md#proxy--how-the-gateway-routes-to-it)
|
||||
— the field-level reference (Caddyfile shapes, exact config keys, the CA-download endpoint).
|
||||
— the field-level reference (Caddyfile shapes, exact config keys, DNS-01 setup).
|
||||
|
||||
@@ -340,70 +340,43 @@ Pin `<node-ip>` with a DHCP reservation — the wildcard hardcodes it.
|
||||
By default the gateway is **HTTP-only**: it generates `auto_https off` and listens
|
||||
on a bare `:<gateway-port>` (default `:9000`), so reach it at `http://<host>:9000/`,
|
||||
**not** `https://` (a TLS hello to the plain-HTTP listener fails with "wrong
|
||||
version number"). `gateway.tls` opts host routes into HTTPS:
|
||||
version number"). `gateway.tls` has two values:
|
||||
|
||||
| `gateway.tls` | listener | host routes | cert / trust |
|
||||
|---------------|----------|-------------|--------------|
|
||||
| `off` (default/unset) | `:<port>` HTTP, `auto_https off` | host matcher on `:<port>` | none |
|
||||
| `internal` | per-host `:443` HTTPS | own `tls internal` site | Caddy **local CA** — must distribute root.crt to clients |
|
||||
| `acme` | one `*.<domain>` `:443` site | matcher inside the wildcard site | **real Let's Encrypt wildcard, no CA install** |
|
||||
|
||||
`acme` and `internal` are mutually exclusive (one `gateway.tls` value); path-prefix
|
||||
and static routes always stay on the HTTP `:<port>` site. Both HTTPS modes need the
|
||||
443/80 bind below.
|
||||
Path-prefix and static routes always stay on the HTTP `:<port>` site — the way to
|
||||
put a service on HTTPS is to give it a `proxy.caddy.host`. A node with no public
|
||||
domain stays on `off` (plain HTTP; use `localhost`/direct ports for anything that
|
||||
needs a secure context).
|
||||
|
||||
#### HTTPS for host routes — `gateway.tls: internal`
|
||||
HTTPS matters beyond encryption: only `https://` (and `http://localhost`) is a
|
||||
browser **secure context**, the prerequisite for WebCrypto/`crypto.subtle` — which
|
||||
apps doing device identity or end-to-end crypto require and browsers disable on
|
||||
plain-HTTP LAN hosts. That's the reason to move such a service to a host route with
|
||||
`acme`.
|
||||
|
||||
Set `tls: internal` under `gateway:` in `castle.yaml` and each **host route**
|
||||
becomes its own HTTPS site served by Caddy's local CA:
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
port: 9000
|
||||
tls: internal # host routes (proxy.caddy.host) → HTTPS via Caddy's local CA
|
||||
```
|
||||
|
||||
```caddyfile
|
||||
foo.lan {
|
||||
tls internal
|
||||
reverse_proxy localhost:9001
|
||||
}
|
||||
```
|
||||
|
||||
This is what makes a remote browser treat the page as a **secure context** — the
|
||||
prerequisite for WebCrypto/`crypto.subtle`, which apps doing device-identity or
|
||||
end-to-end crypto require and which browsers disable on plain HTTP (except
|
||||
`localhost`). Path-prefix and static routes stay on the HTTP `:<gateway-port>`
|
||||
site, so the way to put a service on HTTPS is to give it a `proxy.caddy.host`.
|
||||
|
||||
Two operational requirements:
|
||||
|
||||
- **Bind 443/80.** Caddy serves these host sites on `:443` (and redirects `:80`).
|
||||
A user-level gateway can't bind privileged ports under `NoNewPrivileges`, so
|
||||
lower the floor once: `net.ipv4.ip_unprivileged_port_start=80` (persist in
|
||||
`/etc/sysctl.d/`). This beats `setcap`, which `NoNewPrivileges=true` would void.
|
||||
- **Trust the local CA.** Run `caddy trust` on the gateway host, then distribute
|
||||
the root CA to every other box's system/browser trust store — `.lan` can't get
|
||||
a public cert, so clients trust Caddy's root instead. (Firefox uses its own
|
||||
store; import it there too.) The dashboard's Gateway panel has a **CA cert**
|
||||
download button (only shown when `tls: internal`), backed by
|
||||
`GET /gateway/ca.crt` — the public root cert, sourced from Caddy's admin API,
|
||||
with its SHA-256 shown for out-of-band verification. The on-disk copy is at
|
||||
`~/.local/share/caddy/pki/authorities/local/root.crt`.
|
||||
**Bind 443/80.** The `acme` HTTPS site listens on `:443` (and redirects `:80`). A
|
||||
user-level gateway can't bind privileged ports under `NoNewPrivileges`, so lower
|
||||
the floor once: `net.ipv4.ip_unprivileged_port_start=80` (persist in
|
||||
`/etc/sysctl.d/`). This beats `setcap`, which `NoNewPrivileges=true` would void.
|
||||
|
||||
#### Publicly-trusted HTTPS — `gateway.tls: acme`
|
||||
|
||||
`internal` mode forces every client device to trust a private CA — which some
|
||||
platforms (e.g. Android browsers) make painful. `acme` mode avoids it entirely:
|
||||
Caddy obtains a **real Let's Encrypt wildcard cert** (`*.<domain>`) via a **DNS-01**
|
||||
challenge, so every browser trusts it with **zero CA install** — while the services
|
||||
stay **internal-only**.
|
||||
A private-CA approach (Caddy's `tls internal`) forces every client device to trust
|
||||
a custom root — which some platforms (e.g. Android browsers, and Firefox, which
|
||||
uses its own store) make painful. `acme` mode avoids it entirely: Caddy obtains a
|
||||
**real Let's Encrypt wildcard cert** (`*.<domain>`) via a **DNS-01** challenge, so
|
||||
every browser trusts it with **zero CA install** — while the services stay
|
||||
**internal-only**.
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
port: 9000
|
||||
tls: acme
|
||||
domain: civil.payne.io # wildcard cert *.civil.payne.io; host routes → <service>.civil.payne.io
|
||||
domain: civil.payne.io # wildcard cert *.civil.payne.io; host routes → <label>.civil.payne.io
|
||||
acme_email: you@example.com
|
||||
acme_dns_provider: cloudflare # default
|
||||
```
|
||||
@@ -428,10 +401,12 @@ 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 are **derived from the service name**: a service opts into a
|
||||
host route with `proxy.caddy.host` (its literal value is ignored in acme mode), and
|
||||
the route is published at `<service-name>.<domain>`. One `*.<domain>` site means a
|
||||
single cert covers every host route — adding a service needs no new cert.
|
||||
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.
|
||||
|
||||
Setup (the parts castle can't do for you):
|
||||
|
||||
@@ -456,8 +431,8 @@ Setup (the parts castle can't do for you):
|
||||
redeploy to get a browser-trusted production cert. Verify with
|
||||
`openssl s_client -connect <ip>:443 -servername claw.<domain> | openssl x509 -noout -issuer`.
|
||||
|
||||
The 443/80 bind requirement (above) applies to acme too. Unlike `internal`, there's
|
||||
no CA to distribute — the dashboard's CA-download button is `internal`-only.
|
||||
The 443/80 bind requirement (above) applies here. There's no CA to distribute —
|
||||
the wildcard is publicly trusted.
|
||||
|
||||
Routing only moves bytes — it does **not** supply the proxied app's own auth.
|
||||
If a backend requires a token/credential (e.g. in the URL or a header), that
|
||||
|
||||
Reference in New Issue
Block a user