diff --git a/docs/backends/lxc/lxc-backend.md b/docs/backends/lxc/lxc-backend.md index d3ff17f04..e2fe97bc4 100644 --- a/docs/backends/lxc/lxc-backend.md +++ b/docs/backends/lxc/lxc-backend.md @@ -333,12 +333,11 @@ The zone query should answer the zone you assigned. container init keeps `CAP_NET_ADMIN` there, so a process running as root inside the container can flush or delete them. The command MXC runs is attached with `CAP_NET_ADMIN` dropped from its bounding set whenever chains are installed, so - it cannot. Default-deny closes external reachability for a container that does + it cannot. That same attach installs a seccomp filter refusing + `socket(AF_PACKET, ...)` with `EPERM`. Without it a workload holding + `CAP_NET_RAW` could put frames on the wire beneath the chains, which filter at + the IP layer. Default-deny closes external reachability for a container that does not deliberately tear it down, including services the workload itself starts. -- **Raw sockets bypass egress filtering.** `CAP_NET_RAW` is retained so that an - explicit `protocol: "icmp"` allow works. It also permits `AF_PACKET` sockets, - which write link-layer frames straight to the interface without traversing the - filter chain. - **Policy is not in force while the container starts.** The chains are installed after the container has started and its address has settled, so container init and anything it starts run unfiltered in both directions for that interval. The diff --git a/src/mxc-sdk/src/backends/lxc/common/lxc_bindings.rs b/src/mxc-sdk/src/backends/lxc/common/lxc_bindings.rs index 93ff101a4..0ac48f339 100644 --- a/src/mxc-sdk/src/backends/lxc/common/lxc_bindings.rs +++ b/src/mxc-sdk/src/backends/lxc/common/lxc_bindings.rs @@ -135,6 +135,11 @@ pub enum ContainerFirewall { Absent, } +/// Two networking capabilities LXC cares about are CAP_NET_ADMIN and CAP_NET_RAW. +/// CAP_NET_ADMIN allows a process to change the networking rules +/// CAP_NET_RAW allows a process to use RAW and PACKET sockets and those allow a process to bypass +/// a network chain. +/// Remove both capabilities here. (Technically CAP_NET_RAW is not removed. More details inside) #[cfg(target_os = "linux")] fn confine_network_capabilities(command: &mut std::process::Command) { use std::os::unix::process::CommandExt; @@ -143,18 +148,110 @@ fn confine_network_capabilities(command: &mut std::process::Command) { const CAP_NET_ADMIN: libc::c_ulong = 12; // SAFETY: `pre_exec` runs between fork and exec, where only - // async-signal-safe work is permitted. `prctl` is a bare syscall and this - // closure allocates nothing and captures nothing. + // async-signal-safe work is permitted. `prctl` and `seccomp` are bare + // syscalls, the filter lives on the stack, and this closure allocates + // nothing and captures nothing. unsafe { + // Take away CAP_NET_ADMIN. command.pre_exec(|| { if libc::prctl(libc::PR_CAPBSET_DROP, CAP_NET_ADMIN, 0, 0, 0) != 0 { return Err(std::io::Error::last_os_error()); } - Ok(()) + + // Refuse AF_PACKET communication. + refuse_packet_sockets() }); } } +/// Removing CAP_NET_RAW prevents a process from using RAW and PACKET sockets. However, ICMP is also denied. +/// LXC needs to allow ICMP and prevent programs from using sockets to +/// bypass the network guards. The solution is to give the kernel a custom filter to refuse +/// any socket that carries AF_PACKET. LXC utilizes seccomp. +/// see https://docs.kernel.org/userspace-api/seccomp_filter.html and +/// https://man7.org/linux/man-pages/man2/seccomp.2.html +#[cfg(target_os = "linux")] +fn refuse_packet_sockets() -> std::io::Result<()> { + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + compile_error!("LXC only understands seccomp system calls for x86_64 and arm64."); + + // https://docs.kernel.org/networking/filter.html for more information on filtering sockets + // Filtering sockets requires assembling a small filter program with assembly like syntax. + // The below code is to consolidate some commands into easy to understand words. + + // https://github.com/torvalds/linux/blob/master/include/uapi/linux/bpf_common.h + // Common BPF words + const LOAD_WORD: u16 = (libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16; + const JUMP_IF_EQUAL: u16 = (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16; + const JUMP_IF_AT_LEAST: u16 = (libc::BPF_JMP | libc::BPF_JGE | libc::BPF_K) as u16; + const RETURN: u16 = (libc::BPF_RET | libc::BPF_K) as u16; + + // https://github.com/torvalds/linux/blob/master/include/uapi/linux/seccomp.h + // Keys of the information needed during the filter. + const SYSCALL_NUMBER: u32 = 0; + const ARCHITECTURE: u32 = 4; + const FIRST_ARGUMENT: u32 = 16; + + const REFUSE_WITH_EPERM: u32 = libc::SECCOMP_RET_ERRNO | (libc::EPERM as u32); + + const NO_FLAGS: libc::c_ulong = 0; + + // numbers come from + // https://github.com/torvalds/linux/blob/master/include/uapi/linux/audit.h + // To test the ABI the filter needs to know the endianness and arch. + const _64BIT_ABI_LITTLE_ENDIANNESS: u32 = 0x8000_0000 | 0x4000_0000; + + // Also from https://github.com/torvalds/linux/blob/master/include/uapi/linux/audit.h + #[cfg(target_arch = "x86_64")] + const NATIVE_ARCHITECTURE: u32 = _64BIT_ABI_LITTLE_ENDIANNESS | libc::EM_X86_64 as u32; + #[cfg(target_arch = "aarch64")] + const NATIVE_ARCHITECTURE: u32 = _64BIT_ABI_LITTLE_ENDIANNESS | libc::EM_AARCH64 as u32; + + // Filter only works for x64 and arm64 ABI's. + // Used to filter out a 32 bit process under the "Filters" section. + // check https://man7.org/linux/man-pages/man2/seccomp.2.html + const USES_X32_ABI_FLAG: u32 = 0x4000_0000; + + const SOCKET_SYSCALL: u32 = libc::SYS_socket as u32; + + // Make the filter + #[rustfmt::skip] + let mut program = [ + libc::sock_filter { code: LOAD_WORD, jt: 0, jf: 0, k: ARCHITECTURE }, + libc::sock_filter { code: JUMP_IF_EQUAL, jt: 0, jf: 7, k: NATIVE_ARCHITECTURE }, // test ABI + libc::sock_filter { code: LOAD_WORD, jt: 0, jf: 0, k: SYSCALL_NUMBER }, + libc::sock_filter { code: JUMP_IF_AT_LEAST, jt: 5, jf: 0, k: USES_X32_ABI_FLAG }, // Test 32 bit + libc::sock_filter { code: JUMP_IF_EQUAL, jt: 0, jf: 2, k: SOCKET_SYSCALL }, // Only process a socket syscall + libc::sock_filter { code: LOAD_WORD, jt: 0, jf: 0, k: FIRST_ARGUMENT }, + libc::sock_filter { code: JUMP_IF_EQUAL, jt: 1, jf: 0, k: libc::AF_PACKET as u32 }, // return error if using AF_PACKET + libc::sock_filter { code: RETURN, jt: 0, jf: 0, k: libc::SECCOMP_RET_ALLOW }, + libc::sock_filter { code: RETURN, jt: 0, jf: 0, k: REFUSE_WITH_EPERM }, + libc::sock_filter { code: RETURN, jt: 0, jf: 0, k: libc::SECCOMP_RET_KILL_PROCESS }, + ]; + + // Make the struct + let header = libc::sock_fprog { + len: program.len() as libc::c_ushort, + filter: program.as_mut_ptr(), + }; + + // Make the sys call + let taken = unsafe { + libc::syscall( + libc::SYS_seccomp, + libc::SECCOMP_SET_MODE_FILTER as libc::c_ulong, + NO_FLAGS, + &header as *const libc::sock_fprog, + ) + }; + + if taken != 0 { + return Err(std::io::Error::last_os_error()); + } + + Ok(()) +} + /// A `/etc/hosts` helper emits one `mxc:` diagnostic line, so this is far above /// any legitimate output. #[cfg(target_os = "linux")] @@ -1450,9 +1547,9 @@ mod tests { ); } - // The attach paths drop this capability only when chains are installed: the - // drop is privileged, so applying it to every run costs an unprivileged - // caller the whole execution. + // The attach paths confine the workload only when chains are installed: + // dropping a capability is privileged, so applying it to every run costs an + // unprivileged caller the whole execution. #[cfg(target_os = "linux")] #[test] fn confining_a_command_takes_a_privilege_an_unprivileged_caller_lacks() { @@ -1480,6 +1577,99 @@ mod tests { ); } + /// The probe child opened `AF_PACKET`. + #[cfg(target_os = "linux")] + const AF_PACKET_ALLOWED: i32 = 10; + + /// The probe child was refused with EPERM, which is what the filter returns. + #[cfg(target_os = "linux")] + const AF_PACKET_REFUSED_EPERM: i32 = 11; + + /// The probe child was refused, but for some other reason. + #[cfg(target_os = "linux")] + const AF_PACKET_REFUSED_OTHER: i32 = 12; + + /// Fork a child that optionally installs the packet-socket filter, opens + /// `socket(AF_PACKET, SOCK_RAW, 0)`, and reports which answer the kernel + /// gave. The child leaves through `_exit` and never reaches `exec`, so the + /// attempt runs in the same post-fork context that confines a real + /// workload. Reporting through the exit code keeps a refusal distinct from + /// a child that never ran: a missing or unspawnable program cannot forge + /// one of these three numbers. + #[cfg(target_os = "linux")] + fn probe_packet_socket(install_filter: bool) -> i32 { + use std::os::unix::process::CommandExt; + + let mut command = std::process::Command::new("/bin/true"); + + // SAFETY: `pre_exec` runs between fork and exec, where only + // async-signal-safe work is permitted. This closure makes bare + // syscalls, allocates nothing, and leaves through `_exit`, which skips + // the atexit handlers the parent still owns. + unsafe { + command.pre_exec(move || { + // Seccomp wants this or CAP_SYS_ADMIN. Production is root and + // gets it from the capability; setting it here lets the probe + // report the filter's own verdict at any privilege level. + libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + + if install_filter { + refuse_packet_sockets()?; + } + + let descriptor = libc::socket(libc::AF_PACKET, libc::SOCK_RAW, 0); + if descriptor >= 0 { + libc::close(descriptor); + libc::_exit(AF_PACKET_ALLOWED); + } + + if std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) { + libc::_exit(AF_PACKET_REFUSED_EPERM) + } else { + libc::_exit(AF_PACKET_REFUSED_OTHER) + } + }); + } + + command + .status() + .expect("the probe child must fork") + .code() + .expect("the probe child reports through its exit code, never a signal") + } + + // `Seccomp: 2` in `/proc/self/status` says a filter is attached, not that it + // refuses anything: a filter permitting every syscall reports the same + // number. This opens the one socket the filter exists to stop and reads + // the kernel's answer. + #[cfg(target_os = "linux")] + #[test] + fn the_filter_refuses_a_packet_socket_with_eperm() { + assert_eq!( + probe_packet_socket(true), + AF_PACKET_REFUSED_EPERM, + "a workload under the filter must not reach AF_PACKET, which writes \ + frames below the chains confining it" + ); + } + + // The control for the test above. On its own an EPERM settles nothing, + // because a caller lacking CAP_NET_RAW is refused identically and an empty + // filter would pass just as well. + #[cfg(target_os = "linux")] + #[test] + fn a_packet_socket_is_otherwise_open_to_a_caller_holding_cap_net_raw() { + // SAFETY: `geteuid` is a thread-safe, side-effect-free libc call. + let running_as_root = unsafe { libc::geteuid() } == 0; + + assert_eq!( + probe_packet_socket(false) == AF_PACKET_ALLOWED, + running_as_root, + "an unfiltered caller holding CAP_NET_RAW has to reach AF_PACKET, or \ + the refusal above is the privilege talking rather than the filter" + ); + } + #[cfg(target_os = "linux")] #[test] fn a_failed_release_carries_the_tools_own_diagnosis() { diff --git a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs index 9abf2c93b..814504c27 100644 --- a/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs +++ b/src/mxc-sdk/src/backends/lxc/common/lxc_runner.rs @@ -2145,8 +2145,9 @@ mod tests { } #[test] - fn a_policy_that_installs_no_chain_leaves_the_capability_alone() { - // Dropping it needs CAP_SETPCAP, which an unprivileged caller lacks. + fn a_policy_that_installs_no_chain_leaves_the_workload_unconfined() { + // Confining takes CAP_SETPCAP to drop a capability, which an + // unprivileged caller lacks. assert_eq!( container_firewall(false, false), ContainerFirewall::Absent, diff --git a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_lxc_network_capability.rs b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_lxc_network_capability.rs index 453a12534..bdc01fb1c 100644 --- a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_lxc_network_capability.rs +++ b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_lxc_network_capability.rs @@ -9,6 +9,7 @@ use serde_json::json; use wxc_e2e_tests::{has_lxc_host, has_platform_exec, run_platform_config_value}; const CAP_NET_ADMIN: u64 = 1 << 12; +const CAP_NET_RAW: u64 = 1 << 13; /// Whether the LXC capability prerequisites are present. fn ready() -> bool { @@ -25,13 +26,24 @@ fn capability_mask(status: &str, field: &str) -> u64 { .unwrap_or_else(|| panic!("the container reported no readable {field} line\n{status}")) } +/// Read one decimal field out of the container's `/proc/self/status`. +fn status_number(status: &str, field: &str) -> u64 { + status + .lines() + .find(|line| line.starts_with(field)) + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|value| value.parse().ok()) + .unwrap_or_else(|| panic!("the container reported no readable {field} line\n{status}")) +} + #[test] -fn workload_cannot_reconfigure_the_network() { +fn workload_cannot_reconfigure_or_bypass_the_network() { if !ready() { return; } - // The drop only happens when chains exist, so this policy asks for a firewall. + // The confinement only happens when chains exist. This policy asks for a + // firewall to bring it on. let config = json!({ "version": "0.9.0-alpha", "containerId": "lxc-network-capability", @@ -72,5 +84,21 @@ fn workload_cannot_reconfigure_the_network() { 0, "{field} still carries CAP_NET_ADMIN; the workload can rewrite the firewall confining it\n{status}" ); + + assert_ne!( + capability_mask(&status, field) & CAP_NET_RAW, + 0, + "{field} lost CAP_NET_RAW; an explicit `protocol: \"icmp\"` allow needs a raw socket\n{status}" + ); } + + // 2 is SECCOMP_MODE_FILTER. The kernel writes this line too, and a filter + // cannot be lifted once it is attached. This asserts only that one is + // attached; that it refuses AF_PACKET with EPERM is proven by the + // lxc_bindings unit test the_filter_refuses_a_packet_socket_with_eperm. + assert_eq!( + status_number(&status, "Seccomp:"), + 2, + "the workload runs with no seccomp filter; AF_PACKET reaches the interface below the chains\n{status}" + ); } diff --git a/src/tools/lxc/src/linux_executor_arguments.rs b/src/tools/lxc/src/linux_executor_arguments.rs new file mode 100644 index 000000000..e44835c7c --- /dev/null +++ b/src/tools/lxc/src/linux_executor_arguments.rs @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use clap::Parser; +use mxc_sdk::mxc_common::config_parser::load_one_shot_request; +use mxc_sdk::mxc_common::logger::{Logger, Mode}; +use mxc_sdk::mxc_common::models::ExecutionRequest; +use std::process; + +#[derive(Parser)] +#[command(name = "lxc-exec", about = "Linux Container Executor")] +pub struct LinuxExecutorArguments { + /// Path to config JSON file (positional) + #[arg(value_name = "CONFIG_PATH")] + config_path: Option, + + /// Path to config JSON file + #[arg(long = "config")] + config: Option, + + /// Base64-encoded JSON config + #[arg(long = "config-base64")] + config_base64: Option, + + /// Enable debug/console output + #[arg(long)] + debug: bool, + + /// Delete container mode + #[arg(long)] + delete: bool, + + /// Container name (required with --delete) + #[arg(long = "containername")] + containername: Option, + + /// Enable experimental features + #[arg(long)] + experimental: bool, + + /// Report host backend availability as JSON and exit + #[arg(long = "available-backends")] + available_backends: bool, + + /// Parse and validate config then exit without executing + #[arg(long = "dry-run")] + dry_run: bool, + + /// Path to diagnostic log file (appends, creates if missing) + #[arg(long = "log-file")] + log_file: Option, + + /// Install the warmed Hyperlight snapshot and exit, for the default + /// runtime (`agent`) or the comma-separated runtimes given with `=`: + /// `--setup-hyperlight=python,node`. Names are `agent`, `python`, + /// `python-shell`, `node`, `bash` and `dotnet-jit`. For each, pulls the + /// published rootfs from GHCR unless the image home already holds it, + /// boots it once, and writes the snapshot into the default user data + /// dir (~/.local/share/mxc-hyperlight on Linux, + /// %LOCALAPPDATA%\mxc-hyperlight on Windows). $MXC_HYPERLIGHT_HOME + /// overrides the destination if set. Intended for tool install hooks + /// so first-run has zero warmup cost. + #[arg( + long = "setup-hyperlight", + value_name = "RUNTIMES", + num_args = 0..=1, + require_equals = true, + value_delimiter = ',' + )] + setup_hyperlight: Option>, + + /// Rebuild the snapshot even if one already exists. + #[arg(long, requires = "setup_hyperlight")] + force: bool, +} + +impl LinuxExecutorArguments { + pub fn get_log_path(&self) -> Option { + self.log_file.clone() + } + + pub fn should_run_in_debug(&self) -> bool { + self.debug + } + + pub fn should_display_avalible_backends(&self) -> bool { + self.available_backends + } + + pub fn parse_and_validate(&self) -> bool { + self.dry_run + } + + pub fn get_request(&self) -> ExecutionRequest { + let (config_data, is_base64) = if let Some(ref b64) = self.config_base64 { + (b64.clone(), true) + } else if let Some(ref path) = self.config { + (path.clone(), false) + } else if let Some(ref path) = self.config_path { + (path.clone(), false) + } else { + eprintln!( + "Error: No config provided. Use a positional path, --config, or --config-base64" + ); + process::exit(1); + }; + + let mut logger = Logger::new(if self.debug { + Mode::Console + } else { + Mode::Buffer + }); + + let mut request = match load_one_shot_request(&config_data, &mut logger, is_base64) { + Ok(r) => r, + Err(_) => { + eprint!("Request error\n{}", logger.get_buffer()); + process::exit(1); + } + }; + + request.experimental_enabled = self.experimental; + request.dry_run = self.dry_run; + + request + } + + pub fn does_user_want_hyperlight_setup(&self) -> bool { + self.setup_hyperlight.is_some() + } + + pub fn setup_hyperlight(&self) -> Result { + if let Some(config_path) = &self.config_path { + return Err(format!( + "Error: --setup-hyperlight takes no config path; name runtimes with \ + --setup-hyperlight={config_path}" + )); + } + + #[cfg(not(all(feature = "hyperlight", target_arch = "x86_64")))] + { + return Err( + "Error: --setup-hyperlight requires x86_64 (Hyperlight needs KVM or WHP)" + .to_string(), + ); + } + + #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] + { + // WHP is delay-loaded; check before setup boots a VM. + #[cfg(target_os = "windows")] + if !mxc_sdk::hyperlight_common::is_whp_available() { + return Err( + "Error: --setup-hyperlight requires Windows Hypervisor Platform (WHP). \ + Enable the HypervisorPlatform optional feature and reboot." + .to_string(), + ); + } + + //KVM is checked before anything is pulled. + #[cfg(target_os = "linux")] + if !mxc_sdk::hyperlight_common::is_kvm_available() { + return Err( + "Error: --setup-hyperlight requires KVM: /dev/kvm must be readable and \ + writable by this user." + .to_string(), + ); + } + + // Setup is an interactive install: the pull and the warm-up + // report progress as they go. + let runtimes = + mxc_sdk::hyperlight_common::parse_runtimes(&self.setup_hyperlight.clone().unwrap()) + .map_err(|message| format!("Error: {message}"))?; + + let mut logger = mxc_sdk::mxc_common::logger::Logger::new( + mxc_sdk::mxc_common::logger::Mode::Console, + ); + match mxc_sdk::hyperlight_common::setup(self.force, &runtimes, &mut logger) { + Ok(home) => Ok(format!("hyperlight setup: image home ready at {home:?}")), + Err(msg) => Err(format!("hyperlight setup failed: {msg}")), + } + } + } + + pub fn does_user_want_to_delete_a_container(&self) -> bool { + self.delete + } + + pub fn delete_container(&self) -> Result { + let name = match self.containername { + Some(ref n) => n, + None => return Err("Error: --containername is required with --delete".to_string()), + }; + + Self::delete_lxc_container(name) + } + + fn delete_lxc_container(name: &str) -> Result { + use mxc_sdk::lxc_common::lxc_bindings::LxcContainer; + + let container = LxcContainer::new(name, None); + + if !container.is_defined() { + return Err(format!("Container '{}' does not exist.", name)); + } + + match container.destroy() { + Ok(()) => Ok(format!("Deleted LXC container: {}", name)), + Err(e) => Err(format!("Failed to delete LXC container '{}': {}", name, e)), + } + } +} diff --git a/src/tools/lxc/src/main.rs b/src/tools/lxc/src/main.rs index 795538f1c..e2e373b2f 100644 --- a/src/tools/lxc/src/main.rs +++ b/src/tools/lxc/src/main.rs @@ -1,294 +1,88 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod linux_executor_arguments; + +use clap::Parser; use std::fmt::Write; -use std::process; use std::time::Instant; +use std::{eprint, process}; -use clap::Parser; -use mxc_sdk::mxc_common::config_parser::load_one_shot_request; use mxc_sdk::mxc_common::logger::{Logger, Mode}; use mxc_sdk::mxc_common::models::{ExecutionRequest, ScriptResponse}; -use mxc_sdk::mxc_common::script_runner::handle_dry_run_exit; +use mxc_sdk::mxc_common::script_runner; use mxc_sdk::mxc_common::telemetry; use mxc_sdk::lxc_common::signal_cleanup; -#[derive(Parser)] -#[command(name = "lxc-exec", about = "Linux Container Executor")] -struct Cli { - /// Path to config JSON file (positional) - #[arg(value_name = "CONFIG_PATH")] - config_path: Option, - - /// Path to config JSON file - #[arg(long = "config")] - config: Option, - - /// Base64-encoded JSON config - #[arg(long = "config-base64")] - config_base64: Option, - - /// Enable debug/console output - #[arg(long)] - debug: bool, - - /// Delete container mode - #[arg(long)] - delete: bool, - - /// Container name (required with --delete) - #[arg(long = "containername")] - containername: Option, - - /// Enable experimental features - #[arg(long)] - experimental: bool, - - /// Report host backend availability as JSON and exit - #[arg(long = "available-backends")] - available_backends: bool, - - /// Parse and validate config then exit without executing - #[arg(long = "dry-run")] - dry_run: bool, - - /// Path to diagnostic log file (appends, creates if missing) - #[arg(long = "log-file")] - log_file: Option, - - /// Install the warmed Hyperlight snapshot and exit, for the default - /// runtime (`agent`) or the comma-separated runtimes given with `=`: - /// `--setup-hyperlight=python,node`. Names are `agent`, `python`, - /// `python-shell`, `node`, `bash` and `dotnet-jit`. For each, pulls the - /// published rootfs from GHCR unless the image home already holds it, - /// boots it once, and writes the snapshot into the default user data - /// dir (~/.local/share/mxc-hyperlight on Linux, - /// %LOCALAPPDATA%\mxc-hyperlight on Windows). $MXC_HYPERLIGHT_HOME - /// overrides the destination if set. Intended for tool install hooks - /// so first-run has zero warmup cost. - #[arg( - long = "setup-hyperlight", - value_name = "RUNTIMES", - num_args = 0..=1, - require_equals = true, - value_delimiter = ',' - )] - setup_hyperlight: Option>, - - /// Rebuild the snapshot even if one already exists. Use after - /// replacing `initrd.cpio` so the warm state matches the new - /// bits. Requires --setup-hyperlight. - #[arg(long, requires = "setup_hyperlight")] - force: bool, -} - -fn log_request(request: &ExecutionRequest, logger: &mut Logger) { - let _ = writeln!(logger, "Script code length: {}", request.script_code.len()); - let _ = writeln!(logger, "Working directory: {}", request.working_directory); - let _ = writeln!(logger, "Script timeout: {}", request.script_timeout); - let _ = writeln!(logger, "Container name: {}", request.container_id); -} - -fn display_script_results(response: &ScriptResponse, logger: &mut Logger) { - let code = response.exit_code; - let _ = writeln!(logger, "Exit code: {} (0x{:08X})", code, code as u32); - if !response.error_message.is_empty() { - let _ = writeln!(logger, "Error: {}", response.error_message); - } -} - -/// Surface warnings the run recorded (e.g. Bubblewrap reporting an IPv6 allow -/// the sandbox namespace cannot reach). -/// -/// The logger only retains these rather than writing them itself, so that -/// `mxc_engine` embedders don't get unannounced writes to a terminal they own. -/// `lxc-exec` *does* own its terminal, so it opts in here — matching wxc-exec. -/// stderr, not stdout: stdout carries the workload's own output. -fn emit_warnings(logger: &Logger) { - for warning in logger.warnings() { - eprintln!("{warning}"); - } -} - -fn delete_lxc_container(name: &str, logger: &mut Logger) -> bool { - use mxc_sdk::lxc_common::lxc_bindings::LxcContainer; - - let container = LxcContainer::new(name, None); +fn main() { + let arguments = linux_executor_arguments::LinuxExecutorArguments::parse(); - if !container.is_defined() { - logger.log_line(&format!("Container '{}' does not exist.", name)); - return false; - } + // Get the logger + let mut logger = Logger::new(if arguments.should_run_in_debug() { + Mode::Console + } else { + Mode::Buffer + }); - match container.destroy() { - Ok(()) => { - logger.log_line(&format!("Deleted LXC container: {}", name)); - true - } - Err(e) => { - logger.log_line(&format!("Failed to delete LXC container '{}': {}", name, e)); - false + // Enable the file sink if needed + if let Some(ref log_path) = arguments.get_log_path() { + if let Err(e) = logger.enable_file_sink(std::path::Path::new(log_path)) { + eprintln!("Warning: could not open log file '{}': {}", log_path, e); } } -} - -fn main() { - // Install before spawning any other threads so the signal mask propagates. - // Failure here is fatal: install() either succeeds with the watchdog - // running, or restores the original signal mask and returns Err. We - // refuse to continue without it because containers leaked on SIGTERM/INT - // are exactly the failure mode this code exists to prevent. - if let Err(e) = signal_cleanup::install() { - eprintln!("Error: failed to install signal cleanup handler: {}", e); - process::exit(1); - } - - let cli = Cli::parse(); - // Detection-only fast path used by SDK `getPlatformSupport()`; runs before - // config handling so no JSON file is needed just to ask what the host can - // do, and mutates no host state. - if cli.available_backends { + // Go through the different operating modes + if arguments.should_display_avalible_backends() { match mxc_sdk::mxc_engine::to_json_pretty(&mxc_sdk::mxc_engine::available_backends()) { - Ok(json) => println!("{json}"), + Ok(json) => { + println!("{json}"); + process::exit(0); + } Err(e) => { eprintln!("Error: probe serialization failed: {e}"); process::exit(1); } } - return; - } - - // --setup-hyperlight: eagerly warm up the snapshot and exit. Runs - // before config parsing so the user doesn't need a JSON file on - // disk just to install. - if let Some(runtime_names) = &cli.setup_hyperlight { - // Setup exits before any config is read, so a positional here is - // most likely a runtime name given with a space instead of `=`. - if let Some(stray) = &cli.config_path { - eprintln!( - "Error: --setup-hyperlight takes no config path; name runtimes with \ - --setup-hyperlight={stray}" - ); - process::exit(1); - } - #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] - { - // WHP is delay-loaded; check before setup boots a VM. - #[cfg(target_os = "windows")] - if !mxc_sdk::hyperlight_common::is_whp_available() { - eprintln!( - "Error: --setup-hyperlight requires Windows Hypervisor Platform (WHP). \ - Enable the HypervisorPlatform optional feature and reboot." - ); - process::exit(1); + } else if arguments.does_user_want_hyperlight_setup() { + match arguments.setup_hyperlight() { + Ok(message) => { + eprintln!("{message}"); + process::exit(0); } - // KVM is checked before anything is pulled. - #[cfg(target_os = "linux")] - if !mxc_sdk::hyperlight_common::is_kvm_available() { - eprintln!( - "Error: --setup-hyperlight requires KVM: /dev/kvm must be readable and \ - writable by this user." - ); + Err(message) => { + eprintln!("{message}"); process::exit(1); } - - // Setup is an interactive install: the pull and the warm-up - // report progress as they go. - let mut logger = Logger::new(Mode::Console); - let runtimes = match mxc_sdk::hyperlight_common::parse_runtimes(runtime_names) { - Ok(runtimes) => runtimes, - Err(msg) => { - eprintln!("Error: {msg}"); - process::exit(1); - } - }; - match mxc_sdk::hyperlight_common::setup(cli.force, &runtimes, &mut logger) { - Ok(home) => { - eprintln!("hyperlight setup: image home ready at {:?}", home); - process::exit(0); - } - Err(msg) => { - eprintln!("hyperlight setup failed: {msg}"); - process::exit(1); - } - } - } - #[cfg(not(all(feature = "hyperlight", target_arch = "x86_64")))] - { - let _ = runtime_names; - eprintln!("Error: --setup-hyperlight requires x86_64 (Hyperlight needs KVM or WHP)"); - process::exit(1); } - } - - // Determine config input - let (config_data, is_base64) = if let Some(ref b64) = cli.config_base64 { - (b64.clone(), true) - } else if let Some(ref path) = cli.config { - (path.clone(), false) - } else if let Some(ref path) = cli.config_path { - (path.clone(), false) - } else if !cli.delete { - eprintln!("Error: No config provided. Use a positional path, --config, or --config-base64"); - process::exit(1); - } else { - (String::new(), false) - }; - - let mut logger = Logger::new(if cli.debug { - Mode::Console - } else { - Mode::Buffer - }); - - if let Some(ref log_path) = cli.log_file { - if let Err(e) = logger.enable_file_sink(std::path::Path::new(log_path)) { - eprintln!("Warning: could not open log file '{}': {}", log_path, e); - } - } - - // Delete mode - if cli.delete { - let name = match cli.containername { - Some(ref n) => n.as_str(), - None => { - eprintln!("Error: --containername is required with --delete"); + } else if arguments.does_user_want_to_delete_a_container() { + match arguments.delete_container() { + Ok(message) => { + eprintln!("{message}"); + process::exit(0); + } + Err(message) => { + eprintln!("{message}"); process::exit(1); } - }; - let success = delete_lxc_container(name, &mut logger); - print!("{}", logger.get_buffer()); - process::exit(if success { 0 } else { 1 }); - } - - // Load request - let mut request = match load_one_shot_request(&config_data, &mut logger, is_base64) { - Ok(r) => r, - Err(_) => { - eprint!("Request error\n{}", logger.get_buffer()); - process::exit(1); } - }; + } - request.experimental_enabled = cli.experimental; - request.dry_run = cli.dry_run; + // User wants to run a request. + // Make the request. + let request = arguments.get_request(); - // ── Telemetry init ────────────────────────────────────────────── let telemetry_active = request .telemetry .as_ref() .map(|c| telemetry::init(c, &mut logger)) .unwrap_or(false); + let requested_sandbox_kind = request .telemetry .as_ref() .and_then(|config| config.requested_sandbox_kind); - // Install a crash-telemetry panic hook once telemetry is active, chaining - // the previously-installed hook so the default stderr backtrace still - // prints. The hook body is panic-free and emits no message text. if telemetry_active { telemetry::set_process_context_with_kind(&request.containment, requested_sandbox_kind); telemetry::install_panic_hook(); @@ -296,15 +90,15 @@ fn main() { log_request(&request, &mut logger); - // Dispatch by containment backend. Backend selection and runner - // construction — Bubblewrap (the Linux default for abstract intents), LXC - // (explicit `containment: "lxc"`, plus the catch-all for anything else such - // as `processcontainer`), and the experimental Hyperlight / MicroVM - // backends — live in `mxc_sdk::mxc_engine::run`, the single home for one-shot backend - // dispatch. It runs the selected backend to completion and returns the - // response; experimental backends that require `--experimental` (or that - // aren't compiled in) surface an error here. + // Setup watchdog for unexpected shutdowns + if let Err(e) = signal_cleanup::install() { + eprintln!("Error: failed to install signal cleanup handler: {e}"); + process::exit(1); + } + let run_start = Instant::now(); + + // Run the request. let response = match mxc_sdk::mxc_engine::run(&request, &mut logger) { Ok(response) => response, Err(e) => { @@ -320,19 +114,20 @@ fn main() { process::exit(1); } }; + let run_elapsed = run_start.elapsed(); let _ = writeln!(logger, "Runner completed in {}ms", run_elapsed.as_millis()); - // Emitted before the dry-run branch below, which exits the process. emit_warnings(&logger); - if cli.dry_run { - handle_dry_run_exit(&response, &mut logger); + // Handle dry_run request. + if arguments.parse_and_validate() { + script_runner::handle_dry_run_exit(&response, &mut logger); } + // Show output to the user. display_script_results(&response, &mut logger); - // ── Telemetry emit ────────────────────────────────────────────── telemetry::emit_completion_with_kind( telemetry_active, &request.containment, @@ -344,11 +139,28 @@ fn main() { print!("{}", response.standard_out); eprint!("{}", response.standard_err); - // Never exit non-zero on an infrastructure failure without a diagnostic: - // `display_script_results` only writes the error into the (buffered, - // non-debug-suppressed) logger, so surface it on stderr here for parity - // with wxc-exec (issue #564). - mxc_sdk::mxc_common::script_runner::emit_backend_error_envelope(&response); + script_runner::emit_backend_error_envelope(&response); process::exit(response.exit_code); } + +fn log_request(request: &ExecutionRequest, logger: &mut Logger) { + let _ = writeln!(logger, "Script code length: {}", request.script_code.len()); + let _ = writeln!(logger, "Working directory: {}", request.working_directory); + let _ = writeln!(logger, "Script timeout: {}", request.script_timeout); + let _ = writeln!(logger, "Container name: {}", request.container_id); +} + +fn emit_warnings(logger: &Logger) { + for warning in logger.warnings() { + eprintln!("{warning}"); + } +} + +fn display_script_results(response: &ScriptResponse, logger: &mut Logger) { + let code = response.exit_code; + let _ = writeln!(logger, "Exit code: {} (0x{:08X})", code, code as u32); + if !response.error_message.is_empty() { + let _ = writeln!(logger, "Error: {}", response.error_message); + } +} diff --git a/tests/configs/lxc_network_ga_egress_icmp_allowed.json b/tests/configs/lxc_network_ga_egress_icmp_allowed.json index a7712fc4e..f575b14ee 100644 --- a/tests/configs/lxc_network_ga_egress_icmp_allowed.json +++ b/tests/configs/lxc_network_ga_egress_icmp_allowed.json @@ -4,7 +4,7 @@ "containerId": "CLI-LXC-GA-Egress-Icmp-Allowed", "containment": "lxc", "process": { - "commandLine": "sh -c \"timeout 8 ping -c 1 -W 5 203.0.113.2 >/dev/null 2>&1 && echo MXC_NET_ALLOWED || echo MXC_NET_BLOCKED\"" + "commandLine": "sh -c \"command -v ping || echo MXC_PING_MISSING; timeout 8 ping -c 1 -W 5 203.0.113.2 2>&1 && echo MXC_NET_ALLOWED || echo MXC_NET_BLOCKED\"" }, "lifecycle": { "destroyOnExit": true