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."""
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `:<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)
|
||||
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}",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user