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

@@ -16,6 +16,7 @@ from castle_cli.manifest import (
HttpExposeSpec,
HttpInternal,
ManageSpec,
Reach,
RunCommand,
RunPython,
SystemdDeployment,
@@ -58,7 +59,7 @@ def run_service_create(args: argparse.Namespace) -> int:
run = _run_spec(args.launcher, args.run or args.program or name, name)
expose = None
proxy = False
reach = Reach.OFF
if args.port is not None:
expose = ExposeSpec(
http=HttpExposeSpec(
@@ -67,7 +68,7 @@ def run_service_create(args: argparse.Namespace) -> int:
)
)
# Expose at <name>.<gateway.domain> (the subdomain is the service name).
proxy = not args.no_proxy
reach = Reach.OFF if args.no_proxy else Reach.INTERNAL
config.deployments[name] = SystemdDeployment(
id=name,
@@ -76,7 +77,7 @@ def run_service_create(args: argparse.Namespace) -> int:
description=args.description or None,
run=run,
expose=expose,
proxy=proxy,
reach=reach,
manage=ManageSpec(systemd=SystemdSpec()),
defaults=_defaults(args.env),
)
@@ -86,7 +87,7 @@ def run_service_create(args: argparse.Namespace) -> int:
print(f" runs: {args.launcher} ({args.run or args.program or name})")
if expose:
print(f" port: {args.port}")
if proxy:
if reach != Reach.OFF:
print(f" subdomain: {name}.<gateway.domain>")
print(f"\nNext: castle apply {name}")
return 0

View File

@@ -121,8 +121,12 @@ def run_info(args: argparse.Namespace) -> int:
print(f" {BOLD}port{RESET}: {http.internal.port}")
if http.health_path:
print(f" {BOLD}health{RESET}: {http.health_path}")
if service.proxy:
if service.http_exposed:
print(f" {BOLD}subdomain{RESET}: {name}.<gateway.domain>")
if service.tcp_port is not None:
# Raw-TCP reach is internal-only (public TCP is rejected at load, see
# SystemdDeployment._validate_reach), so there's no "public" state here.
print(f" {BOLD}tcp{RESET}: {name}.<gateway.domain>:{service.tcp_port} (internal)")
if service.manage and service.manage.systemd:
sd = service.manage.systemd
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}")

View 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

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

View File

@@ -18,6 +18,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
ManageSpec,
PathDeployment,
ProgramSpec,
Reach,
ReadinessHttpGet,
RemoteDeployment,
RestartPolicy,