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 run = obj(m.run)
|
||||||
const internal = obj(obj(obj(m.expose).http).internal)
|
const internal = obj(obj(obj(m.expose).http).internal)
|
||||||
const httpExpose = obj(obj(m.expose).http)
|
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 [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = 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 [port, setPort] = useState(internal.port != null ? String(internal.port) : "")
|
||||||
const [health, setHealth] = useState((httpExpose.health_path as string) ?? "")
|
const [health, setHealth] = useState((httpExpose.health_path as string) ?? "")
|
||||||
// Exposed at <service-name>.<gateway.domain> when proxy is true.
|
// How far the service reaches: off | internal | public. Falls back to the
|
||||||
const [expose, setExpose] = useState(m.proxy === true)
|
// 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>)
|
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
|
||||||
|
|
||||||
@@ -74,6 +85,9 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
|||||||
// Rebuild the run block for the chosen launcher, preserving other fields.
|
// Rebuild the run block for the chosen launcher, preserving other fields.
|
||||||
config.run = applyLauncher(obj(config.run), launcher, runProgram)
|
config.run = applyLauncher(obj(config.run), launcher, runProgram)
|
||||||
|
|
||||||
|
// 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) {
|
if (port) {
|
||||||
config.expose = {
|
config.expose = {
|
||||||
http: {
|
http: {
|
||||||
@@ -84,9 +98,11 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
|||||||
} else {
|
} else {
|
||||||
delete config.expose
|
delete config.expose
|
||||||
}
|
}
|
||||||
|
// reach needs a port to route through the gateway; without one it's off.
|
||||||
if (expose) config.proxy = true
|
config.reach = port ? reach : "off"
|
||||||
else delete config.proxy
|
}
|
||||||
|
delete config.proxy
|
||||||
|
delete config.public
|
||||||
|
|
||||||
const env = merged()
|
const env = merged()
|
||||||
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
|
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
|
||||||
@@ -127,6 +143,31 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
{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
|
<TextField
|
||||||
label="Port"
|
label="Port"
|
||||||
value={port}
|
value={port}
|
||||||
@@ -146,20 +187,33 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
|||||||
hint="HTTP path castle polls to report up/down."
|
hint="HTTP path castle polls to report up/down."
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Expose"
|
label="Reach"
|
||||||
hint="Route this service through the gateway at <service-name>.<gateway.domain>. Unchecked: reachable only at its own host:port."
|
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."
|
||||||
>
|
>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<select
|
||||||
type="checkbox"
|
value={reach}
|
||||||
checked={expose}
|
onChange={(e) => setReach(e.target.value)}
|
||||||
onChange={(e) => setExpose(e.target.checked)}
|
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"
|
||||||
<span className="font-mono text-[var(--muted)]">
|
>
|
||||||
{expose ? `${service.id}.<gateway.domain>` : "off (host:port only)"}
|
<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>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{envEditor}
|
{envEditor}
|
||||||
<FormFooter
|
<FormFooter
|
||||||
saving={saving}
|
saving={saving}
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
|||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const [description, setDescription] = useState((m.description as string) ?? "")
|
const [description, setDescription] = useState((m.description as string) ?? "")
|
||||||
const [root, setRoot] = useState((m.root as string) ?? "dist")
|
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(
|
const { element: envEditor, merged } = useEnvSecrets(
|
||||||
obj(obj(m.defaults).env) as Record<string, string>,
|
obj(obj(m.defaults).env) as Record<string, string>,
|
||||||
)
|
)
|
||||||
@@ -32,8 +35,8 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
|||||||
delete config.id
|
delete config.id
|
||||||
config.description = description || undefined
|
config.description = description || undefined
|
||||||
config.root = root || "dist"
|
config.root = root || "dist"
|
||||||
if (isPublic) config.public = true
|
config.reach = isPublic ? "public" : "internal"
|
||||||
else delete config.public
|
delete config.public
|
||||||
const env = merged()
|
const env = merged()
|
||||||
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
|
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
|
||||||
else if (config.defaults) delete (config.defaults as Obj).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)."
|
hint="The built directory the gateway serves, relative to the program source (e.g. dist, public)."
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Public"
|
label="Reach"
|
||||||
hint="Also expose this site to the public internet via the Cloudflare tunnel."
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<select
|
||||||
type="checkbox"
|
value={isPublic ? "public" : "internal"}
|
||||||
checked={isPublic}
|
onChange={(e) => setIsPublic(e.target.value === "public")}
|
||||||
onChange={(e) => setIsPublic(e.target.checked)}
|
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||||
/>
|
>
|
||||||
<span className="font-mono text-[var(--muted)]">
|
<option value="internal">internal</option>
|
||||||
{isPublic ? "public (via tunnel)" : "internal only"}
|
<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>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
{envEditor}
|
{envEditor}
|
||||||
<FormFooter
|
<FormFooter
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ def _summary_from_service(
|
|||||||
if svc.expose and svc.expose.http:
|
if svc.expose and svc.expose.http:
|
||||||
port = svc.expose.http.internal.port
|
port = svc.expose.http.internal.port
|
||||||
health_path = svc.expose.http.health_path
|
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)
|
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
|
port = svc.expose.http.internal.port
|
||||||
health_path = svc.expose.http.health_path
|
health_path = svc.expose.http.health_path
|
||||||
# Exposed at <name>.<domain> when the proxy checkbox is on.
|
# 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)
|
managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable)
|
||||||
systemd_info = _make_systemd_info(name) if managed else None
|
systemd_info = _make_systemd_info(name) if managed else None
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from castle_cli.manifest import (
|
|||||||
HttpExposeSpec,
|
HttpExposeSpec,
|
||||||
HttpInternal,
|
HttpInternal,
|
||||||
ManageSpec,
|
ManageSpec,
|
||||||
|
Reach,
|
||||||
RunCommand,
|
RunCommand,
|
||||||
RunPython,
|
RunPython,
|
||||||
SystemdDeployment,
|
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)
|
run = _run_spec(args.launcher, args.run or args.program or name, name)
|
||||||
|
|
||||||
expose = None
|
expose = None
|
||||||
proxy = False
|
reach = Reach.OFF
|
||||||
if args.port is not None:
|
if args.port is not None:
|
||||||
expose = ExposeSpec(
|
expose = ExposeSpec(
|
||||||
http=HttpExposeSpec(
|
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).
|
# 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(
|
config.deployments[name] = SystemdDeployment(
|
||||||
id=name,
|
id=name,
|
||||||
@@ -76,7 +77,7 @@ def run_service_create(args: argparse.Namespace) -> int:
|
|||||||
description=args.description or None,
|
description=args.description or None,
|
||||||
run=run,
|
run=run,
|
||||||
expose=expose,
|
expose=expose,
|
||||||
proxy=proxy,
|
reach=reach,
|
||||||
manage=ManageSpec(systemd=SystemdSpec()),
|
manage=ManageSpec(systemd=SystemdSpec()),
|
||||||
defaults=_defaults(args.env),
|
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})")
|
print(f" runs: {args.launcher} ({args.run or args.program or name})")
|
||||||
if expose:
|
if expose:
|
||||||
print(f" port: {args.port}")
|
print(f" port: {args.port}")
|
||||||
if proxy:
|
if reach != Reach.OFF:
|
||||||
print(f" subdomain: {name}.<gateway.domain>")
|
print(f" subdomain: {name}.<gateway.domain>")
|
||||||
print(f"\nNext: castle apply {name}")
|
print(f"\nNext: castle apply {name}")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -121,8 +121,12 @@ def run_info(args: argparse.Namespace) -> int:
|
|||||||
print(f" {BOLD}port{RESET}: {http.internal.port}")
|
print(f" {BOLD}port{RESET}: {http.internal.port}")
|
||||||
if http.health_path:
|
if http.health_path:
|
||||||
print(f" {BOLD}health{RESET}: {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>")
|
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:
|
if service.manage and service.manage.systemd:
|
||||||
sd = service.manage.systemd
|
sd = service.manage.systemd
|
||||||
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}")
|
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 = gw.add_subparsers(dest="gateway_command")
|
||||||
gw_sub.add_parser("status", help="Show gateway status + routes (the default)")
|
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
|
# Convergence — the one lifecycle verb. Renders units/Caddyfile/tunnel, then
|
||||||
# reconciles the runtime to match config (activate/restart/deactivate).
|
# reconciles the runtime to match config (activate/restart/deactivate).
|
||||||
p = subparsers.add_parser(
|
p = subparsers.add_parser(
|
||||||
@@ -305,6 +315,10 @@ def main() -> int:
|
|||||||
from castle_cli.commands.gateway import run_gateway
|
from castle_cli.commands.gateway import run_gateway
|
||||||
|
|
||||||
return run_gateway(args)
|
return run_gateway(args)
|
||||||
|
if cmd == "tls":
|
||||||
|
from castle_cli.commands.tls import run_tls
|
||||||
|
|
||||||
|
return run_tls(args)
|
||||||
if cmd == "apply":
|
if cmd == "apply":
|
||||||
from castle_cli.commands.apply import run_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,
|
ManageSpec,
|
||||||
PathDeployment,
|
PathDeployment,
|
||||||
ProgramSpec,
|
ProgramSpec,
|
||||||
|
Reach,
|
||||||
ReadinessHttpGet,
|
ReadinessHttpGet,
|
||||||
RemoteDeployment,
|
RemoteDeployment,
|
||||||
RestartPolicy,
|
RestartPolicy,
|
||||||
|
|||||||
@@ -128,6 +128,12 @@ class GatewayConfig:
|
|||||||
# names never appear in public DNS. `tunnel_id` is the cloudflared tunnel UUID.
|
# names never appear in public DNS. `tunnel_id` is the cloudflared tunnel UUID.
|
||||||
public_domain: str | None = None
|
public_domain: str | None = None
|
||||||
tunnel_id: 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
|
@dataclass
|
||||||
@@ -228,6 +234,30 @@ def resolve_env_split(
|
|||||||
return plain, secret
|
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(
|
def resolve_env_vars(
|
||||||
env: dict[str, str], context: dict[str, str] | None = None
|
env: dict[str, str], context: dict[str, str] | None = None
|
||||||
) -> dict[str, str]:
|
) -> 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"),
|
acme_dns_provider=gateway_data.get("acme_dns_provider", "cloudflare"),
|
||||||
public_domain=gateway_data.get("public_domain"),
|
public_domain=gateway_data.get("public_domain"),
|
||||||
tunnel_id=gateway_data.get("tunnel_id"),
|
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
|
# repo: field points to the git repo for repo-relative sources
|
||||||
@@ -410,7 +441,6 @@ _STRUCTURAL_KEYS = {
|
|||||||
"manage",
|
"manage",
|
||||||
"systemd",
|
"systemd",
|
||||||
"expose",
|
"expose",
|
||||||
"proxy",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -494,12 +524,17 @@ def save_config(config: CastleConfig) -> None:
|
|||||||
if config.gateway.acme_email:
|
if config.gateway.acme_email:
|
||||||
gateway_data["acme_email"] = config.gateway.acme_email
|
gateway_data["acme_email"] = config.gateway.acme_email
|
||||||
# Only persist the provider when non-default, to keep castle.yaml minimal.
|
# 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
|
gateway_data["acme_dns_provider"] = config.gateway.acme_dns_provider
|
||||||
if config.gateway.public_domain:
|
if config.gateway.public_domain:
|
||||||
gateway_data["public_domain"] = config.gateway.public_domain
|
gateway_data["public_domain"] = config.gateway.public_domain
|
||||||
if config.gateway.tunnel_id:
|
if config.gateway.tunnel_id:
|
||||||
gateway_data["tunnel_id"] = 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}
|
data: dict = {"gateway": gateway_data}
|
||||||
if config.repo:
|
if config.repo:
|
||||||
data["repo"] = str(config.repo)
|
data["repo"] = str(config.repo)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from castle_core.config import (
|
|||||||
ensure_dirs,
|
ensure_dirs,
|
||||||
load_config,
|
load_config,
|
||||||
resolve_env_split,
|
resolve_env_split,
|
||||||
|
resolve_placeholders,
|
||||||
)
|
)
|
||||||
from castle_core.generators.caddyfile import (
|
from castle_core.generators.caddyfile import (
|
||||||
_DNS_TOKEN_ENV,
|
_DNS_TOKEN_ENV,
|
||||||
@@ -47,6 +48,7 @@ from castle_core.manifest import (
|
|||||||
DeploymentSpec,
|
DeploymentSpec,
|
||||||
PathDeployment,
|
PathDeployment,
|
||||||
RemoteDeployment,
|
RemoteDeployment,
|
||||||
|
TlsMaterial,
|
||||||
kind_for,
|
kind_for,
|
||||||
)
|
)
|
||||||
from castle_core.registry import (
|
from castle_core.registry import (
|
||||||
@@ -184,6 +186,7 @@ def _node_config(config: CastleConfig) -> NodeConfig:
|
|||||||
acme_dns_provider=config.gateway.acme_dns_provider,
|
acme_dns_provider=config.gateway.acme_dns_provider,
|
||||||
public_domain=config.gateway.public_domain,
|
public_domain=config.gateway.public_domain,
|
||||||
tunnel_id=config.gateway.tunnel_id,
|
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.messages = list(deploy_result.messages)
|
||||||
result.registry = deploy_result.registry
|
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:
|
for name in names:
|
||||||
after_unit = _unit_bytes(name, is_job[name])
|
after_unit = _unit_bytes(name, is_job[name])
|
||||||
action = _classify(name, after_unit)
|
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:
|
def _reload_gateway(messages: list[str]) -> None:
|
||||||
"""Reload Caddy if the gateway is running, so new routes take effect."""
|
"""Reload Caddy if the gateway is running, so new routes take effect."""
|
||||||
gw_unit = unit_name(_GATEWAY_NAME)
|
gw_unit = unit_name(_GATEWAY_NAME)
|
||||||
|
# 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(
|
active = subprocess.run(
|
||||||
["systemctl", "--user", "is-active", gw_unit],
|
["systemctl", "--user", "is-active", gw_unit],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -459,7 +497,12 @@ def _env_context(
|
|||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Placeholder values for defaults.env: ${name}/${data_dir}/${port}/${public_url}/
|
"""Placeholder values for defaults.env: ${name}/${data_dir}/${port}/${public_url}/
|
||||||
${supabase_app_schemas}."""
|
${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:
|
if port is not None:
|
||||||
ctx["port"] = str(port)
|
ctx["port"] = str(port)
|
||||||
if public_url is not None:
|
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:
|
if dep.manage and dep.manage.systemd and not dep.manage.systemd.enable:
|
||||||
managed = False
|
managed = False
|
||||||
|
|
||||||
# `proxy` is the exposure checkbox; the subdomain is the deployment name.
|
# `http_exposed` is the HTTP-gateway checkbox (reach != off AND an http port);
|
||||||
expose = bool(dep.proxy)
|
# 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
|
port = None
|
||||||
health_path = None
|
health_path = None
|
||||||
if dep.expose and dep.expose.http:
|
if dep.expose and dep.expose.http:
|
||||||
port = dep.expose.http.internal.port
|
port = dep.expose.http.internal.port
|
||||||
health_path = dep.expose.http.health_path
|
health_path = dep.expose.http.health_path
|
||||||
|
tcp_port = dep.tcp_port
|
||||||
|
|
||||||
# Env is exactly what's in defaults.env — no hidden convention injection.
|
# 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
|
# ${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).
|
# mode-0600 file (never in the unit or argv).
|
||||||
raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
|
raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
|
||||||
public_url = _public_url(config, name, expose, port)
|
public_url = _public_url(config, name, expose, port)
|
||||||
env, secret_env = resolve_env_split(
|
ctx = _env_context(name, config_key, port, public_url, _supabase_app_schemas(config))
|
||||||
raw_env,
|
# ${tls_*}: paths to castle-materialized cert files for a TLS-material TCP
|
||||||
_env_context(name, config_key, port, public_url, _supabase_app_schemas(config)),
|
# 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)
|
secret_env_file = _write_secret_env_file(name, secret_env)
|
||||||
|
|
||||||
# `command` launchers resolve a tool on PATH → ensure it's installed.
|
# `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)
|
_ensure_python_tool(config, dep.program, messages)
|
||||||
|
|
||||||
run_cmd = _build_run_cmd(
|
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)
|
stop_cmd = _build_stop_cmd(name, run, source_dir)
|
||||||
|
|
||||||
@@ -606,6 +666,7 @@ def _build_deployed(
|
|||||||
health_path=health_path,
|
health_path=health_path,
|
||||||
subdomain=(name if expose else None),
|
subdomain=(name if expose else None),
|
||||||
public=bool(dep.public and expose),
|
public=bool(dep.public and expose),
|
||||||
|
tcp_port=tcp_port,
|
||||||
schedule=getattr(dep, "schedule", None),
|
schedule=getattr(dep, "schedule", None),
|
||||||
managed=managed,
|
managed=managed,
|
||||||
enabled=dep.enabled,
|
enabled=dep.enabled,
|
||||||
@@ -680,6 +741,15 @@ def _ensure_python_tool(
|
|||||||
messages.append(f"Installed {program}")
|
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(
|
def _build_run_cmd(
|
||||||
name: str,
|
name: str,
|
||||||
run: object,
|
run: object,
|
||||||
@@ -687,6 +757,7 @@ def _build_run_cmd(
|
|||||||
messages: list[str],
|
messages: list[str],
|
||||||
source_dir: Path | None = None,
|
source_dir: Path | None = None,
|
||||||
secret_env_file: Path | None = None,
|
secret_env_file: Path | None = None,
|
||||||
|
placeholders: dict[str, str] | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Build a run command list from a LaunchSpec (a systemd deployment's `run`).
|
"""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),
|
# Container name derives from the SERVICE name (matches the systemd unit),
|
||||||
# not the image name — so `castle-<service>` is stable and collision-free.
|
# not the image name — so `castle-<service>` is stable and collision-free.
|
||||||
cmd = [runtime, "run", "--rm", f"--name=castle-{name}"]
|
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]
|
for container_port, host_port in run.ports.items(): # type: ignore[union-attr]
|
||||||
cmd.extend(["-p", f"{host_port}:{container_port}"])
|
cmd.extend(["-p", f"{host_port}:{container_port}"])
|
||||||
for vol in run.volumes: # type: ignore[union-attr]
|
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]
|
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.
|
# env is plain-only; secrets go via --env-file so they never hit argv.
|
||||||
for key, val in env.items():
|
for key, val in env.items():
|
||||||
cmd.extend(["-e", f"{key}={val}"])
|
cmd.extend(["-e", f"{key}={val}"])
|
||||||
if secret_env_file is not None:
|
if secret_env_file is not None:
|
||||||
cmd.extend(["--env-file", str(secret_env_file)])
|
cmd.extend(["--env-file", str(secret_env_file)])
|
||||||
if run.workdir: # type: ignore[union-attr]
|
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]
|
cmd.append(run.image) # type: ignore[union-attr]
|
||||||
if run.command: # 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]
|
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
|
return cmd
|
||||||
case "compose":
|
case "compose":
|
||||||
# A whole docker-compose stack supervised as one unit. `up` runs
|
# 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.
|
"""Derive a systemd deployment's gateway exposure from its spec.
|
||||||
|
|
||||||
The single source of truth shared by the registry build (``deploy``) and
|
The single source of truth shared by the registry build (``deploy``) and
|
||||||
route computation (``compute_routes``), so they never disagree. ``expose`` is
|
route computation (``compute_routes``), so they never disagree. A gateway
|
||||||
the checkbox (``proxy: true``); the subdomain is always the deployment name.
|
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
|
port = None
|
||||||
if dep.expose and dep.expose.http:
|
if dep.expose and dep.expose.http:
|
||||||
port = dep.expose.http.internal.port
|
port = dep.expose.http.internal.port
|
||||||
return bool(dep.proxy), port, None
|
return dep.http_exposed, port, None
|
||||||
|
|
||||||
|
|
||||||
def _local_routes(
|
def _local_routes(
|
||||||
@@ -214,6 +215,18 @@ def generate_caddyfile_from_registry(
|
|||||||
lines.append(f" acme_dns {provider} {{env.{token_env}}}")
|
lines.append(f" acme_dns {provider} {{env.{token_env}}}")
|
||||||
if os.environ.get("CASTLE_ACME_STAGING") == "1":
|
if os.environ.get("CASTLE_ACME_STAGING") == "1":
|
||||||
lines.append(f" acme_ca {_ACME_STAGING_CA}")
|
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 += ["}", ""]
|
lines += ["}", ""]
|
||||||
# One wildcard site → a single cert covers every subdomain; a new service
|
# One wildcard site → a single cert covers every subdomain; a new service
|
||||||
# needs no new cert or challenge.
|
# needs no new cert or challenge.
|
||||||
|
|||||||
@@ -16,6 +16,47 @@ class RestartPolicy(str, Enum):
|
|||||||
ALWAYS = "always"
|
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`)
|
# 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)
|
volumes: list[str] = Field(default_factory=list)
|
||||||
env: EnvMap = Field(default_factory=dict)
|
env: EnvMap = Field(default_factory=dict)
|
||||||
workdir: str | None = None
|
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):
|
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
|
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):
|
class ExposeSpec(BaseModel):
|
||||||
http: HttpExposeSpec | None = None
|
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
|
schedule: str | None = None
|
||||||
timezone: str = "America/Los_Angeles"
|
timezone: str = "America/Los_Angeles"
|
||||||
expose: ExposeSpec | None = None
|
expose: ExposeSpec | None = None
|
||||||
# Route <name>.<gateway.domain> to this process. False → host:port only.
|
# How far this process is exposed (off | internal | public). See `Reach`.
|
||||||
proxy: bool = False
|
reach: Reach = Reach.OFF
|
||||||
# Also publish to the public internet via the Cloudflare tunnel, at
|
|
||||||
# <name>.<gateway.public_domain>. Opt-in; requires `proxy`.
|
|
||||||
public: bool = False
|
|
||||||
manage: ManageSpec | None = None
|
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")
|
@model_validator(mode="after")
|
||||||
def _validate(self) -> SystemdDeployment:
|
def _validate_reach(self) -> SystemdDeployment:
|
||||||
if self.public and not self.proxy:
|
# An exposed reach needs a port to expose. Without an `expose` block the
|
||||||
raise ValueError("public requires proxy (an exposed process).")
|
# 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
|
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):
|
class CaddyDeployment(DeploymentBase):
|
||||||
"""A static site served by the gateway (Caddy ``file_server``) — no process.
|
"""A static site served by the gateway (Caddy ``file_server``) — no process.
|
||||||
@@ -350,8 +494,24 @@ class CaddyDeployment(DeploymentBase):
|
|||||||
|
|
||||||
manager: Literal["caddy"]
|
manager: Literal["caddy"]
|
||||||
root: str = "dist"
|
root: str = "dist"
|
||||||
# Inherently exposed at its subdomain; `public` = also project via the tunnel.
|
# A static site is inherently served at its subdomain, so `reach` is
|
||||||
public: bool = False
|
# `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):
|
class PathDeployment(DeploymentBase):
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class NodeConfig:
|
|||||||
# Cloudflare tunnel: public services publish at <subdomain>.<public_domain>.
|
# Cloudflare tunnel: public services publish at <subdomain>.<public_domain>.
|
||||||
public_domain: str | None = None
|
public_domain: str | None = None
|
||||||
tunnel_id: 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:
|
def __post_init__(self) -> None:
|
||||||
if not self.hostname:
|
if not self.hostname:
|
||||||
@@ -66,6 +68,9 @@ class Deployment:
|
|||||||
# Also projected to the public internet via the tunnel at
|
# Also projected to the public internet via the tunnel at
|
||||||
# <subdomain>.<gateway.public_domain>. Requires subdomain.
|
# <subdomain>.<gateway.public_domain>. Requires subdomain.
|
||||||
public: bool = False
|
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.
|
# For `static` runner services: the absolute dir the gateway file_servers.
|
||||||
# Set → the route is `static` (file_server) rather than `proxy` (reverse_proxy).
|
# Set → the route is `static` (file_server) rather than `proxy` (reverse_proxy).
|
||||||
static_root: str | None = None
|
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"),
|
acme_dns_provider=node_data.get("acme_dns_provider", "cloudflare"),
|
||||||
public_domain=node_data.get("public_domain"),
|
public_domain=node_data.get("public_domain"),
|
||||||
tunnel_id=node_data.get("tunnel_id"),
|
tunnel_id=node_data.get("tunnel_id"),
|
||||||
|
cert_hook=node_data.get("cert_hook", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
deployed: dict[str, Deployment] = {}
|
deployed: dict[str, Deployment] = {}
|
||||||
@@ -154,6 +160,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
|||||||
health_path=comp_data.get("health_path"),
|
health_path=comp_data.get("health_path"),
|
||||||
subdomain=comp_data.get("subdomain"),
|
subdomain=comp_data.get("subdomain"),
|
||||||
public=comp_data.get("public", False),
|
public=comp_data.get("public", False),
|
||||||
|
tcp_port=comp_data.get("tcp_port"),
|
||||||
static_root=comp_data.get("static_root"),
|
static_root=comp_data.get("static_root"),
|
||||||
base_url=comp_data.get("base_url"),
|
base_url=comp_data.get("base_url"),
|
||||||
schedule=comp_data.get("schedule"),
|
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
|
data["node"]["public_domain"] = registry.node.public_domain
|
||||||
if registry.node.tunnel_id:
|
if registry.node.tunnel_id:
|
||||||
data["node"]["tunnel_id"] = 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():
|
for name, comp in registry.deployed.items():
|
||||||
entry: dict = {
|
entry: dict = {
|
||||||
@@ -221,6 +230,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
|||||||
entry["subdomain"] = comp.subdomain
|
entry["subdomain"] = comp.subdomain
|
||||||
if comp.public:
|
if comp.public:
|
||||||
entry["public"] = comp.public
|
entry["public"] = comp.public
|
||||||
|
if comp.tcp_port is not None:
|
||||||
|
entry["tcp_port"] = comp.tcp_port
|
||||||
if comp.static_root:
|
if comp.static_root:
|
||||||
entry["static_root"] = comp.static_root
|
entry["static_root"] = comp.static_root
|
||||||
if comp.base_url:
|
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)}))
|
routes = compute_routes(_make_registry(), _config({"pg": _service(5432, expose=False)}))
|
||||||
assert not [r for r in routes if r.name == "pg"]
|
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:
|
def test_config_port_overrides_stale_registry(self) -> None:
|
||||||
registry = _make_registry(
|
registry = _make_registry(
|
||||||
deployed={"claw": _dep(8001, expose=True, name="claw")}
|
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
|
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:
|
def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None:
|
||||||
"""A compose service runs `docker compose -p <project> -f <abs-file> up`."""
|
"""A compose service runs `docker compose -p <project> -f <abs-file> up`."""
|
||||||
run = RunCompose(launcher="compose", file="docker-compose.yml")
|
run = RunCompose(launcher="compose", file="docker-compose.yml")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from castle_core.manifest import (
|
from castle_core.manifest import (
|
||||||
|
Reach,
|
||||||
BuildSpec,
|
BuildSpec,
|
||||||
CaddyDeployment,
|
CaddyDeployment,
|
||||||
ExposeSpec,
|
ExposeSpec,
|
||||||
@@ -110,14 +111,78 @@ class TestSystemdDeployment:
|
|||||||
)
|
)
|
||||||
assert s.manage.systemd.enable is True
|
assert s.manage.systemd.enable is True
|
||||||
|
|
||||||
def test_public_requires_proxy(self) -> None:
|
def test_reach_ladder_and_legacy_mapping(self) -> None:
|
||||||
"""public without proxy is invalid (public needs an exposed process)."""
|
"""`reach` is canonical; legacy proxy/public map to it, and the derived
|
||||||
with pytest.raises(ValueError, match="public requires proxy"):
|
proxy/public accessors reflect it (public implies internal)."""
|
||||||
SystemdDeployment(
|
# An exposed reach needs an expose block (see test_reach_requires_expose),
|
||||||
id="bad",
|
# so give the base one; reach off doesn't, tested separately below.
|
||||||
|
base = dict(
|
||||||
|
id="svc",
|
||||||
manager="systemd",
|
manager="systemd",
|
||||||
run=RunPython(launcher="python", program="svc"),
|
run=RunPython(launcher="python", program="svc"),
|
||||||
public=True,
|
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:
|
def test_no_run_is_invalid(self) -> None:
|
||||||
@@ -166,11 +231,12 @@ class TestModelSerialization:
|
|||||||
internal=HttpInternal(port=9001), health_path="/health"
|
internal=HttpInternal(port=9001), health_path="/health"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
proxy=True,
|
reach=Reach.INTERNAL,
|
||||||
manage=ManageSpec(systemd=SystemdSpec()),
|
manage=ManageSpec(systemd=SystemdSpec()),
|
||||||
)
|
)
|
||||||
data = s.model_dump(exclude_none=True, exclude={"id"})
|
data = s.model_dump(exclude_none=True, exclude={"id"})
|
||||||
assert data["manager"] == "systemd"
|
assert data["manager"] == "systemd"
|
||||||
assert data["run"]["launcher"] == "python"
|
assert data["run"]["launcher"] == "python"
|
||||||
assert data["expose"]["http"]["internal"]["port"] == 9001
|
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"
|
|| log_fail "xcaddy install failed"
|
||||||
fi
|
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)"
|
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"; }
|
|| { 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"; }
|
sudo install -m 0755 "$tmp/caddy" /usr/local/bin/caddy || { rm -rf "$tmp"; log_fail "install failed"; }
|
||||||
rm -rf "$tmp"
|
rm -rf "$tmp"
|
||||||
|
|
||||||
/usr/local/bin/caddy list-modules 2>/dev/null | grep -q "dns.providers.$provider" \
|
/usr/local/bin/caddy list-modules 2>/dev/null | grep -q "dns.providers.$provider" \
|
||||||
|| log_fail "built caddy is missing 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_info "Built /usr/local/bin/caddy — run 'castle apply' to use it."
|
||||||
log_ok
|
log_ok
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user