diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index 6463d89..32d6556 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -108,6 +108,9 @@ class GatewayConfig: """Gateway configuration.""" 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 @@ -247,7 +250,10 @@ def load_config(root: Path | None = None) -> CastleConfig: data = yaml.safe_load(f) or {} 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_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: """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: data["repo"] = str(config.repo) diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 02bd8dc..4255589 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -73,7 +73,11 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe ensure_dirs() # 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, # or start fresh if deploying all diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index c3f9de3..3440be0 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -160,13 +160,44 @@ def generate_caddyfile_from_registry( registry: NodeRegistry, remote_registries: dict[str, NodeRegistry] | None = None, ) -> 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 `:` 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 ` { 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 `:` + site — give a service a `proxy.caddy.host` to put it on HTTPS. + """ routes = compute_routes(registry, None, remote_registries) 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 - # named host doesn't pull the listener into TLS or try to bind :80/:443. - lines = ["{", " auto_https off", "}", "", f":{gw_port} {{"] + host_routes = [r for r in routes if r.is_host] + lines: list[str] = [] + + 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`, # 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 + if tls_internal: + continue # emitted as its own HTTPS site block above matcher = f"@host_{(r.name or r.address).replace('-', '_').replace('.', '_')}" lines += [ f" {matcher} host {r.address}", diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py index 485aa61..74c8325 100644 --- a/core/src/castle_core/registry.py +++ b/core/src/castle_core/registry.py @@ -21,6 +21,7 @@ class NodeConfig: hostname: str = "" castle_root: str | None = None # repo path, for dev commands gateway_port: int = 9000 + gateway_tls: str | None = None # None/"off" → HTTP-only; "internal" → Caddy local-CA HTTPS def __post_init__(self) -> None: if not self.hostname: @@ -80,6 +81,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry: hostname=node_data.get("hostname", ""), castle_root=node_data.get("castle_root"), gateway_port=node_data.get("gateway_port", 9000), + gateway_tls=node_data.get("gateway_tls"), ) deployed: dict[str, Deployment] = {} @@ -135,6 +137,9 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None: if 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(): entry: dict = { "runner": comp.runner, diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py index 67fc900..2f9dcb8 100644 --- a/core/tests/test_caddyfile.py +++ b/core/tests/test_caddyfile.py @@ -37,10 +37,11 @@ def _isolate_config(monkeypatch: pytest.MonkeyPatch) -> None: def _make_registry( deployed: dict[str, Deployment] | None = None, gateway_port: int = 9000, + gateway_tls: str | None = None, ) -> NodeRegistry: """Create a test registry.""" 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 {}, ) @@ -223,6 +224,48 @@ class TestLocalRoutesFromConfig: 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: """Tests for cross-node routing in Caddyfile.""" diff --git a/docs/registry.md b/docs/registry.md index 0e5769f..bd50699 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -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 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 `` 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=/.lan/ # e.g. address=/civil.lan/192.168.8.222 +``` + +Pin `` 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 `:` (default `:9000`), so reach it at `http://: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 `:` +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 ```yaml