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.
This commit is contained in:
2026-07-18 22:55:08 -07:00
parent 25d7a522cc
commit 05b28cb584
190 changed files with 2428 additions and 3337 deletions

View File

@@ -1,9 +1,9 @@
# TCP exposure & castle-managed TLS material
# 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 castle
> 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 castle's code**.
> *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.
@@ -17,14 +17,14 @@ 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 castle can do without a proxy:
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. Castle
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.
@@ -47,8 +47,8 @@ says *what it speaks*.
### Legacy mapping (back-compat normalizer)
`castle` accepts the old fields and normalizes them so existing yamls keep
working; `castle apply` can rewrite them on next write:
`wildpc` accepts the old fields and normalizes them so existing yamls keep
working; `wildpc apply` can rewrite them on next write:
| old | new |
|-----|-----|
@@ -59,7 +59,7 @@ working; `castle apply` can rewrite them on next write:
---
## 3. Manifest models (`core/src/castle_core/manifest.py`)
## 3. Manifest models (`core/src/wildpc_core/manifest.py`)
```python
class Reach(str, Enum):
@@ -77,7 +77,7 @@ class TlsMaterial(str, Enum):
class TlsSpec(BaseModel):
material: TlsMaterial = TlsMaterial.OFF
# Optional zero-downtime reload argv run after re-materialize on renewal.
# Default: castle restarts the deployment (fine for a ~60-day cadence).
# Default: wildpc restarts the deployment (fine for a ~60-day cadence).
reload: list[str] | None = None
@@ -85,7 +85,7 @@ 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). castle
# 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.
@@ -121,13 +121,13 @@ 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. `castle apply` / `castle doctor` warn if `material` is set without acme.
> 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`, castle adds these to
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/`:
@@ -143,7 +143,7 @@ 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 castle expand it), write `$${key}`
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:
@@ -168,12 +168,12 @@ expose:
tcp: { port: 5432, tls: { material: pair } }
run:
launcher: container
user: ${uid}:${gid} # default for castle containers — see below
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/castle/postgres/data:/var/lib/postgresql/data
- /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']
```
@@ -181,10 +181,10 @@ run:
### 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 castle writes files as the host
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, castle **runs containers as the invoking user** (`--user ${uid}:${gid}`,
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`:
@@ -196,8 +196,8 @@ docker run --user 1000:1000 --tmpfs /var/run/postgresql \
```
The only image-specific detail is a writable runtime dir (`--tmpfs
/var/run/postgresql`) — declared in the deployment yaml, not castle. `RunContainer`
gains a `user: str = "${uid}:${gid}"` field and a `tmpfs: list[str]`; castle stays
/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.)
@@ -205,14 +205,14 @@ chowned to the runtime uid once when a service adopts this — not per-renewal.)
## 5. Cert materialization + the `cert_obtained` hook
### The materializer (`castle_core`, protocol-agnostic)
### 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 `CASTLE_ACME_STAGING=1`).
(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:
@@ -226,12 +226,12 @@ chowned to the runtime uid once when a service adopts this — not per-renewal.)
5. Return True (changed).
`reconcile_tls(config)` — walk all deployments; `materialize_tls` each; for those
that changed, run `tls.reload` argv **or** `castle restart name`. Idempotent;
that changed, run `tls.reload` argv **or** `wildpc restart name`. Idempotent;
prints a summary.
### Wiring
- **`castle apply`** calls `materialize_tls` for each affected deployment before
- **`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
@@ -241,12 +241,12 @@ prints a summary.
email …
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
events {
on cert_obtained exec castle tls reconcile
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 `castle tls reconcile`,
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
@@ -254,15 +254,15 @@ prints a summary.
> 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 `castle tls reconcile` must be
> **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 `castle-tls-reconcile` job (`manager: systemd` +
`schedule`) also runs `castle tls reconcile` — catches a missed event, a Caddy
- **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: `castle tls reconcile [--plan]` (thin wrapper over `reconcile_tls`), and
`castle tls status` to show, per deployment, source vs materialized cert fingerprint
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.
---
@@ -275,8 +275,8 @@ Public raw-TCP can't reach an unmodified client below Cloudflare Enterprise
- `tunnel.py` emits an extra ingress entry per public TCP deployment:
`{ hostname: name.public_domain, service: tcp://localhost:port }`.
- castle ensures a self-hosted **Access** app + policy over that hostname.
- `castle` prints the client connect line:
- 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
@@ -287,7 +287,7 @@ authenticated tunnel, never an open port. Build this only when wanted.
## 7. Generality check
Same castle code, different yaml — the postgres-ness is two env/arg mappings + a
Same wildpc code, different yaml — the postgres-ness is two env/arg mappings + a
format choice, nothing more:
| service | `material` | consumes | reload |
@@ -313,7 +313,7 @@ format choice, nothing more:
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`; `castle service info` shows the `<name>.<domain>:<port>` endpoint;
`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.
@@ -321,22 +321,22 @@ format choice, nothing more:
`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
`castle apply`; unit-tested in isolation (pair/combined/idempotency/stale-clean).
4. **`cert_obtained` hook + `castle tls`** — **CODE DONE (2026-07-03).** Generator
emits the `events { on cert_obtained exec castle tls reconcile }` block, **gated
on `CASTLE_CADDY_CERT_HOOK=1`** so the current (no-plugin) gateway is unaffected;
`castle tls reconcile|status` CLI; `reconcile_tls` idempotent + self-logging.
`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 → castle tls reconcile`
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/castle/postgres-data-backup-pre-tls.tar.gz`); WAL-recovered clean.
`/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; `castle` + `litellm` DBs intact.
→ "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