Refactored Namespace Switching After Exec. Now we have a parameter --target-namespace which contains the namespace to enter

This commit is contained in:
Johannes Leupolz 2025-12-18 19:30:14 +00:00
parent 21349c40b9
commit 32119fbf92
7 changed files with 213 additions and 96 deletions

View file

@ -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 containers namespaces using `setns()` (e.g. `CLONE_NEWNS`, `CLONE_NEWNET`).
Dynamic libraries (e.g. `libc`, `libfuse3`, `libudev`) are therefore resolved and mapped **before** entering the containers 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 containers 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.
---

View file

@ -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<String, String>,
runtime_data: Option<String>,
major: u32,
minor: u32,
major: u64,
minor: u64,
},
#[serde(rename = "remove-device")]
RemoveDevice {
path: String,
major: u32,
minor: u32,
major: u64,
minor: u64,
},
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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<String>,
/// Path to the target process's /proc/<pid>/ns directory used as namespace source.
#[arg(
long = "target-namespace",
value_name = "NS_PATH",
help = "Path to /proc/<pid>/ns used as the namespace source (e.g. /proc/1234/ns or /proc/self/ns)"
)]
pub target_namespace: Option<String>,
}
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",

View file

@ -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<Namespaces> = 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<Pid> {
.lines()
.find(|line| line.starts_with("PPid:"))
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|ppid| ppid.parse::<i32>().ok());
.and_then(|ppid| ppid.parse::<u32>().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<dyn Fn()>,
) -> nix::Result<nix::unistd::Pid> {
//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<u32> {
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<i32> {