diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 2ac76c0..04f91ba 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -408,6 +408,82 @@ During the creation of the device, the type, vendor id, and product id will be BUS_VIRTUAL 0x6 is not used, because I couldn't find a place where I could register a vendor and product id. The now used combination is unique, as the product id is registered under [pid.codes](https://pid.codes/1209/5020/). So, there is no problem to use it in a system-wide hwdb-file for udev. +--- + +## **3.10 Namespace Switching After Exec** + +### **Decision** + +`vuinputd` and its helper actions are executed in the **host mount namespace**, and only **after process startup** do they switch into the target container’s namespaces using `setns()` (e.g. `CLONE_NEWNS`, `CLONE_NEWNET`). + +Dynamic libraries (e.g. `libc`, `libfuse3`, `libudev`) are therefore resolved and mapped **before** entering the container’s mount namespace. +After the namespace switch, the process guarantees that it only performs filesystem operations intended for the container environment. + +### **Rationale** + +This design intentionally separates **code loading** from **runtime filesystem semantics**: + +* ELF loading and dynamic linking are one-time operations performed at `execve()` +* already-mapped libraries are unaffected by later namespace changes +* mount namespaces only affect *future* path resolution, not existing mappings + +By switching namespaces after startup, `vuinputd` avoids assumptions about: + +* the presence of shared libraries inside the container +* libc / dynamic loader compatibility across distributions +* static linking availability for complex dependencies like `libfuse` + +At the same time, runtime behavior (device access, `/dev`, `/sys`, `/proc`) correctly reflects the container’s view once `setns()` has completed. + +### **Why post-exec `setns()` is correct** + +* Widely used by container runtimes and helpers (e.g. `runc`, `crun`, `systemd-nspawn`, `nsenter`) +* Ensures maximum compatibility with heterogeneous container filesystems +* Avoids brittle static builds and duplicated dependency trees +* Preserves security boundaries: namespace changes are explicit and minimal + +### **Constraints and Guarantees** + +To keep this model correct, `vuinputd` enforces: + +* namespace switching occurs before spawning threads +* no unintended filesystem access occurs before `setns()` +* all container-visible paths are accessed only after entering the target namespace +* required kernel interfaces (`/dev/fuse`, `/sys`, `/proc`) are provided by the container + +Under these constraints, post-exec namespace switching provides a robust and predictable execution model. + +### **Alternatives Considered** + +* **Fully static binaries** + Rejected due to complexity, limited library support, and reduced portability. + +* **Executing entirely inside the container filesystem** + Rejected due to dependency availability, loader ABI mismatch, and tighter coupling between host and container environments. + +* **Executing the logic directly without an exec** + + This was the approach used in vuinputd releases 0.1 and 0.2: + the daemon would `fork()` and immediately execute the action logic in the child + without performing an `execve()`. + + While this avoids process re-initialization overhead, it is fundamentally unsafe + in a multi-threaded program. + + In particular: + + * `fork()` only duplicates the calling thread + * other threads may hold internal libc locks at the time of the fork + * common subsystems (notably `malloc`) are not async-signal-safe after `fork()` + * any allocation or lock acquisition in the child can deadlock permanently + + This is not a theoretical concern: if another thread holds the `malloc` arena lock + at the time of `fork()`, the child process may block forever on its first allocation, + including implicit allocations inside libc or Rust runtime code. + See also [https://github.com/rust-lang/rust/blob/c1e865c/src/libstd/sys/unix/process.rs#L202 + +The chosen approach offers the best balance between correctness, portability, and operational simplicity. + --- diff --git a/vuinputd/src/actions/action.rs b/vuinputd/src/actions/action.rs index ba2a6f7..ae1889c 100644 --- a/vuinputd/src/actions/action.rs +++ b/vuinputd/src/actions/action.rs @@ -12,22 +12,22 @@ pub enum Action { #[serde(rename = "mknod-device")] MknodDevice { path: String, - major: u32, - minor: u32, + major: u64, + minor: u64, }, #[serde(rename = "emit-udev-event")] EmitUdevEvent { netlink_message: HashMap, runtime_data: Option, - major: u32, - minor: u32, + major: u64, + minor: u64, }, #[serde(rename = "remove-device")] RemoveDevice { path: String, - major: u32, - minor: u32, + major: u64, + minor: u64, }, } diff --git a/vuinputd/src/cuse_device/vuinput_open.rs b/vuinputd/src/cuse_device/vuinput_open.rs index 757c854..6603000 100644 --- a/vuinputd/src/cuse_device/vuinput_open.rs +++ b/vuinputd/src/cuse_device/vuinput_open.rs @@ -29,7 +29,13 @@ pub unsafe extern "C" fn vuinput_open( let fh = get_fresh_filehandle(); let ctx = fuse_lowlevel::fuse_req_ctx(_req); debug!("fh {}: opened by process id {} (host view)", fh, (*ctx).pid); - let requesting_process = get_requesting_process(Pid::Pid((*ctx).pid)); + let pid = Pid::Pid( + (*ctx) + .pid + .try_into() + .expect("pid must be a positive integer"), + ); + let requesting_process = get_requesting_process(pid); debug!("fh {}: namespaces {}", fh, requesting_process); // namespaces net:4026531840, uts:4026531838, ipc:4026531839, pid:4026531836, pid_for_children:4026531836, user:4026531837, mnt:4026531841, cgroup:4026531835, time:4026531834, time_for_children:4026531834 (*_fi).fh = fh; diff --git a/vuinputd/src/jobs/inject_in_container_job.rs b/vuinputd/src/jobs/inject_in_container_job.rs index 0d9f83c..fe5daf8 100644 --- a/vuinputd/src/jobs/inject_in_container_job.rs +++ b/vuinputd/src/jobs/inject_in_container_job.rs @@ -15,13 +15,12 @@ use log::debug; use crate::{ actions::{ - input_device::ensure_input_device, - netlink_message::send_udev_monitor_message_with_properties, - runtime_data::{ensure_udev_structure, read_udev_data, write_udev_data}, + action::Action, + runtime_data::{read_udev_data}, }, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, - process_tools::{await_process, run_in_net_and_mnt_namespace, Pid, RequestingProcess}, + process_tools::{self, await_process, Pid, RequestingProcess}, }; #[derive(Clone, Debug, Copy, PartialOrd, PartialEq)] @@ -145,31 +144,33 @@ impl InjectInContainerJob { return; } - // define for capturing - let major = self.major; - let minor = self.minor; let runtime_data = runtime_data.unwrap(); let netlink_data = netlink_data.unwrap(); let dev_path = self.dev_path.clone(); - let child_pid = run_in_net_and_mnt_namespace( - &self.requesting_process, - Box::new(move || { - if let Err(e) = ensure_input_device(dev_path.clone(), self.major, self.minor) { - debug!("Error creating input device {}: {e}", dev_path.clone()); - }; - ensure_udev_structure().unwrap(); - if let Err(e) = write_udev_data(runtime_data.as_str(), major, minor) { - debug!( - "Error writing udev data for device {}: {e}", - dev_path.clone() - ); - }; - send_udev_monitor_message_with_properties(netlink_data.clone()); - }), - ) - .expect("subprocess should work"); - let _exit_info = await_process(Pid::Pid(child_pid.as_raw())).await.unwrap(); + let mknod_device_action = Action::MknodDevice { + path: dev_path.clone(), + major: self.major, + minor: self.minor, + }; + + let child_pid_1 = + process_tools::start_action(mknod_device_action, &self.requesting_process) + .expect("subprocess should work"); + + let emit_udev_event_action = Action::EmitUdevEvent { + netlink_message: netlink_data.clone(), + runtime_data: Some(runtime_data), + major: self.major, + minor: self.minor, + }; + + let child_pid_2 = + process_tools::start_action(emit_udev_event_action, &self.requesting_process) + .expect("subprocess should work"); + + let _exit_info = await_process(Pid::Pid(child_pid_1)).await.unwrap(); + let _exit_info = await_process(Pid::Pid(child_pid_2)).await.unwrap(); self.set_state(&State::Finished); } } diff --git a/vuinputd/src/jobs/remove_from_container_job.rs b/vuinputd/src/jobs/remove_from_container_job.rs index 990f9c9..1e9cfb0 100644 --- a/vuinputd/src/jobs/remove_from_container_job.rs +++ b/vuinputd/src/jobs/remove_from_container_job.rs @@ -12,12 +12,11 @@ use log::debug; use crate::{ actions::{ - input_device::remove_input_device, - netlink_message::send_udev_monitor_message_with_properties, runtime_data::delete_udev_data, + action::Action, }, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, - process_tools::{await_process, run_in_net_and_mnt_namespace, Pid, RequestingProcess}, + process_tools::{self, await_process, Pid, RequestingProcess}, }; #[derive(Clone, Debug, Copy, PartialOrd, PartialEq)] @@ -122,30 +121,34 @@ impl RemoveFromContainerJob { } let netlink_data = netlink_event.add_data; - // define for capturing let mut netlink_data = netlink_data.unwrap().clone(); - let major = self.major; - let minor = self.minor; let dev_path = self.dev_path.clone(); let _ = netlink_data.insert("ACTION".to_string(), "remove".to_string()); - let child_pid = run_in_net_and_mnt_namespace( - &self.requesting_process, - Box::new(move || { - // TODO: we should keep the same order as event_execute_rules_on_remove in - // https://github.com/systemd/systemd/blob/main/src/udev/udev-event.c - send_udev_monitor_message_with_properties(netlink_data.clone()); - if let Err(e) = delete_udev_data(major, minor) { - debug!("Error deleting udev data for {}:{}: {e}", major, minor); - } - if let Err(e) = remove_input_device(dev_path.clone(), self.major, self.minor) { - debug!("Error removing input device {}: {e}", dev_path.clone()); - }; - }), - ) - .expect("subprocess should work"); - let _exit_info = await_process(Pid::Pid(child_pid.as_raw())).await; + let remove_device_action = Action::RemoveDevice { + path: dev_path.clone(), + major: self.major, + minor: self.minor, + }; + + let child_pid_1 = + process_tools::start_action(remove_device_action, &self.requesting_process) + .expect("subprocess should work"); + + let emit_udev_event_action = Action::EmitUdevEvent { + netlink_message: netlink_data.clone(), + runtime_data: None, + major: self.major, + minor: self.minor, + }; + + let child_pid_2 = + process_tools::start_action(emit_udev_event_action, &self.requesting_process) + .expect("subprocess should work"); + + let _exit_info = await_process(Pid::Pid(child_pid_1)).await; + let _exit_info = await_process(Pid::Pid(child_pid_2)).await; self.set_state(&State::Finished); } } diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index d80330c..bdf3ef6 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -64,15 +64,29 @@ struct Args { /// Action to execute (JSON encoded). Note that this excludes all other options. #[arg(long, value_name = "JSON")] pub action: Option, + + /// Path to the target process's /proc//ns directory used as namespace source. + #[arg( + long = "target-namespace", + value_name = "NS_PATH", + help = "Path to /proc//ns used as the namespace source (e.g. /proc/1234/ns or /proc/self/ns)" + )] + pub target_namespace: Option, } fn validate_args(args: &Args) -> Result<(), String> { - // action might only occur alone - match (&args.major, &args.minor, &args.devname, &args.action) { - (None, None, None, Some(_)) => {} - (_, _, _, None) => {} + // action might only occur with target-namespace + match ( + &args.major, + &args.minor, + &args.devname, + &args.action, + &args.target_namespace, + ) { + (None, None, None, Some(_), _) => {} + (_, _, _, None, None) => {} _ => { - return Err("--action must not be used in combination with any other argument".into()); + return Err("--action must not be used in combination with any other argument other than target-namespace".into()); } } @@ -100,8 +114,6 @@ fn validate_args(args: &Args) -> Result<(), String> { fn main() -> std::io::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init(); - check_permissions().expect("failed to read the capabilities of the vuinputd process"); - let args = Args::parse(); let argv0 = std::env::args_os() .next() @@ -113,10 +125,15 @@ fn main() -> std::io::Result<()> { } if args.action.is_some() { + if let Some(target_namespace) = args.target_namespace { + process_tools::run_in_net_and_mnt_namespace(target_namespace.as_str()).unwrap(); + } let error_code = actions::handle_action::handle_cli_action(args.action.unwrap()); std::process::exit(error_code); } + check_permissions().expect("failed to read the capabilities of the vuinputd process"); + initialize_vuinput_state(); VUINPUT_COUNTER.set(AtomicU64::new(3)).expect( "failed to initialize the counter that provides the values of the CUSE file handles", diff --git a/vuinputd/src/process_tools/mod.rs b/vuinputd/src/process_tools/mod.rs index 6890344..70e562c 100644 --- a/vuinputd/src/process_tools/mod.rs +++ b/vuinputd/src/process_tools/mod.rs @@ -4,27 +4,29 @@ use async_io::Async; use log::debug; -use nix::{ - sched::{setns, CloneFlags}, - unistd::{fork, ForkResult}, -}; use std::{ fs::{self, File}, io::Read, - os::fd::{AsFd, FromRawFd, OwnedFd, RawFd}, + os::{ + fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}, + unix::process::CommandExt, + }, path::Path, - process, + process::{Command}, sync::OnceLock, }; +use anyhow::anyhow; use std::io; +use crate::actions::action::Action; + pub static SELF_NAMESPACES: OnceLock = OnceLock::new(); #[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] pub enum Pid { SelfPid, - Pid(i32), + Pid(u32), } impl Pid { @@ -184,7 +186,7 @@ fn get_ppid(pid: Pid) -> Option { .lines() .find(|line| line.starts_with("PPid:")) .and_then(|line| line.split_whitespace().nth(1)) - .and_then(|ppid| ppid.parse::().ok()); + .and_then(|ppid| ppid.parse::().ok()); match ppid { None => None, Some(ppid) => Some(Pid::Pid(ppid)), @@ -252,36 +254,48 @@ pub fn get_requesting_process(pid: Pid) -> RequestingProcess { /// Runs a function inside the given network and mount namespaces. /// Returns the child PID so the caller can `waitpid` on it. -pub fn run_in_net_and_mnt_namespace( - ns: &RequestingProcess, - func: Box, -) -> nix::Result { - //Note: The child process is created with a single thread—the one that called fork(). +pub fn start_action(action: Action, ns: &RequestingProcess) -> anyhow::Result { + let action_json = serde_json::to_string(&action).unwrap(); - match unsafe { fork()? } { - ForkResult::Parent { child } => { - // Parent: return the PID of the child - Ok(child) - } - ForkResult::Child => { - debug!("Start new process {}", process::id()); - // enter namespace - let path: &Path = Path::new(ns.nsroot.as_str()); - debug!("Entering namespaces of process {}. We assume this is the root process of the container.",ns.nsroot.clone()); - if !fs::exists(path).unwrap() { - debug!("the root process of the container whose namespaces we want to enter does not exist anymore!"); - std::process::exit(0); - } - let net = File::open(ns.nsroot.clone() + "/net").expect("net not found"); - let mnt = File::open(ns.nsroot.clone() + "/mnt").expect("mnt not found"); - setns(net.as_fd(), CloneFlags::CLONE_NEWNET).expect("couldn't enter net"); - setns(mnt.as_fd(), CloneFlags::CLONE_NEWNS).expect("couldn't enter mnt"); + let child = unsafe { + Command::new("/proc/self/exe") + .args([ + "--action", + action_json.as_str(), + "--target-namespace", + ns.nsroot.as_str(), + ]) + .pre_exec(|| { + // Last resort, if the parent just is killed. + libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL); + Ok(()) + }) + .spawn() + .expect("failed to start vuinputd") + }; - // execute your function - func(); - std::process::exit(0); - } + Result::Ok(child.id()) +} + +pub fn run_in_net_and_mnt_namespace(target_namespace: &str) -> anyhow::Result<()> { + debug!( + "Entering namespaces of process {}. We assume this is the root process of the container.", + target_namespace + ); + + let path: &Path = Path::new(target_namespace); + if !fs::exists(path).unwrap() { + return Err(anyhow!("the root process of the container whose namespaces we want to enter does not exist anymore")); } + let net = File::open(target_namespace.to_string() + "/net")?; + let mnt = File::open(target_namespace.to_string() + "/mnt")?; + + unsafe { + // enter namespaces + libc::setns(net.as_raw_fd(), libc::CLONE_NEWNET); + libc::setns(mnt.as_raw_fd(), libc::CLONE_NEWNS); + }; + anyhow::Ok(()) } pub async fn await_process(pid: Pid) -> io::Result {