Add the microvm.nix host runtime with per-VM isolation #24
No reviewers
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
allod/nexus!24
Loading…
Reference in a new issue
No description provided.
Delete branch "agent/nexus-microvm-host"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Milestone 3 of the microvm.nix framework adoption arc: the
allod/nexushost runtime, its per-start root launcher, and the per-VM isolation nexus#23 asks for. Contracts 8, 8a, 16, 16a, 16b, 21 and 21a, plus the host-side halves of 13 (the launcher's volume preflight) and 17 (host and guest declarations agree before boot).Closes allod/nexus#23
Refs allod/strategy#20
What this adds
nixosModules.microvmHostcomposes the pinned upstream microvm.nix host module, taken fromallod/vm's re-export, with the policy around it. It is separate fromnixosModules.host, so a libvirt-only host does not acquire microvm.nix by importing the baseline. Nexus has no microvm input of its own andallod/vm's lock holds exactly one microvm node, so host and guest modules can only come from one upstream revision (contract 1a).The interesting part is what happens between systemd starting
microvm@<name>.serviceand QEMU running. Upstream execs the runner directly as one sharedmicrovm:kvmprincipal. This replaces that with one root launch helper per VM, generated bynix/microvm/launcher.nix, which proves the plaintext root is the bounded non-swappable root-only tmpfs it claims to be, preflights every declared volume image, prepares that VM's credential directory atomically from a root-only staging directory, removes and recreates its QMP working directory, builds a private mount namespace exposing only its own declared objects, and performs the single uid/gid drop to that VM's ownmicrovm-<name>principal. Removal is one idempotent rootExecStopPostappended after every upstream post-stop command.Two host-wide effects of importing the upstream module are pinned rather than inherited (contract 21a):
hardware.ksm.enablestays false, because QEMU marks guest RAM mergeable and an arc whose isolation boundary is about what one guest can learn about a sibling does not acquire a cross-guest memory-deduplication side channel by import; and the state directory rule is tightened from upstream's0775 microvm:kvm, under which every runner principal — all of them holdkvm— could rename or delete a sibling's whole state entry, including thebootedsymlink the root unit executes at stop.docs/microvm-host.mddocuments the options, the generated runtime layout, the start sequence, and what a guest has to satisfy.How the isolation fixture is built, and the two things worth a reviewer's attention
The attack matrix is 32 cases per run, each declared with the control that is supposed to deny it rather than with a hand-written expected outcome:
ownandown-jailedfor the access a unit must retain,own-denyfor what it must not be able to do to its own objects,parentfor what a root-only source parent or the target's own ownership denies in every mode,namespacefor what the mount namespace is the control for,procfsfor what only the distinct principal closes, andattachfor/proc/<pid>/mem, the one member of that family Yama covers. The expectation is derived from the family and the mode, so a case cannot quietly be credited to whichever control happens to be holding.The
/proc/<sibling-pid>/rootvector is what nexus#23 is actually about, and the fixture is built so it cannot be narrowed away. A private mount namespace alone does not close it: that path resolves inside the target's own root, so it never traverses the root-only source parents that deny the direct path, and at the pinned kernel the open is gated only by the same-credential comparison in__ptrace_may_access. The distinct per-VM principal is the control.microvm-host-isolationproves this both ways — the production node denies it, and a node that collapses both guests onto one principal with the namespace left intact reopens sibling credential reads, QMP directory listing and file creation, socket unlink, image reads and signal delivery.The namespace turned out to be load-bearing for function, not only for isolation. The launcher creates every source parent root-only, so with the namespace removed a runner cannot reach its own credential directory or its own QMP directory either: QEMU never starts. The
nonamespacenode pins that measured outcome. Because a broken VM cannot be attacked, the "which attacks is the namespace the control for" question is answered non-destructively instead, by running the same attack matrix twice on the production node from the same principal — once inside the runner's mount namespace and once outside it. Every case declares which control is responsible for it, so a case cannot quietly be credited to the wrong one: sibling credentials, QMP directories, sockets and images stay denied by the root-only parents and by ownership in both runs, while the sibling's state-directory entries,bootedsymlink and volume directory reopen without the namespace.Risk
R3, unchanged from the plan's assessment for this milestone. This adds generated host units around plaintext credentials, writable images, namespaces and one runner principal per VM. No public PR in this arc touches a real host, mutates deployed state or handles real plaintext.
Residual risk after this validation is concentrated in three places:
microvmcommand upstream installs for every account on the host.microvm -r <name>execs the runner with no unit, so with no prepared credentials, no namespace and no privilege drop. It fails closed only because theactive/<name>directory the runner's-fw_cfgarguments name exists solely while the unit runs, which is what the post-stop hook guarantees. That is an argument, not a wall.rollback/<name>slot's lifecycle land in milestone 5; this PR only guarantees the post-stop hook never removes that slot, which the isolation test asserts.Validation
All three ran green on x86_64-linux at this revision:
nix flake checkreportsThe check omitted these incompatible systems: aarch64-linux, which is Nix's own default for a flake that exposes a second host system; the aarch64 attributes still evaluate.The nested test earned its keep before it ever ran an attack: the namespace stage was creating its
currentsymlink at the bare/var/lib/microvms/<name>/currentrather than inside the namespace root, so it rewrote the host's own state entry and left the jail with no exec target. Every static check passed on that launcher — the string the unit executes was right, and the bind set was right — and only the boot saidsetpriv: failed to execute /var/lib/microvms/vma/current/bin/microvm-run. Fixed in1bba327, with a static guard added alongside it: every path the namespace stage writes must carry the namespace root as a prefix.microvm-host-contractreads rendered artefacts only — the template unit, the per-VM drop-in, the mount unit fragment, the generated launcher/namespace/cleanup/tap shell, the built runner, the tmpfiles rule, the active-system descriptor — and extracts each script from the unit that names it, so anExecStartpointing at the wrong script fails there rather than being inspected as if it were the right one. Two independently evaluated integration fixtures use the default and a moved host plaintext root; the moved one has to move the mount unit, the descriptor, the launcher arguments, every exactcredentialFilesvalue and the built runner's-fw_cfg file=arguments, and no artefact may retain the old default. Namespace contents and post-stop removal are asserted as set equality rather than as grep lists, because a path that is a prefix of a declared one cannot be forbidden by substring.26 mutation fixtures pair with the module's assertions, each classified by the mechanism that rejected it: a named assertion with a required diagnostic substring, or the option type refusing the value before an assertion could report it. A mutation that aborts evaluation instead of tripping its own diagnostic is reported as a failure rather than read as a pass —
tryEvalcatches assertions and throws but notabort, and a diagnostic computed but never asserted on would otherwise pass silently.microvm-host-isolationboots four nodes over two real guests. The attack matrix ran four times — from each of the two production runners inside its own namespace, from one of them in the host mount namespace, and from the collapsed shared principal — for 128 case outcomes, every one matching the control its family names, with the node'skernel.yama.ptrace_scoperecorded as 1 in each run. Beyond that it asserts, on a running host: the resolvedRequires=on the mount unit fragment and that a failing mount unit leaves the VM unstarted; the observed runner and QEMU uid, gid and supplementary groups, with no QEMU-run-withargument; that no credential byte appears in argv, the environment or the journal; prepared directory ownership and modes; per-VM TAP ownership; that a leftover QMP directory is cleared and logged and comes back as a different inode; that post-stop removes the credential, staging and QMP directories while the volume image, the rollback slot and the sibling's prepared state survive; that a missing volume image is refused before QEMU; and that the launcher rejects an absent mount,ramfs, a swappable tmpfs, an oversized tmpfs and a mode-0755 tmpfs before writing anything, with the ancestor-only--targetmatch demonstrated to be the thing that would have passed.Not in this PR
Contracts 9 to 15 and 8b are guest-side and archetypes-side (milestone 4). Contracts 18, 19 and 20 and nexus#21/#22 are milestone 5. The
vminput still pins the unmergedagent/vm-microvm-guest-splitbranch and must move to the mergedmasterrevision when allod/vm#5 lands.The rotation-tool half of contract 8a's mount preflight — the same checks in
vm-ssh-host-keyandforge-ssh-key, which run outside any unit — is deliberately absent: those tools change in milestone 5, and the preflight belongs with that change rather than as a second implementation here.Reviewing model
claude-fable-5, reviewed commit5615d7e, scope covered:allod/nexusPR #24 (milestone 3) — contracts 8, 8a, 16, 16a, 16b, 21, 21a, and the host-side halves of 13 (launcher volume preflight) and 17 (host/guest agreement). Read against pinned microvm.nix39a499ab(/nix/store/yyrsgw6gs5v1qnfn839mrn6mmj91zwfw-source) and pinned nixpkgsb6018f87. Files:flake.nix,nix/microvm/host.nix,nix/microvm/launcher.nix,checks/microvm/{default,fixtures,host-contract,isolation}.nix, and the docs/README diff; upstreamnixos-modules/host/default.nix,lib/runner.nix,lib/runners/qemu.nix,lib/volumes.nix,nixos-modules/host/options.nix. I did not re-run either named check; both are reported green at this revision and I verified by static reading and targetedgrep/lock inspection rather than rebuilding.Findings
No BLOCKERs.
No GAPs.
nexus.microvm.machines.<name>.volumeImagesis a duplicate source of truth the launcher never reads; delete it. Innix/microvm/host.nixthe launcher is fed volumes from the guest —perVmline 88volumes = guest.microvm.volumes;and line 96volumes = map (v: { inherit (v) image readOnly; }) volumes;— and it is the guest-declared images the launcher preflights (launcher.nix267-273,test -e/test -f/volume_probe). The host optionvolumeImages(host.nix 381-390) has exactly one consumer: the assertion at host.nix 302vm.machine.volumeImages == vm.guestVolumeImages, which compares it against that same guest declaration. I confirmed by grep there is no other reader innix/orchecks/. ContrastcredentialSources, which is genuinely host-owned and stays: it is the private source path the launcher installs plaintext from (host.nix 95 -> launcher.nix 284-291), a distinct fact from the guest'scredentialFilesdestination (derived fromhostPlaintextRoot). A volume image has no such source/destination split — in microvm.nix the host image path can only live inguest.microvm.volumes[].image, because that is the string QEMU renders into-drive file=(lib/runners/qemu.nix275-279), and the launcher reads the same field. Somachines.<name>.volumeImagesrestates a fact that already has one authoritative home, which is the architecture principle-8 violation ("duplicating a fact is a bug even when copying is convenient"). It resolves one of two bad ways when archetypes wires it in milestone 4 (the PR body confirms it is "not wired yet"): either the deployer types the private image path in two places kept in sync only by this assertion — error-prone duplication the launcher ignores — or archetypes derives the guest image from the host option viaextendModulesthe way it derivescredentialFiles, in which casehost == guestis a tautology that can never fail and thehost-and-guest-volume-disagreementmutation is unreachable in production. What to do: dropvolumeImagesfrom themachinessubmodule, dropguestVolumeImagesand the line-302 assertion (and itshost-and-guest-volume-disagreementfixture), and letguest.microvm.volumesbe the single declaration the launcher already preflights. Contract 17's volume clause then reduces to "the launcher preflights exactly the images the guest attaches," which is structurally true because it reads them off the guest — so the plan's contract 17 wording should be trimmed to match. This is a plan-shaped simplification as much as a code one; flag it to whoever owns the milestone-4 archetypes wiring so they do not build the duplication in.No QUESTIONs.
What is right, and should not be undone
The isolation fixture is honest, and its honesty is structural rather than incidental.
checks/microvm/isolation.nixassigns every one of the 32 probe cases afamilyand derives the pass/fail expectation fromfamily × modein oneexpectation()function (lines 139-153), so a case cannot be quietly credited with whatever outcome it happens to produce. I walked each case against what actually denies it at the pinned kernel and the family assignments hold:parentcases are denied by the root-only0700source parents (${root}/active,${qmpRoot}) or by0600/0400target ownership in every mode;namespacecases (sibling runner, bothbootedsymlinks, state-dir and sibling-volume-dir enumeration) reopen only in the host mount namespace;procfscases reopen only under the collapsed principal; andattach(/proc/<pid>/mem) stays denied even under one principal because it is the onePTRACE_MODE_ATTACHopen Yama covers. Running the same matrix twice on the production node — inside the runner's namespace and in the host namespace — to attribute the namespace non-destructively, rather than trying to attack an already-broken VM, is the right call, and thenonamespace/shared/mountfailsabotage nodes each reopen exactly the vector they name. I checked thatsharedis production-identical except the principal:sabotagedLauncherreconstructs the samecredentialDirectory,qmpDirectory,jailRootand volume set the host module derives, differing only inprincipal = "microvm-shared". Do not narrow the procfs cases to direct-path checks — nexus#23's second comment is explicit that this is the specific way to close the issue with the boundary still open.The assertion/fixture pairing in
host.nix↔fixtures.nixis complete and non-vacuous. Every assertion I enumerated (two global path/overlap checks, the undeclared-machine and duplicate-QMP/duplicate-image checks, and the per-VM socket, QMP-location,user,vsock.cid, credential-name-set, exact-credential-path, credential-source, and four volume checks) has a mutation that trips precisely it, and the 26 mutations exceed the check's>= 24floor. The three-wayprobeclassification (fixtures.nix273-296) genuinely closes thetryEvalblind spot the brief warned about: anabortescapingtryEvaltakes the whole check down at eval time rather than reading as a pass, a type-rejected value is required to evaluate tofalse, and an assertion mutation that computes a diagnostic but never trips is caught as "no assertion failed." The deliberate omission of an overlong-VM-name fixture, with the reasoning recorded at host.nix 229-235 and fixtures.nix 474-477 (nixpkgs' rawbuiltins.aborton a >31-char group name cannot be paired), is the correct call, not a hole.The launcher/upstream-unit interaction is correct across the lifecycle. The QEMU runner exposes no
supportsNotifySocket(lib/runner.nix24 ->or false) anduseNotifySocketsdefaults false, so the unit isType=simpleand the launcher'spivot_rootcannot strand a filesystemNOTIFY_SOCKET— a real trap the design avoids by inheritance, worth keeping in mind if a future runner sets notify.ExecStopis left as upstream'sbooted/bin/microvm-shutdownand now runs as root in the host namespace, reaching the QMP socket QEMU created becauseqmpDirectoryis bind-mounted into the jail at its identical absolute path (contract 8 / the pass-3 fix);shutdownCommandblocks oncatuntil QEMU exits, so themkAfterExecStopPostcleanup only removes the QMP directory after the socket is dead. TheExecStopPostordering (upstreammicrovm-unregisterfrom the template, then nexus cleanup from the instance drop-in) is the resolved systemd order, checked both statically and on the booted host. The state-directory tightening to0755 root:rootdoes not breakinstall-microvm-@(root) ormicrovm-set-booted@(writes inside${stateDir}/%i, stillmicrovm:kvm), and it does close a runner principal's ability to rewrite thebootedsymlink the root unit executes.WorkingDirectorystays upstream's state dir but the jail'scd ${jailRoot}/pivot_root/cd /discards it, verified against/proc/<pid>/cwdand/proc/<pid>/root/.../bootedin the test.Contract 21a is bounded exactly as the plan pins it:
hardware.ksm.enable = falseneutralizes themem-merge=onthatlib/runners/qemu.nixline 85 sets unconditionally on x86_64, themicrovmstate-package/bridge.conf/bridge-helper/libvirt surface is asserted unchanged, and the two inert upstream additions (boot.kernelModules, thememlockPAM grant with noPAMName) are left alone. The namespace is genuinely load-bearing for function — every source parent is created0700 root:root, sobind_file/bind_direxposing only the leaf is what makes a runner reach its own credential and QMP objects without making a sibling's reachable — and thenonamespacenode pins that measured outcome so a later change cannot silently turn the namespace into mere hardening. ThecredentialSourcessplit, thestrMatching "^/run/allod/..."option type doing the "never a Nix path, never outside /run/allod" half of contract 8a before any assertion, and thesize=16384kreported-form match between the mount unit and the launcher preflight are all correct and should survive later passes.One non-finding worth stating for the next reader: the
vminput still pins the unmergedagent/vm-microvm-guest-splitbranch (flake.lockrev783bf6a), which the PR body discloses and which must move to the merged revision when allod/vm#5 lands — the normal milestone-2-before-3 sequencing gate, not a defect in this diff. The lock holds exactly onemicrovmnode at39a499aband nomicrovminput at the nexus root, so the nexus half of contract 1a holds.The SIMPLIFY is implemented in
16bd9c7.nexus.microvm.machines.<name>.volumeImages, theguestVolumeImagesderivation, the equality assertion and thehost-and-guest-volume-disagreementmutation are gone; themachinessubmodule now carriescredentialSourcesonly, with a comment recording why there is no placement option beside it.docs/microvm-host.mdsays the same. 25 mutation fixtures remain, still above the check's floor.The argument that decided it, beyond the one in the review: a credential has a real source/destination split, so
credentialSourcesnames a fact that exists nowhere else, while a volume image does not — its host path can only live inguest.microvm.volumes[].imagebecause that is what QEMU renders into-drive file=and what the launcher preflights.One piece of evidence worth recording, because it settles the "is this option load-bearing" question better than reading can: removing it left
microvm-host-isolation's derivation byte-identical to the one that already passed — same.drv, same output path — so the option demonstrably fed no generated artefact.microvm-host-contractrebuilt (its mutation set changed) and is green.The plan said this in two places, so it has been trimmed to match through the normal change process rather than left to contradict the code: allod/strategy#23 drops the volume clause from contract 17, drops "writable-volume placement" from the nexus scope bullet, and rewrites the persistent-state ownership bullet to say where placement actually enters. That PR also re-attributes the namespace sabotage to what implementation measured — removing the namespace does not reopen the direct-path attacks, it stops the VM starting at all — which is the case the plan reserves for a measurement that contradicts it. Whoever wires archetypes in milestone 4 should read both changes before deciding how image paths reach the guest.
The
vminput now points atallod/vmmaster, which is where milestone 2 landed.1f96145changes the URL to drop the branch ref; the lock moves from the branch head to the same revision on master, because the merge was a fast-forward and the revision is unchanged.Re-verified at the new pin, not assumed:
nix flake checkgreen.microvm-host-contractgreen.microvm-host-isolationgreen — four probe runs, 32 cases each, zero unexpected outcomes, including the shared-principal node where the procfs attacks must reopen.microvmnode, which is what contract 1a requires of this repo.That clears the last thing this PR was waiting on.