diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index 32d6556..fbbd311 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -110,7 +110,14 @@ class GatewayConfig: 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). + # "acme" → real Let's Encrypt wildcard cert (*.domain) via a DNS-01 challenge; + # publicly trusted, no CA to install. tls: str | None = None + # acme mode only: the zone for the wildcard cert and host-route subdomains + # (e.g. "civil.payne.io" → host routes become .civil.payne.io). + domain: str | None = None + acme_email: str | None = None + acme_dns_provider: str = "cloudflare" @dataclass @@ -253,6 +260,9 @@ def load_config(root: Path | None = None) -> CastleConfig: gateway = GatewayConfig( port=gateway_data.get("port", 9000), tls=gateway_data.get("tls"), + domain=gateway_data.get("domain"), + acme_email=gateway_data.get("acme_email"), + acme_dns_provider=gateway_data.get("acme_dns_provider", "cloudflare"), ) # repo: field points to the git repo for repo-relative sources @@ -397,6 +407,13 @@ def save_config(config: CastleConfig) -> None: gateway_data: dict = {"port": config.gateway.port} if config.gateway.tls: gateway_data["tls"] = config.gateway.tls + if config.gateway.domain: + gateway_data["domain"] = config.gateway.domain + if config.gateway.acme_email: + gateway_data["acme_email"] = config.gateway.acme_email + # Only persist the provider when non-default, to keep castle.yaml minimal. + if config.gateway.acme_dns_provider and config.gateway.acme_dns_provider != "cloudflare": + gateway_data["acme_dns_provider"] = config.gateway.acme_dns_provider 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 2f6b2c6..8c9c29e 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -16,6 +16,7 @@ from pathlib import Path from castle_core.config import ( DATA_DIR, + SECRETS_DIR, SPECS_DIR, CastleConfig, ensure_dirs, @@ -23,6 +24,7 @@ from castle_core.config import ( resolve_env_split, ) from castle_core.generators.caddyfile import ( + _DNS_TOKEN_ENV, generate_caddyfile_from_registry, service_proxy_targets, ) @@ -77,6 +79,9 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe castle_root=str(config.root), gateway_port=config.gateway.port, gateway_tls=config.gateway.tls, + gateway_domain=config.gateway.domain, + acme_email=config.gateway.acme_email, + acme_dns_provider=config.gateway.acme_dns_provider, ) # Load existing registry to preserve entries not being redeployed, @@ -129,6 +134,11 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe caddyfile_path.write_text(caddyfile_content) result.messages.append(f"Caddyfile written: {caddyfile_path}") + # acme mode needs a domain + a DNS-provider token; warn (don't fail) if the + # prerequisites the operator must set up by hand are missing. + if (config.gateway.tls or "").lower() == "acme": + _acme_preflight(config, result.messages) + # Reload systemd daemon subprocess.run(["systemctl", "--user", "daemon-reload"], check=False) @@ -145,6 +155,35 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe _GATEWAY_NAME = "castle-gateway" +def _acme_preflight(config: CastleConfig, messages: list[str]) -> None: + """Warn (never fail, never write) if acme-mode prerequisites are missing. + + acme mode needs a `gateway.domain`, the DNS-provider token mapped into the + castle-gateway service env, and the matching secret on disk — all operator + steps (castle never rewrites the user-authored gateway service YAML).""" + gw = config.gateway + if not gw.domain: + messages.append( + "Warning: gateway.tls=acme but gateway.domain is unset — host routes " + "won't get a wildcard cert (serving plain HTTP on the gateway port)." + ) + return + token_env = _DNS_TOKEN_ENV.get(gw.acme_dns_provider or "cloudflare", "CLOUDFLARE_API_TOKEN") + svc = config.services.get(_GATEWAY_NAME) + env = dict(svc.defaults.env) if (svc and svc.defaults and svc.defaults.env) else {} + if token_env not in env: + messages.append( + f"Warning: acme mode needs {token_env} in the {_GATEWAY_NAME} service env. " + f"Add to services/{_GATEWAY_NAME}.yaml → defaults.env: " + f"{token_env}: ${{secret:{token_env}}}" + ) + if not (SECRETS_DIR / token_env).exists(): + messages.append( + f"Warning: secret '{token_env}' not found in {SECRETS_DIR} — place the " + f"DNS-provider API token there (Cloudflare token scope: Zone:DNS:Edit)." + ) + + def _reload_gateway(messages: list[str]) -> None: """Reload Caddy if the gateway is running, so new routes take effect.""" gw_unit = unit_name(_GATEWAY_NAME) diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index 3440be0..790c010 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -14,6 +14,7 @@ a **target**, of one **kind**: from __future__ import annotations +import os from dataclasses import dataclass from pathlib import Path @@ -21,6 +22,14 @@ from castle_core.config import SPECS_DIR, CastleConfig from castle_core.manifest import ServiceSpec from castle_core.registry import NodeRegistry +# DNS-01 provider → the env var the Caddyfile reads its API token from. The token +# reaches Caddy via the gateway service's defaults.env (a mode-0600 EnvironmentFile). +_DNS_TOKEN_ENV = {"cloudflare": "CLOUDFLARE_API_TOKEN"} + +# Let's Encrypt staging directory — opt in via CASTLE_ACME_STAGING=1 to avoid the +# production rate limits while testing, then cut over to prod (unset the env var). +_ACME_STAGING_CA = "https://acme-staging-v02.api.letsencrypt.org/directory" + @dataclass class GatewayRoute: @@ -156,13 +165,28 @@ def compute_routes( return routes +def _host_matcher_block(label: str, host: str, target: str) -> list[str]: + """A `@host_X host / handle @host_X { reverse_proxy }` block. + + Shared by the off-mode `:` site and the acme wildcard site so the two + can't drift. `label` names the matcher (service name, or the address).""" + matcher = f"@host_{label.replace('-', '_').replace('.', '_')}" + return [ + f" {matcher} host {host}", + f" handle {matcher} {{", + f" reverse_proxy {target}", + " }", + "", + ] + + def generate_caddyfile_from_registry( registry: NodeRegistry, remote_registries: dict[str, NodeRegistry] | None = None, ) -> str: """Render the route list to a Caddyfile. - Two modes, set by `gateway.tls`: + Three 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 @@ -172,15 +196,44 @@ def generate_caddyfile_from_registry( 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. + - **acme** — host routes are served under a single `*.` site with a + 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 + host route maps to `.`. Requires `gateway.domain`; + the DNS provider token reaches Caddy via `{env.}`. """ routes = compute_routes(registry, None, remote_registries) - gw_port = registry.node.gateway_port - tls_internal = (registry.node.gateway_tls or "").lower() == "internal" + node = registry.node + gw_port = node.gateway_port + mode = (node.gateway_tls or "").lower() + tls_internal = mode == "internal" + domain = node.gateway_domain + tls_acme = mode == "acme" and bool(domain) # acme without a domain → off-mode host_routes = [r for r in routes if r.is_host] lines: list[str] = [] - if tls_internal: + if tls_acme: + # Global ACME options: LE account email + DNS-01 provider token (from env). + provider = node.acme_dns_provider or "cloudflare" + token_env = _DNS_TOKEN_ENV.get(provider, "CLOUDFLARE_API_TOKEN") + lines.append("{") + if node.acme_email: + lines.append(f" email {node.acme_email}") + lines.append(f" acme_dns {provider} {{env.{token_env}}}") + if os.environ.get("CASTLE_ACME_STAGING") == "1": + lines.append(f" acme_ca {_ACME_STAGING_CA}") + lines += ["}", ""] + # One wildcard site → a single DNS-01 cert covers every host route, so a + # new host-routed service needs no new cert or challenge. + if host_routes: + lines.append(f"*.{domain} {{") + for r in host_routes: + sub = r.name or r.address.split(".")[0] + lines += _host_matcher_block(r.name or r.address, f"{sub}.{domain}", r.target) + 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). @@ -195,6 +248,9 @@ def generate_caddyfile_from_registry( else: # HTTP-only: keep auto-HTTPS off so the bare-port site stays plain HTTP # and named hosts don't trigger cert provisioning. + if mode == "acme" and not domain: + lines.append("# gateway.tls=acme but gateway.domain is unset — host routes") + lines.append("# fall back to plain-HTTP matchers on the gateway port.") lines += ["{", " auto_https off", "}", ""] lines.append(f":{gw_port} {{") @@ -224,16 +280,9 @@ 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}", - f" handle {matcher} {{", - f" reverse_proxy {r.target}", - " }", - "", - ] + if tls_internal or tls_acme: + continue # emitted as its own HTTPS / wildcard site above + lines += _host_matcher_block(r.name or r.address, r.address, r.target) else: # path-prefix proxy (local or remote) if r.kind == "remote": lines.append(f" # {r.name} on {r.node}") diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py index 3e14c2d..c5085a6 100644 --- a/core/src/castle_core/registry.py +++ b/core/src/castle_core/registry.py @@ -21,7 +21,12 @@ 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 + # None/"off" → HTTP-only; "internal" → Caddy local-CA HTTPS; "acme" → Let's + # Encrypt wildcard (*.gateway_domain) via DNS-01. + gateway_tls: str | None = None + gateway_domain: str | None = None # acme: zone for wildcard cert + host subdomains + acme_email: str | None = None + acme_dns_provider: str = "cloudflare" def __post_init__(self) -> None: if not self.hostname: @@ -85,6 +90,9 @@ def load_registry(path: Path | None = None) -> NodeRegistry: castle_root=node_data.get("castle_root"), gateway_port=node_data.get("gateway_port", 9000), gateway_tls=node_data.get("gateway_tls"), + gateway_domain=node_data.get("gateway_domain"), + acme_email=node_data.get("acme_email"), + acme_dns_provider=node_data.get("acme_dns_provider", "cloudflare"), ) deployed: dict[str, Deployment] = {} @@ -143,6 +151,12 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None: if registry.node.gateway_tls: data["node"]["gateway_tls"] = registry.node.gateway_tls + if registry.node.gateway_domain: + data["node"]["gateway_domain"] = registry.node.gateway_domain + if registry.node.acme_email: + data["node"]["acme_email"] = registry.node.acme_email + if registry.node.acme_dns_provider and registry.node.acme_dns_provider != "cloudflare": + data["node"]["acme_dns_provider"] = registry.node.acme_dns_provider for name, comp in registry.deployed.items(): entry: dict = { diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py index 4f00a22..86dab6e 100644 --- a/core/tests/test_caddyfile.py +++ b/core/tests/test_caddyfile.py @@ -38,10 +38,20 @@ def _make_registry( deployed: dict[str, Deployment] | None = None, gateway_port: int = 9000, gateway_tls: str | None = None, + gateway_domain: str | None = None, + acme_email: str | None = None, + acme_dns_provider: str = "cloudflare", ) -> NodeRegistry: """Create a test registry.""" return NodeRegistry( - node=NodeConfig(hostname="test", gateway_port=gateway_port, gateway_tls=gateway_tls), + node=NodeConfig( + hostname="test", + gateway_port=gateway_port, + gateway_tls=gateway_tls, + gateway_domain=gateway_domain, + acme_email=acme_email, + acme_dns_provider=acme_dns_provider, + ), deployed=deployed or {}, ) @@ -285,6 +295,62 @@ class TestCaddyfileTlsInternal: assert "claw.civil.lan {" not in caddyfile +class TestCaddyfileTlsAcme: + """gateway.tls=acme → host routes served under one *.domain wildcard site + with a Let's Encrypt cert via DNS-01.""" + + def _acme_registry(self, domain: str | None = "civil.payne.io") -> NodeRegistry: + return _make_registry( + gateway_tls="acme", + gateway_domain=domain, + acme_email="paul@example.com", + 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_global_email_and_acme_dns(self) -> None: + caddyfile = generate_caddyfile_from_registry(self._acme_registry()) + assert "email paul@example.com" in caddyfile + assert "acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}" in caddyfile + + def test_wildcard_site_with_derived_host_matcher(self) -> None: + caddyfile = generate_caddyfile_from_registry(self._acme_registry()) + assert "*.civil.payne.io {" in caddyfile + # Subdomain derived from the SERVICE NAME (claw), not the declared .lan host. + assert "@host_claw host claw.civil.payne.io" in caddyfile + assert "reverse_proxy localhost:18789" in caddyfile + + def test_path_routes_stay_on_http_port(self) -> None: + caddyfile = generate_caddyfile_from_registry(self._acme_registry()) + assert ":9000 {" in caddyfile + assert "handle_path /api/*" in caddyfile + + def test_no_auto_https_off_and_no_internal_ca(self) -> None: + caddyfile = generate_caddyfile_from_registry(self._acme_registry()) + assert "auto_https off" not in caddyfile + assert "tls internal" not in caddyfile + # The .lan host must not leak into the :9000 site as a plain matcher. + assert "claw.civil.lan" not in caddyfile + + def test_staging_toggle(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CASTLE_ACME_STAGING", "1") + caddyfile = generate_caddyfile_from_registry(self._acme_registry()) + assert "acme_ca https://acme-staging-v02.api.letsencrypt.org/directory" in caddyfile + + def test_acme_without_domain_falls_back_to_http(self) -> None: + # No domain → no *.None site; host route degrades to an off-mode matcher. + caddyfile = generate_caddyfile_from_registry(self._acme_registry(domain=None)) + assert "*." not in caddyfile + assert "auto_https off" in caddyfile + assert "@host_claw host claw.civil.lan" in caddyfile + + class TestCaddyfileRemoteRegistries: """Tests for cross-node routing in Caddyfile.""" diff --git a/docs/registry.md b/docs/registry.md index 4d6b8c5..0cec34a 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -340,7 +340,17 @@ 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"). +version number"). `gateway.tls` opts host routes into HTTPS: + +| `gateway.tls` | listener | host routes | cert / trust | +|---------------|----------|-------------|--------------| +| `off` (default/unset) | `:` HTTP, `auto_https off` | host matcher on `:` | none | +| `internal` | per-host `:443` HTTPS | own `tls internal` site | Caddy **local CA** — must distribute root.crt to clients | +| `acme` | one `*.` `: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 +and static routes always stay on the HTTP `:` site. Both HTTPS modes need the +443/80 bind below. #### HTTPS for host routes — `gateway.tls: internal` @@ -381,6 +391,74 @@ Two operational requirements: 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` + +`internal` mode forces every client device to trust a private CA — which some +platforms (e.g. Android browsers) make painful. `acme` mode avoids it entirely: +Caddy obtains a **real Let's Encrypt wildcard cert** (`*.`) via a **DNS-01** +challenge, so every browser trusts it with **zero CA install** — while the services +stay **internal-only**. + +```yaml +gateway: + port: 9000 + tls: acme + domain: civil.payne.io # wildcard cert *.civil.payne.io; host routes → .civil.payne.io + acme_email: you@example.com + acme_dns_provider: cloudflare # default +``` + +```caddyfile +{ + email you@example.com + acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN} +} + +*.civil.payne.io { + @host_claw host claw.civil.payne.io + handle @host_claw { + reverse_proxy localhost:18789 + } +} +``` + +How it stays internal-only: DNS-01 proves domain ownership by having Caddy write a +transient `_acme-challenge` TXT to the **public** zone via the DNS provider API — +it needs **no inbound exposure and no public A records** for the services. Only your +**LAN DNS** resolves `*.` to the gateway's private IP. (HTTP-01 can't +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 with `proxy.caddy.host` (its literal value is ignored in acme mode), and +the route is published at `.`. One `*.` 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): + +- **DNS-plugin Caddy.** Stock Caddy has no DNS modules; build one with the + provider plugin: `./install.sh --with-dns-plugin=cloudflare` (uses `xcaddy`, + installs to `/usr/local/bin/caddy`, which the gateway picks up on next deploy). +- **Provider token.** Store a scoped API token as the `CLOUDFLARE_API_TOKEN` + secret (Cloudflare scope: **Zone → DNS → Edit**), and map it into the gateway + service env — add to `services/castle-gateway.yaml`: + ```yaml + defaults: + env: + CLOUDFLARE_API_TOKEN: ${secret:CLOUDFLARE_API_TOKEN} + ``` + `castle deploy` warns if the domain, this env var, or the secret is missing. +- **LAN DNS.** Point `*.` at the gateway's private IP on your LAN + resolver. For a `*.payne.io` subdomain that's **wild-central's dnsmasq** (the + router already forwards `*.payne.io` there): `address=/civil.payne.io/`. + The public zone gets no A records, so services aren't externally reachable. +- **Staging first.** Set `CASTLE_ACME_STAGING=1` to use Let's Encrypt's staging CA + (its rate limits are generous) while verifying issuance, then unset it and + redeploy to get a browser-trusted production cert. Verify with + `openssl s_client -connect :443 -servername claw. | openssl x509 -noout -issuer`. + +The 443/80 bind requirement (above) applies to acme too. Unlike `internal`, there's +no CA to distribute — the dashboard's CA-download button is `internal`-only. + 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. diff --git a/install.sh b/install.sh index fae203d..9af5bb9 100755 --- a/install.sh +++ b/install.sh @@ -162,6 +162,51 @@ ensure_caddy() { log_ok } +# Pinned Caddy version for the DNS-plugin build (reproducible across nodes). +CADDY_DNS_VERSION="${CADDY_DNS_VERSION:-v2.10.0}" + +# Build a Caddy with a DNS-provider plugin, required for gateway.tls=acme +# (Let's Encrypt wildcard via DNS-01). Stock apt Caddy has no DNS modules. The +# result goes to /usr/local/bin/caddy, which precedes /usr/bin on PATH, so the +# gateway (a `command` runner resolving `caddy` via PATH) picks it up on the next +# `castle deploy` with no spec change. Idempotent and opt-in (--with-dns-plugin). +ensure_caddy_dns_plugin() { + local provider="${1:-cloudflare}" + local module + case "$provider" in + cloudflare) module="github.com/caddy-dns/cloudflare" ;; + *) log_fail "Unknown DNS provider '$provider' — add its caddy-dns module to install.sh" ;; + esac + + log_step "Ensuring Caddy with $provider DNS plugin (for gateway.tls=acme)" + if [ -x /usr/local/bin/caddy ] \ + && /usr/local/bin/caddy list-modules 2>/dev/null | grep -q "dns.providers.$provider"; then + log_skip "already present at /usr/local/bin/caddy" + return + fi + + cmd_exists go || log_fail "Go toolchain required to build the DNS-plugin Caddy" + + local gobin; gobin="$(go env GOPATH)/bin" + if [ ! -x "$gobin/xcaddy" ]; then + log_info "Installing xcaddy..." + go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest >/dev/null 2>&1 \ + || log_fail "xcaddy install failed" + fi + + log_info "Building Caddy $CADDY_DNS_VERSION with $module (~1 min)..." + local tmp; tmp="$(mktemp -d)" + ( cd "$tmp" && "$gobin/xcaddy" build "$CADDY_DNS_VERSION" --with "$module" ) \ + || { rm -rf "$tmp"; log_fail "xcaddy build failed"; } + sudo install -m 0755 "$tmp/caddy" /usr/local/bin/caddy || { rm -rf "$tmp"; log_fail "install failed"; } + rm -rf "$tmp" + + /usr/local/bin/caddy list-modules 2>/dev/null | grep -q "dns.providers.$provider" \ + || log_fail "built caddy is missing dns.providers.$provider" + log_info "Built /usr/local/bin/caddy — run 'castle deploy && castle gateway restart' to use it." + log_ok +} + # --------------------------------------------------------------------------- # Directory structure # --------------------------------------------------------------------------- @@ -394,9 +439,12 @@ main() { # Parse args AUTO_YES=0 + WITH_DNS_PLUGIN="" # e.g. "cloudflare" → also build a DNS-plugin Caddy for acme TLS for arg in "$@"; do case "$arg" in --yes|-y) AUTO_YES=1 ;; + --with-dns-plugin) WITH_DNS_PLUGIN="cloudflare" ;; + --with-dns-plugin=*) WITH_DNS_PLUGIN="${arg#*=}" ;; *) printf "Unknown argument: %s\n" "$arg"; exit 1 ;; esac done @@ -405,6 +453,7 @@ main() { check_systemd ensure_docker ensure_caddy + [ -n "$WITH_DNS_PLUGIN" ] && ensure_caddy_dns_plugin "$WITH_DNS_PLUGIN" create_directories enable_lingering seed_caddyfile