From d93a7b4244721365cc2afc8c5a5347606150fc40 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 24 Oct 2026 10:16:21 -0700 Subject: [PATCH 1/3] tests: give the runtime adapter's executions their network's settings The root configuration gains an optional listeners field, after the workload identity, which says whether the guest's listeners are published. Executions, and the account check of a workload identity, enter the root with that publication, so that while listeners are published the entry refuses CAP_NET_BIND_SERVICE and CAP_NET_RAW at stage 0. A layered launch names the publication of its network as OpenVMM publishes listeners: with ingress or host-loopback access allowed, and none without a network. The host gives an execution's start the URL of a proxy-only network's proxy in the proxy variables of its environment, with layered::with_proxy, from the plan that LaunchRecord::read_plan reads back from the store. The network device, its addresses, and the announcer of the guest's listeners stay with NVX's /init. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7c15634-094a-4a66-af59-25a58e732f4b --- tests/runtime-adapter/README.md | 98 ++++++--- .../src/bin/runtime-adapter-host.rs | 17 +- tests/runtime-adapter/src/layered.rs | 136 ++++++++++-- tests/runtime-adapter/src/lib.rs | 4 +- tests/runtime-adapter/src/root.rs | 92 +++++++- tests/runtime-adapter/src/staged.rs | 4 +- tests/runtime-adapter/src/worker.rs | 60 +++--- .../runtime-adapter/src/worker/executions.rs | 32 +-- tests/runtime-adapter/tests/guest_session.rs | 107 ++++++++-- tests/runtime-adapter/tests/layered_launch.rs | 199 +++++++++++++++++- tests/runtime-adapter/tests/vm_layered.rs | 10 +- 11 files changed, 629 insertions(+), 130 deletions(-) diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index b27c6b148..2128d544d 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -111,6 +111,7 @@ zeros: | `mapping-count` | The number of mappings of the host's [mapping table](#ready-mapping-tables-and-start), at most 4,096 | no | | `workload-uid` | The user ID of the sandbox's workload identity, as which every execution runs | with `workload-gid` | | `workload-gid` | The group ID of the sandbox's workload identity | with `workload-uid` | +| `listeners` | Whether the guest's listeners are published, with which every execution enters the root: `published` or `unpublished` | no | The paths are absolute and canonical guest paths of at most 4,095 bytes, without a NUL or a newline: not `/`, and without an empty, `.`, or `..` @@ -125,16 +126,19 @@ Without an overlay mode or a journal policy, the guest takes the defaults of `nvx::guest::root`: an overlay that trusts its lowers, and a scratch that may have a journal. Without a bound of the first partition, the guest bounds it at `root::DEFAULT_FIRST_PARTITION_BOUND`, 64 KiB. Without a mapping count, the -host's mapping table has no mapping. The host program and the guest agent ship -together, so the guest refuses every other configuration, one with an unknown -field included, rather than ignore what it does not know. +host's mapping table has no mapping. Without `listeners`, the guest's listeners +are published, as `nvx::guest::entry`'s default has them, which refuses the +most to the executions. The host program and the guest agent ship together, so +the guest refuses every other configuration, one with an unknown field +included, rather than ignore what it does not know. `RootConfig::default()` holds the values that the host program sends unless its caller names others: the runtime directory `/run/runtime-adapter`, the cgroups `/sys/fs/cgroup/agent` and `/sys/fs/cgroup/workload`, a wait of 30 seconds, an agent's `memory.low` of 16 MiB, as NVX's own guest reserves, and a workload's `memory.max` of 128 MiB and `pids.max` of 128, with neither a bound of the -first partition, an overlay mode, a journal policy, nor a workload identity, and no -mapping. +first partition, an overlay mode, a journal policy, a workload identity, nor a +publication of listeners, and no mapping. A layered launch names the +publication of its network; see [Networks](#networks). A stage of the root that fails is a `root::RootFailure`: the stage's code, one byte, then the problem, as UTF-8 text. The guest's worker, and with it the VM, @@ -575,15 +579,15 @@ credit that it needs, never keeps the others' output from the host. `worker::Entrance` describes it: in new mount, PID, and UTS namespaces, with the root moved over `/`, the entry's default hostname, `nvx-sandbox`, the image's own machine ID, and no time status, so that the entry binds nothing - into the root, and with published listeners, the entry's default, until the - adapter tells whether its listeners are published. It runs as the identity - that exactly one source names: the sandbox's workload identity, with no - supplementary groups or capabilities, or, in a sandbox without one, the - identity that its start names, with its supplementary groups, its + into the root, and with the publication of listeners that the root + configuration names, published unless it names them unpublished. It runs as + the identity that exactly one source names: the sandbox's workload identity, + with no supplementary groups or capabilities, or, in a sandbox without one, + the identity that its start names, with its supplementary groups, its capabilities, and its account mode. A start that names an identity in a - sandbox with a workload identity, or none in a sandbox without one, is refused - with `EINVAL`. Before the worker prepares the execution's cgroup, the entry - checks the identity, and reads its account in existing-account mode: it + sandbox with a workload identity, or none in a sandbox without one, is + refused with `EINVAL`. Before the worker prepares the execution's cgroup, the + entry checks the identity, and reads its account in existing-account mode: it refuses with `EPERM` a capability outside its allowlist, any capability of a user ID other than 0, and `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW` while listeners are published, with `ENOENT` an identity without the user ID's @@ -591,10 +595,12 @@ credit that it needs, never keeps the others' output from the host. database or a root that it cannot read, and with `EINVAL` otherwise. The program starts in the working directory of its start, or in the root's `/`, with exactly the environment of its start, none of the worker's own, and with - its standard input from the host or the null device. It leads a session of its - own, without a controlling terminal, and the process group that the session - creates, and, as PID 1 of its PID namespace, its end ends every process of the - namespace. + its standard input from the host or the null device. It leads a session of + its own, without a controlling terminal, and the process group that the + session creates, and, as PID 1 of its PID namespace, its end ends every + process of the namespace. The guest's network device, its addresses, and the + announcer of its listeners stay with NVX's `/init`, which configures them + before PID 1 starts; see [Networks](#networks). - Each execution runs in a cgroup of its own below the workload cgroup of the root configuration, `execution-N`, where N counts the cgroups that the worker prepared. The worker prepares it with @@ -979,17 +985,37 @@ anything else, so it refuses one that the plan refuses before it needs NVX's artifacts or creates the store. The launch puts the network on the adapter's plan, with -`SandboxPlan::with_network`, which renders it after the block devices, through -NVX's one lowering of a policy, `nvx::host::network::openvmm_arguments`: the -portable profile at the guest's address, the defaults of egress and ingress, -the host-loopback access, the egress rules, lowered into OpenVMM's native -ones, the host-loopback forwards, and the proxy of a proxy-only network, which -the guest reaches at its gateway, at the URL that `SandboxPlan::proxy_url` -gives. A policy that lets nothing through attaches no network device. OpenVMM -appends the guest's network settings to the kernel command line, from which -NVX's `/init` configures the network device before it hands the VM to the -guest agent. The launch's [record](#layered-roots) keeps the network in its -plan. +`LayeredLaunch::with_network`, which renders it after the block devices, +through NVX's one lowering of a policy, `nvx::host::network::openvmm_arguments`: +the portable profile at the guest's address, the defaults of egress and +ingress, the host-loopback access, the egress rules, lowered into OpenVMM's +native ones, the host-loopback forwards, and the proxy of a proxy-only +network, which the guest reaches at its gateway, at the URL that +`SandboxPlan::proxy_url` gives. A policy that lets nothing through attaches no +network device. OpenVMM appends the guest's network settings to the kernel +command line, from which NVX's `/init` configures the network device and its +addresses, and starts the announcer of the guest's listeners if OpenVMM +publishes them, before it hands the VM to the guest agent, which leaves all +three to `/init`. The launch's [record](#layered-roots) keeps the network in +its plan, which `LaunchRecord::read_plan` reads back from the store. + +The guest's executions follow the network: + +- The [root configuration](#root-configurations) says whether the guest's + listeners are published, as OpenVMM publishes them: to the host's private + networks with ingress allowed, or on host loopback with host-loopback access + allowed, but neither with outbound host-loopback access alone nor through a + proxy, and not without a network. The executions enter the root with that + publication, so while listeners are published the entry refuses + `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW`, with which an execution could bind + the privileged port from which the announcer sends, or forge its datagrams, + at stage 0 with `EPERM`. +- The host gives an execution the URL of a proxy-only network's proxy in its + environment, with `layered::with_proxy`, since NVX gives an execution no + proxy settings, and its environment is exactly its start's: in each of + `layered::PROXY_VARIABLES`, `http_proxy`, `https_proxy`, `HTTP_PROXY`, and + `HTTPS_PROXY`, after the start's own entries. A start that names any of + them keeps its own proxy settings. ### The step hook @@ -1382,6 +1408,12 @@ fenced. The tests cover: - the identities that starts name in a sandbox without a workload identity, with their supplementary groups, capabilities, and account modes, and the default hostname, all of which the worker's entry takes; +- the publication of listeners that the root configuration names, published + without it, with which every execution enters, and the account check of a + sandbox's workload identity too: while listeners are published, the entry + refuses `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW` at stage 0, with `EPERM`, + and while they are not, it takes them, as it takes the other capabilities + of its allowlist either way; - the starts that the worker refuses at stage 0 for their identities: one that names an identity in a sandbox with a workload identity, or none in a sandbox without one, with `EINVAL`; a capability outside the entry's @@ -1462,8 +1494,16 @@ Windows: - the networks that a launch reads: the OpenVMM arguments of each element of a policy, which NVX's lowering renders after the plan's block devices, a policy that lets nothing through, which attaches no network device, the proxy's - URL, and the record, which keeps the network as the file gave it; and the + URL, the publication of listeners that the root configuration names for + each, and the record, which keeps the network as the file gave it; and the files that it refuses, each for the plan's reason; +- the URL of a network's proxy in an execution's environment, as the guest + sees it, with and without a scheme, after the start's own entries, which the + start carries; a start that names its own proxy settings, which keeps them; + and a launch without a proxy, which leaves the environment as it is; +- the plan that the record of a launch gives back, from which the host finds + the proxy's URL, none for a VM without a layered root, and the records that + it refuses; - `runtime-adapter-host launch` with shares that it cannot export, which it refuses with status 1 before it creates the store, with artifacts that boot nothing; diff --git a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs index b6615fed3..874127263 100644 --- a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs +++ b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs @@ -37,12 +37,14 @@ //! address and the policy that OpenVMM enforces there, in the serde form of a plan's network, //! which the launch reads before anything else, so that it refuses a network that the plan //! refuses before it needs NVX's artifacts or the store, and puts on the adapter's plan, which -//! renders it; see `runtime_adapter::layered::read_network`. Before it spawns the VM, the launch -//! checks the bindings that STORE stages for the image and the scratch image, from their metadata -//! alone, and fails if STORE stages none for either, or one no longer holds, as for an image that -//! changed after it was staged. It hands OpenVMM the files that the checks opened, which it holds -//! until the guest has assembled the root, or until it has torn down a launch that failed, and -//! records the plan, with its network, and the bindings. +//! renders it; see `runtime_adapter::layered::read_network`. The root configuration says whether +//! the guest's listeners are published through the network, as OpenVMM publishes them, and +//! unpublished without one; see `runtime_adapter::layered::LayeredLaunch::with_network`. Before +//! it spawns the VM, the launch checks the bindings that STORE stages for the image and the +//! scratch image, from their metadata alone, and fails if STORE stages none for either, or one no +//! longer holds, as for an image that changed after it was staged. It hands OpenVMM the files +//! that the checks opened, which it holds until the guest has assembled the root, or until it has +//! torn down a launch that failed, and records the plan, with its network, and the bindings. //! //! SHARES, `--share HOST:TARGET[:ro]... [--deny PATH]... [--reserved TARGET]... //! [--share-root DIR]...`, shares each host directory or regular file HOST with the root, at the @@ -926,8 +928,7 @@ fn launch( if let Some(export) = layered.sharing.plan()? { launch.share(export)?; } - launch.plan = launch.plan.with_network(network); - Ok(launch) + Ok(launch.with_network(network)) }) .transpose() .map_err(|error| describe(&error))?; diff --git a/tests/runtime-adapter/src/layered.rs b/tests/runtime-adapter/src/layered.rs index 21e1cc382..c035ffc6a 100644 --- a/tests/runtime-adapter/src/layered.rs +++ b/tests/runtime-adapter/src/layered.rs @@ -30,11 +30,20 @@ //! //! A launch may give the VM a network: a `SandboxNetwork`, the guest's address and the policy that //! OpenVMM enforces there, which [`read_network`] reads from a file in the serde form of a plan's -//! network, and which the launch puts on the plan with `SandboxPlan::with_network`. The plan -//! renders its OpenVMM arguments through NVX's one lowering of a policy, -//! `nvx::host::network::openvmm_arguments`, as it renders those of NVX's sandbox profile. OpenVMM -//! appends the guest's network settings to the kernel command line itself, and NVX's `/init` -//! configures the network device from them before it hands the VM to the guest agent. +//! network, and which [`LayeredLaunch::with_network`] puts on the plan. The plan renders its +//! OpenVMM arguments through NVX's one lowering of a policy, `nvx::host::network::openvmm_arguments`, +//! as it renders those of NVX's sandbox profile. OpenVMM appends the guest's network settings to +//! the kernel command line itself, and NVX's `/init` configures the network device and its +//! addresses from them, and starts the announcer of the guest's listeners if OpenVMM publishes +//! them, before it hands the VM to the guest agent, which leaves all three to `/init`. +//! +//! The guest's executions follow the network. The root configuration says whether the guest's +//! listeners are published, as OpenVMM publishes them: to the host's private networks with +//! ingress allowed, or on host loopback with host-loopback access allowed. The executions enter +//! the root with that publication, which refuses `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW` while +//! listeners are published, since either could forge an announcement. The host gives an +//! execution the URL of a proxy-only network's proxy, as the guest sees it, in its environment, +//! with [`with_proxy`]. //! //! The host records what it launched in the sandbox's store, beside NVX's runtime records, as a //! [`LaunchRecord`], with the bindings that the launch checked, and the plan, which keeps the @@ -48,6 +57,7 @@ use std::time::Instant; use nvx::host::artifacts::absolute; use nvx::host::filesystem::ExportPlan; use nvx::host::launch::DEFAULT_MEMORY_MIB; +use nvx::host::network::{Access, HostLoopback}; use nvx::host::sandbox::distro::{DISTRO_POLICY_REVISION, DistroBinding}; use nvx::host::sandbox::scratch::{CLAIM_RELEASE_WAIT, ScratchBinding}; use nvx::host::sandbox::{ @@ -60,7 +70,9 @@ use nvx::uuid::Uuid; use nvx::{Error, Hypervisor, Result}; use serde_json::{Value, json}; -use crate::root::{RootConfig, encode_maps}; +use crate::exec::Start; +use crate::root::{Listeners, RootConfig, encode_maps}; +use crate::staged::invalid; /// The name of the record of a launch in the sandbox's store. pub const RECORD_NAME: &str = "runtime-adapter.json"; @@ -70,6 +82,9 @@ pub const RECORD_FORMAT: u64 = 1; /// adapter's initramfs replaces with the guest agent; see [`crate::initramfs`]. An adapter's plan /// renders none of the parameters of NVX's sandbox profile, so the launch passes it as its own. pub const SANDBOX_TOKEN: &str = "nvx_sandbox=1"; +/// The environment variables that hold the URL of a network's proxy for an execution, as +/// [`with_proxy`] sets them: those of HTTP clients, in lowercase and in uppercase. +pub const PROXY_VARIABLES: [&str; 4] = ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"]; /// The size of a sector of a block device, in bytes. const SECTOR_SIZE: u64 = 512; @@ -96,12 +111,13 @@ pub struct LayeredLaunch { impl LayeredLaunch { /// Returns the launch of the image at `distro`, in `format`, of which the guest expects the /// identity `identity`, over the ext4 scratch image at `scratch`, or over RAM without one, - /// without bindings, without shares, and without a network, which `SandboxPlan::with_network` - /// puts on the plan. + /// without bindings, without shares, and without a network, which + /// [`with_network`](Self::with_network) puts on the plan. /// /// The root configuration takes its defaults, with the runtime directory `runtime` instead, if - /// any, as it is, so that the guest checks it, and with the size of each image, if it is a - /// whole number of sectors, which the guest checks against its block device. + /// any, as it is, so that the guest checks it, with the size of each image, if it is a whole + /// number of sectors, which the guest checks against its block device, and with the guest's + /// listeners unpublished, as they are without a network. /// /// Fails with [`ErrorKind::Unsupported`](nvx::ErrorKind::Unsupported) if a path is not /// Unicode, and otherwise as `SandboxPlan::validated` does, such as for an image that is not a @@ -122,6 +138,7 @@ impl LayeredLaunch { .map(sectors) .transpose()? .flatten(), + listeners: Some(listeners(None)), ..RootConfig::default() }; if let Some(runtime) = runtime { @@ -167,6 +184,17 @@ impl LayeredLaunch { .map_or_else(Vec::new, ExportPlan::guest_table) } + /// Returns the launch with `network` on its plan, with `SandboxPlan::with_network`, or without + /// a network, and with a root configuration that says whether the guest's listeners are + /// published through it: as OpenVMM publishes them, with ingress allowed, to the host's + /// private networks, or with host-loopback access allowed, on host loopback. + #[must_use] + pub fn with_network(mut self, network: Option) -> Self { + self.config.listeners = Some(listeners(network.as_ref())); + self.plan = self.plan.with_network(network); + self + } + /// Returns the VM's kernel command line: [`SANDBOX_TOKEN`], then the boot tokens of the /// plan's root, without parameters of the caller. /// @@ -303,8 +331,8 @@ impl LayeredLaunch { } /// Reads the network of a launch from the file at `path`: one `SandboxNetwork`, in the serde form -/// of a plan's network, as NVX's plan persists it, which `SandboxPlan::with_network` puts on the -/// adapter's plan. Without a network, a launch has no network device. +/// of a plan's network, as NVX's plan persists it, which [`LayeredLaunch::with_network`] puts on +/// the adapter's plan. Without a network, a launch has no network device. /// /// The form requires every member and refuses any other, and reading it checks the network as /// `SandboxNetwork::new` does: the guest's address must leave a gateway, and the policy must lower @@ -323,6 +351,44 @@ pub fn read_network(path: &Path) -> Result { }) } +/// Returns whether the guest's listeners are published through `network`, or without one, as +/// OpenVMM publishes them: with ingress allowed, to the host's private networks, or with +/// host-loopback access allowed, on host loopback. OpenVMM tells NVX's `/init` so on the kernel +/// command line, and `/init` then starts the announcer of the guest's listeners. +fn listeners(network: Option<&SandboxNetwork>) -> Listeners { + let published = network.is_some_and(|network| { + let policy = network.policy(); + policy.ingress == Access::Allow || policy.host_loopback == HostLoopback::Allow + }); + if published { + Listeners::Published + } else { + Listeners::Unpublished + } +} + +/// Returns `start` with the URL of the proxy of `plan`'s network, as the guest sees it, +/// `SandboxPlan::proxy_url`, in each of the [`PROXY_VARIABLES`], after the start's own entries: +/// NVX gives an execution no proxy settings, and an execution's environment is exactly its +/// start's. A start of a plan without a proxy, or that names any of those variables, whose caller +/// chose its own proxy settings, is returned as it is. +pub fn with_proxy(mut start: Start, plan: &SandboxPlan) -> Start { + let Some(url) = plan.proxy_url() else { + return start; + }; + let named = start.environment.iter().any(|entry| { + let name = entry.split(|byte| *byte == b'=').next().unwrap_or_default(); + PROXY_VARIABLES + .iter() + .any(|variable| name == variable.as_bytes()) + }); + if !named { + let entries = PROXY_VARIABLES.map(|variable| format!("{variable}={url}").into_bytes()); + start.environment.extend(entries); + } + start +} + /// Returns the adapter's plan of the image at `distro`, in `format`, of which the guest expects /// the identity `identity`, over the ext4 scratch image at `scratch`, or over RAM without one, /// checked against the host with `SandboxPlan::validated`. @@ -445,13 +511,13 @@ pub(crate) fn write_record(path: &Path, record: &Value) -> Result<()> { /// /// The `mode` is `layered`, or `blockless` for a VM without a layered root, whose `plan`, /// `bindings`, and `rootConfiguration` are `null`. The `plan` is the persisted form of the VM's -/// `SandboxPlan`, whose `network` keeps the VM's network, in the form that [`read_network`] -/// reads, or `null` without one. The `bindings` are those that the launch checked, which the store -/// stages for the plan's images, as [`Staged::bindings`](crate::staged::Staged::bindings) finds -/// them, in the serde forms of NVX's `DistroBinding` and `ScratchBinding`; the scratch image's is -/// `null` for an upper in RAM, which has no scratch image. The `rootConfiguration` is the encoding -/// of the root configuration that the host sends, as it sends it, whether or not the guest accepts -/// it. +/// `SandboxPlan`, which [`read_plan`](Self::read_plan) reads back, whose `network` keeps the VM's +/// network, in the form that [`read_network`] reads, or `null` without one. The `bindings` are +/// those that the launch checked, which the store stages for the plan's images, as +/// [`Staged::bindings`](crate::staged::Staged::bindings) finds them, in the serde forms of NVX's +/// `DistroBinding` and `ScratchBinding`; the scratch image's is `null` for an upper in RAM, which +/// has no scratch image. The `rootConfiguration` is the encoding of the root configuration that the +/// host sends, as it sends it, whether or not the guest accepts it. #[derive(Clone, Copy, Debug)] pub struct LaunchRecord<'a> { /// The VM's kernel command line. @@ -498,4 +564,36 @@ impl LaunchRecord<'_> { pub fn write(&self, directory: &Path) -> Result<()> { write_record(&directory.join(RECORD_NAME), &self.to_json()?) } + + /// Reads the plan of the launch that `directory`, the sandbox's store, records: the plan of + /// the VM's layered root, with its network, or `None` for a VM without one. + /// + /// Fails with [`ErrorKind::Io`](nvx::ErrorKind::Io) if the record cannot be read, as when the + /// store holds none, and with [`ErrorKind::Unavailable`](nvx::ErrorKind::Unavailable) if it + /// is not JSON, is not in format [`RECORD_FORMAT`], or has no plan in a plan's serde form. + pub fn read_plan(directory: &Path) -> Result> { + let path = directory.join(RECORD_NAME); + let text = fs::read_to_string(&path).map_err(|error| { + Error::io(format!("cannot read {}", path.display())).with_source(error) + })?; + let unreadable = || { + Error::unavailable(format!( + "cannot read the plan of the launch record {}", + path.display() + )) + }; + let value = serde_json::from_str::(&text) + .map_err(|error| unreadable().with_source(error))?; + if value.get("format").and_then(Value::as_u64) != Some(RECORD_FORMAT) { + let problem = format!("is not in format {RECORD_FORMAT}"); + return Err(unreadable().with_source(invalid(&problem))); + } + match value.get("plan") { + None => Err(unreadable().with_source(invalid("has no plan"))), + Some(Value::Null) => Ok(None), + Some(plan) => serde_json::from_value(plan.clone()) + .map(Some) + .map_err(|error| unreadable().with_source(error)), + } + } } diff --git a/tests/runtime-adapter/src/lib.rs b/tests/runtime-adapter/src/lib.rs index 83389dcf5..885a65214 100644 --- a/tests/runtime-adapter/src/lib.rs +++ b/tests/runtime-adapter/src/lib.rs @@ -40,7 +40,9 @@ //! it checks that the guest agent that assembled the root is of the build that it ships, sends the //! table, then starts the sandbox, and tears down a VM whose order failed. Once the host started //! the sandbox, the worker also runs several [`exec`]utions at once through NVX's execution engine -//! and its guest binding, from one loop that never blocks. +//! and its guest binding, from one loop that never blocks. The executions enter the root with the +//! publication of listeners that the root configuration names, and the host gives them the URL of +//! the network's proxy, if any, in their environment. pub mod exec; pub mod frame; diff --git a/tests/runtime-adapter/src/root.rs b/tests/runtime-adapter/src/root.rs index 3435d5056..7c6f96232 100644 --- a/tests/runtime-adapter/src/root.rs +++ b/tests/runtime-adapter/src/root.rs @@ -8,7 +8,8 @@ //! runtime directory, the cgroups, their resource values, the sizes that the block devices must //! have, how long the guest waits for them, the most bytes that the first partition of a GPT disk //! may hold, how the guest assembles the overlay over the upper, how many mappings the host's -//! mapping table holds, and the workload identity of the whole sandbox, if it has one. +//! mapping table holds, the workload identity of the whole sandbox, if it has one, and whether the +//! sandbox's listeners are published. //! //! A root configuration is UTF-8 text: one `NAME=VALUE` line per field, each ended by a newline, //! in any order, and each at most once. Numbers are decimal, without a sign or leading zeros: @@ -31,6 +32,7 @@ //! | `mapping-count` | The number of mappings of the host's mapping table, at most [`MAX_HOST_MAPPINGS`] | no | //! | `workload-uid` | The user ID of the sandbox's workload identity, as which every execution runs | with `workload-gid` | //! | `workload-gid` | The group ID of the sandbox's workload identity | with `workload-uid` | +//! | `listeners` | Whether the sandbox's listeners are published, [`Listeners`]: `published`, the guest's default, or `unpublished` | no | //! //! The paths are absolute and canonical guest paths: not `/`, without an empty, `.`, or `..` //! component, or a trailing `/`. The runtime directory holds no `,`, `:`, `\`, or `=`, which the @@ -38,7 +40,10 @@ //! positive. A workload identity has both IDs, each below 4,294,967,295, which the kernel reads //! as "unchanged", and no supplementary groups or capabilities; the guest checks its account in //! the assembled root, a failure of [`RootStage::Identity`], and an execution then names no -//! identity of its own. [`RootConfig::decode`] refuses every other configuration, which the guest +//! identity of its own. While the sandbox's listeners are published, through private-network +//! ingress or host loopback, the guest refuses an execution `CAP_NET_BIND_SERVICE` and +//! `CAP_NET_RAW`, with which it could forge the announcements of its listeners. +//! [`RootConfig::decode`] refuses every other configuration, which the guest //! reports as a failure of [`RootStage::Configuration`]. //! //! A [`RootFailure`] is a stage's code, one byte, then the problem as UTF-8 text. @@ -129,8 +134,10 @@ const MAPPING_COUNT: &str = "mapping-count"; const WORKLOAD_UID: &str = "workload-uid"; /// See [`VERSION`]. const WORKLOAD_GID: &str = "workload-gid"; +/// See [`VERSION`]. +const LISTENERS: &str = "listeners"; /// Every field, in the order in which [`RootConfig::encode`] writes them. -const FIELDS: [&str; 16] = [ +const FIELDS: [&str; 17] = [ VERSION, RUNTIME, AGENT_CGROUP, @@ -147,6 +154,7 @@ const FIELDS: [&str; 16] = [ MAPPING_COUNT, WORKLOAD_UID, WORKLOAD_GID, + LISTENERS, ]; /// The overlay mode that a configuration names, which the guest's assembly applies: the @@ -207,6 +215,45 @@ impl JournalPolicy { } } +/// Whether a sandbox's listeners are published, which a configuration names: the +/// `nvx::guest::entry::Publication` of the same name, with which the guest's executions enter the +/// root. +/// +/// The host publishes them through its network: NVX's `/init` starts an announcer of the guest's +/// listeners, and OpenVMM publishes what it announces, to the host's private networks with ingress +/// allowed, or on host loopback with host-loopback access allowed. An execution that may bind a +/// privileged port, as `CAP_NET_BIND_SERVICE` allows, or open a raw socket, as `CAP_NET_RAW` +/// allows, could forge those announcements, so the entry refuses both while listeners are +/// published. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Listeners { + /// `published`: the guest's listeners are published, and executions enter without + /// `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW`. + Published, + /// `unpublished`: no listener is published. + Unpublished, +} + +impl Listeners { + /// Every publication. + pub const ALL: [Self; 2] = [Self::Published, Self::Unpublished]; + + /// Returns the publication's name in a configuration. + pub fn as_str(self) -> &'static str { + match self { + Self::Published => "published", + Self::Unpublished => "unpublished", + } + } + + /// Returns the publication named `name`, if any. + pub fn from_name(name: &str) -> Option { + Self::ALL + .into_iter() + .find(|listeners| listeners.as_str() == name) + } +} + /// The host's configuration of a guest's layered root: everything that the root needs besides its /// boot tokens, as the [module documentation](self) lists it. /// @@ -244,14 +291,17 @@ pub struct RootConfig { /// The workload identity of the whole sandbox, its user ID and group ID, if it has one: every /// execution runs as it, and names no identity of its own. pub workload_identity: Option<(u32, u32)>, + /// Whether the sandbox's listeners are published, unless the guest's default, + /// [`Listeners::Published`], with which every execution enters the root. + pub listeners: Option, } impl Default for RootConfig { /// Returns the configuration of the defaults: [`DEFAULT_RUNTIME`], [`DEFAULT_AGENT_CGROUP`], /// [`DEFAULT_WORKLOAD_CGROUP`], [`DEFAULT_DEVICE_TIMEOUT`], [`DEFAULT_AGENT_MEMORY_LOW`], /// [`DEFAULT_WORKLOAD_MEMORY_MAX`], and [`DEFAULT_WORKLOAD_PIDS_MAX`], with no sizes, the - /// guest's default bound of the first partition, overlay mode, and journal policy, and no - /// mapping or workload identity. + /// guest's default bound of the first partition, overlay mode, and journal policy, no mapping + /// or workload identity, and the guest's default publication of listeners. fn default() -> Self { Self { runtime: DEFAULT_RUNTIME.to_owned(), @@ -268,6 +318,7 @@ impl Default for RootConfig { scratch_journal: None, mapping_count: 0, workload_identity: None, + listeners: None, } } } @@ -318,6 +369,9 @@ impl RootConfig { (WORKLOAD_GID, gid.to_string()), ]); } + if let Some(listeners) = self.listeners { + fields.push((LISTENERS, listeners.as_str().to_owned())); + } fields } @@ -453,6 +507,10 @@ impl RootConfig { ))); } }; + let listeners = values + .get(LISTENERS) + .map(|text| Listeners::from_name(text).ok_or_else(|| unknown(LISTENERS, text))) + .transpose()?; Ok(Self { runtime, agent_cgroup, @@ -469,6 +527,7 @@ impl RootConfig { // The count is at most `MAX_HOST_MAPPINGS`, so it fits a `u32`. mapping_count: mapping_count as u32, workload_identity, + listeners, }) } } @@ -884,6 +943,7 @@ mod tests { scratch_journal: Some(JournalPolicy::Forbidden), mapping_count: 3, workload_identity: Some((1000, 100)), + listeners: Some(Listeners::Unpublished), ..RootConfig::default() } } @@ -907,18 +967,22 @@ mod tests { scratch-journal=forbidden\n\ mapping-count=3\n\ workload-uid=1000\n\ - workload-gid=100\n" + workload-gid=100\n\ + listeners=unpublished\n" ); // Without a mapping, the configuration names no count. let unmapped = String::from_utf8(RootConfig::default().encode()).unwrap(); assert!(!unmapped.contains("mapping-count"), "{unmapped}"); - // Each mode and policy has its own name. + // Each mode, policy, and publication has its own name. for mode in OverlayMode::ALL { assert_eq!(OverlayMode::from_name(mode.as_str()), Some(mode)); } for policy in JournalPolicy::ALL { assert_eq!(JournalPolicy::from_name(policy.as_str()), Some(policy)); } + for listeners in Listeners::ALL { + assert_eq!(Listeners::from_name(listeners.as_str()), Some(listeners)); + } let minimal = RootConfig { agent_memory_low: None, workload_memory_max: None, @@ -929,7 +993,11 @@ mod tests { workload_identity: Some((u32::MAX - 1, u32::MAX - 1)), ..RootConfig::default() }; - for config in [full(), minimal, largest, RootConfig::default()] { + let published = RootConfig { + listeners: Some(Listeners::Published), + ..RootConfig::default() + }; + for config in [full(), minimal, largest, published, RootConfig::default()] { assert_eq!(RootConfig::decode(&config.encode()), Ok(config)); } // The fields may come in any order. @@ -1138,6 +1206,14 @@ mod tests { format!("{base}workload-gid=1000\n").into_bytes(), "names a workload identity without both workload-uid and workload-gid", ), + ( + format!("{base}listeners=Published\n").into_bytes(), + "names the unknown listeners \"Published\"", + ), + ( + format!("{base}listeners=published\nlisteners=unpublished\n").into_bytes(), + "repeats the field listeners", + ), ]; for (body, problem) in cases { let error = RootConfig::decode(&body).unwrap_err(); diff --git a/tests/runtime-adapter/src/staged.rs b/tests/runtime-adapter/src/staged.rs index 8523507c9..8a3e07aba 100644 --- a/tests/runtime-adapter/src/staged.rs +++ b/tests/runtime-adapter/src/staged.rs @@ -249,7 +249,7 @@ fn members<'a, const N: usize>(value: &'a Value, names: [&str; N]) -> Option<[&' .ok() } -/// Returns the error of a record of staged images that is invalid, which `problem` says. -fn invalid(problem: &str) -> Error { +/// Returns the error of a record in the sandbox's store that is invalid, which `problem` says. +pub(crate) fn invalid(problem: &str) -> Error { Error::unavailable(format!("the record {problem}")) } diff --git a/tests/runtime-adapter/src/worker.rs b/tests/runtime-adapter/src/worker.rs index 9f92c0923..f38a1cc7f 100644 --- a/tests/runtime-adapter/src/worker.rs +++ b/tests/runtime-adapter/src/worker.rs @@ -126,21 +126,24 @@ //! - Each program enters the assembled root through `nvx::guest::entry`, as an [`Entrance`] //! describes it: in new mount, PID, and UTS namespaces, with the root moved over `/`, the entry's //! default hostname, `nvx-sandbox`, the image's own machine ID, and no time status, so that the -//! entry binds nothing into the root, and with published listeners, the entry's default. It runs -//! as the identity that exactly one source names: the sandbox's workload identity, which the root -//! configuration names, with no supplementary groups or capabilities, or, in a sandbox without -//! one, the identity that its start names, with its supplementary groups, its capabilities, and -//! its account mode. A start that names an identity in a sandbox with a workload identity, or -//! none in a sandbox without one, is refused with `EINVAL`. Before the worker prepares the -//! execution's cgroup, the entry checks the identity, and reads its account in existing-account -//! mode: it refuses with `EPERM` a capability outside its allowlist, any capability of a user ID -//! other than 0, and `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW` while listeners are published, with -//! `ENOENT` an identity without the user ID's passwd entry or the group ID's group entry, with -//! the error of an account database or a root that it cannot read, and with `EINVAL` otherwise. -//! The program starts in the working directory of its start, or in the root's `/`, with exactly -//! the environment of its start, and leads a session of its own, without a controlling terminal, -//! and the process group that the session creates. As PID 1 of its PID namespace, its end ends -//! every process of the namespace. +//! entry binds nothing into the root, and with the publication of listeners that the root +//! configuration names, published unless it names them unpublished, as the entry's default is. It +//! runs as the identity that exactly one source names: the sandbox's workload identity, which the +//! root configuration names, with no supplementary groups or capabilities, or, in a sandbox +//! without one, the identity that its start names, with its supplementary groups, its +//! capabilities, and its account mode. A start that names an identity in a sandbox with a +//! workload identity, or none in a sandbox without one, is refused with `EINVAL`. Before the +//! worker prepares the execution's cgroup, the entry checks the identity, and reads its account +//! in existing-account mode: it refuses with `EPERM` a capability outside its allowlist, any +//! capability of a user ID other than 0, and `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW` while +//! listeners are published, with `ENOENT` an identity without the user ID's passwd entry or the +//! group ID's group entry, with the error of an account database or a root that it cannot read, +//! and with `EINVAL` otherwise. The program starts in the working directory of its start, or in +//! the root's `/`, with exactly the environment of its start, and leads a session of its own, +//! without a controlling terminal, and the process group that the session creates. As PID 1 of +//! its PID namespace, its end ends every process of the namespace. The guest's network device, +//! its addresses, and the announcer of its listeners stay with NVX's `/init`, which configures +//! them before PID 1 starts. //! - Each execution runs in a cgroup of its own below the workload cgroup, `execution-N`, where N //! counts the cgroups that the worker prepared. The worker prepares it with //! `nvx::guest::cgroup::ExecutionCgroup::prepare`, and the program joins it as it spawns, @@ -211,7 +214,7 @@ use nix::sys::wait::{self, Id, WaitPidFlag, WaitStatus}; use nix::unistd::Pid; use nvx::gpt::Expected; use nvx::guest::cgroup::Layout; -use nvx::guest::entry::{self, Entry, Identity, Prepared}; +use nvx::guest::entry::{self, Entry, Identity, Prepared, Publication}; use nvx::guest::mappings::{Mapping, SourceRoot}; use nvx::guest::nvxs::GuestConnectionError; use nvx::guest::process::Child; @@ -230,8 +233,8 @@ use crate::frame::{MAX_BODY, MAX_TEXT_BODY, Message, truncated}; use crate::guest::{self, ServeError, Step}; use crate::probe::{Facts, Probe}; use crate::root::{ - DEFAULT_FIRST_PARTITION_BOUND, JournalPolicy, OverlayMode, Ready, RootConfig, RootFailure, - RootStage, + DEFAULT_FIRST_PARTITION_BOUND, JournalPolicy, Listeners, OverlayMode, Ready, RootConfig, + RootFailure, RootStage, }; use connection::{Connection, Received}; use executions::{Executions, Sandbox}; @@ -267,20 +270,24 @@ const COMMAND_LINE: &str = "/proc/cmdline"; /// How one execution enters the assembled root: the inputs of its `nvx::guest::entry::Entry` that /// the worker chooses. The entry keeps its defaults for the rest: new mount, PID, and UTS /// namespaces, with the root moved over `/`, the image's own machine ID and no time status, which -/// leave `Entry::bind_runtime` nothing to bind, published listeners, which refuse -/// `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW`, and every device node. +/// leave `Entry::bind_runtime` nothing to bind, and every device node. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Entrance { /// The identity: the sandbox's workload identity, or the one that the execution's start names. pub identity: Identity, /// The hostname, the sandbox's: the entry's default. pub hostname: Vec, + /// Whether the sandbox's listeners are published, as the root configuration says, which + /// refuses `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW` while they are. + pub publication: Publication, } impl Entrance { /// Returns the entry into the root at `root`. pub fn entry(&self, root: impl Into) -> Entry { - Entry::new(root, self.identity.clone()).hostname(self.hostname.clone()) + Entry::new(root, self.identity.clone()) + .hostname(self.hostname.clone()) + .publication(self.publication) } } @@ -608,8 +615,8 @@ fn serve_root( /// bootstraps the guest, assembles the root, mounts the export out of it, if the kernel command /// line names one, and checks the account of the sandbox's workload identity there, if the /// configuration names one, through `operations`. Returns the configuration, with the sandbox in -/// which the executions run, or the failure of the stage that failed: the export's mount ends the -/// assembly. +/// which the executions run, with the publication of listeners that the configuration names, or +/// the failure of the stage that failed: the export's mount ends the assembly. fn assemble( operations: &mut dyn RootOperations, body: &[u8], @@ -629,11 +636,16 @@ fn assemble( .mount_export(export) .map_err(|problem| RootFailure::new(RootStage::Assembly, problem))?; } - let sandbox = Sandbox::new(config.workload_identity); + let publication = match config.listeners { + Some(Listeners::Published) | None => Publication::Published, + Some(Listeners::Unpublished) => Publication::Unpublished, + }; + let sandbox = Sandbox::new(config.workload_identity, publication); if let Some((uid, gid)) = sandbox.identity { let entrance = Entrance { identity: Identity::new(uid, gid), hostname: sandbox.hostname.clone(), + publication: sandbox.publication, }; operations.prepare(&entrance).map_err(|error| { RootFailure::new( diff --git a/tests/runtime-adapter/src/worker/executions.rs b/tests/runtime-adapter/src/worker/executions.rs index a92782a98..8a4627263 100644 --- a/tests/runtime-adapter/src/worker/executions.rs +++ b/tests/runtime-adapter/src/worker/executions.rs @@ -20,7 +20,7 @@ use nix::sys::signal::{self, Signal}; use nix::sys::wait::{self, WaitPidFlag, WaitStatus}; use nvx::exec::{Key, LaunchFailure, Runner, Settlement, Stream, Violation, runner}; use nvx::guest::cgroup::{ExecutionCgroup, Layout, Scope}; -use nvx::guest::entry::{self, AccountError, Capabilities, Capability, Prepared}; +use nvx::guest::entry::{self, AccountError, Capabilities, Capability, Prepared, Publication}; use nvx::guest::exec::binding::{Binding, Event}; use nvx::guest::exec::{Execution, Options, Pipes, Source}; use nvx::guest::process::Child; @@ -97,16 +97,20 @@ pub(super) struct Sandbox { /// The sandbox's workload identity, its user ID and group ID, if the root configuration /// names one, as which every execution runs. pub(super) identity: Option<(u32, u32)>, + /// Whether the sandbox's listeners are published, as the root configuration says. + pub(super) publication: Publication, } impl Sandbox { - /// Returns the sandbox whose workload identity is `identity`, if it has one. Its executions - /// take the entry's default hostname, [`DEFAULT_HOSTNAME`](entry::DEFAULT_HOSTNAME), as - /// nothing configures another: this is the one place that names their hostname. - pub(super) fn new(identity: Option<(u32, u32)>) -> Self { + /// Returns the sandbox whose workload identity is `identity`, if it has one, and whose + /// listeners are published as `publication` says. Its executions take the entry's default + /// hostname, [`DEFAULT_HOSTNAME`](entry::DEFAULT_HOSTNAME), as nothing configures another: + /// this is the one place that names their hostname. + pub(super) fn new(identity: Option<(u32, u32)>, publication: Publication) -> Self { Self { hostname: entry::DEFAULT_HOSTNAME.as_bytes().to_vec(), identity, + publication, } } } @@ -128,9 +132,9 @@ struct Run { } impl Executions { - /// Returns no executions, which run in process groups alone, with the default hostname and - /// without a workload identity, until [`run_in`](Self::run_in) names their cgroups and their - /// sandbox. + /// Returns no executions, which run in process groups alone, with the default hostname, + /// without a workload identity, and with published listeners, until + /// [`run_in`](Self::run_in) names their cgroups and their sandbox. pub(super) fn new() -> Self { Self { attached: BTreeMap::new(), @@ -139,7 +143,7 @@ impl Executions { reaped: None, cleared: None, cgroups: None, - sandbox: Sandbox::new(None), + sandbox: Sandbox::new(None, Publication::Published), prepared: 0, fenced: Vec::new(), turn: 0, @@ -546,10 +550,11 @@ impl Executions { } } - /// Returns how the program of `start` enters the root: with the sandbox's hostname, as the - /// identity that exactly one source names, the sandbox's workload identity, if it has one, or - /// otherwise the identity that the start names. A start that names an identity in a sandbox - /// with a workload identity, or none in a sandbox without one, is `EINVAL`. + /// Returns how the program of `start` enters the root: with the sandbox's hostname and its + /// publication of listeners, as the identity that exactly one source names, the sandbox's + /// workload identity, if it has one, or otherwise the identity that the start names. A start + /// that names an identity in a sandbox with a workload identity, or none in a sandbox without + /// one, is `EINVAL`. fn entrance(&self, start: &Start) -> io::Result { let identity = match (self.sandbox.identity, &start.identity) { (Some((uid, gid)), None) => entry::Identity::new(uid, gid), @@ -559,6 +564,7 @@ impl Executions { Ok(Entrance { identity, hostname: self.sandbox.hostname.clone(), + publication: self.sandbox.publication, }) } diff --git a/tests/runtime-adapter/tests/guest_session.rs b/tests/runtime-adapter/tests/guest_session.rs index 1382fabb1..e7cd2ac44 100644 --- a/tests/runtime-adapter/tests/guest_session.rs +++ b/tests/runtime-adapter/tests/guest_session.rs @@ -26,17 +26,18 @@ //! at once, byte-exact, a stalled sink and a full stream to the host, which stall only what waits //! on them, executions that take turns at the stream, timeouts, cancellations, and stops, the //! starts, messages, and probes that the worker refuses, generations and resets, the descendants -//! that a program leaves in its process group, and executions on a tty. The fake root prepares -//! each execution's entry, as the worker describes it, in a directory of the tests' own that holds -//! account databases, and then spawns the program without entering a root, which needs -//! privileges, in the tests' own root and identity: the tests cover the identity that exactly one -//! source names, the sandbox's or the start's, the entry's inputs, a program's working directory -//! and environment, and the starts that the worker refuses for their identities. Where the tests -//! can create cgroups, each fake root gives the worker a workload cgroup of the test's own, below -//! which the executions run in cgroups of their own, and settle: the tests then also cover a -//! descendant that leaves its program's process group, which the settlement ends, and an execution -//! that does not settle, which keeps its place until the host stops the worker. Elsewhere, the -//! programs run in process groups alone. +//! that a program leaves in its process group, and executions on a tty. The fake root prepares each +//! execution's entry, as the worker describes it, in a directory of the tests' own that holds +//! account databases, and then spawns the program without entering a root, which needs privileges, +//! in the tests' own root and identity: the tests cover the identity that exactly one source names, +//! the sandbox's or the start's, the entry's inputs, the publication of listeners that the root +//! configuration names, which refuses the capabilities that could forge a listener announcement +//! while listeners are published, a program's working directory and environment, and the starts +//! that the worker refuses for their identities. Where the tests can create cgroups, each fake root +//! gives the worker a workload cgroup of the test's own, below which the executions run in cgroups +//! of their own, and settle: the tests then also cover a descendant that leaves its program's +//! process group, which the settlement ends, and an execution that does not settle, which keeps its +//! place until the host stops the worker. Elsewhere, the programs run in process groups alone. #![cfg(all(feature = "guest", target_os = "linux"))] use std::collections::BTreeMap; @@ -63,7 +64,7 @@ use nix::sys::wait::{self, Id, WaitPidFlag, WaitStatus}; use nix::unistd::Pid; use nvx::exec::{Cause, Key, LaunchFailure, Limits, Outcome, Pool, Status, Stream, Timers, Window}; use nvx::guest::cgroup::Layout; -use nvx::guest::entry::{self, Capabilities, Capability, Prepared}; +use nvx::guest::entry::{self, Capabilities, Capability, Prepared, Publication}; use nvx::guest::mappings::{Access, Mapping}; use nvx::guest::nvxs::GuestConnectionError; use nvx::guest::spawn::{self, Spawned}; @@ -84,7 +85,8 @@ use runtime_adapter::frame::{Decoder, FrameError, HEADER_LEN, MAX_BODY, Message} use runtime_adapter::guest::{self, NO_ROOT, RECEIVE_WINDOW, ServeError, Served}; use runtime_adapter::probe::{Facts, Probe}; use runtime_adapter::root::{ - JournalPolicy, MapFailure, OverlayMode, Ready, RootConfig, RootFailure, RootStage, encode_maps, + JournalPolicy, Listeners, MapFailure, OverlayMode, Ready, RootConfig, RootFailure, RootStage, + encode_maps, }; use runtime_adapter::worker::{ self, Entrance, EntryOperations, MAX_EXECUTIONS, MapError, RootOperations, Worked, @@ -1857,11 +1859,13 @@ fn the_worker_reports_the_stage_that_failed_and_ends_with_its_status() { } } -/// Returns the entrance of an execution as `identity`, with the default hostname. +/// Returns the entrance of an execution as `identity`, with the default hostname and published +/// listeners, as a configuration that names no publication has them. fn entrance(identity: entry::Identity) -> Entrance { Entrance { identity, hostname: entry::DEFAULT_HOSTNAME.as_bytes().to_vec(), + publication: Publication::Published, } } @@ -3583,6 +3587,81 @@ fn an_execution_enters_the_root_as_the_identity_that_its_start_names() { assert_eq!(entrances(&root), expected); } +#[test] +fn an_execution_enters_the_root_with_the_publication_of_listeners_that_its_configuration_names() { + // Listeners are published unless the configuration names them unpublished. While they are, + // the entry refuses at stage 0 the capabilities that could forge a listener announcement, + // binding a privileged port and opening a raw socket, which it allows otherwise, as it allows + // the other capabilities of its allowlist either way. + let capabilities = [ + Capability::NET_BIND_SERVICE, + Capability::NET_RAW, + Capability::CHOWN, + ]; + for (listeners, publication) in [ + (None, Publication::Published), + (Some(Listeners::Published), Publication::Published), + (Some(Listeners::Unpublished), Publication::Unpublished), + ] { + let config = RootConfig { + listeners, + ..RootConfig::default() + }; + let mut host = Host::configured(FakeRoot::booting(&layout()), &config); + for (id, capability) in (1..).zip(capabilities) { + let identity = exec::Identity { + capabilities: 1 << capability.number(), + ..exec::Identity::new(0, 0) + }; + host.start( + start_of(id, shell("exit 0")).with_identity(identity), + Vec::new(), + ); + } + host.run_until(|host| (1..=3).all(|id| host.outcome(id).is_some())); + for (id, capability) in (1..).zip(capabilities) { + if publication == Publication::Published && capability != Capability::CHOWN { + let failure = LaunchFailure::new(0, Errno::EPERM as i32); + let outcome = host.outcome(id).unwrap(); + assert_eq!( + outcome.launch_failure(), + Some(failure), + "{listeners:?}: {capability}" + ); + } else { + check_lossless(&host, id, Status::Exited(0)); + } + } + let (root, worked) = host.stop(); + assert_eq!(worked.unwrap(), Worked::Stopped); + let expected = capabilities.map(|capability| Entrance { + publication, + ..entrance( + entry::Identity::new(0, 0).capabilities(Capabilities::EMPTY.with(capability)), + ) + }); + assert_eq!(entrances(&root), expected, "{listeners:?}"); + } + + // The check of a sandbox's workload identity enters with the configuration's publication, as + // each execution does. + let config = RootConfig { + listeners: Some(Listeners::Unpublished), + ..sandbox() + }; + let mut host = Host::configured(FakeRoot::booting(&layout()), &config); + host.start(start_of(1, shell("exit 0")), Vec::new()); + host.run_until(|host| host.outcome(1).is_some()); + check_lossless(&host, 1, Status::Exited(0)); + let (root, worked) = host.stop(); + assert_eq!(worked.unwrap(), Worked::Stopped); + let workload = Entrance { + publication: Publication::Unpublished, + ..entrance(entry::Identity::new(WORKLOAD.0, WORKLOAD.1)) + }; + assert_eq!(entrances(&root), [workload.clone(), workload]); +} + #[test] fn the_worker_refuses_at_stage_0_a_start_whose_identity_breaks_the_rules() { // Each refusal's terminal record tells its `errno`, and the refusal takes no place. diff --git a/tests/runtime-adapter/tests/layered_launch.rs b/tests/runtime-adapter/tests/layered_launch.rs index fca4753e2..e726a2d74 100644 --- a/tests/runtime-adapter/tests/layered_launch.rs +++ b/tests/runtime-adapter/tests/layered_launch.rs @@ -6,7 +6,9 @@ //! records what it launches, with the bindings that it checked, and refuses options that do not //! make a command, networks that the plan refuses before it launches anything, shares that it //! cannot export before it touches the store, executions whose starts the guest would not take, and -//! more executions than the guest runs at once. The tests need no hypervisor. +//! more executions than the guest runs at once. The root configuration says whether the network +//! publishes the guest's listeners, and executions find the URL of the network's proxy in their +//! environment, from the plan that the record keeps. The tests need no hypervisor. #![cfg(feature = "host")] use std::ffi::OsString; @@ -18,21 +20,23 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::time::{Duration, Instant}; +use nvx::exec::{Key, Limits, Timers, Window}; use nvx::host::artifacts::Artifacts; use nvx::host::filesystem::GUEST_EXPORT; use nvx::host::network; -use nvx::host::sandbox::SandboxPlan; use nvx::host::sandbox::scratch::CLAIM_RELEASE_WAIT; +use nvx::host::sandbox::{SandboxNetwork, SandboxPlan}; use nvx::protocol::root::{ImageFormat, RootImage, RootLayout, RootUpper}; use nvx::protocol::{HostMap, MAX_HOST_MAPPINGS}; use nvx::uuid::Uuid; use nvx::{ErrorCode, ErrorKind, Hypervisor}; -use runtime_adapter::exec::MAX_EXECUTIONS; +use runtime_adapter::exec::{MAX_EXECUTIONS, Start}; use runtime_adapter::hook::STEP_HOOK; use runtime_adapter::layered::{ - Bindings, LaunchRecord, LayeredLaunch, RECORD_NAME, SANDBOX_TOKEN, read_network, + Bindings, LaunchRecord, LayeredLaunch, PROXY_VARIABLES, RECORD_NAME, SANDBOX_TOKEN, + read_network, with_proxy, }; -use runtime_adapter::root::RootConfig; +use runtime_adapter::root::{Listeners, RootConfig}; use runtime_adapter::sharing::{HostFsSharing, Share}; use runtime_adapter::staged::Staged; use serde_json::{Value, json}; @@ -231,6 +235,7 @@ fn a_layered_launch_plans_its_blocks_and_boot_tokens_with_the_adapters_plan() { let config = RootConfig { distro_sectors: Some(3), scratch_sectors: Some(8), + listeners: Some(Listeners::Unpublished), ..RootConfig::default() }; assert_eq!(launch.config, config); @@ -372,6 +377,7 @@ fn a_layered_launch_passes_what_the_guest_checks_as_it_is() { .unwrap(); let config = RootConfig { runtime: "run/relative".to_owned(), + listeners: Some(Listeners::Unpublished), ..RootConfig::default() }; assert_eq!(launch.config, config); @@ -1127,8 +1133,7 @@ fn a_launch_renders_each_element_of_its_network_through_the_plan() { let path = directory.join(format!("{name}.json")); fs::write(&path, form.to_string()).unwrap(); let network = read_network(&path).unwrap(); - let mut launch = without.clone(); - launch.plan = launch.plan.with_network(Some(network.clone())); + let launch = without.clone().with_network(Some(network.clone())); // NVX's lowering renders the network, as it does for NVX's own sandbox profile, and the // plan puts it after the block devices, before the launch's own options. assert_eq!( @@ -1143,14 +1148,30 @@ fn a_launch_renders_each_element_of_its_network_through_the_plan() { .chain(options.iter().cloned()) .collect::>(); assert_eq!(arguments(&launch), expected, "{name}"); + assert_eq!(launch.plan.network(), Some(&network), "{name}"); assert_eq!(launch.plan.proxy_url().as_deref(), proxy_url, "{name}"); // The network adds no boot token: OpenVMM appends the guest's network settings itself. assert_eq!( launch.kernel_command_line().unwrap(), without.kernel_command_line().unwrap() ); + // The root configuration says whether OpenVMM publishes the guest's listeners: with + // ingress allowed, or with host-loopback access allowed, which forwards need, but not + // with outbound host-loopback access alone, nor through a proxy. + let published = ["ingress", "host-loopback-allowed", "forwards"].contains(&name); + let listeners = if published { + Listeners::Published + } else { + Listeners::Unpublished + }; + let config = RootConfig { + listeners: Some(listeners), + ..without.config.clone() + }; + assert_eq!(launch.config, config, "{name}"); - // The record keeps the network in the plan, as the file gave it. + // The record keeps the network in the plan, as the file gave it, and the root + // configuration that says whether the listeners are published. let record = LaunchRecord { kernel_command_line: "nvx_sandbox=1", layered: Some(&launch), @@ -1160,8 +1181,15 @@ fn a_launch_renders_each_element_of_its_network_through_the_plan() { let plan: SandboxPlan = serde_json::from_value(json["plan"].clone()).unwrap(); assert_eq!(plan, launch.plan, "{name}"); assert_eq!(plan.proxy_url().as_deref(), proxy_url, "{name}"); + let line = format!("\nlisteners={}\n", listeners.as_str()); + let encoded = json["rootConfiguration"].as_str().unwrap(); + assert!(encoded.ends_with(&line), "{name}: {encoded:?}"); + + // Taking the network away leaves the launch as it was without one. + assert_eq!(launch.with_network(None), without, "{name}"); } - // Without a network, the record says so. + // Without a network, the record says so, and nothing publishes the guest's listeners. + assert_eq!(without.config.listeners, Some(Listeners::Unpublished)); let record = LaunchRecord { kernel_command_line: "nvx_sandbox=1", layered: Some(&without), @@ -1355,6 +1383,159 @@ fn a_launch_reads_only_the_networks_that_the_plan_takes() { } } +/// Returns the start of an execution of `/usr/bin/env` with the environment of the `NAME=VALUE` +/// entries `environment`. +fn printing>>(environment: impl IntoIterator) -> Start { + let limits = Limits::new(16 * 1024, Window::frames(4), Window::frames(8)).unwrap(); + let timers = Timers::new(Duration::from_secs(1), Duration::from_secs(5)); + Start::new(Key::new(1, 2), limits, timers, ["/usr/bin/env"]).with_environment(environment) +} + +/// Returns the network of a guest at `10.0.0.2/24` whose one way out is a proxy at port 3128 of +/// host loopback, with the scheme `scheme`, which the test `directory` reads from a file. +fn proxy_only(directory: &Path, scheme: Value) -> SandboxNetwork { + let form = network_with(|network| { + network["egress"] = json!("deny"); + network["proxy"] = json!({"port": 3128, "scheme": scheme}); + }); + let path = directory.join("proxy.json"); + fs::write(&path, form.to_string()).unwrap(); + read_network(&path).unwrap() +} + +#[test] +fn an_execution_finds_the_url_of_its_network_s_proxy_in_its_environment() { + let directory = directory("layered-proxy"); + let distro = image(directory.join("distro.erofs"), 512); + let without = LayeredLaunch::new(&distro, ImageFormat::Erofs, identity(), None, None).unwrap(); + let own = ["PATH=/usr/bin:/bin", "EMPTY=", "TWICE=a=b"]; + + // Each variable holds the URL of the proxy as the guest sees it, at its gateway, after the + // start's own entries, which the start carries to the guest as its own. + for (scheme, url) in [ + (Value::Null, "10.0.0.1:3128"), + (json!("http"), "http://10.0.0.1:3128"), + (json!("https"), "https://10.0.0.1:3128"), + ] { + let launch = without + .clone() + .with_network(Some(proxy_only(&directory, scheme))); + let given = with_proxy(printing(own), &launch.plan); + let proxies = PROXY_VARIABLES.map(|variable| format!("{variable}={url}")); + let expected = own.into_iter().map(str::to_owned).chain(proxies); + assert_eq!(given, printing(expected), "{url}"); + assert_eq!(Start::decode(&given.encode()), Ok(given), "{url}"); + } + assert_eq!( + PROXY_VARIABLES, + ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"] + ); + + // A start whose caller chose its own proxy settings keeps them, even if they name only one of + // the variables, since a client that reads another would not find the caller's. + let launch = without + .clone() + .with_network(Some(proxy_only(&directory, json!("http")))); + for variable in PROXY_VARIABLES { + let chosen = [ + format!("{variable}=http://proxy.example:8080"), + "A=1".to_owned(), + ]; + let start = printing(chosen.clone()); + assert_eq!( + with_proxy(start, &launch.plan), + printing(chosen), + "{variable}" + ); + } + // A name that only starts or ends like one of them is not one. + let similar = ["http_proxy_port=1", "no_proxy=*", "XHTTP_PROXY=x"]; + let given = with_proxy(printing(similar), &launch.plan); + assert_eq!( + given.environment.len(), + similar.len() + PROXY_VARIABLES.len() + ); + + // Without a proxy, an execution's environment is its start's, whatever the network. + let path = directory.join("open.json"); + fs::write(&path, open_network().to_string()).unwrap(); + let open = without + .clone() + .with_network(Some(read_network(&path).unwrap())); + for launch in [&without, &open] { + assert_eq!(with_proxy(printing(own), &launch.plan), printing(own)); + } +} + +#[test] +fn the_record_of_a_launch_gives_back_its_plan() { + let directory = directory("layered-record-plan"); + let distro = image(directory.join("distro.erofs"), 512); + let scratch = image(directory.join("scratch.ext4"), 512); + let empty = directory.join("store"); + fs::create_dir_all(&empty).unwrap(); + let error = LaunchRecord::read_plan(&empty).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + + // The plan of a layered root, with its network, from which the host gives executions the + // URL of the network's proxy. + let launch = LayeredLaunch::new( + &distro, + ImageFormat::Erofs, + identity(), + Some(&scratch), + None, + ) + .unwrap() + .with_network(Some(proxy_only(&directory, json!("http")))); + let record = LaunchRecord { + kernel_command_line: "nvx_sandbox=1", + layered: Some(&launch), + }; + record.write(&directory).unwrap(); + let plan = LaunchRecord::read_plan(&directory).unwrap().unwrap(); + assert_eq!(plan, launch.plan); + let given = with_proxy(printing(["A=1"]), &plan); + assert_eq!( + given.environment.last(), + Some(&b"HTTPS_PROXY=http://10.0.0.1:3128".to_vec()) + ); + // A VM without a layered root has no plan. + let blockless = LaunchRecord { + kernel_command_line: "hostname=nvx-sandbox", + layered: None, + }; + blockless.write(&directory).unwrap(); + assert_eq!(LaunchRecord::read_plan(&directory).unwrap(), None); + + // A record that is not one of this format's, or whose plan is not a plan, is unavailable. + let mut json = record.to_json().unwrap(); + let mut cases = vec![("{".to_owned(), "EOF while parsing")]; + json["format"] = json!(2); + cases.push((json.to_string(), "the record is not in format 1")); + json["format"] = json!(1); + json.as_object_mut().unwrap().remove("plan"); + cases.push((json.to_string(), "the record has no plan")); + json["plan"] = json!({"distro": 1}); + cases.push((json.to_string(), "")); + cases.push(("[1]".to_owned(), "the record is not in format 1")); + for (contents, cause) in cases { + fs::write(directory.join(RECORD_NAME), &contents).unwrap(); + let error = LaunchRecord::read_plan(&directory).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable, "{contents}: {error}"); + let path = directory.join(RECORD_NAME); + assert_eq!( + error.message(), + format!( + "cannot read the plan of the launch record {}", + path.display() + ) + ); + let source = std::error::Error::source(&error).unwrap().to_string(); + assert!(source.contains(cause), "{contents}: {source}"); + } +} + #[test] fn the_host_program_refuses_a_network_before_it_launches() { let directory = directory("layered-network-command"); diff --git a/tests/runtime-adapter/tests/vm_layered.rs b/tests/runtime-adapter/tests/vm_layered.rs index 93908e3aa..6f51561d2 100644 --- a/tests/runtime-adapter/tests/vm_layered.rs +++ b/tests/runtime-adapter/tests/vm_layered.rs @@ -44,7 +44,9 @@ use nvx::gpt::{Disk, Expected, ExpectedPartition}; use nvx::uuid::Uuid; use runtime_adapter::GUEST_BUILD; use runtime_adapter::layered::RECORD_NAME; -use runtime_adapter::root::{DEFAULT_RUNTIME, JournalPolicy, OverlayMode, RootConfig, RootStage}; +use runtime_adapter::root::{ + DEFAULT_RUNTIME, JournalPolicy, Listeners, OverlayMode, RootConfig, RootStage, +}; use runtime_adapter::staged::STAGED_NAME; use serde_json::{Value, json}; @@ -990,8 +992,9 @@ fn assert_failed(printed: &Printed, stage: RootStage, problem: &str) { /// Checks what the launch recorded in the sandbox's store: a VM with the layered `root` of the /// image `distro`, over `scratch` or RAM, the image of the identity that the launch expects, the /// bindings that the store stages for the images, which the launch checked, the boot tokens of its -/// plan alone, after the launch's own, and the root configuration that the host sent, whether or -/// not the guest accepted it, which it returns. +/// plan alone, after the launch's own, and the root configuration that the host sent, with +/// unpublished listeners, since the VM has no network, whether or not the guest accepted it, which +/// it returns. fn check_record(sandbox: &Sandbox, root: &Root, distro: &Distro, scratch: &Path) -> RootConfig { let read = |name: &str| -> Value { serde_json::from_str(&fs::read_to_string(sandbox.store.join(name)).unwrap()).unwrap() @@ -1051,6 +1054,7 @@ fn check_record(sandbox: &Sandbox, root: &Root, distro: &Distro, scratch: &Path) scratch_journal: root.journal, mapping_count: root.table.mapping_count(), workload_identity: root.workload, + listeners: Some(Listeners::Unpublished), ..RootConfig::default() }; let sent = String::from_utf8(config.encode()).unwrap(); From 72eed0fd49be82bfd42a126b628c8784b35ab00b Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 24 Oct 2026 11:02:01 -0700 Subject: [PATCH 2/3] tests: give the runtime adapter's exec the proxy of the VM's network The host program's exec reads the plan that the launch recorded, and gives each execution the URL of the proxy of the VM's network, if any, in its environment with layered::with_proxy, after its --env entries, unless they name the proxy variables themselves. The VM scenarios can now give their VM a network, which the launch reads from a file that the scenario writes. In a proxy-only network, the executions find the proxy's URL in their environment, and root may hold CAP_NET_BIND_SERVICE and CAP_NET_RAW. A network with ingress allowed publishes the guest's listeners, so the guest refuses both at stage 0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7c15634-094a-4a66-af59-25a58e732f4b --- tests/runtime-adapter/README.md | 23 ++- .../src/bin/runtime-adapter-host.rs | 30 ++-- tests/runtime-adapter/src/layered.rs | 6 +- tests/runtime-adapter/tests/vm_layered.rs | 157 ++++++++++++++++-- 4 files changed, 185 insertions(+), 31 deletions(-) diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index 2128d544d..70a24fc7b 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -1010,12 +1010,13 @@ The guest's executions follow the network: `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW`, with which an execution could bind the privileged port from which the announcer sends, or forge its datagrams, at stage 0 with `EPERM`. -- The host gives an execution the URL of a proxy-only network's proxy in its - environment, with `layered::with_proxy`, since NVX gives an execution no - proxy settings, and its environment is exactly its start's: in each of - `layered::PROXY_VARIABLES`, `http_proxy`, `https_proxy`, `HTTP_PROXY`, and - `HTTPS_PROXY`, after the start's own entries. A start that names any of - them keeps its own proxy settings. +- `runtime-adapter-host exec` gives each of its executions the URL of a + proxy-only network's proxy in its environment, from the plan that the + launch's record keeps, with `layered::with_proxy`, since NVX gives an + execution no proxy settings, and its environment is exactly its start's: in + each of `layered::PROXY_VARIABLES`, `http_proxy`, `https_proxy`, + `HTTP_PROXY`, and `HTTPS_PROXY`, after the start's own entries. A start that + names any of them keeps its own proxy settings. ### The step hook @@ -1143,7 +1144,10 @@ the directory that `NVX_ARTIFACTS_DIR` names, and the guest agent's build from GID, with the supplementary groups GIDs and the capabilities numbered N, such as 0 for `CAP_CHOWN`, if given, and the guest checks its account, unless `--numeric` says that the IDs have none. It starts in the root's directory - DIR, or `/`, with exactly the environment of its `--env` entries. It runs for + DIR, or `/`, with exactly the environment of its `--env` entries, then, if + the VM's [network](#networks) has a proxy, the proxy's URL as the guest sees + it in `http_proxy`, `https_proxy`, `HTTP_PROXY`, and `HTTPS_PROXY`, unless + the entries name any of them. It runs for at most a minute, with 16 KiB chunks and the credit of 4 of them for its input and 8 for its output, which its standard output and error share. The command keeps at most 16 MiB of each stream, and cancels an execution whose @@ -1609,11 +1613,14 @@ stages its images, then launches them, and is a row of the table that its | `executions_run_in_the_root` | The image over the scratch, without a workload identity, in which `exec` runs executions that name their identities | Over one session, four executions as `nobody` that meet only while all of them run, beside one as root that writes more than a MiB to its standard output and a little to its standard error, each byte-exact, ending on its own with 0, and reusable. Over a later session, with new keys: a program that leaves a descendant in a session of its own, which the program's end, as PID 1 of its namespace, ends, so that no output is lost; `nobody`'s IDs and groups, with empty permitted, effective, and bounding sets; root with `CAP_CHOWN` alone in each of them; exactly the start's environment; the start's working directory; the entry's default hostname, `nvx-sandbox`, and PID 1; and a start without an identity, and one with `CAP_SYS_ADMIN`, which the guest refuses at stage 0 with `EINVAL` and `EPERM`, fenced and without output | | `executions_run_as_the_workload_identity` | The image over the scratch, with the workload identity of `nobody` | Executions that name no identity, which run as `nobody`, without capabilities, with exactly the start's environment, and a start that names an identity, which the guest refuses at stage 0 with `EINVAL` | | `accountless_workload_identity_fails_its_stage` | The image over the scratch, with a workload identity whose account the image lacks | The identity stage's failure once the guest assembled the root, which the launch reports, and a VM that ends with 16, which unmounted the scratch | +| `executions_take_the_settings_of_a_proxy_only_network` | The image over the scratch, in a proxy-only network of a guest at `10.0.0.2/24`, whose proxy listens at port 3128 of host loopback | A root configuration whose listeners are unpublished; executions that find the proxy's URL, `http://10.0.0.1:3128`, in `http_proxy`, `https_proxy`, `HTTP_PROXY`, and `HTTPS_PROXY`, after their own entries, but one that names `HTTPS_PROXY` itself, which keeps exactly its own environment; and root with `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW`, which hold them | +| `published_listeners_refuse_binding_and_raw_sockets` | The image over the scratch, in a network of a guest at `10.0.0.2/24` with ingress allowed, through which OpenVMM publishes the guest's listeners | A root configuration whose listeners are published; root with `CAP_NET_BIND_SERVICE`, and with `CAP_NET_RAW`, which the guest refuses at stage 0 with `EPERM`, fenced and without output, and with `CAP_CHOWN`, which holds it; and an execution with exactly its start's environment, without a proxy | A row names the root, the image, the GPT disk's top-layer variant, the identity that the launch expects of the image, the upper, the runtime directory, the overlay mode, the scratch's journal policy, the bound of the -first partition, the sandbox's workload identity, and the host paths that the +first partition, the sandbox's workload identity, the VM's network, which the +launch reads from a file that the scenario writes, and the host paths that the launch shares, and what the scenario expects: a root that the guest assembles, with a check that probes it or runs executions in it, a teardown that fails after a check, the stage that fails, with its problem, a mapping of the table diff --git a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs index 874127263..47be95b22 100644 --- a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs +++ b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs @@ -91,14 +91,17 @@ //! ID GID, with the supplementary groups GIDs and the capabilities numbered N, such as 0 for //! `CAP_CHOWN`, if given, and the guest checks its account, unless `--numeric` says that the IDs //! have none. It starts in the root's directory DIR, or `/`, with exactly the environment of its -//! `--env` entries. It runs for at most a minute, with 16 KiB chunks and the credit of 4 of them -//! for its input and 8 for its output, which its standard output and error share. The command -//! keeps at most 16 MiB of each stream, and cancels an execution whose stream would exceed them; -//! see `runtime_adapter::exec::MAX_CAPTURE`. Execution N, from 1, has the key `(B + N, E)`: E is -//! the epoch of the command's attachment, and B the first 8 bytes of the broker instance's ID, as -//! a little-endian number. The guest refuses a key that it heard of before, and the epochs of a -//! broker instance only advance, while a broker restored from a snapshot has a new instance ID, -//! so no command repeats the key of another's execution. +//! `--env` entries, then, if the VM's network has a proxy, the proxy's URL as the guest sees it +//! in `http_proxy`, `https_proxy`, `HTTP_PROXY`, and `HTTPS_PROXY`, unless the entries name any +//! of them; see `runtime_adapter::layered::with_proxy`. It runs for at most a minute, with 16 KiB +//! chunks and the credit of 4 of them for its input and 8 for its output, which its standard +//! output and error share. The command keeps at most 16 MiB of each stream, and cancels an +//! execution whose stream would exceed them; see `runtime_adapter::exec::MAX_CAPTURE`. Execution +//! N, from 1, has the key `(B + N, E)`: E is the epoch of the command's attachment, and B the +//! first 8 bytes of the broker instance's ID, as a little-endian number. The guest refuses a key +//! that it heard of before, and the epochs of a broker instance only advance, while a broker +//! restored from a snapshot has a new instance ID, so no command repeats the key of another's +//! execution. //! //! Each command but `stage` takes NVX's artifacts from the directory that `NVX_ARTIFACTS_DIR` //! names, a repository checkout or an extracted release, and the VM's runtime identity from NVX's @@ -178,7 +181,7 @@ use runtime_adapter::frame::{MAX_BODY, Message}; use runtime_adapter::hook::{Step, StepHook}; use runtime_adapter::host::{self, Client, ClientError, Executions, OrderError}; use runtime_adapter::initramfs; -use runtime_adapter::layered::{LaunchRecord, LayeredLaunch, read_network}; +use runtime_adapter::layered::{LaunchRecord, LayeredLaunch, read_network, with_proxy}; use runtime_adapter::probe::{Probe, ProbeReply, escape}; use runtime_adapter::root::{JournalPolicy, OverlayMode, RootFailure}; use runtime_adapter::runtime::Runtime; @@ -1291,6 +1294,9 @@ fn exec(store: &Path, guest_build: &str, executions: &[Execution]) -> Result vmm, Err(status) => return Ok(status), }; + // Each execution finds the URL of the proxy of the VM's network, if any, in its environment, + // from the plan that the launch recorded. + let plan = LaunchRecord::read_plan(store.dir()).map_err(|error| describe(&error))?; let deadline = Instant::now() + SESSION_TIMEOUT; let capability = store.read_capability().map_err(|error| describe(&error))?; let mut client = connect(&vmm, &capability, deadline)?; @@ -1302,8 +1308,12 @@ fn exec(store: &Path, guest_build: &str, executions: &[Execution]) -> Result with_proxy(execution.start(key), plan), + None => execution.start(key), + }; running - .start(&mut client, execution.start(key), exec_timers(), deadline) + .start(&mut client, start, exec_timers(), deadline) .map_err(|error| format!("cannot start execution {number}: {}", describe(&error)))?; ids.push(key.id()); } diff --git a/tests/runtime-adapter/src/layered.rs b/tests/runtime-adapter/src/layered.rs index c035ffc6a..f22888950 100644 --- a/tests/runtime-adapter/src/layered.rs +++ b/tests/runtime-adapter/src/layered.rs @@ -41,9 +41,9 @@ //! listeners are published, as OpenVMM publishes them: to the host's private networks with //! ingress allowed, or on host loopback with host-loopback access allowed. The executions enter //! the root with that publication, which refuses `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW` while -//! listeners are published, since either could forge an announcement. The host gives an -//! execution the URL of a proxy-only network's proxy, as the guest sees it, in its environment, -//! with [`with_proxy`]. +//! listeners are published, since either could forge an announcement. The host program's `exec` +//! gives each execution the URL of a proxy-only network's proxy, as the guest sees it, in its +//! environment, with [`with_proxy`], from the plan that [`LaunchRecord::read_plan`] reads back. //! //! The host records what it launched in the sandbox's store, beside NVX's runtime records, as a //! [`LaunchRecord`], with the bindings that the launch checked, and the plan, which keeps the diff --git a/tests/runtime-adapter/tests/vm_layered.rs b/tests/runtime-adapter/tests/vm_layered.rs index 6f51561d2..689d464a3 100644 --- a/tests/runtime-adapter/tests/vm_layered.rs +++ b/tests/runtime-adapter/tests/vm_layered.rs @@ -6,7 +6,8 @@ //! images of a [`Root`], launches a VM with it, and checks what it [`Expect`]s: a root that the //! guest assembles, which the scenario probes, or runs executions in through the host program's //! `exec`, and the stop tears down, the stage of the root that fails, or a launch that the host -//! program refuses before it spawns the VM, since the scenario changed what it staged. A new +//! program refuses before it spawns the VM, since the scenario changed what it staged. A root may +//! give the VM a [`Network`], which the launch reads from a file that the scenario writes. A new //! scenario is a new row, with its own checks, through the probes of the worker or executions. //! //! The tests boot VMs, so they are ignored. Besides what the `common` module lists, they read its @@ -143,6 +144,10 @@ const EPERM: i32 = 1; const EINVAL: i32 = 22; /// The number of executions that meet only while all of them run. const MEETING: usize = 4; +/// The URL of the proxy of the scenarios' proxy-only [`Network`] as the guest sees it, at the +/// gateway of the guest's network, `10.0.0.2/24`: the proxy at port 3128 of host loopback, which +/// no execution reaches. +const PROXY_URL: &str = "http://10.0.0.1:3128"; /// A script that starts a descendant that leads a session of its own and holds the script's /// output: the descendant prints its process ID, which is its session's ID, then moves its output /// to the script's, which ends the command substitution that reads the ID. The script prints @@ -199,6 +204,48 @@ enum Upper { Ram, } +/// The network of a scenario's launch, which the launch reads from a file that the test writes, +/// in the serde form of a plan's network: the guest at `10.0.0.2/24`, with egress denied. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Network { + /// A proxy-only network, whose one way out is the proxy at port 3128 of host loopback, with + /// the scheme `http`, which the guest reaches at its gateway, at [`PROXY_URL`]. + Proxy, + /// Ingress allowed, so that OpenVMM publishes the guest's listeners to the host's private + /// networks. + Ingress, +} + +impl Network { + /// Returns the network's serde form. + fn form(self) -> Value { + let mut form = json!({ + "guestNetwork": "10.0.0.2/24", + "egress": "deny", + "allow": {"name": "egress.allow", "rules": []}, + "deny": {"name": "egress.deny", "rules": []}, + "ingress": "deny", + "hostLoopback": "deny", + "hostLoopbackForwards": [], + "proxy": null, + }); + match self { + Self::Proxy => form["proxy"] = json!({"port": 3128, "scheme": "http"}), + Self::Ingress => form["ingress"] = json!("allow"), + } + form + } + + /// Returns whether OpenVMM publishes the guest's listeners through the network, as the root + /// configuration says. + fn listeners(self) -> Listeners { + match self { + Self::Proxy => Listeners::Unpublished, + Self::Ingress => Listeners::Published, + } + } +} + /// The layered root of a scenario's launch. #[derive(Clone, Copy, Debug)] struct Root { @@ -223,6 +270,8 @@ struct Root { workload: Option<(u32, u32)>, /// The host paths that the launch shares with the root. table: Table, + /// The VM's network, as the launch reads it, if any. + network: Option, } /// The root of the scenarios unless they say otherwise: NVX's EROFS image, with its own identity, @@ -238,6 +287,7 @@ const ROOT: Root = Root { first_partition_bound: None, workload: None, table: Table::None, + network: None, }; /// The root of the scenarios of a GPT disk unless they say otherwise: the disk, with its own GUID, @@ -300,10 +350,11 @@ impl Root { options } - /// Returns the launch's options of the root, with the image `distro`, and the `scratch` image - /// for a root over a scratch: its [images](Self::images), an upper in RAM for a root that is - /// not over a scratch, and the options of the root configuration that the root names. - fn options(&self, distro: &Distro, scratch: &Path) -> Vec { + /// Returns the launch's options of the root, with the image `distro`, the `scratch` image for a + /// root over a scratch, and the file `network` of the network of a root with one: its + /// [images](Self::images), an upper in RAM for a root that is not over a scratch, the options + /// of the root configuration that the root names, and the network's file. + fn options(&self, distro: &Distro, scratch: &Path, network: &Path) -> Vec { let mut options = self.images(distro, scratch); if self.upper == Upper::Ram { options.extend(["--upper".into(), "ram".into()]); @@ -323,6 +374,9 @@ impl Root { if let Some((uid, gid)) = self.workload { options.extend(["--workload-identity".into(), format!("{uid}:{gid}").into()]); } + if self.network.is_some() { + options.extend(["--network".into(), network.into()]); + } options } @@ -837,6 +891,20 @@ scenarios! { "cannot enter the root as the workload identity 4242:4242: ", ), }; + /// Executions in a proxy-only network, which publishes no listener: each finds the proxy's URL + /// in its environment, after its own entries, unless it names its own proxy settings, and root + /// may hold the capabilities that bind privileged ports and open raw sockets. + executions_take_the_settings_of_a_proxy_only_network: Scenario { + root: Root { network: Some(Network::Proxy), ..ROOT }, + expect: Expect::Ready(check_proxy_settings), + }; + /// Executions in a network that publishes the guest's listeners: the guest refuses root the + /// capabilities that could forge a listener announcement at stage 0, but not the others, and + /// an execution's environment, without a proxy, is exactly its start's. + published_listeners_refuse_binding_and_raw_sockets: Scenario { + root: Root { network: Some(Network::Ingress), ..ROOT }, + expect: Expect::Ready(check_published_listeners), + }; } /// Runs the scenario `name`, `scenario`. @@ -852,7 +920,11 @@ fn run(name: &str, scenario: Scenario) { let distro = root.distro(&fixtures, &sandbox.root); let images = root.images(&distro, &scratch); let images = images.iter().map(OsString::as_os_str).collect::>(); - let mut options = root.options(&distro, &scratch); + let network = sandbox.root.join("network.json"); + if let Some(form) = root.network.map(Network::form) { + fs::write(&network, form.to_string()).unwrap(); + } + let mut options = root.options(&distro, &scratch, &network); if root.table != Table::None { write_sources(&sandbox.root); options.extend(root.table.options(&sandbox.root)); @@ -991,9 +1063,10 @@ fn assert_failed(printed: &Printed, stage: RootStage, problem: &str) { /// Checks what the launch recorded in the sandbox's store: a VM with the layered `root` of the /// image `distro`, over `scratch` or RAM, the image of the identity that the launch expects, the -/// bindings that the store stages for the images, which the launch checked, the boot tokens of its -/// plan alone, after the launch's own, and the root configuration that the host sent, with -/// unpublished listeners, since the VM has no network, whether or not the guest accepted it, which +/// bindings that the store stages for the images, which the launch checked, the network, if any, as +/// the scenario's file gave it, the boot tokens of its plan alone, after the launch's own, and the +/// root configuration that the host sent, with listeners published as OpenVMM publishes them +/// through the network, and unpublished without one, whether or not the guest accepted it, which /// it returns. fn check_record(sandbox: &Sandbox, root: &Root, distro: &Distro, scratch: &Path) -> RootConfig { let read = |name: &str| -> Value { @@ -1016,6 +1089,10 @@ fn check_record(sandbox: &Sandbox, root: &Root, distro: &Distro, scratch: &Path) assert_eq!(plan["distro"]["format"], distro.format); assert_eq!(plan["distro"]["identity"], distro.identity.as_str()); assert_eq!(plan["settings"], Value::Null); + assert_eq!( + plan["network"], + root.network.map_or(Value::Null, Network::form) + ); let path = |value: &Value| PathBuf::from(value.as_str().unwrap()); assert_eq!(path(&plan["distro"]["path"]), distro.path); let upper = match root.upper { @@ -1054,7 +1131,10 @@ fn check_record(sandbox: &Sandbox, root: &Root, distro: &Distro, scratch: &Path) scratch_journal: root.journal, mapping_count: root.table.mapping_count(), workload_identity: root.workload, - listeners: Some(Listeners::Unpublished), + listeners: Some( + root.network + .map_or(Listeners::Unpublished, Network::listeners), + ), ..RootConfig::default() }; let sent = String::from_utf8(config.encode()).unwrap(); @@ -2374,3 +2454,60 @@ fn check_workload_executions(vm: &mut Vm<'_>) { assert_ran(&printed, 2, 0, "ONLY=this\n", ""); assert_refused(&printed, 3, EINVAL); } + +/// Checks the VM of the `executions_take_the_settings_of_a_proxy_only_network` scenario, whose +/// network's proxy the guest reaches at [`PROXY_URL`]. +fn check_proxy_settings(vm: &mut Vm<'_>) { + assert_eq!(vm.config.listeners, Some(Listeners::Unpublished)); + let nobody = ids(NOBODY); + let own = "HTTPS_PROXY=http://proxy.example:8080"; + let executions = [ + execution( + &["--identity", &nobody, "--env", "FIRST=1"], + &["/usr/bin/env"], + ), + execution(&["--identity", &nobody, "--env", own], &["/usr/bin/env"]), + execution( + &["--identity", "0:0", "--capabilities", "10,13"], + &["/bin/sh", "-c", IDENTITY_SCRIPT], + ), + ]; + let printed = vm.exec(&executions); + let proxies = ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"] + .map(|variable| format!("{variable}={PROXY_URL}\n")) + .concat(); + assert_ran(&printed, 1, 0, &format!("FIRST=1\n{proxies}"), ""); + assert_ran(&printed, 2, 0, &format!("{own}\n"), ""); + // `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW`, numbers 10 and 13, in each set. + let facts = identity_facts(0, 0, "0", 1 << 10 | 1 << 13); + assert_ran(&printed, 3, 0, &facts, ""); +} + +/// Checks the VM of the `published_listeners_refuse_binding_and_raw_sockets` scenario, whose +/// network publishes the guest's listeners. +fn check_published_listeners(vm: &mut Vm<'_>) { + assert_eq!(vm.config.listeners, Some(Listeners::Published)); + let executions = [ + execution( + &["--identity", "0:0", "--capabilities", "10"], + &["/bin/true"], + ), + execution( + &["--identity", "0:0", "--capabilities", "13"], + &["/bin/true"], + ), + execution( + &["--identity", "0:0", "--capabilities", "0"], + &["/bin/sh", "-c", IDENTITY_SCRIPT], + ), + execution( + &["--identity", &ids(NOBODY), "--env", "ONLY=this"], + &["/usr/bin/env"], + ), + ]; + let printed = vm.exec(&executions); + assert_refused(&printed, 1, EPERM); + assert_refused(&printed, 2, EPERM); + assert_ran(&printed, 3, 0, &identity_facts(0, 0, "0", 1), ""); + assert_ran(&printed, 4, 0, "ONLY=this\n", ""); +} From 42ec8ddcc96eea83b592917ac74ddd8c83207d1b Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 24 Oct 2026 14:08:18 -0700 Subject: [PATCH 3/3] tests: check the proxied starts of exec before it attaches The proxy's variables lengthen an execution's start, so a start that the framing carried when exec parsed its command line may no longer fit once exec gives it the proxy of the VM's network. exec found out only as it sent that start, after it had sent the starts before it, and then failed without collecting their outcomes. exec now gives every start the proxy's variables, and checks each as it checked them without the proxy, before it attaches; it runs none of the executions if one no longer fits. A start takes its key once exec has attached, and a key's encoding has the same size whatever its value. On Linux, whose command lines carry a start as long as the framing allows, the proxy-only VM scenario checks that exec refuses such a batch before it attaches, that none of its executions ran, and that the VM still runs the next command's executions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7c15634-094a-4a66-af59-25a58e732f4b --- tests/runtime-adapter/README.md | 24 +++---- .../src/bin/runtime-adapter-host.rs | 60 +++++++++++------ tests/runtime-adapter/tests/vm_layered.rs | 66 ++++++++++++++++++- 3 files changed, 117 insertions(+), 33 deletions(-) diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index 70a24fc7b..daf5b20cb 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -1147,17 +1147,17 @@ the directory that `NVX_ARTIFACTS_DIR` names, and the guest agent's build from DIR, or `/`, with exactly the environment of its `--env` entries, then, if the VM's [network](#networks) has a proxy, the proxy's URL as the guest sees it in `http_proxy`, `https_proxy`, `HTTP_PROXY`, and `HTTPS_PROXY`, unless - the entries name any of them. It runs for - at most a minute, with 16 KiB chunks and the credit of 4 of them for its - input and 8 for its output, which its standard output and error share. The - command keeps at most 16 MiB of each stream, and cancels an execution whose - stream would exceed them. - Execution N, from 1, has the key `(B + N, E)`: E is the epoch of the - command's attachment, and B the first 8 bytes of the broker instance's ID, as - a little-endian number. The guest refuses a key that it heard of before, and - the epochs of a broker instance only advance, while a broker restored from a - snapshot has a new instance ID, so no command repeats the key of another's - execution. + the entries name any of them. Before it attaches, the command checks that the + framing still carries each start with the proxy's URL, and runs none of the + executions if one no longer fits. It runs for at most a minute, with 16 KiB + chunks and the credit of 4 of them for its input and 8 for its output, which + its standard output and error share. The command keeps at most 16 MiB of each + stream, and cancels an execution whose stream would exceed them. Execution N, + from 1, has the key `(B + N, E)`: E is the epoch of the command's attachment, + and B the first 8 bytes of the broker instance's ID, as a little-endian + number. The guest refuses a key that it heard of before, and the epochs of a + broker instance only advance, while a broker restored from a snapshot has a + new instance ID, so no command repeats the key of another's execution. `reattach`, `stop`, `probe`, and `exec` find a VM that another runtime identity launched mismatched, and then signal nothing: the VM keeps running. The commands @@ -1613,7 +1613,7 @@ stages its images, then launches them, and is a row of the table that its | `executions_run_in_the_root` | The image over the scratch, without a workload identity, in which `exec` runs executions that name their identities | Over one session, four executions as `nobody` that meet only while all of them run, beside one as root that writes more than a MiB to its standard output and a little to its standard error, each byte-exact, ending on its own with 0, and reusable. Over a later session, with new keys: a program that leaves a descendant in a session of its own, which the program's end, as PID 1 of its namespace, ends, so that no output is lost; `nobody`'s IDs and groups, with empty permitted, effective, and bounding sets; root with `CAP_CHOWN` alone in each of them; exactly the start's environment; the start's working directory; the entry's default hostname, `nvx-sandbox`, and PID 1; and a start without an identity, and one with `CAP_SYS_ADMIN`, which the guest refuses at stage 0 with `EINVAL` and `EPERM`, fenced and without output | | `executions_run_as_the_workload_identity` | The image over the scratch, with the workload identity of `nobody` | Executions that name no identity, which run as `nobody`, without capabilities, with exactly the start's environment, and a start that names an identity, which the guest refuses at stage 0 with `EINVAL` | | `accountless_workload_identity_fails_its_stage` | The image over the scratch, with a workload identity whose account the image lacks | The identity stage's failure once the guest assembled the root, which the launch reports, and a VM that ends with 16, which unmounted the scratch | -| `executions_take_the_settings_of_a_proxy_only_network` | The image over the scratch, in a proxy-only network of a guest at `10.0.0.2/24`, whose proxy listens at port 3128 of host loopback | A root configuration whose listeners are unpublished; executions that find the proxy's URL, `http://10.0.0.1:3128`, in `http_proxy`, `https_proxy`, `HTTP_PROXY`, and `HTTPS_PROXY`, after their own entries, but one that names `HTTPS_PROXY` itself, which keeps exactly its own environment; and root with `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW`, which hold them | +| `executions_take_the_settings_of_a_proxy_only_network` | The image over the scratch, in a proxy-only network of a guest at `10.0.0.2/24`, whose proxy listens at port 3128 of host loopback | A root configuration whose listeners are unpublished; executions that find the proxy's URL, `http://10.0.0.1:3128`, in `http_proxy`, `https_proxy`, `HTTP_PROXY`, and `HTTPS_PROXY`, after their own entries, but one that names `HTTPS_PROXY` itself, which keeps exactly its own environment; root with `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW`, which hold them; and, on Linux, a batch with a start that the framing carries alone but not with the proxy's URL, which `exec` refuses before it attaches, running none of its executions | | `published_listeners_refuse_binding_and_raw_sockets` | The image over the scratch, in a network of a guest at `10.0.0.2/24` with ingress allowed, through which OpenVMM publishes the guest's listeners | A root configuration whose listeners are published; root with `CAP_NET_BIND_SERVICE`, and with `CAP_NET_RAW`, which the guest refuses at stage 0 with `EPERM`, fenced and without output, and with `CAP_CHOWN`, which holds it; and an execution with exactly its start's environment, without a proxy | A row names the root, the image, the GPT disk's top-layer variant, the diff --git a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs index 47be95b22..26bde76f4 100644 --- a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs +++ b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs @@ -93,15 +93,16 @@ //! have none. It starts in the root's directory DIR, or `/`, with exactly the environment of its //! `--env` entries, then, if the VM's network has a proxy, the proxy's URL as the guest sees it //! in `http_proxy`, `https_proxy`, `HTTP_PROXY`, and `HTTPS_PROXY`, unless the entries name any -//! of them; see `runtime_adapter::layered::with_proxy`. It runs for at most a minute, with 16 KiB -//! chunks and the credit of 4 of them for its input and 8 for its output, which its standard -//! output and error share. The command keeps at most 16 MiB of each stream, and cancels an -//! execution whose stream would exceed them; see `runtime_adapter::exec::MAX_CAPTURE`. Execution -//! N, from 1, has the key `(B + N, E)`: E is the epoch of the command's attachment, and B the -//! first 8 bytes of the broker instance's ID, as a little-endian number. The guest refuses a key -//! that it heard of before, and the epochs of a broker instance only advance, while a broker -//! restored from a snapshot has a new instance ID, so no command repeats the key of another's -//! execution. +//! of them; see `runtime_adapter::layered::with_proxy`. Before it attaches, the command checks +//! that the framing still carries each start with the proxy's URL, and runs none of the +//! executions if one no longer fits. It runs for at most a minute, with 16 KiB chunks and the +//! credit of 4 of them for its input and 8 for its output, which its standard output and error +//! share. The command keeps at most 16 MiB of each stream, and cancels an execution whose stream +//! would exceed them; see `runtime_adapter::exec::MAX_CAPTURE`. Execution N, from 1, has the key +//! `(B + N, E)`: E is the epoch of the command's attachment, and B the first 8 bytes of the +//! broker instance's ID, as a little-endian number. The guest refuses a key that it heard of +//! before, and the epochs of a broker instance only advance, while a broker restored from a +//! snapshot has a new instance ID, so no command repeats the key of another's execution. //! //! Each command but `stage` takes NVX's artifacts from the directory that `NVX_ARTIFACTS_DIR` //! names, a repository checkout or an extracted release, and the VM's runtime identity from NVX's @@ -352,6 +353,17 @@ impl Execution { } } +/// Checks that the guest takes `start`: that it decodes it, as it decodes an execution's start, +/// and that the framing carries it. The guest ends its session on a start that it cannot decode. +fn check_start(start: &Start) -> Result<(), String> { + let encoded = start.encode(); + Start::decode(&encoded).map_err(|error| error.problem().to_owned())?; + Message::ExecStart(encoded) + .encode() + .map_err(|error| format!("the start of an execution is too long: {error}"))?; + Ok(()) +} + /// Returns the limits of an execution of `exec`: chunks of [`EXEC_CHUNK`] bytes, and frame-counted /// credit, of [`EXEC_INPUT_FRAMES`] for the input, and of [`EXEC_OUTPUT_FRAMES`] in one pool that /// standard output and error share. @@ -827,11 +839,7 @@ fn parse_exec( // one that breaks the rules of the encoding, as a relative working directory or an // environment entry without a name does, or that the framing does not carry, before it // attaches. - let encoded = execution.start(Key::new(0, 0)).encode(); - Start::decode(&encoded).map_err(|error| error.problem().to_owned())?; - Message::ExecStart(encoded) - .encode() - .map_err(|error| format!("the start of an execution is too long: {error}"))?; + check_start(&execution.start(Key::new(0, 0)))?; executions.push(execution); if !more { break; @@ -1295,8 +1303,23 @@ fn exec(store: &Path, guest_build: &str, executions: &[Execution]) -> Result return Ok(status), }; // Each execution finds the URL of the proxy of the VM's network, if any, in its environment, - // from the plan that the launch recorded. + // from the plan that the launch recorded. The proxy's variables lengthen a start, so the + // command checks every start again before it attaches, rather than fail once it sent the + // starts before it. A start takes its key once the command attached, and a key's encoding has + // the same size whatever its value. let plan = LaunchRecord::read_plan(store.dir()).map_err(|error| describe(&error))?; + let mut starts = Vec::with_capacity(executions.len()); + for (number, execution) in (1..).zip(executions) { + let start = execution.start(Key::new(0, 0)); + let start = match &plan { + Some(plan) => with_proxy(start, plan), + None => start, + }; + check_start(&start).map_err(|problem| { + format!("execution {number}, with the proxy of the VM's network: {problem}") + })?; + starts.push(start); + } let deadline = Instant::now() + SESSION_TIMEOUT; let capability = store.read_capability().map_err(|error| describe(&error))?; let mut client = connect(&vmm, &capability, deadline)?; @@ -1306,12 +1329,9 @@ fn exec(store: &Path, guest_build: &str, executions: &[Execution]) -> Result with_proxy(execution.start(key), plan), - None => execution.start(key), - }; + start.key = key; running .start(&mut client, start, exec_timers(), deadline) .map_err(|error| format!("cannot start execution {number}: {}", describe(&error)))?; diff --git a/tests/runtime-adapter/tests/vm_layered.rs b/tests/runtime-adapter/tests/vm_layered.rs index 689d464a3..251b62be9 100644 --- a/tests/runtime-adapter/tests/vm_layered.rs +++ b/tests/runtime-adapter/tests/vm_layered.rs @@ -41,9 +41,12 @@ use std::io::{self, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::time::Duration; +use nvx::exec::{Key, Limits, Timers, Window}; use nvx::gpt::{Disk, Expected, ExpectedPartition}; use nvx::uuid::Uuid; use runtime_adapter::GUEST_BUILD; +use runtime_adapter::exec::Start; +use runtime_adapter::frame::MAX_BODY; use runtime_adapter::layered::RECORD_NAME; use runtime_adapter::root::{ DEFAULT_RUNTIME, JournalPolicy, Listeners, OverlayMode, RootConfig, RootStage, @@ -893,7 +896,8 @@ scenarios! { }; /// Executions in a proxy-only network, which publishes no listener: each finds the proxy's URL /// in its environment, after its own entries, unless it names its own proxy settings, and root - /// may hold the capabilities that bind privileged ports and open raw sockets. + /// may hold the capabilities that bind privileged ports and open raw sockets. On Linux, `exec` + /// refuses before it attaches a batch with a start that the proxy's URL takes past the framing. executions_take_the_settings_of_a_proxy_only_network: Scenario { root: Root { network: Some(Network::Proxy), ..ROOT }, expect: Expect::Ready(check_proxy_settings), @@ -2481,6 +2485,66 @@ fn check_proxy_settings(vm: &mut Vm<'_>) { // `CAP_NET_BIND_SERVICE` and `CAP_NET_RAW`, numbers 10 and 13, in each set. let facts = identity_facts(0, 0, "0", 1 << 10 | 1 << 13); assert_ran(&printed, 3, 0, &facts, ""); + // Only Linux's command lines carry a start as long as the framing allows. + if cfg!(target_os = "linux") { + check_unframed_batch(vm); + } +} + +/// Checks, in the VM of a proxy-only network, that `exec` refuses before it attaches a batch with +/// a start that the framing carries alone, but not with the proxy's variables: none of the batch's +/// executions runs, not even the first, which would leave a marker, and the VM still runs the next +/// command's. +fn check_unframed_batch(vm: &mut Vm<'_>) { + let marker = "/tmp/unframed-batch"; + let first = execution(&["--identity", "0:0"], &["/bin/touch", marker]); + // The second start's environment fills it up to the framing's limit, in entries that Linux + // takes as arguments, of at most 128 KiB each. An entry takes its length, a `u32`, too. + let names = ["PAD0=", "PAD1=", "PAD2="]; + let fill = MAX_BODY + - unframed_start_length(&[]) + - names.map(|name| 4 + name.len()).iter().sum::(); + let padding = (0..names.len()) + .map(|index| { + let share = fill / names.len() + usize::from(index < fill % names.len()); + format!("{}{}", names[index], "x".repeat(share)) + }) + .collect::>(); + assert_eq!(unframed_start_length(&padding), MAX_BODY); + let mut options = vec!["--identity".to_owned(), "0:0".to_owned()]; + for entry in &padding { + options.extend(["--env".to_owned(), entry.clone()]); + } + options.extend(["--".to_owned(), "/bin/true".to_owned()]); + let batch = [first, vec![";".to_owned()], options].concat(); + let batch = batch.iter().map(OsStr::new).collect::>(); + let refused = vm.sandbox.run("exec", &batch, FAILED); + assert_eq!(refused["reattached"], "live"); + assert!( + !refused.keys().any(|key| key.starts_with("exec.")), + "{refused:?}" + ); + let stderr = vm.sandbox.stderr(); + let problem = + "execution 2, with the proxy of the VM's network: the start of an execution is too long"; + assert!(stderr.contains(problem), "{stderr}"); + let printed = vm.exec(&[execution( + &["--identity", "0:0"], + &["/bin/sh", "-c", &format!("test ! -e {marker}")], + )]); + assert_ran(&printed, 1, 0, "", ""); +} + +/// Returns the length of the encoded start that `exec` makes, before the proxy's variables, of an +/// execution as root of `/bin/true` with the environment `environment`: a key, the limits, and the +/// timers each have the same size whatever their values. +fn unframed_start_length(environment: &[String]) -> usize { + let limits = Limits::new(1, Window::frames(1), Window::frames(1)).unwrap(); + let timers = Timers::new(Duration::from_secs(1), Duration::from_secs(1)); + let mut start = Start::new(Key::new(0, 0), limits, timers, ["/bin/true"]) + .with_environment(environment.iter().map(String::as_str)); + start.identity = Some(runtime_adapter::exec::Identity::new(0, 0)); + start.encode().len() } /// Checks the VM of the `published_listeners_refuse_binding_and_raw_sockets` scenario, whose