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:
@@ -1,9 +1,8 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
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 type { GatewayInfo, HealthStatus } from "@/types"
|
||||||
import { useGatewayReload, useCaddyfile } from "@/services/api/hooks"
|
import { useGatewayReload, useCaddyfile } from "@/services/api/hooks"
|
||||||
import { apiClient } from "@/services/api/client"
|
|
||||||
import { HealthBadge } from "./HealthBadge"
|
import { HealthBadge } from "./HealthBadge"
|
||||||
|
|
||||||
interface GatewayPanelProps {
|
interface GatewayPanelProps {
|
||||||
@@ -34,20 +33,6 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<button
|
||||||
onClick={() => reload()}
|
onClick={() => reload()}
|
||||||
disabled={reloading}
|
disabled={reloading}
|
||||||
|
|||||||
@@ -124,8 +124,7 @@ export interface GatewayInfo {
|
|||||||
service_count: number
|
service_count: number
|
||||||
managed_count: number
|
managed_count: number
|
||||||
routes: GatewayRoute[]
|
routes: GatewayRoute[]
|
||||||
tls?: string | null // "internal" → host routes served over HTTPS by Caddy's local CA
|
tls?: string | null // "acme" → host routes served over HTTPS with a Let's Encrypt wildcard
|
||||||
ca_fingerprint?: string | null // SHA-256 of the downloadable root CA
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceActionResponse {
|
export interface ServiceActionResponse {
|
||||||
|
|||||||
@@ -156,11 +156,9 @@ class GatewayInfo(BaseModel):
|
|||||||
service_count: int
|
service_count: int
|
||||||
managed_count: int
|
managed_count: int
|
||||||
routes: list[GatewayRoute] = []
|
routes: list[GatewayRoute] = []
|
||||||
# TLS mode (None/"off" → HTTP-only; "internal" → Caddy local-CA HTTPS for host
|
# TLS mode: None/"off" → HTTP-only; "acme" → Let's Encrypt wildcard (publicly
|
||||||
# routes). When "internal", ca_fingerprint is the SHA-256 of the root CA the
|
# trusted, no client CA setup) for host routes.
|
||||||
# dashboard offers for download so clients can trust *.lan HTTPS.
|
|
||||||
tls: str | None = None
|
tls: str | None = None
|
||||||
ca_fingerprint: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class NodeSummary(BaseModel):
|
class NodeSummary(BaseModel):
|
||||||
|
|||||||
@@ -3,15 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
import shutil
|
||||||
import ssl
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
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.config import SPECS_DIR
|
||||||
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
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.
|
# Caddyfile order is precedence-sensitive; the displayed table is alphabetical.
|
||||||
routes.sort(key=lambda r: r.address)
|
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(
|
return GatewayInfo(
|
||||||
port=registry.node.gateway_port,
|
port=registry.node.gateway_port,
|
||||||
hostname=registry.node.hostname,
|
hostname=registry.node.hostname,
|
||||||
@@ -913,8 +901,7 @@ def get_gateway() -> GatewayInfo:
|
|||||||
service_count=service_count,
|
service_count=service_count,
|
||||||
managed_count=managed_count,
|
managed_count=managed_count,
|
||||||
routes=routes,
|
routes=routes,
|
||||||
tls=tls,
|
tls=registry.node.gateway_tls,
|
||||||
ca_fingerprint=ca_fingerprint,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -925,66 +912,6 @@ def get_caddyfile() -> dict[str, str]:
|
|||||||
return {"content": generate_caddyfile_from_registry(registry)}
|
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")
|
@router.post("/gateway/reload")
|
||||||
async def reload_gateway() -> dict[str, str]:
|
async def reload_gateway() -> dict[str, str]:
|
||||||
"""Regenerate Caddyfile and reload Caddy."""
|
"""Regenerate Caddyfile and reload Caddy."""
|
||||||
|
|||||||
@@ -270,15 +270,9 @@ class TestGateway:
|
|||||||
assert r["kind"] in ("static", "proxy", "remote")
|
assert r["kind"] in ("static", "proxy", "remote")
|
||||||
|
|
||||||
def test_gateway_tls_off_by_default(self, client: TestClient) -> None:
|
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()
|
data = client.get("/gateway").json()
|
||||||
assert data["tls"] is None
|
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:
|
class TestConfigEditor:
|
||||||
|
|||||||
@@ -186,27 +186,23 @@ def generate_caddyfile_from_registry(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Render the route list to a Caddyfile.
|
"""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 +
|
- **off (default)** — HTTP-only. Everything (host matchers + path prefixes +
|
||||||
static) lives in one `:<port>` site with `auto_https off`, so a named host
|
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.
|
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
|
- **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
|
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
|
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>}`.
|
the DNS provider token reaches Caddy via `{env.<TOKEN>}`.
|
||||||
"""
|
"""
|
||||||
routes = compute_routes(registry, None, remote_registries)
|
routes = compute_routes(registry, None, remote_registries)
|
||||||
node = registry.node
|
node = registry.node
|
||||||
gw_port = node.gateway_port
|
gw_port = node.gateway_port
|
||||||
mode = (node.gateway_tls or "").lower()
|
mode = (node.gateway_tls or "").lower()
|
||||||
tls_internal = mode == "internal"
|
|
||||||
domain = node.gateway_domain
|
domain = node.gateway_domain
|
||||||
tls_acme = mode == "acme" and bool(domain) # acme without a domain → off-mode
|
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.append(f" acme_ca {_ACME_STAGING_CA}")
|
||||||
lines += ["}", ""]
|
lines += ["}", ""]
|
||||||
# One wildcard site → a single DNS-01 cert covers every host route, so a
|
# 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:
|
if host_routes:
|
||||||
lines.append(f"*.{domain} {{")
|
lines.append(f"*.{domain} {{")
|
||||||
for r in host_routes:
|
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 += _host_matcher_block(r.name or r.address, f"{sub}.{domain}", r.target)
|
||||||
lines.append("}")
|
lines.append("}")
|
||||||
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:
|
else:
|
||||||
# HTTP-only: keep auto-HTTPS off so the bare-port site stays plain HTTP
|
# HTTP-only: keep auto-HTTPS off so the bare-port site stays plain HTTP
|
||||||
# and named hosts don't trigger cert provisioning.
|
# and named hosts don't trigger cert provisioning.
|
||||||
@@ -280,8 +266,8 @@ def generate_caddyfile_from_registry(
|
|||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
elif r.is_host: # host-based proxy
|
elif r.is_host: # host-based proxy
|
||||||
if tls_internal or tls_acme:
|
if tls_acme:
|
||||||
continue # emitted as its own HTTPS / wildcard site above
|
continue # emitted in the *.<domain> wildcard site above
|
||||||
lines += _host_matcher_block(r.name or r.address, r.address, r.target)
|
lines += _host_matcher_block(r.name or r.address, r.address, r.target)
|
||||||
else: # path-prefix proxy (local or remote)
|
else: # path-prefix proxy (local or remote)
|
||||||
if r.kind == "remote":
|
if r.kind == "remote":
|
||||||
|
|||||||
@@ -234,12 +234,11 @@ class TestLocalRoutesFromConfig:
|
|||||||
assert "reverse_proxy localhost:8001" in caddyfile
|
assert "reverse_proxy localhost:8001" in caddyfile
|
||||||
|
|
||||||
|
|
||||||
class TestCaddyfileTlsInternal:
|
class TestCaddyfileOffMode:
|
||||||
"""gateway.tls=internal → host routes become their own HTTPS sites."""
|
"""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(
|
return _make_registry(
|
||||||
gateway_tls=tls,
|
|
||||||
deployed={
|
deployed={
|
||||||
"claw": Deployment(
|
"claw": Deployment(
|
||||||
runner="node", run_cmd=["claw"], port=18789, proxy_host="claw.civil.lan"
|
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:
|
def test_host_matcher_and_auto_https_off(self) -> None:
|
||||||
caddyfile = generate_caddyfile_from_registry(self._host_registry("internal"))
|
caddyfile = generate_caddyfile_from_registry(self._host_registry())
|
||||||
assert "claw.civil.lan {" in caddyfile
|
assert "auto_https off" in caddyfile
|
||||||
assert "tls internal" in caddyfile
|
assert "@host_claw host claw.civil.lan" in caddyfile
|
||||||
assert "reverse_proxy localhost:18789" 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:
|
def test_path_routes_on_http_port(self) -> None:
|
||||||
"""The Supabase substrate (compose runner + host route) becomes its own
|
caddyfile = generate_caddyfile_from_registry(self._host_registry())
|
||||||
HTTPS site under tls:internal — routing is runner-agnostic."""
|
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(
|
registry = _make_registry(
|
||||||
gateway_tls="internal",
|
|
||||||
deployed={
|
deployed={
|
||||||
"supabase": Deployment(
|
"supabase": Deployment(
|
||||||
runner="compose",
|
runner="compose",
|
||||||
@@ -271,29 +274,9 @@ class TestCaddyfileTlsInternal:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
caddyfile = generate_caddyfile_from_registry(registry)
|
caddyfile = generate_caddyfile_from_registry(registry)
|
||||||
assert "supabase.lan {" in caddyfile
|
assert "@host_supabase host supabase.lan" in caddyfile
|
||||||
assert "tls internal" in caddyfile
|
|
||||||
assert "reverse_proxy localhost:8000" 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:
|
class TestCaddyfileTlsAcme:
|
||||||
"""gateway.tls=acme → host routes served under one *.domain wildcard site
|
"""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:
|
def test_wildcard_site_with_derived_host_matcher(self) -> None:
|
||||||
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
|
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
|
||||||
assert "*.civil.payne.io {" in caddyfile
|
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 "@host_claw host claw.civil.payne.io" in caddyfile
|
||||||
assert "reverse_proxy localhost:18789" 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:
|
def test_path_routes_stay_on_http_port(self) -> None:
|
||||||
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
|
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
|
||||||
assert ":9000 {" in caddyfile
|
assert ":9000 {" in caddyfile
|
||||||
|
|||||||
@@ -85,37 +85,28 @@ subdomain can.
|
|||||||
Pin the node's IP with a **DHCP reservation** — the wildcard hardcodes it, so a
|
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.
|
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
|
`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 |
|
| `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 |
|
| `off` *(default)* | plain HTTP on `:9000` | none | you don't need HTTPS; a node with no public domain |
|
||||||
| `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; any/multiple devices (phones, etc.) |
|
||||||
| `acme` | HTTPS from a **real Let's Encrypt wildcard** | **nothing** | you own a domain; multiple devices (phones, etc.) |
|
|
||||||
|
|
||||||
### `off` — plain HTTP
|
### `off` — plain HTTP
|
||||||
|
|
||||||
The gateway generates `auto_https off` and listens on a bare `:9000`. Reach it at
|
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
|
`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
|
> 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
|
||||||
Each host route becomes its own `tls internal` HTTPS site, signed by a CA Caddy
|
> browsers; Firefox, which uses its own store) make painful — the exact problem
|
||||||
generates on the node. Browsers get a real secure context — but only if they
|
> `acme` solves without any client setup.
|
||||||
**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.
|
|
||||||
|
|
||||||
### `acme` — real Let's Encrypt wildcard via DNS-01
|
### `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
|
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.
|
**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 subdomains come from the **first label of `proxy.caddy.host`**: a
|
||||||
host route with `proxy.caddy.host`, and it's published at `<service>.<domain>`.
|
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
|
This is the recommended mode when you own a domain and want to reach services from
|
||||||
arbitrary devices.
|
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://`
|
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**,
|
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
|
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
|
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
|
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
|
## Putting a service on trusted HTTPS — the recipe
|
||||||
|
|
||||||
1. **Give it a host route.** In the service's `proxy.caddy`, set `host:` (drop any
|
1. **Give it a host route.** In the service's `proxy.caddy`, set `host:` to the
|
||||||
`path_prefix`). The literal host value is used as-is in `internal` mode; in
|
subdomain **label** you want (`host: claw`), and drop any `path_prefix`. In
|
||||||
`acme` mode the published name is derived as `<service>.<domain>`.
|
`acme` mode the published name is `<label>.<gateway.domain>`.
|
||||||
2. **Make the name resolve.** Add (or rely on) the LAN wildcard for the zone
|
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.
|
(§DNS). Verify: `dig +short <label>.<domain>` → the node's IP.
|
||||||
3. **Pick a trust mode** on the gateway (`gateway.tls`), plus the operational
|
3. **Set `gateway.tls: acme`** (with `domain`/`acme_email`), plus the operational
|
||||||
prerequisites for it (below).
|
prerequisites (below).
|
||||||
4. **Deploy & reload:** `castle deploy` regenerates the Caddyfile and reloads Caddy.
|
4. **Deploy & reload:** `castle deploy` regenerates the Caddyfile and reloads Caddy.
|
||||||
5. **Update the app's origin allowlist** if it has one (§secure context).
|
5. **Update the app's origin allowlist** if it has one (§secure context).
|
||||||
|
|
||||||
## Operational prerequisites
|
## Operational prerequisites
|
||||||
|
|
||||||
Both HTTPS modes need the gateway to bind privileged ports; `acme` also needs a
|
`acme` needs the gateway to bind privileged ports, plus a plugin-enabled Caddy and
|
||||||
plugin-enabled Caddy and a DNS token.
|
a DNS token.
|
||||||
|
|
||||||
- **Bind `:443`/`:80`.** Caddy serves HTTPS on `:443` (and redirects `:80`). A
|
- **Bind `:443`/`:80`.** Caddy serves HTTPS on `:443` (and redirects `:80`). A
|
||||||
user-level gateway can't bind privileged ports under `NoNewPrivileges`, so lower
|
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 |
|
| 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 quick internal tool, HTTP is fine | path prefix, or a `.lan`/bare 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 node with no public domain, needs a secure context | — | `off` | reach it via `http://localhost` / direct port on the node |
|
||||||
| a domain you own + many devices (phones) | `*.sub.domain` on the LAN resolver | `acme` | HTTPS, **no client setup**, internal-only |
|
| 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
|
The last row is the sweet spot for a personal LAN, and what this node runs today:
|
||||||
runs today: `*.civil.payne.io` (wild-central DNS) + a Let's Encrypt wildcard via
|
`*.civil.payne.io` (wild-central DNS) + a Let's Encrypt wildcard via Cloudflare
|
||||||
Cloudflare DNS-01, so e.g. `https://openclaw.civil.payne.io/` is trusted on any
|
DNS-01, so e.g. `https://claw.civil.payne.io/` is trusted on any device with
|
||||||
device with nothing to install.
|
nothing to install.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [registry.md — `proxy`, gateway routes, and the `gateway.tls` modes](registry.md#proxy--how-the-gateway-routes-to-it)
|
- [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).
|
||||||
|
|||||||
@@ -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
|
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/`,
|
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
|
**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 |
|
| `gateway.tls` | listener | host routes | cert / trust |
|
||||||
|---------------|----------|-------------|--------------|
|
|---------------|----------|-------------|--------------|
|
||||||
| `off` (default/unset) | `:<port>` HTTP, `auto_https off` | host matcher on `:<port>` | none |
|
| `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` | 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
|
Path-prefix and static routes always stay on the HTTP `:<port>` site — the way to
|
||||||
and static routes always stay on the HTTP `:<port>` site. Both HTTPS modes need the
|
put a service on HTTPS is to give it a `proxy.caddy.host`. A node with no public
|
||||||
443/80 bind below.
|
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**
|
**Bind 443/80.** The `acme` HTTPS site listens on `:443` (and redirects `:80`). A
|
||||||
becomes its own HTTPS site served by Caddy's local CA:
|
user-level gateway can't bind privileged ports under `NoNewPrivileges`, so lower
|
||||||
|
the floor once: `net.ipv4.ip_unprivileged_port_start=80` (persist in
|
||||||
```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.
|
`/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`
|
#### Publicly-trusted HTTPS — `gateway.tls: acme`
|
||||||
|
|
||||||
`internal` mode forces every client device to trust a private CA — which some
|
A private-CA approach (Caddy's `tls internal`) forces every client device to trust
|
||||||
platforms (e.g. Android browsers) make painful. `acme` mode avoids it entirely:
|
a custom root — which some platforms (e.g. Android browsers, and Firefox, which
|
||||||
Caddy obtains a **real Let's Encrypt wildcard cert** (`*.<domain>`) via a **DNS-01**
|
uses its own store) make painful. `acme` mode avoids it entirely: Caddy obtains a
|
||||||
challenge, so every browser trusts it with **zero CA install** — while the services
|
**real Let's Encrypt wildcard cert** (`*.<domain>`) via a **DNS-01** challenge, so
|
||||||
stay **internal-only**.
|
every browser trusts it with **zero CA install** — while the services stay
|
||||||
|
**internal-only**.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
gateway:
|
gateway:
|
||||||
port: 9000
|
port: 9000
|
||||||
tls: acme
|
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_email: you@example.com
|
||||||
acme_dns_provider: cloudflare # default
|
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
|
**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.)
|
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 subdomains come from the **first label of `proxy.caddy.host`**: a
|
||||||
host route with `proxy.caddy.host` (its literal value is ignored in acme mode), and
|
service declares `host: claw` (or a legacy `claw.civil.lan`) and is published at
|
||||||
the route is published at `<service-name>.<domain>`. One `*.<domain>` site means a
|
`claw.<domain>`. Only the label matters — the domain is the gateway's, so services
|
||||||
single cert covers every host route — adding a service needs no new cert.
|
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):
|
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
|
redeploy to get a browser-trusted production cert. Verify with
|
||||||
`openssl s_client -connect <ip>:443 -servername claw.<domain> | openssl x509 -noout -issuer`.
|
`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
|
The 443/80 bind requirement (above) applies here. There's no CA to distribute —
|
||||||
no CA to distribute — the dashboard's CA-download button is `internal`-only.
|
the wildcard is publicly trusted.
|
||||||
|
|
||||||
Routing only moves bytes — it does **not** supply the proxied app's own auth.
|
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
|
If a backend requires a token/credential (e.g. in the URL or a header), that
|
||||||
|
|||||||
Reference in New Issue
Block a user