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

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