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
38 changes: 38 additions & 0 deletions nvx-rs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ changes only when the module's purpose does.
| [`nvx::protocol::cmdline`](#nvxprotocolcmdline) | The kernel command line as Linux and the guest's init read it, with the virtio-mmio devices and the guest's DNS server that it names |
| [`nvx::protocol::export`](#nvxprotocolexport) | The boot tokens of the host export, the virtio-fs device from which a guest maps host paths, and the guest's parser of them |
| [`nvx::protocol::nvxs`](#nvxprotocolnvxs) | The NVXS session, independent of any application protocol: its records and the state machines of its host and guest roles |
| [`nvx::protocol::restore_ready`](#nvxprotocolrestore_ready) | The exact parser of OpenVMM's restore-ready event, including its terminating newline |
| [`nvx::protocol::root`](#nvxprotocolroot) | The boot tokens that tell the guest which image its layered root assembles and where the root keeps its upper |
| [`nvx::protocol::share`](#nvxprotocolshare) | The guest targets that a sandbox's live shares may not take, which the host and the guest both refuse |
| [`nvx::protocol::status`](#nvxprotocolstatus) | The exit statuses with which a clone's guest ends its VM once it fails fast, and what each means |
Expand All @@ -124,6 +125,7 @@ changes only when the module's purpose does.
| [`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) | The directory of a VM, private to the current user, with the fixed names of the files and endpoints of its launches |
| [`nvx::host::config`](#nvxhostconfig) | `VmConfig`, a VM's configuration as one value, and `CaptureConfig`, what a capture adds and the identity of its snapshots, checked without reading the host |
| [`nvx::host::restore`](#nvxhostrestore) | The restore-ready listener, with native ownership transfer for a caller's own I/O |
| [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity |
| [`nvx::host::launch`](#nvxhostlaunch) | `LaunchSpec` and `VmmLaunch`: the OpenVMM command line of a managed microVM, its checks, and the inputs that it shares with an adapter's launch |
| [`nvx::host::network`](#nvxhostnetwork) | The one lowering of a network posture into OpenVMM options, for the CLI and external orchestrators |
Expand Down Expand Up @@ -234,6 +236,12 @@ number, and the guest's receive window and credits, without I/O.
An application message may span DATA records, and the application's own framing
reassembles it.

### `nvx::protocol::restore_ready`

`parse_ready_event` accepts only `OPENVMM_RESTORE_READY_V1\n`, byte for byte,
including the newline. Truncated events, another version, CRLF endings, and
trailing bytes fail with `ProtocolError`.

### `nvx::protocol::root`

The boot tokens of a layered root, which the host renders and guests parse by
Expand Down Expand Up @@ -524,6 +532,36 @@ boot's command line. No path, launch option, view, network policy, or anything
of the VM's root is part of it, nor the VM's own processors and memory, for
which the capture's count.

### `nvx::host::restore`

`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
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.
This requires Linux 6.5 or a backport of that option; if the host cannot
provide it, receive fails with `ErrorKind::Unavailable`, without a numeric-PID
fallback. An exited held VM is refused too.

`into_std()` transfers the same nonblocking `UnixListener` on Linux, or the
same overlapped first pipe instance as an `OwnedHandle` on Windows, without
reopening or duplicating it. Transfer precedes acceptance; transferring after
the receiver accepted a client fails with `ErrorKind::Unsupported`. A caller
that drives the native object owns its I/O. On Linux it checks the original
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.

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
namespaces, it uses passwordless `sudo` only for that isolated helper, not the
test suite or build. If neither route works, the test fails; it never changes
host namespace policy or skips the identity assertions.

### `nvx::host::artifacts`

`Artifacts` (also `nvx::Artifacts`): the OpenVMM executable, guest kernel, and
Expand Down
5 changes: 5 additions & 0 deletions nvx-rs/public-api/linux.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2132,6 +2132,9 @@ field nvx::host::report::Report::category
field nvx::host::report::Report::status_code
field nvx::host::report::Report::teardown_complete
fn nvx::host::report::read
mod nvx::host::restore
struct nvx::host::restore::ReadyListener
method nvx::host::restore::ReadyListener::into_std
mod nvx::host::runtime
struct nvx::host::runtime::ArtifactIdentity
method nvx::host::runtime::ArtifactIdentity::as_str
Expand Down Expand Up @@ -3555,6 +3558,8 @@ field nvx::protocol::nvxs::SessionError::Window::outstanding
field nvx::protocol::nvxs::SessionError::Window::window
fn nvx::protocol::nvxs::encode_record
fn nvx::protocol::parse_host_mapping_count
mod nvx::protocol::restore_ready
fn nvx::protocol::restore_ready::parse_ready_event
mod nvx::protocol::root
const nvx::protocol::root::DISTRO_MMIO_BASE
enum nvx::protocol::root::ImageFormat
Expand Down
5 changes: 5 additions & 0 deletions nvx-rs/public-api/windows.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,9 @@ field nvx::host::report::Report::category
field nvx::host::report::Report::status_code
field nvx::host::report::Report::teardown_complete
fn nvx::host::report::read
mod nvx::host::restore
struct nvx::host::restore::ReadyListener
method nvx::host::restore::ReadyListener::into_std
mod nvx::host::runtime
struct nvx::host::runtime::ArtifactIdentity
method nvx::host::runtime::ArtifactIdentity::as_str
Expand Down Expand Up @@ -2480,6 +2483,8 @@ field nvx::protocol::nvxs::SessionError::Window::outstanding
field nvx::protocol::nvxs::SessionError::Window::window
fn nvx::protocol::nvxs::encode_record
fn nvx::protocol::parse_host_mapping_count
mod nvx::protocol::restore_ready
fn nvx::protocol::restore_ready::parse_ready_event
mod nvx::protocol::root
const nvx::protocol::root::DISTRO_MMIO_BASE
enum nvx::protocol::root::ImageFormat
Expand Down
3 changes: 3 additions & 0 deletions nvx-rs/src/host/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
//! opened, which it inherits through the [`supervisor`]'s allowlist.
//! - [`vm`] holds a caller-owned VM directory, private to the current user, the fixed names of
//! its files and endpoints, and its control credential, which the library never writes.
//! - [`restore`] listens for OpenVMM's restore-ready event and verifies its client against the
//! held VM process.
//! - `cli` (feature `cli`) is the `nvx-rs` command-line interface, which runs microVMs and
//! sandboxes, looks up and downloads artifacts, and compiles egress policies.
//!
Expand Down Expand Up @@ -85,6 +87,7 @@ pub(crate) mod paths;
pub mod platform;
pub mod process;
pub mod report;
pub mod restore;
pub mod runtime;
pub mod sandbox;
pub mod seal;
Expand Down
83 changes: 82 additions & 1 deletion nvx-rs/src/host/platform/linux/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@

use std::fs::{self, OpenOptions, Permissions};
use std::io::{self, Read};
use std::os::fd::AsRawFd;
use std::os::fd::{AsFd, AsRawFd};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
use std::thread;
use std::time::{Duration, Instant};

use nix::poll::{PollFd, PollFlags, PollTimeout, poll};
use nix::sys::socket::{getsockopt, sockopt::PeerPidfd};

use crate::host::platform::{Transport, TransportHandle, time_left};
use crate::host::sys;

Expand Down Expand Up @@ -60,6 +63,17 @@ pub(crate) fn listen_ready(socket_path: &Path) -> io::Result<(String, ReadyListe
}

impl ReadyListener {
/// Transfers the listening socket only while no receive has accepted a client.
pub(crate) fn into_std(self) -> io::Result<UnixListener> {
if self.connection.is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"the restore-ready listener already accepted a client",
));
}
Ok(self.socket)
}

/// Waits at most `timeout` for what the VMM, the process `pid`, sends to the endpoint, and
/// appends it to `received`, which then holds at most `limit` bytes. Returns whether more may
/// arrive: `false` once the VMM closed its end of the connection.
Expand All @@ -72,6 +86,30 @@ impl ReadyListener {
received: &mut Vec<u8>,
limit: usize,
timeout: Duration,
) -> io::Result<bool> {
self.receive_checked(pid, received, limit, timeout, false)
}

/// Receives only from the original socket peer while it still runs, never from a cached
/// numeric PID that a later process reused. Requires Linux's `SO_PEERPIDFD`.
pub(crate) fn receive_verified(
&mut self,
pid: u32,
received: &mut Vec<u8>,
limit: usize,
timeout: Duration,
) -> io::Result<bool> {
self.receive_checked(pid, received, limit, timeout, true)
}

/// Shares the byte/deadline receive path, verifying the original peer only when requested.
fn receive_checked(
&mut self,
pid: u32,
received: &mut Vec<u8>,
limit: usize,
timeout: Duration,
verify_original_peer: bool,
) -> io::Result<bool> {
let deadline = Instant::now() + timeout;
if self.connection.is_none() {
Expand Down Expand Up @@ -100,6 +138,9 @@ impl ReadyListener {
),
));
}
if verify_original_peer {
check_original_peer(&connection)?;
}
connection.set_nonblocking(true)?;
self.connection = Some(connection);
}
Expand Down Expand Up @@ -136,6 +177,46 @@ impl ReadyListener {
}
}

/// The kernel returns a descriptor of the socket's original peer, not a new lookup of its PID.
fn check_original_peer(connection: &UnixStream) -> io::Result<()> {
let peer = match getsockopt(connection, PeerPidfd) {
Err(
sys::Errno::ENOPROTOOPT
| sys::Errno::EOPNOTSUPP
| sys::Errno::ENOSYS
| sys::Errno::EPERM
| sys::Errno::EACCES,
) => {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"restore-ready peer authentication requires SO_PEERPIDFD",
));
}
// Before Linux 6.16, a reaped peer yields EINVAL rather than a readable pidfd.
Err(sys::Errno::EINVAL | sys::Errno::ESRCH | sys::Errno::ENODATA) => {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"the original restore-ready socket peer exited",
));
}
result => result?,
};
let mut descriptors = [PollFd::new(peer.as_fd(), PollFlags::POLLIN)];
if poll(&mut descriptors, PollTimeout::ZERO)? == 0 {
return Ok(());
}
let events = descriptors[0].revents().unwrap_or(PollFlags::empty());
if events.intersects(PollFlags::POLLIN | PollFlags::POLLHUP) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"the original restore-ready socket peer exited",
));
}
Err(io::Error::other(format!(
"the restore-ready peer pidfd reported unexpected events {events:?}"
)))
}

