# mvirt 0.12.1

migrations: no
wire: unchanged
rev: 62f94f5fafce9ff39bad3c4e0399944c4ecc4b53

v0.12.1 — the security audit's first lane of fixes, and the console's revised shape

A patch in the full sense ADR-0069 D9 gives the word: nothing here registers a migration or moves the
storage wire, and no entity, proto or image pin moved either — so it is takeable from 0.12.0 by every
installation, and undoable by booting the previous generation. What it carries is lane A of the
security audit, whose findings 0.12.0 only documented, and the ADR-0077 console revisions that were
still open when 0.12.0 was cut.

Security

- **Same-host vNIC delivery enforces the destination's inbound security group** (#150). The same-host
  fast path (`RouteEntry` ACTION_REDIRECT) delivered tap_a→tap_b applying only the SOURCE's egress
  rules: inbound evaluation lives in `tc_ingress`, which is attached to `vxlan0` alone, so two guests
  on one host reached each other on rules nobody wrote. `tc_egress` no longer delivers inline — it
  tail-calls `sg_inbound_deliver` through a new `EGRESS_PROGS` jump table (an inline second rule scan
  blows the verifier's 1M-instruction budget), which re-runs the destination's inbound rules and the
  shared conntrack exactly as `tc_ingress` does before the L2 rewrite. An empty jump slot fails
  CLOSED. A verifier fix rides along: `csum16`'s data-dependent carry-fold loop, whose termination
  the verifier could not prove, becomes two unconditional folds, exact under 128 KiB.
- **The reverse tunnel fails closed without its mTLS terminator** (#127). When the terminator could
  not be built — a multi-voter bootstrap race, a restore without the CA — the accept loop served
  unauthenticated plain TCP with self-asserted node ids, while the log claimed the acceptor "stays
  down". It now refuses to accept at all: the listener is dropped and dialing nodes see a closed
  port, while raft voting and replication keep running.
- **The session signing secret is per-installation, never compiled in** (#135). `MVIRT_SESSION_SECRET`
  when set, otherwise generated from the system RNG on first boot into `<data dir>/session.key`, 0600
  from creation and never clobbered. The pre-fix demo secret shipped inside every released binary —
  anyone holding it could mint admin sessions — and is now refused by blake3 fingerprint, so the
  literal is gone from the binary and a host configured with it refuses to sign rather than starting
  quietly.
- **The cross-project IDORs in the vm/nic/volume creates are closed** (#136, #137, #138). `vm.create`
  validates `spec.volumeId` against the caller's project, turning a cross-tenant disk read through
  the slice compiler into a 400; a vNIC may only bind a network in its owner's project, re-checked at
  persist time; volume `PATCH vmId` checks the target VM's project before planned attachment.
- **The route reflector scopes every announcement to what the node owns** (#151). Network
  reachability only from nodes hosting a member, default routes only from border tiers, `SG_MEMBER`
  only for addresses the node hosts that actually carry the group, `LB_TARGET` only for the node's
  own backends of the announced LB. Fail-closed on store errors and unknown hop types; withdrawals
  stay unvalidated, since removal is always safe.
- **Console tickets are single-use, and the grant is re-checked at attach** (#154). Each ticket
  carries a `jti` consumed at attach, so a replayed ticket — out of a proxy access log — is worthless
  after its first use; the attach path re-runs `require(vm.console)`, so a revocation or account
  deletion inside the 30s TTL takes effect immediately instead of at expiry.
- **Key material is 0600 from creation, data dirs 0700** (#132, #133) — no write-then-chmod window on
  the KEK, the raft leaf key, backup artifacts, the session key, mvdb's redb pair, or the node's
  onboarding key and agent state.
- **Audit seams on every mutating route** (#143): IAM users/SAs/API keys, password reset, org and
  project tenancy, httplb, loadbalancer, service, backup, checkpoint, ippool, ipprefix, nodegroup,
  oidc, onboarding, registry, storage and systemapp now emit Denied/Success/Error rows, with
  route-table-versus-audit-presence coverage so a new route cannot quietly skip it.
- **The firewall matches the daemons' actual binds** (#124). mvirt-vmm (`[::1]:50051`) and mvirt-ebpf
  (`[::1]:50054`) are loopback-only and reached exclusively through the reverse-tunnel proxy, so
  opening their ports was pure exposure surface; they are gone from the control-plane host, the node
  and hypervisor images, and the bare-metal fabric lists. `mvirt-stor-agentd`'s listen default moves
  to `[::1]:50053` — it was `127.0.0.1:50055`, colliding with the cplane bootstrap port on a
  colocated host and contradicting every wiring around it (#130).

Console (ADR-0077)

- Routes and SG membership fold into one **Route Mesh** view. Both were standalone pages rendering
  the same thing — the route reflector's live soft state — and are now two tabs on `/platform/mesh`;
  the old paths redirect to their tab **with the query string intact**, so M3b deep links and
  bookmarks survive.
- Deployments live in Settings; the Platform Services rail group is gone. What distinguishes a
  deployment from a plain page is no longer where it sits but what it carries: the D4 skeleton and a
  live health badge, which Catalog never gets.
- Settings pages and project detail pages read as **one card of ruled blocks** rather than a stack of
  bubbles — a new `Block` primitive (a Section without the card chrome). Lists, tables and toolbars
  keep their own cards.
- The cp/raft badge appears only on nodes whose group carries the control-plane task, and the
  Control-plane facet counts only those nodes — a node in a group without `controlPlane=true` runs no
  replica by design and was being badged "cp inactive" for it.

Also

- `platform.yaml` splits the default and compute node groups, so the control-plane host borders while
  VMs run on the compute tier. `platform.yaml` is inert once a platform is initialized: this reaches
  new installations only, not a running fleet.
- ADR-0078 records **mvirt-smoke**, a standalone binary that drives the public REST API in the order a
  user meets the platform, and strikes ADR-0077 D6/M5 in its favour. Documentation only — the binary
  is not in this release.
- Security audit track 07 (the guest-VM perspective on the storage plane) completes the track set.

Upgrade

Control-plane host first, then nodes one at a time. `migrations: no`, so a control-plane host rolls
back the ordinary way — by booting the previous generation — and no store restore is involved.

Two things change behaviour on the way in, both deliberately:

- **Everyone is logged out once.** The compiled-in session secret is refused, so the control plane
  mints a fresh per-installation key on first boot and every outstanding token stops verifying. In a
  multi-voter group that key is per host, so a leader change logs sessions out again until #170 is
  fixed; setting `MVIRT_SESSION_SECRET` (base64, 32 bytes) to one value on every member avoids both.
  A host whose environment still carries the old demo value refuses to start, loudly and on purpose.
- **Same-host guest-to-guest traffic now obeys the destination's inbound rules.** Anything that
  worked only because the fast path skipped them will stop working at the reboot. Check the inbound
  security groups of workloads that talk to each other on one host before the window, not after.


## Upgrading

Point your flake's `mvirt` input at this release and redeploy the control-plane
host first, then each node in turn:

```
nix run .#colmena -- apply boot --reboot --on <control-plane host>
nix run .#colmena -- apply boot --reboot --on <node>   # one at a time
```

This release adds no migrations, so the control-plane host rolls back the same way
a node does: boot the previous NixOS generation.
