mirror of
https://github.com/joleuger/vuinputd.git
synced 2026-07-18 00:45:07 +00:00
Refactored Namespace Switching After Exec. Now we have a parameter --target-namespace which contains the namespace to enter
This commit is contained in:
parent
21349c40b9
commit
32119fbf92
7 changed files with 213 additions and 96 deletions
|
|
@ -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.
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue