Add gateway.tls=internal: HTTPS for host routes via Caddy's local CA
The gateway was HTTP-only by design, but a plain-HTTP origin is not a browser
"secure context", so apps that need WebCrypto (device identity, E2E crypto)
can't run when proxied — only localhost or HTTPS qualifies.
Add an opt-in `gateway.tls: internal`. When set, each host route
(proxy.caddy.host) is emitted as its own `<host> { tls internal; reverse_proxy }`
site, served over HTTPS by Caddy's local CA (Caddy binds :443 and redirects :80).
Path-prefix/static routes stay on the HTTP :<port> site — give a service a host
route to put it on HTTPS. Default (unset/off) keeps the existing HTTP-only
Caddyfile (single :<port> block, auto_https off, host matchers inline).
Plumbing mirrors gateway_port: GatewayConfig.tls (castle.yaml load/save) →
NodeConfig.gateway_tls (registry load/save, set in deploy) → the Caddyfile
generator. Tests cover both modes; docs/registry.md documents the option plus
the two operational requirements (binding 443/80 via the unprivileged-port
sysctl, and trusting Caddy's root CA on clients) and the host-route-vs-prefix
guidance that motivates it.
This commit is contained in:
@@ -108,6 +108,9 @@ class GatewayConfig:
|
|||||||
"""Gateway configuration."""
|
"""Gateway configuration."""
|
||||||
|
|
||||||
port: int = 9000
|
port: int = 9000
|
||||||
|
# None/"off" → HTTP-only gateway. "internal" → Caddy serves host routes over
|
||||||
|
# HTTPS with its local CA (browsers get a secure context; trust the root CA).
|
||||||
|
tls: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -247,7 +250,10 @@ def load_config(root: Path | None = None) -> CastleConfig:
|
|||||||
data = yaml.safe_load(f) or {}
|
data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
gateway_data = data.get("gateway", {})
|
gateway_data = data.get("gateway", {})
|
||||||
gateway = GatewayConfig(port=gateway_data.get("port", 9000))
|
gateway = GatewayConfig(
|
||||||
|
port=gateway_data.get("port", 9000),
|
||||||
|
tls=gateway_data.get("tls"),
|
||||||
|
)
|
||||||
|
|
||||||
# repo: field points to the git repo for repo-relative sources
|
# repo: field points to the git repo for repo-relative sources
|
||||||
repo_path: Path | None = None
|
repo_path: Path | None = None
|
||||||
@@ -388,7 +394,10 @@ def _write_resource_dir(directory: Path, specs: dict[str, dict]) -> None:
|
|||||||
|
|
||||||
def save_config(config: CastleConfig) -> None:
|
def save_config(config: CastleConfig) -> None:
|
||||||
"""Save castle config: global castle.yaml + programs/, services/, jobs/ dirs."""
|
"""Save castle config: global castle.yaml + programs/, services/, jobs/ dirs."""
|
||||||
data: dict = {"gateway": {"port": config.gateway.port}}
|
gateway_data: dict = {"port": config.gateway.port}
|
||||||
|
if config.gateway.tls:
|
||||||
|
gateway_data["tls"] = config.gateway.tls
|
||||||
|
data: dict = {"gateway": gateway_data}
|
||||||
if config.repo:
|
if config.repo:
|
||||||
data["repo"] = str(config.repo)
|
data["repo"] = str(config.repo)
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
|
|||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
|
|
||||||
# Build node config
|
# Build node config
|
||||||
node = NodeConfig(castle_root=str(config.root), gateway_port=config.gateway.port)
|
node = NodeConfig(
|
||||||
|
castle_root=str(config.root),
|
||||||
|
gateway_port=config.gateway.port,
|
||||||
|
gateway_tls=config.gateway.tls,
|
||||||
|
)
|
||||||
|
|
||||||
# Load existing registry to preserve entries not being redeployed,
|
# Load existing registry to preserve entries not being redeployed,
|
||||||
# or start fresh if deploying all
|
# or start fresh if deploying all
|
||||||
|
|||||||
@@ -160,13 +160,44 @@ def generate_caddyfile_from_registry(
|
|||||||
registry: NodeRegistry,
|
registry: NodeRegistry,
|
||||||
remote_registries: dict[str, NodeRegistry] | None = None,
|
remote_registries: dict[str, NodeRegistry] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render the route list to a Caddyfile."""
|
"""Render the route list to a Caddyfile.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
routes = compute_routes(registry, None, remote_registries)
|
routes = compute_routes(registry, None, remote_registries)
|
||||||
gw_port = registry.node.gateway_port
|
gw_port = registry.node.gateway_port
|
||||||
|
tls_internal = (registry.node.gateway_tls or "").lower() == "internal"
|
||||||
|
|
||||||
# HTTP-only internal gateway on a non-standard port → disable auto-HTTPS so a
|
host_routes = [r for r in routes if r.is_host]
|
||||||
# named host doesn't pull the listener into TLS or try to bind :80/:443.
|
lines: list[str] = []
|
||||||
lines = ["{", " auto_https off", "}", "", f":{gw_port} {{"]
|
|
||||||
|
if 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.
|
||||||
|
lines += ["{", " auto_https off", "}", ""]
|
||||||
|
|
||||||
|
lines.append(f":{gw_port} {{")
|
||||||
|
|
||||||
# Trailing-slash redirect: `handle_path /foo/*` doesn't match the bare `/foo`,
|
# Trailing-slash redirect: `handle_path /foo/*` doesn't match the bare `/foo`,
|
||||||
# so without this the no-slash form falls through to the root catch-all
|
# so without this the no-slash form falls through to the root catch-all
|
||||||
@@ -193,6 +224,8 @@ def generate_caddyfile_from_registry(
|
|||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
elif r.is_host: # host-based proxy
|
elif r.is_host: # host-based proxy
|
||||||
|
if tls_internal:
|
||||||
|
continue # emitted as its own HTTPS site block above
|
||||||
matcher = f"@host_{(r.name or r.address).replace('-', '_').replace('.', '_')}"
|
matcher = f"@host_{(r.name or r.address).replace('-', '_').replace('.', '_')}"
|
||||||
lines += [
|
lines += [
|
||||||
f" {matcher} host {r.address}",
|
f" {matcher} host {r.address}",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class NodeConfig:
|
|||||||
hostname: str = ""
|
hostname: str = ""
|
||||||
castle_root: str | None = None # repo path, for dev commands
|
castle_root: str | None = None # repo path, for dev commands
|
||||||
gateway_port: int = 9000
|
gateway_port: int = 9000
|
||||||
|
gateway_tls: str | None = None # None/"off" → HTTP-only; "internal" → Caddy local-CA HTTPS
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not self.hostname:
|
if not self.hostname:
|
||||||
@@ -80,6 +81,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
|||||||
hostname=node_data.get("hostname", ""),
|
hostname=node_data.get("hostname", ""),
|
||||||
castle_root=node_data.get("castle_root"),
|
castle_root=node_data.get("castle_root"),
|
||||||
gateway_port=node_data.get("gateway_port", 9000),
|
gateway_port=node_data.get("gateway_port", 9000),
|
||||||
|
gateway_tls=node_data.get("gateway_tls"),
|
||||||
)
|
)
|
||||||
|
|
||||||
deployed: dict[str, Deployment] = {}
|
deployed: dict[str, Deployment] = {}
|
||||||
@@ -135,6 +137,9 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
|||||||
if registry.node.castle_root:
|
if registry.node.castle_root:
|
||||||
data["node"]["castle_root"] = registry.node.castle_root
|
data["node"]["castle_root"] = registry.node.castle_root
|
||||||
|
|
||||||
|
if registry.node.gateway_tls:
|
||||||
|
data["node"]["gateway_tls"] = registry.node.gateway_tls
|
||||||
|
|
||||||
for name, comp in registry.deployed.items():
|
for name, comp in registry.deployed.items():
|
||||||
entry: dict = {
|
entry: dict = {
|
||||||
"runner": comp.runner,
|
"runner": comp.runner,
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ def _isolate_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
def _make_registry(
|
def _make_registry(
|
||||||
deployed: dict[str, Deployment] | None = None,
|
deployed: dict[str, Deployment] | None = None,
|
||||||
gateway_port: int = 9000,
|
gateway_port: int = 9000,
|
||||||
|
gateway_tls: str | None = None,
|
||||||
) -> NodeRegistry:
|
) -> NodeRegistry:
|
||||||
"""Create a test registry."""
|
"""Create a test registry."""
|
||||||
return NodeRegistry(
|
return NodeRegistry(
|
||||||
node=NodeConfig(hostname="test", gateway_port=gateway_port),
|
node=NodeConfig(hostname="test", gateway_port=gateway_port, gateway_tls=gateway_tls),
|
||||||
deployed=deployed or {},
|
deployed=deployed or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -223,6 +224,48 @@ class TestLocalRoutesFromConfig:
|
|||||||
assert "reverse_proxy localhost:8001" in caddyfile
|
assert "reverse_proxy localhost:8001" in caddyfile
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaddyfileTlsInternal:
|
||||||
|
"""gateway.tls=internal → host routes become their own HTTPS sites."""
|
||||||
|
|
||||||
|
def _host_registry(self, tls: str | None) -> NodeRegistry:
|
||||||
|
return _make_registry(
|
||||||
|
gateway_tls=tls,
|
||||||
|
deployed={
|
||||||
|
"claw": Deployment(
|
||||||
|
runner="node", run_cmd=["claw"], port=18789, proxy_host="claw.civil.lan"
|
||||||
|
),
|
||||||
|
"api": Deployment(
|
||||||
|
runner="python", run_cmd=["api"], port=9020, proxy_path="/api"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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_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 TestCaddyfileRemoteRegistries:
|
class TestCaddyfileRemoteRegistries:
|
||||||
"""Tests for cross-node routing in Caddyfile."""
|
"""Tests for cross-node routing in Caddyfile."""
|
||||||
|
|
||||||
|
|||||||
@@ -278,6 +278,93 @@ 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
|
complete table (all kinds) is shown by `castle gateway status`, the dashboard
|
||||||
Gateway panel, and `GET /gateway`; the Caddyfile is generated from it.
|
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.)
|
||||||
|
|
||||||
|
#### Host routes need DNS, and the gateway is HTTP-only
|
||||||
|
|
||||||
|
A host route only does something once `<host>` resolves **to this node**. For a
|
||||||
|
LAN `.lan` zone that's the LAN's DNS authority (typically the router that hands
|
||||||
|
out `.lan` DHCP names) — not necessarily any central/mesh resolver. A single
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
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").
|
||||||
|
|
||||||
|
#### HTTPS for host routes — `gateway.tls: internal`
|
||||||
|
|
||||||
|
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
|
||||||
|
`~/.local/share/caddy/pki/authorities/local/root.crt` 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.)
|
||||||
|
|
||||||
|
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
|
||||||
|
stays the client's responsibility through the gateway exactly as it would direct.
|
||||||
|
A host served over HTTPS also has its own **origin** (`https://foo.lan`, no port);
|
||||||
|
an app that allowlists origins must include it.
|
||||||
|
|
||||||
### `manage` — How to manage it
|
### `manage` — How to manage it
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
Reference in New Issue
Block a user