The September 23 setup account below records the VM before the final gateway cutover. The release evidence records the completed live broker and member rollout; use the cutover runbook for the current deployment procedure.
Design and operator recipe. On September 23 the dedicated peterbot-worker
domain and virbr-ctl network were provisioned on P910. The unrelated desktop
VM was left running. The guest runner is healthy at 192.168.241.2:8780; the
existing P910 gateway container reached its health endpoint. A disposable
worker compiled and ran the pinned Rust fixture and passed 30 checks after
a guest reboot. The final image also passed six offline wheel/crate cache checks
inside the VM. The setup-only NAT vNIC was detached from the live and persistent
domain; the guest rebooted with only the host-only control link, and both
worker smokes passed again. A Docker IPAM conflict initially gave a worker
the broker alias .240.2; that address is now reserved and workers allocate
from .128/25. After another reboot the worker received a 401 through the
guest DNAT and narrow P910 UFW rule from a temporary host-only test listener;
the full synthetic-path smoke passed 31 checks with no skips. The actual gateway
broker is still a release gate because the overlay is not yet deployed.
Production still uses
the earlier gateway and host runner images. The recipe remains the recovery
path for the pinned Debian generic-cloud 20260909-2596 and Rust 2026-09-03
inputs.
P910 (trusted host, stays as-is) guest: peterbot-worker
┌───────────────────────────────────────────┐ ┌─────────────────────────────────┐
│ gateway container (Discord, broker :8770)│ │ Debian 12, docker-ce │
│ publishes 192.168.241.1:8770 (overlay) │ │ vNIC2 static 192.168.241.2 │
│ model server / proxy (100.73.210.66:8000) │ │ runner container (:8780 only │
│ libvirt: desktop VM (untouched) │ │ bound there; peterbot_control)│
│ bridge virbr-ctl 192.168.241.1/24 (new) │◄──┤ worker container (transient, │
│ host-only: no NAT, no DHCP, no routing │ │ one at a time; pbworkers │
│ │ │ bridge, egress: broker alias │
│ │ │ 192.168.240.2:8770 ONLY) │
└───────────────────────────────────────────┘ └─────────────────────────────────┘
- The gateway, Discord, model access and all state stay on P910. Only the trusted runner supervisor + disposable workers move into the guest.
- One worker at a time: the guest compose pins
PETERBOT_RUNNER_CONCURRENCY: 1so the contract holds even if gateway config drifts. - The desktop VM is never touched: new domain
peterbot-worker, new networkvirbr-ctl. The existing default NAT is used only for the guest's own package updates at setup time (detachable afterwards, see posture B).
| address | who | why |
|---|---|---|
192.168.241.1 |
P910 host on virbr-ctl |
broker :8770 is published only here via the VM overlay; nothing else |
192.168.241.2 |
guest vNIC2, static on virbr-ctl |
runner API, published only here; also the MASQUERADE source for broker traffic |
192.168.242.0/24 (ctl0) |
guest peterbot_control bridge |
runner's container network; NO L2 with pbworkers |
192.168.240.1 |
guest on pbworkers |
bridge gateway address; the internal worker network has no general default route |
192.168.240.2 |
broker alias on pbworkers (DNAT) |
Docker IPAM reserves this address; workers allocate from 192.168.240.128/25 |
Broker ingress is a docker published port on P910 bound to 192.168.241.1
ONLY (deploy/vm/compose.hermes-vm-gateway.yml). The overlay also disables
the old host runner service and detaches the gateway from the old host worker
network; remove the settled host runner at cutover and verify it is stopped.
Trade-off, stated plainly:
this puts a docker-proxy listener on the P910 host stack, but on the
host-only virbr-ctl segment — unreachable from any network the worker or an
untrusted process can reach, never 0.0.0.0, never the tailnet. The alternative
(macvlan-attaching the gateway container to virbr-ctl) would need a
compose.hermes.yml change owned by the gateway agent; the overlay keeps the
VM rollout additive and one-command reversible.
- Gateway → runner (
http://192.168.241.2:8780/{health,run,cancel}):Authorization: Bearer $PETERBOT_RUNNER_TOKEN, constant-time checked in the runner (peterbot/sandbox_runner.py). Reachable only fromvirbr-ctl: the port is bound to.241.2, the guest INPUT wall drops.240/24, andvirbr-ctlis host-only (no external L2). Token compromise alone is not enough without also being on that segment. mTLS via a tunnel is later hardening, not required for pilot. - Worker → gateway broker (
POST http://192.168.240.2:8770/internal/...): per-job capability token from/run/secrets/worker_token, bound server-side to the job — identical to the P910 compose design. The guest DNATs.240.2:8770 → 192.168.241.1:8770and MASQUERADEs the source (P910 has no route back into192.168.240.0/24); the FORWARD chain allows only192.168.240.0/24 → 192.168.241.1:8770and drops everything else (internet, runner, guest hosts, cloud metadata, spoofed sources —deploy/vm/peterbot-vm-firewall.sh).br_netfilteris enabled or bridged frames would bypass that wall entirely; verify asserts it. - Runner → worker: docker API over the guest-local socket. P910's
socket is never visible in the guest, let alone the worker. The runner
supervises by container name over the socket — it shares no L2 with workers
(
peterbot_controlvspbworkers), so a compromised runner cannot even reach a worker IP; the wall independently denies worker→runner. - Worker → guest/runner services: blocked twice. The
pbworkersbridge is--internal, ICC-off, Docker-masquerade-off (created EXTERNALLY bypeterbot-worker-vm-setup.shso names/subnets never drift); the only egress path is the reviewed firewall's DNAT. An INPUT chain drops.240/24from the guest stack, so the published runner port on.241.2is also unreachable from a worker (its DNAT'd destination would be local; INPUT kills it). - Worker → anything else: denied (
--dns 127.0.0.1, no host mounts, cap-drop ALL, guest egress wall). DNS for model calls never happens worker-side; the gateway proxies inference.
A compromised worker can speak capability-authenticated broker protocol to the gateway and nothing else.
Built on the trusted build host from pinned inputs: Debian base digest, pip/npm
pins, Hermes v2026.9.11@939e45c…, and Rust 1.98.1 with per-arch SHA-256 from
the static.rust-lang.org/dist/2026-09-03 channel manifest
(docker/Dockerfile.hermes-worker header; the build verifies each tarball with
sha256sum -c before extraction and fails closed). Transfer is
docker save → SSH → digest compare → docker load
(deploy/vm/peterbot-vm-transfer.sh); the guest holds no registry credentials.
Tag images with the Git revision and record docker image inspect digests in
the deployment log.
All P910-side steps run as the operator user (member of libvirt/kvm/
docker — virsh/qemu actions are performed by libvirtd; no sudo, no root
login). One idempotent script; it refuses to run unless KVM,
qemu:///system, the default network and an operator key are present, and
touches only peterbot-worker* assets in $PETERBOT_VM_DIR
(default /mnt/NVME/docker/appdata/peterbot/vm, group-kvm for the qemu
runtime uid) plus virbr-ctl.
- Operator key (auto-generated if absent, private half never printed):
ssh-keygen -t ed25519 -f ~/.ssh/peterbot_vm -N '' - Provision the guest network and domain (operator user, from a repo checkout):
It verifies the pinned Debian 12 generic-cloud qcow2 (
sh deploy/vm/peterbot-worker-vm-provision.sh
20260909-2596, SHA-512 pinned in the script,sha512sum -cbefore use), defines/startsvirbr-ctlfromdeploy/vm/virbr-ctl.xml, copies the base to a dedicated 60 GiB qcow2 (grows only; no backing-file coupling), builds a NoCloud seed (user-data + meta-data + a SEPARATE network-config file — the NoCloud contract; viacloud-localds -N, mkisofs-equivalent fallback), pins both vNIC MACs in$VM_DIR/peterbot-worker.state(network-config matches BY MAC: vNIC2 → static.241.2, vNIC1 → DHCP for setup only), stages the guest payload to/opt/peterbot, and defines/starts/autostarts the domain. The VM directory is setgidkvmso both the operator and the guest QEMU process can access the disk without sudo; the seed explicitly permits key-only root SSH inside this dedicated guest. Re-running converges (fresh instance-id re-runs first-boot setup; all stages idempotent). - At the final gateway cutover, once
virbr-ctlowns192.168.241.1and the candidate image/configuration are staged, publish the broker on that address only (VM gateway overlay, removing the old host runner dependency):Until this is applied the broker alias has no live target; the guest smoke reports that honestly (SKIP gate). Docker cannot binddocker compose -f compose.hermes.yml -f deploy/vm/compose.hermes-vm-gateway.yml up -d --remove-orphans peterbot
.241.1before the network exists. - First boot (~2–4 min on NAT): cloud-init runs
peterbot-worker-vm-setup.sh— apt docker-ce and docker-compose-plugin (Docker's signed repo), sshd key-only hardening,ip_forward+br_netfilterpersistence (deliberately NOTip_nonlocal_bind), the external networks (peterbot_workersbridgepbworkersinternal/ICC-off/masq-off with.240.2reserved as an auxiliary address and workers in.128/25;peterbot_controlbridgectl0— routable because the runner's published port needs the DNAT path; workers NEVER join it), the egress-wall systemd unit (pre-creates the bare bridge so the broker alias answers ARP before dockerd starts) plus a Docker restart hook that reasserts firewall rule order, docker + qemu-guest-agent enabled, andpython3-aiohttp/python3-urllib3for the in-guest smoke. Log: guest/var/log/peterbot-setup.log. - Stage the candidate runner/worker images on P910 first (build on P910 or
docker save | ssh p910 docker loadfrom the trusted build host), then transfer those exact tags into the guest:deploy/vm/peterbot-vm-transfer.sh \ peterbot-hermes-runner:REV peterbot-hermes-worker:REV
- Deploy the stack (guest): write a root-only
/opt/peterbot/.envwithPETERBOT_RUNNER_IMAGE,PETERBOT_WORKER_IMAGE(the transferred tags) andPETERBOT_RUNNER_TOKEN(the same protected token used by the gateway, never printed or committed), thenThe wall unit installs before docker at boot; the reload re-asserts rule order now that dockerd has had its own pass over the chains.ssh -i ~/.ssh/peterbot_vm root@192.168.241.2 \ 'cd /opt/peterbot && docker compose -f deploy/vm/compose.hermes-vm.yml --env-file .env up -d \ && systemctl reload peterbot-vm-firewall'
- Gateway config (handoff, gateway agent): set
runner_urltohttp://192.168.241.2:8780in the gateway's hermes config + samePETERBOT_RUNNER_TOKEN. No code change;hermes_gatewayalready calls this API throughhermes_settings. - Verify:
- guest posture:
ssh -i ~/.ssh/peterbot_vm root@192.168.241.2 'sh /opt/peterbot/deploy/vm/peterbot-worker-vm-verify.sh'(asserts wall rule position before Docker's rules, br_netfilter, alias ARP, DNAT target.241.1, INPUT wall, both external networks with pinned bridge options and internal/routable posture,ip_nonlocal_bind=0, runner health on.241.2only and bound to it). - end-to-end: INSIDE the guest (it drives the guest-local docker socket; the
transfer script stages
peterbot/,deploy/,tests/fixtures/edigitsand the Hermes fixture at/opt/peterbot). Find the runner's container IP on ctl0 first (docker inspect peterbot-hermes-runner -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'), then:It compiles + runs the Rust e-digits fixture inside the real restricted worker, compares against the Python decimal reference, re-runs the pinned Hermes runtime fixture and the in-container isolation probes (broker 401 included —ssh -i ~/.ssh/peterbot_vm root@192.168.241.2 \ 'cd /opt/peterbot && PETERBOT_SMOKE_TOKEN=*** \ python3 deploy/smoke_rust_worker.py \ --image peterbot-hermes-worker:REV \ --gateway-host 192.168.240.2 \ --runner-probe <runner-ctl0-ip>:8780 --runner-probe 192.168.241.2:8780'
--gateway-hostproves the full DNAT→P910→container path), and proves the runner is unreachable from the worker. A SKIP only exits 0 with--expect-no-gatewayexplicitly declared; otherwise it fails the run. - package cache proof on the final VM image (same restricted
worker_args, one disposable worker, no broker needed yet):python3 deploy/smoke_package_worker.py --image peterbot-hermes-worker:REV. It installs and imports a pinned wheel offline, builds and runs a pinned Rust crate offline, checks the cache is read-only, and cleans up the worker. - isolation sweep from the gateway container's network (env-only):
PETERBOT_ISOLATION_GATEWAY_HOST=192.168.240.2,PETERBOT_ISOLATION_RUNNER_HOST=192.168.241.2,PETERBOT_ISOLATION_HOST_GATEWAY=192.168.241.1,PETERBOT_ISOLATION_INFERENCE_HOST=100.73.210.66,PETERBOT_ISOLATION_P910_HOST=100.99.6.59. - P910 host gate: UFW INPUT defaults to DROP. Its narrow persistent rule
accepts only
192.168.241.2onvirbr-ctlto192.168.241.1:8770. Docker publishes that port by DNAT into the gateway container, so those packets instead reach FORWARD/DOCKER-USER;deploy/hermes-firewall.shalso inserts a narrow allow keyed to the same guest source, interface, and conntrack original host destination/port before the host's final Docker drop. The systemd unit reapplies it on Docker restart. The real-gateway smoke then proved 31/31 checks including broker 401.
- guest posture:
Posture B (no internet at all in the guest): after first boot,
virsh detach-device peterbot-worker <vnic1.xml> and remove the NAT interface
from netplan; package updates then need a re-attach window. The wall already
gives workers no internet either way; this only narrows the guest OS itself.
virsh autostart+ guest systemd: firewall unit (installsBefore=docker, pre-creates the barepbworkersbridge + alias so the broker path works even before any worker started — dockerd must adopt the matching bridge); a DockerExecStartPosthook moves the wall to rule 1 after every daemon restart. Docker and the runner compose (restart: unless-stopped) come back; the runner clears stale worker containers at startup.- Run
peterbot-worker-vm-verify.shafter every reboot: unit active, wall first in FORWARD, br_netfilter on, alias present, runner/healthok. - Gateway side: runner
/healthfailing keeps Peter degraded; no state lost.
Guest 6 vCPU / 12 GiB covers the build profile (4 CPU, 4 GiB, 2 GiB + 512 MiB
tmpfs) with runner+OS headroom; pids_limit ceilings stay, memory-swap equals
memory (no swap escape). One worker at a time — a second task is refused by the
gateway's single foreground slot, not dropped by the runner.
- Gateway:
runner_urlback to the P910-local runner and re-apply compose WITHOUT the overlay (docker compose -f compose.hermes.yml up -d peterbot— removes the 8770 publish; the pilot runner container was never removed) → Peter keeps working on-host. - Guest:
docker compose -f deploy/vm/compose.hermes-vm.yml down;virsh shutdown peterbot-worker(graceful) orvirsh destroy(hard). virsh net-destroy/net-undefine virbr-ctl; delete disks only after incident review. The desktop VM and P910 state are untouched at every step; no P910 data ever lived in the guest.
- No unrestricted internet for workers or persisted member projects — the pinned offline toolchain covers dependency-free Rust; dependency access is a later slice (proxy/allowlist design), as is per-officer project storage.
- Member-facing execution stays behind the existing officer-only gate; this VM work changes where workers run, not who may invoke Peter.
- mTLS gateway↔runner and a guest-side read-only artifact cache are future hardening, not part of this recipe.