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:
2026-06-30 19:55:17 -07:00
parent fa3a646064
commit a022a136fe
9 changed files with 113 additions and 257 deletions

View File

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

View File

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

View File

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

View File

@@ -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."""

View File

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

View File

@@ -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":

View File

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

View File

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

View File

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