/// Gives the restore-ready socket at `socket_path`, which the caller bound, the mode 0600, whatever
/// the umask: its owner, the user of the clone's VMM, may connect to it, which takes the
/// permission to write it, and no one else may.
Expand Down
71 changes: 45 additions & 26 deletions nvx-rs/src/host/platform/windows/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,36 +63,55 @@ pub(crate) fn listen_ready(_socket_path: &Path) -> io::Result<(String, ReadyList
getrandom::fill(&mut bytes).map_err(io::Error::other)?;
let suffix: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
let endpoint = format!("//./pipe/nvx-restore-ready-{suffix}");
let name = wide(endpoint.replace('/', "\\"));
let pipe =
with_owner_only_attributes(&CurrentUser::get()?, FILE_ALL_ACCESS, 0, |attributes| {
// SAFETY: the name is NUL-terminated, and the attributes hold a valid descriptor.
let handle = unsafe {
CreateNamedPipeW(
name.as_ptr(),
PIPE_ACCESS_INBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE | FILE_FLAG_OVERLAPPED,
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
1,
0,
READY_BUFFER_LEN,
0,
attributes,
)
};
owned(handle).ok_or_else(io::Error::last_os_error)
})??;
// SAFETY: creates an unnamed manual-reset event with default security.
let event = owned(unsafe { CreateEventW(ptr::null(), 1, 0, ptr::null()) })
.ok_or_else(io::Error::last_os_error)?;
let listener = ReadyListener {
pipe,
event,
connected: false,
};
let listener = ReadyListener::bind(&endpoint)?;
Ok((endpoint, listener))
}

