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

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