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.
19 KiB
Fleet Mesh Plan — OpenBao + NATS
Status: Phases 0–4 complete + verified live across both nodes (civil + primer), including TLS hardening (NATS + OpenBao). Merged to main. Remaining nice-to-have: the live curl+kill breaker demo (needs a peer-unique consumed service) — every underlying piece is verified.
Context
Wild PC's mesh today is a deliberately minimal, read-only gossip: each node
publishes a secret-stripped NodeRegistry to Mosquitto MQTT (retained) + an
online/offline LWT, and peers aggregate it into MeshStateManager. It is
LAN-only, unauthenticated, carries no secrets, and does nothing with what it
discovers beyond display.
The goal is a purpose-driven heterogeneous fleet (media server, security
appliance, TV, comms handler, AI gateway) where nodes come and go as expected:
shared LAN-wide config + selected secrets, discoverability + presence, and
binding by purpose (a consumer needs media-index, not a hostname) with
circuit-breakers so churn degrades gracefully instead of hanging.
Two adopted, genuinely-open components (see licensing analysis in chat history):
- NATS (CNCF, Apache-2.0) — replaces Mosquitto as the mesh substrate and does three jobs in one: pub/sub, JetStream KV (shared config + registry), and TTL keys as presence (a provider's key vanishes on death → the breaker trip signal, no separate health plumbing).
- OpenBao (Linux Foundation / OpenSSF, MPL-2.0, Vault fork) — the secret
authority behind wildpc's existing
${secret:...}mechanism.
Design stance: static single-writer role authority, no consensus —
availability comes from followers running cached local state, not failover.
NATS single-node matches that topology. Consensus stays explicitly out of scope.
Architecture — planes → components → code seams
| Plane | Component | Primary code seam (verified) |
|---|---|---|
| Transport + KV + presence | NATS (wildpc-nats) |
wildpc-api/.../mqtt_client.py (whole file), main.py lifespan 62-77, config.py Settings |
| Shared config + registry + presence | NATS JetStream KV | mesh.py MeshStateManager; new KV buckets |
| Secret authority | OpenBao (wildpc-openbao) |
core/.../config.py:307 _read_secret (single chokepoint), wildpc-api/.../secrets.py CRUD |
| Binding-by-purpose + breaker | Caddy gateway | generators/caddyfile.py compute_routes (remote kind + ignored remote_registries are pre-stubbed), deploy.py:509-540 _target_url/_requires_env (local-only today) |
| Authority role | NodeConfig.role |
wildpc_core/registry.py / manifest.py |
Phased plan
Each phase is independently useful; the risky secret-transport work is last.
Phase 0 — Stand up the two services (no behavior change)
wildpc-nats: aSystemdDeployment+RunContainer(nats:2with JetStream-js), data volume/data/wildpc/wildpc-nats,reach: internal(nats.<domain>). Follow the container YAML shape indocs/tcp-exposure.md(postgres example). Replace the Mosquitto provisioning ininstall.sh(setup_mqtt,seed_mosquitto_config, image/port consts) with NATS — or better, promote it to a bootstrap deployment YAML (today the broker is only a shell-provisioned container, not a declared deployment).wildpc-openbao:RunContainer(openbao/openbao), data volume, a file storage backend,reach: internal, auto-unseal (local transit/key) so the node boots unattended (Decision 2).
Phase 1 — NATS as the mesh transport (swap Mosquitto, behavior-preserving)
- New
nats_client.py(async-nativenats-py) replacingmqtt_client.py. Registry → a JetStream KV bucketwildpc-registrykeyed by hostname (KV last-value replaces MQTT retained). Presence → awildpc-presencebucket with a per-node TTL key the node renews (replaces LWT + the 300sSTALE_TTLpoll; noteprune_staleis currently uncalled). - Wire in
main.pylifespan; renamemqtt_*→nats_*inconfig.py(keepWILDPC_API_prefix).MeshStateManagerlogic is reused; the KV watch callback drivesupdate_node/set_offline— and becausenats-pyis asyncio-native, therun_coroutine_threadsafecross-thread hop forbroadcast("mesh", …)goes away. - Preserve the secret-stripping invariant from
_registry_to_json(env / run_cmd / wildpc_root never on the wire). - Rename MQTT-named fields:
models.pyMeshStatus(217-226) + frontendtypes/index.ts(326-330),MeshPanel.tsx. Droppaho-mqtt; addnats-py. Keep zeroconf peer-advert for LAN discovery and support explicit NATS seed URLs for cross-network (Decision 1); drop the dangling unused_mqtt._tcpbrowse. Rewritetest_mqtt.py→test_nats.py. - Fixes a latent gap: today the registry is published on-connect only (no
periodic/on-change republish). KV writes on
wildpc applyfix this naturally.
Phase 2 — Shared config + presence via JetStream KV
- Buckets:
wildpc-config(shared LAN config, authority-written only),wildpc-registry(per-node),wildpc-presence(TTL liveness). - Add static
role: authority | followertoNodeConfig(wildpc.yaml). Pincivil= authority (Decision 3); only the authority may writewildpc-config. Authority-down ⇒ shared state read-only, nodes serve cached. - Followers watch
wildpc-configand reconcile viawildpc apply. Presence key renewal is the churn signal for Phase 3.
Phase 3 — Cross-node requires resolution + gateway binding + breaker (keystone)
- Extend
_target_url/_requires_env(deploy.py:509-540) to fall through to the mesh registry when arefisn't satisfied locally (today it's local-only). The ref is already host-independent — this removes the "must be local" restriction, not adds a concept. - Produce the pre-stubbed
remoteGatewayRoute; threadremote_registriesintocompute_routes(currently accepted-but-ignored) and into the Caddyfile write atdeploy.py:157(currently passed nothing). Binding = a stable local subdomain route the gateway re-targets to the remote node's URL — the program sees one unchanging URL. - Circuit-breaker: gate the remote route on the
wildpc-presencekey (regenerate +_reload_gatewaywhen a provider appears/vanishes), backed by Caddy passive health (fail_duration/lb_try_duration) on thereverse_proxyline in_host_matcher_block. Consumer degraded-mode = fallback target or clean 503. (Also carryhealth_urlinto the registry — it's currently dropped onmanager: nonerefs.)
Phase 4 — OpenBao as the secret backend (last; needs the security foundation)
- Introduce a
SecretBackendseam at the single read chokepoint_read_secret(config.py:307) + thewildpc-api/.../secrets.pywriter:FileSecretBackend(default, unchanged) andOpenBaoBackend.${secret:NAME}syntax is untouched; backend selected in wildpc.yaml or by env. - Followers auth to OpenBao (token / AppRole); need-to-know scoping via per-deployment policies. Keep the file backend as fallback; migrate secrets in.
Cross-cutting prerequisite (gates Phase 4 + any cross-network)
- Transport hardening: NATS TLS + nkey/user auth (replace Mosquitto's
allow_anonymous); OpenBao TLS listener. No secret and no cross-network link moves until this is in. Cross-network overlay (e.g. Nebula) is orthogonal and layers under Caddy later.
Verification (per phase, end-to-end)
- P1: bring up two nodes; confirm registry + presence propagate over NATS and
the mesh view (
/mesh/status, System Map) is identical to the MQTT behavior. - P2: write a key to
wildpc-configon the authority; observe a follower watch fire andwildpc applyreconcile. - P3: define a service on node A that
requiresa ref provided on node B; confirm the gateway routes cross-node, then kill node B and confirm the consumer fails fast (curl returns the degraded 503, not a hang) and recovers when B returns. - P4: store a secret in OpenBao; confirm a service renders it via
EnvironmentFile=/--env-file; then disable the file fallback and confirm it still resolves.
Progress
Phase 0 — DONE + verified (2026-07-07)
Both services stood up alongside the live MQTT mesh (no cutover yet):
wildpc-nats(nats:2, JetStream) — deployment~/.wildpc/deployments/services/wildpc-nats.yaml, config/data/wildpc/wildpc-nats/config/nats-server.conf. Verified: container active,/healthzok, JetStream enabled (/jszshows store dir + limits), and a real KV round-trip — createdwildpc-registry(put/get) and a TTLwildpc-presencebucket (the presence primitive). Test buckets cleaned up.wildpc-openbao(openbao/openbao:latest, v2.5.5) — deployment~/.wildpc/deployments/services/wildpc-openbao.yaml, config/data/wildpc/wildpc-openbao/config/openbao.hcl. Verified: initialized (1 share / threshold 1), unsealed, KV-v2 secret put/get/delete round-trip. Unseal key + root token stored as wildpc secretsOPENBAO_UNSEAL_KEY/OPENBAO_ROOT_TOKEN.
Consciously deferred (tracked):
- Auto-unseal on boot. OpenBao is unsealed now but a container restart
re-seals it. The manifest
SystemdSpechas noexec_start_posthook, so proper boot-unseal wiring lands with Phase 4 (either a small manifest addition or a companion oneshot). Manual recovery: unseal withOPENBAO_UNSEAL_KEY. install.shbootstrap parity. Fresh-install provisioning still seeds Mosquitto; adding NATS/OpenBao there (and retiring Mosquitto) is bootstrap-only (no runtime impact) and folds in with the Phase 1 cutover.
Phase 1 — DONE + single-node verified; live cutover done (2026-07-07)
MQTT/Mosquitto transport replaced by NATS JetStream KV.
- New
wildpc_api/mesh_wire.py— transport-agnostic registry (de)serialization (moved out ofmqtt_client.py; preserves the secret-stripping invariant). - New
wildpc_api/nats_client.py—WildpcNATSClient: connects, PUTs its registry to thewildpc-registryKV bucket, seeds from existing keys, watches for peer PUT/DELETE, heartbeats a re-PUT (crash liveness via the existing stale-TTL), and DELETEs its key on graceful stop (immediate peer-offline). Async-native → the paho cross-threadrun_coroutine_threadsafehop is gone. main.pylifespan,config.py(nats_enabled/nats_url),models.pyMeshStatus(connected/nats_url),nodes.py/mesh/statusall rewired.- Frontend:
types/index.tsMeshStatus+MeshPanel.tsxupdated to the new fields.mqtt_client.py+test_mqtt.pydeleted;test_mesh_wire.pyadded.mdns.pydead_mqtt._tcpbroker-browse removed.paho-mqtt→nats-py. - Live cutover:
wildpc-api.yamlenv →WILDPC_API_NATS_*,requires: wildpc-nats; applied. wildpc-api healthy on NATS,/mesh/statusconnected, registry (9.7 KiB, 48 deployments) published to KV, no secrets on the wire. - Verification run: ruff clean; 89 api + 196 core tests pass; frontend
tscclean; runtime KV round-trip confirmed against the livewildpc-nats.
Pending second node: two-node mesh-view parity (peer sees peer, offline on
departure) — needs the second LAN node running the NATS-enabled wildpc-api
pointed at civil's NATS (seed URL) or a clustered wildpc-nats. Revert path if
needed: restore WILDPC_API_MQTT_* env — but note mqtt_client.py is deleted,
so revert = git checkout main -- wildpc-api then re-apply. The old Mosquitto
mqtt service is left running (dormant) as a safety net; retire it once the
second node is proven on NATS.
Phase 2 — DONE + single-node verified + tested (2026-07-07)
rolefield onNodeConfig+WildpcConfig, wired end-to-end: wildpc.yaml top-levelrole:→ config →_node_config→ registry.yaml → mesh wire.civilpinnedrole: authority; defaultfollower.- Presence (
wildpc-presence) — a TTL KV bucket each node renews on the heartbeat; expiry = the node is gone. Delete-on-stop for immediate departure. - Shared config (
wildpc-config) —get_shared_config/put_shared_config(authority-gated: followers raisePermissionError), plus a watch loop that broadcasts aconfig_changedSSE (the followerwildpc applyreconcile hook hangs off this — inert on one node). - Hardened
stop()to bound the NATS drain (can't hang systemd shutdown). - Verified live: all three buckets present,
civil=onlinepresence,"role": "authority"on the wire, authority write + follower-deny exercised against the runningwildpc-nats. - Tests added:
core/tests/test_fleet_role.py(role config load + registry round-trip),wildpc-api/tests/test_nats_client.py(role gating, hermetic), role round-trip intest_mesh_wire.py. Suites: 200 core + 93 api pass.
Pending second node: the follower-side reconcile (watch wildpc-config →
wildpc apply) is wired as an SSE hook but only meaningful with a peer.
Phase 4 — secret-read backend DONE + verified + tested (2026-07-07)
- New
core/wildpc_core/secret_backends.py:SecretBackendprotocol,FileSecretBackend(the historical behavior),OpenBaoBackend(KV-v2 read with file fallback), andbuild_backend()selecting viaWILDPC_SECRET_BACKEND(default file — production is byte-for-byte unchanged until opted in). _read_secret(the single chokepoint,config.py) now delegates to the active backend.${secret:NAME}syntax untouched.- OpenBao token bootstraps from the file backend (it can't live in the vault it unlocks); a missing key / auth failure / unreachable server all fall through to file, so a partly-migrated vault keeps working.
- Verified live against the running
wildpc-openbao: a secret stored in the vault resolves through${secret:...}in openbao mode; file-only secrets resolve via fallback; missing → placeholder; default file mode unchanged. - Tests:
core/tests/test_secret_backends.py(file hit/miss, backend selection, unreachable-vault + empty-token fallback). Suites: 206 core + 93 api.
Hardening:
- Write path — DONE + verified live.
SecretBackendgainedwrite/delete/list_names;wildpc-api/secrets.pyroutes all CRUD through the active backend, so in openbao mode the dashboard writes to the vault. Verified: write→vault→read→list→delete round-trip against live OpenBao. - Auto-unseal on boot — DONE + verified. Added
exec_start_postto the systemd spec (general,--prefixed so a hook failure never fails the unit);wildpc-openbaoruns/data/wildpc/wildpc-openbao/unseal.sh(polls up, unseals withOPENBAO_UNSEAL_KEY). Verified: manual seal → restart → auto-unsealed. - TLS hardening — DONE + verified across both nodes (2026-07-07).
Reuses the existing ACME wildcard cert via
expose.tcp.tls(the postgres mechanism) — services present a publicly-trusted cert for<name>.civil.payne.io, so clients verify against the system CA with no custom CA to distribute.- NATS:
tls{}(wildcard cert in/tls) +authorization{token}($NATS_TOKENfrom theNATS_TOKENsecret). Clients connect totls://wildpc-nats.civil.payne.io:4222with the token;WildpcNATSClientgained token support (atls://URL → nats-py verifies via system CA). civil + primer both cut over; plaintext now rejected (verified:certificate is valid for *.civil.payne.io, not localhost). - OpenBao: listener
tls_cert_file/tls_key_filefrom/tls; reachable athttps://wildpc-openbao.civil.payne.io:8200. Auto-unseal + secret backend both use the HTTPS URL. Verified: HTTPS serves, auto-unsealed over HTTPS, plaintext rejected, backend reads a secret over HTTPS. - Coordinated cutover across the whole fleet (both nodes) with a brief, expected mesh blip; mesh isn't load-bearing so no service impact.
- NATS:
Phase 3 — cross-node routing + breaker: logic DONE + verified against a real peer (2026-07-07)
Real second node: primer (192.168.8.129) migrated onto the NATS mesh
(branch checked out, wildpc-api pointed at nats://civil:4222 via a systemd
drop-in). civil ↔ primer mesh confirmed over the LAN — Phase 1 two-node parity
done for real, not simulated. (This also restored the civil↔primer mesh my
Phase 1 cutover had split — primer was still on MQTT→civil.)
NodeConfig.address— routable host peers proxy to (wired through registry + wire; falls back to hostname, which civil resolves for primer).compute_routesnow emitsremoteroutes for services this node consumes (a localrequiresref satisfied by an online peer), targeting<peer-address>:<port>._host_remote_blockrenders a fail-fast breaker (2s dial timeout + passive health) for the there-but-wedged case; presence expiry removes the peer from the route set entirely (gone → no route) — the primary breaker.wildpc_api/mesh_gateway.py— on peer join/leave/change (+ startup) the API re-renders the Caddyfile (same generatorapplyuses + remote routes for online peers) and reloads the gateway iff content changed.- Verified: 4 hermetic tests (route emitted / address fallback / breaker:
peer-absent → no route / unconsumed → no route); resolution against primer's
real registry pulled live from the mesh →
wildpc-api → primer:9020; and the live integration proven a no-op on civil (Caddyfile hash unchanged, gateway healthy) since civil consumes nothing cross-node yet. Suites: 210 core + 93 api.
Remaining: the full live curl+kill E2E (civil routing to a peer service, then
failing fast on kill) needs a peer-unique service civil consumes — every
underlying piece is verified, but the end-to-end demo needs that provisioning.
primer is now a permanent mesh member on the branch (revert: remove the drop-in +
git checkout main on primer).
Decisions (resolved)
- Discovery — both. Keep mDNS for zero-config LAN peer discovery and support explicit NATS seed URLs so the mesh can span networks. mDNS is the convenience path on-LAN; seed URLs are the cross-network path. Client connects via seeds when present, falls back to mDNS-discovered peers on-LAN.
- OpenBao unseal — auto for now. Auto-unseal (local transit/key) so a home node boots unattended; designed so we can migrate to manual/stricter unseal later (a supported rekey/unseal-migration, not zero-effort but planned for). Keep the backend seam clean so the switch is config, not code.
- Authority =
civil.civil(the acme node,civil.payne.io) is pinned asrole: authority; all others are followers. Confirmed: whencivilis down, shared config/secrets go read-only fleet-wide and every node keeps serving its own deployments from cached local state. - NATS — single server now, cluster-ready. Run one
wildpc-natstoday (matches the single-writer authority). Clustering is deferred but must be a pure config addition (cluster/routes block + a few more nodes) — no re-architecture.