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).
195 lines
6.6 KiB
Python
195 lines
6.6 KiB
Python
"""Node registry — per-machine deployment state."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import socket
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
from castle_core.config import CONTENT_DIR, SPECS_DIR
|
|
|
|
REGISTRY_PATH = SPECS_DIR / "registry.yaml"
|
|
STATIC_DIR = CONTENT_DIR # backwards-compat alias
|
|
|
|
|
|
@dataclass
|
|
class NodeConfig:
|
|
"""Per-node identity and settings."""
|
|
|
|
hostname: str = ""
|
|
castle_root: str | None = None # repo path, for dev commands
|
|
gateway_port: int = 9000
|
|
# 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:
|
|
self.hostname = socket.gethostname()
|
|
|
|
|
|
@dataclass
|
|
class Deployment:
|
|
"""A component deployed on this node with resolved runtime config."""
|
|
|
|
runner: str
|
|
run_cmd: list[str]
|
|
# Optional teardown command emitted as systemd ``ExecStop=`` (e.g. compose
|
|
# ``down``). Empty for runners whose stop is just SIGTERM to the ExecStart pid.
|
|
stop_cmd: list[str] = field(default_factory=list)
|
|
env: dict[str, str] = field(default_factory=dict)
|
|
# Names (never values) of secret-bearing env vars. Their resolved values live
|
|
# only in the mode-0600 env file, never in env/run_cmd/registry — this is for
|
|
# visibility (which secrets a deployment expects).
|
|
secret_env_keys: list[str] = field(default_factory=list)
|
|
description: str | None = None
|
|
behavior: str = "daemon"
|
|
stack: str | None = None
|
|
port: int | None = None
|
|
health_path: str | None = None
|
|
proxy_path: str | None = None
|
|
proxy_host: str | None = None
|
|
base_url: str | None = None
|
|
schedule: str | None = None
|
|
managed: bool = False
|
|
|
|
|
|
@dataclass
|
|
class NodeRegistry:
|
|
"""What's deployed on this node."""
|
|
|
|
node: NodeConfig
|
|
deployed: dict[str, Deployment] = field(default_factory=dict)
|
|
|
|
|
|
def load_registry(path: Path | None = None) -> NodeRegistry:
|
|
"""Load the node registry from ~/.castle/registry.yaml."""
|
|
if path is None:
|
|
path = REGISTRY_PATH
|
|
|
|
if not path.exists():
|
|
raise FileNotFoundError(
|
|
f"Registry not found: {path}\n"
|
|
"Run 'castle deploy' to generate it from castle.yaml."
|
|
)
|
|
|
|
with open(path) as f:
|
|
data = yaml.safe_load(f)
|
|
|
|
if not data:
|
|
raise ValueError(f"Empty registry: {path}")
|
|
|
|
node_data = data.get("node", {})
|
|
node = NodeConfig(
|
|
hostname=node_data.get("hostname", ""),
|
|
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] = {}
|
|
for name, comp_data in data.get("deployed", {}).items():
|
|
# Support both old "category" and new "behavior" keys for migration
|
|
behavior = comp_data.get("behavior")
|
|
if behavior is None:
|
|
category = comp_data.get("category", "service")
|
|
behavior = (
|
|
"daemon"
|
|
if category == "service"
|
|
else "tool"
|
|
if category in ("job", "tool")
|
|
else "frontend"
|
|
if category == "frontend"
|
|
else category
|
|
)
|
|
deployed[name] = Deployment(
|
|
runner=comp_data.get("runner", "command"),
|
|
run_cmd=comp_data.get("run_cmd", []),
|
|
stop_cmd=comp_data.get("stop_cmd", []),
|
|
env=comp_data.get("env", {}),
|
|
secret_env_keys=comp_data.get("secret_env_keys", []),
|
|
description=comp_data.get("description"),
|
|
behavior=behavior,
|
|
stack=comp_data.get("stack"),
|
|
port=comp_data.get("port"),
|
|
health_path=comp_data.get("health_path"),
|
|
proxy_path=comp_data.get("proxy_path"),
|
|
proxy_host=comp_data.get("proxy_host"),
|
|
base_url=comp_data.get("base_url"),
|
|
schedule=comp_data.get("schedule"),
|
|
managed=comp_data.get("managed", False),
|
|
)
|
|
|
|
return NodeRegistry(node=node, deployed=deployed)
|
|
|
|
|
|
def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
|
"""Write the node registry to ~/.castle/registry.yaml."""
|
|
if path is None:
|
|
path = REGISTRY_PATH
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
data: dict = {
|
|
"node": {
|
|
"hostname": registry.node.hostname,
|
|
"gateway_port": registry.node.gateway_port,
|
|
},
|
|
"deployed": {},
|
|
}
|
|
|
|
if registry.node.castle_root:
|
|
data["node"]["castle_root"] = registry.node.castle_root
|
|
|
|
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 = {
|
|
"runner": comp.runner,
|
|
"run_cmd": comp.run_cmd,
|
|
}
|
|
if comp.stop_cmd:
|
|
entry["stop_cmd"] = comp.stop_cmd
|
|
if comp.env:
|
|
entry["env"] = comp.env
|
|
if comp.secret_env_keys:
|
|
entry["secret_env_keys"] = comp.secret_env_keys
|
|
if comp.description:
|
|
entry["description"] = comp.description
|
|
entry["behavior"] = comp.behavior
|
|
if comp.stack:
|
|
entry["stack"] = comp.stack
|
|
if comp.port is not None:
|
|
entry["port"] = comp.port
|
|
if comp.health_path:
|
|
entry["health_path"] = comp.health_path
|
|
if comp.proxy_path:
|
|
entry["proxy_path"] = comp.proxy_path
|
|
if comp.proxy_host:
|
|
entry["proxy_host"] = comp.proxy_host
|
|
if comp.base_url:
|
|
entry["base_url"] = comp.base_url
|
|
if comp.schedule:
|
|
entry["schedule"] = comp.schedule
|
|
if comp.managed:
|
|
entry["managed"] = comp.managed
|
|
data["deployed"][name] = entry
|
|
|
|
with open(path, "w") as f:
|
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|