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

@@ -185,6 +185,21 @@ class TestConfigSourceOfTruth:
routes = compute_routes(_make_registry(), _config({"pg": _service(5432, expose=False)}))
assert not [r for r in routes if r.name == "pg"]
def test_tcp_service_has_no_http_route(self) -> None:
"""A raw-TCP service (reach: internal + expose.tcp) is reachable by
name+port via DNS, but must NOT get an HTTP gateway route."""
tcp = SystemdDeployment.model_validate(
{
"manager": "systemd",
"run": RunPython(launcher="python", program="pg"),
"reach": "internal",
"expose": {"tcp": {"port": 5432}},
}
)
assert tcp.tcp_port == 5432 and tcp.http_exposed is False
routes = compute_routes(_make_registry(), _config({"pg": tcp}))
assert not [r for r in routes if r.name == "pg"]
def test_config_port_overrides_stale_registry(self) -> None:
registry = _make_registry(
deployed={"claw": _dep(8001, expose=True, name="claw")}