- Add parameter device_owner that can be used when uids are mapped inside the container to ensure that the folders get created with the uid 0 of the container

- internal refactorings
- target-namespace is now target-pid (which a shorter syntax)
This commit is contained in:
Johannes Leupolz 2026-04-09 21:06:13 +00:00
parent 8e0a00e817
commit b75806cc09
7 changed files with 271 additions and 47 deletions

View file

@ -29,7 +29,7 @@ cargo build -p vuinputd-tests
podman build --dns 1.1.1.1 -t vuinputd-tests -f vuinputd-tests/podman/Containerfile .
```
Run with `cargo test -p vuinputd-tests --features "requires-privileges requires-uinput requires-podman -- --test-threads=1"`.
Run with `cargo test -p vuinputd-tests --features "requires-privileges requires-uinput requires-podman" -- --test-threads=1`.
## Performance tests

View file

@ -107,6 +107,18 @@ impl PodmanBuilder {
self
}
pub fn userns(mut self, mode: &str) -> Self {
self.args.push("--userns".into());
self.args.push(mode.into());
self
}
pub fn uidmap(mut self, uidmap: &str) -> Self {
self.args.push("--uidmap".into());
self.args.push(uidmap.into());
self
}
/// Enable bidirectional IPC using a Unix seqpacket socketpair.
pub fn with_ipc(mut self) -> io::Result<(Self, SandboxIpc)> {
let (parent, child) = nix::sys::socket::socketpair(

View file

@ -71,6 +71,7 @@ impl Drop for VuinputdGuard {
// Wait a bit
for _ in 0..10 {
if let Ok(Some(_)) = self.child.try_wait() {
println!("vuinputd for tests shutdown gracefully");
return;
}
thread::sleep(Duration::from_millis(100));
@ -79,5 +80,6 @@ impl Drop for VuinputdGuard {
// Still alive → SIGKILL
let _ = signal::kill(pid, Signal::SIGKILL);
let _ = self.child.wait();
println!("vuinputd for tests killed");
}
}

View file

@ -10,6 +10,7 @@ pub struct GlobalConfig {
pub policy: DevicePolicy,
pub placement: Placement,
pub vudevname: String,
pub device_owner: DeviceOwner,
}
// The actual static variable. It starts empty and is set once in main().
@ -41,16 +42,40 @@ pub enum Placement {
None,
}
/// Device owner of the created devices
#[derive(Debug, Clone, ValueEnum, Default, PartialEq, Eq)]
pub enum DeviceOwner {
#[default]
/// Automatically derive useful settings (how might change in the future)
Auto,
/// Use the uid and gid of vuinputd
Vuinputd,
/// Same as dev folder in container
ContainerDevFolder,
}
impl DeviceOwner {
pub fn to_string_rep(&self) -> String {
match self {
DeviceOwner::Auto => "auto".to_string(),
DeviceOwner::Vuinputd => "vuinputd".to_string(),
DeviceOwner::ContainerDevFolder => "container-dev-folder".to_string(),
}
}
}
pub fn initialize_global_config(
device_policy: &DevicePolicy,
placement: &Placement,
devname: &Option<String>,
device_owner: &DeviceOwner,
) {
if CONFIG
.set(GlobalConfig {
policy: device_policy.clone(),
placement: placement.clone(),
vudevname: devname.clone().unwrap_or("vuinput".to_string()),
device_owner: device_owner.clone(),
})
.is_err()
{
@ -70,3 +95,7 @@ pub fn get_placement<'a>() -> &'a Placement {
pub fn get_vudevname<'a>() -> &'a String {
&CONFIG.get().unwrap().vudevname
}
pub fn get_device_owner<'a>() -> &'a DeviceOwner {
&CONFIG.get().unwrap().device_owner
}

View file

@ -35,7 +35,7 @@ use crate::cuse_device::evdev_write_watcher::{
use crate::cuse_device::state::{initialize_dedup_last_error, initialize_vuinput_state};
use crate::cuse_device::vuinput_make_cuse_ops;
use crate::cuse_device::vuinput_open::VUINPUT_COUNTER;
use crate::global_config::{DevicePolicy, Placement};
use crate::global_config::{DeviceOwner, DevicePolicy, Placement};
use crate::input_realizer::host_fs;
use crate::jobs::monitor_udev_job::MonitorBackgroundLoop;
@ -80,13 +80,13 @@ struct Args {
#[arg(long = "action-base64", value_name = "BASE64")]
pub action_base64: Option<String>,
/// Path to the target process's /proc/<pid>/ns directory used as namespace source.
/// Process id that is used as the namespace source (e.g. 1234 is used to read the namespaces from /proc/1234/ns).
#[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)"
long = "target-pid",
value_name = "PID",
help = "Process id that is used as the namespace source (e.g. 1234 is used to read the namespaces from /proc/1234/ns)."
)]
pub target_namespace: Option<String>,
pub target_pid: Option<String>,
#[arg(
long = "vt-guard",
@ -104,6 +104,10 @@ struct Args {
/// Placement of device nodes and udev data
#[arg(long, value_enum, default_value_t)]
pub placement: Placement,
/// Owner of the created devices
#[arg(long = "device-owner", value_enum, default_value_t)]
pub device_owner: DeviceOwner,
}
fn validate_args(args: &Args) -> Result<(), String> {
@ -116,18 +120,18 @@ fn validate_args(args: &Args) -> Result<(), String> {
}
};
// action might only occur with target-namespace
// action might only occur with target-pid
match (
&args.major,
&args.minor,
&args.devname,
action,
&args.target_namespace,
&args.target_pid,
) {
(None, None, None, Some(_), _) => {}
(_, _, _, None, None) => {}
_ => {
return Err("--action or --action-base64 must not be used in combination with any other argument other than target-namespace".into());
return Err("--action or --action-base64 must not be used in combination with any other argument other than target-pid".into());
}
}
@ -181,8 +185,8 @@ fn main() -> std::io::Result<()> {
};
if action.is_some() {
if let Some(target_namespace) = args.target_namespace {
process_tools::run_in_net_and_mnt_namespace(target_namespace.as_str()).unwrap();
if let Some(target_pid) = args.target_pid {
process_tools::run_in_net_and_mnt_namespace(target_pid.as_str(),&args.device_owner).unwrap();
}
let error_code = actions::handle_action::handle_cli_action(action.unwrap());
std::process::exit(error_code);
@ -196,7 +200,12 @@ fn main() -> std::io::Result<()> {
check_permissions().expect("failed to read the capabilities of the vuinputd process");
vt_tools::check_vt_status();
global_config::initialize_global_config(&args.device_policy, &args.placement, &args.devname);
global_config::initialize_global_config(
&args.device_policy,
&args.placement,
&args.devname,
&args.device_owner,
);
initialize_evdev_write_watcher().expect(
"failed to initialize the watcher that watches for writes on the created evdev devices",
);
@ -208,7 +217,7 @@ fn main() -> std::io::Result<()> {
.set(Mutex::new(Dispatcher::new()))
.expect("failed to initialize the job dispatcher");
SELF_NAMESPACES
.set(get_namespace(Pid::SelfPid))
.set(get_self_namespace())
.expect("failed to retrieve the namespaces of the vuinputd process");
initialize_dedup_last_error();
JOB_DISPATCHER

View file

@ -11,7 +11,7 @@ use std::{
io::Read,
os::{
fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
unix::process::CommandExt,
unix::{fs::MetadataExt, process::CommandExt},
},
path::Path,
process::Command,
@ -21,23 +21,34 @@ use std::{
use anyhow::anyhow;
use std::io;
use crate::actions::action::Action;
use crate::{
actions::action::Action,
global_config::{get_device_owner, DeviceOwner},
};
pub mod ns_fscreds;
pub static SELF_NAMESPACES: OnceLock<Namespaces> = OnceLock::new();
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum Pid {
SelfPid,
Pid(u32),
}
impl Pid {
pub fn path(&self) -> String {
match self {
Pid::SelfPid => "/proc/self".to_string(),
Pid::Pid(pid_no) => format!("/proc/{}", pid_no),
}
}
pub fn to_string_rep(&self) -> String {
let Pid::Pid(val) = self;
val.to_string()
}
}
enum PidOrSelf {
Pid(u32),
SelfPid,
}
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
@ -80,15 +91,13 @@ pub fn is_compat_process(pid: Pid) -> Option<bool> {
Err(_) => None,
}
}
Pid::SelfPid => unreachable!(),
}
}
// TODO: Rename to capture all relevant process information
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct RequestingProcess {
pub nspath: String,
pub nsroot: String,
pub pid_requestor: Pid,
pub pid_requestor_root: Pid,
pub namespaces: Namespaces,
pub is_compat: bool,
}
@ -134,10 +143,19 @@ impl std::fmt::Display for RequestingProcess {
}
}
pub fn get_self_namespace() -> Namespaces {
get_namespace_of_pid_or_self(PidOrSelf::SelfPid)
}
pub fn get_namespace(pid: Pid) -> Namespaces {
let pid: String = match pid {
Pid::Pid(pid) => pid.to_string(),
Pid::SelfPid => "self".to_string(),
let Pid::Pid(pid) = pid;
get_namespace_of_pid_or_self(PidOrSelf::Pid(pid))
}
fn get_namespace_of_pid_or_self(pid_or_self: PidOrSelf) -> Namespaces {
let pid: String = match pid_or_self {
PidOrSelf::Pid(pid) => pid.to_string(),
PidOrSelf::SelfPid => "self".to_string(),
};
let nspath = format!("/proc/{}/ns", pid);
@ -181,7 +199,6 @@ pub fn get_namespace(pid: Pid) -> Namespaces {
fn get_ppid(pid: Pid) -> Option<Pid> {
let content = match pid {
Pid::SelfPid => fs::read_to_string(format!("/proc/self/status")).ok()?,
Pid::Pid(pid) => fs::read_to_string(format!("/proc/{}/status", pid)).ok()?,
};
let ppid = content
@ -239,29 +256,26 @@ pub fn get_requesting_process(pid: Pid) -> RequestingProcess {
pid.path()
);
let nspath = format!("{}/ns", pid.path());
let nsroot = format!("{}/ns", ppid.path());
RequestingProcess {
nspath: nspath,
nsroot: nsroot,
pid_requestor: pid,
pid_requestor_root: ppid,
namespaces: nsinodes,
is_compat: is_compat,
}
}
Pid::SelfPid => {
unreachable!();
}
}
}
fn print_debug_string(action: &str, ns: &RequestingProcess) {
let action_base64 = (BASE64_STANDARD.encode(action));
let action_base64 = BASE64_STANDARD.encode(action);
let mut debugstring = String::new();
debugstring.push_str("In case you need to debug the system calls, call `strace vuinputd");
debugstring.push_str(" --target-namespace ");
debugstring.push_str(ns.nsroot.as_str());
debugstring.push_str(" --target-pid ");
debugstring.push_str(&ns.pid_requestor_root.to_string_rep());
debugstring.push_str(" --action-base64 ");
debugstring.push_str(action_base64.as_str());
debugstring.push_str(" --device-owner ");
debugstring.push_str(get_device_owner().to_string_rep().as_str());
debugstring.push_str("`");
debug!("{}", debugstring);
}
@ -272,13 +286,17 @@ pub fn start_action(action: Action, ns: &RequestingProcess) -> anyhow::Result<u3
let action_json = serde_json::to_string(&action).unwrap();
print_debug_string(&action_json, &ns);
let device_owner = get_device_owner().to_string_rep();
let child = unsafe {
Command::new("/proc/self/exe")
.args([
"--action",
action_json.as_str(),
"--target-namespace",
ns.nsroot.as_str(),
"--target-pid",
ns.pid_requestor_root.to_string_rep().as_str(),
"--device-owner",
device_owner.as_str(),
])
.pre_exec(|| {
// Last resort, if the parent just is killed.
@ -292,24 +310,40 @@ pub fn start_action(action: Action, ns: &RequestingProcess) -> anyhow::Result<u3
Result::Ok(child.id())
}
pub fn run_in_net_and_mnt_namespace(target_namespace: &str) -> anyhow::Result<()> {
pub fn run_in_net_and_mnt_namespace(target_pid: &str, device_owner: &DeviceOwner) -> anyhow::Result<()> {
debug!(
"Entering namespaces of process {}. We assume this is the root process of the container.",
target_namespace
target_pid
);
let path: &Path = Path::new(target_namespace);
let fs_uid_gid = if *device_owner == DeviceOwner::ContainerDevFolder {
let pid:u32 = target_pid.trim().parse()?;
let pid = Pid::Pid(pid);
let fs_uid=ns_fscreds::get_uid_in_container(pid, 0)?;
let fs_gid=ns_fscreds::get_gid_in_container(pid, 0)?;
Some((fs_uid,fs_gid))
} else {
None
};
let nspath = format!("/proc/{}/ns", target_pid);
let path: &Path = Path::new(&nspath);
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")?;
let net = File::open(nspath.to_string() + "/net")?;
let mnt = File::open(nspath.to_string() + "/mnt")?;
unsafe {
// enter namespaces
libc::setns(net.as_raw_fd(), libc::CLONE_NEWNET);
libc::setns(mnt.as_raw_fd(), libc::CLONE_NEWNS);
};
if let Some((fs_uid,fs_gid)) = fs_uid_gid {
ns_fscreds::acquire_uid_and_gid(fs_uid, fs_gid)?;
}
anyhow::Ok(())
}
@ -338,9 +372,6 @@ pub async fn await_process(pid: Pid) -> io::Result<i32> {
Ok(si.si_status())
}
}
Pid::SelfPid => {
unreachable!();
}
}
}

