Files
wild-pc/docs/tcp-exposure.md
Paul Payne 05b28cb584 Rename Castle -> Wild PC across the repo
Repo-side rename only (Phases 1-3 of the migration plan); the live box
(~/.castle, systemd units, /data/castle, domains) is a separate cutover.

- Slug `castle` -> `wildpc`: CLI command, module names (wildpc_core/cli/api),
  dist names, entry point `wildpc = wildpc_cli.main:main`.
- Identifiers: CastleConfig/NATSClient/DirError/MDNS -> Wildpc*.
- Env/constants: CASTLE_* -> WILDPC_*; ~/.castle -> ~/.wildpc, castle.yaml ->
  wildpc.yaml, /data/castle -> /data/wildpc.
- Systemd UNIT_PREFIX castle- -> wildpc-; own programs castle-api/gateway/etc.
- Display prose "Castle" -> "Wild PC" in docs, agent-guide files, README, frontend.
- Package dirs and bootstrap yaml renamed via git mv; lockfiles regenerated;
  redundant nested uv.lock files dropped (workspace root lock is authoritative).

Tests: core 273, cli 47, wildpc-api 120 all pass. Frontend type-checks + builds.
Fixed a stale test fixture (secret_env_path kind arg) broken pre-rename.
2026-07-18 22:55:08 -07:00

344 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# TCP exposure & wildpc-managed TLS material
> Status: **design / not yet implemented.** This specs the `reach` exposure
> ladder, raw-TCP service exposure (postgres, redis, …), and how wildpc
> materializes its ACME wildcard cert onto a service so a raw port presents a
> *trusted* cert — generically, with **no protocol knowledge in wildpc'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 wildpc 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. Wild PC
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)
`wildpc` accepts the old fields and normalizes them so existing yamls keep
working; `wildpc 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/wildpc_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: wildpc 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). wildpc
# 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. `wildpc apply` / `wildpc doctor` warn if `material` is set without acme.
---
## 4. Placeholders (`deploy.py` `_env_context`)
When a deployment declares `expose.tcp.tls.material != off`, wildpc 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 wildpc 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 wildpc 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/wildpc/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 wildpc 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, wildpc **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 wildpc. `RunContainer`
gains a `user: str = "${uid}:${gid}"` field and a `tmpfs: list[str]`; wildpc 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 (`wildpc_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 `WILDPC_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** `wildpc restart name`. Idempotent;
prints a summary.
### Wiring
- **`wildpc 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 wildpc tls reconcile
}
}
```
Caddy fires `cert_obtained` on every issuance **and renewal** (renewal reuses
the same event with `renewal: true`); the handler runs `wildpc 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 `wildpc tls reconcile` must be
> idempotent and log its *own* outcome (don't rely on Caddy surfacing failures).
- **Safety net:** a nightly `wildpc-tls-reconcile` job (`manager: systemd` +
`schedule`) also runs `wildpc 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: `wildpc tls reconcile [--plan]` (thin wrapper over `reconcile_tls`), and
`wildpc 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 }`.
- wildpc ensures a self-hosted **Access** app + policy over that hostname.
- `wildpc` 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 wildpc 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`; `wildpc 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
`wildpc apply`; unit-tested in isolation (pair/combined/idempotency/stale-clean).
4. **`cert_obtained` hook + `wildpc tls`** — **CODE DONE (2026-07-03).** Generator
emits the `events { on cert_obtained exec wildpc tls reconcile }` block, **gated
on `WILDPC_CADDY_CERT_HOOK=1`** so the current (no-plugin) gateway is unaffected;
`wildpc 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 → wildpc 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/wildpc/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; `wildpc` + `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.