Add gateway.tls=acme: Let's Encrypt wildcard cert via DNS-01

internal-CA HTTPS forces every device to trust a private root, which breaks down
on some clients (Android browsers). acme mode instead obtains a real Let's Encrypt
wildcard cert (*.<domain>) via a DNS-01 challenge, so browsers trust it with zero
CA install — while services stay internal-only (no inbound exposure, no public A
records; only a transient _acme-challenge TXT touches the public zone, and LAN DNS
resolves the names to a private IP).

- Config: GatewayConfig/NodeConfig gain domain, acme_email, acme_dns_provider
  (plumbed through load/save + registry + deploy, omitted-when-empty for
  round-trip stability). tls stays the discriminator: off | internal | acme.
- Caddyfile generator: acme branch emits a global {email, acme_dns <provider>
  {env.TOKEN}} block + one *.<domain> wildcard site with host matchers derived
  from the service name (<name>.<domain>); path/static stay on :<port>. Shared
  _host_matcher_block helper (reused by off-mode). CASTLE_ACME_STAGING=1 toggles
  the LE staging CA; acme-without-domain falls back to HTTP with a warning.
- deploy(): _acme_preflight warns (never writes) when domain, the gateway-service
  token env, or the CLOUDFLARE_API_TOKEN secret is missing.
- install.sh: opt-in --with-dns-plugin[=provider] builds a DNS-plugin Caddy via
  xcaddy (pinned) to /usr/local/bin/caddy, which the gateway picks up on deploy.
- Tests: TestCaddyfileTlsAcme (global block, wildcard site, derived host matcher,
  path routes on :port, staging toggle, no-domain fallback). Docs: gateway.tls
  modes table + full acme/DNS-01 section (setup, wild-central LAN DNS, staging).
This commit is contained in:
2026-06-30 18:45:13 -07:00
parent b726e79c23
commit 121f970f14
7 changed files with 329 additions and 17 deletions

View File

@@ -340,7 +340,17 @@ 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
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
version number").
version number"). `gateway.tls` opts host routes into HTTPS:
| `gateway.tls` | listener | host routes | cert / trust |
|---------------|----------|-------------|--------------|
| `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` and `internal` are mutually exclusive (one `gateway.tls` value); path-prefix
and static routes always stay on the HTTP `:<port>` 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** (`*.<domain>`) 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 → <service>.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 `*.<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.)
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 `<service-name>.<domain>`. 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):
- **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 `*.<domain>` 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/<gateway-ip>`.
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 <ip>:443 -servername claw.<domain> | 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.