impl ReadyListener {
/// Binds the first instance of the fresh endpoint that the VM directory selected.
pub(crate) fn bind(endpoint: &str) -> io::Result<Self> {
let name = wide(endpoint.replace('/', "\\"));
let pipe =
with_owner_only_attributes(&CurrentUser::get()?, FILE_ALL_ACCESS, 0, |attributes| {
// SAFETY: the name is NUL-terminated, and the attributes hold a valid descriptor.
let handle = unsafe {
CreateNamedPipeW(
name.as_ptr(),
PIPE_ACCESS_INBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE | FILE_FLAG_OVERLAPPED,
PIPE_TYPE_BYTE
| PIPE_READMODE_BYTE
| PIPE_WAIT
| PIPE_REJECT_REMOTE_CLIENTS,
1,
0,
READY_BUFFER_LEN,
0,
attributes,
)
};
owned(handle).ok_or_else(io::Error::last_os_error)
})??;
// SAFETY: creates an unnamed manual-reset event with default security.
let event = owned(unsafe { CreateEventW(ptr::null(), 1, 0, ptr::null()) })
.ok_or_else(io::Error::last_os_error)?;
Ok(Self {
pipe,
event,
connected: false,
})
}

/// Transfers the overlapped first pipe instance only before acceptance.
pub(crate) fn into_std(self) -> io::Result<OwnedHandle> {
if self.connected {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"the restore-ready listener already accepted a client",
));
}
Ok(self.pipe)
}

/// Waits at most `timeout` for what the VMM, the process `pid`, sends to the endpoint, and
/// appends it to `received`, which then holds at most `limit` bytes. Returns whether more may
/// arrive: `false` once the VMM closed its end of the pipe.
Expand Down
Loading
Loading