Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 47 additions & 13 deletions nvx-rs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ changes only when the module's purpose does.
| [`nvx::Hypervisor`](#nvxhypervisor) | KVM, MSHV, or WHP, with its serde form behind the `serde` feature |
| [`nvx::exec`](#nvxexec) | The execution engine: the streams, credit, and outcome of an execution's two sides, without I/O, for any framing |
| [`nvx::host::runtime`](#nvxhostruntime) | `Runtime`: what every launch shares, the runtime's files and their identity, the hypervisor and its probe, and the launch options |
| [`nvx::host::vm`](#nvxhostvm) | A private VM directory, cold and capture preparation, owned cold-launch command, verified detached launch, confirmed stop, and caller-saved reattachment |
| [`nvx::host::vm`](#nvxhostvm) | A private VM directory, cold, capture and restore preparation, owned launch commands, verified detached launch, confirmed stop, and caller-saved reattachment |
| [`nvx::host::snapshot`](#nvxhostsnapshot) | Strict private snapshots, post-exit capture finishing, verified metadata, VM compatibility, captured NIC addresses, and time eligibility |
| [`nvx::host::config`](#nvxhostconfig) | `VmConfig` and `CaptureConfig`: pure configuration, cold-boot kernel/OpenVMM arguments, portable length checks, and capture identity |
| [`nvx::host::restore`](#nvxhostrestore) | A caller-driven restore to Ready without I/O or a clock, and its peer-verified restore-ready listener |
Expand Down Expand Up @@ -513,13 +513,38 @@ failure never hides confirmed exit.

`vm::create(&runtime, &config, None, &dir)` creates an inspectable cold
`Prepared` launch without starting a process or binding a listener. It requires
`config.boot`, refuses a snapshot input, rechecks the directory's privacy, and
`config.boot`, rechecks the directory's privacy, and
creates a new private log. Existing log, outcome and Linux endpoint entries
are refused rather than replaced. An ordinary cold boot creates only the log
before a spawn; OpenVMM creates its remaining outputs when it runs. A failed preparation
closes and removes only its new files, preserving all pre-existing entries and
caller-owned inputs, and reports a cleanup failure with the original error.

With `Some(snapshot)`, creation refuses `boot`, checks `Snapshot::accepts`
at the real `HostTime::now`, and hard-links only the existing OpenVMM files
into a new `<vm_dir>/openvmm/`. A different filesystem fails; there is no
copy or snapshot-parent fallback. The restore's arguments follow the clone
launch's rules: the devices at the snapshot's processor capacity,
`--restore-network-policy` with the restore's own policy in place of `--net`
and the guest's address, which OpenVMM takes from the snapshot,
`--restore-absent-scratch` for an upper in RAM, `--restore-processors` only
when the snapshot records its boot processors, `--restore-memory`, the
console attachment that the snapshot allows, the snapshot's backend and CPU
profile, and the launch options, with no kernel, initramfs, command line or
`--memory`. Creation binds the restore-ready listener before any spawn and
retains that same owned listener through `Launched::ready` or
`VmmCommand::ready`, never in the child's inheritance allowlist.

On Linux, the caller holds its per-snapshot lock from creation, including
adding links, through a peer-verified restore-ready event or verified VM
exit, and around view removal. NVX takes no lock; Windows needs none.
A restore whose caller dies before OpenVMM connects the restore-ready
endpoint cannot complete: OpenVMM checks the snapshot files before that one
connection, which it does not retry, and the dead caller's endpoint no longer
listens. After the connection OpenVMM no longer checks those files, so no
durable in-flight restore record is needed; the caller keeps the VM's process
as for any launch. A successful view remains caller-owned.

With `boot.capture`, creation requires a blockless root and refuses every
filesystem export. It creates private sparse `capture-image.raw` and
`capture-scratch.raw` files, verifies their actual complete geometries, and
Expand Down Expand Up @@ -566,13 +591,14 @@ selects the network's presence and IPv4 and derived IPv6 gateways, while the
launch's policy supplies DNS, publication and proxy. An omitted limit takes the
record's maximum bound. It reads no host path and opens no snapshot. A
blockless root or memory that cannot fit the record's byte count fails with
`ErrorKind::Unsupported`. These projections do not enable the restore caller;
`create` still accepts cold launches only.
`ErrorKind::Unsupported`. A restore resolves these settings against the
captured NIC and its own requested policy.

`Prepared::into_command()` moves the complete launch into `VmmCommand`: its
program, arguments, working directory, capability, outputs, inherited resources,
opened log and Windows breakaway setting. It neither reopens nor duplicates
resources, renders again nor rediscovers paths. `InheritedHandle` owns its
opened log, Windows breakaway setting, and any already-bound ready listener.
It neither reopens nor duplicates resources, renders again nor rediscovers
paths. `InheritedHandle` owns its
resource, converts from and back to `OwnedFd` on Unix or `OwnedHandle` on Windows,
and also accepts an opened `File`. The command documents the caller's spawn
contract, including standard streams, allowlisted inheritance, Linux detachment
Expand All @@ -589,9 +615,17 @@ outcome with complete teardown. Build with
kernel, initramfs, new VM directory, and the three artifact SHA-256 digests from
their installation metadata, in that order. Artifact paths may be relative to
the caller's directory.
Append a snapshot directory and EROFS image to run its restore-ready
demonstration instead, using a snapshot captured with a disconnected boot
console, as `nvx-rs snapshot capture` captures it. It uses the public
peer-verified receiver, then intentionally stops and verifies the VM's exit
without driving guest activation. Linux callers of the example coordinate on
the snapshot's `key.bin` lock, taken before opening the snapshot and retained
through view removal.

`vm::spawn(prepared)` consumes that preparation and returns `Launched` with
its verified `process`, the same `outputs`, and the same `capability`. The log
its verified `process`, the same `outputs`, the same `capability`, and a
restore's same bound `ready` listener. The log
and inherited resources are passed as already opened objects, never reopened
by name. The capability reaches OpenVMM only on standard input, then
end-of-file, not in arguments, environment variables, or a file. A failure
Expand Down Expand Up @@ -673,9 +707,9 @@ the supplied `HostTime` is `Unavailable`.

The caller keeps the directory and every entry private and immutable while
using the snapshot; opening checks them once and retains no file handles
or lock. `Snapshot` remains an opaque input of a future restore:
`vm::create` still refuses `Some(snapshot)` with `ErrorKind::Unsupported`;
only cold boots are supported.
or lock. `vm::create(..., Some(snapshot), ...)` checks compatibility and
current-time eligibility, then creates the fixed hard-linked view and
binds the caller-owned ready listener described above.

`CaptureContext` records a prepared capture's checked configuration and
effective devices independently of runtime paths or network policy.
Expand Down Expand Up @@ -844,7 +878,7 @@ Start. Terminal results are offered once and absorb later inputs.

`ReadyListener` owns a restore-ready endpoint: a mode-0600 Unix socket in the
private VM directory on Linux, or a fresh first-instance named pipe with an
owner-only protected access control list on Windows. The library's receiver
owner-only protected access control list on Windows. Its public `receive`
checks the connecting client's process ID against the held VM process. On
Linux it also checks the socket's original peer through `SO_PEERPIDFD`, so
a reaped client's queued event cannot impersonate a VM that reuses its ID.
Expand All @@ -861,8 +895,8 @@ socket peer through `SO_PEERPIDFD`, refuses an exited peer, and matches its
credentials against the held VM process: opening a pidfd from the cached
numeric PID is not authentication. On Windows it checks the named-pipe client
process ID. After matching the peer, it checks `Process::has_exited` and
refuses an exited VM. Listener construction remains internal until restore
launch preparation supplies it.
refuses an exited VM. Only `vm::create` constructs a listener, for a restore,
and hands it over through `VmmCommand::ready` or `Launched::ready`.

The Linux PID-reuse regression runs a separate helper in private PID and mount
namespaces with Bash as its reaper. If the host refuses unprivileged user
Expand Down
123 changes: 114 additions & 9 deletions nvx-rs/examples/external-launch.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,41 @@
//! A caller-owned cold launch, using the strict low-level spawn primitive without a store.
//! A caller-owned launch, using the strict low-level spawn primitive without a store.
//!
//! Run the compiled example with a backend, OpenVMM, kernel, initramfs, new VM directory, and
//! the three artifact SHA-256 digests from their installation metadata, in that order.
//! Append SNAPSHOT-DIR IMAGE.erofs for a restore-ready demonstration: it verifies the ready
//! event, then intentionally stops and verifies the VM's exit without driving guest activation.
//! The snapshot must have captured a disconnected boot console, as `nvx-rs snapshot capture`
//! does.

use std::error::Error;
#[cfg(target_os = "linux")]
use std::fs::File;
use std::io::{self, Write};
use std::path::Path;
use std::time::{Duration, Instant};

use nvx::Hypervisor;
use nvx::activation::{MachineIdMode, View};
use nvx::host::config::{Boot, ConsoleAttachment, RootConfig, VmConfig};
use nvx::host::config::{
Boot, ConsoleAttachment, LayeredRoot, NetworkConfig, RootConfig, UpperConfig, VmConfig,
};
use nvx::host::network::{Access, NetworkPolicy};
use nvx::host::platform::Transport;
use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity};
use nvx::host::supervisor::{self, Inherited};
use nvx::host::{artifacts, report, session, vm};
use nvx::host::{artifacts, report, session, snapshot, vm};
use nvx::protocol::root::RootImage;

/// One byte past OpenVMM's 25-byte restore-ready event, so that a longer event fails to parse.
const READY_LIMIT: usize = 26;

fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<_> = std::env::args_os().collect();
if args.len() != 9 {
if args.len() != 9 && args.len() != 11 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"usage: external-launch BACKEND OPENVMM KERNEL INITRAMFS VM-DIR \
OPENVMM-SHA256 KERNEL-SHA256 INITRAMFS-SHA256",
OPENVMM-SHA256 KERNEL-SHA256 INITRAMFS-SHA256 [SNAPSHOT-DIR IMAGE.erofs]",
)
.into());
}
Expand Down Expand Up @@ -52,7 +65,19 @@ fn main() -> Result<(), Box<dyn Error>> {
windows_breakaway: false,
options: LaunchOptions::default(),
};
let config = VmConfig {
let snapshot_dir = (args.len() == 11).then(|| Path::new(&args[9]));
// Every caller of this snapshot must take this same lock before it opens the snapshot, and
// hold it through the restore-ready event or the VM's exit, and the view's removal.
#[cfg(target_os = "linux")]
let snapshot_lock = snapshot_dir
.map(|dir| File::open(dir.join(snapshot::KEY_FILE)))
.transpose()?;
#[cfg(target_os = "linux")]
if let Some(lock) = &snapshot_lock {
lock.lock()?;
}
let snapshot = snapshot_dir.map(snapshot::Snapshot::open).transpose()?;
let mut config = VmConfig {
processors: 1,
memory_mib: 256,
root: RootConfig::Blockless,
Expand All @@ -69,6 +94,29 @@ fn main() -> Result<(), Box<dyn Error>> {
capture: None,
}),
};
if let Some(snapshot) = &snapshot {
let key = snapshot.key();
config.processors = match key.boot_processors() {
0 => key.processors(),
boot => boot,
};
config.memory_mib = key.memory() / (1 << 20);
config.root = RootConfig::Layered(LayeredRoot {
image: artifacts::absolute(Path::new(&args[10]))?,
format: RootImage::erofs(None),
gpt_first_partition_bound: None,
upper: UpperConfig::Ram {
max_memory_percent: 50,
},
overlay: nvx::activation::OverlayMode::TrustedLowers,
});
config.network = snapshot.nic().map(|_| NetworkConfig {
guest_network: None,
policy: NetworkPolicy::new(Access::Deny),
});
config.console = ConsoleAttachment::Disconnected;
config.boot = None;
}
vm::create_private_dir(Path::new(&args[5]))?;
let dir = vm::VmDir::open(Path::new(&args[5]))?;
let vm::VmmCommand {
Expand All @@ -80,18 +128,55 @@ fn main() -> Result<(), Box<dyn Error>> {
inherit,
log,
breakaway_from_job,
} = vm::create(&runtime, &config, None, &dir)?.into_command();
mut ready,
} = vm::create(&runtime, &config, snapshot.as_ref(), &dir)?.into_command();
let command = supervisor::VmmCommand {
program,
arguments,
working_dir,
breakaway_from_job,
inherit: inherit.iter().map(Inherited::new).collect(),
};
let mut launched = supervisor::spawn_vm(&command, capability.as_bytes(), log)?;
let mut launched = match supervisor::spawn_vm(&command, capability.as_bytes(), log) {
Ok(launched) => launched,
Err(error) => {
if snapshot.is_some()
&& let Err(cleanup) = remove_view(dir.path())
{
return Err(io::Error::other(format!(
"{error}; cannot remove restore view after spawn failure: {cleanup}"
))
.into());
}
return Err(error.into());
}
};
let result = (|| -> Result<(), Box<dyn Error>> {
let process = launched.process()?;
let deadline = Instant::now() + Duration::from_secs(60);
if let Some(listener) = &mut ready {
let mut event = Vec::new();
let mut open = true;
while open && event.len() < READY_LIMIT {
let timeout = deadline.saturating_duration_since(Instant::now());
if timeout.is_zero() {
return Err(
io::Error::new(io::ErrorKind::TimedOut, "restore-ready timed out").into(),
);
}
open = listener.receive(&process, &mut event, READY_LIMIT, timeout)?;
}
nvx::protocol::restore_ready::parse_ready_event(&event)?;
process.kill()?;
if process.wait(deadline)?.is_none() || launched.wait_timeout(Duration::ZERO)?.is_none()
{
return Err(io::Error::other("restored VM exit is unconfirmed").into());
}
println!(
"external restore-ready peer verified; demonstration VM intentionally stopped"
);
return Ok(());
}
let console = outputs.console.as_deref().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "no prepared boot console")
})?;
Expand Down Expand Up @@ -136,16 +221,36 @@ fn main() -> Result<(), Box<dyn Error>> {
))
.into());
}
if snapshot.is_some()
&& let Err(cleanup) = remove_view(dir.path())
{
return Err(io::Error::other(format!(
"{error}; cannot remove restore view: {cleanup}"
))
.into());
}
return Err(error);
}
launched.detach();
if snapshot.is_some() {
remove_view(dir.path())?;
}
println!(
"external cold launch completed; outcome={}",
"external launch completed; outcome={}",
outputs.outcome.display()
);
Ok(())
}

