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:
2026-06-30 19:55:17 -07:00
parent fa3a646064
commit a022a136fe
9 changed files with 113 additions and 257 deletions

View File

@@ -1,9 +1,8 @@
import { useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { Globe, RefreshCw, FileText, ShieldCheck } from "lucide-react"
import { Globe, RefreshCw, FileText } from "lucide-react"
import type { GatewayInfo, HealthStatus } from "@/types"
import { useGatewayReload, useCaddyfile } from "@/services/api/hooks"
import { apiClient } from "@/services/api/client"
import { HealthBadge } from "./HealthBadge"
interface GatewayPanelProps {
@@ -34,20 +33,6 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</span>
</div>
<div className="flex items-center gap-1">
{gateway.tls === "internal" && (
<a
href={apiClient.streamUrl("/gateway/ca.crt")}
download="castle-root.crt"
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-[var(--border)] hover:bg-[var(--border)]/80 text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
title={
"Download the gateway's root CA — install on other devices to trust *.lan HTTPS" +
(gateway.ca_fingerprint ? `\nSHA-256: ${gateway.ca_fingerprint}` : "")
}
>
<ShieldCheck size={12} />
CA cert
</a>
)}
<button
onClick={() => reload()}
disabled={reloading}

View File

@@ -124,8 +124,7 @@ export interface GatewayInfo {
service_count: number
managed_count: number
routes: GatewayRoute[]
tls?: string | null // "internal" → host routes served over HTTPS by Caddy's local CA
ca_fingerprint?: string | null // SHA-256 of the downloadable root CA
tls?: string | null // "acme" → host routes served over HTTPS with a Let's Encrypt wildcard
}
export interface ServiceActionResponse {

View File

@@ -156,11 +156,9 @@ class GatewayInfo(BaseModel):
service_count: int
managed_count: int
routes: list[GatewayRoute] = []
# TLS mode (None/"off" → HTTP-only; "internal" → Caddy local-CA HTTPS for host
# routes). When "internal", ca_fingerprint is the SHA-256 of the root CA the
# dashboard offers for download so clients can trust *.lan HTTPS.
# TLS mode: None/"off" → HTTP-only; "acme" → Let's Encrypt wildcard (publicly
# trusted, no client CA setup) for host routes.
tls: str | None = None
ca_fingerprint: str | None = None
class NodeSummary(BaseModel):

View File

@@ -3,15 +3,10 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import shutil
import ssl
import urllib.request
from pathlib import Path
from fastapi import APIRouter, HTTPException, Response, status
from fastapi import APIRouter, HTTPException, status
from castle_core.config import SPECS_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
@@ -899,13 +894,6 @@ def get_gateway() -> GatewayInfo:
# Caddyfile order is precedence-sensitive; the displayed table is alphabetical.
routes.sort(key=lambda r: r.address)
tls = registry.node.gateway_tls
ca_fingerprint = None
if (tls or "").lower() == "internal":
pem = _gateway_ca_pem(registry.node.gateway_tls)
if pem:
ca_fingerprint = _ca_fingerprint(pem)
return GatewayInfo(
port=registry.node.gateway_port,
hostname=registry.node.hostname,
@@ -913,8 +901,7 @@ def get_gateway() -> GatewayInfo:
service_count=service_count,
managed_count=managed_count,
routes=routes,
tls=tls,
ca_fingerprint=ca_fingerprint,
tls=registry.node.gateway_tls,
)
@@ -925,66 +912,6 @@ def get_caddyfile() -> dict[str, str]:
return {"content": generate_caddyfile_from_registry(registry)}
# Caddy's admin API exposes the local CA's root cert — the authoritative source,
# matching the running gateway (same endpoint `caddy trust` uses).
_CADDY_ADMIN = "http://localhost:2019"
def _gateway_ca_pem(gateway_tls: str | None) -> str | None:
"""The gateway's local-CA root certificate (PEM), or None.
Only the public root cert — never the CA private key. Returns None unless
`gateway.tls` is "internal" and a CA exists. Prefers Caddy's admin API (the
running gateway's actual CA); falls back to the on-disk root.crt.
"""
if (gateway_tls or "").lower() != "internal":
return None
try:
with urllib.request.urlopen(f"{_CADDY_ADMIN}/pki/ca/local", timeout=2) as resp:
data = json.loads(resp.read())
pem = data.get("root_certificate")
if pem:
return pem
except Exception:
pass # admin API down → try the file
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
root = Path(base) / "caddy" / "pki" / "authorities" / "local" / "root.crt"
if root.is_file():
return root.read_text()
return None
def _ca_fingerprint(pem: str) -> str | None:
"""Colon-separated uppercase SHA-256 of the cert DER, for out-of-band verify."""
try:
der = ssl.PEM_cert_to_DER_cert(pem)
except Exception:
return None
hexd = hashlib.sha256(der).hexdigest().upper()
return ":".join(hexd[i : i + 2] for i in range(0, len(hexd), 2))
@router.get("/gateway/ca.crt")
def get_gateway_ca() -> Response:
"""Download the gateway's local-CA root cert so clients can trust *.lan HTTPS.
Public certificate only (the CA private key never leaves the host). 404 when
the gateway isn't serving internal-CA TLS or no cert has been issued yet.
"""
registry = get_registry()
pem = _gateway_ca_pem(registry.node.gateway_tls)
if not pem:
raise HTTPException(
status_code=404,
detail="No gateway CA (gateway.tls is not 'internal', or no cert yet).",
)
return Response(
content=pem,
media_type="application/x-x509-ca-cert",
headers={"Content-Disposition": 'attachment; filename="castle-root.crt"'},
)
@router.post("/gateway/reload")
async def reload_gateway() -> dict[str, str]:
"""Regenerate Caddyfile and reload Caddy."""

View File

@@ -270,15 +270,9 @@ class TestGateway:
assert r["kind"] in ("static", "proxy", "remote")
def test_gateway_tls_off_by_default(self, client: TestClient) -> None:
"""No TLS configured → tls/ca_fingerprint are null (HTTP-only gateway)."""
"""No TLS configured → tls is null (HTTP-only gateway)."""
data = client.get("/gateway").json()
assert data["tls"] is None
assert data["ca_fingerprint"] is None
def test_gateway_ca_404_without_internal_tls(self, client: TestClient) -> None:
"""The CA download is unavailable unless gateway.tls is 'internal'."""
response = client.get("/gateway/ca.crt")
assert response.status_code == 404
class TestConfigEditor:

View File

@@ -186,27 +186,23 @@ def generate_caddyfile_from_registry(
) -> str:
"""Render the route list to a Caddyfile.
Three modes, set by `gateway.tls`:
Two modes, set by `gateway.tls`:
- **off (default)** — HTTP-only. Everything (host matchers + path prefixes +
static) lives in one `:<port>` site with `auto_https off`, so a named host
can't pull the listener into TLS or try to bind :80/:443.
- **internal** — each host route becomes its own `<host> { tls internal … }`
site, served over HTTPS by Caddy's local CA (Caddy listens :443 and
redirects :80). This makes those hosts a browser "secure context". Path
prefixes, static frontends, and the dashboard stay on the HTTP `:<port>`
site — give a service a `proxy.caddy.host` to put it on HTTPS.
- **acme** — host routes are served under a single `*.<domain>` site with a
real Let's Encrypt **wildcard** cert obtained via a DNS-01 challenge (one
cert for all of them). Publicly trusted → no CA install on clients. Each
host route maps to `<service-name>.<domain>`. Requires `gateway.domain`;
host route is published at `<label>.<domain>`, where `<label>` is the first
DNS label of the service's `proxy.caddy.host` (so a bare `claw`, or a legacy
`claw.civil.lan`, both yield `claw.<domain>`). Requires `gateway.domain`;
the DNS provider token reaches Caddy via `{env.<TOKEN>}`.
"""
routes = compute_routes(registry, None, remote_registries)
node = registry.node
gw_port = node.gateway_port
mode = (node.gateway_tls or "").lower()
tls_internal = mode == "internal"
domain = node.gateway_domain
tls_acme = mode == "acme" and bool(domain) # acme without a domain → off-mode
@@ -225,26 +221,16 @@ def generate_caddyfile_from_registry(
lines.append(f" acme_ca {_ACME_STAGING_CA}")
lines += ["}", ""]
# One wildcard site → a single DNS-01 cert covers every host route, so a
# new host-routed service needs no new cert or challenge.
# new host-routed service needs no new cert or challenge. The published
# subdomain is the host's first label (a bare `claw` or `claw.civil.lan`
# both → `claw.<domain>`), keeping services domain-agnostic.
if host_routes:
lines.append(f"*.{domain} {{")
for r in host_routes:
sub = r.name or r.address.split(".")[0]
sub = (r.address.split(".")[0] if r.address else "") or r.name or ""
lines += _host_matcher_block(r.name or r.address, f"{sub}.{domain}", r.target)
lines.append("}")
lines.append("")
elif tls_internal:
# Per-host HTTPS sites via Caddy's internal CA. `tls internal` overrides
# ACME for that host, so no public cert is attempted; clients must trust
# the Caddy root CA (`caddy trust`, then distribute root.crt).
for r in host_routes:
lines += [
f"{r.address} {{",
" tls internal",
f" reverse_proxy {r.target}",
"}",
"",
]
else:
# HTTP-only: keep auto-HTTPS off so the bare-port site stays plain HTTP
# and named hosts don't trigger cert provisioning.
@@ -280,8 +266,8 @@ def generate_caddyfile_from_registry(
"",
]
elif r.is_host: # host-based proxy
if tls_internal or tls_acme:
continue # emitted as its own HTTPS / wildcard site above
if tls_acme:
continue # emitted in the *.<domain> wildcard site above
lines += _host_matcher_block(r.name or r.address, r.address, r.target)
else: # path-prefix proxy (local or remote)
if r.kind == "remote":

View File

@@ -234,12 +234,11 @@ class TestLocalRoutesFromConfig:
assert "reverse_proxy localhost:8001" in caddyfile
class TestCaddyfileTlsInternal:
"""gateway.tls=internal → host routes become their own HTTPS sites."""
class TestCaddyfileOffMode:
"""gateway.tls unset/off → HTTP-only: host matchers live on the :<port> site."""
def _host_registry(self, tls: str | None) -> NodeRegistry:
def _host_registry(self) -> NodeRegistry:
return _make_registry(
gateway_tls=tls,
deployed={
"claw": Deployment(
runner="node", run_cmd=["claw"], port=18789, proxy_host="claw.civil.lan"
@@ -250,17 +249,21 @@ class TestCaddyfileTlsInternal:
},
)
def test_host_route_becomes_tls_site(self) -> None:
caddyfile = generate_caddyfile_from_registry(self._host_registry("internal"))
assert "claw.civil.lan {" in caddyfile
assert "tls internal" in caddyfile
assert "reverse_proxy localhost:18789" in caddyfile
def test_host_matcher_and_auto_https_off(self) -> None:
caddyfile = generate_caddyfile_from_registry(self._host_registry())
assert "auto_https off" in caddyfile
assert "@host_claw host claw.civil.lan" in caddyfile
assert "tls internal" not in caddyfile # internal mode is gone
assert "claw.civil.lan {" not in caddyfile # not a standalone TLS site
def test_compose_substrate_host_route_is_tls_site(self) -> None:
"""The Supabase substrate (compose runner + host route) becomes its own
HTTPS site under tls:internal — routing is runner-agnostic."""
def test_path_routes_on_http_port(self) -> None:
caddyfile = generate_caddyfile_from_registry(self._host_registry())
assert ":9000 {" in caddyfile
assert "handle_path /api/*" in caddyfile
def test_routing_is_runner_agnostic(self) -> None:
"""A compose-runner service with a host route is matched like any other."""
registry = _make_registry(
gateway_tls="internal",
deployed={
"supabase": Deployment(
runner="compose",
@@ -271,29 +274,9 @@ class TestCaddyfileTlsInternal:
},
)
caddyfile = generate_caddyfile_from_registry(registry)
assert "supabase.lan {" in caddyfile
assert "tls internal" in caddyfile
assert "@host_supabase host supabase.lan" in caddyfile
assert "reverse_proxy localhost:8000" in caddyfile
def test_no_auto_https_off_in_tls_mode(self) -> None:
# auto_https off would suppress the internal-CA certs we now want.
caddyfile = generate_caddyfile_from_registry(self._host_registry("internal"))
assert "auto_https off" not in caddyfile
# Host matcher form must NOT be used when the host is its own TLS site.
assert "@host_claw" not in caddyfile
def test_path_routes_stay_on_http_port(self) -> None:
caddyfile = generate_caddyfile_from_registry(self._host_registry("internal"))
assert ":9000 {" in caddyfile
assert "handle_path /api/*" in caddyfile
def test_off_mode_keeps_host_matcher_and_auto_https_off(self) -> None:
caddyfile = generate_caddyfile_from_registry(self._host_registry(None))
assert "auto_https off" in caddyfile
assert "@host_claw host claw.civil.lan" in caddyfile
assert "tls internal" not in caddyfile
assert "claw.civil.lan {" not in caddyfile
class TestCaddyfileTlsAcme:
"""gateway.tls=acme → host routes served under one *.domain wildcard site
@@ -322,10 +305,27 @@ class TestCaddyfileTlsAcme:
def test_wildcard_site_with_derived_host_matcher(self) -> None:
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
assert "*.civil.payne.io {" in caddyfile
# Subdomain derived from the SERVICE NAME (claw), not the declared .lan host.
# Published name = the host's first label under the gateway domain.
assert "@host_claw host claw.civil.payne.io" in caddyfile
assert "reverse_proxy localhost:18789" in caddyfile
def test_subdomain_from_host_label_not_service_name(self) -> None:
# Service is named "openclaw" but declares host label "claw" → the label
# wins (published as claw.<domain>), so the declared name is authoritative.
registry = _make_registry(
gateway_tls="acme",
gateway_domain="civil.payne.io",
acme_email="paul@example.com",
deployed={
"openclaw": Deployment(
runner="node", run_cmd=["c"], port=18789, proxy_host="claw"
),
},
)
caddyfile = generate_caddyfile_from_registry(registry)
assert "host claw.civil.payne.io" in caddyfile
assert "openclaw.civil.payne.io" not in caddyfile
def test_path_routes_stay_on_http_port(self) -> None:
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
assert ":9000 {" in caddyfile

View File

@@ -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).

View File

@@ -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
**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.
- **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`.
#### 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