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:
2026-07-04 23:04:26 -07:00
parent 0e8bf2571f
commit 3c566540aa
20 changed files with 1382 additions and 107 deletions

View File

@@ -165,6 +165,16 @@ def build_parser() -> argparse.ArgumentParser:
gw_sub = gw.add_subparsers(dest="gateway_command")
gw_sub.add_parser("status", help="Show gateway status + routes (the default)")
# TLS material for raw-TCP services (cert cut from the gateway wildcard).
tls = subparsers.add_parser(
"tls", help="Manage castle-materialized TLS certs for raw-TCP services"
)
tls_sub = tls.add_subparsers(dest="tls_command")
tls_sub.add_parser(
"reconcile", help="Refresh materialized certs from the wildcard + reload changed"
)
tls_sub.add_parser("status", help="Show each TLS service's cert fingerprint + expiry")
# Convergence — the one lifecycle verb. Renders units/Caddyfile/tunnel, then
# reconciles the runtime to match config (activate/restart/deactivate).
p = subparsers.add_parser(
@@ -305,6 +315,10 @@ def main() -> int:
from castle_cli.commands.gateway import run_gateway
return run_gateway(args)
if cmd == "tls":
from castle_cli.commands.tls import run_tls
return run_tls(args)
if cmd == "apply":
from castle_cli.commands.apply import run_apply