/// Removes only the successful fixed restore view, while the caller still holds its lock.
fn remove_view(directory: &Path) -> io::Result<()> {
let view = directory.join(snapshot::OPENVMM_DIR);
for name in [snapshot::MANIFEST_NAME, "memory.bin", "state.bin"] {
std::fs::remove_file(view.join(name))?;
}
std::fs::remove_dir(view)
}

/// Prints console output through a marker, retaining only enough bytes to match across reads.
fn read_until(console: &mut dyn Transport, marker: &[u8], deadline: Instant) -> io::Result<()> {
let mut pending = Vec::new();
Expand Down
3 changes: 3 additions & 0 deletions nvx-rs/public-api/linux.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2486,6 +2486,7 @@ variant nvx::host::restore::Attribution::Snapshot
variant nvx::host::restore::Attribution::Unknown
struct nvx::host::restore::ReadyListener
method nvx::host::restore::ReadyListener::into_std
method nvx::host::restore::ReadyListener::receive
enum nvx::host::restore::RestoreAction
variant nvx::host::restore::RestoreAction::AwaitReadyEvent
variant nvx::host::restore::RestoreAction::Connect
Expand Down Expand Up @@ -3780,6 +3781,7 @@ struct nvx::host::vm::Launched
field nvx::host::vm::Launched::capability
field nvx::host::vm::Launched::outputs
field nvx::host::vm::Launched::process
field nvx::host::vm::Launched::ready
struct nvx::host::vm::Prepared
method nvx::host::vm::Prepared::activation_record
method nvx::host::vm::Prepared::arguments
Expand Down Expand Up @@ -3819,6 +3821,7 @@ field nvx::host::vm::VmmCommand::inherit
field nvx::host::vm::VmmCommand::log
field nvx::host::vm::VmmCommand::outputs
field nvx::host::vm::VmmCommand::program
field nvx::host::vm::VmmCommand::ready
field nvx::host::vm::VmmCommand::working_dir
fn nvx::host::vm::create
fn nvx::host::vm::create_private_dir
Expand Down
3 changes: 3 additions & 0 deletions nvx-rs/public-api/windows.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,7 @@ variant nvx::host::restore::Attribution::Snapshot
variant nvx::host::restore::Attribution::Unknown
struct nvx::host::restore::ReadyListener
method nvx::host::restore::ReadyListener::into_std
method nvx::host::restore::ReadyListener::receive
enum nvx::host::restore::RestoreAction
variant nvx::host::restore::RestoreAction::AwaitReadyEvent
variant nvx::host::restore::RestoreAction::Connect
Expand Down Expand Up @@ -2365,6 +2366,7 @@ struct nvx::host::vm::Launched
field nvx::host::vm::Launched::capability
field nvx::host::vm::Launched::outputs
field nvx::host::vm::Launched::process
field nvx::host::vm::Launched::ready
struct nvx::host::vm::Prepared
method nvx::host::vm::Prepared::activation_record
method nvx::host::vm::Prepared::arguments
Expand Down Expand Up @@ -2404,6 +2406,7 @@ field nvx::host::vm::VmmCommand::inherit
field nvx::host::vm::VmmCommand::log
field nvx::host::vm::VmmCommand::outputs
field nvx::host::vm::VmmCommand::program
field nvx::host::vm::VmmCommand::ready
field nvx::host::vm::VmmCommand::working_dir
fn nvx::host::vm::create
fn nvx::host::vm::create_private_dir
Expand Down
Loading
Loading