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:
@@ -110,7 +110,14 @@ class GatewayConfig:
|
|||||||
port: int = 9000
|
port: int = 9000
|
||||||
# None/"off" → HTTP-only gateway. "internal" → Caddy serves host routes over
|
# 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).
|
# 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
|
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
|
@dataclass
|
||||||
@@ -253,6 +260,9 @@ def load_config(root: Path | None = None) -> CastleConfig:
|
|||||||
gateway = GatewayConfig(
|
gateway = GatewayConfig(
|
||||||
port=gateway_data.get("port", 9000),
|
port=gateway_data.get("port", 9000),
|
||||||
tls=gateway_data.get("tls"),
|
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
|
# 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}
|
gateway_data: dict = {"port": config.gateway.port}
|
||||||
if config.gateway.tls:
|
if config.gateway.tls:
|
||||||
gateway_data["tls"] = 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}
|
data: dict = {"gateway": gateway_data}
|
||||||
if config.repo:
|
if config.repo:
|
||||||
data["repo"] = str(config.repo)
|
data["repo"] = str(config.repo)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from castle_core.config import (
|
from castle_core.config import (
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
|
SECRETS_DIR,
|
||||||
SPECS_DIR,
|
SPECS_DIR,
|
||||||
CastleConfig,
|
CastleConfig,
|
||||||
ensure_dirs,
|
ensure_dirs,
|
||||||
@@ -23,6 +24,7 @@ from castle_core.config import (
|
|||||||
resolve_env_split,
|
resolve_env_split,
|
||||||
)
|
)
|
||||||
from castle_core.generators.caddyfile import (
|
from castle_core.generators.caddyfile import (
|
||||||
|
_DNS_TOKEN_ENV,
|
||||||
generate_caddyfile_from_registry,
|
generate_caddyfile_from_registry,
|
||||||
service_proxy_targets,
|
service_proxy_targets,
|
||||||
)
|
)
|
||||||
@@ -77,6 +79,9 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
|
|||||||
castle_root=str(config.root),
|
castle_root=str(config.root),
|
||||||
gateway_port=config.gateway.port,
|
gateway_port=config.gateway.port,
|
||||||
gateway_tls=config.gateway.tls,
|
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,
|
# 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)
|
caddyfile_path.write_text(caddyfile_content)
|
||||||
result.messages.append(f"Caddyfile written: {caddyfile_path}")
|
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
|
# Reload systemd daemon
|
||||||
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
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"
|
_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:
|
def _reload_gateway(messages: list[str]) -> None:
|
||||||
"""Reload Caddy if the gateway is running, so new routes take effect."""
|
"""Reload Caddy if the gateway is running, so new routes take effect."""
|
||||||
gw_unit = unit_name(_GATEWAY_NAME)
|
gw_unit = unit_name(_GATEWAY_NAME)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ a **target**, of one **kind**:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -21,6 +22,14 @@ from castle_core.config import SPECS_DIR, CastleConfig
|
|||||||
from castle_core.manifest import ServiceSpec
|
from castle_core.manifest import ServiceSpec
|
||||||
from castle_core.registry import NodeRegistry
|
from castle_core.registry import NodeRegistry
|
||||||
|
|
||||||
|
# DNS-01 provider → the env var the Caddyfile reads its API token from. The token
|
||||||
|
# reaches Caddy via the gateway service's defaults.env (a mode-0600 EnvironmentFile).
|
||||||
|
_DNS_TOKEN_ENV = {"cloudflare": "CLOUDFLARE_API_TOKEN"}
|
||||||
|
|
||||||
|
# Let's Encrypt staging directory — opt in via CASTLE_ACME_STAGING=1 to avoid the
|
||||||
|
# production rate limits while testing, then cut over to prod (unset the env var).
|
||||||
|
_ACME_STAGING_CA = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GatewayRoute:
|
class GatewayRoute:
|
||||||
@@ -156,13 +165,28 @@ def compute_routes(
|
|||||||
return routes
|
return routes
|
||||||
|
|
||||||
|
|
||||||
|
def _host_matcher_block(label: str, host: str, target: str) -> list[str]:
|
||||||
|
"""A `@host_X host <host> / handle @host_X { reverse_proxy <target> }` block.
|
||||||
|
|
||||||
|
Shared by the off-mode `:<port>` site and the acme wildcard site so the two
|
||||||
|
can't drift. `label` names the matcher (service name, or the address)."""
|
||||||
|
matcher = f"@host_{label.replace('-', '_').replace('.', '_')}"
|
||||||
|
return [
|
||||||
|
f" {matcher} host {host}",
|
||||||
|
f" handle {matcher} {{",
|
||||||
|
f" reverse_proxy {target}",
|
||||||
|
" }",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def generate_caddyfile_from_registry(
|
def generate_caddyfile_from_registry(
|
||||||
registry: NodeRegistry,
|
registry: NodeRegistry,
|
||||||
remote_registries: dict[str, NodeRegistry] | None = None,
|
remote_registries: dict[str, NodeRegistry] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render the route list to a Caddyfile.
|
"""Render the route list to a Caddyfile.
|
||||||
|
|
||||||
Two modes, set by `gateway.tls`:
|
Three modes, set by `gateway.tls`:
|
||||||
|
|
||||||
- **off (default)** — HTTP-only. Everything (host matchers + path prefixes +
|
- **off (default)** — HTTP-only. Everything (host matchers + path prefixes +
|
||||||
static) lives in one `:<port>` site with `auto_https off`, so a named host
|
static) lives in one `:<port>` site with `auto_https off`, so a named host
|
||||||
@@ -172,15 +196,44 @@ def generate_caddyfile_from_registry(
|
|||||||
redirects :80). This makes those hosts a browser "secure context". Path
|
redirects :80). This makes those hosts a browser "secure context". Path
|
||||||
prefixes, static frontends, and the dashboard stay on the HTTP `:<port>`
|
prefixes, static frontends, and the dashboard stay on the HTTP `:<port>`
|
||||||
site — give a service a `proxy.caddy.host` to put it on HTTPS.
|
site — give a service a `proxy.caddy.host` to put it on HTTPS.
|
||||||
|
- **acme** — host routes are served under a single `*.<domain>` site with a
|
||||||
|
real Let's Encrypt **wildcard** cert obtained via a DNS-01 challenge (one
|
||||||
|
cert for all of them). Publicly trusted → no CA install on clients. Each
|
||||||
|
host route maps to `<service-name>.<domain>`. Requires `gateway.domain`;
|
||||||
|
the DNS provider token reaches Caddy via `{env.<TOKEN>}`.
|
||||||
"""
|
"""
|
||||||
routes = compute_routes(registry, None, remote_registries)
|
routes = compute_routes(registry, None, remote_registries)
|
||||||
gw_port = registry.node.gateway_port
|
node = registry.node
|
||||||
tls_internal = (registry.node.gateway_tls or "").lower() == "internal"
|
gw_port = node.gateway_port
|
||||||
|
mode = (node.gateway_tls or "").lower()
|
||||||
|
tls_internal = mode == "internal"
|
||||||
|
domain = node.gateway_domain
|
||||||
|
tls_acme = mode == "acme" and bool(domain) # acme without a domain → off-mode
|
||||||
|
|
||||||
host_routes = [r for r in routes if r.is_host]
|
host_routes = [r for r in routes if r.is_host]
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
|
|
||||||
if tls_internal:
|
if tls_acme:
|
||||||
|
# Global ACME options: LE account email + DNS-01 provider token (from env).
|
||||||
|
provider = node.acme_dns_provider or "cloudflare"
|
||||||
|
token_env = _DNS_TOKEN_ENV.get(provider, "CLOUDFLARE_API_TOKEN")
|
||||||
|
lines.append("{")
|
||||||
|
if node.acme_email:
|
||||||
|
lines.append(f" email {node.acme_email}")
|
||||||
|
lines.append(f" acme_dns {provider} {{env.{token_env}}}")
|
||||||
|
if os.environ.get("CASTLE_ACME_STAGING") == "1":
|
||||||
|
lines.append(f" acme_ca {_ACME_STAGING_CA}")
|
||||||
|
lines += ["}", ""]
|
||||||
|
# One wildcard site → a single DNS-01 cert covers every host route, so a
|
||||||
|
# new host-routed service needs no new cert or challenge.
|
||||||
|
if host_routes:
|
||||||
|
lines.append(f"*.{domain} {{")
|
||||||
|
for r in host_routes:
|
||||||
|
sub = r.name or r.address.split(".")[0]
|
||||||
|
lines += _host_matcher_block(r.name or r.address, f"{sub}.{domain}", r.target)
|
||||||
|
lines.append("}")
|
||||||
|
lines.append("")
|
||||||
|
elif tls_internal:
|
||||||
# Per-host HTTPS sites via Caddy's internal CA. `tls internal` overrides
|
# Per-host HTTPS sites via Caddy's internal CA. `tls internal` overrides
|
||||||
# ACME for that host, so no public cert is attempted; clients must trust
|
# ACME for that host, so no public cert is attempted; clients must trust
|
||||||
# the Caddy root CA (`caddy trust`, then distribute root.crt).
|
# the Caddy root CA (`caddy trust`, then distribute root.crt).
|
||||||
@@ -195,6 +248,9 @@ def generate_caddyfile_from_registry(
|
|||||||
else:
|
else:
|
||||||
# HTTP-only: keep auto-HTTPS off so the bare-port site stays plain HTTP
|
# HTTP-only: keep auto-HTTPS off so the bare-port site stays plain HTTP
|
||||||
# and named hosts don't trigger cert provisioning.
|
# and named hosts don't trigger cert provisioning.
|
||||||
|
if mode == "acme" and not domain:
|
||||||
|
lines.append("# gateway.tls=acme but gateway.domain is unset — host routes")
|
||||||
|
lines.append("# fall back to plain-HTTP matchers on the gateway port.")
|
||||||
lines += ["{", " auto_https off", "}", ""]
|
lines += ["{", " auto_https off", "}", ""]
|
||||||
|
|
||||||
lines.append(f":{gw_port} {{")
|
lines.append(f":{gw_port} {{")
|
||||||
@@ -224,16 +280,9 @@ def generate_caddyfile_from_registry(
|
|||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
elif r.is_host: # host-based proxy
|
elif r.is_host: # host-based proxy
|
||||||
if tls_internal:
|
if tls_internal or tls_acme:
|
||||||
continue # emitted as its own HTTPS site block above
|
continue # emitted as its own HTTPS / wildcard site above
|
||||||
matcher = f"@host_{(r.name or r.address).replace('-', '_').replace('.', '_')}"
|
lines += _host_matcher_block(r.name or r.address, r.address, r.target)
|
||||||
lines += [
|
|
||||||
f" {matcher} host {r.address}",
|
|
||||||
f" handle {matcher} {{",
|
|
||||||
f" reverse_proxy {r.target}",
|
|
||||||
" }",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
else: # path-prefix proxy (local or remote)
|
else: # path-prefix proxy (local or remote)
|
||||||
if r.kind == "remote":
|
if r.kind == "remote":
|
||||||
lines.append(f" # {r.name} on {r.node}")
|
lines.append(f" # {r.name} on {r.node}")
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ class NodeConfig:
|
|||||||
hostname: str = ""
|
hostname: str = ""
|
||||||
castle_root: str | None = None # repo path, for dev commands
|
castle_root: str | None = None # repo path, for dev commands
|
||||||
gateway_port: int = 9000
|
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:
|
def __post_init__(self) -> None:
|
||||||
if not self.hostname:
|
if not self.hostname:
|
||||||
@@ -85,6 +90,9 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
|||||||
castle_root=node_data.get("castle_root"),
|
castle_root=node_data.get("castle_root"),
|
||||||
gateway_port=node_data.get("gateway_port", 9000),
|
gateway_port=node_data.get("gateway_port", 9000),
|
||||||
gateway_tls=node_data.get("gateway_tls"),
|
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] = {}
|
deployed: dict[str, Deployment] = {}
|
||||||
@@ -143,6 +151,12 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
|||||||
|
|
||||||
if registry.node.gateway_tls:
|
if registry.node.gateway_tls:
|
||||||
data["node"]["gateway_tls"] = 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():
|
for name, comp in registry.deployed.items():
|
||||||
entry: dict = {
|
entry: dict = {
|
||||||
|
|||||||
@@ -38,10 +38,20 @@ def _make_registry(
|
|||||||
deployed: dict[str, Deployment] | None = None,
|
deployed: dict[str, Deployment] | None = None,
|
||||||
gateway_port: int = 9000,
|
gateway_port: int = 9000,
|
||||||
gateway_tls: str | None = None,
|
gateway_tls: str | None = None,
|
||||||
|
gateway_domain: str | None = None,
|
||||||
|
acme_email: str | None = None,
|
||||||
|
acme_dns_provider: str = "cloudflare",
|
||||||
) -> NodeRegistry:
|
) -> NodeRegistry:
|
||||||
"""Create a test registry."""
|
"""Create a test registry."""
|
||||||
return NodeRegistry(
|
return NodeRegistry(
|
||||||
node=NodeConfig(hostname="test", gateway_port=gateway_port, gateway_tls=gateway_tls),
|
node=NodeConfig(
|
||||||
|
hostname="test",
|
||||||
|
gateway_port=gateway_port,
|
||||||
|
gateway_tls=gateway_tls,
|
||||||
|
gateway_domain=gateway_domain,
|
||||||
|
acme_email=acme_email,
|
||||||
|
acme_dns_provider=acme_dns_provider,
|
||||||
|
),
|
||||||
deployed=deployed or {},
|
deployed=deployed or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -285,6 +295,62 @@ class TestCaddyfileTlsInternal:
|
|||||||
assert "claw.civil.lan {" not in caddyfile
|
assert "claw.civil.lan {" not in caddyfile
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaddyfileTlsAcme:
|
||||||
|
"""gateway.tls=acme → host routes served under one *.domain wildcard site
|
||||||
|
with a Let's Encrypt cert via DNS-01."""
|
||||||
|
|
||||||
|
def _acme_registry(self, domain: str | None = "civil.payne.io") -> NodeRegistry:
|
||||||
|
return _make_registry(
|
||||||
|
gateway_tls="acme",
|
||||||
|
gateway_domain=domain,
|
||||||
|
acme_email="paul@example.com",
|
||||||
|
deployed={
|
||||||
|
"claw": Deployment(
|
||||||
|
runner="node", run_cmd=["claw"], port=18789, proxy_host="claw.civil.lan"
|
||||||
|
),
|
||||||
|
"api": Deployment(
|
||||||
|
runner="python", run_cmd=["api"], port=9020, proxy_path="/api"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_global_email_and_acme_dns(self) -> None:
|
||||||
|
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
|
||||||
|
assert "email paul@example.com" in caddyfile
|
||||||
|
assert "acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}" in caddyfile
|
||||||
|
|
||||||
|
def test_wildcard_site_with_derived_host_matcher(self) -> None:
|
||||||
|
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
|
||||||
|
assert "*.civil.payne.io {" in caddyfile
|
||||||
|
# Subdomain derived from the SERVICE NAME (claw), not the declared .lan host.
|
||||||
|
assert "@host_claw host claw.civil.payne.io" in caddyfile
|
||||||
|
assert "reverse_proxy localhost:18789" in caddyfile
|
||||||
|
|
||||||
|
def test_path_routes_stay_on_http_port(self) -> None:
|
||||||
|
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
|
||||||
|
assert ":9000 {" in caddyfile
|
||||||
|
assert "handle_path /api/*" in caddyfile
|
||||||
|
|
||||||
|
def test_no_auto_https_off_and_no_internal_ca(self) -> None:
|
||||||
|
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
|
||||||
|
assert "auto_https off" not in caddyfile
|
||||||
|
assert "tls internal" not in caddyfile
|
||||||
|
# The .lan host must not leak into the :9000 site as a plain matcher.
|
||||||
|
assert "claw.civil.lan" not in caddyfile
|
||||||
|
|
||||||
|
def test_staging_toggle(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("CASTLE_ACME_STAGING", "1")
|
||||||
|
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
|
||||||
|
assert "acme_ca https://acme-staging-v02.api.letsencrypt.org/directory" in caddyfile
|
||||||
|
|
||||||
|
def test_acme_without_domain_falls_back_to_http(self) -> None:
|
||||||
|
# No domain → no *.None site; host route degrades to an off-mode matcher.
|
||||||
|
caddyfile = generate_caddyfile_from_registry(self._acme_registry(domain=None))
|
||||||
|
assert "*." not in caddyfile
|
||||||
|
assert "auto_https off" in caddyfile
|
||||||
|
assert "@host_claw host claw.civil.lan" in caddyfile
|
||||||
|
|
||||||
|
|
||||||
class TestCaddyfileRemoteRegistries:
|
class TestCaddyfileRemoteRegistries:
|
||||||
"""Tests for cross-node routing in Caddyfile."""
|
"""Tests for cross-node routing in Caddyfile."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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/`,
|
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
|
**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`
|
#### 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
|
with its SHA-256 shown for out-of-band verification. The on-disk copy is at
|
||||||
`~/.local/share/caddy/pki/authorities/local/root.crt`.
|
`~/.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.
|
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
|
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.
|
stays the client's responsibility through the gateway exactly as it would direct.
|
||||||
|
|||||||
49
install.sh
49
install.sh
@@ -162,6 +162,51 @@ ensure_caddy() {
|
|||||||
log_ok
|
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
|
# Directory structure
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -394,9 +439,12 @@ main() {
|
|||||||
|
|
||||||
# Parse args
|
# Parse args
|
||||||
AUTO_YES=0
|
AUTO_YES=0
|
||||||
|
WITH_DNS_PLUGIN="" # e.g. "cloudflare" → also build a DNS-plugin Caddy for acme TLS
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--yes|-y) AUTO_YES=1 ;;
|
--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 ;;
|
*) printf "Unknown argument: %s\n" "$arg"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -405,6 +453,7 @@ main() {
|
|||||||
check_systemd
|
check_systemd
|
||||||
ensure_docker
|
ensure_docker
|
||||||
ensure_caddy
|
ensure_caddy
|
||||||
|
[ -n "$WITH_DNS_PLUGIN" ] && ensure_caddy_dns_plugin "$WITH_DNS_PLUGIN"
|
||||||
create_directories
|
create_directories
|
||||||
enable_lingering
|
enable_lingering
|
||||||
seed_caddyfile
|
seed_caddyfile
|
||||||
|
|||||||
Reference in New Issue
Block a user