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

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