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:
@@ -16,6 +16,7 @@ from castle_cli.manifest import (
|
||||
HttpExposeSpec,
|
||||
HttpInternal,
|
||||
ManageSpec,
|
||||
Reach,
|
||||
RunCommand,
|
||||
RunPython,
|
||||
SystemdDeployment,
|
||||
@@ -58,7 +59,7 @@ def run_service_create(args: argparse.Namespace) -> int:
|
||||
run = _run_spec(args.launcher, args.run or args.program or name, name)
|
||||
|
||||
expose = None
|
||||
proxy = False
|
||||
reach = Reach.OFF
|
||||
if args.port is not None:
|
||||
expose = ExposeSpec(
|
||||
http=HttpExposeSpec(
|
||||
@@ -67,7 +68,7 @@ def run_service_create(args: argparse.Namespace) -> int:
|
||||
)
|
||||
)
|
||||
# Expose at <name>.<gateway.domain> (the subdomain is the service name).
|
||||
proxy = not args.no_proxy
|
||||
reach = Reach.OFF if args.no_proxy else Reach.INTERNAL
|
||||
|
||||
config.deployments[name] = SystemdDeployment(
|
||||
id=name,
|
||||
@@ -76,7 +77,7 @@ def run_service_create(args: argparse.Namespace) -> int:
|
||||
description=args.description or None,
|
||||
run=run,
|
||||
expose=expose,
|
||||
proxy=proxy,
|
||||
reach=reach,
|
||||
manage=ManageSpec(systemd=SystemdSpec()),
|
||||
defaults=_defaults(args.env),
|
||||
)
|
||||
@@ -86,7 +87,7 @@ def run_service_create(args: argparse.Namespace) -> int:
|
||||
print(f" runs: {args.launcher} ({args.run or args.program or name})")
|
||||
if expose:
|
||||
print(f" port: {args.port}")
|
||||
if proxy:
|
||||
if reach != Reach.OFF:
|
||||
print(f" subdomain: {name}.<gateway.domain>")
|
||||
print(f"\nNext: castle apply {name}")
|
||||
return 0
|
||||
|
||||
@@ -121,8 +121,12 @@ def run_info(args: argparse.Namespace) -> int:
|
||||
print(f" {BOLD}port{RESET}: {http.internal.port}")
|
||||
if http.health_path:
|
||||
print(f" {BOLD}health{RESET}: {http.health_path}")
|
||||
if service.proxy:
|
||||
if service.http_exposed:
|
||||
print(f" {BOLD}subdomain{RESET}: {name}.<gateway.domain>")
|
||||
if service.tcp_port is not None:
|
||||
# Raw-TCP reach is internal-only (public TCP is rejected at load, see
|
||||
# SystemdDeployment._validate_reach), so there's no "public" state here.
|
||||
print(f" {BOLD}tcp{RESET}: {name}.<gateway.domain>:{service.tcp_port} (internal)")
|
||||
if service.manage and service.manage.systemd:
|
||||
sd = service.manage.systemd
|
||||
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}")
|
||||
|
||||
78
cli/src/castle_cli/commands/tls.py
Normal file
78
cli/src/castle_cli/commands/tls.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""castle tls — castle-managed TLS certs for raw-TCP services.
|
||||
|
||||
Each TLS-material TCP service (postgres, redis, …) gets the gateway's ACME
|
||||
wildcard cert cut onto it so it presents a *trusted* cert on its raw port.
|
||||
`reconcile` refreshes those copies from the wildcard and reloads the services
|
||||
whose cert changed — it's what the Caddy `cert_obtained` hook and the nightly
|
||||
safety-net job both run. `status` shows each service's cert fingerprint + expiry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from castle_cli.config import load_config
|
||||
from castle_core.tls import _tls_of, reconcile_tls, tls_dir_for, wildcard_cert
|
||||
|
||||
|
||||
def run_tls(args: argparse.Namespace) -> int:
|
||||
if getattr(args, "tls_command", None) == "status":
|
||||
return _tls_status()
|
||||
return _tls_reconcile()
|
||||
|
||||
|
||||
def _tls_reconcile() -> int:
|
||||
config = load_config()
|
||||
for msg in reconcile_tls(config):
|
||||
print(msg)
|
||||
return 0
|
||||
|
||||
|
||||
def _fingerprint(pem: bytes) -> str:
|
||||
return hashlib.sha256(pem).hexdigest()[:12]
|
||||
|
||||
|
||||
def _not_after(cert_pem: bytes) -> str:
|
||||
"""Best-effort cert expiry (uses cryptography if available, else '—')."""
|
||||
try:
|
||||
from cryptography import x509
|
||||
|
||||
cert = x509.load_pem_x509_certificate(cert_pem)
|
||||
left = cert.not_valid_after_utc - datetime.now(timezone.utc)
|
||||
return f"{cert.not_valid_after_utc:%Y-%m-%d} ({left.days}d left)"
|
||||
except Exception:
|
||||
return "—"
|
||||
|
||||
|
||||
def _tls_status() -> int:
|
||||
config = load_config()
|
||||
domain = config.gateway.domain
|
||||
src = wildcard_cert(domain) if domain else None
|
||||
src_fp = _fingerprint(src[0].read_bytes()) if src else None
|
||||
print(f"wildcard source: *.{domain} " + (f"[{src_fp}]" if src_fp else "(not found)"))
|
||||
|
||||
rows = []
|
||||
for name, dep in sorted(config.deployments.items()):
|
||||
if _tls_of(dep) is None:
|
||||
continue
|
||||
config_key = dep.program or name
|
||||
cert = tls_dir_for(config_key) / "cert.pem"
|
||||
combined = tls_dir_for(config_key) / "combined.pem"
|
||||
have = cert if cert.exists() else combined if combined.exists() else None
|
||||
if have is None:
|
||||
rows.append((name, "not materialized", "—", ""))
|
||||
continue
|
||||
pem = have.read_bytes()
|
||||
fp = _fingerprint(pem)
|
||||
drift = "" if src_fp and fp == src_fp else " ⚠ stale (run: castle tls reconcile)"
|
||||
rows.append((name, fp, _not_after(pem), drift))
|
||||
|
||||
if not rows:
|
||||
print(" (no TLS-material TCP services)")
|
||||
return 0
|
||||
print()
|
||||
for name, fp, exp, drift in rows:
|
||||
print(f" {name:20s} {fp:14s} {exp}{drift}")
|
||||
return 0
|
||||
@@ -165,6 +165,16 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
gw_sub = gw.add_subparsers(dest="gateway_command")
|
||||
gw_sub.add_parser("status", help="Show gateway status + routes (the default)")
|
||||
|
||||
# TLS material for raw-TCP services (cert cut from the gateway wildcard).
|
||||
tls = subparsers.add_parser(
|
||||
"tls", help="Manage castle-materialized TLS certs for raw-TCP services"
|
||||
)
|
||||
tls_sub = tls.add_subparsers(dest="tls_command")
|
||||
tls_sub.add_parser(
|
||||
"reconcile", help="Refresh materialized certs from the wildcard + reload changed"
|
||||
)
|
||||
tls_sub.add_parser("status", help="Show each TLS service's cert fingerprint + expiry")
|
||||
|
||||
# Convergence — the one lifecycle verb. Renders units/Caddyfile/tunnel, then
|
||||
# reconciles the runtime to match config (activate/restart/deactivate).
|
||||
p = subparsers.add_parser(
|
||||
@@ -305,6 +315,10 @@ def main() -> int:
|
||||
from castle_cli.commands.gateway import run_gateway
|
||||
|
||||
return run_gateway(args)
|
||||
if cmd == "tls":
|
||||
from castle_cli.commands.tls import run_tls
|
||||
|
||||
return run_tls(args)
|
||||
if cmd == "apply":
|
||||
from castle_cli.commands.apply import run_apply
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
||||
ManageSpec,
|
||||
PathDeployment,
|
||||
ProgramSpec,
|
||||
Reach,
|
||||
ReadinessHttpGet,
|
||||
RemoteDeployment,
|
||||
RestartPolicy,
|
||||
|
||||
Reference in New Issue
Block a user