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:
2026-06-29 08:13:17 -07:00
parent 12dea5651c
commit 0d9c1ae6c2
6 changed files with 189 additions and 8 deletions

View File

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