feat: raw-TCP exposure with castle-managed TLS + reach model
Expose raw-TCP services (postgres) by name at <name>.<domain>:<port> and
cut the gateway's ACME wildcard cert onto them so they present a trusted
cert. Replaces the proxy/public booleans with a single `reach` enum
(off|internal|public); legacy input still parses via derived accessors.
Core:
- expose.tcp{port,tls} + TlsSpec(material: pair|combined|off, reload)
- tls.py: materialize cert files from Caddy's wildcard, reconcile on
renewal; `castle tls` CLI; optional cert_obtained events-exec hook
(gateway.cert_hook, gated so a plugin-less Caddy still parses)
- apply waits (bounded) for the wildcard to issue before materializing so
a fresh-node TLS service starts with its cert in place, then scopes
materialization to the deployments being applied
- reach: internal|public requires an expose block (no silent no-op);
public raw-TCP guarded until tunnel support lands
- chain.pem (${tls_ca}) is the issuer chain (leaf stripped), a real CA
bundle distinct from cert.pem
- one shared ${...} resolver (resolve_placeholders) for env and container
run fields; run.env now expands like volumes/args; $$ escapes a literal
- validate the generated Caddyfile before reloading the gateway so an
invalid config never degrades routing
Docs: docs/tcp-exposure.md. Tests cover reach/expose validation,
placeholder expansion + escape, issuer-chain material, TLS materialize.
This commit is contained in:
@@ -42,6 +42,14 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
const run = obj(m.run)
|
||||
const internal = obj(obj(obj(m.expose).http).internal)
|
||||
const httpExpose = obj(obj(m.expose).http)
|
||||
// A raw-TCP service (postgres, redis, …) exposes `expose.tcp`, not `expose.http`.
|
||||
// It's reachable at <name>.<domain>:<port> via DNS (no gateway HTTP route), so the
|
||||
// HTTP port/health/reach controls below don't apply — show its exposure read-only
|
||||
// and never rebuild `expose` on save (that would nuke expose.tcp). Edit TCP/TLS in
|
||||
// deployments/<name>.yaml for now.
|
||||
const tcp = obj(obj(m.expose).tcp)
|
||||
const tcpTls = obj(tcp.tls)
|
||||
const isTcp = tcp.port != null
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
@@ -58,8 +66,11 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
)
|
||||
const [port, setPort] = useState(internal.port != null ? String(internal.port) : "")
|
||||
const [health, setHealth] = useState((httpExpose.health_path as string) ?? "")
|
||||
// Exposed at <service-name>.<gateway.domain> when proxy is true.
|
||||
const [expose, setExpose] = useState(m.proxy === true)
|
||||
// How far the service reaches: off | internal | public. Falls back to the
|
||||
// legacy proxy/public booleans for any deployment not yet re-saved.
|
||||
const [reach, setReach] = useState(
|
||||
(m.reach as string) ?? (m.public === true ? "public" : m.proxy === true ? "internal" : "off"),
|
||||
)
|
||||
|
||||
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
|
||||
|
||||
@@ -74,19 +85,24 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
// Rebuild the run block for the chosen launcher, preserving other fields.
|
||||
config.run = applyLauncher(obj(config.run), launcher, runProgram)
|
||||
|
||||
if (port) {
|
||||
config.expose = {
|
||||
http: {
|
||||
internal: { port: parseInt(port, 10) },
|
||||
...(health ? { health_path: health } : {}),
|
||||
},
|
||||
// For a TCP service, leave expose.tcp + reach exactly as-is (they're already
|
||||
// in the cloned config) — only the HTTP path rebuilds expose from the form.
|
||||
if (!isTcp) {
|
||||
if (port) {
|
||||
config.expose = {
|
||||
http: {
|
||||
internal: { port: parseInt(port, 10) },
|
||||
...(health ? { health_path: health } : {}),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
delete config.expose
|
||||
}
|
||||
} else {
|
||||
delete config.expose
|
||||
// reach needs a port to route through the gateway; without one it's off.
|
||||
config.reach = port ? reach : "off"
|
||||
}
|
||||
|
||||
if (expose) config.proxy = true
|
||||
else delete config.proxy
|
||||
delete config.proxy
|
||||
delete config.public
|
||||
|
||||
const env = merged()
|
||||
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
|
||||
@@ -127,39 +143,77 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<TextField
|
||||
label="Port"
|
||||
value={port}
|
||||
onChange={setPort}
|
||||
width="w-32"
|
||||
mono
|
||||
placeholder="9001"
|
||||
hint="The port the service listens on. Castle health-checks and proxies this port; map it to the program's own var with ${port} in Environment."
|
||||
/>
|
||||
<TextField
|
||||
label="Health path"
|
||||
value={health}
|
||||
onChange={setHealth}
|
||||
width="w-48"
|
||||
mono
|
||||
placeholder="/health"
|
||||
hint="HTTP path castle polls to report up/down."
|
||||
/>
|
||||
<Field
|
||||
label="Expose"
|
||||
hint="Route this service through the gateway at <service-name>.<gateway.domain>. Unchecked: reachable only at its own host:port."
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={expose}
|
||||
onChange={(e) => setExpose(e.target.checked)}
|
||||
{isTcp ? (
|
||||
<Field
|
||||
label="Exposure"
|
||||
hint="A raw-TCP service — reachable by name + port via DNS, not the HTTP gateway. Edit its port/TLS in deployments/<name>.yaml for now."
|
||||
>
|
||||
<div className="font-mono text-xs text-[var(--muted)] space-y-1">
|
||||
<div>
|
||||
<span className="text-[var(--fg)]">tcp</span> · port{" "}
|
||||
<span className="text-[var(--fg)]">{String(tcp.port)}</span>
|
||||
{tcpTls.material && tcpTls.material !== "off" ? (
|
||||
<>
|
||||
{" "}
|
||||
· tls <span className="text-[var(--fg)]">{String(tcpTls.material)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
reach{" "}
|
||||
<span className="text-[var(--fg)]">{String(m.reach ?? "internal")}</span> —{" "}
|
||||
{service.id}.<gateway.domain>:{String(tcp.port)}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
label="Port"
|
||||
value={port}
|
||||
onChange={setPort}
|
||||
width="w-32"
|
||||
mono
|
||||
placeholder="9001"
|
||||
hint="The port the service listens on. Castle health-checks and proxies this port; map it to the program's own var with ${port} in Environment."
|
||||
/>
|
||||
<span className="font-mono text-[var(--muted)]">
|
||||
{expose ? `${service.id}.<gateway.domain>` : "off (host:port only)"}
|
||||
</span>
|
||||
</label>
|
||||
</Field>
|
||||
<TextField
|
||||
label="Health path"
|
||||
value={health}
|
||||
onChange={setHealth}
|
||||
width="w-48"
|
||||
mono
|
||||
placeholder="/health"
|
||||
hint="HTTP path castle polls to report up/down."
|
||||
/>
|
||||
<Field
|
||||
label="Reach"
|
||||
hint="How far this service is exposed. off: host:port only. internal: <service-name>.<gateway.domain> via the gateway. public: also to the internet via the Cloudflare tunnel."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={reach}
|
||||
onChange={(e) => setReach(e.target.value)}
|
||||
disabled={!port}
|
||||
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)] disabled:opacity-50"
|
||||
>
|
||||
<option value="off">off</option>
|
||||
<option value="internal">internal</option>
|
||||
<option value="public">public</option>
|
||||
</select>
|
||||
<span className="font-mono text-[var(--muted)] text-xs">
|
||||
{!port
|
||||
? "set a port to expose"
|
||||
: reach === "off"
|
||||
? "host:port only"
|
||||
: reach === "public"
|
||||
? `${service.id}.<gateway.public_domain>`
|
||||
: `${service.id}.<gateway.domain>`}
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{envEditor}
|
||||
<FormFooter
|
||||
saving={saving}
|
||||
|
||||
@@ -19,7 +19,10 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [description, setDescription] = useState((m.description as string) ?? "")
|
||||
const [root, setRoot] = useState((m.root as string) ?? "dist")
|
||||
const [isPublic, setIsPublic] = useState(m.public === true)
|
||||
// Static sites are always served (reach internal|public); the toggle picks public.
|
||||
const [isPublic, setIsPublic] = useState(
|
||||
((m.reach as string) ?? (m.public === true ? "public" : "internal")) === "public",
|
||||
)
|
||||
const { element: envEditor, merged } = useEnvSecrets(
|
||||
obj(obj(m.defaults).env) as Record<string, string>,
|
||||
)
|
||||
@@ -32,8 +35,8 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
||||
delete config.id
|
||||
config.description = description || undefined
|
||||
config.root = root || "dist"
|
||||
if (isPublic) config.public = true
|
||||
else delete config.public
|
||||
config.reach = isPublic ? "public" : "internal"
|
||||
delete config.public
|
||||
const env = merged()
|
||||
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
|
||||
else if (config.defaults) delete (config.defaults as Obj).env
|
||||
@@ -58,19 +61,22 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
||||
hint="The built directory the gateway serves, relative to the program source (e.g. dist, public)."
|
||||
/>
|
||||
<Field
|
||||
label="Public"
|
||||
hint="Also expose this site to the public internet via the Cloudflare tunnel."
|
||||
label="Reach"
|
||||
hint="How far this static site is served. internal: <name>.<gateway.domain>. public: also to the internet via the Cloudflare tunnel. (A static site is always served, so there's no 'off'.)"
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
/>
|
||||
<span className="font-mono text-[var(--muted)]">
|
||||
{isPublic ? "public (via tunnel)" : "internal only"}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={isPublic ? "public" : "internal"}
|
||||
onChange={(e) => setIsPublic(e.target.value === "public")}
|
||||
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||
>
|
||||
<option value="internal">internal</option>
|
||||
<option value="public">public</option>
|
||||
</select>
|
||||
<span className="font-mono text-[var(--muted)] text-xs">
|
||||
{isPublic ? `${dep.id}.<gateway.public_domain>` : `${dep.id}.<gateway.domain>`}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
{envEditor}
|
||||
<FormFooter
|
||||
|
||||
@@ -114,7 +114,7 @@ def _summary_from_service(
|
||||
if svc.expose and svc.expose.http:
|
||||
port = svc.expose.http.internal.port
|
||||
health_path = svc.expose.http.health_path
|
||||
subdomain = name if svc.proxy else None
|
||||
subdomain = name if svc.http_exposed else None
|
||||
|
||||
managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable)
|
||||
|
||||
@@ -288,7 +288,7 @@ def _service_from_spec(name: str, svc: SystemdDeployment, config: object) -> Ser
|
||||
port = svc.expose.http.internal.port
|
||||
health_path = svc.expose.http.health_path
|
||||
# Exposed at <name>.<domain> when the proxy checkbox is on.
|
||||
subdomain = name if svc.proxy else None
|
||||
subdomain = name if svc.http_exposed else None
|
||||
|
||||
managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable)
|
||||
systemd_info = _make_systemd_info(name) if managed else None
|
||||
|
||||
@@ -16,6 +16,7 @@ from castle_cli.manifest import (
|
||||
HttpExposeSpec,
|
||||
HttpInternal,
|
||||
ManageSpec,
|
||||
Reach,
|
||||
RunCommand,
|
||||
RunPython,
|
||||
SystemdDeployment,
|
||||
@@ -58,7 +59,7 @@ def run_service_create(args: argparse.Namespace) -> int:
|
||||
run = _run_spec(args.launcher, args.run or args.program or name, name)
|
||||
|
||||
expose = None
|
||||
proxy = False
|
||||
reach = Reach.OFF
|
||||
if args.port is not None:
|
||||
expose = ExposeSpec(
|
||||
http=HttpExposeSpec(
|
||||
@@ -67,7 +68,7 @@ def run_service_create(args: argparse.Namespace) -> int:
|
||||
)
|
||||
)
|
||||
# Expose at <name>.<gateway.domain> (the subdomain is the service name).
|
||||
proxy = not args.no_proxy
|
||||
reach = Reach.OFF if args.no_proxy else Reach.INTERNAL
|
||||
|
||||
config.deployments[name] = SystemdDeployment(
|
||||
id=name,
|
||||
@@ -76,7 +77,7 @@ def run_service_create(args: argparse.Namespace) -> int:
|
||||
description=args.description or None,
|
||||
run=run,
|
||||
expose=expose,
|
||||
proxy=proxy,
|
||||
reach=reach,
|
||||
manage=ManageSpec(systemd=SystemdSpec()),
|
||||
defaults=_defaults(args.env),
|
||||
)
|
||||
@@ -86,7 +87,7 @@ def run_service_create(args: argparse.Namespace) -> int:
|
||||
print(f" runs: {args.launcher} ({args.run or args.program or name})")
|
||||
if expose:
|
||||
print(f" port: {args.port}")
|
||||
if proxy:
|
||||
if reach != Reach.OFF:
|
||||
print(f" subdomain: {name}.<gateway.domain>")
|
||||
print(f"\nNext: castle apply {name}")
|
||||
return 0
|
||||
|
||||
@@ -121,8 +121,12 @@ def run_info(args: argparse.Namespace) -> int:
|
||||
print(f" {BOLD}port{RESET}: {http.internal.port}")
|
||||
if http.health_path:
|
||||
print(f" {BOLD}health{RESET}: {http.health_path}")
|
||||
if service.proxy:
|
||||
if service.http_exposed:
|
||||
print(f" {BOLD}subdomain{RESET}: {name}.<gateway.domain>")
|
||||
if service.tcp_port is not None:
|
||||
# Raw-TCP reach is internal-only (public TCP is rejected at load, see
|
||||
# SystemdDeployment._validate_reach), so there's no "public" state here.
|
||||
print(f" {BOLD}tcp{RESET}: {name}.<gateway.domain>:{service.tcp_port} (internal)")
|
||||
if service.manage and service.manage.systemd:
|
||||
sd = service.manage.systemd
|
||||
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}")
|
||||
|
||||
78
cli/src/castle_cli/commands/tls.py
Normal file
78
cli/src/castle_cli/commands/tls.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""castle tls — castle-managed TLS certs for raw-TCP services.
|
||||
|
||||
Each TLS-material TCP service (postgres, redis, …) gets the gateway's ACME
|
||||
wildcard cert cut onto it so it presents a *trusted* cert on its raw port.
|
||||
`reconcile` refreshes those copies from the wildcard and reloads the services
|
||||
whose cert changed — it's what the Caddy `cert_obtained` hook and the nightly
|
||||
safety-net job both run. `status` shows each service's cert fingerprint + expiry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from castle_cli.config import load_config
|
||||
from castle_core.tls import _tls_of, reconcile_tls, tls_dir_for, wildcard_cert
|
||||
|
||||
|
||||
def run_tls(args: argparse.Namespace) -> int:
|
||||
if getattr(args, "tls_command", None) == "status":
|
||||
return _tls_status()
|
||||
return _tls_reconcile()
|
||||
|
||||
|
||||
def _tls_reconcile() -> int:
|
||||
config = load_config()
|
||||
for msg in reconcile_tls(config):
|
||||
print(msg)
|
||||
return 0
|
||||
|
||||
|
||||
def _fingerprint(pem: bytes) -> str:
|
||||
return hashlib.sha256(pem).hexdigest()[:12]
|
||||
|
||||
|
||||
def _not_after(cert_pem: bytes) -> str:
|
||||
"""Best-effort cert expiry (uses cryptography if available, else '—')."""
|
||||
try:
|
||||
from cryptography import x509
|
||||
|
||||
cert = x509.load_pem_x509_certificate(cert_pem)
|
||||
left = cert.not_valid_after_utc - datetime.now(timezone.utc)
|
||||
return f"{cert.not_valid_after_utc:%Y-%m-%d} ({left.days}d left)"
|
||||
except Exception:
|
||||
return "—"
|
||||
|
||||
|
||||
def _tls_status() -> int:
|
||||
config = load_config()
|
||||
domain = config.gateway.domain
|
||||
src = wildcard_cert(domain) if domain else None
|
||||
src_fp = _fingerprint(src[0].read_bytes()) if src else None
|
||||
print(f"wildcard source: *.{domain} " + (f"[{src_fp}]" if src_fp else "(not found)"))
|
||||
|
||||
rows = []
|
||||
for name, dep in sorted(config.deployments.items()):
|
||||
if _tls_of(dep) is None:
|
||||
continue
|
||||
config_key = dep.program or name
|
||||
cert = tls_dir_for(config_key) / "cert.pem"
|
||||
combined = tls_dir_for(config_key) / "combined.pem"
|
||||
have = cert if cert.exists() else combined if combined.exists() else None
|
||||
if have is None:
|
||||
rows.append((name, "not materialized", "—", ""))
|
||||
continue
|
||||
pem = have.read_bytes()
|
||||
fp = _fingerprint(pem)
|
||||
drift = "" if src_fp and fp == src_fp else " ⚠ stale (run: castle tls reconcile)"
|
||||
rows.append((name, fp, _not_after(pem), drift))
|
||||
|
||||
if not rows:
|
||||
print(" (no TLS-material TCP services)")
|
||||
return 0
|
||||
print()
|
||||
for name, fp, exp, drift in rows:
|
||||
print(f" {name:20s} {fp:14s} {exp}{drift}")
|
||||
return 0
|
||||
@@ -165,6 +165,16 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
gw_sub = gw.add_subparsers(dest="gateway_command")
|
||||
gw_sub.add_parser("status", help="Show gateway status + routes (the default)")
|
||||
|
||||
# TLS material for raw-TCP services (cert cut from the gateway wildcard).
|
||||
tls = subparsers.add_parser(
|
||||
"tls", help="Manage castle-materialized TLS certs for raw-TCP services"
|
||||
)
|
||||
tls_sub = tls.add_subparsers(dest="tls_command")
|
||||
tls_sub.add_parser(
|
||||
"reconcile", help="Refresh materialized certs from the wildcard + reload changed"
|
||||
)
|
||||
tls_sub.add_parser("status", help="Show each TLS service's cert fingerprint + expiry")
|
||||
|
||||
# Convergence — the one lifecycle verb. Renders units/Caddyfile/tunnel, then
|
||||
# reconciles the runtime to match config (activate/restart/deactivate).
|
||||
p = subparsers.add_parser(
|
||||
@@ -305,6 +315,10 @@ def main() -> int:
|
||||
from castle_cli.commands.gateway import run_gateway
|
||||
|
||||
return run_gateway(args)
|
||||
if cmd == "tls":
|
||||
from castle_cli.commands.tls import run_tls
|
||||
|
||||
return run_tls(args)
|
||||
if cmd == "apply":
|
||||
from castle_cli.commands.apply import run_apply
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
||||
ManageSpec,
|
||||
PathDeployment,
|
||||
ProgramSpec,
|
||||
Reach,
|
||||
ReadinessHttpGet,
|
||||
RemoteDeployment,
|
||||
RestartPolicy,
|
||||
|
||||
@@ -128,6 +128,12 @@ class GatewayConfig:
|
||||
# names never appear in public DNS. `tunnel_id` is the cloudflared tunnel UUID.
|
||||
public_domain: str | None = None
|
||||
tunnel_id: str | None = None
|
||||
# acme mode only: emit the `events { on cert_obtained exec castle tls reconcile }`
|
||||
# hook so certs materialized onto raw-TCP services refresh on renewal. Requires
|
||||
# the events-exec plugin in the gateway's Caddy build — set true only once that
|
||||
# Caddy is installed (see docs/tcp-exposure.md §5). Default false keeps a
|
||||
# plugin-less gateway parseable.
|
||||
cert_hook: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -228,6 +234,30 @@ def resolve_env_split(
|
||||
return plain, secret
|
||||
|
||||
|
||||
def resolve_placeholders(value: str, context: dict[str, str] | None) -> str:
|
||||
"""Expand ``${key}`` refs in a single string from ``context``.
|
||||
|
||||
The one ``${...}`` grammar shared by env resolution (:func:`resolve_env_split`)
|
||||
and run-spec expansion (argv/volumes/env in a container launch), so a new
|
||||
placeholder only has to be added to the context dict, never to a second engine.
|
||||
Unknown refs — including ``${secret:...}`` — pass through untouched (secrets
|
||||
never belong in argv; they go via ``--env-file``). Write ``$${key}`` to emit a
|
||||
literal ``${key}`` (e.g. a container arg the container's own shell must expand).
|
||||
"""
|
||||
if not context:
|
||||
return value
|
||||
|
||||
def replace_var(match: re.Match[str]) -> str:
|
||||
ref = match.group(1)
|
||||
return context.get(ref, match.group(0))
|
||||
|
||||
# Split on the `$$` escape so an escaped `$${x}` never reaches the substitution
|
||||
# regex, then rejoin with a literal `$`.
|
||||
return "$".join(
|
||||
re.sub(r"\$\{([^}]+)\}", replace_var, part) for part in value.split("$$")
|
||||
)
|
||||
|
||||
|
||||
def resolve_env_vars(
|
||||
env: dict[str, str], context: dict[str, str] | None = None
|
||||
) -> dict[str, str]:
|
||||
@@ -335,6 +365,7 @@ def load_config(root: Path | None = None) -> CastleConfig:
|
||||
acme_dns_provider=gateway_data.get("acme_dns_provider", "cloudflare"),
|
||||
public_domain=gateway_data.get("public_domain"),
|
||||
tunnel_id=gateway_data.get("tunnel_id"),
|
||||
cert_hook=gateway_data.get("cert_hook", False),
|
||||
)
|
||||
|
||||
# repo: field points to the git repo for repo-relative sources
|
||||
@@ -410,7 +441,6 @@ _STRUCTURAL_KEYS = {
|
||||
"manage",
|
||||
"systemd",
|
||||
"expose",
|
||||
"proxy",
|
||||
}
|
||||
|
||||
|
||||
@@ -494,12 +524,17 @@ def save_config(config: CastleConfig) -> None:
|
||||
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":
|
||||
if (
|
||||
config.gateway.acme_dns_provider
|
||||
and config.gateway.acme_dns_provider != "cloudflare"
|
||||
):
|
||||
gateway_data["acme_dns_provider"] = config.gateway.acme_dns_provider
|
||||
if config.gateway.public_domain:
|
||||
gateway_data["public_domain"] = config.gateway.public_domain
|
||||
if config.gateway.tunnel_id:
|
||||
gateway_data["tunnel_id"] = config.gateway.tunnel_id
|
||||
if config.gateway.cert_hook:
|
||||
gateway_data["cert_hook"] = config.gateway.cert_hook
|
||||
data: dict = {"gateway": gateway_data}
|
||||
if config.repo:
|
||||
data["repo"] = str(config.repo)
|
||||
|
||||
@@ -22,6 +22,7 @@ from castle_core.config import (
|
||||
ensure_dirs,
|
||||
load_config,
|
||||
resolve_env_split,
|
||||
resolve_placeholders,
|
||||
)
|
||||
from castle_core.generators.caddyfile import (
|
||||
_DNS_TOKEN_ENV,
|
||||
@@ -47,6 +48,7 @@ from castle_core.manifest import (
|
||||
DeploymentSpec,
|
||||
PathDeployment,
|
||||
RemoteDeployment,
|
||||
TlsMaterial,
|
||||
kind_for,
|
||||
)
|
||||
from castle_core.registry import (
|
||||
@@ -184,6 +186,7 @@ def _node_config(config: CastleConfig) -> NodeConfig:
|
||||
acme_dns_provider=config.gateway.acme_dns_provider,
|
||||
public_domain=config.gateway.public_domain,
|
||||
tunnel_id=config.gateway.tunnel_id,
|
||||
cert_hook=config.gateway.cert_hook,
|
||||
)
|
||||
|
||||
|
||||
@@ -258,6 +261,19 @@ def apply(
|
||||
result.messages = list(deploy_result.messages)
|
||||
result.registry = deploy_result.registry
|
||||
|
||||
# Materialize TLS cert files before (re)starting so a TLS service finds them on
|
||||
# start. On a fresh node the gateway reload above only kicks off ACME issuance,
|
||||
# so wait (bounded) for the wildcard first — otherwise the service would start
|
||||
# without its cert and, with cert_hook off (the default), never recover. Scope
|
||||
# materialization to the deployments being applied so a scoped apply doesn't
|
||||
# rewrite an unrelated service's cert without reloading it. No reload here — the
|
||||
# activation loop below starts/restarts as needed; rotation-driven reloads are
|
||||
# the `castle tls reconcile` / cert_obtained path.
|
||||
from castle_core.tls import materialize_all, wait_for_wildcard
|
||||
|
||||
wait_for_wildcard(config, names, result.messages)
|
||||
materialize_all(config, result.messages, only=names)
|
||||
|
||||
for name in names:
|
||||
after_unit = _unit_bytes(name, is_job[name])
|
||||
action = _classify(name, after_unit)
|
||||
@@ -387,6 +403,28 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
|
||||
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)
|
||||
# Validate the generated Caddyfile before reloading. An invalid config (most
|
||||
# often gateway.cert_hook enabled while the running Caddy lacks the events-exec
|
||||
# plugin, so the `events {}` block fails to adapt) must not be pushed: a bad
|
||||
# reload leaves stale routing and a later cold start would refuse to load. Skip
|
||||
# the reload and point at the likely cause instead of silently degrading.
|
||||
caddyfile = SPECS_DIR / "Caddyfile"
|
||||
caddy = shutil.which("caddy")
|
||||
if caddy and caddyfile.exists():
|
||||
check = subprocess.run(
|
||||
[caddy, "validate", "--adapter", "caddyfile", "--config", str(caddyfile)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if check.returncode != 0:
|
||||
messages.append(
|
||||
"Warning: generated Caddyfile is invalid — gateway NOT reloaded (the "
|
||||
"running config is left untouched). If gateway.cert_hook is enabled, "
|
||||
"the gateway's Caddy build needs the events-exec plugin; rebuild it "
|
||||
"(install.sh) or set cert_hook: false.\n"
|
||||
+ (check.stderr.strip() or check.stdout.strip())
|
||||
)
|
||||
return
|
||||
active = subprocess.run(
|
||||
["systemctl", "--user", "is-active", gw_unit],
|
||||
capture_output=True,
|
||||
@@ -459,7 +497,12 @@ def _env_context(
|
||||
) -> dict[str, str]:
|
||||
"""Placeholder values for defaults.env: ${name}/${data_dir}/${port}/${public_url}/
|
||||
${supabase_app_schemas}."""
|
||||
ctx = {"name": name, "data_dir": str(DATA_DIR / config_key)}
|
||||
ctx = {
|
||||
"name": name,
|
||||
"data_dir": str(DATA_DIR / config_key),
|
||||
"uid": str(os.getuid()),
|
||||
"gid": str(os.getgid()),
|
||||
}
|
||||
if port is not None:
|
||||
ctx["port"] = str(port)
|
||||
if public_url is not None:
|
||||
@@ -562,13 +605,16 @@ def _build_deployed(
|
||||
if dep.manage and dep.manage.systemd and not dep.manage.systemd.enable:
|
||||
managed = False
|
||||
|
||||
# `proxy` is the exposure checkbox; the subdomain is the deployment name.
|
||||
expose = bool(dep.proxy)
|
||||
# `http_exposed` is the HTTP-gateway checkbox (reach != off AND an http port);
|
||||
# the subdomain is the deployment name. A raw-TCP service is not http_exposed —
|
||||
# it's reachable at <name>.<domain>:<tcp_port> via bind + wildcard DNS.
|
||||
expose = dep.http_exposed
|
||||
port = None
|
||||
health_path = None
|
||||
if dep.expose and dep.expose.http:
|
||||
port = dep.expose.http.internal.port
|
||||
health_path = dep.expose.http.health_path
|
||||
tcp_port = dep.tcp_port
|
||||
|
||||
# Env is exactly what's in defaults.env — no hidden convention injection.
|
||||
# ${port}/${data_dir}/${name}/${public_url} map the program's own env var
|
||||
@@ -576,10 +622,23 @@ def _build_deployed(
|
||||
# mode-0600 file (never in the unit or argv).
|
||||
raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
|
||||
public_url = _public_url(config, name, expose, port)
|
||||
env, secret_env = resolve_env_split(
|
||||
raw_env,
|
||||
_env_context(name, config_key, port, public_url, _supabase_app_schemas(config)),
|
||||
)
|
||||
ctx = _env_context(name, config_key, port, public_url, _supabase_app_schemas(config))
|
||||
# ${tls_*}: paths to castle-materialized cert files for a TLS-material TCP
|
||||
# service. The deployment maps them into its own config (mount ${tls_dir} for a
|
||||
# container, or reference ${tls_cert}/${tls_key} directly for a native service).
|
||||
tls = dep.expose.tcp.tls if (dep.expose and dep.expose.tcp) else None
|
||||
if tls and tls.material != TlsMaterial.OFF:
|
||||
tls_dir = DATA_DIR / config_key / "tls"
|
||||
ctx.update(
|
||||
{
|
||||
"tls_dir": str(tls_dir),
|
||||
"tls_cert": str(tls_dir / "cert.pem"),
|
||||
"tls_key": str(tls_dir / "key.pem"),
|
||||
"tls_pem": str(tls_dir / "combined.pem"),
|
||||
"tls_ca": str(tls_dir / "chain.pem"),
|
||||
}
|
||||
)
|
||||
env, secret_env = resolve_env_split(raw_env, ctx)
|
||||
secret_env_file = _write_secret_env_file(name, secret_env)
|
||||
|
||||
# `command` launchers resolve a tool on PATH → ensure it's installed.
|
||||
@@ -588,7 +647,8 @@ def _build_deployed(
|
||||
_ensure_python_tool(config, dep.program, messages)
|
||||
|
||||
run_cmd = _build_run_cmd(
|
||||
name, run, env, messages, source_dir, secret_env_file=secret_env_file
|
||||
name, run, env, messages, source_dir, secret_env_file=secret_env_file,
|
||||
placeholders=ctx,
|
||||
)
|
||||
stop_cmd = _build_stop_cmd(name, run, source_dir)
|
||||
|
||||
@@ -606,6 +666,7 @@ def _build_deployed(
|
||||
health_path=health_path,
|
||||
subdomain=(name if expose else None),
|
||||
public=bool(dep.public and expose),
|
||||
tcp_port=tcp_port,
|
||||
schedule=getattr(dep, "schedule", None),
|
||||
managed=managed,
|
||||
enabled=dep.enabled,
|
||||
@@ -680,6 +741,15 @@ def _ensure_python_tool(
|
||||
messages.append(f"Installed {program}")
|
||||
|
||||
|
||||
def _subst(value: str, placeholders: dict[str, str] | None) -> str:
|
||||
"""Expand ``${key}`` in a run-spec string field from castle's computed values
|
||||
(``${uid}``/``${gid}``/``${data_dir}``/``${tls_dir}``/…), via the one shared
|
||||
``${...}`` resolver (:func:`resolve_placeholders`). Unknown refs pass through
|
||||
unchanged (secrets never belong in argv — they go via --env-file); write
|
||||
``$${key}`` to pass a literal ``${key}`` to a container's own shell/env."""
|
||||
return resolve_placeholders(value, placeholders)
|
||||
|
||||
|
||||
def _build_run_cmd(
|
||||
name: str,
|
||||
run: object,
|
||||
@@ -687,6 +757,7 @@ def _build_run_cmd(
|
||||
messages: list[str],
|
||||
source_dir: Path | None = None,
|
||||
secret_env_file: Path | None = None,
|
||||
placeholders: dict[str, str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Build a run command list from a LaunchSpec (a systemd deployment's `run`).
|
||||
|
||||
@@ -729,24 +800,31 @@ def _build_run_cmd(
|
||||
# Container name derives from the SERVICE name (matches the systemd unit),
|
||||
# not the image name — so `castle-<service>` is stable and collision-free.
|
||||
cmd = [runtime, "run", "--rm", f"--name=castle-{name}"]
|
||||
if run.user: # type: ignore[union-attr]
|
||||
# Run as the invoking user (uid uniformity → bind-mounted
|
||||
# certs/data/secrets readable with no chown). ${uid}/${gid} expand
|
||||
# to the castle process's own ids.
|
||||
cmd.extend(["--user", _subst(run.user, placeholders)]) # type: ignore[union-attr]
|
||||
for tp in run.tmpfs: # type: ignore[union-attr]
|
||||
cmd.extend(["--tmpfs", _subst(tp, placeholders)])
|
||||
for container_port, host_port in run.ports.items(): # type: ignore[union-attr]
|
||||
cmd.extend(["-p", f"{host_port}:{container_port}"])
|
||||
for vol in run.volumes: # type: ignore[union-attr]
|
||||
cmd.extend(["-v", vol])
|
||||
cmd.extend(["-v", _subst(vol, placeholders)])
|
||||
for key, val in run.env.items(): # type: ignore[union-attr]
|
||||
cmd.extend(["-e", f"{key}={val}"])
|
||||
cmd.extend(["-e", f"{key}={_subst(val, placeholders)}"])
|
||||
# env is plain-only; secrets go via --env-file so they never hit argv.
|
||||
for key, val in env.items():
|
||||
cmd.extend(["-e", f"{key}={val}"])
|
||||
if secret_env_file is not None:
|
||||
cmd.extend(["--env-file", str(secret_env_file)])
|
||||
if run.workdir: # type: ignore[union-attr]
|
||||
cmd.extend(["-w", run.workdir]) # type: ignore[union-attr]
|
||||
cmd.extend(["-w", _subst(run.workdir, placeholders)]) # type: ignore[union-attr]
|
||||
cmd.append(run.image) # type: ignore[union-attr]
|
||||
if run.command: # type: ignore[union-attr]
|
||||
cmd.extend(run.command) # type: ignore[union-attr]
|
||||
cmd.extend(_subst(c, placeholders) for c in run.command) # type: ignore[union-attr]
|
||||
if run.args: # type: ignore[union-attr]
|
||||
cmd.extend(run.args) # type: ignore[union-attr]
|
||||
cmd.extend(_subst(a, placeholders) for a in run.args) # type: ignore[union-attr]
|
||||
return cmd
|
||||
case "compose":
|
||||
# A whole docker-compose stack supervised as one unit. `up` runs
|
||||
|
||||
@@ -54,13 +54,14 @@ def service_proxy_targets(name: str, dep: SystemdDeployment) -> ProxyTargets:
|
||||
"""Derive a systemd deployment's gateway exposure from its spec.
|
||||
|
||||
The single source of truth shared by the registry build (``deploy``) and
|
||||
route computation (``compute_routes``), so they never disagree. ``expose`` is
|
||||
the checkbox (``proxy: true``); the subdomain is always the deployment name.
|
||||
route computation (``compute_routes``), so they never disagree. A gateway
|
||||
route is HTTP-only: ``http_exposed`` requires ``reach != off`` *and* an HTTP
|
||||
port, so a raw-TCP service (``expose.tcp``) never yields a route here.
|
||||
"""
|
||||
port = None
|
||||
if dep.expose and dep.expose.http:
|
||||
port = dep.expose.http.internal.port
|
||||
return bool(dep.proxy), port, None
|
||||
return dep.http_exposed, port, None
|
||||
|
||||
|
||||
def _local_routes(
|
||||
@@ -214,6 +215,18 @@ def generate_caddyfile_from_registry(
|
||||
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}")
|
||||
# On issuance/renewal, refresh certs materialized onto raw-TCP services and
|
||||
# reload them (idempotent — a no-op when nothing rotated). Requires the
|
||||
# events-exec plugin in the gateway's Caddy build, so it's gated on the
|
||||
# durable `gateway.cert_hook` flag, set only once that Caddy is in place;
|
||||
# false → the block is omitted and a plugin-less gateway parses fine. See
|
||||
# docs/tcp-exposure.md §5.
|
||||
if getattr(node, "cert_hook", False):
|
||||
lines += [
|
||||
" events {",
|
||||
" on cert_obtained exec castle tls reconcile",
|
||||
" }",
|
||||
]
|
||||
lines += ["}", ""]
|
||||
# One wildcard site → a single cert covers every subdomain; a new service
|
||||
# needs no new cert or challenge.
|
||||
|
||||
@@ -16,6 +16,47 @@ class RestartPolicy(str, Enum):
|
||||
ALWAYS = "always"
|
||||
|
||||
|
||||
class Reach(str, Enum):
|
||||
"""How far a deployment is exposed — a protocol-agnostic ladder.
|
||||
|
||||
``off`` → reachable only at its own host:port (no gateway route).
|
||||
``internal`` → reachable at ``<name>.<domain>`` (HTTP via the gateway, or TCP
|
||||
via bind + wildcard DNS).
|
||||
``public`` → *also* projected to the internet (HTTP via the tunnel origin;
|
||||
TCP via ``cloudflared access tcp``). Implies ``internal``.
|
||||
|
||||
Replaces the old ``proxy``/``public`` booleans; ``proxy``/``public`` survive as
|
||||
derived read-only accessors and as accepted *legacy input* (normalized below).
|
||||
"""
|
||||
|
||||
OFF = "off"
|
||||
INTERNAL = "internal"
|
||||
PUBLIC = "public"
|
||||
|
||||
|
||||
def _reach_from_legacy(data: object, default: Reach) -> object:
|
||||
"""Map legacy ``proxy``/``public`` booleans on a raw deployment dict to ``reach``.
|
||||
|
||||
Runs as a ``mode="before"`` validator. When ``reach`` is given explicitly it
|
||||
wins (legacy keys are dropped); otherwise ``reach`` is derived from the old
|
||||
booleans: ``public`` → PUBLIC, ``proxy`` → INTERNAL, else ``default``. Non-dict
|
||||
input (e.g. model re-validation) passes through untouched.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
d = dict(data)
|
||||
proxy = bool(d.pop("proxy", False))
|
||||
public = bool(d.pop("public", False))
|
||||
if "reach" not in d:
|
||||
if public:
|
||||
d["reach"] = Reach.PUBLIC
|
||||
elif proxy:
|
||||
d["reach"] = Reach.INTERNAL
|
||||
else:
|
||||
d["reach"] = default
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Launch specs — how systemd starts a process (discriminated union on `launcher`)
|
||||
# ---------------------
|
||||
@@ -51,6 +92,13 @@ class RunContainer(LaunchBase):
|
||||
volumes: list[str] = Field(default_factory=list)
|
||||
env: EnvMap = Field(default_factory=dict)
|
||||
workdir: str | None = None
|
||||
# Run the container as this uid[:gid] (e.g. "${uid}:${gid}"). Running as the
|
||||
# invoking user makes bind-mounted data/secrets/certs readable with no chown —
|
||||
# see docs/tcp-exposure.md §4. None → the image's own default user.
|
||||
user: str | None = None
|
||||
# tmpfs mounts (e.g. ["/var/run/postgresql"]) for image runtime dirs that must
|
||||
# be writable when the container runs as a non-default uid.
|
||||
tmpfs: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RunNode(LaunchBase):
|
||||
@@ -119,7 +167,7 @@ class ManageSpec(BaseModel):
|
||||
|
||||
|
||||
# ---------------------
|
||||
# HTTP exposure + proxy
|
||||
# Exposure — HTTP (via the gateway) or raw TCP (bind + DNS)
|
||||
# ---------------------
|
||||
|
||||
|
||||
@@ -134,8 +182,55 @@ class HttpExposeSpec(BaseModel):
|
||||
health_path: str | None = None
|
||||
|
||||
|
||||
class TlsMaterial(str, Enum):
|
||||
"""What cert files castle materializes onto a service from the wildcard cert.
|
||||
|
||||
``off`` → the service does its own TLS (or none); castle stays out of it.
|
||||
``pair`` → cert.pem + key.pem (postgres, redis, most daemons).
|
||||
``combined`` → one file: key+cert concatenated (mongodb, haproxy, …).
|
||||
"""
|
||||
|
||||
OFF = "off"
|
||||
PAIR = "pair"
|
||||
COMBINED = "combined"
|
||||
|
||||
|
||||
class TlsSpec(BaseModel):
|
||||
"""Castle-managed TLS material for a raw-TCP service, cut from the gateway's
|
||||
ACME wildcard cert (valid for ``<name>.<domain>``) and refreshed on renewal.
|
||||
The service consumes the materialized files via the ``${tls_*}`` placeholders.
|
||||
"""
|
||||
|
||||
material: TlsMaterial = TlsMaterial.OFF
|
||||
# Optional zero-downtime reload argv (a single command) run after the cert is
|
||||
# re-materialized on renewal — e.g. ["systemctl", "--user", "reload", "castle-postgres"].
|
||||
# Default (None): castle restarts the deployment (fine at a ~60-day cadence).
|
||||
reload: list[str] | None = None
|
||||
|
||||
|
||||
class TcpExposeSpec(BaseModel):
|
||||
"""A raw-TCP service (postgres, redis, …). It doesn't ride the HTTP gateway:
|
||||
with ``reach: internal`` it's reachable at ``<name>.<domain>:<port>`` via the
|
||||
wildcard DNS record + the bound port (no Caddy route). Publishing the port on
|
||||
the LAN is the deployment's own job (a container's ``run.ports``, or a native
|
||||
service binding ``0.0.0.0``); castle doesn't rebind it, so there's no bind-host
|
||||
field here to imply otherwise. ``tls`` (optional) has castle drop the wildcard
|
||||
cert onto the service so it presents a trusted cert for ``<name>.<domain>``.
|
||||
"""
|
||||
|
||||
port: int = Field(ge=1, le=65535)
|
||||
tls: TlsSpec | None = None
|
||||
|
||||
|
||||
class ExposeSpec(BaseModel):
|
||||
http: HttpExposeSpec | None = None
|
||||
tcp: TcpExposeSpec | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _one_protocol(self) -> ExposeSpec:
|
||||
if self.http and self.tcp:
|
||||
raise ValueError("a deployment exposes http OR tcp, not both")
|
||||
return self
|
||||
|
||||
|
||||
# ---------------------
|
||||
@@ -327,19 +422,68 @@ class SystemdDeployment(DeploymentBase):
|
||||
schedule: str | None = None
|
||||
timezone: str = "America/Los_Angeles"
|
||||
expose: ExposeSpec | None = None
|
||||
# Route <name>.<gateway.domain> to this process. False → host:port only.
|
||||
proxy: bool = False
|
||||
# Also publish to the public internet via the Cloudflare tunnel, at
|
||||
# <name>.<gateway.public_domain>. Opt-in; requires `proxy`.
|
||||
public: bool = False
|
||||
# How far this process is exposed (off | internal | public). See `Reach`.
|
||||
reach: Reach = Reach.OFF
|
||||
manage: ManageSpec | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _normalize_reach(cls, data: object) -> object:
|
||||
return _reach_from_legacy(data, default=Reach.OFF)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate(self) -> SystemdDeployment:
|
||||
if self.public and not self.proxy:
|
||||
raise ValueError("public requires proxy (an exposed process).")
|
||||
def _validate_reach(self) -> SystemdDeployment:
|
||||
# An exposed reach needs a port to expose. Without an `expose` block the
|
||||
# reach silently no-ops — no route, no subdomain, no tunnel entry — so a
|
||||
# typo'd/omitted `expose` reads as success while the service is unreachable.
|
||||
# Reject it at load so the mistake surfaces (replaces the old
|
||||
# "public requires proxy" guard, now that reach is the canonical field).
|
||||
# Static frontends (manager: caddy) are inherently exposed and validated
|
||||
# elsewhere; this is a supervised process, which needs an explicit port.
|
||||
if self.reach != Reach.OFF and not self.expose:
|
||||
raise ValueError(
|
||||
f"reach: {self.reach.value} requires an `expose` block "
|
||||
"(expose.http or expose.tcp); a port-only process uses reach: off"
|
||||
)
|
||||
# Public raw-TCP (tunnel + Access) is a later step; guard it explicitly
|
||||
# rather than silently no-op'ing when a TCP service asks for it.
|
||||
if (
|
||||
self.reach == Reach.PUBLIC
|
||||
and self.expose
|
||||
and self.expose.tcp
|
||||
and not self.expose.http
|
||||
):
|
||||
raise ValueError(
|
||||
"reach: public for a raw-TCP service isn't supported yet "
|
||||
"(see docs/tcp-exposure.md step 5); use reach: internal"
|
||||
)
|
||||
return self
|
||||
|
||||
# Derived, read-only back-compat accessors (not serialized) so existing
|
||||
# readers keep working while the stored/authored field is `reach`.
|
||||
@property
|
||||
def proxy(self) -> bool:
|
||||
return self.reach != Reach.OFF
|
||||
|
||||
@property
|
||||
def public(self) -> bool:
|
||||
return self.reach == Reach.PUBLIC
|
||||
|
||||
@property
|
||||
def http_exposed(self) -> bool:
|
||||
"""Exposed through the HTTP gateway at ``<name>.<domain>`` — the predicate
|
||||
for a Caddy route / subdomain. Requires ``reach != off`` *and* an HTTP
|
||||
port; a raw-TCP service (``expose.tcp``) is never HTTP-exposed."""
|
||||
return self.reach != Reach.OFF and bool(self.expose and self.expose.http)
|
||||
|
||||
@property
|
||||
def tcp_port(self) -> int | None:
|
||||
"""The raw-TCP port this service is exposed on, or None. Reachable at
|
||||
``<name>.<domain>:<port>`` when ``reach != off`` (bind + wildcard DNS)."""
|
||||
if self.reach != Reach.OFF and self.expose and self.expose.tcp:
|
||||
return self.expose.tcp.port
|
||||
return None
|
||||
|
||||
|
||||
class CaddyDeployment(DeploymentBase):
|
||||
"""A static site served by the gateway (Caddy ``file_server``) — no process.
|
||||
@@ -350,8 +494,24 @@ class CaddyDeployment(DeploymentBase):
|
||||
|
||||
manager: Literal["caddy"]
|
||||
root: str = "dist"
|
||||
# Inherently exposed at its subdomain; `public` = also project via the tunnel.
|
||||
public: bool = False
|
||||
# A static site is inherently served at its subdomain, so `reach` is
|
||||
# `internal` or `public` (never `off`). `public` = also project via the tunnel.
|
||||
reach: Reach = Reach.INTERNAL
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _normalize_reach(cls, data: object) -> object:
|
||||
return _reach_from_legacy(data, default=Reach.INTERNAL)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_reach(self) -> CaddyDeployment:
|
||||
if self.reach == Reach.OFF:
|
||||
raise ValueError("a static (caddy) deployment is always served; reach must be internal|public")
|
||||
return self
|
||||
|
||||
@property
|
||||
def public(self) -> bool:
|
||||
return self.reach == Reach.PUBLIC
|
||||
|
||||
|
||||
class PathDeployment(DeploymentBase):
|
||||
|
||||
@@ -30,6 +30,8 @@ class NodeConfig:
|
||||
# Cloudflare tunnel: public services publish at <subdomain>.<public_domain>.
|
||||
public_domain: str | None = None
|
||||
tunnel_id: str | None = None
|
||||
# Emit the cert_obtained → `castle tls reconcile` hook (needs events-exec plugin).
|
||||
cert_hook: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.hostname:
|
||||
@@ -66,6 +68,9 @@ class Deployment:
|
||||
# Also projected to the public internet via the tunnel at
|
||||
# <subdomain>.<gateway.public_domain>. Requires subdomain.
|
||||
public: bool = False
|
||||
# Raw-TCP exposure port (postgres, redis, …). Set → reachable at
|
||||
# <name>.<gateway.domain>:<tcp_port> via bind + wildcard DNS (no Caddy route).
|
||||
tcp_port: int | None = None
|
||||
# For `static` runner services: the absolute dir the gateway file_servers.
|
||||
# Set → the route is `static` (file_server) rather than `proxy` (reverse_proxy).
|
||||
static_root: str | None = None
|
||||
@@ -113,6 +118,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
||||
acme_dns_provider=node_data.get("acme_dns_provider", "cloudflare"),
|
||||
public_domain=node_data.get("public_domain"),
|
||||
tunnel_id=node_data.get("tunnel_id"),
|
||||
cert_hook=node_data.get("cert_hook", False),
|
||||
)
|
||||
|
||||
deployed: dict[str, Deployment] = {}
|
||||
@@ -154,6 +160,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
||||
health_path=comp_data.get("health_path"),
|
||||
subdomain=comp_data.get("subdomain"),
|
||||
public=comp_data.get("public", False),
|
||||
tcp_port=comp_data.get("tcp_port"),
|
||||
static_root=comp_data.get("static_root"),
|
||||
base_url=comp_data.get("base_url"),
|
||||
schedule=comp_data.get("schedule"),
|
||||
@@ -194,6 +201,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
||||
data["node"]["public_domain"] = registry.node.public_domain
|
||||
if registry.node.tunnel_id:
|
||||
data["node"]["tunnel_id"] = registry.node.tunnel_id
|
||||
if registry.node.cert_hook:
|
||||
data["node"]["cert_hook"] = registry.node.cert_hook
|
||||
|
||||
for name, comp in registry.deployed.items():
|
||||
entry: dict = {
|
||||
@@ -221,6 +230,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
||||
entry["subdomain"] = comp.subdomain
|
||||
if comp.public:
|
||||
entry["public"] = comp.public
|
||||
if comp.tcp_port is not None:
|
||||
entry["tcp_port"] = comp.tcp_port
|
||||
if comp.static_root:
|
||||
entry["static_root"] = comp.static_root
|
||||
if comp.base_url:
|
||||
|
||||
239
core/src/castle_core/tls.py
Normal file
239
core/src/castle_core/tls.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Castle-managed TLS material for raw-TCP services.
|
||||
|
||||
Cuts the gateway's ACME wildcard cert (valid for ``<name>.<domain>``) onto a
|
||||
service so it presents a *trusted* cert on its raw port, and refreshes it on
|
||||
renewal. Protocol-agnostic: castle only copies files (in the requested format)
|
||||
and signals the service — each deployment declares the format (``pair`` /
|
||||
``combined``) and how it consumes the files (via the ``${tls_*}`` placeholders).
|
||||
|
||||
Two entry points:
|
||||
- ``materialize_all`` — write/refresh the cert files, no reload (used by ``apply``,
|
||||
which (re)starts the service itself).
|
||||
- ``reconcile_tls`` — materialize *and* reload the services whose cert changed
|
||||
(used by ``castle tls reconcile`` and the Caddy ``cert_obtained`` hook).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from castle_core.config import DATA_DIR, CastleConfig
|
||||
from castle_core.manifest import SystemdDeployment, TlsMaterial
|
||||
|
||||
_KEY_MODE_FILES = {"key.pem", "combined.pem"} # secret → 0600; certs → 0644
|
||||
|
||||
|
||||
def _caddy_data_dir() -> Path:
|
||||
xdg = os.environ.get("XDG_DATA_HOME")
|
||||
base = Path(xdg) if xdg else Path.home() / ".local" / "share"
|
||||
return base / "caddy"
|
||||
|
||||
|
||||
def wildcard_cert(domain: str) -> tuple[Path, Path] | None:
|
||||
"""``(crt, key)`` for ``*.<domain>`` from Caddy's store, or None.
|
||||
|
||||
Caddy stores it at ``certificates/<acme-dir>/wildcard_.<domain>/…``. Prefer a
|
||||
production cert over staging (``CASTLE_ACME_STAGING=1`` yields a staging dir).
|
||||
The ``.crt`` is the full chain (leaf + intermediates).
|
||||
"""
|
||||
store = _caddy_data_dir() / "certificates"
|
||||
if not store.is_dir():
|
||||
return None
|
||||
stem = f"wildcard_.{domain}"
|
||||
matches = sorted(
|
||||
store.glob(f"*/{stem}/{stem}.crt"),
|
||||
key=lambda p: 1 if "staging" in str(p) else 0, # prod (0) before staging (1)
|
||||
)
|
||||
for crt in matches:
|
||||
key = crt.with_suffix(".key")
|
||||
if key.exists():
|
||||
return crt, key
|
||||
return None
|
||||
|
||||
|
||||
def tls_dir_for(config_key: str) -> Path:
|
||||
"""Where a deployment's materialized cert files live (``${tls_dir}``)."""
|
||||
return DATA_DIR / config_key / "tls"
|
||||
|
||||
|
||||
def _tls_of(dep: object) -> object | None:
|
||||
"""The active TlsSpec for a deployment, or None (not systemd / no tcp / off)."""
|
||||
if not isinstance(dep, SystemdDeployment):
|
||||
return None
|
||||
tcp = dep.expose.tcp if dep.expose else None
|
||||
tls = tcp.tls if tcp else None
|
||||
if not tls or tls.material == TlsMaterial.OFF:
|
||||
return None
|
||||
return tls
|
||||
|
||||
|
||||
_PEM_CERT = re.compile(
|
||||
rb"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----\s*", re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def _issuer_chain(crt: bytes) -> bytes:
|
||||
"""The issuer chain from a leaf+chain ``.crt``: every cert *after* the leaf
|
||||
(the intermediates), for ``${tls_ca}``. Empty when the ``.crt`` is a bare leaf
|
||||
(a genuine LE cert always ships an intermediate, so this is non-empty in
|
||||
practice)."""
|
||||
blocks = [m.group(0) for m in _PEM_CERT.finditer(crt)]
|
||||
return b"".join(blocks[1:])
|
||||
|
||||
|
||||
def _wanted_files(
|
||||
tls_dir: Path, material: TlsMaterial, crt: bytes, key: bytes
|
||||
) -> dict[Path, bytes]:
|
||||
"""The exact file set for a material choice. ``chain.pem`` (for ``${tls_ca}``)
|
||||
is the *issuer chain* — the intermediates only, leaf stripped — so it's a real
|
||||
CA bundle distinct from the leaf-bearing ``cert.pem``/``combined.pem``, always
|
||||
provided regardless of material."""
|
||||
files: dict[Path, bytes] = {tls_dir / "chain.pem": _issuer_chain(crt)}
|
||||
if material == TlsMaterial.PAIR:
|
||||
files[tls_dir / "cert.pem"] = crt
|
||||
files[tls_dir / "key.pem"] = key
|
||||
elif material == TlsMaterial.COMBINED:
|
||||
files[tls_dir / "combined.pem"] = key + crt
|
||||
return files
|
||||
|
||||
|
||||
def materialize_tls(config: CastleConfig, name: str, dep: object) -> bool:
|
||||
"""Write ``dep``'s cert files from the wildcard, in its declared format.
|
||||
|
||||
Idempotent: returns ``False`` (no write) when the on-disk copy already matches
|
||||
the source, so it's safe to call on every ``apply`` and every renewal event.
|
||||
Returns ``True`` when files were (re)written. No reload here — see callers.
|
||||
"""
|
||||
tls = _tls_of(dep)
|
||||
if tls is None:
|
||||
return False
|
||||
domain = config.gateway.domain
|
||||
if not domain:
|
||||
return False
|
||||
src = wildcard_cert(domain)
|
||||
if src is None:
|
||||
return False
|
||||
crt_path, key_path = src
|
||||
crt, key = crt_path.read_bytes(), key_path.read_bytes()
|
||||
|
||||
config_key = dep.program or name # type: ignore[attr-defined]
|
||||
tls_dir = tls_dir_for(config_key)
|
||||
wanted = _wanted_files(tls_dir, tls.material, crt, key) # type: ignore[attr-defined]
|
||||
|
||||
if all(p.exists() and p.read_bytes() == c for p, c in wanted.items()):
|
||||
return False # already current
|
||||
|
||||
tls_dir.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(tls_dir, 0o700)
|
||||
# Drop files left over from a previous material choice.
|
||||
for stale in ("cert.pem", "key.pem", "combined.pem", "chain.pem"):
|
||||
p = tls_dir / stale
|
||||
if p not in wanted and p.exists():
|
||||
p.unlink()
|
||||
for path, content in wanted.items():
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(content)
|
||||
os.chmod(path, 0o600 if path.name in _KEY_MODE_FILES else 0o644)
|
||||
return True
|
||||
|
||||
|
||||
def materialize_all(
|
||||
config: CastleConfig,
|
||||
messages: list[str] | None = None,
|
||||
only: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Materialize certs for TLS-material deployments; no reload. For ``apply``,
|
||||
which starts/restarts the services itself.
|
||||
|
||||
``only`` scopes materialization to the given deployment names (what a scoped
|
||||
``castle apply <name>`` is converging). Left None → every deployment. Scoping
|
||||
keeps a scoped apply from rewriting an *unrelated* service's cert on disk
|
||||
without also reloading it (which would leave the file diverged from the running
|
||||
process until the next ``castle tls reconcile``)."""
|
||||
msgs = messages if messages is not None else []
|
||||
scope = set(only) if only is not None else None
|
||||
for name, dep in sorted(config.deployments.items()):
|
||||
if scope is not None and name not in scope:
|
||||
continue
|
||||
if _tls_of(dep) is None:
|
||||
continue
|
||||
if materialize_tls(config, name, dep):
|
||||
msgs.append(f"tls: materialized cert for {name}")
|
||||
return msgs
|
||||
|
||||
|
||||
def wait_for_wildcard(
|
||||
config: CastleConfig,
|
||||
names: list[str],
|
||||
messages: list[str] | None = None,
|
||||
timeout: float = 120.0,
|
||||
interval: float = 3.0,
|
||||
) -> list[str]:
|
||||
"""Block until the ACME wildcard cert exists, when an in-scope deployment needs
|
||||
castle-materialized TLS but the cert isn't issued yet.
|
||||
|
||||
On a fresh node the gateway reload during ``apply`` only *kicks off* DNS-01
|
||||
issuance of ``*.<domain>`` (seconds to a couple minutes); materializing right
|
||||
after would find no cert and start the TLS service pointed at missing files —
|
||||
and with ``gateway.cert_hook`` disabled (the default) nothing would later
|
||||
reconcile it. Waiting here lets ``apply`` bring the service up with its cert in
|
||||
place on first deploy. Bounded: on timeout it warns and returns so ``apply``
|
||||
still proceeds (rerun ``castle tls reconcile`` once the cert lands)."""
|
||||
msgs = messages if messages is not None else []
|
||||
needs = [
|
||||
n
|
||||
for n in names
|
||||
if (dep := config.deployments.get(n)) is not None and _tls_of(dep) is not None
|
||||
]
|
||||
if not needs:
|
||||
return msgs
|
||||
domain = config.gateway.domain
|
||||
if not domain or wildcard_cert(domain) is not None:
|
||||
return msgs # no acme domain, or the cert already exists — nothing to wait on
|
||||
msgs.append(f"tls: waiting for ACME wildcard *.{domain} to be issued…")
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(interval)
|
||||
if wildcard_cert(domain) is not None:
|
||||
msgs.append(f"tls: wildcard *.{domain} issued")
|
||||
return msgs
|
||||
msgs.append(
|
||||
f"tls: wildcard *.{domain} not ready after {int(timeout)}s — "
|
||||
f"{', '.join(needs)} may start without a cert; rerun `castle tls reconcile` "
|
||||
"once it is issued"
|
||||
)
|
||||
return msgs
|
||||
|
||||
|
||||
def _reload(name: str, tls: object, msgs: list[str]) -> None:
|
||||
reload_cmd = getattr(tls, "reload", None)
|
||||
if reload_cmd:
|
||||
subprocess.run(reload_cmd, check=False)
|
||||
msgs.append(f"tls: reloaded {name} (reload command)")
|
||||
else:
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "restart", f"castle-{name}.service"], check=False
|
||||
)
|
||||
msgs.append(f"tls: restarted {name} to pick up rotated cert")
|
||||
|
||||
|
||||
def reconcile_tls(config: CastleConfig, messages: list[str] | None = None) -> list[str]:
|
||||
"""Materialize certs and reload the services whose cert changed. Idempotent —
|
||||
a no-op when nothing rotated. Invoked by ``castle tls reconcile`` and the Caddy
|
||||
``cert_obtained`` hook. Logs its own outcome (the hook's ``exec`` swallows it)."""
|
||||
msgs = messages if messages is not None else []
|
||||
for name, dep in sorted(config.deployments.items()):
|
||||
tls = _tls_of(dep)
|
||||
if tls is None:
|
||||
continue
|
||||
if materialize_tls(config, name, dep):
|
||||
msgs.append(f"tls: refreshed cert for {name}")
|
||||
_reload(name, tls, msgs)
|
||||
if not msgs:
|
||||
msgs.append("tls: all materialized certs current — nothing to do")
|
||||
return msgs
|
||||
@@ -185,6 +185,21 @@ class TestConfigSourceOfTruth:
|
||||
routes = compute_routes(_make_registry(), _config({"pg": _service(5432, expose=False)}))
|
||||
assert not [r for r in routes if r.name == "pg"]
|
||||
|
||||
def test_tcp_service_has_no_http_route(self) -> None:
|
||||
"""A raw-TCP service (reach: internal + expose.tcp) is reachable by
|
||||
name+port via DNS, but must NOT get an HTTP gateway route."""
|
||||
tcp = SystemdDeployment.model_validate(
|
||||
{
|
||||
"manager": "systemd",
|
||||
"run": RunPython(launcher="python", program="pg"),
|
||||
"reach": "internal",
|
||||
"expose": {"tcp": {"port": 5432}},
|
||||
}
|
||||
)
|
||||
assert tcp.tcp_port == 5432 and tcp.http_exposed is False
|
||||
routes = compute_routes(_make_registry(), _config({"pg": tcp}))
|
||||
assert not [r for r in routes if r.name == "pg"]
|
||||
|
||||
def test_config_port_overrides_stale_registry(self) -> None:
|
||||
registry = _make_registry(
|
||||
deployed={"claw": _dep(8001, expose=True, name="claw")}
|
||||
|
||||
@@ -107,6 +107,45 @@ def test_container_without_secrets_has_no_env_file() -> None:
|
||||
assert "--env-file" not in cmd
|
||||
|
||||
|
||||
def test_container_placeholders_expand_in_run_fields() -> None:
|
||||
"""${key} placeholders expand consistently across a container's env, volumes,
|
||||
and args — env used to be the odd one out that passed through literally."""
|
||||
run = RunContainer(
|
||||
launcher="container",
|
||||
image="img:latest",
|
||||
env={"DATA": "${data_dir}/x"},
|
||||
volumes=["${tls_dir}:/tls:ro"],
|
||||
args=["--advertise", "${name}:${port}"],
|
||||
)
|
||||
ph = {
|
||||
"name": "svc",
|
||||
"port": "5432",
|
||||
"data_dir": "/data/castle/svc",
|
||||
"tls_dir": "/data/castle/svc/tls",
|
||||
}
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
|
||||
cmd = _build_run_cmd("svc", run, {}, [], placeholders=ph)
|
||||
joined = " ".join(cmd)
|
||||
assert "DATA=/data/castle/svc/x" in joined # env expanded
|
||||
assert "/data/castle/svc/tls:/tls:ro" in joined # volume expanded
|
||||
assert "svc:5432" in cmd # arg expanded
|
||||
|
||||
|
||||
def test_container_double_dollar_escapes_placeholder() -> None:
|
||||
"""$${key} passes a literal ${key} through to the container's own shell/env
|
||||
instead of castle expanding it (docker-compose-style escape)."""
|
||||
run = RunContainer(
|
||||
launcher="container",
|
||||
image="img:latest",
|
||||
args=["sh", "-c", "exec myd --advertise $${name}:${port}"],
|
||||
)
|
||||
ph = {"name": "svc", "port": "5432"}
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
|
||||
cmd = _build_run_cmd("svc", run, {}, [], placeholders=ph)
|
||||
# castle expands ${port} but leaves $${name} as a literal ${name} for the shell
|
||||
assert "exec myd --advertise ${name}:5432" in cmd
|
||||
|
||||
|
||||
def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None:
|
||||
"""A compose service runs `docker compose -p <project> -f <abs-file> up`."""
|
||||
run = RunCompose(launcher="compose", file="docker-compose.yml")
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from castle_core.manifest import (
|
||||
Reach,
|
||||
BuildSpec,
|
||||
CaddyDeployment,
|
||||
ExposeSpec,
|
||||
@@ -110,14 +111,78 @@ class TestSystemdDeployment:
|
||||
)
|
||||
assert s.manage.systemd.enable is True
|
||||
|
||||
def test_public_requires_proxy(self) -> None:
|
||||
"""public without proxy is invalid (public needs an exposed process)."""
|
||||
with pytest.raises(ValueError, match="public requires proxy"):
|
||||
SystemdDeployment(
|
||||
id="bad",
|
||||
manager="systemd",
|
||||
run=RunPython(launcher="python", program="svc"),
|
||||
public=True,
|
||||
def test_reach_ladder_and_legacy_mapping(self) -> None:
|
||||
"""`reach` is canonical; legacy proxy/public map to it, and the derived
|
||||
proxy/public accessors reflect it (public implies internal)."""
|
||||
# An exposed reach needs an expose block (see test_reach_requires_expose),
|
||||
# so give the base one; reach off doesn't, tested separately below.
|
||||
base = dict(
|
||||
id="svc",
|
||||
manager="systemd",
|
||||
run=RunPython(launcher="python", program="svc"),
|
||||
expose={"http": {"internal": {"port": 9001}}},
|
||||
)
|
||||
# legacy input still parses
|
||||
s_proxy = SystemdDeployment.model_validate({**base, "proxy": True})
|
||||
assert s_proxy.reach == Reach.INTERNAL
|
||||
assert s_proxy.proxy is True and s_proxy.public is False
|
||||
s_pub = SystemdDeployment.model_validate({**base, "proxy": True, "public": True})
|
||||
assert s_pub.reach == Reach.PUBLIC
|
||||
assert s_pub.proxy is True and s_pub.public is True
|
||||
# legacy public alone now simply means public (which implies internal)
|
||||
assert SystemdDeployment.model_validate({**base, "public": True}).reach == Reach.PUBLIC
|
||||
# new canonical field (reach off needs no expose block)
|
||||
no_expose = dict(
|
||||
id="svc", manager="systemd", run=RunPython(launcher="python", program="svc")
|
||||
)
|
||||
assert SystemdDeployment(**no_expose, reach=Reach.OFF).reach == Reach.OFF
|
||||
assert SystemdDeployment(**base, reach=Reach.PUBLIC).public is True
|
||||
|
||||
def test_reach_requires_expose(self) -> None:
|
||||
"""An exposed reach with no expose block is rejected (it would otherwise
|
||||
silently no-op — no route, no subdomain, no tunnel). Replaces the old
|
||||
'public requires proxy' guard."""
|
||||
base = dict(manager="systemd", run=RunPython(launcher="python", program="svc"))
|
||||
for reach in ("internal", "public"):
|
||||
with pytest.raises(ValueError, match="requires an `expose` block"):
|
||||
SystemdDeployment.model_validate({**base, "reach": reach})
|
||||
# legacy public: true with no expose maps to reach public → same rejection
|
||||
with pytest.raises(ValueError, match="requires an `expose` block"):
|
||||
SystemdDeployment.model_validate({**base, "public": True})
|
||||
|
||||
def test_tcp_exposure_is_not_http_exposed(self) -> None:
|
||||
"""A raw-TCP service is reachable by name+port but never HTTP-routed."""
|
||||
base = dict(manager="systemd", run=RunCommand(launcher="command", argv=["pg"]))
|
||||
tcp = SystemdDeployment.model_validate(
|
||||
{**base, "reach": "internal", "expose": {"tcp": {"port": 5432}}}
|
||||
)
|
||||
assert tcp.tcp_port == 5432
|
||||
assert tcp.http_exposed is False # <-- no Caddy route
|
||||
http = SystemdDeployment.model_validate(
|
||||
{**base, "reach": "internal", "expose": {"http": {"internal": {"port": 9001}}}}
|
||||
)
|
||||
assert http.http_exposed is True and http.tcp_port is None
|
||||
|
||||
def test_expose_is_one_protocol(self) -> None:
|
||||
with pytest.raises(ValueError, match="http OR tcp"):
|
||||
SystemdDeployment.model_validate(
|
||||
{
|
||||
"manager": "systemd",
|
||||
"run": RunCommand(launcher="command", argv=["x"]),
|
||||
"expose": {"http": {"internal": {"port": 1}}, "tcp": {"port": 2}},
|
||||
}
|
||||
)
|
||||
|
||||
def test_public_tcp_guarded(self) -> None:
|
||||
"""reach: public on a raw-TCP service is rejected until step 5 lands."""
|
||||
with pytest.raises(ValueError, match="public for a raw-TCP"):
|
||||
SystemdDeployment.model_validate(
|
||||
{
|
||||
"manager": "systemd",
|
||||
"run": RunCommand(launcher="command", argv=["x"]),
|
||||
"reach": "public",
|
||||
"expose": {"tcp": {"port": 5432}},
|
||||
}
|
||||
)
|
||||
|
||||
def test_no_run_is_invalid(self) -> None:
|
||||
@@ -166,11 +231,12 @@ class TestModelSerialization:
|
||||
internal=HttpInternal(port=9001), health_path="/health"
|
||||
)
|
||||
),
|
||||
proxy=True,
|
||||
reach=Reach.INTERNAL,
|
||||
manage=ManageSpec(systemd=SystemdSpec()),
|
||||
)
|
||||
data = s.model_dump(exclude_none=True, exclude={"id"})
|
||||
assert data["manager"] == "systemd"
|
||||
assert data["run"]["launcher"] == "python"
|
||||
assert data["expose"]["http"]["internal"]["port"] == 9001
|
||||
assert data["proxy"] is True
|
||||
assert data["reach"] == "internal"
|
||||
assert "proxy" not in data # derived accessor, not serialized
|
||||
|
||||
112
core/tests/test_tls.py
Normal file
112
core/tests/test_tls.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Tests for castle-managed TLS material (core/src/castle_core/tls.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import castle_core.config as C
|
||||
import castle_core.tls as T
|
||||
from castle_core.manifest import SystemdDeployment
|
||||
|
||||
|
||||
def _write_wildcard(xdg: Path, domain: str, tag: str, acme_dir: str) -> None:
|
||||
d = xdg / "caddy" / "certificates" / acme_dir / f"wildcard_.{domain}"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / f"wildcard_.{domain}.crt").write_bytes(f"CERT-{tag}\n".encode())
|
||||
(d / f"wildcard_.{domain}.key").write_bytes(f"KEY-{tag}\n".encode())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tls_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Isolate Caddy's cert store (XDG_DATA_HOME, read live) and DATA_DIR (patched
|
||||
on the tls module) to a temp dir — no importlib.reload, so no global leak."""
|
||||
domain = "civil.payne.io"
|
||||
xdg = tmp_path / "xdg"
|
||||
_write_wildcard(xdg, domain, "PROD", "acme-v02.api.letsencrypt.org-directory")
|
||||
_write_wildcard(xdg, domain, "STAGING", "acme-staging-v02.api.letsencrypt.org-directory")
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(xdg))
|
||||
monkeypatch.setattr(T, "DATA_DIR", tmp_path / "data") # tls_dir_for reads this
|
||||
return T, C, domain
|
||||
|
||||
|
||||
def _cfg(C, domain, dep):
|
||||
return C.CastleConfig(
|
||||
root=None, gateway=C.GatewayConfig(port=9000, domain=domain), repo=None,
|
||||
programs={}, deployments={"postgres": dep},
|
||||
)
|
||||
|
||||
|
||||
def _pg(material: str):
|
||||
return SystemdDeployment.model_validate(
|
||||
{
|
||||
"manager": "systemd",
|
||||
"program": "postgres",
|
||||
"run": {"launcher": "container", "image": "postgres:17"},
|
||||
"reach": "internal",
|
||||
"expose": {"tcp": {"port": 5432, "tls": {"material": material}}},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_prefers_prod_over_staging(tls_env) -> None:
|
||||
T, _, domain = tls_env
|
||||
crt, _ = T.wildcard_cert(domain)
|
||||
assert crt.read_text().strip() == "CERT-PROD"
|
||||
|
||||
|
||||
def test_pair_material_and_idempotency(tls_env) -> None:
|
||||
T, C, domain = tls_env
|
||||
pg = _pg("pair")
|
||||
cfg = _cfg(C, domain, pg)
|
||||
assert T.materialize_tls(cfg, "postgres", pg) is True # first write
|
||||
assert T.materialize_tls(cfg, "postgres", pg) is False # idempotent
|
||||
td = T.tls_dir_for("postgres")
|
||||
assert sorted(p.name for p in td.iterdir()) == ["cert.pem", "chain.pem", "key.pem"]
|
||||
assert (td / "cert.pem").read_text().strip() == "CERT-PROD"
|
||||
assert oct((td / "key.pem").stat().st_mode)[-3:] == "600" # secret
|
||||
assert oct((td / "cert.pem").stat().st_mode)[-3:] == "644" # public
|
||||
|
||||
|
||||
def test_material_switch_cleans_stale(tls_env) -> None:
|
||||
T, C, domain = tls_env
|
||||
pair = _pg("pair")
|
||||
T.materialize_tls(_cfg(C, domain, pair), "postgres", pair)
|
||||
combined = _pg("combined")
|
||||
assert T.materialize_tls(_cfg(C, domain, combined), "postgres", combined) is True
|
||||
td = T.tls_dir_for("postgres")
|
||||
assert sorted(p.name for p in td.iterdir()) == ["chain.pem", "combined.pem"]
|
||||
assert (td / "combined.pem").read_text() == "KEY-PROD\nCERT-PROD\n" # key + cert
|
||||
assert oct((td / "combined.pem").stat().st_mode)[-3:] == "600"
|
||||
|
||||
|
||||
def test_pair_chain_is_issuer_not_leaf(tls_env, tmp_path) -> None:
|
||||
"""`chain.pem` (${tls_ca}) is the issuer chain — the intermediates only, leaf
|
||||
stripped — so it's a real CA bundle distinct from the leaf-bearing cert.pem
|
||||
(regression: they used to be byte-identical)."""
|
||||
T, C, domain = tls_env
|
||||
leaf = b"-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----\n"
|
||||
inter = b"-----BEGIN CERTIFICATE-----\nINTERMEDIATE\n-----END CERTIFICATE-----\n"
|
||||
crt_dir = (
|
||||
Path(tmp_path) / "xdg" / "caddy" / "certificates"
|
||||
/ "acme-v02.api.letsencrypt.org-directory" / f"wildcard_.{domain}"
|
||||
)
|
||||
(crt_dir / f"wildcard_.{domain}.crt").write_bytes(leaf + inter)
|
||||
pg = _pg("pair")
|
||||
assert T.materialize_tls(_cfg(C, domain, pg), "postgres", pg) is True
|
||||
td = T.tls_dir_for("postgres")
|
||||
assert (td / "cert.pem").read_bytes() == leaf + inter # server presents leaf+chain
|
||||
assert (td / "chain.pem").read_bytes() == inter # CA bundle = intermediates
|
||||
assert (td / "cert.pem").read_bytes() != (td / "chain.pem").read_bytes()
|
||||
|
||||
|
||||
def test_material_off_is_noop(tls_env) -> None:
|
||||
T, C, domain = tls_env
|
||||
off = SystemdDeployment.model_validate(
|
||||
{"manager": "systemd", "program": "postgres",
|
||||
"run": {"launcher": "container", "image": "postgres:17"},
|
||||
"reach": "internal", "expose": {"tcp": {"port": 5432}}}
|
||||
)
|
||||
assert T.materialize_tls(_cfg(C, domain, off), "postgres", off) is False
|
||||
assert not T.tls_dir_for("postgres").exists()
|
||||
343
docs/tcp-exposure.md
Normal file
343
docs/tcp-exposure.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# TCP exposure & castle-managed TLS material
|
||||
|
||||
> Status: **design / not yet implemented.** This specs the `reach` exposure
|
||||
> ladder, raw-TCP service exposure (postgres, redis, …), and how castle
|
||||
> materializes its ACME wildcard cert onto a service so a raw port presents a
|
||||
> *trusted* cert — generically, with **no protocol knowledge in castle's code**.
|
||||
|
||||
Read `docs/dns-and-tls.md` first — this builds on the acme wildcard model.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why
|
||||
|
||||
The gateway (Caddy) is HTTP-only: it multiplexes subdomains onto `:443` by TLS
|
||||
SNI. Raw-TCP services (postgres/5432, redis/6379, …) can't ride that — the
|
||||
protocols either send a cleartext preamble before any ClientHello (postgres,
|
||||
mysql) so there's no SNI to route on, or they simply aren't HTTP. But they don't
|
||||
*need* the gateway: the wildcard DNS record already points **every** subdomain at
|
||||
the node, and each service already has its own port. So "expose a TCP service
|
||||
internally" reduces to two things castle can do without a proxy:
|
||||
|
||||
1. **bind the port** on the LAN, and
|
||||
2. **put a trusted cert on the service** — the one wildcard cert we already own,
|
||||
which is valid for `<name>.<domain>` (wildcards match).
|
||||
|
||||
The only per-service variation is *what file format the cert takes* and *how the
|
||||
service is told to re-read it*. Both are pushed into the deployment yaml. Castle
|
||||
stays a cert-mover and a signal-sender.
|
||||
|
||||
Public raw-TCP is a separate, Cloudflare-edge concern — see §6.
|
||||
|
||||
---
|
||||
|
||||
## 2. The `reach` ladder (replaces `proxy` / `public`)
|
||||
|
||||
Exposure becomes one protocol-agnostic enum on the deployment:
|
||||
|
||||
| `reach` | meaning |
|
||||
|---------|---------|
|
||||
| `off` *(default)* | reachable only at its own `host:port` (no gateway route, no DNS intent) |
|
||||
| `internal` | reachable at `<name>.<domain>` — HTTP via the gateway, or TCP via bind + wildcard DNS |
|
||||
| `public` | *also* projected to the internet (HTTP via tunnel origin; TCP via `cloudflared access tcp`) |
|
||||
|
||||
`reach` is orthogonal to **protocol**, which is described by `expose.http` /
|
||||
`expose.tcp` (ports live there). One field says *how far it reaches*; the other
|
||||
says *what it speaks*.
|
||||
|
||||
### Legacy mapping (back-compat normalizer)
|
||||
|
||||
`castle` accepts the old fields and normalizes them so existing yamls keep
|
||||
working; `castle apply` can rewrite them on next write:
|
||||
|
||||
| old | new |
|
||||
|-----|-----|
|
||||
| *(neither)* | `reach: off` |
|
||||
| `proxy: true` | `reach: internal` |
|
||||
| `proxy: true` + `public: true` | `reach: public` |
|
||||
| `public: true` without `proxy` | *(already invalid — unchanged)* |
|
||||
|
||||
---
|
||||
|
||||
## 3. Manifest models (`core/src/castle_core/manifest.py`)
|
||||
|
||||
```python
|
||||
class Reach(str, Enum):
|
||||
OFF = "off"
|
||||
INTERNAL = "internal"
|
||||
PUBLIC = "public"
|
||||
|
||||
|
||||
class TlsMaterial(str, Enum):
|
||||
OFF = "off" # service does its own TLS (or none)
|
||||
PAIR = "pair" # cert.pem + key.pem (postgres, redis, most daemons)
|
||||
COMBINED = "combined" # one file: key+cert concatenated (mongodb, haproxy)
|
||||
|
||||
|
||||
class TlsSpec(BaseModel):
|
||||
material: TlsMaterial = TlsMaterial.OFF
|
||||
# Optional zero-downtime reload argv run after re-materialize on renewal.
|
||||
# Default: castle restarts the deployment (fine for a ~60-day cadence).
|
||||
reload: list[str] | None = None
|
||||
|
||||
|
||||
class TcpExposeSpec(BaseModel):
|
||||
port: int = Field(ge=1, le=65535)
|
||||
tls: TlsSpec | None = None # step 3; absent = service does its own TLS
|
||||
# No bind-host field: publishing the port on the LAN is the deployment's own
|
||||
# job (a container's run.ports, or a native service binding 0.0.0.0). castle
|
||||
# doesn't rebind it, so a `host:` here would be an ignored (misleading) field.
|
||||
|
||||
|
||||
class ExposeSpec(BaseModel):
|
||||
http: HttpExposeSpec | None = None
|
||||
tcp: TcpExposeSpec | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _one_protocol(self) -> "ExposeSpec":
|
||||
if self.http and self.tcp:
|
||||
raise ValueError("a deployment exposes http OR tcp, not both")
|
||||
return self
|
||||
```
|
||||
|
||||
On `SystemdDeployment`, **replace** `proxy: bool` / `public: bool` with:
|
||||
|
||||
```python
|
||||
reach: Reach = Reach.OFF
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate(self) -> "SystemdDeployment":
|
||||
if self.reach == Reach.PUBLIC and not (self.expose and (self.expose.http or self.expose.tcp)):
|
||||
raise ValueError("reach: public requires an exposed http or tcp port")
|
||||
tls = self.expose and self.expose.tcp and self.expose.tcp.tls
|
||||
if tls and tls.material != TlsMaterial.OFF and self.reach == Reach.OFF:
|
||||
raise ValueError("tls.material needs reach: internal|public")
|
||||
return self
|
||||
```
|
||||
|
||||
`CaddyDeployment` (static) also gains `reach: Reach = Reach.INTERNAL`
|
||||
(constrained to `internal|public` — a static site is inherently served), and its
|
||||
old `public: bool` normalizes to `reach`.
|
||||
|
||||
> **acme prerequisite:** `tls.material != off` only makes sense when
|
||||
> `gateway.tls == acme` with a domain — otherwise there's no wildcard cert to
|
||||
> copy. `castle apply` / `castle doctor` warn if `material` is set without acme.
|
||||
|
||||
---
|
||||
|
||||
## 4. Placeholders (`deploy.py` `_env_context`)
|
||||
|
||||
When a deployment declares `expose.tcp.tls.material != off`, castle adds these to
|
||||
the placeholder context (alongside the existing `${port}`/`${data_dir}`/… set),
|
||||
pointing at the materialized copies under `‹data_dir›/tls/`:
|
||||
|
||||
| placeholder | expands to (host path) |
|
||||
|-------------|------------------------|
|
||||
| `${tls_dir}` | `‹DATA_DIR›/‹config_key›/tls` — mount this into a container |
|
||||
| `${tls_cert}` | `${tls_dir}/cert.pem` (leaf + chain) |
|
||||
| `${tls_key}` | `${tls_dir}/key.pem` |
|
||||
| `${tls_pem}` | `${tls_dir}/combined.pem` (`material: combined`) |
|
||||
| `${tls_ca}` | `${tls_dir}/chain.pem` (issuer chain) |
|
||||
|
||||
They resolve through the one shared `${...}` resolver (`resolve_placeholders`,
|
||||
which `resolve_env_split` also uses) — for a container these `${key}` refs are
|
||||
expanded in `run.env`, `run.volumes`, `run.args`, `run.command`, `run.workdir`,
|
||||
`run.user`, and `run.tmpfs` alike. To pass a **literal** `${key}` through to the
|
||||
container's own shell/env (rather than have castle expand it), write `$${key}`
|
||||
(docker-compose-style `$$` escape).
|
||||
|
||||
**Native service** (python/command launcher) references the host paths directly:
|
||||
|
||||
```yaml
|
||||
reach: internal
|
||||
expose:
|
||||
tcp: { port: 6379, tls: { material: pair } }
|
||||
defaults:
|
||||
env:
|
||||
REDIS_TLS_CERT: ${tls_cert}
|
||||
REDIS_TLS_KEY: ${tls_key}
|
||||
```
|
||||
|
||||
**Container** mounts `${tls_dir}` and uses in-container paths (the host→container
|
||||
path differs, so the author maps it — consistent with our "image specifics live
|
||||
in the deployment" rule):
|
||||
|
||||
```yaml
|
||||
reach: internal
|
||||
expose:
|
||||
tcp: { port: 5432, tls: { material: pair } }
|
||||
run:
|
||||
launcher: container
|
||||
user: ${uid}:${gid} # default for castle containers — see below
|
||||
image: postgres:17
|
||||
ports: { '5432': 5432 }
|
||||
tmpfs: [/var/run/postgresql] # image runtime dir, needs to be writable by our uid
|
||||
volumes:
|
||||
- /data/castle/postgres/data:/var/lib/postgresql/data
|
||||
- ${tls_dir}:/tls:ro
|
||||
args: ['-c','ssl=on','-c','ssl_cert_file=/tls/cert.pem','-c','ssl_key_file=/tls/key.pem']
|
||||
```
|
||||
|
||||
### Container key ownership — dissolved by uid uniformity (verified)
|
||||
|
||||
The naive problem: postgres refuses a key that isn't `0600` **and owned by the
|
||||
process uid** (999 in the stock image), while castle writes files as the host
|
||||
user (1000), and bind mounts preserve host uid — so 999 can't read them, and
|
||||
chowning across uids needs privilege. Rather than patch that with a privileged
|
||||
chown, castle **runs containers as the invoking user** (`--user ${uid}:${gid}`,
|
||||
the default on `RunContainer`, overridable per deployment). Then the process, its
|
||||
data dir, its secrets *and* its certs are all one uid — nothing to chown, for any
|
||||
service. Verified end-to-end against `postgres:17`:
|
||||
|
||||
```
|
||||
docker run --user 1000:1000 --tmpfs /var/run/postgresql \
|
||||
-v …/key.pem:/tls/key.pem:ro (0600, owned 1000) … postgres:17 -c ssl=on …
|
||||
→ pg_stat_ssl: t | TLSv1.3 # SSL on, real TLS 1.3, key read fine, zero privilege
|
||||
```
|
||||
|
||||
The only image-specific detail is a writable runtime dir (`--tmpfs
|
||||
/var/run/postgresql`) — declared in the deployment yaml, not castle. `RunContainer`
|
||||
gains a `user: str = "${uid}:${gid}"` field and a `tmpfs: list[str]`; castle stays
|
||||
privilege-free. (One-time migration cost: an existing data dir owned by 999 gets
|
||||
chowned to the runtime uid once when a service adopts this — not per-renewal.)
|
||||
|
||||
---
|
||||
|
||||
## 5. Cert materialization + the `cert_obtained` hook
|
||||
|
||||
### The materializer (`castle_core`, protocol-agnostic)
|
||||
|
||||
`materialize_tls(config, name, dep) -> bool` — for one deployment with
|
||||
`tls.material != off`:
|
||||
|
||||
1. Locate the source wildcard in Caddy's store by glob (prefer prod over staging):
|
||||
`~/.local/share/caddy/certificates/acme-v02*-directory/wildcard_.‹domain›/wildcard_.‹domain›.{crt,key}`
|
||||
(falls back to `acme-staging-v02*` when `CASTLE_ACME_STAGING=1`).
|
||||
2. Compare a hash of the source to the materialized copy; **return False if
|
||||
unchanged** (idempotent — safe to call every apply and every renewal).
|
||||
3. Write `‹data_dir›/tls/` in the requested format:
|
||||
- `pair` → `cert.pem` (the `.crt`, already leaf+chain), `key.pem`
|
||||
- `combined` → `combined.pem` = `key.pem` + `cert.pem` concatenated
|
||||
- `chain.pem` (for `${tls_ca}`) is written for either format — the **issuer
|
||||
chain** (intermediates only, leaf stripped), a real CA bundle distinct from
|
||||
the leaf-bearing `cert.pem`/`combined.pem`.
|
||||
- files `0600` (key) / `0644` (cert); dir `0700`.
|
||||
4. Apply `tls.owner` (via the privileged chown step, §4) when set.
|
||||
5. Return True (changed).
|
||||
|
||||
`reconcile_tls(config)` — walk all deployments; `materialize_tls` each; for those
|
||||
that changed, run `tls.reload` argv **or** `castle restart ‹name›`. Idempotent;
|
||||
prints a summary.
|
||||
|
||||
### Wiring
|
||||
|
||||
- **`castle apply`** calls `materialize_tls` for each affected deployment before
|
||||
(re)starting it — so first deploy has certs in place.
|
||||
- **Event-driven (the hook you wanted) — VERIFIED.** Build Caddy with
|
||||
`github.com/mholt/caddy-events-exec` (add the `--with` to `install.sh`'s xcaddy
|
||||
build, alongside `caddy-dns/cloudflare`) and add to the Caddyfile global options:
|
||||
```
|
||||
{
|
||||
email …
|
||||
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||
events {
|
||||
on cert_obtained exec castle tls reconcile
|
||||
}
|
||||
}
|
||||
```
|
||||
Caddy fires `cert_obtained` on every issuance **and renewal** (renewal reuses
|
||||
the same event with `renewal: true`); the handler runs `castle tls reconcile`,
|
||||
which re-copies the rotated wildcard and reloads consumers. Immediate, no polling.
|
||||
> **Proven end-to-end** (2026-07-03): built into our Caddy 2.11.4, an
|
||||
> `on cert_obtained exec` handler fired on internal-CA issuance with event data
|
||||
> (`issuer=local`, `identifier`, `certificate_path`) correctly passed. Since the
|
||||
> emit is issuer-agnostic (CertMagic `config.go`), acme renewal fires it identically.
|
||||
> Two caveats from the test: (a) the module is **experimental** — pin the xcaddy
|
||||
> build and re-verify on Caddy upgrades; (b) `exec` runs the command in the
|
||||
> **background and swallows its exit code** — so `castle tls reconcile` must be
|
||||
> idempotent and log its *own* outcome (don't rely on Caddy surfacing failures).
|
||||
- **Safety net:** a nightly `castle-tls-reconcile` job (`manager: systemd` +
|
||||
`schedule`) also runs `castle tls reconcile` — catches a missed event, a Caddy
|
||||
restart, or a manual cert swap. Same idempotent call, so belt-and-suspenders is
|
||||
free.
|
||||
|
||||
New CLI: `castle tls reconcile [--plan]` (thin wrapper over `reconcile_tls`), and
|
||||
`castle tls status` to show, per deployment, source vs materialized cert fingerprint
|
||||
+ expiry.
|
||||
|
||||
---
|
||||
|
||||
## 6. `reach: public` for TCP (later, separable)
|
||||
|
||||
Public raw-TCP can't reach an unmodified client below Cloudflare Enterprise
|
||||
(Spectrum arbitrary-TCP is Enterprise-gated). The free, minimal path rides the
|
||||
**existing** tunnel:
|
||||
|
||||
- `tunnel.py` emits an extra ingress entry per public TCP deployment:
|
||||
`{ hostname: ‹name›.‹public_domain›, service: tcp://localhost:‹port› }`.
|
||||
- castle ensures a self-hosted **Access** app + policy over that hostname.
|
||||
- `castle` prints the client connect line:
|
||||
`cloudflared access tcp --hostname ‹name›.‹public_domain› --url localhost:‹local›`.
|
||||
|
||||
The client runs `cloudflared access tcp` (or WARP private-network) and points its
|
||||
tool (`psql`/`redis-cli`) at `localhost`. This is the correct posture for a DB —
|
||||
authenticated tunnel, never an open port. Build this only when wanted.
|
||||
|
||||
---
|
||||
|
||||
## 7. Generality check
|
||||
|
||||
Same castle code, different yaml — the postgres-ness is two env/arg mappings + a
|
||||
format choice, nothing more:
|
||||
|
||||
| service | `material` | consumes | reload |
|
||||
|---------|-----------|----------|--------|
|
||||
| postgres | `pair` | `ssl_cert_file` / `ssl_key_file` args | SIGHUP / restart |
|
||||
| redis | `pair` | `--tls-cert-file/--tls-key-file/--tls-ca-cert-file` | restart |
|
||||
| mongodb | `combined` | `net.tls.certificateKeyFile: /tls/combined.pem` | restart |
|
||||
| arbitrary | `pair` | its own `TLS_CERT`/`TLS_KEY` env | restart |
|
||||
|
||||
`pair` vs `combined` is the entire protocol-variation surface.
|
||||
|
||||
---
|
||||
|
||||
## 8. Build order
|
||||
|
||||
1. ~~**`reach` enum + normalizer** for HTTP (replace `proxy`/`public`); migrate
|
||||
existing yamls.~~ **DONE (2026-07-03).** Canonical `reach` on
|
||||
Systemd/CaddyDeployment; `_reach_from_legacy` before-validator accepts legacy
|
||||
input; `proxy`/`public` are derived read-only accessors. 13 yamls migrated
|
||||
(`apply --plan` = already converged); app toggles + `deploy_create` write
|
||||
`reach`; all suites green.
|
||||
2. ~~**`expose.tcp` + bind** (internal TCP, `material: off`): `postgres.civil…:5432`
|
||||
works, service does its own TLS.~~ **DONE (2026-07-03).** `TcpExposeSpec` +
|
||||
`ExposeSpec` one-protocol validator; `http_exposed`/`tcp_port` predicates so a
|
||||
TCP `reach: internal` yields **no** Caddy route (correctness); registry carries
|
||||
`tcp_port`; `castle service info` shows the `<name>.<domain>:<port>` endpoint;
|
||||
public-TCP guarded (step 5). Applied to `postgres` — verified: `psql -h
|
||||
postgres.civil.payne.io` connects by name, postgres absent from HTTP routes,
|
||||
`apply --plan` still converged.
|
||||
3. **TLS material + placeholders + `materialize_tls`** — **CODE DONE (2026-07-03).**
|
||||
`TlsSpec`/`TlsMaterial`; `${tls_dir|cert|key|pem|ca}` + `${uid}/${gid}`
|
||||
placeholders; `RunContainer.user`/`tmpfs` (uid-uniformity, no chown);
|
||||
`core/tls.py` (`wildcard_cert`/`materialize_tls`/`materialize_all`); wired into
|
||||
`castle apply`; unit-tested in isolation (pair/combined/idempotency/stale-clean).
|
||||
4. **`cert_obtained` hook + `castle tls`** — **CODE DONE (2026-07-03).** Generator
|
||||
emits the `events { on cert_obtained exec castle tls reconcile }` block, **gated
|
||||
on `CASTLE_CADDY_CERT_HOOK=1`** so the current (no-plugin) gateway is unaffected;
|
||||
`castle tls reconcile|status` CLI; `reconcile_tls` idempotent + self-logging.
|
||||
*(Nightly safety-net job: TODO.)*
|
||||
→ **LIVE ROLLOUT DONE (2026-07-03).** (a) `/usr/local/bin/caddy` rebuilt with
|
||||
cloudflare-dns + `caddy-events-exec` (old binary → `caddy.bak-pre-events`;
|
||||
`install.sh` bakes both plugins). (b) Gate is a durable **`gateway.cert_hook`**
|
||||
config flag (not an env var) → NodeConfig → generator; set true, `apply`'d; the
|
||||
running gateway's admin API confirms the `cert_obtained → castle tls reconcile`
|
||||
subscription is live. (c) postgres enabled: `user: ${uid}:${gid}` + `tmpfs` +
|
||||
`tls.material: pair`; data dir chowned 999→1000 once (cold backup at
|
||||
`/data/castle/postgres-data-backup-pre-tls.tar.gz`); WAL-recovered clean.
|
||||
**Verified:** `psql -h postgres.civil.payne.io sslmode=verify-full sslrootcert=system`
|
||||
→ "verify-full OK" against the trusted LE wildcard; `castle` + `litellm` DBs intact.
|
||||
Also landed: `${uid}/${gid}` in the placeholder context + placeholder expansion
|
||||
in container run fields (`volumes`/`args`/`user`/`tmpfs`) so `${tls_dir}` mounts work.
|
||||
5. **`reach: public` for TCP** (tunnel `tcp://` + Access + connect line) — when
|
||||
wanted.
|
||||
10
install.sh
10
install.sh
@@ -202,15 +202,21 @@ ensure_caddy_dns_plugin() {
|
||||
|| log_fail "xcaddy install failed"
|
||||
fi
|
||||
|
||||
log_info "Building Caddy $CADDY_DNS_VERSION with $module (~1 min)..."
|
||||
# events-exec: lets the gateway run `castle tls reconcile` on cert issuance/
|
||||
# renewal (the cert_obtained hook), so certs materialized onto raw-TCP services
|
||||
# refresh automatically. See docs/tcp-exposure.md §5.
|
||||
local events_module="github.com/mholt/caddy-events-exec"
|
||||
log_info "Building Caddy $CADDY_DNS_VERSION with $module + events-exec (~1 min)..."
|
||||
local tmp; tmp="$(mktemp -d)"
|
||||
( cd "$tmp" && "$gobin/xcaddy" build "$CADDY_DNS_VERSION" --with "$module" ) \
|
||||
( cd "$tmp" && "$gobin/xcaddy" build "$CADDY_DNS_VERSION" --with "$module" --with "$events_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"
|
||||
/usr/local/bin/caddy list-modules 2>/dev/null | grep -q "events.handlers.exec" \
|
||||
|| log_fail "built caddy is missing events.handlers.exec"
|
||||
log_info "Built /usr/local/bin/caddy — run 'castle apply' to use it."
|
||||
log_ok
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user