diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index 01f8f12aa..c362e917c 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 @@ -976,17 +982,38 @@ 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`. +- `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 @@ -1114,17 +1141,20 @@ 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 - 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. + 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. 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 @@ -1379,6 +1409,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 @@ -1453,8 +1489,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 stage`, which records the staged images, a staging that fails, which leaves them, and a staging that replaces them; - `runtime-adapter-host launch` with shares that it cannot export, which it @@ -1562,11 +1606,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; 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 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 c446c9a1d..1a287a5b0 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 @@ -89,14 +91,18 @@ //! 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`. 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 @@ -176,7 +182,9 @@ 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, Share, Shares, Staged, read_network}; +use runtime_adapter::layered::{ + LaunchRecord, LayeredLaunch, Share, Shares, Staged, read_network, with_proxy, +}; use runtime_adapter::probe::{Probe, ProbeReply, escape}; use runtime_adapter::root::{JournalPolicy, OverlayMode, RootFailure}; use runtime_adapter::runtime::Runtime; @@ -341,6 +349,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. @@ -816,11 +835,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; @@ -920,8 +935,7 @@ fn launch( if let Some(export) = layered.shares.plan()? { launch.share(export)?; } - launch.plan = launch.plan.with_network(network); - Ok(launch) + Ok(launch.with_network(network)) }) .transpose() .map_err(|error| describe(&error))?; @@ -1284,6 +1298,24 @@ 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. 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)?; @@ -1293,10 +1325,11 @@ fn exec(store: &Path, guest_build: &str, executions: &[Execution]) -> Result) -> 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. /// @@ -460,8 +487,8 @@ fn authorize(roots: &[PathBuf], path: &Path) -> Result<()> { } /// 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 @@ -480,6 +507,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`. @@ -802,7 +867,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. +/// Returns the error of a record in the sandbox's store that is invalid, which `problem` says. fn invalid(problem: &str) -> Error { Error::unavailable(format!("the record {problem}")) } @@ -828,12 +893,13 @@ 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`] 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`] 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. @@ -880,4 +946,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 73881c937..a0c266d7b 100644 --- a/tests/runtime-adapter/src/lib.rs +++ b/tests/runtime-adapter/src/lib.rs @@ -39,7 +39,9 @@ //! 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. +//! 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/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 1756351f9..18beedc13 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, @@ -548,10 +552,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), @@ -561,6 +566,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 85c6e8f15..800766b0e 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,23 +20,24 @@ 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::distro::DistroFormat; 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, STAGED_NAME, Share, Shares, - Staged, read_network, + Bindings, LaunchRecord, LayeredLaunch, PROXY_VARIABLES, RECORD_NAME, SANDBOX_TOKEN, + STAGED_NAME, Share, Shares, Staged, read_network, with_proxy, }; -use runtime_adapter::root::RootConfig; +use runtime_adapter::root::{Listeners, RootConfig}; use serde_json::{Value, json}; /// The SHA-256 digest of 1 KiB of zeros, the content of the tests' images with a manifest. @@ -245,6 +248,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); @@ -386,6 +390,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); @@ -1327,8 +1332,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!( @@ -1343,14 +1347,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), @@ -1360,8 +1380,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), @@ -1555,6 +1582,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!(1); + cases.push((json.to_string(), "the record is not in format 2")); + json["format"] = json!(2); + 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(("[2]".to_owned(), "the record is not in format 2")); + 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_stages_images_in_the_store() { let directory = directory("layered-stage-command"); diff --git a/tests/runtime-adapter/tests/vm_layered.rs b/tests/runtime-adapter/tests/vm_layered.rs index af100fd4b..aa41f7aa1 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 @@ -40,11 +41,16 @@ 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, STAGED_NAME}; -use runtime_adapter::root::{DEFAULT_RUNTIME, JournalPolicy, OverlayMode, RootConfig, RootStage}; +use runtime_adapter::root::{ + DEFAULT_RUNTIME, JournalPolicy, Listeners, OverlayMode, RootConfig, RootStage, +}; use serde_json::{Value, json}; use common::{Fixtures, Printed, Sandbox}; @@ -140,6 +146,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 @@ -196,6 +206,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 { @@ -220,6 +272,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, @@ -235,6 +289,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, @@ -297,10 +352,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()]); @@ -320,6 +376,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 } @@ -834,6 +893,21 @@ 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. 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), + }; + /// 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`. @@ -849,7 +923,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)); @@ -988,9 +1066,11 @@ 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. +/// 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 { serde_json::from_str(&fs::read_to_string(sandbox.store.join(name)).unwrap()).unwrap() @@ -1012,6 +1092,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 { @@ -1050,6 +1134,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( + root.network + .map_or(Listeners::Unpublished, Network::listeners), + ), ..RootConfig::default() }; let sent = String::from_utf8(config.encode()).unwrap(); @@ -2369,3 +2457,120 @@ 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, ""); + // 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 +/// 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", ""); +}