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

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