View file

@ -0,0 +1,141 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::fs;
use std::io::{self, BufRead};
use std::path::Path;
use crate::process_tools::Pid;
#[derive(Debug, Clone, PartialEq)]
struct IdMapEntry {
pub inside_start: u64,
pub outside_start: u64,
pub length: u64,
}
fn parse_id_map(pid: u32, map_type: &str) -> io::Result<Vec<IdMapEntry>> {
let path = format!("/proc/{}/{}", pid, map_type);
let file = fs::File::open(&path)?;
let reader = io::BufReader::new(file);
Ok(reader
.lines()
.filter_map(|line| {
let line = line.ok()?;
let mut parts = line.split_whitespace();
let inside_start = parts.next()?.parse().ok()?;
let outside_start = parts.next()?.parse().ok()?;
let length = parts.next()?.parse().ok()?;
Some(IdMapEntry {
inside_start,
outside_start,
length,
})
})
.collect())
}
fn to_host_id(entries: &[IdMapEntry], inside_id: u64) -> Option<u64> {
entries.iter().find_map(|e| {
if inside_id >= e.inside_start && inside_id < e.inside_start + e.length {
Some(e.outside_start + (inside_id - e.inside_start))
} else {
None
}
})
}
/// Returns the host UID that corresponds to `ns_uid` (e.g. 0) inside the container.
pub fn get_uid_in_container(pid: Pid, ns_uid: u64) -> anyhow::Result<u32> {
let Pid::Pid(pid) = pid;
let entries = parse_id_map(pid, "uid_map")?;
to_host_id(&entries, ns_uid)
.map(|id| id as u32)
.ok_or_else(|| anyhow::anyhow!("uid {} is not mapped in /proc/{}/uid_map", ns_uid, pid))
}
/// Returns the host GID that corresponds to `ns_gid` (e.g. 0) inside the container.
pub fn get_gid_in_container(pid: Pid, ns_gid: u64) -> anyhow::Result<u32> {
let Pid::Pid(pid) = pid;
let entries = parse_id_map(pid, "gid_map")?;
to_host_id(&entries, ns_gid)
.map(|id| id as u32)
.ok_or_else(|| anyhow::anyhow!("gid {} is not mapped in /proc/{}/gid_map", ns_gid, pid))
}
/// Switch filesystem UID/GID to the given host IDs.
/// GID must be set before UID — dropping UID=0 removes the ability to change GID.
pub fn acquire_uid_and_gid(target_uid: u32, target_gid: u32) -> anyhow::Result<()> {
unsafe {
libc::setfsgid(target_gid as libc::gid_t);
libc::setfsuid(target_uid as libc::uid_t);
}
Ok(())
}
/// Switch filesystem UID/GID to match whatever owner the given path has on the host.
pub fn acquire_uid_and_gid_of_path(path: &str) -> anyhow::Result<()> {
use std::os::unix::fs::MetadataExt;
let metadata = fs::metadata(Path::new(path))?;
let target_uid = metadata.uid();
let target_gid = metadata.gid();
acquire_uid_and_gid(target_uid, target_gid)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_str(s: &str) -> Vec<IdMapEntry> {
s.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let inside_start = parts.next()?.parse().ok()?;
let outside_start = parts.next()?.parse().ok()?;
let length = parts.next()?.parse().ok()?;
Some(IdMapEntry {
inside_start,
outside_start,
length,
})
})
.collect()
}
#[test]
fn uid0_in_rootless_container_maps_to_host_uid() {
// Typical rootless setup: container root (0) → host uid 100000
let map = parse_str("0 100000 65536");
assert_eq!(to_host_id(&map, 0), Some(100000));
assert_eq!(to_host_id(&map, 1), Some(100001));
}
#[test]
fn uid_outside_range_returns_none() {
let map = parse_str("0 100000 65536");
assert_eq!(to_host_id(&map, 65536), None);
}
#[test]
fn identity_map_returns_same_id() {
// Process not in a user namespace: 0 0 4294967295
let map = parse_str("0 0 4294967295");
assert_eq!(to_host_id(&map, 0), Some(0));
assert_eq!(to_host_id(&map, 1000), Some(1000));
}
#[test]
fn proc_self_uid_is_parseable() {
let uid = unsafe { libc::getuid() } as u64;
let entries =
parse_id_map(std::process::id(), "uid_map").expect("failed to read /proc/self/uid_map");
assert!(
to_host_id(&entries, uid).is_some(),
"current uid {} not found in uid_map",
uid
);
}
}