From 4c01ecb3013de12e83660448fe40d031d9179418 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sun, 7 Dec 2025 22:11:47 +0000 Subject: [PATCH 001/108] Add some sections in the DESIGN.md for topics I want to talk about. --- README.md | 5 ----- docs/DESIGN.md | 49 +++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0908236..6cf9a8a 100644 --- a/README.md +++ b/README.md @@ -115,11 +115,6 @@ It reliably demonstrates the core concept — exposing `/dev/uinput` devices ins Provide a deb/rpm package for simple deployment. * [ ] **Check for compatibility with steam runtime:** - https://gitlab.steamos.cloud/steamrt/steam-runtime-tools/-/blob/main/steam-runtime-tools/input-device.c - https://gitlab.steamos.cloud/steamrt/steam-runtime-tools/-/blob/main/docs/container-runtime.md - https://gitlab.steamos.cloud/steamrt/steam-runtime-tools/-/blob/main/docs/ld-library-path-runtime.md - https://github.com/ValveSoftware/steam-for-linux/issues/10175?utm_source=chatgpt.com - https://github.com/ValveSoftware/steam-for-linux/issues/8042 * [ ] **Forward known controller pids automatically:** The main reason that vuinputd overrides pids is to ensure that those are not used by the host by accident, especially for keyboards that otherwise might get a seat assigned. This is irrelevant for gamepads. So the pids of known gamepads can just be forwarded. This is relevant for the 360 input devices that are created by steam. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 166e29c..b79d136 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -564,9 +564,11 @@ Library used by wolf and sunshine #### 5.3.2 Steam -https://github.com/games-on-whales/inputtino - -Library used by wolf and sunshine +https://gitlab.steamos.cloud/steamrt/steam-runtime-tools/-/blob/main/steam-runtime-tools/input-device.c +https://gitlab.steamos.cloud/steamrt/steam-runtime-tools/-/blob/main/docs/container-runtime.md +https://gitlab.steamos.cloud/steamrt/steam-runtime-tools/-/blob/main/docs/ld-library-path-runtime.md +https://github.com/ValveSoftware/steam-for-linux/issues/10175 +https://github.com/ValveSoftware/steam-for-linux/issues/8042 ### 5.4 Applications that use the created devices @@ -574,15 +576,37 @@ Library used by wolf and sunshine https://github.com/libsdl-org/SDL/blob/main/src/joystick/linux/SDL_sysjoystick.c +https://github.com/libsdl-org/SDL/blob/main/src/joystick/SDL_joystick.c + #### 5.4.2 libudev and netlink -#### 5.4.3 libinput +https://github.com/systemd/systemd/tree/main/src/libudev + +https://insujang.github.io/2018-11-27/udev-device-manager-for-the-linux-kernel-in-userspace/ + +https://games-on-whales.github.io/wolf/stable/dev/fake-udev.html + +https://github.com/JohnCMcDonough/virtual-gamepad + +#### 5.4.3 libinput, libevdev + +https://gitlab.freedesktop.org/libinput/libinput/-/tree/main/src?ref_type=heads + +https://gitlab.freedesktop.org/libevdev/libevdev/-/blob/master/libevdev/libevdev-uinput.c?ref_type=heads #### 5.4.4. Proton -## 6. Alternative Approaches +https://github.com/GloriousEggroll/proton-ge-custom/blob/master/docs/CONTROLLERS.md -### 6.1 trace accesses of /dev/uinput with eBPF +## 6. HIDAPI + +https://github.com/libusb/hidapi + +https://abeltra.me/blog/inputtino-uhid-1/ + +## 7. Alternative Approaches + +### 7.1 trace accesses of /dev/uinput with eBPF **Idea (short):** attach an eBPF program to the syscall tracepoint for `ioctl` (`tracepoint/syscalls/sys_enter_ioctl`), filter by container cgroup, and send small events (pid, tgid, fd, cmd, timestamp, short payload sample) to userspace using the BPF ring buffer. A privileged host agent consumes the ringbuf events, duplicates the target FD via `pidfd_getfd()` and proceeds with UI_GET_SYSNAME / sysfs resolution to retrieve the sys-path and the dev-path. Having the dev-path and the pid of the container, the solution could proceed as in the current solution. @@ -606,11 +630,20 @@ Inside the trace program you will typically use: The **`pidfd_getfd()`** syscall (introduced in Linux 5.6, see `man pidfd_getfd(2)`) allows one process to **duplicate a file descriptor from another process** into its own FD table. It takes a *pidfd* (obtained via `pidfd_open()` or from `CLONE_PIDFD`), the target FD number in the remote process, and optional flags. The resulting descriptor refers to the **same open file description**—sharing offset, status flags, and driver state—exactly as if the target process had called `dup()`. Permission checks apply: the caller must either share credentials (same UID) or hold `CAP_SYS_PTRACE` or an equivalent capability over the target. This makes `pidfd_getfd()` the canonical and race-free way to inspect or reuse another process’s device handles (for example, to run `UI_GET_SYSNAME` on a client apps' fd on `/dev/uinput` ) without invasive ptrace tricks. -### 6.2 LD_PRELOAD +### 7.2 LD_PRELOAD See src/fake-uinput/README.md on wolf https://github.com/games-on-whales/wolf/issues/81 https://github.com/games-on-whales/wolf/pull/88 -https://github.com/zerofltexx/wolf/commit/5b3282ceef6373c5afd2a860365c886fa942f59c#diff-2446d8f27f6ac4efff38510458548cea92179eddf38c187f5ad90d6bdd4b3d69 \ No newline at end of file +https://github.com/zerofltexx/wolf/commit/5b3282ceef6373c5afd2a860365c886fa942f59c#diff-2446d8f27f6ac4efff38510458548cea92179eddf38c187f5ad90d6bdd4b3d69 + + +### 7.3 Custom kernel modul + +https://github.com/dkms-project/dkms + +https://github.com/torvalds/linux/blob/master/drivers/input/misc/uinput.c + +https://lore.kernel.org/linux-bluetooth/20191201145357.ybq5gfty4ulnfasq@pali/t/#u From 7b8ad17b79f1f11a68e8048a332470c70d97c9c8 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sun, 7 Dec 2025 22:12:35 +0000 Subject: [PATCH 002/108] Add some files with empty sections I want to talk about. --- docs/SECURITY.md | 11 +++++++++++ docs/TRUST.md | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 docs/SECURITY.md create mode 100644 docs/TRUST.md diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..fbe94ac --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,11 @@ +# Security + +## Small code base + +## Fuzzing + +## Audit + +## Unsafe code + +Known problem: A lot of unsafe code. Open are ideas to mitigate this issue. \ No newline at end of file diff --git a/docs/TRUST.md b/docs/TRUST.md new file mode 100644 index 0000000..e5757b7 --- /dev/null +++ b/docs/TRUST.md @@ -0,0 +1,19 @@ +# Trust + +## Supply-chain attacks + +Only crates available in the debian eco system are used. + +## Reproducable builds + +## Code coverage + +## Attestation + +SLSA Attestation + +https://github.com/actions/attest-build-provenance + +## Signed artifacts + +Sigstore / cosign From 3f6186ef867d3ff9b4d498592bfcbc6292b45a69 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sun, 7 Dec 2025 22:56:57 +0000 Subject: [PATCH 003/108] Change in release handler to make it semantically cleaner --- vuinputd/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 6f8ceed..61602cd 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -363,7 +363,7 @@ unsafe extern "C" fn vuinput_release( ); drop(vuinput_state_mutex); // this also closes the file when no other references are open // TODO: maybe also ensure that nothing is left in the containers - fuse_lowlevel::fuse_reply_err(_req, 0); + fuse_lowlevel::fuse_reply_none(_req); } unsafe extern "C" fn vuinput_ioctl( From 2c3531152b0324021779a0327b137b1d05d4f2bc Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sun, 7 Dec 2025 23:18:26 +0000 Subject: [PATCH 004/108] Revert last change and add comment. Althought it looks cleaner, it is wrong and actually blocks. Added comment to highlight that fact. --- vuinputd/src/main.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 61602cd..befb57b 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -363,7 +363,9 @@ unsafe extern "C" fn vuinput_release( ); drop(vuinput_state_mutex); // this also closes the file when no other references are open // TODO: maybe also ensure that nothing is left in the containers - fuse_lowlevel::fuse_reply_none(_req); + + // This _must_ be fuse_reply_err. fuse_reply_none would lead to a deadlock of the uinput user. + fuse_lowlevel::fuse_reply_err(_req, 0); } unsafe extern "C" fn vuinput_ioctl( From ee38a7147cf561d909f2ce2f232470aa4c2844b9 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 8 Dec 2025 22:48:22 +0000 Subject: [PATCH 005/108] Some name refactorings --- docs/DESIGN.md | 9 +++++ vuinputd/Cargo.toml | 2 +- vuinputd/src/container/mod.rs | 9 ----- .../src/{jobs => job_engine}/closure_job.rs | 5 ++- vuinputd/src/{jobs => job_engine}/job.rs | 0 .../src/{jobs => job_engine}/job_builder.rs | 0 vuinputd/src/job_engine/mod.rs | 38 ++++++++++++++++++ vuinputd/src/{jobs => job_engine}/tests.rs | 7 +++- .../inject_in_container_job.rs | 5 +-- .../{container => jobs}/mknod_input_device.rs | 0 vuinputd/src/jobs/mod.rs | 40 +++---------------- .../monitor_udev_job.rs} | 2 +- .../{container => jobs}/netlink_message.rs | 0 .../remove_from_container_job.rs | 5 +-- .../src/{container => jobs}/runtime_data.rs | 0 vuinputd/src/main.rs | 29 +++++++++----- 16 files changed, 85 insertions(+), 66 deletions(-) delete mode 100644 vuinputd/src/container/mod.rs rename vuinputd/src/{jobs => job_engine}/closure_job.rs (97%) rename vuinputd/src/{jobs => job_engine}/job.rs (100%) rename vuinputd/src/{jobs => job_engine}/job_builder.rs (100%) create mode 100644 vuinputd/src/job_engine/mod.rs rename vuinputd/src/{jobs => job_engine}/tests.rs (89%) rename vuinputd/src/{container => jobs}/inject_in_container_job.rs (93%) rename vuinputd/src/{container => jobs}/mknod_input_device.rs (100%) rename vuinputd/src/{monitor_udev.rs => jobs/monitor_udev_job.rs} (99%) rename vuinputd/src/{container => jobs}/netlink_message.rs (100%) rename vuinputd/src/{container => jobs}/remove_from_container_job.rs (91%) rename vuinputd/src/{container => jobs}/runtime_data.rs (100%) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b79d136..2ac76c0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -570,6 +570,11 @@ https://gitlab.steamos.cloud/steamrt/steam-runtime-tools/-/blob/main/docs/ld-lib https://github.com/ValveSoftware/steam-for-linux/issues/10175 https://github.com/ValveSoftware/steam-for-linux/issues/8042 +#### 5.3.3 Selkies Project + +Absolutely untested. +https://github.com/selkies-project/selkies/pull/173 + ### 5.4 Applications that use the created devices #### 5.4.1 SDL @@ -598,6 +603,10 @@ https://gitlab.freedesktop.org/libevdev/libevdev/-/blob/master/libevdev/libevdev https://github.com/GloriousEggroll/proton-ge-custom/blob/master/docs/CONTROLLERS.md +wine control + +https://github.com/flatpak/xdg-desktop-portal/issues/536 + ## 6. HIDAPI https://github.com/libusb/hidapi diff --git a/vuinputd/Cargo.toml b/vuinputd/Cargo.toml index 5616e67..0aa4dfa 100644 --- a/vuinputd/Cargo.toml +++ b/vuinputd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vuinputd" -version = "0.2.0" +version = "0.2.1" edition = "2021" authors = ["Johannes Leupolz "] license = "MIT" diff --git a/vuinputd/src/container/mod.rs b/vuinputd/src/container/mod.rs deleted file mode 100644 index 610b907..0000000 --- a/vuinputd/src/container/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-License-Identifier: MIT -// -// Author: Johannes Leupolz - -pub mod inject_in_container_job; -pub mod remove_from_container_job; -pub mod mknod_input_device; -pub mod runtime_data; -pub mod netlink_message; \ No newline at end of file diff --git a/vuinputd/src/jobs/closure_job.rs b/vuinputd/src/job_engine/closure_job.rs similarity index 97% rename from vuinputd/src/jobs/closure_job.rs rename to vuinputd/src/job_engine/closure_job.rs index 9236e94..7ac1b8f 100644 --- a/vuinputd/src/jobs/closure_job.rs +++ b/vuinputd/src/job_engine/closure_job.rs @@ -5,7 +5,8 @@ use std::future::Future; use std::pin::Pin; -use crate::jobs::job::{Dispatcher, Job, JobTarget}; +use crate::job_engine::job::{Dispatcher, Job, JobTarget}; + pub struct ClosureJob { desc: String, @@ -56,7 +57,7 @@ impl Job for ClosureJob { } /// Example usage -#[allow(dead_code)] +#[test] pub fn example() { let mut dispatcher = Dispatcher::new(); diff --git a/vuinputd/src/jobs/job.rs b/vuinputd/src/job_engine/job.rs similarity index 100% rename from vuinputd/src/jobs/job.rs rename to vuinputd/src/job_engine/job.rs diff --git a/vuinputd/src/jobs/job_builder.rs b/vuinputd/src/job_engine/job_builder.rs similarity index 100% rename from vuinputd/src/jobs/job_builder.rs rename to vuinputd/src/job_engine/job_builder.rs diff --git a/vuinputd/src/job_engine/mod.rs b/vuinputd/src/job_engine/mod.rs new file mode 100644 index 0000000..3639120 --- /dev/null +++ b/vuinputd/src/job_engine/mod.rs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz +//! # Design: Async Per-Container Job Executor (Tokio) +//! +//! ## Overview +//! A scalable, structured design for running async jobs per container. +//! +//! - Global dispatcher routes jobs to per-container async loops or to global queue. +//! - Each container has its own unbounded job queue (no backpressure). +//! - Loops are spawned lazily on first job and exit when their sender drops. +//! - Graceful shutdown happens automatically (channel close → loop exit). +//! - Periodic cleanup removes idle container queues. +//! +//! ## Async Jobs +//! - Each `Job` contains an async closure `task: Box Pin>> + Send>` +//! - This allows full async/await usage inside the job body. +//! +//! +//! +--------------------------------------+ +//! | Global dispatcher | +//! +----------+---------------------------+ +//! | | +//! v v +//! +----------+-----------+ +------------+ +//! | Per-container queues | | Host queue | +//! +----+------+----+-----+ +------------+ +//! | | | +//! +----v----+ +---v----+ +---v----+ +//! | Cont A | | Cont B | | Cont C | +//! | loop() | | loop() | | loop() | +//! +---------+ +--------+ +--------+ + +pub mod closure_job; +pub mod job; + +#[cfg(test)] +mod tests; \ No newline at end of file diff --git a/vuinputd/src/jobs/tests.rs b/vuinputd/src/job_engine/tests.rs similarity index 89% rename from vuinputd/src/jobs/tests.rs rename to vuinputd/src/job_engine/tests.rs index 45d8f26..db87e02 100644 --- a/vuinputd/src/jobs/tests.rs +++ b/vuinputd/src/job_engine/tests.rs @@ -1,5 +1,7 @@ -use crate::jobs::closure_job::ClosureJob; -use crate::jobs::job::{Dispatcher, Job, JobTarget}; + + +use crate::job_engine::closure_job::ClosureJob; +use crate::job_engine::job::{Dispatcher, JobTarget}; use super::*; use futures::executor::LocalPool; @@ -49,6 +51,7 @@ fn test_job_ordering() { }), ))); + dispatcher.close(); dispatcher.wait_until_finished(); assert_eq!(*c.lock().unwrap(), 6); diff --git a/vuinputd/src/container/inject_in_container_job.rs b/vuinputd/src/jobs/inject_in_container_job.rs similarity index 93% rename from vuinputd/src/container/inject_in_container_job.rs rename to vuinputd/src/jobs/inject_in_container_job.rs index 53e6fcc..19abaea 100644 --- a/vuinputd/src/container/inject_in_container_job.rs +++ b/vuinputd/src/jobs/inject_in_container_job.rs @@ -7,9 +7,8 @@ use std::{collections::HashMap, future::Future, pin::Pin, sync::{Arc, Condvar, M use async_io::Timer; use log::debug; -use crate::{ - container::{mknod_input_device::ensure_input_device, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{self, ensure_udev_structure, read_udev_data, write_udev_data}}, jobs::job::{Job, JobTarget}, monitor_udev::EVENT_STORE, requesting_process::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace} -}; +use crate::{job_engine::job::{Job, JobTarget}, jobs::{mknod_input_device::ensure_input_device, monitor_udev_job::EVENT_STORE, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{ensure_udev_structure, read_udev_data, write_udev_data}}, requesting_process::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace}}; + #[derive(Clone,Debug,Copy,PartialOrd,PartialEq)] pub enum State { diff --git a/vuinputd/src/container/mknod_input_device.rs b/vuinputd/src/jobs/mknod_input_device.rs similarity index 100% rename from vuinputd/src/container/mknod_input_device.rs rename to vuinputd/src/jobs/mknod_input_device.rs diff --git a/vuinputd/src/jobs/mod.rs b/vuinputd/src/jobs/mod.rs index 3639120..cf229cf 100644 --- a/vuinputd/src/jobs/mod.rs +++ b/vuinputd/src/jobs/mod.rs @@ -1,38 +1,10 @@ // SPDX-License-Identifier: MIT // // Author: Johannes Leupolz -//! # Design: Async Per-Container Job Executor (Tokio) -//! -//! ## Overview -//! A scalable, structured design for running async jobs per container. -//! -//! - Global dispatcher routes jobs to per-container async loops or to global queue. -//! - Each container has its own unbounded job queue (no backpressure). -//! - Loops are spawned lazily on first job and exit when their sender drops. -//! - Graceful shutdown happens automatically (channel close → loop exit). -//! - Periodic cleanup removes idle container queues. -//! -//! ## Async Jobs -//! - Each `Job` contains an async closure `task: Box Pin>> + Send>` -//! - This allows full async/await usage inside the job body. -//! -//! -//! +--------------------------------------+ -//! | Global dispatcher | -//! +----------+---------------------------+ -//! | | -//! v v -//! +----------+-----------+ +------------+ -//! | Per-container queues | | Host queue | -//! +----+------+----+-----+ +------------+ -//! | | | -//! +----v----+ +---v----+ +---v----+ -//! | Cont A | | Cont B | | Cont C | -//! | loop() | | loop() | | loop() | -//! +---------+ +--------+ +--------+ -pub mod closure_job; -pub mod job; - -#[cfg(test)] -mod tests; \ No newline at end of file +pub mod inject_in_container_job; +pub mod remove_from_container_job; +pub mod monitor_udev_job; +pub mod mknod_input_device; +pub mod runtime_data; +pub mod netlink_message; \ No newline at end of file diff --git a/vuinputd/src/monitor_udev.rs b/vuinputd/src/jobs/monitor_udev_job.rs similarity index 99% rename from vuinputd/src/monitor_udev.rs rename to vuinputd/src/jobs/monitor_udev_job.rs index 0d648b0..3697e07 100644 --- a/vuinputd/src/monitor_udev.rs +++ b/vuinputd/src/jobs/monitor_udev_job.rs @@ -19,7 +19,7 @@ use libudev::Monitor; use log::debug; use regex::Regex; -use crate::jobs::job::{Job, JobTarget}; +use crate::job_engine::job::{Job, JobTarget}; // === Basic types === diff --git a/vuinputd/src/container/netlink_message.rs b/vuinputd/src/jobs/netlink_message.rs similarity index 100% rename from vuinputd/src/container/netlink_message.rs rename to vuinputd/src/jobs/netlink_message.rs diff --git a/vuinputd/src/container/remove_from_container_job.rs b/vuinputd/src/jobs/remove_from_container_job.rs similarity index 91% rename from vuinputd/src/container/remove_from_container_job.rs rename to vuinputd/src/jobs/remove_from_container_job.rs index 1f7989b..a08bd81 100644 --- a/vuinputd/src/container/remove_from_container_job.rs +++ b/vuinputd/src/jobs/remove_from_container_job.rs @@ -7,9 +7,8 @@ use std::{collections::HashMap, future::Future, pin::Pin, sync::{Arc, Condvar, M use async_io::Timer; use log::debug; -use crate::{ - container::{mknod_input_device::{ensure_input_device, remove_input_device}, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{self, delete_udev_data, ensure_udev_structure, read_udev_data, write_udev_data}}, jobs::job::{Job, JobTarget}, monitor_udev::EVENT_STORE, requesting_process::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace} -}; +use crate::{job_engine::job::{Job, JobTarget}, jobs::{mknod_input_device::remove_input_device, monitor_udev_job::EVENT_STORE, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{delete_udev_data, ensure_udev_structure, read_udev_data, write_udev_data}}, requesting_process::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace}}; + #[derive(Clone,Debug,Copy,PartialOrd,PartialEq)] diff --git a/vuinputd/src/container/runtime_data.rs b/vuinputd/src/jobs/runtime_data.rs similarity index 100% rename from vuinputd/src/container/runtime_data.rs rename to vuinputd/src/jobs/runtime_data.rs diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index befb57b..59f1ef8 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -38,18 +38,17 @@ use std::sync::{Arc, Mutex, OnceLock, RwLock}; use uinput_ioctls::*; pub mod requesting_process; -pub mod monitor_udev; pub mod compat; use crate::compat::input_event_compat; -use crate::container::inject_in_container_job::InjectInContainerJob; -use crate::container::remove_from_container_job::RemoveFromContainerJob; -use crate::monitor_udev::MonitorBackgroundLoop; +use crate::jobs::inject_in_container_job::InjectInContainerJob; +use crate::jobs::monitor_udev_job::MonitorBackgroundLoop; +use crate::jobs::remove_from_container_job::RemoveFromContainerJob; use crate::requesting_process::*; -pub mod jobs; -use crate::jobs::job::*; +pub mod job_engine; +use crate::job_engine::job::*; -pub mod container; +pub mod jobs; #[derive(Debug)] @@ -351,7 +350,7 @@ unsafe extern "C" fn vuinput_release( let remove_job=RemoveFromContainerJob::new(vuinput_state.requesting_process.clone(),input_device.devnode.clone(),input_device.syspath.clone(),input_device.major,input_device.minor); let awaiter = remove_job.get_awaiter_for_state(); JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(remove_job)); - awaiter(&container::remove_from_container_job::State::Finished); + awaiter(&jobs::remove_from_container_job::State::Finished); } drop(vuinput_state); @@ -364,7 +363,15 @@ unsafe extern "C" fn vuinput_release( drop(vuinput_state_mutex); // this also closes the file when no other references are open // TODO: maybe also ensure that nothing is left in the containers - // This _must_ be fuse_reply_err. fuse_reply_none would lead to a deadlock of the uinput user. + // Note: For CUSE, the kernel always issues RELEASE via fuse_sync_release(), + // which forces a *synchronous* request (fuse_simple_request()). + // + // That means the kernel thread blocks until userspace sends a reply header. + // Calling fuse_reply_none() would send no header at all, causing the kernel + // to wait forever and the caller to deadlock. + // + // Therefore we must always send a real reply for RELEASE. + // `fuse_reply_err(req, 0)` is enough to wake the kernel and is safe here. fuse_lowlevel::fuse_reply_err(_req, 0); } @@ -522,7 +529,7 @@ unsafe extern "C" fn vuinput_ioctl( let inject_job=InjectInContainerJob::new(vuinput_state.requesting_process.clone(),devnode.clone(),sysname.clone(),major,minor); let awaiter = inject_job.get_awaiter_for_state(); JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(inject_job)); - awaiter(&container::inject_in_container_job::State::Finished); + awaiter(&jobs::inject_in_container_job::State::Finished); debug!("fh {}: injecting dev-nodes in container has been finished ", fh); } @@ -542,7 +549,7 @@ unsafe extern "C" fn vuinput_ioctl( let remove_job=RemoveFromContainerJob::new(vuinput_state.requesting_process.clone(),input_device.devnode.clone(),input_device.syspath.clone(),input_device.major,input_device.minor); let awaiter = remove_job.get_awaiter_for_state(); JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(remove_job)); - awaiter(&container::remove_from_container_job::State::Finished); + awaiter(&jobs::remove_from_container_job::State::Finished); debug!("fh {}: removing dev-nodes from container has been finished ", fh); } From 320a77ef63fe4793f037d8122fdabd1480747f5c Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 8 Dec 2025 23:03:34 +0000 Subject: [PATCH 006/108] Files renamed for more clarity --- vuinputd/src/job_engine/job.rs | 2 +- vuinputd/src/job_engine/tests.rs | 310 ++++++++++++++++++ vuinputd/src/jobs/inject_in_container_job.rs | 2 +- .../src/jobs/remove_from_container_job.rs | 2 +- vuinputd/src/main.rs | 6 +- .../mod.rs} | 0 6 files changed, 317 insertions(+), 5 deletions(-) rename vuinputd/src/{requesting_process.rs => process_tools/mod.rs} (100%) diff --git a/vuinputd/src/job_engine/job.rs b/vuinputd/src/job_engine/job.rs index 051bce8..d03bc27 100644 --- a/vuinputd/src/job_engine/job.rs +++ b/vuinputd/src/job_engine/job.rs @@ -11,7 +11,7 @@ use std::sync::Mutex; use std::thread::{self, JoinHandle}; use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; -use crate::requesting_process::RequestingProcess; +use crate::process_tools::RequestingProcess; // To discuss: // what we handle here, could also be named Task. The decision for job was more or less diff --git a/vuinputd/src/job_engine/tests.rs b/vuinputd/src/job_engine/tests.rs index db87e02..77276db 100644 --- a/vuinputd/src/job_engine/tests.rs +++ b/vuinputd/src/job_engine/tests.rs @@ -56,3 +56,313 @@ fn test_job_ordering() { assert_eq!(*c.lock().unwrap(), 6); } + +/* + +// +// 2. Cancellation test (default: do NOT run cancelled job) +// +#[test] +fn test_cancellation_stops_jobs() { + let (mut dispatcher, mut pool) = test_dispatcher(); + let c = shared_counter(); + + // First job runs + let c1 = c.clone(); + dispatcher.queue(Job::new(move |_ctx| { + *c1.borrow_mut() += 1; + Poll::Ready(()) + })); + + // Second job should NOT run (will be cancelled) + let c2 = c.clone(); + dispatcher.queue(Job::with_flags( + move |_ctx| { + *c2.borrow_mut() += 1; + Poll::Ready(()) + }, + JobFlags::default(), // execute_after_cancellation = false + )); + + // Immediately close before job #2 runs + dispatcher.close(); + run_pool_to_completion(&mut pool); + + assert_eq!(*c.borrow(), 1); +} + +// +// 3. Cancellation *with* execute_after_cancellation +// +#[test] +fn test_cancellation_runs_cleanup_jobs() { + let (mut dispatcher, mut pool) = test_dispatcher(); + let c = shared_counter(); + + // First job runs + let c1 = c.clone(); + dispatcher.queue(Job::new(move |_ctx| { + *c1.borrow_mut() += 1; + Poll::Ready(()) + })); + + // Cleanup job must run even after cancellation + let c2 = c.clone(); + dispatcher.queue(Job::with_flags( + move |_ctx| { + *c2.borrow_mut() += 10; + Poll::Ready(()) + }, + JobFlags { + execute_after_cancellation: true, + }, + )); + + dispatcher.close(); + run_pool_to_completion(&mut pool); + + assert_eq!(*c.borrow(), 11); +} + +// +// 4. SIGTERM-like shutdown behaviour +// +#[test] +fn test_shutdown_wait_until_finished() { + let (mut dispatcher, mut pool) = test_dispatcher(); + let c = shared_counter(); + + // Simulate some work + let c1 = c.clone(); + dispatcher.queue(Job::new(move |_ctx| { + *c1.borrow_mut() += 1; + Poll::Ready(()) + })); + + dispatcher.close(); + run_pool_to_completion(&mut pool); + dispatcher.wait_until_finished(); + + assert_eq!(*c.borrow(), 1); +} + +// +// 5. Failure propagation +// +#[test] +fn test_job_failure_does_not_crash_dispatcher() { + let (mut dispatcher, mut pool) = test_dispatcher(); + let c = shared_counter(); + + dispatcher.queue(Job::new(|_ctx| { + panic!("intentional test panic"); + #[allow(unreachable_code)] + Poll::Ready(()) + })); + + // Following job should still run if the system recovers + let c2 = c.clone(); + dispatcher.queue(Job::new(move |_ctx| { + *c2.borrow_mut() += 1; + Poll::Ready(()) + })); + + dispatcher.close(); + + // Some designs need catch_unwind here; yours might not. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + run_pool_to_completion(&mut pool); + })); + + assert_eq!(*c.borrow(), 1); +} + +// +// 6. Stress: many short jobs +// +#[test] +fn test_many_small_jobs() { + let (mut dispatcher, mut pool) = test_dispatcher(); + let count = shared_counter(); + + for _ in 0..200 { + let c = count.clone(); + dispatcher.queue(Job::new(move |_ctx| { + *c.borrow_mut() += 1; + Poll::Ready(()) + })); + } + + dispatcher.close(); + run_pool_to_completion(&mut pool); + + assert_eq!(*count.borrow(), 200); +} + +// +// 7. Ensure no jobs run after final close +// +#[test] +fn test_no_jobs_after_close() { + let (mut dispatcher, mut pool) = test_dispatcher(); + dispatcher.close(); + + let executed = shared_counter(); + let e = executed.clone(); + + dispatcher.queue(Job::new(move |_ctx| { + *e.borrow_mut() += 1; + Poll::Ready(()) + })); + + // Dispatcher should ignore queued jobs after close() + run_pool_to_completion(&mut pool); + + assert_eq!(*executed.borrow(), 0); +} +#[test] +fn test_stress_light_multi_target() { + use std::sync::{Arc, Mutex}; + use crate::jobs::{Dispatcher, JobTarget}; + use crate::jobs::closure_job::ClosureJob; + + // Shared result buffer + let results = Arc::new(Mutex::new(Vec::new())); + + let mut dispatcher = Dispatcher::new(); + + // Create a few job targets + let target_a = JobTarget::Container("A".into()); + let target_b = JobTarget::Container("B".into()); + let host = JobTarget::Host; + + // Create 10 jobs per target + for i in 0..10 { + let results_a = results.clone(); + dispatcher.dispatch(Box::new(ClosureJob::new( + format!("A-{i}"), + target_a.clone(), + false, + Box::new(move |_t| { + Box::pin(async move { + results_a.lock().unwrap().push(format!("A-{i}")); + }) + }), + ))); + + let results_b = results.clone(); + dispatcher.dispatch(Box::new(ClosureJob::new( + format!("B-{i}"), + target_b.clone(), + false, + Box::new(move |_t| { + Box::pin(async move { + results_b.lock().unwrap().push(format!("B-{i}")); + }) + }), + ))); + + let results_h = results.clone(); + dispatcher.dispatch(Box::new(ClosureJob::new( + format!("H-{i}"), + host.clone(), + false, + Box::new(move |_t| { + Box::pin(async move { + results_h.lock().unwrap().push(format!("H-{i}")); + }) + }), + ))); + } + + dispatcher.close(); + dispatcher.wait_until_finished(); + + let buf = results.lock().unwrap(); + + // Each target must preserve *its* order: + assert_eq!( + buf.iter().filter(|s| s.starts_with("A-")).cloned().collect::>(), + (0..10).map(|i| format!("A-{i}")).collect::>() + ); + + assert_eq!( + buf.iter().filter(|s| s.starts_with("B-")).cloned().collect::>(), + (0..10).map(|i| format!("B-{i}")).collect::>() + ); + + assert_eq!( + buf.iter().filter(|s| s.starts_with("H-")).cloned().collect::>(), + (0..10).map(|i| format!("H-{i}")).collect::>() + ); + + // But between targets, the order may be interleaved (expected) + // So we don't assert cross-order. +} + +#[test] +fn test_cleanup_when_target_disappears() { + use std::sync::{Arc, Mutex}; + use crate::jobs::{Dispatcher, JobTarget}; + use crate::jobs::closure_job::ClosureJob; + + let results = Arc::new(Mutex::new(Vec::new())); + let mut dispatcher = Dispatcher::new(); + + let target_dead = JobTarget::Container("dead".into()); + let target_alive = JobTarget::Container("alive".into()); + + // Two jobs for dead target: one normal, one allowed after cancel + { + let r = results.clone(); + dispatcher.dispatch(Box::new(ClosureJob::new( + "dead-normal", + target_dead.clone(), + false, + Box::new(move |_| Box::pin(async move { + r.lock().unwrap().push("dead-normal".into()); + })) + ))); + + let r = results.clone(); + dispatcher.dispatch(Box::new(ClosureJob::new( + "dead-cleanup", + target_dead.clone(), + true, // allowed after cancellation + Box::new(move |_| Box::pin(async move { + r.lock().unwrap().push("dead-cleanup".into()); + })) + ))); + } + + // One job for a live target + { + let r = results.clone(); + dispatcher.dispatch(Box::new(ClosureJob::new( + "alive-job", + target_alive.clone(), + false, + Box::new(move |_| Box::pin(async move { + r.lock().unwrap().push("alive-job".into()); + })) + ))); + } + + // Simulate container removal + dispatcher.target_gone(&target_dead); + + dispatcher.close(); + dispatcher.wait_until_finished(); + + let buf = results.lock().unwrap(); + + // Should NOT run: + assert!(!buf.contains(&"dead-normal".into())); + + // Should run because execute_after_cancellation = true + assert!(buf.contains(&"dead-cleanup".into())); + + // Should run normally + assert!(buf.contains(&"alive-job".into())); +} + */ \ No newline at end of file diff --git a/vuinputd/src/jobs/inject_in_container_job.rs b/vuinputd/src/jobs/inject_in_container_job.rs index 19abaea..eca8fb2 100644 --- a/vuinputd/src/jobs/inject_in_container_job.rs +++ b/vuinputd/src/jobs/inject_in_container_job.rs @@ -7,7 +7,7 @@ use std::{collections::HashMap, future::Future, pin::Pin, sync::{Arc, Condvar, M use async_io::Timer; use log::debug; -use crate::{job_engine::job::{Job, JobTarget}, jobs::{mknod_input_device::ensure_input_device, monitor_udev_job::EVENT_STORE, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{ensure_udev_structure, read_udev_data, write_udev_data}}, requesting_process::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace}}; +use crate::{job_engine::job::{Job, JobTarget}, jobs::{mknod_input_device::ensure_input_device, monitor_udev_job::EVENT_STORE, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{ensure_udev_structure, read_udev_data, write_udev_data}}, process_tools::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace}}; #[derive(Clone,Debug,Copy,PartialOrd,PartialEq)] diff --git a/vuinputd/src/jobs/remove_from_container_job.rs b/vuinputd/src/jobs/remove_from_container_job.rs index a08bd81..1de6865 100644 --- a/vuinputd/src/jobs/remove_from_container_job.rs +++ b/vuinputd/src/jobs/remove_from_container_job.rs @@ -7,7 +7,7 @@ use std::{collections::HashMap, future::Future, pin::Pin, sync::{Arc, Condvar, M use async_io::Timer; use log::debug; -use crate::{job_engine::job::{Job, JobTarget}, jobs::{mknod_input_device::remove_input_device, monitor_udev_job::EVENT_STORE, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{delete_udev_data, ensure_udev_structure, read_udev_data, write_udev_data}}, requesting_process::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace}}; +use crate::{job_engine::job::{Job, JobTarget}, jobs::{mknod_input_device::remove_input_device, monitor_udev_job::EVENT_STORE, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{delete_udev_data, ensure_udev_structure, read_udev_data, write_udev_data}}, process_tools::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace}}; diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 59f1ef8..a67067e 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -37,16 +37,18 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use uinput_ioctls::*; -pub mod requesting_process; pub mod compat; + use crate::compat::input_event_compat; use crate::jobs::inject_in_container_job::InjectInContainerJob; use crate::jobs::monitor_udev_job::MonitorBackgroundLoop; use crate::jobs::remove_from_container_job::RemoveFromContainerJob; -use crate::requesting_process::*; + +pub mod process_tools; pub mod job_engine; use crate::job_engine::job::*; +use crate::process_tools::*; pub mod jobs; diff --git a/vuinputd/src/requesting_process.rs b/vuinputd/src/process_tools/mod.rs similarity index 100% rename from vuinputd/src/requesting_process.rs rename to vuinputd/src/process_tools/mod.rs From c79252939567307607b6b888370027094f4920ac Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 8 Dec 2025 23:46:57 +0000 Subject: [PATCH 007/108] Structural refactoring. Now everything related to cuse is in its own module. After the refacoring, a lot of ugly imports need to be cleaned up. --- vuinputd/src/compat.rs | 34 - vuinputd/src/cuse_device/mod.rs | 172 +++++ vuinputd/src/cuse_device/vuinput_ioctl.rs | 371 ++++++++++ vuinputd/src/cuse_device/vuinput_open.rs | 71 ++ vuinputd/src/cuse_device/vuinput_release.rs | 71 ++ vuinputd/src/cuse_device/vuinput_write.rs | 165 +++++ vuinputd/src/job_engine/mod.rs | 6 + vuinputd/src/main.rs | 708 +------------------- vuinputd/src/process_tools/mod.rs | 18 +- 9 files changed, 878 insertions(+), 738 deletions(-) delete mode 100644 vuinputd/src/compat.rs create mode 100644 vuinputd/src/cuse_device/mod.rs create mode 100644 vuinputd/src/cuse_device/vuinput_ioctl.rs create mode 100644 vuinputd/src/cuse_device/vuinput_open.rs create mode 100644 vuinputd/src/cuse_device/vuinput_release.rs create mode 100644 vuinputd/src/cuse_device/vuinput_write.rs diff --git a/vuinputd/src/compat.rs b/vuinputd/src/compat.rs deleted file mode 100644 index b7f609c..0000000 --- a/vuinputd/src/compat.rs +++ /dev/null @@ -1,34 +0,0 @@ -use libc::{__s32, __u16, c_ulong, input_event}; - - -#[repr(C)] -pub struct input_event_compat { - pub input_event_sec: u32, - pub input_event_usec: u32, - pub type_: __u16, - pub code: __u16, - pub value: __s32, -} - -// this is static for the architecture -pub fn compat_uses_64bit_time() -> bool { - let uname = nix::sys::utsname::uname().unwrap(); - let arch = uname.machine().to_str().unwrap(); - - match arch { - "x86_64" => false, - "ppc64" => false, // some setups still 32-bit time_t - _ => true, // arm64, riscv64, s390x all use 64-bit - } -} - -pub fn map_to_64_bit(compat: &input_event_compat) -> input_event{ - let mut mapped: input_event = unsafe { std::mem::zeroed() }; - mapped.time.tv_sec=compat.input_event_sec.into(); - mapped.time.tv_usec=compat.input_event_usec.into(); - mapped.type_=compat.type_; - mapped.code=compat.code; - mapped.value=compat.value; - - mapped -} \ No newline at end of file diff --git a/vuinputd/src/cuse_device/mod.rs b/vuinputd/src/cuse_device/mod.rs new file mode 100644 index 0000000..5c928a7 --- /dev/null +++ b/vuinputd/src/cuse_device/mod.rs @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +pub mod vuinput_ioctl; +pub mod vuinput_write; +pub mod vuinput_release; +pub mod vuinput_open; + +use std::collections::HashMap; +use std::fs::{self, File}; +use std::io; +use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::io::{ErrorKind}; + +use ::cuse_lowlevel::*; + +use crate::process_tools::RequestingProcess; + +#[derive(Debug)] +struct VuInputDevice { + cuse_fh : u64, + major : u64, + minor : u64, + syspath: String, + devnode: String, + runtime_data: Option, + netlink_data: Option +} + +#[derive(Debug)] +pub struct VuInputState { + file: File, + requesting_process: RequestingProcess, + input_device: Option +} + +#[derive(Debug,Eq, Hash, PartialEq, Clone)] +pub enum VuFileHandle { + Fh(u64) +} + +impl VuFileHandle { + fn from_fuse_file_info(fi: &fuse_lowlevel::fuse_file_info) -> VuFileHandle { + VuFileHandle::Fh(fi.fh) + } +} + +impl std::fmt::Display for VuFileHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VuFileHandle::Fh(fh) => writeln!(f, "VuFileHandle({:?})",fh)?, + } + Ok(()) + } +} + +#[derive(Debug)] +pub enum VuError { + WriteError +} + +pub static VUINPUT_STATE: OnceLock>>>> = OnceLock::new(); + +// For log limiting. Idea: Move to log_limit crate +pub static DEDUP_LAST_ERROR: OnceLock>> = OnceLock::new(); + + +pub const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; +pub const BUS_USB: u16 = 0x03; + +pub fn get_vuinput_state( + fh:&VuFileHandle, +) -> Result>, String> { + let map = VUINPUT_STATE + .get() + .ok_or("global not initialized".to_string())?; + let guard = map.read().map_err(|e| e.to_string())?; + guard + .get(&fh) + .cloned() + .ok_or("handle not opened".to_string()) +} + + +pub fn insert_vuinput_state( + fh:&VuFileHandle, + state: VuInputState, +) -> Result<(), String> { + let map = VUINPUT_STATE + .get() + .ok_or("global not initialized".to_string())?; + let mut guard = map.write().map_err(|e| e.to_string())?; + + if guard.contains_key(&fh) { + return Err(format!( + "file handle {} already exists. file handles must not be reused!", + &fh + )); + } + + let _ = guard.insert(fh.clone(), Arc::new(Mutex::new(state))); + Ok(()) +} + +pub fn remove_vuinput_state( + fh:&VuFileHandle, +) -> Result>, String> { + let map = VUINPUT_STATE + .get() + .ok_or("global not initialized".to_string())?; + let mut guard = map.write().map_err(|e| e.to_string())?; + let old_value = guard.remove(&fh).ok_or("fh unknown")?; + Ok(old_value) +} + +pub fn fetch_device_node(path: &str) -> io::Result { + for entry in fs::read_dir(path)? { + let entry = entry?; // propagate per-entry errors + if let Some(name) = entry.file_name().to_str() { + if name.starts_with("event") { + return Ok(format!("/dev/input/{}", name)); + } + } + } + // If no device is found, return an error + Err(io::Error::new(ErrorKind::NotFound, "no device found")) +} + +/// Returns (major, minor) numbers of a device node at `path` +pub fn fetch_major_minor(path: &str) -> io::Result<(u64, u64)> { + let metadata = fs::metadata(path)?; + + // Ensure it's a character device + if !metadata.file_type().is_char_device() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Not a character device", + )); + } + + let rdev = metadata.rdev(); + let major = ((rdev >> 8) & 0xfff) as u64; + let minor = ((rdev & 0xff) | ((rdev >> 12) & 0xfff00)) as u64; + + Ok((major, minor)) +} + + + + +// Instance of cuse_lowlevel_ops with all stubs assigned. +// Setting to None leads to e.g. "write error: Function not implemented". +// You can find the implementations of the uinput default (open, release ,read, write, poll, +// and ioctl) in uinput_fops of uinput.c. +// See: https://github.com/torvalds/linux/blob/master/drivers/input/misc/uinput.c, +pub fn vuinput_make_cuse_ops() -> cuse_lowlevel::cuse_lowlevel_ops { + cuse_lowlevel::cuse_lowlevel_ops { + init: None, + init_done: None, + destroy: None, + open: Some(vuinput_open::vuinput_open), + read: None, + write: Some(vuinput_write::vuinput_write), + flush: None, + release: Some(vuinput_release::vuinput_release), + fsync: None, + ioctl: Some(vuinput_ioctl::vuinput_ioctl), + poll: None, + } +} diff --git a/vuinputd/src/cuse_device/vuinput_ioctl.rs b/vuinputd/src/cuse_device/vuinput_ioctl.rs new file mode 100644 index 0000000..9d747aa --- /dev/null +++ b/vuinputd/src/cuse_device/vuinput_ioctl.rs @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use libc::{O_CLOEXEC, input_id}; +use libc::{iovec, off_t, size_t, EBADRQC, EIO, ENOENT}; +use libc::{uinput_abs_setup, uinput_ff_erase, uinput_ff_upload, uinput_setup}; +use ::cuse_lowlevel::*; +use log::{debug, error, info, trace}; +use std::collections::HashMap; +use std::ffi::{CStr, CString}; +use std::{fs, ptr}; +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::io::{self, ErrorKind}; +use std::os::fd::AsRawFd; +use std::os::raw::{c_char, c_int, c_uint, c_void}; +use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use uinput_ioctls::*; + +use crate::cuse_device::{SYS_INPUT_DIR, VuFileHandle, VuInputDevice, fetch_device_node, get_vuinput_state}; +use crate::job_engine::JOB_DISPATCHER; +use crate::jobs::inject_in_container_job::InjectInContainerJob; +use crate::jobs::remove_from_container_job::RemoveFromContainerJob; +use crate::process_tools::SELF_NAMESPACES; +use crate::{cuse_device::*, jobs}; + +pub unsafe extern "C" fn vuinput_ioctl( + _req: fuse_lowlevel::fuse_req_t, + _cmd: c_int, + _arg: *mut c_void, //note: this is a pointer in the application space and should not be dereferenced at all + _fi: *mut fuse_lowlevel::fuse_file_info, + _flags: c_uint, + _in_buf: *const c_void, // note: this was mapped by the kernel and can be read from + _in_bufsz: size_t, + _out_bufsz: size_t, +) { + // fuse_reply_ioctl_retry is only necessary for variable length commands; + // see comment "Now check variable-length commands" in uinput.c of the linux kernel. + // Those are UI_GET_SYSNAME and UI_ABS_SETUP as of v0.4. + + // ioctl to map are listed on https://www.freedesktop.org/software/libevdev/doc/latest/ioctls.html + // https://docs.rs/linux-raw-sys/0.11.0/src/linux_raw_sys/x86_64/ioctl.rs.html#529 + + let cmd_u64 = (_cmd as c_uint).into(); + // normalize the variable length ones + let cmd_without_size = cmd_u64 & !(nix::sys::ioctl::SIZEMASK << nix::sys::ioctl::SIZESHIFT); + let cmd_normalized = match cmd_without_size { + UI_GET_SYSNAME_WITHOUT_SIZE => UI_GET_SYSNAME_WITHOUT_SIZE, + //UI_ABS_SETUP => UI_ABS_SETUP_WITHOUT_SIZE, + _ => cmd_u64, + }; + let vufh= VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap()); + let vuinput_state_mutex = get_vuinput_state(&vufh).unwrap(); + let fh = &(*_fi).fh; + let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); + + // ensure for all ioctls that need mapped data, that we have the data correctly mapped + match (_in_bufsz, _out_bufsz, cmd_normalized) { + (0, _, UI_ABS_SETUP) => { + //todo: i guess this needs to be reworked as this is variable size. i guess it is not reachable at all + debug!("fh {}: submitting _in_bufsz for UI_ABS_SETUP", fh); + let iov = iovec { + iov_base: _arg, + iov_len: ::std::mem::size_of::(), + }; + fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0); + return; + } + (_, 0, UI_GET_SYSNAME_WITHOUT_SIZE) => { + let size = (cmd_u64 & nix::sys::ioctl::SIZEMASK) >> nix::sys::ioctl::SIZESHIFT; + debug!( + "fh {}: submitting _out_bufsz for UI_GET_SYSNAME({}) ", + fh, size + ); + let iov = iovec { + iov_base: _arg, + iov_len: 64, + }; + fuse_lowlevel::fuse_reply_ioctl_retry(_req, std::ptr::null(), 0, &iov, 1); + return; + } + (_, 0, UI_GET_VERSION) => { + let size = (cmd_u64 & nix::sys::ioctl::SIZEMASK) >> nix::sys::ioctl::SIZESHIFT; + debug!( + "fh {}: submitting _out_bufsz for UI_GET_VERSION({}) ", + fh, size + ); + let iov = iovec { + iov_base: _arg, + iov_len: std::mem::size_of::(), + }; + fuse_lowlevel::fuse_reply_ioctl_retry(_req, std::ptr::null(), 0, &iov, 1); + return; + } + (0, _, UI_DEV_SETUP) => { + debug!("fh {}: submitting _in_bufsz for UI_DEV_SETUP", fh); + let iov = iovec { + iov_base: _arg, + iov_len: ::std::mem::size_of::(), + }; + fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0); + return; + } + (0, _, UI_SET_PHYS) => { + debug!("fh {}: submitting _in_bufsz for UI_SET_PHYS", fh); + let iov = iovec { + iov_base: _arg, + iov_len: ::std::mem::size_of::() * 1024, + }; + fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0); + return; + } + (0, _, UI_BEGIN_FF_UPLOAD) => { + debug!("fh {}: submitting _in_bufsz for UI_BEGIN_FF_UPLOAD", fh); + let iov = iovec { + iov_base: _arg, + iov_len: ::std::mem::size_of::(), + }; + fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, &iov, 1); + return; + } + (0, _, UI_END_FF_UPLOAD) => { + debug!("fh {}: submitting _in_bufsz for UI_END_FF_UPLOAD", fh); + let iov = iovec { + iov_base: _arg, + iov_len: ::std::mem::size_of::(), + }; + fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0); + return; + } + (0, _, UI_BEGIN_FF_ERASE) => { + debug!("fh {}: submitting _in_bufsz for UI_BEGIN_FF_ERASE", fh); + let iov = iovec { + iov_base: _arg, + iov_len: ::std::mem::size_of::(), + }; + fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, &iov, 1); + return; + } + (0, _, UI_END_FF_ERASE) => { + debug!("fh {}: submitting _in_bufsz for UI_END_FF_ERASE", fh); + let iov = iovec { + iov_base: _arg, + iov_len: ::std::mem::size_of::(), + }; + fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0); + return; + } + _ => { + //nothing to map + } + } + + let fd = vuinput_state.file.as_raw_fd(); + + // now we can assume that the data is mapped or it is not required + match cmd_normalized { + UI_DEV_CREATE => { + debug!("fh {}: ioctl UI_DEV_CREATE", fh); + ui_dev_create(fd).unwrap(); + + let mut resultbuf: [c_char; 64] = [0; 64]; + ui_get_sysname(fd, resultbuf.as_mut_slice()).unwrap(); + let sysname = format!( + "{}{}", + SYS_INPUT_DIR, + CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() + ); + debug!("fh {}: syspath: {}", fh, sysname); + let devnode = fetch_device_node(&sysname).unwrap(); + debug!("fh {}: devnode: {}", fh, devnode); + let (major,minor) = fetch_major_minor(&devnode).unwrap(); + debug!("fh {}: major: {} minor: {} ", fh, major,minor); + vuinput_state.input_device = Some(VuInputDevice {cuse_fh:*fh, major: major, minor: minor, syspath: sysname.clone(), devnode: devnode.clone(), runtime_data: None, netlink_data: None }); + + // Create device in container, if the request was really from another namespace + if ! SELF_NAMESPACES.get().unwrap().equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) { + let inject_job=InjectInContainerJob::new(vuinput_state.requesting_process.clone(),devnode.clone(),sysname.clone(),major,minor); + let awaiter = inject_job.get_awaiter_for_state(); + JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(inject_job)); + awaiter(&jobs::inject_in_container_job::State::Finished); + debug!("fh {}: injecting dev-nodes in container has been finished ", fh); + } + + // write a SYN-event (which is just zeros) just for validation + let syn_event : [u8; 24] = [0; 24]; + vuinput_state.file.write_all(&syn_event).unwrap(); + + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_DEV_DESTROY => { + debug!("fh {}: ioctl UI_DEV_DESTROY", fh); + let input_device = vuinput_state.input_device.take(); + + // Remove device in container, if the request was really from another namespace + if input_device.is_some() && ! SELF_NAMESPACES.get().unwrap().equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) { + let input_device = input_device.unwrap(); + let remove_job=RemoveFromContainerJob::new(vuinput_state.requesting_process.clone(),input_device.devnode.clone(),input_device.syspath.clone(),input_device.major,input_device.minor); + let awaiter = remove_job.get_awaiter_for_state(); + JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(remove_job)); + awaiter(&jobs::remove_from_container_job::State::Finished); + debug!("fh {}: removing dev-nodes from container has been finished ", fh); + } + + ui_dev_destroy(fd).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_DEV_SETUP => { + debug!("fh {}: ioctl UI_DEV_SETUP", fh); + assert!(_in_bufsz != 0, "should have _in_bufsz"); + let setup_ptr = _in_buf as *mut uinput_setup; + debug!( + "product: {:x} vendor: {:x}", + (*setup_ptr).id.product, + (*setup_ptr).id.vendor + ); + // replace vendor and product id to the values from sunshine (see inputtino_common.h of sunshine) + // The pid is registered for vuinputd, see https://pid.codes/1209/5020/ + (*setup_ptr).id.product = 0x5020; + (*setup_ptr).id.vendor = 0x1209; + ui_dev_setup(fd, setup_ptr).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_ABS_SETUP => { + //todo: i guess this needs to be reworked as this is variable size. i guess it is not reachable at all + debug!("fh {}: ioctl UI_ABS_SETUP", fh); + assert!(_in_bufsz != 0, "should have _in_bufsz"); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_GET_SYSNAME_WITHOUT_SIZE => { + debug!("fh {}: ioctl UI_GET_SYSNAME {_out_bufsz}", fh); + assert!( + _out_bufsz == 64, + "should have _out_bufsz of length 64 (currently hardcoded)" + ); + let mut resultbuf: [c_char; 64] = [0; 64]; + ui_get_sysname(fd, resultbuf.as_mut_slice()).unwrap(); + let sysname = CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy(); + debug!("fh {}: sysname: {}", fh, sysname); + fuse_lowlevel::fuse_reply_ioctl( + _req, + 0, + resultbuf.as_mut_ptr() as *mut c_void, + _out_bufsz, + ); + } + UI_GET_VERSION => { + let mut version_of_kernel = 0; + let pversion_of_kernel = std::ptr::from_mut(&mut version_of_kernel); + ui_get_version(fd, pversion_of_kernel).unwrap(); + debug!("fh {}: ioctl UI_GET_VERSION {}", fh, version_of_kernel); + let reply_arg = 5; + let preply_arg = std::ptr::from_ref(&reply_arg); + fuse_lowlevel::fuse_reply_ioctl( + _req, + 0, + preply_arg as *const c_void, + std::mem::size_of::(), + ); + } + UI_SET_EVBIT => { + let value = _arg as c_uint; + debug!("fh {}: ioctl UI_SET_EVBIT {}", fh, value); + ui_set_evbit(fd, value.into()).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_SET_KEYBIT => { + let value = _arg as c_uint; + debug!("fh {}: ioctl UI_SET_KEYBIT {}", fh, value); + ui_set_keybit(fd, value.into()).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_SET_RELBIT => { + let value = _arg as c_uint; + debug!("fh {}: ioctl UI_SET_RELBIT {}", fh, value); + ui_set_relbit(fd, value.into()).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_SET_ABSBIT => { + let value = _arg as c_uint; + debug!("fh {}: ioctl UI_SET_ABSBIT {}", fh, value); + ui_set_absbit(fd, value.into()).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_SET_MSCBIT => { + let value = _arg as c_uint; + debug!("fh {}: ioctl UI_SET_MSCBIT {}", fh, value); + ui_set_mscbit(fd, value.into()).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_SET_LEDBIT => { + let value = _arg as c_uint; + debug!("fh {}: ioctl UI_SET_LEDBIT {}", fh, value); + ui_set_ledbit(fd, value.into()).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_SET_SNDBIT => { + let value = _arg as c_uint; + debug!("fh {}: ioctl UI_SET_SNDBIT {}", fh, value); + ui_set_sndbit(fd, value.into()).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_SET_FFBIT => { + let value = _arg as c_uint; + debug!("fh {}: ioctl UI_SET_FFBIT {}", fh, value); + ui_set_ffbit(fd, value.into()).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_SET_PHYS => { + assert!(_in_bufsz != 0, "should have _in_bufsz"); + debug!("fh {}: ioctl UI_SET_PHYS", fh); + // inbuf is actually a *const c_char, but + // but the macro to generate ui_set_phys expects a ptr to the actual data structure. + let phys = _in_buf as *const *const c_char; + ui_set_phys(fd, phys).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_SET_SWBIT => { + let value = _arg as c_uint; + debug!("fh {}: ioctl UI_SET_SWBIT {}", fh, value); + ui_set_swbit(fd, value.into()).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_SET_PROPBIT => { + let value = _arg as c_uint; + debug!("fh {}: ioctl UI_SET_PROPBIT {}", fh, value); + ui_set_propbit(fd, value.into()).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_BEGIN_FF_UPLOAD => { + assert!(_in_bufsz != 0, "should have _in_bufsz"); + debug!("fh {}: ioctl UI_BEGIN_FF_UPLOAD", fh); + let ff_upload_ptr = _in_buf as *mut uinput_ff_upload; + debug!("request_id: {:x}", (*ff_upload_ptr).request_id); + ui_begin_ff_upload(fd, ff_upload_ptr).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, ff_upload_ptr as *mut c_void, _out_bufsz); + } + UI_END_FF_UPLOAD => { + assert!(_in_bufsz != 0, "should have _in_bufsz"); + debug!("fh {}: ioctl UI_END_FF_UPLOAD", fh); + let ff_upload_ptr = _in_buf as *const uinput_ff_upload; + debug!("request_id: {:x}", (*ff_upload_ptr).request_id); + ui_end_ff_upload(fd, ff_upload_ptr).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + UI_BEGIN_FF_ERASE => { + assert!(_in_bufsz != 0, "should have _in_bufsz"); + debug!("fh {}: ioctl UI_BEGIN_FF_ERASE", fh); + let ff_erase_ptr = _in_buf as *mut uinput_ff_erase; + debug!("request_id: {:x}", (*ff_erase_ptr).request_id); + ui_begin_ff_erase(fd, ff_erase_ptr).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, ff_erase_ptr as *mut c_void, _out_bufsz); + } + UI_END_FF_ERASE => { + assert!(_in_bufsz != 0, "should have _in_bufsz"); + debug!("fh {}: ioctl UI_END_FF_ERASE", fh); + let ff_erase_ptr = _in_buf as *const uinput_ff_erase; + debug!("request_id: {:x}", (*ff_erase_ptr).request_id); + ui_end_ff_erase(fd, ff_erase_ptr).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); + } + _ => { + debug!("fh {}: ioctl cmd {}", fh, _cmd); + fuse_lowlevel::fuse_reply_err(_req, EBADRQC); + } + } +} \ No newline at end of file diff --git a/vuinputd/src/cuse_device/vuinput_open.rs b/vuinputd/src/cuse_device/vuinput_open.rs new file mode 100644 index 0000000..67b0e4b --- /dev/null +++ b/vuinputd/src/cuse_device/vuinput_open.rs @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::sync::OnceLock; +use libc::{O_CLOEXEC, input_id}; +use libc::{iovec, off_t, size_t, EBADRQC, EIO, ENOENT}; +use libc::{uinput_abs_setup, uinput_ff_erase, uinput_ff_upload, uinput_setup}; +use ::cuse_lowlevel::*; +use log::{debug, error, info, trace}; +use std::collections::HashMap; +use std::ffi::{CStr, CString}; +use std::{fs, ptr}; +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::io::{self, ErrorKind}; +use std::os::fd::AsRawFd; +use std::os::raw::{c_char, c_int, c_uint, c_void}; +use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; +use uinput_ioctls::*; + +use crate::process_tools::{Pid, get_requesting_process}; +use crate::cuse_device::*; + +pub static VUINPUT_COUNTER: OnceLock = OnceLock::new(); + +fn get_fresh_filehandle() -> u64 { + let ctr = VUINPUT_COUNTER.get().unwrap(); + ctr.fetch_add(1, Ordering::SeqCst).into() +} + +pub unsafe extern "C" fn vuinput_open( + _req: fuse_lowlevel::fuse_req_t, + _fi: *mut fuse_lowlevel::fuse_file_info, +) { + 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)); + 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; + // Open the path in read-only mode, returns `io::Result` + let open_vuinput_result = OpenOptions::new() + .read(true) + .write(true) + //.custom_flags(O_NONBLOCK) + .custom_flags(O_CLOEXEC) + .open(Path::new("/dev/uinput")); + match open_vuinput_result { + Ok(v) => { + insert_vuinput_state( + &VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap()), + VuInputState { + file: v, + requesting_process, + input_device: None + }, + ) + .unwrap(); + fuse_lowlevel::fuse_reply_open(_req, _fi); + } + Err(e) => { + error!("couldn't open /dev/uinput: {}", e); + fuse_lowlevel::fuse_reply_err(_req, ENOENT); + } + } +} \ No newline at end of file diff --git a/vuinputd/src/cuse_device/vuinput_release.rs b/vuinputd/src/cuse_device/vuinput_release.rs new file mode 100644 index 0000000..ee80a63 --- /dev/null +++ b/vuinputd/src/cuse_device/vuinput_release.rs @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use libc::{O_CLOEXEC, input_id}; +use libc::{iovec, off_t, size_t, EBADRQC, EIO, ENOENT}; +use libc::{uinput_abs_setup, uinput_ff_erase, uinput_ff_upload, uinput_setup}; +use ::cuse_lowlevel::*; +use log::{debug, error, info, trace}; +use std::collections::HashMap; +use std::ffi::{CStr, CString}; +use std::{fs, ptr}; +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::io::{self, ErrorKind}; +use std::os::fd::AsRawFd; +use std::os::raw::{c_char, c_int, c_uint, c_void}; +use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use uinput_ioctls::*; +use crate::{cuse_device::*, jobs}; +use crate::job_engine::JOB_DISPATCHER; +use crate::jobs::remove_from_container_job::RemoveFromContainerJob; +use crate::process_tools::SELF_NAMESPACES; + +pub unsafe extern "C" fn vuinput_release( + _req: fuse_lowlevel::fuse_req_t, + _fi: *mut fuse_lowlevel::fuse_file_info, +) { + let fh = &(*_fi).fh; + let vuinput_state_mutex = remove_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap(); + + let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); + let input_device = vuinput_state.input_device.take(); + + // Remove device in container, if the request was really from another namespace + // Only do this in case it has not already been done by the ioctl UI_DEV_DESTROY + // this here is relevant if the process was killed and didn't have the chance to send the + // ioctl UI_DEV_DESTROY. + if input_device.is_some() && ! SELF_NAMESPACES.get().unwrap().equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) { + let input_device = input_device.unwrap(); + let remove_job=RemoveFromContainerJob::new(vuinput_state.requesting_process.clone(),input_device.devnode.clone(),input_device.syspath.clone(),input_device.major,input_device.minor); + let awaiter = remove_job.get_awaiter_for_state(); + JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(remove_job)); + awaiter(&jobs::remove_from_container_job::State::Finished); + } + + drop(vuinput_state); + + debug!( + "fh {}: references left before releasing device {} (expected is 1)", + fh, + Arc::strong_count(&vuinput_state_mutex) + ); + drop(vuinput_state_mutex); // this also closes the file when no other references are open + // TODO: maybe also ensure that nothing is left in the containers + + // Note: For CUSE, the kernel always issues RELEASE via fuse_sync_release(), + // which forces a *synchronous* request (fuse_simple_request()). + // + // That means the kernel thread blocks until userspace sends a reply header. + // Calling fuse_reply_none() would send no header at all, causing the kernel + // to wait forever and the caller to deadlock. + // + // Therefore we must always send a real reply for RELEASE. + // `fuse_reply_err(req, 0)` is enough to wake the kernel and is safe here. + fuse_lowlevel::fuse_reply_err(_req, 0); +} + diff --git a/vuinputd/src/cuse_device/vuinput_write.rs b/vuinputd/src/cuse_device/vuinput_write.rs new file mode 100644 index 0000000..b8e57c8 --- /dev/null +++ b/vuinputd/src/cuse_device/vuinput_write.rs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use libc::{O_CLOEXEC, input_id}; +use libc::{iovec, off_t, size_t, EBADRQC, EIO, ENOENT}; +use libc::{uinput_abs_setup, uinput_ff_erase, uinput_ff_upload, uinput_setup}; +use ::cuse_lowlevel::*; +use log::{debug, error, info, trace}; +use std::collections::HashMap; +use std::ffi::{CStr, CString}; +use std::{fs, ptr}; +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::io::{self, ErrorKind}; +use std::os::fd::AsRawFd; +use std::os::raw::{c_char, c_int, c_uint, c_void}; +use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use uinput_ioctls::*; +use libc::{__s32, __u16, c_ulong, input_event}; +use crate::cuse_device::*; + + + +// TODO: compat-mode+ ensure sizeof(struct input_event) +pub unsafe extern "C" fn vuinput_write( + _req: fuse_lowlevel::fuse_req_t, + _buf: *const c_char, + _size: size_t, + _off: off_t, + _fi: *mut fuse_lowlevel::fuse_file_info, +) { + assert!( + _off == 0, + "vuinput_write: offset needs to be 0 but is {}", + _off + ); + + let fh = &(*_fi).fh; + let slice = std::slice::from_raw_parts(_buf as *const u8, _size); + let vuinput_state_mutex = get_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap(); + let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); + + if vuinput_state.input_device.is_none() { + debug!("{}: legacy device setup recognized! Ignore the data and use hardcoded values",fh); + + assert!(_size == std::mem::size_of::()); + let legacy_uinput_user_dev = _buf as *const libc::uinput_user_dev; + + let mut usetup: uinput_setup = unsafe { std::mem::zeroed() }; + usetup.id.bustype = BUS_USB; + // The pid is registered for vuinputd, see https://pid.codes/1209/5020/ + usetup.id.vendor = 0x1209; + usetup.id.product = 0x5020; + usetup.id.version = (*legacy_uinput_user_dev).id.version; + usetup.ff_effects_max=(*legacy_uinput_user_dev).ff_effects_max; + usetup.name=(*legacy_uinput_user_dev).name; + + // Call IOCTLs to setup and create the device + // Assuming your wrappers accept (fd, ptr_to_usetup) etc. + // We'll pass pointer to usetup + let usetup_ptr = &mut usetup as *mut uinput_setup; + let fd = vuinput_state.file.as_raw_fd(); + ui_dev_setup(fd, usetup_ptr).unwrap(); + + // setup abs + for code in 0..libc::ABS_CNT{ + if (*legacy_uinput_user_dev).absmax[code] != 0 || (*legacy_uinput_user_dev).absmin[code] != 0 { + let mut abs_setup: uinput_abs_setup = unsafe { std::mem::zeroed() }; + abs_setup.code=code.try_into().unwrap(); + abs_setup.absinfo.maximum = (*legacy_uinput_user_dev).absmax[code]; + abs_setup.absinfo.minimum = (*legacy_uinput_user_dev).absmin[code]; + abs_setup.absinfo.fuzz = (*legacy_uinput_user_dev).absfuzz[code]; + abs_setup.absinfo.flat = (*legacy_uinput_user_dev).absflat[code]; + + let abs_setup_ptr = &mut abs_setup as *mut uinput_abs_setup; + ui_abs_setup(fd, abs_setup_ptr).unwrap(); + } + } + + fuse_lowlevel::fuse_reply_write(_req, _size); + return; + } + + let mut bytes = 0; + let mut result = Result::Ok(0); + + let compat_size= std::mem::size_of::(); + let normal_size= std::mem::size_of::(); + let is_compat = vuinput_state.requesting_process.is_compat; + // TODO: ARM: && !compat_uses_64bit_time() + + if !is_compat { + while bytes + normal_size <= _size && result.is_ok() { + result = vuinput_state.file.write(&slice[bytes..bytes + normal_size]); + bytes += normal_size; + } + } else { + while bytes + compat_size <= _size && result.is_ok() { + let position= _buf.byte_add(bytes); + let compat = position as *const input_event_compat; + let normal = map_to_64_bit(&*compat); + let normal_ptr=(&normal as *const libc::input_event) as *const u8; + let slice = std::slice::from_raw_parts(normal_ptr,normal_size); + result = vuinput_state.file.write(&slice); + bytes += compat_size; + } + }; + + match result { + Ok(_) => { + trace!("wrote {} of {} bytes (compat {})", bytes,_size,is_compat); + fuse_lowlevel::fuse_reply_write(_req, bytes); + } + Err(e) => { + let mut last_error = DEDUP_LAST_ERROR.get().unwrap().lock().unwrap(); + + match *last_error { + Some((last_fh,VuError::WriteError)) if *fh == last_fh => {}, + _ => {debug!("fh {}: error writing to uinput: {e:?}",fh);} + } + + *last_error = Some((*fh,VuError::WriteError)); + + fuse_lowlevel::fuse_reply_err(_req, EIO); + } + } +} + + + +#[repr(C)] +pub struct input_event_compat { + pub input_event_sec: u32, + pub input_event_usec: u32, + pub type_: __u16, + pub code: __u16, + pub value: __s32, +} + +// this is static for the architecture +pub fn compat_uses_64bit_time() -> bool { + let uname = nix::sys::utsname::uname().unwrap(); + let arch = uname.machine().to_str().unwrap(); + + match arch { + "x86_64" => false, + "ppc64" => false, // some setups still 32-bit time_t + _ => true, // arm64, riscv64, s390x all use 64-bit + } +} + +pub fn map_to_64_bit(compat: &input_event_compat) -> input_event{ + let mut mapped: input_event = unsafe { std::mem::zeroed() }; + mapped.time.tv_sec=compat.input_event_sec.into(); + mapped.time.tv_usec=compat.input_event_usec.into(); + mapped.type_=compat.type_; + mapped.code=compat.code; + mapped.value=compat.value; + + mapped +} \ No newline at end of file diff --git a/vuinputd/src/job_engine/mod.rs b/vuinputd/src/job_engine/mod.rs index 3639120..9ea9ce1 100644 --- a/vuinputd/src/job_engine/mod.rs +++ b/vuinputd/src/job_engine/mod.rs @@ -31,8 +31,14 @@ //! | loop() | | loop() | | loop() | //! +---------+ +--------+ +--------+ +use std::sync::{Mutex, OnceLock}; + +use crate::job_engine::job::Dispatcher; + pub mod closure_job; pub mod job; +pub static JOB_DISPATCHER: OnceLock>= OnceLock::new(); + #[cfg(test)] mod tests; \ No newline at end of file diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index a67067e..500fcfe 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -37,9 +37,10 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use uinput_ioctls::*; -pub mod compat; +pub mod cuse_device; -use crate::compat::input_event_compat; +use crate::cuse_device::vuinput_open::VUINPUT_COUNTER; +use crate::cuse_device::{DEDUP_LAST_ERROR, VUINPUT_STATE, vuinput_make_cuse_ops}; use crate::jobs::inject_in_container_job::InjectInContainerJob; use crate::jobs::monitor_udev_job::MonitorBackgroundLoop; use crate::jobs::remove_from_container_job::RemoveFromContainerJob; @@ -47,711 +48,12 @@ use crate::jobs::remove_from_container_job::RemoveFromContainerJob; pub mod process_tools; pub mod job_engine; -use crate::job_engine::job::*; +use crate::job_engine::{JOB_DISPATCHER, job::*}; use crate::process_tools::*; pub mod jobs; -#[derive(Debug)] -struct VuInputDevice { - cuse_fh : u64, - major : u64, - minor : u64, - syspath: String, - devnode: String, - runtime_data: Option, - netlink_data: Option -} - -#[derive(Debug)] -struct VuInputState { - file: File, - requesting_process: RequestingProcess, - input_device: Option -} - -#[derive(Debug,Eq, Hash, PartialEq, Clone)] -enum VuFileHandle { - Fh(u64) -} - -impl VuFileHandle { - fn from_fuse_file_info(fi: &fuse_lowlevel::fuse_file_info) -> VuFileHandle { - VuFileHandle::Fh(fi.fh) - } -} - -impl std::fmt::Display for VuFileHandle { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - VuFileHandle::Fh(fh) => writeln!(f, "VuFileHandle({:?})",fh)?, - } - Ok(()) - } -} - -#[derive(Debug)] -enum VuError { - WriteError -} - -static VUINPUT_COUNTER: OnceLock = OnceLock::new(); -static VUINPUT_STATE: OnceLock>>>> = OnceLock::new(); -static JOB_DISPATCHER: OnceLock>= OnceLock::new(); -static VUINPUTD_NAMESPACES: OnceLock= OnceLock::new(); - -// For log limiting. Idea: Move to log_limit crate -static DEDUP_LAST_ERROR: OnceLock>> = OnceLock::new(); - - -const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; -const BUS_USB: u16 = 0x03; - -fn get_vuinput_state( - fh:&VuFileHandle, -) -> Result>, String> { - let map = VUINPUT_STATE - .get() - .ok_or("global not initialized".to_string())?; - let guard = map.read().map_err(|e| e.to_string())?; - guard - .get(&fh) - .cloned() - .ok_or("handle not opened".to_string()) -} - -fn get_fresh_filehandle() -> u64 { - let ctr = VUINPUT_COUNTER.get().unwrap(); - ctr.fetch_add(1, Ordering::SeqCst).into() -} - -fn insert_vuinput_state( - fh:&VuFileHandle, - state: VuInputState, -) -> Result<(), String> { - let map = VUINPUT_STATE - .get() - .ok_or("global not initialized".to_string())?; - let mut guard = map.write().map_err(|e| e.to_string())?; - - if guard.contains_key(&fh) { - return Err(format!( - "file handle {} already exists. file handles must not be reused!", - &fh - )); - } - - let _ = guard.insert(fh.clone(), Arc::new(Mutex::new(state))); - Ok(()) -} - -fn remove_vuinput_state( - fh:&VuFileHandle, -) -> Result>, String> { - let map = VUINPUT_STATE - .get() - .ok_or("global not initialized".to_string())?; - let mut guard = map.write().map_err(|e| e.to_string())?; - let old_value = guard.remove(&fh).ok_or("fh unknown")?; - Ok(old_value) -} - -fn fetch_device_node(path: &str) -> io::Result { - for entry in fs::read_dir(path)? { - let entry = entry?; // propagate per-entry errors - if let Some(name) = entry.file_name().to_str() { - if name.starts_with("event") { - return Ok(format!("/dev/input/{}", name)); - } - } - } - // If no device is found, return an error - Err(io::Error::new(ErrorKind::NotFound, "no device found")) -} - -/// Returns (major, minor) numbers of a device node at `path` -fn fetch_major_minor(path: &str) -> io::Result<(u64, u64)> { - let metadata = fs::metadata(path)?; - - // Ensure it's a character device - if !metadata.file_type().is_char_device() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Not a character device", - )); - } - - let rdev = metadata.rdev(); - let major = ((rdev >> 8) & 0xfff) as u64; - let minor = ((rdev & 0xff) | ((rdev >> 12) & 0xfff00)) as u64; - - Ok((major, minor)) -} - -unsafe extern "C" fn vuinput_open( - _req: fuse_lowlevel::fuse_req_t, - _fi: *mut fuse_lowlevel::fuse_file_info, -) { - 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)); - 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; - // Open the path in read-only mode, returns `io::Result` - let open_vuinput_result = OpenOptions::new() - .read(true) - .write(true) - //.custom_flags(O_NONBLOCK) - .custom_flags(O_CLOEXEC) - .open(Path::new("/dev/uinput")); - match open_vuinput_result { - Ok(v) => { - insert_vuinput_state( - &VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap()), - VuInputState { - file: v, - requesting_process, - input_device: None - }, - ) - .unwrap(); - fuse_lowlevel::fuse_reply_open(_req, _fi); - } - Err(e) => { - error!("couldn't open /dev/uinput: {}", e); - fuse_lowlevel::fuse_reply_err(_req, ENOENT); - } - } -} - - -// TODO: compat-mode+ ensure sizeof(struct input_event) -unsafe extern "C" fn vuinput_write( - _req: fuse_lowlevel::fuse_req_t, - _buf: *const c_char, - _size: size_t, - _off: off_t, - _fi: *mut fuse_lowlevel::fuse_file_info, -) { - assert!( - _off == 0, - "vuinput_write: offset needs to be 0 but is {}", - _off - ); - - let fh = &(*_fi).fh; - let slice = std::slice::from_raw_parts(_buf as *const u8, _size); - let vuinput_state_mutex = get_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap(); - let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); - - if vuinput_state.input_device.is_none() { - debug!("{}: legacy device setup recognized! Ignore the data and use hardcoded values",fh); - - assert!(_size == std::mem::size_of::()); - let legacy_uinput_user_dev = _buf as *const libc::uinput_user_dev; - - let mut usetup: uinput_setup = unsafe { std::mem::zeroed() }; - usetup.id.bustype = BUS_USB; - // The pid is registered for vuinputd, see https://pid.codes/1209/5020/ - usetup.id.vendor = 0x1209; - usetup.id.product = 0x5020; - usetup.id.version = (*legacy_uinput_user_dev).id.version; - usetup.ff_effects_max=(*legacy_uinput_user_dev).ff_effects_max; - usetup.name=(*legacy_uinput_user_dev).name; - - // Call IOCTLs to setup and create the device - // Assuming your wrappers accept (fd, ptr_to_usetup) etc. - // We'll pass pointer to usetup - let usetup_ptr = &mut usetup as *mut uinput_setup; - let fd = vuinput_state.file.as_raw_fd(); - ui_dev_setup(fd, usetup_ptr).unwrap(); - - // setup abs - for code in 0..libc::ABS_CNT{ - if (*legacy_uinput_user_dev).absmax[code] != 0 || (*legacy_uinput_user_dev).absmin[code] != 0 { - let mut abs_setup: uinput_abs_setup = unsafe { std::mem::zeroed() }; - abs_setup.code=code.try_into().unwrap(); - abs_setup.absinfo.maximum = (*legacy_uinput_user_dev).absmax[code]; - abs_setup.absinfo.minimum = (*legacy_uinput_user_dev).absmin[code]; - abs_setup.absinfo.fuzz = (*legacy_uinput_user_dev).absfuzz[code]; - abs_setup.absinfo.flat = (*legacy_uinput_user_dev).absflat[code]; - - let abs_setup_ptr = &mut abs_setup as *mut uinput_abs_setup; - ui_abs_setup(fd, abs_setup_ptr).unwrap(); - } - } - - fuse_lowlevel::fuse_reply_write(_req, _size); - return; - } - - let mut bytes = 0; - let mut result = Result::Ok(0); - - let compat_size= std::mem::size_of::(); - let normal_size= std::mem::size_of::(); - let is_compat = vuinput_state.requesting_process.is_compat; - // TODO: ARM: && !compat_uses_64bit_time() - - if !is_compat { - while bytes + normal_size <= _size && result.is_ok() { - result = vuinput_state.file.write(&slice[bytes..bytes + normal_size]); - bytes += normal_size; - } - } else { - while bytes + compat_size <= _size && result.is_ok() { - let position= _buf.byte_add(bytes); - let compat = position as *const input_event_compat; - let normal = compat::map_to_64_bit(&*compat); - let normal_ptr=(&normal as *const libc::input_event) as *const u8; - let slice = std::slice::from_raw_parts(normal_ptr,normal_size); - result = vuinput_state.file.write(&slice); - bytes += compat_size; - } - }; - - match result { - Ok(_) => { - trace!("wrote {} of {} bytes (compat {})", bytes,_size,is_compat); - fuse_lowlevel::fuse_reply_write(_req, bytes); - } - Err(e) => { - let mut last_error = DEDUP_LAST_ERROR.get().unwrap().lock().unwrap(); - - match *last_error { - Some((last_fh,VuError::WriteError)) if *fh == last_fh => {}, - _ => {debug!("fh {}: error writing to uinput: {e:?}",fh);} - } - - *last_error = Some((*fh,VuError::WriteError)); - - fuse_lowlevel::fuse_reply_err(_req, EIO); - } - } -} - -unsafe extern "C" fn vuinput_release( - _req: fuse_lowlevel::fuse_req_t, - _fi: *mut fuse_lowlevel::fuse_file_info, -) { - let fh = &(*_fi).fh; - let vuinput_state_mutex = remove_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap(); - - let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); - let input_device = vuinput_state.input_device.take(); - - // Remove device in container, if the request was really from another namespace - // Only do this in case it has not already been done by the ioctl UI_DEV_DESTROY - // this here is relevant if the process was killed and didn't have the chance to send the - // ioctl UI_DEV_DESTROY. - if input_device.is_some() && ! VUINPUTD_NAMESPACES.get().unwrap().equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) { - let input_device = input_device.unwrap(); - let remove_job=RemoveFromContainerJob::new(vuinput_state.requesting_process.clone(),input_device.devnode.clone(),input_device.syspath.clone(),input_device.major,input_device.minor); - let awaiter = remove_job.get_awaiter_for_state(); - JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(remove_job)); - awaiter(&jobs::remove_from_container_job::State::Finished); - } - - drop(vuinput_state); - - debug!( - "fh {}: references left before releasing device {} (expected is 1)", - fh, - Arc::strong_count(&vuinput_state_mutex) - ); - drop(vuinput_state_mutex); // this also closes the file when no other references are open - // TODO: maybe also ensure that nothing is left in the containers - - // Note: For CUSE, the kernel always issues RELEASE via fuse_sync_release(), - // which forces a *synchronous* request (fuse_simple_request()). - // - // That means the kernel thread blocks until userspace sends a reply header. - // Calling fuse_reply_none() would send no header at all, causing the kernel - // to wait forever and the caller to deadlock. - // - // Therefore we must always send a real reply for RELEASE. - // `fuse_reply_err(req, 0)` is enough to wake the kernel and is safe here. - fuse_lowlevel::fuse_reply_err(_req, 0); -} - -unsafe extern "C" fn vuinput_ioctl( - _req: fuse_lowlevel::fuse_req_t, - _cmd: c_int, - _arg: *mut c_void, //note: this is a pointer in the application space and should not be dereferenced at all - _fi: *mut fuse_lowlevel::fuse_file_info, - _flags: c_uint, - _in_buf: *const c_void, // note: this was mapped by the kernel and can be read from - _in_bufsz: size_t, - _out_bufsz: size_t, -) { - // fuse_reply_ioctl_retry is only necessary for variable length commands; - // see comment "Now check variable-length commands" in uinput.c of the linux kernel. - // Those are UI_GET_SYSNAME and UI_ABS_SETUP as of v0.4. - - // ioctl to map are listed on https://www.freedesktop.org/software/libevdev/doc/latest/ioctls.html - // https://docs.rs/linux-raw-sys/0.11.0/src/linux_raw_sys/x86_64/ioctl.rs.html#529 - - let cmd_u64 = (_cmd as c_uint).into(); - // normalize the variable length ones - let cmd_without_size = cmd_u64 & !(nix::sys::ioctl::SIZEMASK << nix::sys::ioctl::SIZESHIFT); - let cmd_normalized = match cmd_without_size { - UI_GET_SYSNAME_WITHOUT_SIZE => UI_GET_SYSNAME_WITHOUT_SIZE, - //UI_ABS_SETUP => UI_ABS_SETUP_WITHOUT_SIZE, - _ => cmd_u64, - }; - let vufh= VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap()); - let vuinput_state_mutex = get_vuinput_state(&vufh).unwrap(); - let fh = &(*_fi).fh; - let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); - - // ensure for all ioctls that need mapped data, that we have the data correctly mapped - match (_in_bufsz, _out_bufsz, cmd_normalized) { - (0, _, UI_ABS_SETUP) => { - //todo: i guess this needs to be reworked as this is variable size. i guess it is not reachable at all - debug!("fh {}: submitting _in_bufsz for UI_ABS_SETUP", fh); - let iov = iovec { - iov_base: _arg, - iov_len: ::std::mem::size_of::(), - }; - fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0); - return; - } - (_, 0, UI_GET_SYSNAME_WITHOUT_SIZE) => { - let size = (cmd_u64 & nix::sys::ioctl::SIZEMASK) >> nix::sys::ioctl::SIZESHIFT; - debug!( - "fh {}: submitting _out_bufsz for UI_GET_SYSNAME({}) ", - fh, size - ); - let iov = iovec { - iov_base: _arg, - iov_len: 64, - }; - fuse_lowlevel::fuse_reply_ioctl_retry(_req, std::ptr::null(), 0, &iov, 1); - return; - } - (_, 0, UI_GET_VERSION) => { - let size = (cmd_u64 & nix::sys::ioctl::SIZEMASK) >> nix::sys::ioctl::SIZESHIFT; - debug!( - "fh {}: submitting _out_bufsz for UI_GET_VERSION({}) ", - fh, size - ); - let iov = iovec { - iov_base: _arg, - iov_len: std::mem::size_of::(), - }; - fuse_lowlevel::fuse_reply_ioctl_retry(_req, std::ptr::null(), 0, &iov, 1); - return; - } - (0, _, UI_DEV_SETUP) => { - debug!("fh {}: submitting _in_bufsz for UI_DEV_SETUP", fh); - let iov = iovec { - iov_base: _arg, - iov_len: ::std::mem::size_of::(), - }; - fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0); - return; - } - (0, _, UI_SET_PHYS) => { - debug!("fh {}: submitting _in_bufsz for UI_SET_PHYS", fh); - let iov = iovec { - iov_base: _arg, - iov_len: ::std::mem::size_of::() * 1024, - }; - fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0); - return; - } - (0, _, UI_BEGIN_FF_UPLOAD) => { - debug!("fh {}: submitting _in_bufsz for UI_BEGIN_FF_UPLOAD", fh); - let iov = iovec { - iov_base: _arg, - iov_len: ::std::mem::size_of::(), - }; - fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, &iov, 1); - return; - } - (0, _, UI_END_FF_UPLOAD) => { - debug!("fh {}: submitting _in_bufsz for UI_END_FF_UPLOAD", fh); - let iov = iovec { - iov_base: _arg, - iov_len: ::std::mem::size_of::(), - }; - fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0); - return; - } - (0, _, UI_BEGIN_FF_ERASE) => { - debug!("fh {}: submitting _in_bufsz for UI_BEGIN_FF_ERASE", fh); - let iov = iovec { - iov_base: _arg, - iov_len: ::std::mem::size_of::(), - }; - fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, &iov, 1); - return; - } - (0, _, UI_END_FF_ERASE) => { - debug!("fh {}: submitting _in_bufsz for UI_END_FF_ERASE", fh); - let iov = iovec { - iov_base: _arg, - iov_len: ::std::mem::size_of::(), - }; - fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0); - return; - } - _ => { - //nothing to map - } - } - - let fd = vuinput_state.file.as_raw_fd(); - - // now we can assume that the data is mapped or it is not required - match cmd_normalized { - UI_DEV_CREATE => { - debug!("fh {}: ioctl UI_DEV_CREATE", fh); - ui_dev_create(fd).unwrap(); - - let mut resultbuf: [c_char; 64] = [0; 64]; - ui_get_sysname(fd, resultbuf.as_mut_slice()).unwrap(); - let sysname = format!( - "{}{}", - SYS_INPUT_DIR, - CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() - ); - debug!("fh {}: syspath: {}", fh, sysname); - let devnode = fetch_device_node(&sysname).unwrap(); - debug!("fh {}: devnode: {}", fh, devnode); - let (major,minor) = fetch_major_minor(&devnode).unwrap(); - debug!("fh {}: major: {} minor: {} ", fh, major,minor); - vuinput_state.input_device = Some(VuInputDevice {cuse_fh:*fh, major: major, minor: minor, syspath: sysname.clone(), devnode: devnode.clone(), runtime_data: None, netlink_data: None }); - - // Create device in container, if the request was really from another namespace - if ! VUINPUTD_NAMESPACES.get().unwrap().equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) { - let inject_job=InjectInContainerJob::new(vuinput_state.requesting_process.clone(),devnode.clone(),sysname.clone(),major,minor); - let awaiter = inject_job.get_awaiter_for_state(); - JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(inject_job)); - awaiter(&jobs::inject_in_container_job::State::Finished); - debug!("fh {}: injecting dev-nodes in container has been finished ", fh); - } - - // write a SYN-event (which is just zeros) just for validation - let syn_event : [u8; 24] = [0; 24]; - vuinput_state.file.write_all(&syn_event).unwrap(); - - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_DEV_DESTROY => { - debug!("fh {}: ioctl UI_DEV_DESTROY", fh); - let input_device = vuinput_state.input_device.take(); - - // Remove device in container, if the request was really from another namespace - if input_device.is_some() && ! VUINPUTD_NAMESPACES.get().unwrap().equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) { - let input_device = input_device.unwrap(); - let remove_job=RemoveFromContainerJob::new(vuinput_state.requesting_process.clone(),input_device.devnode.clone(),input_device.syspath.clone(),input_device.major,input_device.minor); - let awaiter = remove_job.get_awaiter_for_state(); - JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(remove_job)); - awaiter(&jobs::remove_from_container_job::State::Finished); - debug!("fh {}: removing dev-nodes from container has been finished ", fh); - } - - ui_dev_destroy(fd).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_DEV_SETUP => { - debug!("fh {}: ioctl UI_DEV_SETUP", fh); - assert!(_in_bufsz != 0, "should have _in_bufsz"); - let setup_ptr = _in_buf as *mut uinput_setup; - debug!( - "product: {:x} vendor: {:x}", - (*setup_ptr).id.product, - (*setup_ptr).id.vendor - ); - // replace vendor and product id to the values from sunshine (see inputtino_common.h of sunshine) - // The pid is registered for vuinputd, see https://pid.codes/1209/5020/ - (*setup_ptr).id.product = 0x5020; - (*setup_ptr).id.vendor = 0x1209; - ui_dev_setup(fd, setup_ptr).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_ABS_SETUP => { - //todo: i guess this needs to be reworked as this is variable size. i guess it is not reachable at all - debug!("fh {}: ioctl UI_ABS_SETUP", fh); - assert!(_in_bufsz != 0, "should have _in_bufsz"); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_GET_SYSNAME_WITHOUT_SIZE => { - debug!("fh {}: ioctl UI_GET_SYSNAME {_out_bufsz}", fh); - assert!( - _out_bufsz == 64, - "should have _out_bufsz of length 64 (currently hardcoded)" - ); - let mut resultbuf: [c_char; 64] = [0; 64]; - ui_get_sysname(fd, resultbuf.as_mut_slice()).unwrap(); - let sysname = CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy(); - debug!("fh {}: sysname: {}", fh, sysname); - fuse_lowlevel::fuse_reply_ioctl( - _req, - 0, - resultbuf.as_mut_ptr() as *mut c_void, - _out_bufsz, - ); - } - UI_GET_VERSION => { - let mut version_of_kernel = 0; - let pversion_of_kernel = std::ptr::from_mut(&mut version_of_kernel); - ui_get_version(fd, pversion_of_kernel).unwrap(); - debug!("fh {}: ioctl UI_GET_VERSION {}", fh, version_of_kernel); - let reply_arg = 5; - let preply_arg = std::ptr::from_ref(&reply_arg); - fuse_lowlevel::fuse_reply_ioctl( - _req, - 0, - preply_arg as *const c_void, - std::mem::size_of::(), - ); - } - UI_SET_EVBIT => { - let value = _arg as c_uint; - debug!("fh {}: ioctl UI_SET_EVBIT {}", fh, value); - ui_set_evbit(fd, value.into()).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_SET_KEYBIT => { - let value = _arg as c_uint; - debug!("fh {}: ioctl UI_SET_KEYBIT {}", fh, value); - ui_set_keybit(fd, value.into()).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_SET_RELBIT => { - let value = _arg as c_uint; - debug!("fh {}: ioctl UI_SET_RELBIT {}", fh, value); - ui_set_relbit(fd, value.into()).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_SET_ABSBIT => { - let value = _arg as c_uint; - debug!("fh {}: ioctl UI_SET_ABSBIT {}", fh, value); - ui_set_absbit(fd, value.into()).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_SET_MSCBIT => { - let value = _arg as c_uint; - debug!("fh {}: ioctl UI_SET_MSCBIT {}", fh, value); - ui_set_mscbit(fd, value.into()).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_SET_LEDBIT => { - let value = _arg as c_uint; - debug!("fh {}: ioctl UI_SET_LEDBIT {}", fh, value); - ui_set_ledbit(fd, value.into()).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_SET_SNDBIT => { - let value = _arg as c_uint; - debug!("fh {}: ioctl UI_SET_SNDBIT {}", fh, value); - ui_set_sndbit(fd, value.into()).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_SET_FFBIT => { - let value = _arg as c_uint; - debug!("fh {}: ioctl UI_SET_FFBIT {}", fh, value); - ui_set_ffbit(fd, value.into()).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_SET_PHYS => { - assert!(_in_bufsz != 0, "should have _in_bufsz"); - debug!("fh {}: ioctl UI_SET_PHYS", fh); - // inbuf is actually a *const c_char, but - // but the macro to generate ui_set_phys expects a ptr to the actual data structure. - let phys = _in_buf as *const *const c_char; - ui_set_phys(fd, phys).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_SET_SWBIT => { - let value = _arg as c_uint; - debug!("fh {}: ioctl UI_SET_SWBIT {}", fh, value); - ui_set_swbit(fd, value.into()).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_SET_PROPBIT => { - let value = _arg as c_uint; - debug!("fh {}: ioctl UI_SET_PROPBIT {}", fh, value); - ui_set_propbit(fd, value.into()).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_BEGIN_FF_UPLOAD => { - assert!(_in_bufsz != 0, "should have _in_bufsz"); - debug!("fh {}: ioctl UI_BEGIN_FF_UPLOAD", fh); - let ff_upload_ptr = _in_buf as *mut uinput_ff_upload; - debug!("request_id: {:x}", (*ff_upload_ptr).request_id); - ui_begin_ff_upload(fd, ff_upload_ptr).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, ff_upload_ptr as *mut c_void, _out_bufsz); - } - UI_END_FF_UPLOAD => { - assert!(_in_bufsz != 0, "should have _in_bufsz"); - debug!("fh {}: ioctl UI_END_FF_UPLOAD", fh); - let ff_upload_ptr = _in_buf as *const uinput_ff_upload; - debug!("request_id: {:x}", (*ff_upload_ptr).request_id); - ui_end_ff_upload(fd, ff_upload_ptr).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - UI_BEGIN_FF_ERASE => { - assert!(_in_bufsz != 0, "should have _in_bufsz"); - debug!("fh {}: ioctl UI_BEGIN_FF_ERASE", fh); - let ff_erase_ptr = _in_buf as *mut uinput_ff_erase; - debug!("request_id: {:x}", (*ff_erase_ptr).request_id); - ui_begin_ff_erase(fd, ff_erase_ptr).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, ff_erase_ptr as *mut c_void, _out_bufsz); - } - UI_END_FF_ERASE => { - assert!(_in_bufsz != 0, "should have _in_bufsz"); - debug!("fh {}: ioctl UI_END_FF_ERASE", fh); - let ff_erase_ptr = _in_buf as *const uinput_ff_erase; - debug!("request_id: {:x}", (*ff_erase_ptr).request_id); - ui_end_ff_erase(fd, ff_erase_ptr).unwrap(); - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); - } - _ => { - debug!("fh {}: ioctl cmd {}", fh, _cmd); - fuse_lowlevel::fuse_reply_err(_req, EBADRQC); - } - } -} - -// Instance of cuse_lowlevel_ops with all stubs assigned. -// Setting to None leads to e.g. "write error: Function not implemented". -// You can find the implementations of the uinput default (open, release ,read, write, poll, -// and ioctl) in uinput_fops of uinput.c. -// See: https://github.com/torvalds/linux/blob/master/drivers/input/misc/uinput.c, -pub fn vuinput_make_cuse_ops() -> cuse_lowlevel::cuse_lowlevel_ops { - cuse_lowlevel::cuse_lowlevel_ops { - init: None, - init_done: None, - destroy: None, - open: Some(vuinput_open), - read: None, - write: Some(vuinput_write), - flush: None, - release: Some(vuinput_release), - fsync: None, - ioctl: Some(vuinput_ioctl), - poll: None, - } -} - -fn check_permissions() -> Result<(), std::io::Error> { - let path = Path::new("/proc/self/status"); - debug!("Capabilities of vuinputd process:"); - fs::read_to_string(path). - and_then(|status_file| { - status_file.lines() - .filter(|line| line.starts_with("Cap")) - .for_each(move |x| debug!("{}",x)); - Ok(()) - }) -} - fn main() -> std::io::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init(); @@ -762,7 +64,7 @@ fn main() -> std::io::Result<()> { VUINPUT_STATE.set(RwLock::new(HashMap::new())).expect("failed to initialize global state"); VUINPUT_COUNTER.set(AtomicU64::new(3)).expect("failed to initialize the counter that provides the values of the CUSE file handles"); // 3, because 1 and 2 are usually STDOUT and STDERR JOB_DISPATCHER.set(Mutex::new(Dispatcher::new())).expect("failed to initialize the job dispatcher"); - VUINPUTD_NAMESPACES.set(get_namespace(Pid::SelfPid)).expect("failed to retrieve the namespaces of the vuinputd process"); + SELF_NAMESPACES.set(get_namespace(Pid::SelfPid)).expect("failed to retrieve the namespaces of the vuinputd process"); DEDUP_LAST_ERROR.set(Mutex::new(None)).expect("failed to initialize the log deduplication state"); JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(MonitorBackgroundLoop::new())); diff --git a/vuinputd/src/process_tools/mod.rs b/vuinputd/src/process_tools/mod.rs index 06b6e51..2d7b686 100644 --- a/vuinputd/src/process_tools/mod.rs +++ b/vuinputd/src/process_tools/mod.rs @@ -9,12 +9,15 @@ use nix::{ unistd::{fork, ForkResult}, }; use std::{ - fs::{self, File}, io::Read, os::fd::{AsFd, FromRawFd, OwnedFd, RawFd}, path::{self, Path}, process, thread, time::Duration + fs::{self, File}, io::Read, os::fd::{AsFd, FromRawFd, OwnedFd, RawFd}, path::{self, Path}, process, sync::OnceLock, thread, time::Duration }; use std::io::{self, BufRead}; use std::path::PathBuf; +pub static SELF_NAMESPACES: OnceLock= OnceLock::new(); + + #[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] pub enum Pid { @@ -311,3 +314,16 @@ pub async fn await_process(pid: Pid) -> io::Result { }, } } + + +pub fn check_permissions() -> Result<(), std::io::Error> { + let path = Path::new("/proc/self/status"); + debug!("Capabilities of vuinputd process:"); + fs::read_to_string(path). + and_then(|status_file| { + status_file.lines() + .filter(|line| line.starts_with("Cap")) + .for_each(move |x| debug!("{}",x)); + Ok(()) + }) +} \ No newline at end of file From 2fe9a70006e7d38ba919434bcff2c7cc5ffc664f Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 9 Dec 2025 09:20:51 +0000 Subject: [PATCH 008/108] Remove unused imports --- vuinputd/src/cuse_device/vuinput_ioctl.rs | 15 +++--------- vuinputd/src/cuse_device/vuinput_open.rs | 20 ++++------------ vuinputd/src/cuse_device/vuinput_release.rs | 19 ++------------- vuinputd/src/cuse_device/vuinput_write.rs | 20 ++++------------ vuinputd/src/jobs/inject_in_container_job.rs | 2 +- vuinputd/src/jobs/mknod_input_device.rs | 3 +-- .../src/jobs/remove_from_container_job.rs | 5 ++-- vuinputd/src/jobs/runtime_data.rs | 1 - vuinputd/src/main.rs | 24 ++++--------------- vuinputd/src/process_tools/mod.rs | 5 ++-- 10 files changed, 26 insertions(+), 88 deletions(-) diff --git a/vuinputd/src/cuse_device/vuinput_ioctl.rs b/vuinputd/src/cuse_device/vuinput_ioctl.rs index 9d747aa..d157471 100644 --- a/vuinputd/src/cuse_device/vuinput_ioctl.rs +++ b/vuinputd/src/cuse_device/vuinput_ioctl.rs @@ -2,23 +2,14 @@ // // Author: Johannes Leupolz -use libc::{O_CLOEXEC, input_id}; -use libc::{iovec, off_t, size_t, EBADRQC, EIO, ENOENT}; +use libc::{iovec, size_t, EBADRQC}; use libc::{uinput_abs_setup, uinput_ff_erase, uinput_ff_upload, uinput_setup}; use ::cuse_lowlevel::*; -use log::{debug, error, info, trace}; -use std::collections::HashMap; -use std::ffi::{CStr, CString}; -use std::{fs, ptr}; -use std::fs::{File, OpenOptions}; +use log::{debug}; +use std::ffi::{CStr}; use std::io::Write; -use std::io::{self, ErrorKind}; use std::os::fd::AsRawFd; use std::os::raw::{c_char, c_int, c_uint, c_void}; -use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; -use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; use uinput_ioctls::*; use crate::cuse_device::{SYS_INPUT_DIR, VuFileHandle, VuInputDevice, fetch_device_node, get_vuinput_state}; diff --git a/vuinputd/src/cuse_device/vuinput_open.rs b/vuinputd/src/cuse_device/vuinput_open.rs index 67b0e4b..9acfffd 100644 --- a/vuinputd/src/cuse_device/vuinput_open.rs +++ b/vuinputd/src/cuse_device/vuinput_open.rs @@ -3,24 +3,14 @@ // Author: Johannes Leupolz use std::sync::OnceLock; -use libc::{O_CLOEXEC, input_id}; -use libc::{iovec, off_t, size_t, EBADRQC, EIO, ENOENT}; -use libc::{uinput_abs_setup, uinput_ff_erase, uinput_ff_upload, uinput_setup}; +use libc::{O_CLOEXEC}; +use libc::{ENOENT}; use ::cuse_lowlevel::*; -use log::{debug, error, info, trace}; -use std::collections::HashMap; -use std::ffi::{CStr, CString}; -use std::{fs, ptr}; -use std::fs::{File, OpenOptions}; -use std::io::Write; -use std::io::{self, ErrorKind}; -use std::os::fd::AsRawFd; -use std::os::raw::{c_char, c_int, c_uint, c_void}; -use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; +use log::{debug, error}; +use std::fs::{OpenOptions}; +use std::os::unix::fs::{OpenOptionsExt}; use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, RwLock}; -use uinput_ioctls::*; use crate::process_tools::{Pid, get_requesting_process}; use crate::cuse_device::*; diff --git a/vuinputd/src/cuse_device/vuinput_release.rs b/vuinputd/src/cuse_device/vuinput_release.rs index ee80a63..9454e5c 100644 --- a/vuinputd/src/cuse_device/vuinput_release.rs +++ b/vuinputd/src/cuse_device/vuinput_release.rs @@ -2,24 +2,9 @@ // // Author: Johannes Leupolz -use libc::{O_CLOEXEC, input_id}; -use libc::{iovec, off_t, size_t, EBADRQC, EIO, ENOENT}; -use libc::{uinput_abs_setup, uinput_ff_erase, uinput_ff_upload, uinput_setup}; use ::cuse_lowlevel::*; -use log::{debug, error, info, trace}; -use std::collections::HashMap; -use std::ffi::{CStr, CString}; -use std::{fs, ptr}; -use std::fs::{File, OpenOptions}; -use std::io::Write; -use std::io::{self, ErrorKind}; -use std::os::fd::AsRawFd; -use std::os::raw::{c_char, c_int, c_uint, c_void}; -use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; -use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; -use uinput_ioctls::*; +use log::{debug}; +use std::sync::{Arc}; use crate::{cuse_device::*, jobs}; use crate::job_engine::JOB_DISPATCHER; use crate::jobs::remove_from_container_job::RemoveFromContainerJob; diff --git a/vuinputd/src/cuse_device/vuinput_write.rs b/vuinputd/src/cuse_device/vuinput_write.rs index b8e57c8..b788dcb 100644 --- a/vuinputd/src/cuse_device/vuinput_write.rs +++ b/vuinputd/src/cuse_device/vuinput_write.rs @@ -2,25 +2,15 @@ // // Author: Johannes Leupolz -use libc::{O_CLOEXEC, input_id}; -use libc::{iovec, off_t, size_t, EBADRQC, EIO, ENOENT}; -use libc::{uinput_abs_setup, uinput_ff_erase, uinput_ff_upload, uinput_setup}; +use libc::{off_t, size_t, EIO}; +use libc::{uinput_abs_setup, uinput_setup}; use ::cuse_lowlevel::*; -use log::{debug, error, info, trace}; -use std::collections::HashMap; -use std::ffi::{CStr, CString}; -use std::{fs, ptr}; -use std::fs::{File, OpenOptions}; +use log::{debug, trace}; use std::io::Write; -use std::io::{self, ErrorKind}; use std::os::fd::AsRawFd; -use std::os::raw::{c_char, c_int, c_uint, c_void}; -use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; -use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::os::raw::{c_char}; use uinput_ioctls::*; -use libc::{__s32, __u16, c_ulong, input_event}; +use libc::{__s32, __u16, input_event}; use crate::cuse_device::*; diff --git a/vuinputd/src/jobs/inject_in_container_job.rs b/vuinputd/src/jobs/inject_in_container_job.rs index eca8fb2..4c0dd93 100644 --- a/vuinputd/src/jobs/inject_in_container_job.rs +++ b/vuinputd/src/jobs/inject_in_container_job.rs @@ -111,7 +111,7 @@ impl InjectInContainerJob { // wait a maximum of 5 seconds == 50 attempts Timer::after(Duration::from_millis(100)).await; } - if (netlink_data.is_none() || runtime_data.is_none()) { + if netlink_data.is_none() || runtime_data.is_none() { if netlink_data.is_none() { debug!("Give up reading netlink data"); } diff --git a/vuinputd/src/jobs/mknod_input_device.rs b/vuinputd/src/jobs/mknod_input_device.rs index 9c39af5..4f2252b 100644 --- a/vuinputd/src/jobs/mknod_input_device.rs +++ b/vuinputd/src/jobs/mknod_input_device.rs @@ -3,10 +3,9 @@ // Author: Johannes Leupolz use nix::sys::stat::{makedev, mknod, stat, Mode, SFlag}; -use nix::unistd::{chown, Gid, Uid}; use std::error::Error; use std::fs; -use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::os::unix::fs::{PermissionsExt}; use std::path::Path; pub fn ensure_input_device(dev_path: String, major: u64, minor: u64) -> Result<(), Box> { diff --git a/vuinputd/src/jobs/remove_from_container_job.rs b/vuinputd/src/jobs/remove_from_container_job.rs index 1de6865..12b066d 100644 --- a/vuinputd/src/jobs/remove_from_container_job.rs +++ b/vuinputd/src/jobs/remove_from_container_job.rs @@ -2,12 +2,11 @@ // // Author: Johannes Leupolz -use std::{collections::HashMap, future::Future, pin::Pin, sync::{Arc, Condvar, Mutex}, time::Duration}; +use std::{future::Future, pin::Pin, sync::{Arc, Condvar, Mutex}}; -use async_io::Timer; use log::debug; -use crate::{job_engine::job::{Job, JobTarget}, jobs::{mknod_input_device::remove_input_device, monitor_udev_job::EVENT_STORE, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{delete_udev_data, ensure_udev_structure, read_udev_data, write_udev_data}}, process_tools::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace}}; +use crate::{job_engine::job::{Job, JobTarget}, jobs::{mknod_input_device::remove_input_device, monitor_udev_job::EVENT_STORE, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{delete_udev_data}}, process_tools::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace}}; diff --git a/vuinputd/src/jobs/runtime_data.rs b/vuinputd/src/jobs/runtime_data.rs index 94327d4..f74b05a 100644 --- a/vuinputd/src/jobs/runtime_data.rs +++ b/vuinputd/src/jobs/runtime_data.rs @@ -78,7 +78,6 @@ pub fn read_udev_data(major: u64, minor: u64) -> io::Result { #[cfg(test)] mod tests { - use super::*; #[test] fn test_replacement_and_filter() { diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 500fcfe..52f2410 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -17,33 +17,19 @@ // naming: dev_path vs dev_node. I guess I mean the same. // Send warning, if udev monitor does not exist - -use libc::{O_CLOEXEC, input_id}; -use libc::{iovec, off_t, size_t, EBADRQC, EIO, ENOENT}; -use libc::{uinput_abs_setup, uinput_ff_erase, uinput_ff_upload, uinput_setup}; use ::cuse_lowlevel::*; -use log::{debug, error, info, trace}; +use log::info; use std::collections::HashMap; -use std::ffi::{CStr, CString}; -use std::{fs, ptr}; -use std::fs::{File, OpenOptions}; -use std::io::Write; -use std::io::{self, ErrorKind}; -use std::os::fd::AsRawFd; -use std::os::raw::{c_char, c_int, c_uint, c_void}; -use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; -use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; -use uinput_ioctls::*; +use std::ffi::CString; +use std::os::raw::{c_char}; +use std::sync::atomic::{AtomicU64}; +use std::sync::{Mutex, RwLock}; pub mod cuse_device; use crate::cuse_device::vuinput_open::VUINPUT_COUNTER; use crate::cuse_device::{DEDUP_LAST_ERROR, VUINPUT_STATE, vuinput_make_cuse_ops}; -use crate::jobs::inject_in_container_job::InjectInContainerJob; use crate::jobs::monitor_udev_job::MonitorBackgroundLoop; -use crate::jobs::remove_from_container_job::RemoveFromContainerJob; pub mod process_tools; diff --git a/vuinputd/src/process_tools/mod.rs b/vuinputd/src/process_tools/mod.rs index 2d7b686..24952c0 100644 --- a/vuinputd/src/process_tools/mod.rs +++ b/vuinputd/src/process_tools/mod.rs @@ -9,11 +9,10 @@ use nix::{ unistd::{fork, ForkResult}, }; use std::{ - fs::{self, File}, io::Read, os::fd::{AsFd, FromRawFd, OwnedFd, RawFd}, path::{self, Path}, process, sync::OnceLock, thread, time::Duration + fs::{self, File}, io::Read, os::fd::{AsFd, FromRawFd, OwnedFd, RawFd}, path::{Path}, process, sync::OnceLock }; -use std::io::{self, BufRead}; -use std::path::PathBuf; +use std::io; pub static SELF_NAMESPACES: OnceLock= OnceLock::new(); From b3d0a0225d89855bd5f990badc307342aa8e4bfa Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 10 Dec 2025 22:46:17 +0000 Subject: [PATCH 009/108] Small refactorings and note in SECURITY.md --- docs/SECURITY.md | 13 +- vuinputd/src/cuse_device/mod.rs | 138 +--------------------- vuinputd/src/cuse_device/state.rs | 112 ++++++++++++++++++ vuinputd/src/cuse_device/vuinput_ioctl.rs | 43 ++++++- vuinputd/src/cuse_device/vuinput_write.rs | 1 - vuinputd/src/jobs/mknod_input_device.rs | 2 +- vuinputd/src/main.rs | 12 +- 7 files changed, 174 insertions(+), 147 deletions(-) create mode 100644 vuinputd/src/cuse_device/state.rs diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fbe94ac..64de57a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -8,4 +8,15 @@ ## Unsafe code -Known problem: A lot of unsafe code. Open are ideas to mitigate this issue. \ No newline at end of file +Known problem: A lot of unsafe code. Open are ideas to mitigate this issue. + +## seccomp, AppArmor, SELinux, cgroups mounts, /sys read-write + +This is a big TODO. Which permissions can be reduced. Now we assume we are quite privileagued: +- We have all Linux kernel capabilities, +- The default seccomp profile is disabled, +- The default AppArmor profile is disabled, +- The default SELinux process label is disabled, +- all host devices are accessible, +- /sys is read-write, +- cgroups mount is read-write. \ No newline at end of file diff --git a/vuinputd/src/cuse_device/mod.rs b/vuinputd/src/cuse_device/mod.rs index 5c928a7..b4e4d8e 100644 --- a/vuinputd/src/cuse_device/mod.rs +++ b/vuinputd/src/cuse_device/mod.rs @@ -2,153 +2,21 @@ // // Author: Johannes Leupolz +pub mod state; pub mod vuinput_ioctl; pub mod vuinput_write; pub mod vuinput_release; pub mod vuinput_open; -use std::collections::HashMap; -use std::fs::{self, File}; -use std::io; +use std::{fs, io}; use std::os::unix::fs::{FileTypeExt, MetadataExt}; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::io::{ErrorKind}; use ::cuse_lowlevel::*; +use state::*; -use crate::process_tools::RequestingProcess; - -#[derive(Debug)] -struct VuInputDevice { - cuse_fh : u64, - major : u64, - minor : u64, - syspath: String, - devnode: String, - runtime_data: Option, - netlink_data: Option -} - -#[derive(Debug)] -pub struct VuInputState { - file: File, - requesting_process: RequestingProcess, - input_device: Option -} - -#[derive(Debug,Eq, Hash, PartialEq, Clone)] -pub enum VuFileHandle { - Fh(u64) -} - -impl VuFileHandle { - fn from_fuse_file_info(fi: &fuse_lowlevel::fuse_file_info) -> VuFileHandle { - VuFileHandle::Fh(fi.fh) - } -} - -impl std::fmt::Display for VuFileHandle { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - VuFileHandle::Fh(fh) => writeln!(f, "VuFileHandle({:?})",fh)?, - } - Ok(()) - } -} - -#[derive(Debug)] -pub enum VuError { - WriteError -} - -pub static VUINPUT_STATE: OnceLock>>>> = OnceLock::new(); - -// For log limiting. Idea: Move to log_limit crate -pub static DEDUP_LAST_ERROR: OnceLock>> = OnceLock::new(); - - -pub const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; pub const BUS_USB: u16 = 0x03; -pub fn get_vuinput_state( - fh:&VuFileHandle, -) -> Result>, String> { - let map = VUINPUT_STATE - .get() - .ok_or("global not initialized".to_string())?; - let guard = map.read().map_err(|e| e.to_string())?; - guard - .get(&fh) - .cloned() - .ok_or("handle not opened".to_string()) -} - - -pub fn insert_vuinput_state( - fh:&VuFileHandle, - state: VuInputState, -) -> Result<(), String> { - let map = VUINPUT_STATE - .get() - .ok_or("global not initialized".to_string())?; - let mut guard = map.write().map_err(|e| e.to_string())?; - - if guard.contains_key(&fh) { - return Err(format!( - "file handle {} already exists. file handles must not be reused!", - &fh - )); - } - - let _ = guard.insert(fh.clone(), Arc::new(Mutex::new(state))); - Ok(()) -} - -pub fn remove_vuinput_state( - fh:&VuFileHandle, -) -> Result>, String> { - let map = VUINPUT_STATE - .get() - .ok_or("global not initialized".to_string())?; - let mut guard = map.write().map_err(|e| e.to_string())?; - let old_value = guard.remove(&fh).ok_or("fh unknown")?; - Ok(old_value) -} - -pub fn fetch_device_node(path: &str) -> io::Result { - for entry in fs::read_dir(path)? { - let entry = entry?; // propagate per-entry errors - if let Some(name) = entry.file_name().to_str() { - if name.starts_with("event") { - return Ok(format!("/dev/input/{}", name)); - } - } - } - // If no device is found, return an error - Err(io::Error::new(ErrorKind::NotFound, "no device found")) -} - -/// Returns (major, minor) numbers of a device node at `path` -pub fn fetch_major_minor(path: &str) -> io::Result<(u64, u64)> { - let metadata = fs::metadata(path)?; - - // Ensure it's a character device - if !metadata.file_type().is_char_device() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Not a character device", - )); - } - - let rdev = metadata.rdev(); - let major = ((rdev >> 8) & 0xfff) as u64; - let minor = ((rdev & 0xff) | ((rdev >> 12) & 0xfff00)) as u64; - - Ok((major, minor)) -} - - - // Instance of cuse_lowlevel_ops with all stubs assigned. // Setting to None leads to e.g. "write error: Function not implemented". diff --git a/vuinputd/src/cuse_device/state.rs b/vuinputd/src/cuse_device/state.rs new file mode 100644 index 0000000..7e8b503 --- /dev/null +++ b/vuinputd/src/cuse_device/state.rs @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::collections::HashMap; +use std::fs::{File}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; + +use ::cuse_lowlevel::*; + +use crate::process_tools::RequestingProcess; + +#[derive(Debug)] +pub struct VuInputDevice { + pub major : u64, + pub minor : u64, + pub syspath: String, + pub devnode: String, +} + +#[derive(Debug)] +pub struct VuInputState { + pub file: File, + pub requesting_process: RequestingProcess, + pub input_device: Option +} + +#[derive(Debug,Eq, Hash, PartialEq, Clone)] +pub enum VuFileHandle { + Fh(u64) +} + +impl VuFileHandle { + pub fn from_fuse_file_info(fi: &fuse_lowlevel::fuse_file_info) -> VuFileHandle { + VuFileHandle::Fh(fi.fh) + } +} + +impl std::fmt::Display for VuFileHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VuFileHandle::Fh(fh) => writeln!(f, "VuFileHandle({:?})",fh)?, + } + Ok(()) + } +} + +pub fn get_vuinput_state( + fh:&VuFileHandle, +) -> Result>, String> { + let map = VUINPUT_STATE + .get() + .ok_or("global not initialized".to_string())?; + let guard = map.read().map_err(|e| e.to_string())?; + guard + .get(&fh) + .cloned() + .ok_or("handle not opened".to_string()) +} + + +pub fn insert_vuinput_state( + fh:&VuFileHandle, + state: VuInputState, +) -> Result<(), String> { + let map = VUINPUT_STATE + .get() + .ok_or("global not initialized".to_string())?; + let mut guard = map.write().map_err(|e| e.to_string())?; + + if guard.contains_key(&fh) { + return Err(format!( + "file handle {} already exists. file handles must not be reused!", + &fh + )); + } + + let _ = guard.insert(fh.clone(), Arc::new(Mutex::new(state))); + Ok(()) +} + +pub fn remove_vuinput_state( + fh:&VuFileHandle, +) -> Result>, String> { + let map = VUINPUT_STATE + .get() + .ok_or("global not initialized".to_string())?; + let mut guard = map.write().map_err(|e| e.to_string())?; + let old_value = guard.remove(&fh).ok_or("fh unknown")?; + Ok(old_value) +} + +pub fn initialize_vuinput_state() { + VUINPUT_STATE.set(RwLock::new(HashMap::new())).expect("failed to initialize global state"); +} + +pub fn initialize_dedup_last_error() { + DEDUP_LAST_ERROR.set(Mutex::new(None)).expect("failed to initialize the log deduplication state"); +} + + +#[derive(Debug)] +pub enum VuError { + WriteError +} + + +pub static VUINPUT_STATE: OnceLock>>>> = OnceLock::new(); + +// For log limiting. Idea: Move to log_limit crate +pub static DEDUP_LAST_ERROR: OnceLock>> = OnceLock::new(); + diff --git a/vuinputd/src/cuse_device/vuinput_ioctl.rs b/vuinputd/src/cuse_device/vuinput_ioctl.rs index d157471..9483c70 100644 --- a/vuinputd/src/cuse_device/vuinput_ioctl.rs +++ b/vuinputd/src/cuse_device/vuinput_ioctl.rs @@ -12,13 +12,15 @@ use std::os::fd::AsRawFd; use std::os::raw::{c_char, c_int, c_uint, c_void}; use uinput_ioctls::*; -use crate::cuse_device::{SYS_INPUT_DIR, VuFileHandle, VuInputDevice, fetch_device_node, get_vuinput_state}; +use crate::cuse_device::{VuFileHandle, get_vuinput_state}; use crate::job_engine::JOB_DISPATCHER; use crate::jobs::inject_in_container_job::InjectInContainerJob; use crate::jobs::remove_from_container_job::RemoveFromContainerJob; use crate::process_tools::SELF_NAMESPACES; use crate::{cuse_device::*, jobs}; +pub const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; + pub unsafe extern "C" fn vuinput_ioctl( _req: fuse_lowlevel::fuse_req_t, _cmd: c_int, @@ -166,7 +168,7 @@ pub unsafe extern "C" fn vuinput_ioctl( debug!("fh {}: devnode: {}", fh, devnode); let (major,minor) = fetch_major_minor(&devnode).unwrap(); debug!("fh {}: major: {} minor: {} ", fh, major,minor); - vuinput_state.input_device = Some(VuInputDevice {cuse_fh:*fh, major: major, minor: minor, syspath: sysname.clone(), devnode: devnode.clone(), runtime_data: None, netlink_data: None }); + vuinput_state.input_device = Some(VuInputDevice {major: major, minor: minor, syspath: sysname.clone(), devnode: devnode.clone() }); // Create device in container, if the request was really from another namespace if ! SELF_NAMESPACES.get().unwrap().equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) { @@ -211,6 +213,7 @@ pub unsafe extern "C" fn vuinput_ioctl( ); // replace vendor and product id to the values from sunshine (see inputtino_common.h of sunshine) // The pid is registered for vuinputd, see https://pid.codes/1209/5020/ + (*setup_ptr).id.bustype = BUS_USB; (*setup_ptr).id.product = 0x5020; (*setup_ptr).id.vendor = 0x1209; ui_dev_setup(fd, setup_ptr).unwrap(); @@ -359,4 +362,38 @@ pub unsafe extern "C" fn vuinput_ioctl( fuse_lowlevel::fuse_reply_err(_req, EBADRQC); } } -} \ No newline at end of file +} + + +pub fn fetch_device_node(path: &str) -> io::Result { + for entry in fs::read_dir(path)? { + let entry = entry?; // propagate per-entry errors + if let Some(name) = entry.file_name().to_str() { + if name.starts_with("event") { + return Ok(format!("/dev/input/{}", name)); + } + } + } + // If no device is found, return an error + Err(io::Error::new(ErrorKind::NotFound, "no device found")) +} + +/// Returns (major, minor) numbers of a device node at `path` +pub fn fetch_major_minor(path: &str) -> io::Result<(u64, u64)> { + let metadata = fs::metadata(path)?; + + // Ensure it's a character device + if !metadata.file_type().is_char_device() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Not a character device", + )); + } + + let rdev = metadata.rdev(); + let major = ((rdev >> 8) & 0xfff) as u64; + let minor = ((rdev & 0xff) | ((rdev >> 12) & 0xfff00)) as u64; + + Ok((major, minor)) +} + diff --git a/vuinputd/src/cuse_device/vuinput_write.rs b/vuinputd/src/cuse_device/vuinput_write.rs index b788dcb..6665b24 100644 --- a/vuinputd/src/cuse_device/vuinput_write.rs +++ b/vuinputd/src/cuse_device/vuinput_write.rs @@ -14,7 +14,6 @@ use libc::{__s32, __u16, input_event}; use crate::cuse_device::*; - // TODO: compat-mode+ ensure sizeof(struct input_event) pub unsafe extern "C" fn vuinput_write( _req: fuse_lowlevel::fuse_req_t, diff --git a/vuinputd/src/jobs/mknod_input_device.rs b/vuinputd/src/jobs/mknod_input_device.rs index 4f2252b..f2ae97b 100644 --- a/vuinputd/src/jobs/mknod_input_device.rs +++ b/vuinputd/src/jobs/mknod_input_device.rs @@ -87,7 +87,7 @@ pub fn remove_input_device(dev_path: String, major: u64, minor: u64) -> Result<( return Err("Device that should be deleted has wrong major and minor".into()) } } - Err(x) => return Err("Could not execute stat on device file".into()) + Err(_x) => return Err("Could not execute stat on device file".into()) } let _ = fs::remove_file(path); diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 52f2410..42045a3 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -19,16 +19,16 @@ use ::cuse_lowlevel::*; use log::info; -use std::collections::HashMap; use std::ffi::CString; use std::os::raw::{c_char}; use std::sync::atomic::{AtomicU64}; -use std::sync::{Mutex, RwLock}; +use std::sync::{Mutex}; pub mod cuse_device; +use crate::cuse_device::state::{initialize_dedup_last_error, initialize_vuinput_state}; use crate::cuse_device::vuinput_open::VUINPUT_COUNTER; -use crate::cuse_device::{DEDUP_LAST_ERROR, VUINPUT_STATE, vuinput_make_cuse_ops}; +use crate::cuse_device::{vuinput_make_cuse_ops}; use crate::jobs::monitor_udev_job::MonitorBackgroundLoop; pub mod process_tools; @@ -43,15 +43,15 @@ pub mod jobs; 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");; + check_permissions().expect("failed to read the capabilities of the vuinputd process"); let args: Vec = std::env::args().collect(); - VUINPUT_STATE.set(RwLock::new(HashMap::new())).expect("failed to initialize global state"); + initialize_vuinput_state(); VUINPUT_COUNTER.set(AtomicU64::new(3)).expect("failed to initialize the counter that provides the values of the CUSE file handles"); // 3, because 1 and 2 are usually STDOUT and STDERR JOB_DISPATCHER.set(Mutex::new(Dispatcher::new())).expect("failed to initialize the job dispatcher"); SELF_NAMESPACES.set(get_namespace(Pid::SelfPid)).expect("failed to retrieve the namespaces of the vuinputd process"); - DEDUP_LAST_ERROR.set(Mutex::new(None)).expect("failed to initialize the log deduplication state"); + initialize_dedup_last_error(); JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(MonitorBackgroundLoop::new())); info!("Starting vuinputd"); From f0bff128a6090be495d0fab928495c4d16b53c67 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 10 Dec 2025 22:46:52 +0000 Subject: [PATCH 010/108] cargo fmt --- cuse-lowlevel/build.rs | 24 ++-- cuse-lowlevel/src/lib.rs | 7 +- vuinput-examples/src/bin/mouse-advanced.rs | 15 +- vuinput-examples/src/bin/mouse-reuse.rs | 16 +-- vuinputd/src/cuse_device/mod.rs | 9 +- vuinputd/src/cuse_device/state.rs | 46 +++--- vuinputd/src/cuse_device/vuinput_ioctl.rs | 78 +++++++--- vuinputd/src/cuse_device/vuinput_open.rs | 16 +-- vuinputd/src/cuse_device/vuinput_release.rs | 36 +++-- vuinputd/src/cuse_device/vuinput_write.rs | 85 +++++------ vuinputd/src/job_engine/closure_job.rs | 6 +- vuinputd/src/job_engine/mod.rs | 4 +- vuinputd/src/job_engine/tests.rs | 9 +- vuinputd/src/jobs/inject_in_container_job.rs | 95 ++++++++----- vuinputd/src/jobs/mknod_input_device.rs | 11 +- vuinputd/src/jobs/mod.rs | 6 +- vuinputd/src/jobs/monitor_udev_job.rs | 12 +- vuinputd/src/jobs/netlink_message.rs | 47 +++--- .../src/jobs/remove_from_container_job.rs | 78 ++++++---- vuinputd/src/jobs/runtime_data.rs | 3 - vuinputd/src/main.rs | 39 +++-- vuinputd/src/process_tools/mod.rs | 134 +++++++++--------- 22 files changed, 429 insertions(+), 347 deletions(-) diff --git a/cuse-lowlevel/build.rs b/cuse-lowlevel/build.rs index 0c45b92..9813c1b 100644 --- a/cuse-lowlevel/build.rs +++ b/cuse-lowlevel/build.rs @@ -4,7 +4,7 @@ // Author: Johannes Leupolz // // This library is heavily baased on https://github.com/richard-w/libfuse-sys -// but adopted to only provide the low-level modules of fuse and cuse. +// but adopted to only provide the low-level modules of fuse and cuse. extern crate bindgen; extern crate pkg_config; @@ -15,7 +15,6 @@ use std::path::PathBuf; const FUSE_USE_VERSION: u32 = 314; //fuse version of ubuntu 24.04 - fn fuse_binding_filter(builder: bindgen::Builder) -> bindgen::Builder { let builder = builder // Whitelist "fuse_*" symbols and blocklist everything else @@ -103,18 +102,13 @@ fn main() { let mut pkgcfg = pkg_config::Config::new(); // Find libfuse - let fuse3_lib = pkgcfg.cargo_metadata(true).probe("fuse3").expect("Failed to find pkg-config module fuse3"); - + let fuse3_lib = pkgcfg + .cargo_metadata(true) + .probe("fuse3") + .expect("Failed to find pkg-config module fuse3"); + // Generate lowlevel bindings - generate_fuse_bindings( - "fuse_lowlevel.h", - &fuse3_lib, - fuse_binding_filter, - ); + generate_fuse_bindings("fuse_lowlevel.h", &fuse3_lib, fuse_binding_filter); // Generate lowlevel cuse bindings - generate_fuse_bindings( - "cuse_lowlevel.h", - &fuse3_lib, - cuse_binding_filter, - ); -} \ No newline at end of file + generate_fuse_bindings("cuse_lowlevel.h", &fuse3_lib, cuse_binding_filter); +} diff --git a/cuse-lowlevel/src/lib.rs b/cuse-lowlevel/src/lib.rs index 5a3b6c1..eb5296d 100644 --- a/cuse-lowlevel/src/lib.rs +++ b/cuse-lowlevel/src/lib.rs @@ -4,7 +4,7 @@ // Author: Johannes Leupolz // // This library is heavily baased on https://github.com/richard-w/libfuse-sys -// but adopted to only provide the low-level modules of fuse and cuse. +// but adopted to only provide the low-level modules of fuse and cuse. #![allow(non_snake_case)] #![allow(non_camel_case_types)] @@ -15,7 +15,6 @@ use libc::*; - pub mod fuse_lowlevel { use super::*; include!(concat!(env!("OUT_DIR"), "/fuse_lowlevel.rs")); @@ -24,8 +23,8 @@ pub mod fuse_lowlevel { pub mod cuse_lowlevel { use super::*; include!(concat!(env!("OUT_DIR"), "/cuse_lowlevel.rs")); - + use fuse_lowlevel::{ fuse_args, fuse_conn_info, fuse_file_info, fuse_pollhandle, fuse_req_t, fuse_session, }; -} \ No newline at end of file +} diff --git a/vuinput-examples/src/bin/mouse-advanced.rs b/vuinput-examples/src/bin/mouse-advanced.rs index 7269ada..e1c77e5 100644 --- a/vuinput-examples/src/bin/mouse-advanced.rs +++ b/vuinput-examples/src/bin/mouse-advanced.rs @@ -23,8 +23,7 @@ const REL_Y: i32 = 1; const SYN_REPORT: i32 = 0; const BUS_USB: u16 = 0x03; -/// - +/// fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> { // libc's input_event struct layout: @@ -62,11 +61,10 @@ fn main() -> io::Result<()> { // open device - matches: open("/dev/uinput-test", O_WRONLY | O_NONBLOCK); let args: Vec = std::env::args().collect(); - let device= - match args.len() { - 2 => args[1].clone(), - _ => "/dev/uinput".to_string() - }; + let device = match args.len() { + 2 => args[1].clone(), + _ => "/dev/uinput".to_string(), + }; let path = CString::new(device).unwrap(); let fd = unsafe { open(path.as_ptr(), O_WRONLY | O_NONBLOCK) }; @@ -97,19 +95,16 @@ fn main() -> io::Result<()> { std::process::exit(1); }); - ui_set_evbit(fd, EV_REL.try_into().unwrap()).unwrap_or_else(|e| { eprintln!("ui_set_evbit(EV_REL) failed: {:?}", e); std::process::exit(1); }); - ui_set_relbit(fd, REL_X.try_into().unwrap()).unwrap_or_else(|e| { eprintln!("ui_set_relbit(REL_X) failed: {:?}", e); std::process::exit(1); }); - ui_set_relbit(fd, REL_Y.try_into().unwrap()).unwrap_or_else(|e| { eprintln!("ui_set_relbit(REL_Y) failed: {:?}", e); std::process::exit(1); diff --git a/vuinput-examples/src/bin/mouse-reuse.rs b/vuinput-examples/src/bin/mouse-reuse.rs index 7b32db3..06c34a5 100644 --- a/vuinput-examples/src/bin/mouse-reuse.rs +++ b/vuinput-examples/src/bin/mouse-reuse.rs @@ -23,8 +23,7 @@ const REL_Y: i32 = 1; const SYN_REPORT: i32 = 0; const BUS_USB: u16 = 0x03; -/// - +/// fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> { // libc's input_event struct layout: @@ -62,11 +61,10 @@ fn main() -> io::Result<()> { // open device - matches: open("/dev/uinput-test", O_WRONLY | O_NONBLOCK); let args: Vec = std::env::args().collect(); - let device= - match args.len() { - 2 => args[1].clone(), - _ => "/dev/uinput".to_string() - }; + let device = match args.len() { + 2 => args[1].clone(), + _ => "/dev/uinput".to_string(), + }; let path = CString::new(device).unwrap(); let fd = unsafe { open(path.as_ptr(), O_WRONLY | O_NONBLOCK) }; @@ -97,19 +95,16 @@ fn main() -> io::Result<()> { std::process::exit(1); }); - ui_set_evbit(fd, EV_REL.try_into().unwrap()).unwrap_or_else(|e| { eprintln!("ui_set_evbit(EV_REL) failed: {:?}", e); std::process::exit(1); }); - ui_set_relbit(fd, REL_X.try_into().unwrap()).unwrap_or_else(|e| { eprintln!("ui_set_relbit(REL_X) failed: {:?}", e); std::process::exit(1); }); - ui_set_relbit(fd, REL_Y.try_into().unwrap()).unwrap_or_else(|e| { eprintln!("ui_set_relbit(REL_Y) failed: {:?}", e); std::process::exit(1); @@ -217,7 +212,6 @@ fn main() -> io::Result<()> { sleep(Duration::from_millis(300)); } - // Give userspace time to read events sleep(Duration::from_secs(5)); diff --git a/vuinputd/src/cuse_device/mod.rs b/vuinputd/src/cuse_device/mod.rs index b4e4d8e..5c5852e 100644 --- a/vuinputd/src/cuse_device/mod.rs +++ b/vuinputd/src/cuse_device/mod.rs @@ -4,20 +4,19 @@ pub mod state; pub mod vuinput_ioctl; -pub mod vuinput_write; -pub mod vuinput_release; pub mod vuinput_open; +pub mod vuinput_release; +pub mod vuinput_write; -use std::{fs, io}; +use std::io::ErrorKind; use std::os::unix::fs::{FileTypeExt, MetadataExt}; -use std::io::{ErrorKind}; +use std::{fs, io}; use ::cuse_lowlevel::*; use state::*; pub const BUS_USB: u16 = 0x03; - // Instance of cuse_lowlevel_ops with all stubs assigned. // Setting to None leads to e.g. "write error: Function not implemented". // You can find the implementations of the uinput default (open, release ,read, write, poll, diff --git a/vuinputd/src/cuse_device/state.rs b/vuinputd/src/cuse_device/state.rs index 7e8b503..d296a9d 100644 --- a/vuinputd/src/cuse_device/state.rs +++ b/vuinputd/src/cuse_device/state.rs @@ -3,7 +3,7 @@ // Author: Johannes Leupolz use std::collections::HashMap; -use std::fs::{File}; +use std::fs::File; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use ::cuse_lowlevel::*; @@ -12,8 +12,8 @@ use crate::process_tools::RequestingProcess; #[derive(Debug)] pub struct VuInputDevice { - pub major : u64, - pub minor : u64, + pub major: u64, + pub minor: u64, pub syspath: String, pub devnode: String, } @@ -22,12 +22,12 @@ pub struct VuInputDevice { pub struct VuInputState { pub file: File, pub requesting_process: RequestingProcess, - pub input_device: Option + pub input_device: Option, } -#[derive(Debug,Eq, Hash, PartialEq, Clone)] +#[derive(Debug, Eq, Hash, PartialEq, Clone)] pub enum VuFileHandle { - Fh(u64) + Fh(u64), } impl VuFileHandle { @@ -39,15 +39,13 @@ impl VuFileHandle { impl std::fmt::Display for VuFileHandle { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - VuFileHandle::Fh(fh) => writeln!(f, "VuFileHandle({:?})",fh)?, + VuFileHandle::Fh(fh) => writeln!(f, "VuFileHandle({:?})", fh)?, } Ok(()) } } -pub fn get_vuinput_state( - fh:&VuFileHandle, -) -> Result>, String> { +pub fn get_vuinput_state(fh: &VuFileHandle) -> Result>, String> { let map = VUINPUT_STATE .get() .ok_or("global not initialized".to_string())?; @@ -58,11 +56,7 @@ pub fn get_vuinput_state( .ok_or("handle not opened".to_string()) } - -pub fn insert_vuinput_state( - fh:&VuFileHandle, - state: VuInputState, -) -> Result<(), String> { +pub fn insert_vuinput_state(fh: &VuFileHandle, state: VuInputState) -> Result<(), String> { let map = VUINPUT_STATE .get() .ok_or("global not initialized".to_string())?; @@ -79,9 +73,7 @@ pub fn insert_vuinput_state( Ok(()) } -pub fn remove_vuinput_state( - fh:&VuFileHandle, -) -> Result>, String> { +pub fn remove_vuinput_state(fh: &VuFileHandle) -> Result>, String> { let map = VUINPUT_STATE .get() .ok_or("global not initialized".to_string())?; @@ -91,22 +83,24 @@ pub fn remove_vuinput_state( } pub fn initialize_vuinput_state() { - VUINPUT_STATE.set(RwLock::new(HashMap::new())).expect("failed to initialize global state"); + VUINPUT_STATE + .set(RwLock::new(HashMap::new())) + .expect("failed to initialize global state"); } pub fn initialize_dedup_last_error() { - DEDUP_LAST_ERROR.set(Mutex::new(None)).expect("failed to initialize the log deduplication state"); + DEDUP_LAST_ERROR + .set(Mutex::new(None)) + .expect("failed to initialize the log deduplication state"); } - #[derive(Debug)] pub enum VuError { - WriteError + WriteError, } - -pub static VUINPUT_STATE: OnceLock>>>> = OnceLock::new(); +pub static VUINPUT_STATE: OnceLock>>>> = + OnceLock::new(); // For log limiting. Idea: Move to log_limit crate -pub static DEDUP_LAST_ERROR: OnceLock>> = OnceLock::new(); - +pub static DEDUP_LAST_ERROR: OnceLock>> = OnceLock::new(); diff --git a/vuinputd/src/cuse_device/vuinput_ioctl.rs b/vuinputd/src/cuse_device/vuinput_ioctl.rs index 9483c70..2ad991f 100644 --- a/vuinputd/src/cuse_device/vuinput_ioctl.rs +++ b/vuinputd/src/cuse_device/vuinput_ioctl.rs @@ -2,17 +2,17 @@ // // Author: Johannes Leupolz +use ::cuse_lowlevel::*; use libc::{iovec, size_t, EBADRQC}; use libc::{uinput_abs_setup, uinput_ff_erase, uinput_ff_upload, uinput_setup}; -use ::cuse_lowlevel::*; -use log::{debug}; -use std::ffi::{CStr}; +use log::debug; +use std::ffi::CStr; use std::io::Write; use std::os::fd::AsRawFd; use std::os::raw::{c_char, c_int, c_uint, c_void}; use uinput_ioctls::*; -use crate::cuse_device::{VuFileHandle, get_vuinput_state}; +use crate::cuse_device::{get_vuinput_state, VuFileHandle}; use crate::job_engine::JOB_DISPATCHER; use crate::jobs::inject_in_container_job::InjectInContainerJob; use crate::jobs::remove_from_container_job::RemoveFromContainerJob; @@ -46,7 +46,7 @@ pub unsafe extern "C" fn vuinput_ioctl( //UI_ABS_SETUP => UI_ABS_SETUP_WITHOUT_SIZE, _ => cmd_u64, }; - let vufh= VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap()); + let vufh = VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap()); let vuinput_state_mutex = get_vuinput_state(&vufh).unwrap(); let fh = &(*_fi).fh; let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); @@ -166,21 +166,44 @@ pub unsafe extern "C" fn vuinput_ioctl( debug!("fh {}: syspath: {}", fh, sysname); let devnode = fetch_device_node(&sysname).unwrap(); debug!("fh {}: devnode: {}", fh, devnode); - let (major,minor) = fetch_major_minor(&devnode).unwrap(); - debug!("fh {}: major: {} minor: {} ", fh, major,minor); - vuinput_state.input_device = Some(VuInputDevice {major: major, minor: minor, syspath: sysname.clone(), devnode: devnode.clone() }); + let (major, minor) = fetch_major_minor(&devnode).unwrap(); + debug!("fh {}: major: {} minor: {} ", fh, major, minor); + vuinput_state.input_device = Some(VuInputDevice { + major: major, + minor: minor, + syspath: sysname.clone(), + devnode: devnode.clone(), + }); // Create device in container, if the request was really from another namespace - if ! SELF_NAMESPACES.get().unwrap().equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) { - let inject_job=InjectInContainerJob::new(vuinput_state.requesting_process.clone(),devnode.clone(),sysname.clone(),major,minor); + if !SELF_NAMESPACES + .get() + .unwrap() + .equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) + { + let inject_job = InjectInContainerJob::new( + vuinput_state.requesting_process.clone(), + devnode.clone(), + sysname.clone(), + major, + minor, + ); let awaiter = inject_job.get_awaiter_for_state(); - JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(inject_job)); + JOB_DISPATCHER + .get() + .unwrap() + .lock() + .unwrap() + .dispatch(Box::new(inject_job)); awaiter(&jobs::inject_in_container_job::State::Finished); - debug!("fh {}: injecting dev-nodes in container has been finished ", fh); + debug!( + "fh {}: injecting dev-nodes in container has been finished ", + fh + ); } // write a SYN-event (which is just zeros) just for validation - let syn_event : [u8; 24] = [0; 24]; + let syn_event: [u8; 24] = [0; 24]; vuinput_state.file.write_all(&syn_event).unwrap(); fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); @@ -190,13 +213,32 @@ pub unsafe extern "C" fn vuinput_ioctl( let input_device = vuinput_state.input_device.take(); // Remove device in container, if the request was really from another namespace - if input_device.is_some() && ! SELF_NAMESPACES.get().unwrap().equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) { + if input_device.is_some() + && !SELF_NAMESPACES + .get() + .unwrap() + .equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) + { let input_device = input_device.unwrap(); - let remove_job=RemoveFromContainerJob::new(vuinput_state.requesting_process.clone(),input_device.devnode.clone(),input_device.syspath.clone(),input_device.major,input_device.minor); + let remove_job = RemoveFromContainerJob::new( + vuinput_state.requesting_process.clone(), + input_device.devnode.clone(), + input_device.syspath.clone(), + input_device.major, + input_device.minor, + ); let awaiter = remove_job.get_awaiter_for_state(); - JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(remove_job)); + JOB_DISPATCHER + .get() + .unwrap() + .lock() + .unwrap() + .dispatch(Box::new(remove_job)); awaiter(&jobs::remove_from_container_job::State::Finished); - debug!("fh {}: removing dev-nodes from container has been finished ", fh); + debug!( + "fh {}: removing dev-nodes from container has been finished ", + fh + ); } ui_dev_destroy(fd).unwrap(); @@ -364,7 +406,6 @@ pub unsafe extern "C" fn vuinput_ioctl( } } - pub fn fetch_device_node(path: &str) -> io::Result { for entry in fs::read_dir(path)? { let entry = entry?; // propagate per-entry errors @@ -396,4 +437,3 @@ pub fn fetch_major_minor(path: &str) -> io::Result<(u64, u64)> { Ok((major, minor)) } - diff --git a/vuinputd/src/cuse_device/vuinput_open.rs b/vuinputd/src/cuse_device/vuinput_open.rs index 9acfffd..757c854 100644 --- a/vuinputd/src/cuse_device/vuinput_open.rs +++ b/vuinputd/src/cuse_device/vuinput_open.rs @@ -2,18 +2,18 @@ // // Author: Johannes Leupolz -use std::sync::OnceLock; -use libc::{O_CLOEXEC}; -use libc::{ENOENT}; use ::cuse_lowlevel::*; +use libc::ENOENT; +use libc::O_CLOEXEC; use log::{debug, error}; -use std::fs::{OpenOptions}; -use std::os::unix::fs::{OpenOptionsExt}; +use std::fs::OpenOptions; +use std::os::unix::fs::OpenOptionsExt; use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::OnceLock; -use crate::process_tools::{Pid, get_requesting_process}; use crate::cuse_device::*; +use crate::process_tools::{get_requesting_process, Pid}; pub static VUINPUT_COUNTER: OnceLock = OnceLock::new(); @@ -47,7 +47,7 @@ pub unsafe extern "C" fn vuinput_open( VuInputState { file: v, requesting_process, - input_device: None + input_device: None, }, ) .unwrap(); @@ -58,4 +58,4 @@ pub unsafe extern "C" fn vuinput_open( fuse_lowlevel::fuse_reply_err(_req, ENOENT); } } -} \ No newline at end of file +} diff --git a/vuinputd/src/cuse_device/vuinput_release.rs b/vuinputd/src/cuse_device/vuinput_release.rs index 9454e5c..5994e2f 100644 --- a/vuinputd/src/cuse_device/vuinput_release.rs +++ b/vuinputd/src/cuse_device/vuinput_release.rs @@ -2,20 +2,21 @@ // // Author: Johannes Leupolz -use ::cuse_lowlevel::*; -use log::{debug}; -use std::sync::{Arc}; -use crate::{cuse_device::*, jobs}; use crate::job_engine::JOB_DISPATCHER; use crate::jobs::remove_from_container_job::RemoveFromContainerJob; use crate::process_tools::SELF_NAMESPACES; +use crate::{cuse_device::*, jobs}; +use ::cuse_lowlevel::*; +use log::debug; +use std::sync::Arc; pub unsafe extern "C" fn vuinput_release( _req: fuse_lowlevel::fuse_req_t, _fi: *mut fuse_lowlevel::fuse_file_info, ) { let fh = &(*_fi).fh; - let vuinput_state_mutex = remove_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap(); + let vuinput_state_mutex = + remove_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap(); let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); let input_device = vuinput_state.input_device.take(); @@ -24,11 +25,27 @@ pub unsafe extern "C" fn vuinput_release( // Only do this in case it has not already been done by the ioctl UI_DEV_DESTROY // this here is relevant if the process was killed and didn't have the chance to send the // ioctl UI_DEV_DESTROY. - if input_device.is_some() && ! SELF_NAMESPACES.get().unwrap().equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) { + if input_device.is_some() + && !SELF_NAMESPACES + .get() + .unwrap() + .equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) + { let input_device = input_device.unwrap(); - let remove_job=RemoveFromContainerJob::new(vuinput_state.requesting_process.clone(),input_device.devnode.clone(),input_device.syspath.clone(),input_device.major,input_device.minor); + let remove_job = RemoveFromContainerJob::new( + vuinput_state.requesting_process.clone(), + input_device.devnode.clone(), + input_device.syspath.clone(), + input_device.major, + input_device.minor, + ); let awaiter = remove_job.get_awaiter_for_state(); - JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(remove_job)); + JOB_DISPATCHER + .get() + .unwrap() + .lock() + .unwrap() + .dispatch(Box::new(remove_job)); awaiter(&jobs::remove_from_container_job::State::Finished); } @@ -40,7 +57,7 @@ pub unsafe extern "C" fn vuinput_release( Arc::strong_count(&vuinput_state_mutex) ); drop(vuinput_state_mutex); // this also closes the file when no other references are open - // TODO: maybe also ensure that nothing is left in the containers + // TODO: maybe also ensure that nothing is left in the containers // Note: For CUSE, the kernel always issues RELEASE via fuse_sync_release(), // which forces a *synchronous* request (fuse_simple_request()). @@ -53,4 +70,3 @@ pub unsafe extern "C" fn vuinput_release( // `fuse_reply_err(req, 0)` is enough to wake the kernel and is safe here. fuse_lowlevel::fuse_reply_err(_req, 0); } - diff --git a/vuinputd/src/cuse_device/vuinput_write.rs b/vuinputd/src/cuse_device/vuinput_write.rs index 6665b24..671eb8b 100644 --- a/vuinputd/src/cuse_device/vuinput_write.rs +++ b/vuinputd/src/cuse_device/vuinput_write.rs @@ -2,17 +2,16 @@ // // Author: Johannes Leupolz +use crate::cuse_device::*; +use ::cuse_lowlevel::*; +use libc::{__s32, __u16, input_event}; use libc::{off_t, size_t, EIO}; use libc::{uinput_abs_setup, uinput_setup}; -use ::cuse_lowlevel::*; use log::{debug, trace}; use std::io::Write; use std::os::fd::AsRawFd; -use std::os::raw::{c_char}; +use std::os::raw::c_char; use uinput_ioctls::*; -use libc::{__s32, __u16, input_event}; -use crate::cuse_device::*; - // TODO: compat-mode+ ensure sizeof(struct input_event) pub unsafe extern "C" fn vuinput_write( @@ -27,14 +26,18 @@ pub unsafe extern "C" fn vuinput_write( "vuinput_write: offset needs to be 0 but is {}", _off ); - + let fh = &(*_fi).fh; let slice = std::slice::from_raw_parts(_buf as *const u8, _size); - let vuinput_state_mutex = get_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap(); + let vuinput_state_mutex = + get_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap(); let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); - + if vuinput_state.input_device.is_none() { - debug!("{}: legacy device setup recognized! Ignore the data and use hardcoded values",fh); + debug!( + "{}: legacy device setup recognized! Ignore the data and use hardcoded values", + fh + ); assert!(_size == std::mem::size_of::()); let legacy_uinput_user_dev = _buf as *const libc::uinput_user_dev; @@ -45,8 +48,8 @@ pub unsafe extern "C" fn vuinput_write( usetup.id.vendor = 0x1209; usetup.id.product = 0x5020; usetup.id.version = (*legacy_uinput_user_dev).id.version; - usetup.ff_effects_max=(*legacy_uinput_user_dev).ff_effects_max; - usetup.name=(*legacy_uinput_user_dev).name; + usetup.ff_effects_max = (*legacy_uinput_user_dev).ff_effects_max; + usetup.name = (*legacy_uinput_user_dev).name; // Call IOCTLs to setup and create the device // Assuming your wrappers accept (fd, ptr_to_usetup) etc. @@ -56,10 +59,12 @@ pub unsafe extern "C" fn vuinput_write( ui_dev_setup(fd, usetup_ptr).unwrap(); // setup abs - for code in 0..libc::ABS_CNT{ - if (*legacy_uinput_user_dev).absmax[code] != 0 || (*legacy_uinput_user_dev).absmin[code] != 0 { + for code in 0..libc::ABS_CNT { + if (*legacy_uinput_user_dev).absmax[code] != 0 + || (*legacy_uinput_user_dev).absmin[code] != 0 + { let mut abs_setup: uinput_abs_setup = unsafe { std::mem::zeroed() }; - abs_setup.code=code.try_into().unwrap(); + abs_setup.code = code.try_into().unwrap(); abs_setup.absinfo.maximum = (*legacy_uinput_user_dev).absmax[code]; abs_setup.absinfo.minimum = (*legacy_uinput_user_dev).absmin[code]; abs_setup.absinfo.fuzz = (*legacy_uinput_user_dev).absfuzz[code]; @@ -77,50 +82,50 @@ pub unsafe extern "C" fn vuinput_write( let mut bytes = 0; let mut result = Result::Ok(0); - let compat_size= std::mem::size_of::(); - let normal_size= std::mem::size_of::(); + let compat_size = std::mem::size_of::(); + let normal_size = std::mem::size_of::(); let is_compat = vuinput_state.requesting_process.is_compat; // TODO: ARM: && !compat_uses_64bit_time() - + if !is_compat { while bytes + normal_size <= _size && result.is_ok() { result = vuinput_state.file.write(&slice[bytes..bytes + normal_size]); - bytes += normal_size; + bytes += normal_size; } } else { while bytes + compat_size <= _size && result.is_ok() { - let position= _buf.byte_add(bytes); + let position = _buf.byte_add(bytes); let compat = position as *const input_event_compat; let normal = map_to_64_bit(&*compat); - let normal_ptr=(&normal as *const libc::input_event) as *const u8; - let slice = std::slice::from_raw_parts(normal_ptr,normal_size); + let normal_ptr = (&normal as *const libc::input_event) as *const u8; + let slice = std::slice::from_raw_parts(normal_ptr, normal_size); result = vuinput_state.file.write(&slice); - bytes += compat_size; + bytes += compat_size; } }; - + match result { Ok(_) => { - trace!("wrote {} of {} bytes (compat {})", bytes,_size,is_compat); + trace!("wrote {} of {} bytes (compat {})", bytes, _size, is_compat); fuse_lowlevel::fuse_reply_write(_req, bytes); } Err(e) => { let mut last_error = DEDUP_LAST_ERROR.get().unwrap().lock().unwrap(); - + match *last_error { - Some((last_fh,VuError::WriteError)) if *fh == last_fh => {}, - _ => {debug!("fh {}: error writing to uinput: {e:?}",fh);} + Some((last_fh, VuError::WriteError)) if *fh == last_fh => {} + _ => { + debug!("fh {}: error writing to uinput: {e:?}", fh); + } } - - *last_error = Some((*fh,VuError::WriteError)); - + + *last_error = Some((*fh, VuError::WriteError)); + fuse_lowlevel::fuse_reply_err(_req, EIO); } } } - - #[repr(C)] pub struct input_event_compat { pub input_event_sec: u32, @@ -138,17 +143,17 @@ pub fn compat_uses_64bit_time() -> bool { match arch { "x86_64" => false, "ppc64" => false, // some setups still 32-bit time_t - _ => true, // arm64, riscv64, s390x all use 64-bit + _ => true, // arm64, riscv64, s390x all use 64-bit } } -pub fn map_to_64_bit(compat: &input_event_compat) -> input_event{ +pub fn map_to_64_bit(compat: &input_event_compat) -> input_event { let mut mapped: input_event = unsafe { std::mem::zeroed() }; - mapped.time.tv_sec=compat.input_event_sec.into(); - mapped.time.tv_usec=compat.input_event_usec.into(); - mapped.type_=compat.type_; - mapped.code=compat.code; - mapped.value=compat.value; + mapped.time.tv_sec = compat.input_event_sec.into(); + mapped.time.tv_usec = compat.input_event_usec.into(); + mapped.type_ = compat.type_; + mapped.code = compat.code; + mapped.value = compat.value; mapped -} \ No newline at end of file +} diff --git a/vuinputd/src/job_engine/closure_job.rs b/vuinputd/src/job_engine/closure_job.rs index 7ac1b8f..5e0fb65 100644 --- a/vuinputd/src/job_engine/closure_job.rs +++ b/vuinputd/src/job_engine/closure_job.rs @@ -7,7 +7,6 @@ use std::pin::Pin; use crate::job_engine::job::{Dispatcher, Job, JobTarget}; - pub struct ClosureJob { desc: String, execute_after_cancellation: bool, @@ -79,11 +78,10 @@ pub fn example() { // println!("Running container job for {:?}", target); // })); - // - // JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(ClosureJob::new("Monitor udev events", JobTarget::BackgroundLoop,false, + // + // JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(ClosureJob::new("Monitor udev events", JobTarget::BackgroundLoop,false, // Box::new(move |_target| Box::pin(monitor_udev::udev_monitor_loop(cancel_token.clone())))))); - // Allow loops to run briefly before dropping all senders -> graceful shutdown dispatcher.close(); dispatcher.wait_until_finished(); diff --git a/vuinputd/src/job_engine/mod.rs b/vuinputd/src/job_engine/mod.rs index 9ea9ce1..77f6dc1 100644 --- a/vuinputd/src/job_engine/mod.rs +++ b/vuinputd/src/job_engine/mod.rs @@ -38,7 +38,7 @@ use crate::job_engine::job::Dispatcher; pub mod closure_job; pub mod job; -pub static JOB_DISPATCHER: OnceLock>= OnceLock::new(); +pub static JOB_DISPATCHER: OnceLock> = OnceLock::new(); #[cfg(test)] -mod tests; \ No newline at end of file +mod tests; diff --git a/vuinputd/src/job_engine/tests.rs b/vuinputd/src/job_engine/tests.rs index 77276db..bcf7ecc 100644 --- a/vuinputd/src/job_engine/tests.rs +++ b/vuinputd/src/job_engine/tests.rs @@ -1,5 +1,3 @@ - - use crate::job_engine::closure_job::ClosureJob; use crate::job_engine::job::{Dispatcher, JobTarget}; @@ -32,11 +30,10 @@ fn test_job_ordering() { let c1 = c1.clone(); Box::pin(async move { *c1.lock().unwrap() = 5; - }) + }) }), ))); - // job 2: increment to 6 let c2 = c.clone(); dispatcher.dispatch(Box::new(ClosureJob::new( @@ -47,7 +44,7 @@ fn test_job_ordering() { let c2 = c2.clone(); Box::pin(async move { *c2.lock().unwrap() += 1; - }) + }) }), ))); @@ -365,4 +362,4 @@ fn test_cleanup_when_target_disappears() { // Should run normally assert!(buf.contains(&"alive-job".into())); } - */ \ No newline at end of file + */ diff --git a/vuinputd/src/jobs/inject_in_container_job.rs b/vuinputd/src/jobs/inject_in_container_job.rs index 4c0dd93..e78cad1 100644 --- a/vuinputd/src/jobs/inject_in_container_job.rs +++ b/vuinputd/src/jobs/inject_in_container_job.rs @@ -2,22 +2,36 @@ // // Author: Johannes Leupolz -use std::{collections::HashMap, future::Future, pin::Pin, sync::{Arc, Condvar, Mutex}, time::Duration}; +use std::{ + collections::HashMap, + future::Future, + pin::Pin, + sync::{Arc, Condvar, Mutex}, + time::Duration, +}; use async_io::Timer; use log::debug; -use crate::{job_engine::job::{Job, JobTarget}, jobs::{mknod_input_device::ensure_input_device, monitor_udev_job::EVENT_STORE, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{ensure_udev_structure, read_udev_data, write_udev_data}}, process_tools::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace}}; +use crate::{ + job_engine::job::{Job, JobTarget}, + jobs::{ + mknod_input_device::ensure_input_device, + monitor_udev_job::EVENT_STORE, + netlink_message::send_udev_monitor_message_with_properties, + runtime_data::{ensure_udev_structure, read_udev_data, write_udev_data}, + }, + process_tools::{await_process, run_in_net_and_mnt_namespace, Pid, RequestingProcess}, +}; - -#[derive(Clone,Debug,Copy,PartialOrd,PartialEq)] +#[derive(Clone, Debug, Copy, PartialOrd, PartialEq)] pub enum State { Initialized, Started, Finished, } -#[derive(Clone,Debug)] +#[derive(Clone, Debug)] pub struct InjectInContainerJob { requesting_process: RequestingProcess, target: JobTarget, @@ -25,17 +39,23 @@ pub struct InjectInContainerJob { sys_path: String, major: u64, minor: u64, - sync_state: Arc<(Mutex,Condvar)>, + sync_state: Arc<(Mutex, Condvar)>, } impl InjectInContainerJob { - pub fn new(requesting_process: RequestingProcess,dev_path: String, sys_path: String, major: u64, minor: u64) -> Self { + pub fn new( + requesting_process: RequestingProcess, + dev_path: String, + sys_path: String, + major: u64, + minor: u64, + ) -> Self { Self { requesting_process: requesting_process.clone(), target: JobTarget::Container(requesting_process), dev_path: dev_path, sys_path: sys_path, - major: major , + major: major, minor: minor, sync_state: Arc::new((Mutex::new(State::Initialized), Condvar::new())), } @@ -52,10 +72,10 @@ impl InjectInContainerJob { pub fn get_awaiter_for_state(&self) -> impl FnOnce(&State) -> () { // pattern is described on https://doc.rust-lang.org/stable/std/sync/struct.Condvar.html let sync_state = self.sync_state.clone(); - let awaiter = move | state: &State| { + let awaiter = move |state: &State| { let (lock, cvar) = &*sync_state; let mut current_state = lock.lock().unwrap(); - while *current_state < *state { + while *current_state < *state { current_state = cvar.wait(current_state).unwrap(); } }; @@ -87,30 +107,33 @@ impl InjectInContainerJob { // Should be: Wait for the device to be created, the runtime data to be written and the // netlink message to be sent self.set_state(&State::Started); - let mut netlink_data: Option> = None; + let mut netlink_data: Option> = None; let mut runtime_data: Option = None; let mut number_of_attempt = 1; - while number_of_attempt<=50 && !(netlink_data.is_some() && runtime_data.is_some()) { - + while number_of_attempt <= 50 && !(netlink_data.is_some() && runtime_data.is_some()) { if netlink_data.is_none() { - - if let Some(netlink_event)=EVENT_STORE.get().unwrap().lock().unwrap().take(&self.sys_path) { + if let Some(netlink_event) = EVENT_STORE + .get() + .unwrap() + .lock() + .unwrap() + .take(&self.sys_path) + { if netlink_event.tombstone || netlink_event.remove_data.is_some() { debug!("do nothing, because the device has already been removed in the meantime"); return; } - netlink_data=netlink_event.add_data; + netlink_data = netlink_event.add_data; }; } if runtime_data.is_none() { - runtime_data = read_udev_data(self.major,self.minor).ok(); - + runtime_data = read_udev_data(self.major, self.minor).ok(); } - number_of_attempt+=1; + number_of_attempt += 1; // wait a maximum of 5 seconds == 50 attempts Timer::after(Duration::from_millis(100)).await; - } + } if netlink_data.is_none() || runtime_data.is_none() { if netlink_data.is_none() { debug!("Give up reading netlink data"); @@ -124,27 +147,29 @@ impl InjectInContainerJob { // define for capturing let major = self.major; - let minor=self.minor; + 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()); - - })) + 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(); self.set_state(&State::Finished); - } } diff --git a/vuinputd/src/jobs/mknod_input_device.rs b/vuinputd/src/jobs/mknod_input_device.rs index f2ae97b..02fc4bd 100644 --- a/vuinputd/src/jobs/mknod_input_device.rs +++ b/vuinputd/src/jobs/mknod_input_device.rs @@ -5,18 +5,17 @@ use nix::sys::stat::{makedev, mknod, stat, Mode, SFlag}; use std::error::Error; use std::fs; -use std::os::unix::fs::{PermissionsExt}; +use std::os::unix::fs::PermissionsExt; use std::path::Path; pub fn ensure_input_device(dev_path: String, major: u64, minor: u64) -> Result<(), Box> { - let input_dir = Path::new("/dev/input"); // Create directory like `mkdir -p` if !input_dir.exists() { println!("Create /dev/input"); fs::create_dir_all(input_dir)?; } - + let path = Path::new(&dev_path); let expected_dev = makedev(major, minor); let expected_mode = 0o666; @@ -84,12 +83,12 @@ pub fn remove_input_device(dev_path: String, major: u64, minor: u64) -> Result<( let is_char = (st.st_mode & libc::S_IFMT as u32) == libc::S_IFCHR as u32; let dev_ok = st.st_rdev == expected_dev; if !(is_char && dev_ok) { - return Err("Device that should be deleted has wrong major and minor".into()) + return Err("Device that should be deleted has wrong major and minor".into()); } } - Err(_x) => return Err("Could not execute stat on device file".into()) + Err(_x) => return Err("Could not execute stat on device file".into()), } let _ = fs::remove_file(path); Ok(()) -} \ No newline at end of file +} diff --git a/vuinputd/src/jobs/mod.rs b/vuinputd/src/jobs/mod.rs index cf229cf..1580916 100644 --- a/vuinputd/src/jobs/mod.rs +++ b/vuinputd/src/jobs/mod.rs @@ -3,8 +3,8 @@ // Author: Johannes Leupolz pub mod inject_in_container_job; -pub mod remove_from_container_job; -pub mod monitor_udev_job; pub mod mknod_input_device; +pub mod monitor_udev_job; +pub mod netlink_message; +pub mod remove_from_container_job; pub mod runtime_data; -pub mod netlink_message; \ No newline at end of file diff --git a/vuinputd/src/jobs/monitor_udev_job.rs b/vuinputd/src/jobs/monitor_udev_job.rs index 3697e07..fe6d4e9 100644 --- a/vuinputd/src/jobs/monitor_udev_job.rs +++ b/vuinputd/src/jobs/monitor_udev_job.rs @@ -129,12 +129,10 @@ impl EventStore { pub static EVENT_STORE: OnceLock>> = OnceLock::new(); -pub struct MonitorBackgroundLoop { -} +pub struct MonitorBackgroundLoop {} impl MonitorBackgroundLoop { pub fn new() -> Self { - Self { - } + Self {} } } @@ -208,16 +206,14 @@ pub async fn udev_monitor_loop(cancel_token: Arc) { let key = match key.as_str() { "ID_VUINPUT_KEYBOARD" => "ID_INPUT_KEYBOARD".to_string(), "ID_VUINPUT_MOUSE" => "ID_INPUT_MOUSE".to_string(), - _ => key + _ => key, }; let value: String = property.value().to_str().unwrap().to_string(); - if key!="ID_SEAT" { + if key != "ID_SEAT" { properties.insert(key, value); } } - - let value_of_devpath = properties.get("DEVPATH").unwrap(); diff --git a/vuinputd/src/jobs/netlink_message.rs b/vuinputd/src/jobs/netlink_message.rs index 8deb8de..2a1dc80 100644 --- a/vuinputd/src/jobs/netlink_message.rs +++ b/vuinputd/src/jobs/netlink_message.rs @@ -6,11 +6,11 @@ use std::collections::HashMap; use std::mem; use std::os::fd::{AsRawFd, OwnedFd}; -use std::io::{IoSlice}; +use std::io::IoSlice; use log::debug; use nix::sys::socket::{ - bind, sendmsg, socket, AddressFamily, MsgFlags, NetlinkAddr, SockFlag, SockProtocol, SockType + bind, sendmsg, socket, AddressFamily, MsgFlags, NetlinkAddr, SockFlag, SockProtocol, SockType, }; /// Netlink constants @@ -79,7 +79,7 @@ pub fn string_hash32(s: &str) -> u32 { match s { "input" => 3248653424, "" => 0, - _ => panic!("uncovered use case") + _ => panic!("uncovered use case"), } } @@ -95,12 +95,10 @@ fn open_netlink(groups: u32) -> Result { .map_err(|e| format!("Could not create netlink socket: {}", e))?; // pid 0 => the kernel takes care of assigning it. - let sockaddr=NetlinkAddr::new(0, groups); - let raw_fd= fd.as_raw_fd(); + let sockaddr = NetlinkAddr::new(0, groups); + let raw_fd = fd.as_raw_fd(); - bind(raw_fd, &sockaddr).map_err(|e| { - format!("Could not bind netlink socket: {}", e) - })?; + bind(raw_fd, &sockaddr).map_err(|e| format!("Could not bind netlink socket: {}", e))?; Ok(fd) } @@ -128,16 +126,19 @@ pub fn send_udev_monitor_message( let fd = open_netlink(groups)?; // prepare iovecs - let iov = [ - IoSlice::new(&header_bytes), - IoSlice::new(payload), - ]; + let iov = [IoSlice::new(&header_bytes), IoSlice::new(payload)]; // destination sockaddr (NULL nl_pid => kernel / multicast) let sockaddr = NetlinkAddr::new(0, groups); - let _rc = sendmsg(fd.as_raw_fd(), &iov, &[], MsgFlags::empty(), Some(&sockaddr)) - .map_err(|e| format!("Could not send message: {}", e)); + let _rc = sendmsg( + fd.as_raw_fd(), + &iov, + &[], + MsgFlags::empty(), + Some(&sockaddr), + ) + .map_err(|e| format!("Could not send message: {}", e)); debug!("udev message sent"); // ensure cleanup @@ -146,29 +147,27 @@ pub fn send_udev_monitor_message( Ok(()) } -pub fn send_udev_monitor_message_with_properties(properties:HashMap) { +pub fn send_udev_monitor_message_with_properties(properties: HashMap) { let device_name = match properties.get("DEVNAME") { Some(name) => name, - None => "unknown device" + None => "unknown device", }; - debug!("Sending udev message over netlink for {}",device_name); - let mut payload:Vec = Vec::new(); - for (key,value) in properties.iter() { + debug!("Sending udev message over netlink for {}", device_name); + let mut payload: Vec = Vec::new(); + for (key, value) in properties.iter() { payload.extend(key.as_bytes()); payload.extend("=".as_bytes()); payload.extend(value.as_bytes()); payload.push(0); } - - send_udev_monitor_message(&payload,Some("input"),None,UDEV_EVENT_MODE).unwrap(); + + send_udev_monitor_message(&payload, Some("input"), None, UDEV_EVENT_MODE).unwrap(); } // println!("{:02X?}", payload); // 746573743D76616C75650043555252454E545F544147533D3A736561745F7675696E7075743A00544147533D3A736561745F7675696E7075743A00444556504154483D2F646576696365732F7669727475616C2F696E7075742F696E7075743133382F6576656E74390049445F5655494E5055545F4D4F5553453D31004D494E4F523D37330049445F494E5055543D31002E494E5055545F434C4153533D6D6F757365005345514E554D3D3134393231002E484156455F485744425F50524F504552544945533D31004D414A4F523D313300414354494F4E3D6164640049445F53455249414C3D6E6F73657269616C004445564E414D453D2F6465762F696E7075742F6576656E743900555345435F494E495449414C495A45443D31373337373733353034373139320049445F5655494E5055543D310049445F534541543D736561745F7675696E7075740053554253595354454D3D696E70757400 // dGVzdD12YWx1ZQBDVVJSRU5UX1RBR1M9OnNlYXRfdnVpbnB1dDoAVEFHUz06c2VhdF92dWlucHV0OgBERVZQQVRIPS9kZXZpY2VzL3ZpcnR1YWwvaW5wdXQvaW5wdXQxMzgvZXZlbnQ5AElEX1ZVSU5QVVRfTU9VU0U9MQBNSU5PUj03MwBJRF9JTlBVVD0xAC5JTlBVVF9DTEFTUz1tb3VzZQBTRVFOVU09MTQ5MjEALkhBVkVfSFdEQl9QUk9QRVJUSUVTPTEATUFKT1I9MTMAQUNUSU9OPWFkZABJRF9TRVJJQUw9bm9zZXJpYWwAREVWTkFNRT0vZGV2L2lucHV0L2V2ZW50OQBVU0VDX0lOSVRJQUxJWkVEPTE3Mzc3NzM1MDQ3MTkyAElEX1ZVSU5QVVQ9MQBJRF9TRUFUPXNlYXRfdnVpbnB1dABTVUJTWVNURU09aW5wdXQA - - /* UDEV [16427452.069342] add /devices/virtual/input/input97 (input) ACTION=add @@ -211,4 +210,4 @@ TAGS=:seat_vuinput:power-switch: CURRENT_TAGS=:seat_vuinput:power-switch: -*/ \ No newline at end of file +*/ diff --git a/vuinputd/src/jobs/remove_from_container_job.rs b/vuinputd/src/jobs/remove_from_container_job.rs index 12b066d..e4b0077 100644 --- a/vuinputd/src/jobs/remove_from_container_job.rs +++ b/vuinputd/src/jobs/remove_from_container_job.rs @@ -2,23 +2,31 @@ // // Author: Johannes Leupolz -use std::{future::Future, pin::Pin, sync::{Arc, Condvar, Mutex}}; +use std::{ + future::Future, + pin::Pin, + sync::{Arc, Condvar, Mutex}, +}; use log::debug; -use crate::{job_engine::job::{Job, JobTarget}, jobs::{mknod_input_device::remove_input_device, monitor_udev_job::EVENT_STORE, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{delete_udev_data}}, process_tools::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace}}; +use crate::{ + job_engine::job::{Job, JobTarget}, + jobs::{ + mknod_input_device::remove_input_device, monitor_udev_job::EVENT_STORE, + netlink_message::send_udev_monitor_message_with_properties, runtime_data::delete_udev_data, + }, + process_tools::{await_process, run_in_net_and_mnt_namespace, Pid, RequestingProcess}, +}; - - -#[derive(Clone,Debug,Copy,PartialOrd,PartialEq)] +#[derive(Clone, Debug, Copy, PartialOrd, PartialEq)] pub enum State { Initialized, Started, Finished, } - -#[derive(Clone,Debug)] +#[derive(Clone, Debug)] pub struct RemoveFromContainerJob { requesting_process: RequestingProcess, target: JobTarget, @@ -26,17 +34,23 @@ pub struct RemoveFromContainerJob { sys_path: String, major: u64, minor: u64, - sync_state: Arc<(Mutex,Condvar)>, + sync_state: Arc<(Mutex, Condvar)>, } impl RemoveFromContainerJob { - pub fn new(requesting_process: RequestingProcess,dev_path: String, sys_path: String, major: u64, minor: u64) -> Self { + pub fn new( + requesting_process: RequestingProcess, + dev_path: String, + sys_path: String, + major: u64, + minor: u64, + ) -> Self { Self { requesting_process: requesting_process.clone(), target: JobTarget::Container(requesting_process), dev_path: dev_path, sys_path: sys_path, - major: major , + major: major, minor: minor, sync_state: Arc::new((Mutex::new(State::Initialized), Condvar::new())), } @@ -52,7 +66,7 @@ impl RemoveFromContainerJob { pub fn get_awaiter_for_state(&self) -> impl FnOnce(&State) -> () { // pattern is described on https://doc.rust-lang.org/stable/std/sync/struct.Condvar.html let sync_state = self.sync_state.clone(); - let awaiter = move | state: &State| { + let awaiter = move |state: &State| { let (lock, cvar) = &*sync_state; let mut current_state = lock.lock().unwrap(); while *current_state < *state { @@ -61,7 +75,6 @@ impl RemoveFromContainerJob { }; awaiter } - } impl Job for RemoveFromContainerJob { @@ -86,7 +99,13 @@ impl RemoveFromContainerJob { async fn remove_from_container(self) { self.set_state(&State::Started); - let netlink_event = match EVENT_STORE.get().unwrap().lock().unwrap().take(&self.sys_path) { + let netlink_event = match EVENT_STORE + .get() + .unwrap() + .lock() + .unwrap() + .take(&self.sys_path) + { Some(netlink_event) => netlink_event, None => { debug!("do nothing, because the device has never been announced via netlink"); @@ -100,31 +119,32 @@ impl RemoveFromContainerJob { self.set_state(&State::Finished); return; } - let netlink_data=netlink_event.add_data; + 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 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()); - }; + 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; self.set_state(&State::Finished); - } } diff --git a/vuinputd/src/jobs/runtime_data.rs b/vuinputd/src/jobs/runtime_data.rs index f74b05a..8c5903e 100644 --- a/vuinputd/src/jobs/runtime_data.rs +++ b/vuinputd/src/jobs/runtime_data.rs @@ -9,7 +9,6 @@ use std::path::Path; /// Ensure required udev directories and files exist pub fn ensure_udev_structure() -> io::Result<()> { // TODO: this _must_ exist, before a service using libinput is run. The time of device creation might be too late - let data_dir = Path::new("/run/udev/data"); let control_file = Path::new("/run/udev/control"); @@ -53,7 +52,6 @@ pub fn write_udev_data(content: &str, major: u64, minor: u64) -> io::Result<()> cleaned.push_str(&line); cleaned.push('\n'); } - let path = format!("/run/udev/data/c{}:{}", major, minor); let mut file = File::create(&path)?; @@ -65,7 +63,6 @@ pub fn write_udev_data(content: &str, major: u64, minor: u64) -> io::Result<()> /// Delete udev data for a given major/minor number /// - `major`, `minor` = device numbers pub fn delete_udev_data(major: u64, minor: u64) -> io::Result<()> { - let path = format!("/run/udev/data/c{}:{}", major, minor); fs::remove_file(&path)?; Ok(()) diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 42045a3..9c793fd 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -20,26 +20,25 @@ use ::cuse_lowlevel::*; use log::info; use std::ffi::CString; -use std::os::raw::{c_char}; -use std::sync::atomic::{AtomicU64}; -use std::sync::{Mutex}; +use std::os::raw::c_char; +use std::sync::atomic::AtomicU64; +use std::sync::Mutex; pub mod cuse_device; 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::cuse_device::{vuinput_make_cuse_ops}; use crate::jobs::monitor_udev_job::MonitorBackgroundLoop; pub mod process_tools; pub mod job_engine; -use crate::job_engine::{JOB_DISPATCHER, job::*}; +use crate::job_engine::{job::*, JOB_DISPATCHER}; use crate::process_tools::*; pub mod jobs; - fn main() -> std::io::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init(); @@ -48,11 +47,22 @@ fn main() -> std::io::Result<()> { let args: Vec = std::env::args().collect(); initialize_vuinput_state(); - VUINPUT_COUNTER.set(AtomicU64::new(3)).expect("failed to initialize the counter that provides the values of the CUSE file handles"); // 3, because 1 and 2 are usually STDOUT and STDERR - JOB_DISPATCHER.set(Mutex::new(Dispatcher::new())).expect("failed to initialize the job dispatcher"); - SELF_NAMESPACES.set(get_namespace(Pid::SelfPid)).expect("failed to retrieve the namespaces of the vuinputd process"); + VUINPUT_COUNTER.set(AtomicU64::new(3)).expect( + "failed to initialize the counter that provides the values of the CUSE file handles", + ); // 3, because 1 and 2 are usually STDOUT and STDERR + JOB_DISPATCHER + .set(Mutex::new(Dispatcher::new())) + .expect("failed to initialize the job dispatcher"); + SELF_NAMESPACES + .set(get_namespace(Pid::SelfPid)) + .expect("failed to retrieve the namespaces of the vuinputd process"); initialize_dedup_last_error(); - JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(MonitorBackgroundLoop::new())); + JOB_DISPATCHER + .get() + .unwrap() + .lock() + .unwrap() + .dispatch(Box::new(MonitorBackgroundLoop::new())); info!("Starting vuinputd"); @@ -62,7 +72,7 @@ fn main() -> std::io::Result<()> { let mut dev_info_argv: Vec<*const c_char> = vec![ vuinput_devicename.as_ptr(), // pointer to the C string - std::ptr::null(), // null terminator, often required by C APIs + std::ptr::null(), // null terminator, often required by C APIs ]; // setting dev_major and dev_minor to 0 leads to a dynamic assignment of the major and minor, very likely beginning with 234:0 @@ -104,7 +114,12 @@ fn main() -> std::io::Result<()> { } info!("Stopping vuinputd"); JOB_DISPATCHER.get().unwrap().lock().unwrap().close(); - JOB_DISPATCHER.get().unwrap().lock().unwrap().wait_until_finished(); + JOB_DISPATCHER + .get() + .unwrap() + .lock() + .unwrap() + .wait_until_finished(); Ok(()) } diff --git a/vuinputd/src/process_tools/mod.rs b/vuinputd/src/process_tools/mod.rs index 24952c0..6890344 100644 --- a/vuinputd/src/process_tools/mod.rs +++ b/vuinputd/src/process_tools/mod.rs @@ -9,14 +9,17 @@ use nix::{ unistd::{fork, ForkResult}, }; use std::{ - fs::{self, File}, io::Read, os::fd::{AsFd, FromRawFd, OwnedFd, RawFd}, path::{Path}, process, sync::OnceLock + fs::{self, File}, + io::Read, + os::fd::{AsFd, FromRawFd, OwnedFd, RawFd}, + path::Path, + process, + sync::OnceLock, }; use std::io; -pub static SELF_NAMESPACES: OnceLock= OnceLock::new(); - - +pub static SELF_NAMESPACES: OnceLock = OnceLock::new(); #[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] pub enum Pid { @@ -28,7 +31,7 @@ impl Pid { pub fn path(&self) -> String { match self { Pid::SelfPid => "/proc/self".to_string(), - Pid::Pid(pid_no) => format!("/proc/{}",pid_no) + Pid::Pid(pid_no) => format!("/proc/{}", pid_no), } } } @@ -45,12 +48,10 @@ pub struct Namespaces { pub cgroup: Option, pub time: Option, pub time_for_children: Option, - } /// Returns true if the process with `pid` is a 32-bit (compat) process. None, if unsure. pub fn is_compat_process(pid: Pid) -> Option { - match pid { Pid::Pid(pid) => { const EI_CLASS: usize = 4; @@ -75,8 +76,7 @@ pub fn is_compat_process(pid: Pid) -> Option { Err(_) => None, } } - Pid::SelfPid => - unreachable!() + Pid::SelfPid => unreachable!(), } } @@ -112,12 +112,20 @@ impl std::fmt::Display for RequestingProcess { writeln!(f, " uts: {:?}", self.namespaces.uts)?; writeln!(f, " ipc: {:?}", self.namespaces.ipc)?; writeln!(f, " pid: {:?}", self.namespaces.pid)?; - writeln!(f, " pid_for_children: {:?}", self.namespaces.pid_for_children)?; + writeln!( + f, + " pid_for_children: {:?}", + self.namespaces.pid_for_children + )?; writeln!(f, " user: {:?}", self.namespaces.user)?; writeln!(f, " mnt: {:?}", self.namespaces.mnt)?; writeln!(f, " cgroup: {:?}", self.namespaces.cgroup)?; writeln!(f, " time: {:?}", self.namespaces.time)?; - writeln!(f, " time_for_children: {:?}", self.namespaces.time_for_children)?; + writeln!( + f, + " time_for_children: {:?}", + self.namespaces.time_for_children + )?; Ok(()) } } @@ -168,45 +176,42 @@ pub fn get_namespace(pid: Pid) -> Namespaces { } fn get_ppid(pid: Pid) -> Option { - 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 + 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 .lines() .find(|line| line.starts_with("PPid:")) .and_then(|line| line.split_whitespace().nth(1)) .and_then(|ppid| ppid.parse::().ok()); match ppid { None => None, - Some(ppid)=> Some(Pid::Pid(ppid)) + Some(ppid) => Some(Pid::Pid(ppid)), } } - - pub fn get_requesting_process(pid: Pid) -> RequestingProcess { - match pid { - Pid::Pid(_) => - { + Pid::Pid(_) => { let is_compat = match is_compat_process(pid) { Some(false) => { - debug!("identified process {} as 64 bit process",pid.path()); + debug!("identified process {} as 64 bit process", pid.path()); false - }, + } Some(true) => { - debug!("identified process {} as 32 bit process",pid.path()); + debug!("identified process {} as 32 bit process", pid.path()); true - }, + } None => { - debug!("could not identify bitness of process {}. Assume 64 bit process",pid.path()); + debug!( + "could not identify bitness of process {}. Assume 64 bit process", + pid.path() + ); false - }, + } }; - // go up the parent hierarchy until we find a parent with different namespaces let mut ppid = pid; let nsinodes = get_namespace(pid); @@ -214,19 +219,21 @@ pub fn get_requesting_process(pid: Pid) -> RequestingProcess { let candidate_ppid = get_ppid(ppid); match candidate_ppid { None => break, - Some(candidate_ppid) => - { + Some(candidate_ppid) => { let ppid_nsinodes = get_namespace(candidate_ppid); if nsinodes.equal_mnt_and_net(&ppid_nsinodes) { - ppid=candidate_ppid; + ppid = candidate_ppid; } else { break; } } - } } - debug!("identified process {} as root of process id {}",ppid.path(),pid.path()); + debug!( + "identified process {} as root of process id {}", + ppid.path(), + pid.path() + ); let nspath = format!("{}/ns", pid.path()); let nsroot = format!("{}/ns", ppid.path()); @@ -234,19 +241,21 @@ pub fn get_requesting_process(pid: Pid) -> RequestingProcess { nspath: nspath, nsroot: nsroot, namespaces: nsinodes, - is_compat: is_compat + is_compat: is_compat, } - }, - Pid::SelfPid => - { + } + Pid::SelfPid => { unreachable!(); - }, + } } } /// 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 { +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(). match unsafe { fork()? } { @@ -255,7 +264,7 @@ pub fn run_in_net_and_mnt_namespace(ns: &RequestingProcess, func: Box) Ok(child) } ForkResult::Child => { - debug!("Start new process {}",process::id()); + 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()); @@ -267,7 +276,7 @@ pub fn run_in_net_and_mnt_namespace(ns: &RequestingProcess, func: Box) 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"); - + // execute your function func(); std::process::exit(0); @@ -276,15 +285,13 @@ pub fn run_in_net_and_mnt_namespace(ns: &RequestingProcess, func: Box) } pub async fn await_process(pid: Pid) -> io::Result { - match pid { - Pid::Pid(pid) => - { - unsafe { + Pid::Pid(pid) => { + unsafe { // Use pidfd_open() (libc) to get a real FD let pidfd = libc::syscall(libc::SYS_pidfd_open, pid, 0); if pidfd == -1 { - return Err(io::Error::last_os_error()) + return Err(io::Error::last_os_error()); } let owned_fd = OwnedFd::from_raw_fd(pidfd as RawFd); @@ -294,35 +301,28 @@ pub async fn await_process(pid: Pid) -> io::Result { // Retrieve the exit code using waitid() let mut si: libc::siginfo_t = std::mem::zeroed(); - let r = libc::waitid( - libc::P_PID, - pid as u32, - &mut si, - libc::WEXITED, - ); + let r = libc::waitid(libc::P_PID, pid as u32, &mut si, libc::WEXITED); if r != 0 { return Err(io::Error::last_os_error()); } Ok(si.si_status()) } - }, - Pid::SelfPid => - { + } + Pid::SelfPid => { unreachable!(); - }, + } } } - pub fn check_permissions() -> Result<(), std::io::Error> { let path = Path::new("/proc/self/status"); debug!("Capabilities of vuinputd process:"); - fs::read_to_string(path). - and_then(|status_file| { - status_file.lines() - .filter(|line| line.starts_with("Cap")) - .for_each(move |x| debug!("{}",x)); - Ok(()) - }) -} \ No newline at end of file + fs::read_to_string(path).and_then(|status_file| { + status_file + .lines() + .filter(|line| line.starts_with("Cap")) + .for_each(move |x| debug!("{}", x)); + Ok(()) + }) +} From cb73fde9af305c33d22f9775a7626462da63ee3e Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Fri, 12 Dec 2025 11:10:30 +0000 Subject: [PATCH 011/108] Setup code for integration tests --- Cargo.toml | 1 + docs/TESTS.md | 4 + vuinputd-tests/Cargo.toml | 17 + .../src/bin/keyboard-in-container.rs | 399 ++++++++++++++++++ vuinputd-tests/tests/integration_tests.rs | 14 + 5 files changed, 435 insertions(+) create mode 100644 vuinputd-tests/Cargo.toml create mode 100644 vuinputd-tests/src/bin/keyboard-in-container.rs create mode 100644 vuinputd-tests/tests/integration_tests.rs diff --git a/Cargo.toml b/Cargo.toml index 4128079..e6393d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "cuse-lowlevel", "vuinputd", "vuinput-examples", + "vuinputd-tests", ] # Optional: force same version for all crates diff --git a/docs/TESTS.md b/docs/TESTS.md index ddd8602..2b31958 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -1,5 +1,9 @@ # Tests +## Integration tests + +Run with `cargo test -p vuinputd-tests --features "requires-root requires-uinput"`. + ## Manual end-to-end tests | vuinputd | host | input type | app that creates device | app that reads device | working | Notes | diff --git a/vuinputd-tests/Cargo.toml b/vuinputd-tests/Cargo.toml new file mode 100644 index 0000000..a325378 --- /dev/null +++ b/vuinputd-tests/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "vuinputd-tests" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "keyboard-in-container" + +[dependencies] +uinput-ioctls = { path = "../uinput-ioctls" } +nix = { version = "0.30", features = ["ioctl"] } # ioctl & libc bindings +libc = "0.2" # raw system calls +libudev = "0.3" # enumerate-udev + +[features] +requires-root = [] +requires-uinput = [] \ No newline at end of file diff --git a/vuinputd-tests/src/bin/keyboard-in-container.rs b/vuinputd-tests/src/bin/keyboard-in-container.rs new file mode 100644 index 0000000..61a4e13 --- /dev/null +++ b/vuinputd-tests/src/bin/keyboard-in-container.rs @@ -0,0 +1,399 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use libc::uinput_setup; +use libc::{c_int, close, open, write, O_NONBLOCK, O_WRONLY}; +use std::ffi::{CStr, CString}; +use std::io; +use std::mem::{size_of, zeroed}; +use std::os::raw::{c_char, c_void}; +use std::ptr; +use std::thread::sleep; +use std::time::Duration; +pub use uinput_ioctls::*; + +// Constants (same numeric values as in linux headers) +const EV_SYN: i32 = 0x00; +const EV_KEY: i32 = 0x01; +const SYN_REPORT: i32 = 0; +const BUS_USB: u16 = 0x03; + +/// Key codes. Those are used by udev to recognize a device as a keyboard. +const KEY_ESC: i32 = 1; +const KEY_1: i32 = 2; +const KEY_2: i32 = 3; +const KEY_3: i32 = 4; +const KEY_4: i32 = 5; +const KEY_5: i32 = 6; +const KEY_6: i32 = 7; +const KEY_7: i32 = 8; +const KEY_8: i32 = 9; +const KEY_9: i32 = 10; +const KEY_0: i32 = 11; +const KEY_MINUS: i32 = 12; +const KEY_EQUAL: i32 = 13; +const KEY_BACKSPACE: i32 = 14; +const KEY_TAB: i32 = 15; +const KEY_Q: i32 = 16; +const KEY_W: i32 = 17; +const KEY_E: i32 = 18; +const KEY_R: i32 = 19; +const KEY_T: i32 = 20; +const KEY_Y: i32 = 21; +const KEY_U: i32 = 22; +const KEY_I: i32 = 23; +const KEY_O: i32 = 24; +const KEY_P: i32 = 25; +const KEY_LEFTBRACE: i32 = 26; +const KEY_RIGHTBRACE: i32 = 27; +const KEY_ENTER: i32 = 28; +const KEY_LEFTCTRL: i32 = 29; +const KEY_A: i32 = 30; +const KEY_S: i32 = 31; + +/// Space and other common keys +const KEY_D: i32 = 32; +const KEY_F: i32 = 33; +const KEY_G: i32 = 34; +const KEY_H: i32 = 35; +const KEY_J: i32 = 36; +const KEY_K: i32 = 37; +const KEY_L: i32 = 38; +const KEY_SEMICOLON: i32 = 39; +const KEY_APOSTROPHE: i32 = 40; +const KEY_GRAVE: i32 = 41; +const KEY_LEFTSHIFT: i32 = 42; +const KEY_BACKSLASH: i32 = 43; +const KEY_Z: i32 = 44; +const KEY_X: i32 = 45; +const KEY_C: i32 = 46; +const KEY_V: i32 = 47; +const KEY_B: i32 = 48; +const KEY_N: i32 = 49; +const KEY_M: i32 = 50; +const KEY_COMMA: i32 = 51; +const KEY_DOT: i32 = 52; +const KEY_SLASH: i32 = 53; +const KEY_RIGHTSHIFT: i32 = 54; +const KEY_KPASTERISK: i32 = 55; +const KEY_LEFTALT: i32 = 56; +const KEY_SPACE: i32 = 57; +const KEY_CAPSLOCK: i32 = 58; + +/// Function keys +const KEY_F1: i32 = 59; +const KEY_F2: i32 = 60; +const KEY_F3: i32 = 61; +const KEY_F4: i32 = 62; +const KEY_F5: i32 = 63; +const KEY_F6: i32 = 64; +const KEY_F7: i32 = 65; +const KEY_F8: i32 = 66; +const KEY_F9: i32 = 67; +const KEY_F10: i32 = 68; +const KEY_NUMLOCK: i32 = 69; +const KEY_SCROLLLOCK: i32 = 70; +const KEY_KP7: i32 = 71; +const KEY_KP8: i32 = 72; +const KEY_KP9: i32 = 73; +const KEY_KPMINUS: i32 = 74; +const KEY_KP4: i32 = 75; +const KEY_KP5: i32 = 76; +const KEY_KP6: i32 = 77; +const KEY_KPPLUS: i32 = 78; +const KEY_KP1: i32 = 79; +const KEY_KP2: i32 = 80; +const KEY_KP3: i32 = 81; +const KEY_KP0: i32 = 82; +const KEY_KPDOT: i32 = 83; + +/// Arrow keys and navigation +const KEY_ZENKAKUHANKAKU: i32 = 85; +const KEY_102ND: i32 = 86; +const KEY_F11: i32 = 87; +const KEY_F12: i32 = 88; +const KEY_RO: i32 = 89; +const KEY_KATAKANA: i32 = 90; +const KEY_HIRAGANA: i32 = 91; +const KEY_HENKAN: i32 = 92; +const KEY_KATAKANAHIRAGANA: i32 = 93; +const KEY_MUHENKAN: i32 = 94; +const KEY_KPJPCOMMA: i32 = 95; +const KEY_KPENTER: i32 = 96; +const KEY_RIGHTCTRL: i32 = 97; +const KEY_KPSLASH: i32 = 98; +const KEY_SYSRQ: i32 = 99; +const KEY_RIGHTALT: i32 = 100; +const KEY_LINEFEED: i32 = 101; +const KEY_HOME: i32 = 102; +const KEY_UP: i32 = 103; +const KEY_PAGEUP: i32 = 104; +const KEY_LEFT: i32 = 105; +const KEY_RIGHT: i32 = 106; +const KEY_END: i32 = 107; +const KEY_DOWN: i32 = 108; +const KEY_PAGEDOWN: i32 = 109; +const KEY_INSERT: i32 = 110; +const KEY_DELETE: i32 = 111; + +/// Configure a full 101-key standard keyboard +unsafe fn set_standard_keyboard_keys(fd: i32) -> Result<(), std::io::Error> { + // We need to set more bits so that systemd recognizes a keyboard as a keyboard. + // At least the first 32 bits are ESC, numbers, and Q to D, except KEY_RESERVED need to be considered. + // udev-builtin-input_id.c consideres the mask = 0xFFFFFFFE + + // EV_KEY + ui_set_evbit(fd, EV_KEY.try_into().unwrap())?; + + // All standard keys (1..101+) + let all_keys = [ + // Modifier + main keys + KEY_ESC, + KEY_1, + KEY_2, + KEY_3, + KEY_4, + KEY_5, + KEY_6, + KEY_7, + KEY_8, + KEY_9, + KEY_0, + KEY_MINUS, + KEY_EQUAL, + KEY_BACKSPACE, + KEY_TAB, + KEY_Q, + KEY_W, + KEY_E, + KEY_R, + KEY_T, + KEY_Y, + KEY_U, + KEY_I, + KEY_O, + KEY_P, + KEY_LEFTBRACE, + KEY_RIGHTBRACE, + KEY_ENTER, + KEY_LEFTCTRL, + KEY_A, + KEY_S, + KEY_D, + KEY_F, + KEY_G, + KEY_H, + KEY_J, + KEY_K, + KEY_L, + KEY_SEMICOLON, + KEY_APOSTROPHE, + KEY_GRAVE, + KEY_LEFTSHIFT, + KEY_BACKSLASH, + KEY_Z, + KEY_X, + KEY_C, + KEY_V, + KEY_B, + KEY_N, + KEY_M, + KEY_COMMA, + KEY_DOT, + KEY_SLASH, + KEY_RIGHTSHIFT, + KEY_KPASTERISK, + KEY_LEFTALT, + KEY_SPACE, + KEY_CAPSLOCK, + // Function keys + KEY_F1, + KEY_F2, + KEY_F3, + KEY_F4, + KEY_F5, + KEY_F6, + KEY_F7, + KEY_F8, + KEY_F9, + KEY_F10, + KEY_F11, + KEY_F12, + KEY_NUMLOCK, + KEY_SCROLLLOCK, + // Keypad + KEY_KP7, + KEY_KP8, + KEY_KP9, + KEY_KPMINUS, + KEY_KP4, + KEY_KP5, + KEY_KP6, + KEY_KPPLUS, + KEY_KP1, + KEY_KP2, + KEY_KP3, + KEY_KP0, + KEY_KPDOT, + KEY_KPENTER, + KEY_KPSLASH, + KEY_KPJPCOMMA, + // Arrows / navigation + KEY_HOME, + KEY_UP, + KEY_PAGEUP, + KEY_LEFT, + KEY_RIGHT, + KEY_END, + KEY_DOWN, + KEY_PAGEDOWN, + KEY_INSERT, + KEY_DELETE, + KEY_RIGHTCTRL, + KEY_RIGHTALT, + // Optional Japanese / additional keys + KEY_ZENKAKUHANKAKU, + KEY_102ND, + KEY_RO, + KEY_KATAKANA, + KEY_HIRAGANA, + KEY_HENKAN, + KEY_KATAKANAHIRAGANA, + KEY_MUHENKAN, + KEY_LINEFEED, + KEY_SYSRQ, + ]; + + for &key in all_keys.iter() { + ui_set_keybit(fd, key.try_into().unwrap())?; + } + + Ok(()) +} + +fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> { + // libc's input_event struct layout: + // struct input_event { + // struct timeval time; + // __u16 type; + // __u16 code; + // __s32 value; + // }; + // + // libc provides input_event as `libc::input_event` on Linux. + let mut ie: libc::input_event = unsafe { zeroed() }; + + // time fields are ignored by kernel for synthetic events - set zero + ie.time.tv_sec = 0; + ie.time.tv_usec = 0; + + // input_event fields: type and code are u16 in C; value is i32 + ie.type_ = ev_type as u16; // note: in libc the field is `type_` + ie.code = code as u16; + ie.value = val as i32; + + // write the struct to the uinput fd + let buf_ptr = &ie as *const libc::input_event as *const c_void; + let bytes = size_of::(); + + let written = unsafe { write(fd, buf_ptr, bytes) }; + if written as usize != bytes { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +fn main() -> io::Result<()> { + // open device - matches: open("/dev/uinput-test", O_WRONLY | O_NONBLOCK); + let path = CString::new("/dev/uinput-test").unwrap(); + let fd = unsafe { open(path.as_ptr(), O_WRONLY | O_NONBLOCK) }; + if fd < 0 { + eprintln!("error opening uinput"); + return Err(io::Error::last_os_error()); + } + + // In your snippet you supplied value.into() to the wrappers. The wrappers may accept different types. + // We follow your earlier usage pattern: + unsafe { + let mut version_of_uinput = 0; + let pversion_of_uinput = std::ptr::from_mut(&mut version_of_uinput); + eprintln!("ioctl UI_GET_VERSION request"); + ui_get_version(fd, pversion_of_uinput).unwrap_or_else(|e| { + eprintln!("ui_get_version failed: {:?}", e); + std::process::exit(1); + }); + eprintln!("ioctl UI_GET_VERSION {}", version_of_uinput); + + let _ = set_standard_keyboard_keys(fd).unwrap_or_else(|e| { + eprintln!("set_standard_keyboard_keys failed: {:?}", e); + std::process::exit(1); + }); + } + + // Prepare uinput_setup struct + let mut usetup: uinput_setup = unsafe { zeroed() }; + + // Fill id and name fields + // `id` has bustype, vendor, product fields (types may vary slightly by libc version) + // set bustype/vendor/product as in C example + // Note: make sure the fields exist as below in your libc version; adapt if names differ. + usetup.id.bustype = BUS_USB; + usetup.id.vendor = 0xbeef; + usetup.id.product = 0xdead; + + // Copy device name into the C char array in the struct + let name = CString::new("Example device").unwrap(); + // uinput_setup::name is usually [c_char; UINPUT_MAX_NAME_SIZE] + unsafe { + // Fill with zeros first (already zeroed by zeroed()) then copy bytes + let name_ptr = usetup.name.as_mut_ptr() as *mut c_char; + ptr::copy_nonoverlapping(name.as_ptr(), name_ptr, name.to_bytes_with_nul().len()); + } + + // Call IOCTLs to setup and create the device + // Assuming your wrappers accept (fd, ptr_to_usetup) etc. + // We'll pass pointer to usetup + let usetup_ptr = &mut usetup as *mut uinput_setup; + unsafe { + ui_dev_setup(fd, usetup_ptr).unwrap_or_else(|e| { + eprintln!("ui_dev_setup failed: {:?}", e); + close(fd); + std::process::exit(1); + }); + + ui_dev_create(fd).unwrap_or_else(|e| { + eprintln!("ui_dev_create failed: {:?}", e); + close(fd); + std::process::exit(1); + }); + + let mut resultbuf: [c_char; 64] = [0; 64]; + ui_get_sysname(fd, &mut resultbuf).unwrap(); + let sysname = CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy(); + eprintln!("sysname: {}", sysname); + + // Sleep 1 second to allow userspace to detect the device (same as C example) + sleep(Duration::from_secs(10)); + + // Emit press + syn + release + syn + emit(fd, EV_KEY, KEY_SPACE, 1)?; + emit(fd, EV_SYN, SYN_REPORT, 0)?; + emit(fd, EV_KEY, KEY_SPACE, 0)?; + emit(fd, EV_SYN, SYN_REPORT, 0)?; + + // Give userspace time to read events + sleep(Duration::from_secs(10)); + + // Destroy device and close fd + ui_dev_destroy(fd).unwrap_or_else(|e| { + eprintln!("ui_dev_destroy failed: {:?}", e); + close(fd); + std::process::exit(1); + }); + + close(fd); + } + + Ok(()) +} diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs new file mode 100644 index 0000000..f547794 --- /dev/null +++ b/vuinputd-tests/tests/integration_tests.rs @@ -0,0 +1,14 @@ +use std::process::Command; + +#[cfg(all(feature = "requires-root", feature = "requires-uinput"))] +#[test] +fn test_keyboard_in_container() { + let keyboard_in_container = env!("CARGO_BIN_EXE_keyboard-in-container"); + + // Run the ns_child helper which unshares namespaces + let status = Command::new(keyboard_in_container) + .status() + .expect("failed to launch ns helper"); + + assert!(status.success()); +} From 61cfb84cff33768b3bbaad451ad9f560dd0c0da2 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Fri, 12 Dec 2025 11:11:05 +0000 Subject: [PATCH 012/108] Add .gitignore --- .gitignore | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90f4dfb --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# Ignore build artifacts and editor files for this Rust workspace + +# Rust / Cargo +/target/ +**/target/ +**/target/* +**/debug/ +**/release/ +**/incremental/ + +# Generated package crates / archives +/package/ +/package/* + +# tarpaulin coverage output +/tarpaulin/ + +# VS Code and JetBrains +.vscode/ +.idea/ + +# Editor swap / backup files +*~ +*.swp +*.swo + +# macOS +.DS_Store + +# Windows +Thumbs.db + +# Temporary files +/tmp/ + +# Build artifacts +*.o +*.rlib +*.so +*.dylib +*.dll +*.exe + +# Coverage/profiler data +*.profraw +*.profdata + +# Environment files (optional) +.env +.env.local + +# Misc +.cache/ +.cargo/ + +# Debian build files +ex/* From ad6a4cc696da14fa8578f94ae65b16fa3d7aa596 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sat, 13 Dec 2025 20:36:06 +0000 Subject: [PATCH 013/108] Started to add wrapper for bubblewrap that I want to use to make my integration tests without a heavy container engine as docker. IPC with the client application is not working, yet. --- docs/TESTS.md | 5 +- vuinputd-tests/Cargo.toml | 8 +- vuinputd-tests/src/bin/bwrap-ipc.rs | 21 +++ vuinputd-tests/src/bwrap.rs | 207 ++++++++++++++++++++++ vuinputd-tests/src/lib.rs | 1 + vuinputd-tests/tests/integration_tests.rs | 53 +++++- 6 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 vuinputd-tests/src/bin/bwrap-ipc.rs create mode 100644 vuinputd-tests/src/bwrap.rs create mode 100644 vuinputd-tests/src/lib.rs diff --git a/docs/TESTS.md b/docs/TESTS.md index 2b31958..1e9a6c3 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -2,7 +2,10 @@ ## Integration tests -Run with `cargo test -p vuinputd-tests --features "requires-root requires-uinput"`. +Install bubblewrap: +`apt-get install bubblewrap`. + +Run with `cargo test -p vuinputd-tests --features "requires-root requires-uinput requires-bwrap"`. ## Manual end-to-end tests diff --git a/vuinputd-tests/Cargo.toml b/vuinputd-tests/Cargo.toml index a325378..70ae4bc 100644 --- a/vuinputd-tests/Cargo.toml +++ b/vuinputd-tests/Cargo.toml @@ -6,12 +6,16 @@ edition = "2021" [[bin]] name = "keyboard-in-container" +[[bin]] +name = "bwrap-ipc" + [dependencies] uinput-ioctls = { path = "../uinput-ioctls" } -nix = { version = "0.30", features = ["ioctl"] } # ioctl & libc bindings +nix = { version = "0.30", features = ["ioctl","socket"] } # ioctl & libc bindings libc = "0.2" # raw system calls libudev = "0.3" # enumerate-udev [features] requires-root = [] -requires-uinput = [] \ No newline at end of file +requires-uinput = [] +requires-bwrap = [] \ No newline at end of file diff --git a/vuinputd-tests/src/bin/bwrap-ipc.rs b/vuinputd-tests/src/bin/bwrap-ipc.rs new file mode 100644 index 0000000..f1f2389 --- /dev/null +++ b/vuinputd-tests/src/bin/bwrap-ipc.rs @@ -0,0 +1,21 @@ +use core::panic; +use std::{str::from_utf8_unchecked, time::Duration}; + +use vuinputd_tests::bwrap::SandboxChildIpc; + +fn main() { + println!("starting bwrap-ipc"); + let ipc = unsafe { SandboxChildIpc::from_fd() }; + + let incoming = ipc + .recv(Some(Duration::from_secs(5))) + .expect("error receiving input from ipc as child within 5 seconds"); + let incoming_str = + str::from_utf8(&incoming).expect("message received from ipc is not encoded as utf8"); + if incoming_str == "continue" { + ipc.send(b"ok").unwrap(); + } else { + ipc.send(b"nok").unwrap(); + panic!("expected ipc message to be 'continue'"); + } +} diff --git a/vuinputd-tests/src/bwrap.rs b/vuinputd-tests/src/bwrap.rs new file mode 100644 index 0000000..f6feb83 --- /dev/null +++ b/vuinputd-tests/src/bwrap.rs @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::io; +use std::os::fd::{FromRawFd, IntoRawFd, RawFd}; +use std::os::unix::net::UnixDatagram; +use std::os::unix::process::CommandExt; +use std::process::{Command, Output}; +use std::time::Duration; + +use nix::errno::Errno; +use nix::sys::socket::{socketpair, AddressFamily, SockFlag, SockType}; +use nix::unistd::close; + +/// Check if bubblewrap is available. +pub fn bwrap_available() -> bool { + Command::new("bwrap") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// IPC handle kept by the parent. +pub struct SandboxIpc { + sock: UnixDatagram, +} + +impl SandboxIpc { + pub fn recv(&self, read_timeout: Option) -> io::Result> { + let mut buf = vec![0u8; 4096]; + self.sock.set_read_timeout(read_timeout)?; + let n = self.sock.recv(&mut buf)?; + buf.truncate(n); + Ok(buf) + } + + pub fn send(&self, data: &[u8]) -> io::Result<()> { + self.sock.send(data)?; + Ok(()) + } +} + +/// IPC handle inside the container. +pub struct SandboxChildIpc { + sock: UnixDatagram, +} + +impl SandboxChildIpc { + /// FD number is fixed and known. + pub const FD: RawFd = 3; + + /// # Safety + /// Must only be called once in the child. + pub unsafe fn from_fd() -> Self { + let sock = UnixDatagram::from_raw_fd(Self::FD); + Self { sock } + } + + pub fn send(&self, data: &[u8]) -> io::Result<()> { + self.sock.send(data)?; + Ok(()) + } + + pub fn recv(&self, read_timeout: Option) -> io::Result> { + let mut buf = vec![0u8; 4096]; + self.sock.set_read_timeout(read_timeout)?; + let n = self.sock.recv(&mut buf)?; + buf.truncate(n); + Ok(buf) + } +} + +/// Builder for bubblewrap invocations. +#[derive(Default)] +pub struct BwrapBuilder { + args: Vec, + ipc_child_fd: Option, +} + +impl BwrapBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn unshare_all(mut self) -> Self { + self.args.push("--unshare-all".into()); + self + } + + pub fn unshare_net(mut self) -> Self { + self.args.push("--unshare-net".into()); + self + } + + pub fn proc(mut self) -> Self { + self.args.push("--proc".into()); + self.args.push("/proc".into()); + self + } + + pub fn tmpfs(mut self, path: &str) -> Self { + self.args.push("--tmpfs".into()); + self.args.push(path.into()); + self + } + + // https://superuser.com/questions/1577262/bwrap-execvp-no-such-file-or-directory-when-ro-binding-non-root-path + pub fn ro_bind(mut self, src: &str, dst: &str) -> Self { + self.args + .extend(["--ro-bind".into(), src.into(), dst.into()]); + self + } + + pub fn bind(mut self, src: &str, dst: &str) -> Self { + self.args.extend(["--bind".into(), src.into(), dst.into()]); + self + } + + /// Ensure the container dies if the parent dies. + /// + /// This uses bwrap's `--die-with-parent` flag, which internally + /// uses a parent-death signal (PR_SET_PDEATHSIG). + pub fn die_with_parent(mut self) -> Self { + self.args.push("--die-with-parent".into()); + self + } + + /// Enable bidirectional IPC using a Unix seqpacket socketpair. + pub fn with_ipc(mut self) -> io::Result<(Self, SandboxIpc)> { + let (parent, child) = socketpair( + AddressFamily::Unix, + SockType::SeqPacket, + None, + SockFlag::empty(), + ) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + + // Parent side + let parent_sock = unsafe { UnixDatagram::from_raw_fd(parent.into_raw_fd()) }; + + // Child side must become FD 3 inside container + self.ipc_child_fd = Some(child.into_raw_fd()); + + Ok((self, SandboxIpc { sock: parent_sock })) + } + + /// Final command executed inside the container. + pub fn command(mut self, cmd: &str) -> Self { + //self.args.push("--".into()); + self.args.push(cmd.into()); + self + } + + pub fn run(mut self) -> io::Result { + println!("Arguments for bwrap: {:?}", &self.args); + + let mut cmd = Command::new("bwrap"); + + if let Some(fd) = self.ipc_child_fd.take() { + // Move child FD to 3. Note that the FD 3 needs to be linked at the + // beginning of the child program. + unsafe { + cmd.pre_exec(move || { + let res = libc::dup2(fd, SandboxChildIpc::FD); + Errno::result(res) + .map(drop) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + close(fd).ok(); + Ok(()) + }) + }; + } + + cmd.args(&self.args).output() + } +} + +#[cfg(feature = "requires-bwrap")] +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bwrap_works() { + if !bwrap_available() { + panic!("bwrap not available"); + } + + let out = BwrapBuilder::new() + .unshare_net() + //.proc() + .ro_bind("/", "/") + .tmpfs("/tmp") + .die_with_parent() + .command("/usr/bin/sh") + .run() + .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); + + assert!(out.status.success()); + } +} diff --git a/vuinputd-tests/src/lib.rs b/vuinputd-tests/src/lib.rs new file mode 100644 index 0000000..238911a --- /dev/null +++ b/vuinputd-tests/src/lib.rs @@ -0,0 +1 @@ +pub mod bwrap; diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index f547794..06de7be 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -1,4 +1,55 @@ -use std::process::Command; +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::{process::Command, time::Duration}; +use vuinputd_tests::bwrap; + +#[cfg(all(feature = "requires-root", feature = "requires-bwrap"))] +#[test] +fn test_bwrap_simple() { + let out = bwrap::BwrapBuilder::new() + .unshare_all() + .ro_bind("/", "/") + .tmpfs("/tmp") + .die_with_parent() + .command("ls /") + .run() + .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); +} + +#[cfg(all(feature = "requires-root", feature = "requires-bwrap"))] +#[ignore] +#[test] +fn test_bwrap_ipc() { + let bwrap_ipc = env!("CARGO_BIN_EXE_bwrap-ipc"); + + let (builder, ipc) = bwrap::BwrapBuilder::new() + .unshare_all() + .ro_bind("/", "/") + .tmpfs("/tmp") + .die_with_parent() + .with_ipc() + .expect("failed to create IPC"); + + let out = builder + .command(bwrap_ipc) + .run() + .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); + ipc.send("continue".as_bytes()) + .unwrap_or_else(|e| panic!("failed to send data via ipc: {e}")); + + ipc.recv(Some(Duration::from_secs(5))) + .expect("error receiving input from ipc as host within 5 seconds"); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); +} #[cfg(all(feature = "requires-root", feature = "requires-uinput"))] #[test] From 1683829780f6b45da2695f7976f11d900057e6cf Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sat, 13 Dec 2025 22:36:01 +0000 Subject: [PATCH 014/108] Fix bwrap-ipc test. --- vuinputd-tests/src/bin/bwrap-ipc.rs | 4 +++- vuinputd-tests/src/bwrap.rs | 10 +++++++--- vuinputd-tests/tests/integration_tests.rs | 16 ++++++++++++---- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/vuinputd-tests/src/bin/bwrap-ipc.rs b/vuinputd-tests/src/bin/bwrap-ipc.rs index f1f2389..a965c5e 100644 --- a/vuinputd-tests/src/bin/bwrap-ipc.rs +++ b/vuinputd-tests/src/bin/bwrap-ipc.rs @@ -1,5 +1,5 @@ use core::panic; -use std::{str::from_utf8_unchecked, time::Duration}; +use std::{time::Duration}; use vuinputd_tests::bwrap::SandboxChildIpc; @@ -13,9 +13,11 @@ fn main() { let incoming_str = str::from_utf8(&incoming).expect("message received from ipc is not encoded as utf8"); if incoming_str == "continue" { + println!("child received continue"); ipc.send(b"ok").unwrap(); } else { ipc.send(b"nok").unwrap(); + println!("child received {}",incoming_str); panic!("expected ipc message to be 'continue'"); } } diff --git a/vuinputd-tests/src/bwrap.rs b/vuinputd-tests/src/bwrap.rs index f6feb83..ec0a91a 100644 --- a/vuinputd-tests/src/bwrap.rs +++ b/vuinputd-tests/src/bwrap.rs @@ -3,7 +3,7 @@ // Author: Johannes Leupolz use std::io; -use std::os::fd::{FromRawFd, IntoRawFd, RawFd}; +use std::os::fd::{FromRawFd, IntoRawFd, OwnedFd, RawFd}; use std::os::unix::net::UnixDatagram; use std::os::unix::process::CommandExt; use std::process::{Command, Output}; @@ -76,7 +76,7 @@ impl SandboxChildIpc { #[derive(Default)] pub struct BwrapBuilder { args: Vec, - ipc_child_fd: Option, + ipc_child_fd: Option, } impl BwrapBuilder { @@ -141,7 +141,7 @@ impl BwrapBuilder { let parent_sock = unsafe { UnixDatagram::from_raw_fd(parent.into_raw_fd()) }; // Child side must become FD 3 inside container - self.ipc_child_fd = Some(child.into_raw_fd()); + self.ipc_child_fd = Some(child); Ok((self, SandboxIpc { sock: parent_sock })) } @@ -158,7 +158,11 @@ impl BwrapBuilder { let mut cmd = Command::new("bwrap"); + if let Some(fd) = self.ipc_child_fd.take() { + // give up ownership of ipc_child_fd in host process. + let fd = fd.into_raw_fd(); + // Move child FD to 3. Note that the FD 3 needs to be linked at the // beginning of the child program. unsafe { diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index 06de7be..4f67444 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -36,19 +36,27 @@ fn test_bwrap_ipc() { .with_ipc() .expect("failed to create IPC"); + // Note that builder.run() will block. Thus, the send needs to happen before the child process blocks + // the host process. + ipc.send("continue".as_bytes()) + .unwrap_or_else(|e| panic!("failed to send data via ipc: {e}")); + let out = builder .command(bwrap_ipc) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); - ipc.send("continue".as_bytes()) - .unwrap_or_else(|e| panic!("failed to send data via ipc: {e}")); - ipc.recv(Some(Duration::from_secs(5))) - .expect("error receiving input from ipc as host within 5 seconds"); + let result = ipc.recv(Some(Duration::from_secs(5))); println!("Output"); println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); + + let result = result.expect("error receiving input from ipc as host within 5 seconds"); + let result_str = + str::from_utf8(&result).expect("message received from ipc is not encoded as utf8"); + println!("host received {}",result_str); + } #[cfg(all(feature = "requires-root", feature = "requires-uinput"))] From 13a9921a5c5c9b32a58a9f9f20bc841c6db948c2 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sat, 13 Dec 2025 23:09:31 +0000 Subject: [PATCH 015/108] Fixes in examples --- vuinput-examples/src/bin/keyboard-advanced.rs | 10 ++++++++-- vuinput-examples/src/bin/mouse-advanced.rs | 2 +- vuinput-examples/src/bin/mouse-reuse.rs | 2 +- vuinputd-tests/src/bin/keyboard-in-container.rs | 14 ++++++++++---- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/vuinput-examples/src/bin/keyboard-advanced.rs b/vuinput-examples/src/bin/keyboard-advanced.rs index 61a4e13..1ee2c24 100644 --- a/vuinput-examples/src/bin/keyboard-advanced.rs +++ b/vuinput-examples/src/bin/keyboard-advanced.rs @@ -305,8 +305,14 @@ fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> { } fn main() -> io::Result<()> { - // open device - matches: open("/dev/uinput-test", O_WRONLY | O_NONBLOCK); - let path = CString::new("/dev/uinput-test").unwrap(); + // open device - matches: open("/dev/uinput", O_WRONLY | O_NONBLOCK); + let args: Vec = std::env::args().collect(); + let device = match args.len() { + 2 => args[1].clone(), + _ => "/dev/uinput".to_string(), + }; + + let path = CString::new(device).unwrap(); let fd = unsafe { open(path.as_ptr(), O_WRONLY | O_NONBLOCK) }; if fd < 0 { eprintln!("error opening uinput"); diff --git a/vuinput-examples/src/bin/mouse-advanced.rs b/vuinput-examples/src/bin/mouse-advanced.rs index e1c77e5..6b872a4 100644 --- a/vuinput-examples/src/bin/mouse-advanced.rs +++ b/vuinput-examples/src/bin/mouse-advanced.rs @@ -58,7 +58,7 @@ fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> { } fn main() -> io::Result<()> { - // open device - matches: open("/dev/uinput-test", O_WRONLY | O_NONBLOCK); + // open device - matches: open("/dev/uinput", O_WRONLY | O_NONBLOCK); let args: Vec = std::env::args().collect(); let device = match args.len() { diff --git a/vuinput-examples/src/bin/mouse-reuse.rs b/vuinput-examples/src/bin/mouse-reuse.rs index 06c34a5..855a5ee 100644 --- a/vuinput-examples/src/bin/mouse-reuse.rs +++ b/vuinput-examples/src/bin/mouse-reuse.rs @@ -58,7 +58,7 @@ fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> { } fn main() -> io::Result<()> { - // open device - matches: open("/dev/uinput-test", O_WRONLY | O_NONBLOCK); + // open device - matches: open("/dev/uinput", O_WRONLY | O_NONBLOCK); let args: Vec = std::env::args().collect(); let device = match args.len() { diff --git a/vuinputd-tests/src/bin/keyboard-in-container.rs b/vuinputd-tests/src/bin/keyboard-in-container.rs index 61a4e13..43c1908 100644 --- a/vuinputd-tests/src/bin/keyboard-in-container.rs +++ b/vuinputd-tests/src/bin/keyboard-in-container.rs @@ -305,8 +305,14 @@ fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> { } fn main() -> io::Result<()> { - // open device - matches: open("/dev/uinput-test", O_WRONLY | O_NONBLOCK); - let path = CString::new("/dev/uinput-test").unwrap(); + // open device - matches: open("/dev/uinput", O_WRONLY | O_NONBLOCK); + let args: Vec = std::env::args().collect(); + let device = match args.len() { + 2 => args[1].clone(), + _ => "/dev/uinput".to_string(), + }; + + let path = CString::new(device).unwrap(); let fd = unsafe { open(path.as_ptr(), O_WRONLY | O_NONBLOCK) }; if fd < 0 { eprintln!("error opening uinput"); @@ -374,7 +380,7 @@ fn main() -> io::Result<()> { eprintln!("sysname: {}", sysname); // Sleep 1 second to allow userspace to detect the device (same as C example) - sleep(Duration::from_secs(10)); + sleep(Duration::from_secs(1)); // Emit press + syn + release + syn emit(fd, EV_KEY, KEY_SPACE, 1)?; @@ -383,7 +389,7 @@ fn main() -> io::Result<()> { emit(fd, EV_SYN, SYN_REPORT, 0)?; // Give userspace time to read events - sleep(Duration::from_secs(10)); + sleep(Duration::from_secs(2)); // Destroy device and close fd ui_dev_destroy(fd).unwrap_or_else(|e| { From 8784475eda4443666a018ab09c7003a15b29a519 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sat, 13 Dec 2025 23:10:11 +0000 Subject: [PATCH 016/108] bubblewrap wrapper supports command arguments --- vuinputd-tests/src/bwrap.rs | 7 ++++--- vuinputd-tests/tests/integration_tests.rs | 10 +++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/vuinputd-tests/src/bwrap.rs b/vuinputd-tests/src/bwrap.rs index ec0a91a..1e70c23 100644 --- a/vuinputd-tests/src/bwrap.rs +++ b/vuinputd-tests/src/bwrap.rs @@ -147,9 +147,10 @@ impl BwrapBuilder { } /// Final command executed inside the container. - pub fn command(mut self, cmd: &str) -> Self { - //self.args.push("--".into()); + pub fn command(mut self, cmd: &str, args: &[&str]) -> Self { + self.args.push("--".into()); self.args.push(cmd.into()); + self.args.extend(args.iter().map(|s| s.to_string())); self } @@ -198,7 +199,7 @@ mod tests { .ro_bind("/", "/") .tmpfs("/tmp") .die_with_parent() - .command("/usr/bin/sh") + .command("/usr/bin/echo",&[]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index 4f67444..b2ecb45 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -8,12 +8,14 @@ use vuinputd_tests::bwrap; #[cfg(all(feature = "requires-root", feature = "requires-bwrap"))] #[test] fn test_bwrap_simple() { + use std::vec; + let out = bwrap::BwrapBuilder::new() .unshare_all() .ro_bind("/", "/") .tmpfs("/tmp") .die_with_parent() - .command("ls /") + .command("/usr/bin/echo",&["test","test","test"]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); @@ -23,7 +25,6 @@ fn test_bwrap_simple() { } #[cfg(all(feature = "requires-root", feature = "requires-bwrap"))] -#[ignore] #[test] fn test_bwrap_ipc() { let bwrap_ipc = env!("CARGO_BIN_EXE_bwrap-ipc"); @@ -42,7 +43,7 @@ fn test_bwrap_ipc() { .unwrap_or_else(|e| panic!("failed to send data via ipc: {e}")); let out = builder - .command(bwrap_ipc) + .command(bwrap_ipc,&[]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); @@ -64,10 +65,9 @@ fn test_bwrap_ipc() { fn test_keyboard_in_container() { let keyboard_in_container = env!("CARGO_BIN_EXE_keyboard-in-container"); - // Run the ns_child helper which unshares namespaces let status = Command::new(keyboard_in_container) .status() - .expect("failed to launch ns helper"); + .expect("failed to launch keyboard-in-container"); assert!(status.success()); } From e4335df8e6a83b5cb653c74a8a35cd823780a87b Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sun, 14 Dec 2025 21:22:09 +0000 Subject: [PATCH 017/108] Add integration tests that just try to create a device using uinput using bubblewrap. No further checks. --- vuinputd-tests/src/bwrap.rs | 5 +++ vuinputd-tests/tests/integration_tests.rs | 52 +++++++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/vuinputd-tests/src/bwrap.rs b/vuinputd-tests/src/bwrap.rs index 1e70c23..6801d00 100644 --- a/vuinputd-tests/src/bwrap.rs +++ b/vuinputd-tests/src/bwrap.rs @@ -118,6 +118,11 @@ impl BwrapBuilder { self } + pub fn dev_bind(mut self, src: &str, dst: &str) -> Self { + self.args.extend(["--dev-bind".into(), src.into(), dst.into()]); + self + } + /// Ensure the container dies if the parent dies. /// /// This uses bwrap's `--die-with-parent` flag, which internally diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index b2ecb45..bab3bc1 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -8,8 +8,6 @@ use vuinputd_tests::bwrap; #[cfg(all(feature = "requires-root", feature = "requires-bwrap"))] #[test] fn test_bwrap_simple() { - use std::vec; - let out = bwrap::BwrapBuilder::new() .unshare_all() .ro_bind("/", "/") @@ -60,9 +58,10 @@ fn test_bwrap_ipc() { } + #[cfg(all(feature = "requires-root", feature = "requires-uinput"))] #[test] -fn test_keyboard_in_container() { +fn test_keyboard_on_host() { let keyboard_in_container = env!("CARGO_BIN_EXE_keyboard-in-container"); let status = Command::new(keyboard_in_container) @@ -71,3 +70,50 @@ fn test_keyboard_in_container() { assert!(status.success()); } + + +#[cfg(all(feature = "requires-root", feature = "requires-uinput", feature = "requires-bwrap"))] +#[test] +fn test_keyboard_in_container_with_uinput() { + let keyboard_in_container = env!("CARGO_BIN_EXE_keyboard-in-container"); + + let out = bwrap::BwrapBuilder::new() + .unshare_net() + .ro_bind("/", "/") + .tmpfs("/tmp") + .dev_bind("/dev/uinput", "/dev/uinput") + .die_with_parent() + .command(keyboard_in_container,&[]) + .run() + .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); + + assert!(out.status.success()); +} + +#[cfg(all(feature = "requires-root", feature = "requires-uinput", feature = "requires-bwrap"))] +#[ignore] +#[test] +fn test_keyboard_in_container_with_vuinput() { + println!("Note that vuinputd needs to run for this test"); + let keyboard_in_container = env!("CARGO_BIN_EXE_keyboard-in-container"); + + let out = bwrap::BwrapBuilder::new() + .unshare_net() + .ro_bind("/", "/") + .tmpfs("/tmp") + .dev_bind("/dev/vuinput", "/dev/uinput") + .die_with_parent() + .command(keyboard_in_container,&[]) + .run() + .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); + + assert!(out.status.success()); +} From 0e6abf1d85b1f71c6d9e48022d94c2be483ef31b Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sun, 14 Dec 2025 21:44:10 +0000 Subject: [PATCH 018/108] Fix integration test for vuinput. --- docs/USAGE.md | 9 +++++++++ vuinputd-tests/tests/integration_tests.rs | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index bdd8b48..005645d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -235,6 +235,15 @@ Sample output from `journalctl` showing vuinputd output: | Device appears on host seat | udev rules not isolating | Add udev rules from vuinputd/udev-folder | | Input delayed or missing | CUSE layer error | Check host logs via `journalctl -u vuinputd` | + +``` +Dez 14 21:33:17 wohnzimmer vuinputd[2172719]: Create /dev/input +Dez 14 21:33:17 wohnzimmer vuinputd[2172719]: [2025-12-14T21:33:17Z DEBUG vuinputd::jobs::inject_in_container_job] Error creating input device /dev/input/event12: Read-o> +Dez 14 21:33:17 wohnzimmer vuinputd[2172719]: thread '' panicked at vuinputd/src/jobs/inject_in_container_job.rs:161:41: +Dez 14 21:33:17 wohnzimmer vuinputd[2172719]: called `Result::unwrap()` on an `Err` value: Os { code: 30, kind: ReadOnlyFilesystem, message: "Read-only file system" } +``` + +Ensure /dev and /run are writable in the container. If in doubt, use tmpfs. --- ## 8. Notes and Advanced Topics diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index bab3bc1..915136d 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -95,7 +95,6 @@ fn test_keyboard_in_container_with_uinput() { } #[cfg(all(feature = "requires-root", feature = "requires-uinput", feature = "requires-bwrap"))] -#[ignore] #[test] fn test_keyboard_in_container_with_vuinput() { println!("Note that vuinputd needs to run for this test"); @@ -105,6 +104,10 @@ fn test_keyboard_in_container_with_vuinput() { .unshare_net() .ro_bind("/", "/") .tmpfs("/tmp") + // dev needs to be writable for the new devices + .tmpfs("/dev") + // run needs to be writable for the udev devices + .tmpfs("/run") .dev_bind("/dev/vuinput", "/dev/uinput") .die_with_parent() .command(keyboard_in_container,&[]) From 16afd2a48240f17d51a4521a428bd2d692747ec8 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 15 Dec 2025 22:02:36 +0000 Subject: [PATCH 019/108] Add command line options for vuinputd (devname, major, and minor) to control the name and properties of the virtual uinput character device --- debian/control | 1 + vuinputd/Cargo.toml | 1 + vuinputd/src/main.rs | 72 ++++++++++++++++++++++++++++--- vuinputd/systemd/vuinputd.service | 4 +- 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/debian/control b/debian/control index 40cd853..721071c 100644 --- a/debian/control +++ b/debian/control @@ -21,6 +21,7 @@ Build-Depends: debhelper (>= 12), librust-futures-dev, librust-async-io-dev, librust-anyhow-dev, + librust-clap-dev, pkg-config, build-essential Standards-Version: 4.6.0 diff --git a/vuinputd/Cargo.toml b/vuinputd/Cargo.toml index 0aa4dfa..78c488b 100644 --- a/vuinputd/Cargo.toml +++ b/vuinputd/Cargo.toml @@ -26,3 +26,4 @@ async-channel = "2.5.0" futures = "0.3.31" async-io = "2.6.0" anyhow = "1.0.100" +clap = { version = "4", features = ["derive"] } diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 9c793fd..544fb39 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -39,12 +39,64 @@ use crate::process_tools::*; pub mod jobs; +use clap::Parser; + +const DEV_PREFIX: &str = "/dev/"; +const DEVNAME_MAX_LEN: usize = 128 - DEV_PREFIX.len(); + +#[derive(Debug, Parser)] +#[command(author, version, about)] +struct Args { + /// Major device number + #[arg(long)] + major: Option, + + /// Minor device number + #[arg(long)] + minor: Option, + + /// Device name (without /dev/) + #[arg(long)] + devname: Option, +} + +fn validate_args(args: &Args) -> Result<(), String> { + // major/minor must appear together + match (&args.major, &args.minor) { + (Some(_), Some(_)) | (None, None) => {} + _ => { + return Err( + "--major and --minor must be specified together or not at all".into(), + ); + } + } + + // devname length constraint + if let Some(devname) = &args.devname { + if devname.len() >= DEVNAME_MAX_LEN { + return Err(format!( + "--devname must be shorter than {} bytes", + DEVNAME_MAX_LEN + )); + } + } + + Ok(()) +} + 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: Vec = std::env::args().collect(); + let args = Args::parse(); + let argv0 = std::env::args_os().next().expect("Couldn't retrieve program name"); + + if let Err(e) = validate_args(&args) { + eprintln!("Error: {e}"); + std::process::exit(2); + } + initialize_vuinput_state(); VUINPUT_COUNTER.set(AtomicU64::new(3)).expect( @@ -68,7 +120,11 @@ fn main() -> std::io::Result<()> { let cuse_ops = vuinput_make_cuse_ops(); - let vuinput_devicename = CString::new(format!("DEVNAME=vuinput")).unwrap(); + let vuinput_devicename = match &args.devname { + None => "vuinput", + Some(devname) => devname + }; + let vuinput_devicename = CString::new(format!("DEVNAME={}",vuinput_devicename)).unwrap(); let mut dev_info_argv: Vec<*const c_char> = vec![ vuinput_devicename.as_ptr(), // pointer to the C string @@ -77,17 +133,19 @@ fn main() -> std::io::Result<()> { // setting dev_major and dev_minor to 0 leads to a dynamic assignment of the major and minor, very likely beginning with 234:0 // see in https://www.kernel.org/doc/Documentation/admin-guide/devices.txt - // major 120 is reserved for local/experimental use. I picked minor 414795 with the use - // of a random number generator to omit conflicts. + let (major,minor) = match ((&args).major, (&args).minor) { + (Some(major), Some(minor)) => (major,minor), + _ => (0,0) + }; let ci = cuse_lowlevel::cuse_info { - dev_major: 120, - dev_minor: 414795, + dev_major: major, + dev_minor: minor, dev_info_argc: 1, dev_info_argv: dev_info_argv.as_mut_ptr(), flags: cuse_lowlevel::CUSE_UNRESTRICTED_IOCTL, }; - let arg_program_name = CString::new(args[0].clone()).unwrap(); + let arg_program_name = CString::new(argv0.as_encoded_bytes()).unwrap(); let parg_program_name = arg_program_name.into_raw(); let arg_foreground = CString::new("-f").unwrap(); let parg_foreground = arg_foreground.into_raw(); diff --git a/vuinputd/systemd/vuinputd.service b/vuinputd/systemd/vuinputd.service index 5951795..a34e9c2 100644 --- a/vuinputd/systemd/vuinputd.service +++ b/vuinputd/systemd/vuinputd.service @@ -4,7 +4,9 @@ After=systemd-udevd.service Requires=systemd-udevd.service [Service] -ExecStart=/usr/local/bin/vuinputd +# major 120 is reserved for local/experimental use. I picked minor 414795 with the use +# of a random number generator to omit conflicts. +ExecStart=/usr/local/bin/vuinputd --major 120 --minor 414795 Restart=on-failure # Needs CAP_SYS_ADMIN for CUSE + /dev/uinput (I am still missing a capability for the correct working mode) From 0183da2477e37d436a3e80d23ba2b8e9981f78fa Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 15 Dec 2025 22:38:29 +0000 Subject: [PATCH 020/108] Now a separate instance of vuinputd with its own device node runs as part of the integration tests. --- vuinputd-tests/Cargo.toml | 3 +- vuinputd-tests/src/lib.rs | 5 ++ vuinputd-tests/src/run_vuinputd.rs | 63 +++++++++++++++++++++++ vuinputd-tests/tests/integration_tests.rs | 6 ++- 4 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 vuinputd-tests/src/run_vuinputd.rs diff --git a/vuinputd-tests/Cargo.toml b/vuinputd-tests/Cargo.toml index 70ae4bc..0bdd7b3 100644 --- a/vuinputd-tests/Cargo.toml +++ b/vuinputd-tests/Cargo.toml @@ -11,9 +11,10 @@ name = "bwrap-ipc" [dependencies] uinput-ioctls = { path = "../uinput-ioctls" } -nix = { version = "0.30", features = ["ioctl","socket"] } # ioctl & libc bindings +nix = { version = "0.30", features = ["ioctl","socket","signal"] } # ioctl & libc bindings libc = "0.2" # raw system calls libudev = "0.3" # enumerate-udev +vuinputd = { path = "../vuinputd" } [features] requires-root = [] diff --git a/vuinputd-tests/src/lib.rs b/vuinputd-tests/src/lib.rs index 238911a..5ca94c5 100644 --- a/vuinputd-tests/src/lib.rs +++ b/vuinputd-tests/src/lib.rs @@ -1 +1,6 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + pub mod bwrap; +pub mod run_vuinputd; diff --git a/vuinputd-tests/src/run_vuinputd.rs b/vuinputd-tests/src/run_vuinputd.rs new file mode 100644 index 0000000..be2ea99 --- /dev/null +++ b/vuinputd-tests/src/run_vuinputd.rs @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::{ + process::{Child, Command}, + sync::OnceLock, + time::Duration, + thread, +}; + +use nix::sys::signal::{self,Signal}; +use nix::unistd::Pid; + +/// Global singleton +static VUINPUTD: OnceLock = OnceLock::new(); + +pub fn ensure_vuinputd_running() { + VUINPUTD.get_or_init(|| { + VuinputdGuard::start() + }); +} + +struct VuinputdGuard { + child: Child, +} + +impl VuinputdGuard { + fn start() -> Self { + println!("Executing vuinputd located via cargo run"); + let child = Command::new("cargo") + .args(["run", "-p", "vuinputd", "--","--major","120","--minor","414796","--devname","vuinputd-test"]) + // adjust args/env if needed + .spawn() + .expect("failed to start vuinputd"); + + // Optional: give it time to create /dev/vuinput + thread::sleep(Duration::from_millis(300)); + + Self { child } + } +} + +impl Drop for VuinputdGuard { + fn drop(&mut self) { + let pid = Pid::from_raw(self.child.id() as i32); + + // First: SIGTERM + let _ = signal::kill(pid, Signal::SIGTERM); + + // Wait a bit + for _ in 0..10 { + if let Ok(Some(_)) = self.child.try_wait() { + return; + } + thread::sleep(Duration::from_millis(100)); + } + + // Still alive → SIGKILL + let _ = signal::kill(pid, Signal::SIGKILL); + let _ = self.child.wait(); + } +} diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index 915136d..0c95947 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -4,6 +4,7 @@ use std::{process::Command, time::Duration}; use vuinputd_tests::bwrap; +use vuinputd_tests::run_vuinputd; #[cfg(all(feature = "requires-root", feature = "requires-bwrap"))] #[test] @@ -97,7 +98,8 @@ fn test_keyboard_in_container_with_uinput() { #[cfg(all(feature = "requires-root", feature = "requires-uinput", feature = "requires-bwrap"))] #[test] fn test_keyboard_in_container_with_vuinput() { - println!("Note that vuinputd needs to run for this test"); + run_vuinputd::ensure_vuinputd_running(); + let keyboard_in_container = env!("CARGO_BIN_EXE_keyboard-in-container"); let out = bwrap::BwrapBuilder::new() @@ -108,7 +110,7 @@ fn test_keyboard_in_container_with_vuinput() { .tmpfs("/dev") // run needs to be writable for the udev devices .tmpfs("/run") - .dev_bind("/dev/vuinput", "/dev/uinput") + .dev_bind("/dev/vuinput-test", "/dev/uinput") .die_with_parent() .command(keyboard_in_container,&[]) .run() From 97115609ede3247ebe53ed38d52c30ec6b8ba40a Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 16 Dec 2025 14:45:12 +0000 Subject: [PATCH 021/108] Name refactorings --- docs/TESTS.md | 2 +- vuinputd-tests/Cargo.toml | 4 ++-- ...board-in-container.rs => test-keyboard.rs} | 0 vuinputd-tests/tests/integration_tests.rs | 22 +++++++++---------- 4 files changed, 14 insertions(+), 14 deletions(-) rename vuinputd-tests/src/bin/{keyboard-in-container.rs => test-keyboard.rs} (100%) diff --git a/docs/TESTS.md b/docs/TESTS.md index 1e9a6c3..9b403c7 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -5,7 +5,7 @@ Install bubblewrap: `apt-get install bubblewrap`. -Run with `cargo test -p vuinputd-tests --features "requires-root requires-uinput requires-bwrap"`. +Run with `cargo test -p vuinputd-tests --features "requires-privileges requires-uinput requires-bwrap"`. ## Manual end-to-end tests diff --git a/vuinputd-tests/Cargo.toml b/vuinputd-tests/Cargo.toml index 0bdd7b3..5207d3a 100644 --- a/vuinputd-tests/Cargo.toml +++ b/vuinputd-tests/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [[bin]] -name = "keyboard-in-container" +name = "test-keyboard" [[bin]] name = "bwrap-ipc" @@ -17,6 +17,6 @@ libudev = "0.3" # enumerate-udev vuinputd = { path = "../vuinputd" } [features] -requires-root = [] +requires-privileges = [] requires-uinput = [] requires-bwrap = [] \ No newline at end of file diff --git a/vuinputd-tests/src/bin/keyboard-in-container.rs b/vuinputd-tests/src/bin/test-keyboard.rs similarity index 100% rename from vuinputd-tests/src/bin/keyboard-in-container.rs rename to vuinputd-tests/src/bin/test-keyboard.rs diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index 0c95947..00414d2 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -6,7 +6,7 @@ use std::{process::Command, time::Duration}; use vuinputd_tests::bwrap; use vuinputd_tests::run_vuinputd; -#[cfg(all(feature = "requires-root", feature = "requires-bwrap"))] +#[cfg(all(feature = "requires-privileges", feature = "requires-bwrap"))] #[test] fn test_bwrap_simple() { let out = bwrap::BwrapBuilder::new() @@ -23,7 +23,7 @@ fn test_bwrap_simple() { println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); } -#[cfg(all(feature = "requires-root", feature = "requires-bwrap"))] +#[cfg(all(feature = "requires-privileges", feature = "requires-bwrap"))] #[test] fn test_bwrap_ipc() { let bwrap_ipc = env!("CARGO_BIN_EXE_bwrap-ipc"); @@ -60,12 +60,12 @@ fn test_bwrap_ipc() { } -#[cfg(all(feature = "requires-root", feature = "requires-uinput"))] +#[cfg(all(feature = "requires-privileges", feature = "requires-uinput"))] #[test] fn test_keyboard_on_host() { - let keyboard_in_container = env!("CARGO_BIN_EXE_keyboard-in-container"); + let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); - let status = Command::new(keyboard_in_container) + let status = Command::new(test_keyboard) .status() .expect("failed to launch keyboard-in-container"); @@ -73,10 +73,10 @@ fn test_keyboard_on_host() { } -#[cfg(all(feature = "requires-root", feature = "requires-uinput", feature = "requires-bwrap"))] +#[cfg(all(feature = "requires-privileges", feature = "requires-uinput", feature = "requires-bwrap"))] #[test] fn test_keyboard_in_container_with_uinput() { - let keyboard_in_container = env!("CARGO_BIN_EXE_keyboard-in-container"); + let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); let out = bwrap::BwrapBuilder::new() .unshare_net() @@ -84,7 +84,7 @@ fn test_keyboard_in_container_with_uinput() { .tmpfs("/tmp") .dev_bind("/dev/uinput", "/dev/uinput") .die_with_parent() - .command(keyboard_in_container,&[]) + .command(test_keyboard,&[]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); @@ -95,12 +95,12 @@ fn test_keyboard_in_container_with_uinput() { assert!(out.status.success()); } -#[cfg(all(feature = "requires-root", feature = "requires-uinput", feature = "requires-bwrap"))] +#[cfg(all(feature = "requires-privileges", feature = "requires-uinput", feature = "requires-bwrap"))] #[test] fn test_keyboard_in_container_with_vuinput() { run_vuinputd::ensure_vuinputd_running(); - let keyboard_in_container = env!("CARGO_BIN_EXE_keyboard-in-container"); + let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); let out = bwrap::BwrapBuilder::new() .unshare_net() @@ -112,7 +112,7 @@ fn test_keyboard_in_container_with_vuinput() { .tmpfs("/run") .dev_bind("/dev/vuinput-test", "/dev/uinput") .die_with_parent() - .command(keyboard_in_container,&[]) + .command(test_keyboard,&[]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); From e4501ce795a49e9ef6f395ebf09b4dd1bcab2346 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 17 Dec 2025 00:02:50 +0000 Subject: [PATCH 022/108] Extend integration tests with some nice features as event logging. Not everything working, yet. --- vuinputd-tests/Cargo.toml | 3 + vuinputd-tests/src/bin/bwrap-ipc.rs | 4 + vuinputd-tests/src/bin/test-keyboard.rs | 365 ++++++++++++++-------- vuinputd-tests/src/lib.rs | 1 + vuinputd-tests/src/test_log.rs | 28 ++ vuinputd-tests/tests/integration_tests.rs | 20 ++ 6 files changed, 287 insertions(+), 134 deletions(-) create mode 100644 vuinputd-tests/src/test_log.rs diff --git a/vuinputd-tests/Cargo.toml b/vuinputd-tests/Cargo.toml index 5207d3a..e6da9c4 100644 --- a/vuinputd-tests/Cargo.toml +++ b/vuinputd-tests/Cargo.toml @@ -15,6 +15,9 @@ nix = { version = "0.30", features = ["ioctl","socket","signal"] } # ioctl & lib libc = "0.2" # raw system calls libudev = "0.3" # enumerate-udev vuinputd = { path = "../vuinputd" } +clap = { version = "4", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" [features] requires-privileges = [] diff --git a/vuinputd-tests/src/bin/bwrap-ipc.rs b/vuinputd-tests/src/bin/bwrap-ipc.rs index a965c5e..16d33ca 100644 --- a/vuinputd-tests/src/bin/bwrap-ipc.rs +++ b/vuinputd-tests/src/bin/bwrap-ipc.rs @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + use core::panic; use std::{time::Duration}; diff --git a/vuinputd-tests/src/bin/test-keyboard.rs b/vuinputd-tests/src/bin/test-keyboard.rs index 43c1908..111e169 100644 --- a/vuinputd-tests/src/bin/test-keyboard.rs +++ b/vuinputd-tests/src/bin/test-keyboard.rs @@ -2,11 +2,15 @@ // // Author: Johannes Leupolz -use libc::uinput_setup; +use clap::Parser; +use libc::{CLOCK_MONOTONIC, input_event, timespec, uinput_setup}; use libc::{c_int, close, open, write, O_NONBLOCK, O_WRONLY}; +use vuinputd_tests::test_log::{LoggedInputEvent, TestLog}; use std::ffi::{CStr, CString}; -use std::io; -use std::mem::{size_of, zeroed}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, ErrorKind}; +use std::mem::{self, size_of, zeroed}; +use std::os::fd::AsRawFd; use std::os::raw::{c_char, c_void}; use std::ptr; use std::thread::sleep; @@ -14,128 +18,130 @@ use std::time::Duration; pub use uinput_ioctls::*; // Constants (same numeric values as in linux headers) -const EV_SYN: i32 = 0x00; -const EV_KEY: i32 = 0x01; -const SYN_REPORT: i32 = 0; +const EV_SYN: u16 = 0x00; +const EV_KEY: u16 = 0x01; +const SYN_REPORT: u16 = 0; const BUS_USB: u16 = 0x03; /// Key codes. Those are used by udev to recognize a device as a keyboard. -const KEY_ESC: i32 = 1; -const KEY_1: i32 = 2; -const KEY_2: i32 = 3; -const KEY_3: i32 = 4; -const KEY_4: i32 = 5; -const KEY_5: i32 = 6; -const KEY_6: i32 = 7; -const KEY_7: i32 = 8; -const KEY_8: i32 = 9; -const KEY_9: i32 = 10; -const KEY_0: i32 = 11; -const KEY_MINUS: i32 = 12; -const KEY_EQUAL: i32 = 13; -const KEY_BACKSPACE: i32 = 14; -const KEY_TAB: i32 = 15; -const KEY_Q: i32 = 16; -const KEY_W: i32 = 17; -const KEY_E: i32 = 18; -const KEY_R: i32 = 19; -const KEY_T: i32 = 20; -const KEY_Y: i32 = 21; -const KEY_U: i32 = 22; -const KEY_I: i32 = 23; -const KEY_O: i32 = 24; -const KEY_P: i32 = 25; -const KEY_LEFTBRACE: i32 = 26; -const KEY_RIGHTBRACE: i32 = 27; -const KEY_ENTER: i32 = 28; -const KEY_LEFTCTRL: i32 = 29; -const KEY_A: i32 = 30; -const KEY_S: i32 = 31; +const KEY_ESC: u16 = 1; +const KEY_1: u16 = 2; +const KEY_2: u16 = 3; +const KEY_3: u16 = 4; +const KEY_4: u16 = 5; +const KEY_5: u16 = 6; +const KEY_6: u16 = 7; +const KEY_7: u16 = 8; +const KEY_8: u16 = 9; +const KEY_9: u16 = 10; +const KEY_0: u16 = 11; +const KEY_MINUS: u16 = 12; +const KEY_EQUAL: u16 = 13; +const KEY_BACKSPACE: u16 = 14; +const KEY_TAB: u16 = 15; +const KEY_Q: u16 = 16; +const KEY_W: u16 = 17; +const KEY_E: u16 = 18; +const KEY_R: u16 = 19; +const KEY_T: u16 = 20; +const KEY_Y: u16 = 21; +const KEY_U: u16 = 22; +const KEY_I: u16 = 23; +const KEY_O: u16 = 24; +const KEY_P: u16 = 25; +const KEY_LEFTBRACE: u16 = 26; +const KEY_RIGHTBRACE: u16 = 27; +const KEY_ENTER: u16 = 28; +const KEY_LEFTCTRL: u16 = 29; +const KEY_A: u16 = 30; +const KEY_S: u16 = 31; /// Space and other common keys -const KEY_D: i32 = 32; -const KEY_F: i32 = 33; -const KEY_G: i32 = 34; -const KEY_H: i32 = 35; -const KEY_J: i32 = 36; -const KEY_K: i32 = 37; -const KEY_L: i32 = 38; -const KEY_SEMICOLON: i32 = 39; -const KEY_APOSTROPHE: i32 = 40; -const KEY_GRAVE: i32 = 41; -const KEY_LEFTSHIFT: i32 = 42; -const KEY_BACKSLASH: i32 = 43; -const KEY_Z: i32 = 44; -const KEY_X: i32 = 45; -const KEY_C: i32 = 46; -const KEY_V: i32 = 47; -const KEY_B: i32 = 48; -const KEY_N: i32 = 49; -const KEY_M: i32 = 50; -const KEY_COMMA: i32 = 51; -const KEY_DOT: i32 = 52; -const KEY_SLASH: i32 = 53; -const KEY_RIGHTSHIFT: i32 = 54; -const KEY_KPASTERISK: i32 = 55; -const KEY_LEFTALT: i32 = 56; -const KEY_SPACE: i32 = 57; -const KEY_CAPSLOCK: i32 = 58; +const KEY_D: u16 = 32; +const KEY_F: u16 = 33; +const KEY_G: u16 = 34; +const KEY_H: u16 = 35; +const KEY_J: u16 = 36; +const KEY_K: u16 = 37; +const KEY_L: u16 = 38; +const KEY_SEMICOLON: u16 = 39; +const KEY_APOSTROPHE: u16 = 40; +const KEY_GRAVE: u16 = 41; +const KEY_LEFTSHIFT: u16 = 42; +const KEY_BACKSLASH: u16 = 43; +const KEY_Z: u16 = 44; +const KEY_X: u16 = 45; +const KEY_C: u16 = 46; +const KEY_V: u16 = 47; +const KEY_B: u16 = 48; +const KEY_N: u16 = 49; +const KEY_M: u16 = 50; +const KEY_COMMA: u16 = 51; +const KEY_DOT: u16 = 52; +const KEY_SLASH: u16 = 53; +const KEY_RIGHTSHIFT: u16 = 54; +const KEY_KPASTERISK: u16 = 55; +const KEY_LEFTALT: u16 = 56; +const KEY_SPACE: u16 = 57; +const KEY_CAPSLOCK: u16 = 58; /// Function keys -const KEY_F1: i32 = 59; -const KEY_F2: i32 = 60; -const KEY_F3: i32 = 61; -const KEY_F4: i32 = 62; -const KEY_F5: i32 = 63; -const KEY_F6: i32 = 64; -const KEY_F7: i32 = 65; -const KEY_F8: i32 = 66; -const KEY_F9: i32 = 67; -const KEY_F10: i32 = 68; -const KEY_NUMLOCK: i32 = 69; -const KEY_SCROLLLOCK: i32 = 70; -const KEY_KP7: i32 = 71; -const KEY_KP8: i32 = 72; -const KEY_KP9: i32 = 73; -const KEY_KPMINUS: i32 = 74; -const KEY_KP4: i32 = 75; -const KEY_KP5: i32 = 76; -const KEY_KP6: i32 = 77; -const KEY_KPPLUS: i32 = 78; -const KEY_KP1: i32 = 79; -const KEY_KP2: i32 = 80; -const KEY_KP3: i32 = 81; -const KEY_KP0: i32 = 82; -const KEY_KPDOT: i32 = 83; +const KEY_F1: u16 = 59; +const KEY_F2: u16 = 60; +const KEY_F3: u16 = 61; +const KEY_F4: u16 = 62; +const KEY_F5: u16 = 63; +const KEY_F6: u16 = 64; +const KEY_F7: u16 = 65; +const KEY_F8: u16 = 66; +const KEY_F9: u16 = 67; +const KEY_F10: u16 = 68; +const KEY_NUMLOCK: u16 = 69; +const KEY_SCROLLLOCK: u16 = 70; +const KEY_KP7: u16 = 71; +const KEY_KP8: u16 = 72; +const KEY_KP9: u16 = 73; +const KEY_KPMINUS: u16 = 74; +const KEY_KP4: u16 = 75; +const KEY_KP5: u16 = 76; +const KEY_KP6: u16 = 77; +const KEY_KPPLUS: u16 = 78; +const KEY_KP1: u16 = 79; +const KEY_KP2: u16 = 80; +const KEY_KP3: u16 = 81; +const KEY_KP0: u16 = 82; +const KEY_KPDOT: u16 = 83; /// Arrow keys and navigation -const KEY_ZENKAKUHANKAKU: i32 = 85; -const KEY_102ND: i32 = 86; -const KEY_F11: i32 = 87; -const KEY_F12: i32 = 88; -const KEY_RO: i32 = 89; -const KEY_KATAKANA: i32 = 90; -const KEY_HIRAGANA: i32 = 91; -const KEY_HENKAN: i32 = 92; -const KEY_KATAKANAHIRAGANA: i32 = 93; -const KEY_MUHENKAN: i32 = 94; -const KEY_KPJPCOMMA: i32 = 95; -const KEY_KPENTER: i32 = 96; -const KEY_RIGHTCTRL: i32 = 97; -const KEY_KPSLASH: i32 = 98; -const KEY_SYSRQ: i32 = 99; -const KEY_RIGHTALT: i32 = 100; -const KEY_LINEFEED: i32 = 101; -const KEY_HOME: i32 = 102; -const KEY_UP: i32 = 103; -const KEY_PAGEUP: i32 = 104; -const KEY_LEFT: i32 = 105; -const KEY_RIGHT: i32 = 106; -const KEY_END: i32 = 107; -const KEY_DOWN: i32 = 108; -const KEY_PAGEDOWN: i32 = 109; -const KEY_INSERT: i32 = 110; -const KEY_DELETE: i32 = 111; +const KEY_ZENKAKUHANKAKU: u16 = 85; +const KEY_102ND: u16 = 86; +const KEY_F11: u16 = 87; +const KEY_F12: u16 = 88; +const KEY_RO: u16 = 89; +const KEY_KATAKANA: u16 = 90; +const KEY_HIRAGANA: u16 = 91; +const KEY_HENKAN: u16 = 92; +const KEY_KATAKANAHIRAGANA: u16 = 93; +const KEY_MUHENKAN: u16 = 94; +const KEY_KPJPCOMMA: u16 = 95; +const KEY_KPENTER: u16 = 96; +const KEY_RIGHTCTRL: u16 = 97; +const KEY_KPSLASH: u16 = 98; +const KEY_SYSRQ: u16 = 99; +const KEY_RIGHTALT: u16 = 100; +const KEY_LINEFEED: u16 = 101; +const KEY_HOME: u16 = 102; +const KEY_UP: u16 = 103; +const KEY_PAGEUP: u16 = 104; +const KEY_LEFT: u16 = 105; +const KEY_RIGHT: u16 = 106; +const KEY_END: u16 = 107; +const KEY_DOWN: u16 = 108; +const KEY_PAGEDOWN: u16 = 109; +const KEY_INSERT: u16 = 110; +const KEY_DELETE: u16 = 111; + +const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; /// Configure a full 101-key standard keyboard unsafe fn set_standard_keyboard_keys(fd: i32) -> Result<(), std::io::Error> { @@ -272,7 +278,20 @@ unsafe fn set_standard_keyboard_keys(fd: i32) -> Result<(), std::io::Error> { Ok(()) } -fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> { + +#[derive(Debug, Parser)] +#[command(author, version, about)] +struct Args { + /// Use IPC + #[arg(long, default_value_t = false)] + ipc: bool, + + /// Device path (with /dev/) + #[arg(long)] + dev_path: Option, +} + +fn emit(fd: c_int, ev_type: u16, code: u16, val: i32) -> io::Result<()> { // libc's input_event struct layout: // struct input_event { // struct timeval time; @@ -288,10 +307,9 @@ fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> { ie.time.tv_sec = 0; ie.time.tv_usec = 0; - // input_event fields: type and code are u16 in C; value is i32 - ie.type_ = ev_type as u16; // note: in libc the field is `type_` - ie.code = code as u16; - ie.value = val as i32; + ie.type_ = ev_type; // note: in libc the field is `type_` + ie.code = code; + ie.value = val; // write the struct to the uinput fd let buf_ptr = &ie as *const libc::input_event as *const c_void; @@ -304,11 +322,76 @@ fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> { Ok(()) } + +fn emit_read_and_log(emit_to: c_int, read_from:&File, ev_type: u16, code: u16, val: i32) -> io::Result { + let (time_sent_sec,time_sent_nsec) = monotonic_time(); + emit(emit_to, ev_type, code, val)?; + let input_event_recv=read_event(&read_from).unwrap(); + let (time_recv_sec,time_recv_nsec) = monotonic_time(); + let duration_nsec =(time_recv_sec-time_sent_sec)*1_000_000+(time_recv_nsec-time_sent_nsec)/1000; + let send_and_receive_match = input_event_recv.type_==ev_type && input_event_recv.code==code && input_event_recv.value==val; + + Ok(LoggedInputEvent { + tv_sec: time_sent_sec, + tv_usec: time_sent_nsec, + duration_nsec: duration_nsec, + type_: ev_type, + code: code, + value: val, + send_and_receive_match: send_and_receive_match + }) +} + + +pub fn fetch_device_node(path: &str) -> io::Result { + println!("Read dir {}",&path); + for entry in fs::read_dir(path)? { + let entry = entry?; // propagate per-entry errors + if let Some(name) = entry.file_name().to_str() { + if name.starts_with("event") { + return Ok(format!("/dev/input/{}", name)); + } + } + } + // If no device is found, return an error + Err(io::Error::new(ErrorKind::NotFound, "no device found")) +} + +pub fn read_event(event_dev : &File) -> io::Result { + + let mut ev: input_event = unsafe { mem::zeroed() };/* + let ret = unsafe { + libc::read( + event_dev.as_raw_fd(), + &mut ev as *mut _ as *mut c_void, + mem::size_of::(), + ) + }; + if ret as usize != mem::size_of::() { + return Err(io::Error::last_os_error()); + }*/ + Ok(ev) +} + +fn monotonic_time() -> (i64,i64) { + let mut ts = timespec { + tv_sec: 0, + tv_nsec: 0, + }; + + unsafe { + libc::clock_gettime(CLOCK_MONOTONIC, &mut ts); + } + (ts.tv_sec ,ts.tv_nsec) +} + + fn main() -> io::Result<()> { // open device - matches: open("/dev/uinput", O_WRONLY | O_NONBLOCK); - let args: Vec = std::env::args().collect(); - let device = match args.len() { - 2 => args[1].clone(), + let args=Args::parse(); + + let device = match args.dev_path { + Some(dev_path) => dev_path, _ => "/dev/uinput".to_string(), }; @@ -374,22 +457,36 @@ fn main() -> io::Result<()> { std::process::exit(1); }); + // Sleep 2 second to allow userspace to detect the device (same as C example) + sleep(Duration::from_secs(2)); + let mut resultbuf: [c_char; 64] = [0; 64]; - ui_get_sysname(fd, &mut resultbuf).unwrap(); - let sysname = CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy(); + ui_get_sysname(fd, resultbuf.as_mut_slice()).unwrap(); + let sysname = format!( + "{}{}", + SYS_INPUT_DIR, + CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() + ); + println!("syspath: {}", sysname); + let devnode = fetch_device_node(&sysname).unwrap_or_else(|e| panic!("failed to fetch device node!: {e}")); + println!("devnode: {}", devnode); + eprintln!("sysname: {}", sysname); - // Sleep 1 second to allow userspace to detect the device (same as C example) - sleep(Duration::from_secs(1)); + let event_device = OpenOptions::new() + .read(true) + .open(&devnode) + .unwrap_or_else(|err| panic!("Could not open event device {}, Error {}",&devnode,err)); // Emit press + syn + release + syn - emit(fd, EV_KEY, KEY_SPACE, 1)?; - emit(fd, EV_SYN, SYN_REPORT, 0)?; - emit(fd, EV_KEY, KEY_SPACE, 0)?; - emit(fd, EV_SYN, SYN_REPORT, 0)?; + let ev1 = emit_read_and_log(fd, &event_device, EV_KEY, KEY_SPACE, 1)?; + let ev2 = emit_read_and_log(fd, &event_device,EV_SYN, SYN_REPORT, 0)?; + let ev3 = emit_read_and_log(fd, &event_device,EV_KEY, KEY_SPACE, 0)?; + let ev4 = emit_read_and_log(fd, &event_device,EV_SYN, SYN_REPORT, 0)?; - // Give userspace time to read events - sleep(Duration::from_secs(2)); + let eventlog = TestLog{events:vec![ev1,ev2,ev3,ev4]}; + let serialized = serde_json::to_string(&eventlog).unwrap(); + println!("Event log: {}",serialized); // Destroy device and close fd ui_dev_destroy(fd).unwrap_or_else(|e| { diff --git a/vuinputd-tests/src/lib.rs b/vuinputd-tests/src/lib.rs index 5ca94c5..9e87813 100644 --- a/vuinputd-tests/src/lib.rs +++ b/vuinputd-tests/src/lib.rs @@ -4,3 +4,4 @@ pub mod bwrap; pub mod run_vuinputd; +pub mod test_log; diff --git a/vuinputd-tests/src/test_log.rs b/vuinputd-tests/src/test_log.rs new file mode 100644 index 0000000..9da3eae --- /dev/null +++ b/vuinputd-tests/src/test_log.rs @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use serde::{Serialize, Deserialize}; + + +#[derive(Serialize, Deserialize, Debug)] +pub struct LoggedInputEvent { + pub tv_sec: i64, + + pub tv_usec: i64, + + pub duration_nsec: i64, + + pub type_: u16, + + pub code: u16, + + pub value: i32, + + pub send_and_receive_match: bool, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct TestLog { + pub events:Vec +} \ No newline at end of file diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index 00414d2..34ac79f 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -60,6 +60,23 @@ fn test_bwrap_ipc() { } +#[cfg(all(feature = "requires-privileges", feature = "requires-bwrap"))] +#[test] +fn test_list_sys_in_container() { + let out = bwrap::BwrapBuilder::new() + .unshare_all() + .ro_bind("/", "/") + .tmpfs("/tmp") + .die_with_parent() + .command("/usr/bin/ls",&["-lh","/sys/devices/virtual/input/input235"]) + .run() + .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); +} + #[cfg(all(feature = "requires-privileges", feature = "requires-uinput"))] #[test] fn test_keyboard_on_host() { @@ -83,6 +100,7 @@ fn test_keyboard_in_container_with_uinput() { .ro_bind("/", "/") .tmpfs("/tmp") .dev_bind("/dev/uinput", "/dev/uinput") + .dev_bind("/dev/input", "/dev/input") .die_with_parent() .command(test_keyboard,&[]) .run() @@ -96,6 +114,7 @@ fn test_keyboard_in_container_with_uinput() { } #[cfg(all(feature = "requires-privileges", feature = "requires-uinput", feature = "requires-bwrap"))] +#[ignore] #[test] fn test_keyboard_in_container_with_vuinput() { run_vuinputd::ensure_vuinputd_running(); @@ -108,6 +127,7 @@ fn test_keyboard_in_container_with_vuinput() { .tmpfs("/tmp") // dev needs to be writable for the new devices .tmpfs("/dev") + .tmpfs("/dev/input") // run needs to be writable for the udev devices .tmpfs("/run") .dev_bind("/dev/vuinput-test", "/dev/uinput") From 364b86a1902d9ba9a1c5facb1ca10ae1d67db949 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 17 Dec 2025 11:43:58 +0000 Subject: [PATCH 023/108] Fix integration test to ensure SYN is sent before we read. --- vuinputd-tests/src/bin/test-keyboard.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/vuinputd-tests/src/bin/test-keyboard.rs b/vuinputd-tests/src/bin/test-keyboard.rs index 111e169..1891d31 100644 --- a/vuinputd-tests/src/bin/test-keyboard.rs +++ b/vuinputd-tests/src/bin/test-keyboard.rs @@ -315,7 +315,9 @@ fn emit(fd: c_int, ev_type: u16, code: u16, val: i32) -> io::Result<()> { let buf_ptr = &ie as *const libc::input_event as *const c_void; let bytes = size_of::(); + //println!("write to {} {} {} {} ",fd,ev_type,code,val); let written = unsafe { write(fd, buf_ptr, bytes) }; + //println!("written"); if written as usize != bytes { return Err(io::Error::last_os_error()); } @@ -323,10 +325,13 @@ fn emit(fd: c_int, ev_type: u16, code: u16, val: i32) -> io::Result<()> { } +// Note that before we can read, a SYN needs to be sent. Thus combine it. fn emit_read_and_log(emit_to: c_int, read_from:&File, ev_type: u16, code: u16, val: i32) -> io::Result { let (time_sent_sec,time_sent_nsec) = monotonic_time(); emit(emit_to, ev_type, code, val)?; + emit(emit_to,EV_SYN, SYN_REPORT, 0)?; let input_event_recv=read_event(&read_from).unwrap(); + let _syn_recv=read_event(&read_from).unwrap(); let (time_recv_sec,time_recv_nsec) = monotonic_time(); let duration_nsec =(time_recv_sec-time_sent_sec)*1_000_000+(time_recv_nsec-time_sent_nsec)/1000; let send_and_receive_match = input_event_recv.type_==ev_type && input_event_recv.code==code && input_event_recv.value==val; @@ -359,7 +364,7 @@ pub fn fetch_device_node(path: &str) -> io::Result { pub fn read_event(event_dev : &File) -> io::Result { - let mut ev: input_event = unsafe { mem::zeroed() };/* + let mut ev: input_event = unsafe { mem::zeroed() }; let ret = unsafe { libc::read( event_dev.as_raw_fd(), @@ -369,7 +374,7 @@ pub fn read_event(event_dev : &File) -> io::Result { }; if ret as usize != mem::size_of::() { return Err(io::Error::last_os_error()); - }*/ + } Ok(ev) } @@ -457,9 +462,6 @@ fn main() -> io::Result<()> { std::process::exit(1); }); - // Sleep 2 second to allow userspace to detect the device (same as C example) - sleep(Duration::from_secs(2)); - let mut resultbuf: [c_char; 64] = [0; 64]; ui_get_sysname(fd, resultbuf.as_mut_slice()).unwrap(); let sysname = format!( @@ -473,18 +475,19 @@ fn main() -> io::Result<()> { eprintln!("sysname: {}", sysname); + // Sleep 12 second to allow userspace to also listen to the device in case we need to debug + // Comment this out: sleep(Duration::from_secs(12)); + let event_device = OpenOptions::new() .read(true) .open(&devnode) .unwrap_or_else(|err| panic!("Could not open event device {}, Error {}",&devnode,err)); - // Emit press + syn + release + syn + // Emit (press + syn) + (release + syn) let ev1 = emit_read_and_log(fd, &event_device, EV_KEY, KEY_SPACE, 1)?; - let ev2 = emit_read_and_log(fd, &event_device,EV_SYN, SYN_REPORT, 0)?; - let ev3 = emit_read_and_log(fd, &event_device,EV_KEY, KEY_SPACE, 0)?; - let ev4 = emit_read_and_log(fd, &event_device,EV_SYN, SYN_REPORT, 0)?; + let ev2 = emit_read_and_log(fd, &event_device,EV_KEY, KEY_SPACE, 0)?; - let eventlog = TestLog{events:vec![ev1,ev2,ev3,ev4]}; + let eventlog = TestLog{events:vec![ev1,ev2]}; let serialized = serde_json::to_string(&eventlog).unwrap(); println!("Event log: {}",serialized); From d86a90e2b1a203e8d76301fc70836c43391fb041 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 17 Dec 2025 21:12:53 +0000 Subject: [PATCH 024/108] Some refactorings to prepare the switch from calling a lambda function from the forked process to reinvoking the whole process with the job description as json --- vuinputd-tests/src/bin/test-keyboard.rs | 2 - vuinputd/Cargo.toml | 2 + vuinputd/src/actions/action.rs | 27 +++++++++ vuinputd/src/actions/handle_action.rs | 30 ++++++++++ .../{jobs => actions}/mknod_input_device.rs | 0 vuinputd/src/actions/mod.rs | 9 +++ .../src/{jobs => actions}/netlink_message.rs | 0 .../src/{jobs => actions}/runtime_data.rs | 0 vuinputd/src/job_engine/closure_job.rs | 60 ++++++++++--------- vuinputd/src/jobs/inject_in_container_job.rs | 9 +-- vuinputd/src/jobs/mod.rs | 3 - .../src/jobs/remove_from_container_job.rs | 7 +-- vuinputd/src/main.rs | 22 +++++++ 13 files changed, 125 insertions(+), 46 deletions(-) create mode 100644 vuinputd/src/actions/action.rs create mode 100644 vuinputd/src/actions/handle_action.rs rename vuinputd/src/{jobs => actions}/mknod_input_device.rs (100%) create mode 100644 vuinputd/src/actions/mod.rs rename vuinputd/src/{jobs => actions}/netlink_message.rs (100%) rename vuinputd/src/{jobs => actions}/runtime_data.rs (100%) diff --git a/vuinputd-tests/src/bin/test-keyboard.rs b/vuinputd-tests/src/bin/test-keyboard.rs index 1891d31..18eda1f 100644 --- a/vuinputd-tests/src/bin/test-keyboard.rs +++ b/vuinputd-tests/src/bin/test-keyboard.rs @@ -13,8 +13,6 @@ use std::mem::{self, size_of, zeroed}; use std::os::fd::AsRawFd; use std::os::raw::{c_char, c_void}; use std::ptr; -use std::thread::sleep; -use std::time::Duration; pub use uinput_ioctls::*; // Constants (same numeric values as in linux headers) diff --git a/vuinputd/Cargo.toml b/vuinputd/Cargo.toml index 78c488b..b182b69 100644 --- a/vuinputd/Cargo.toml +++ b/vuinputd/Cargo.toml @@ -27,3 +27,5 @@ futures = "0.3.31" async-io = "2.6.0" anyhow = "1.0.100" clap = { version = "4", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/vuinputd/src/actions/action.rs b/vuinputd/src/actions/action.rs new file mode 100644 index 0000000..fa57f01 --- /dev/null +++ b/vuinputd/src/actions/action.rs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use serde::{Serialize, Deserialize}; + +#[derive(Serialize,Deserialize)] +#[serde(tag = "action")] +pub enum Action { + #[serde(rename = "mknod-device")] + MknodDevice { + path: String, + major: u32, + minor: u32, + mode: u32, + }, + + #[serde(rename = "announce-via-netlink")] + AnnounceViaNetlink { + message: String, + }, + + #[serde(rename = "remove-device")] + RemoveDevice { + path: String, + }, +} diff --git a/vuinputd/src/actions/handle_action.rs b/vuinputd/src/actions/handle_action.rs new file mode 100644 index 0000000..aea5ec3 --- /dev/null +++ b/vuinputd/src/actions/handle_action.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use super::action::Action; + +pub fn handle_cli_action(json: String) -> i32 { + let action: Action = serde_json::from_str(&json) + .expect("invalid action JSON"); + handle_action(action).unwrap_or_else(|err| + { + panic!("Error handling action: {}",err); + }); + 0 +} + +fn handle_action(action: Action) -> Result<(), String> { + + match action { + Action::MknodDevice { .. } => { + Ok(()) + } + Action::AnnounceViaNetlink { .. } => { + Ok(()) + } + Action::RemoveDevice { .. } => { + Ok(()) + } + } +} diff --git a/vuinputd/src/jobs/mknod_input_device.rs b/vuinputd/src/actions/mknod_input_device.rs similarity index 100% rename from vuinputd/src/jobs/mknod_input_device.rs rename to vuinputd/src/actions/mknod_input_device.rs diff --git a/vuinputd/src/actions/mod.rs b/vuinputd/src/actions/mod.rs new file mode 100644 index 0000000..af3b886 --- /dev/null +++ b/vuinputd/src/actions/mod.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +pub mod action; +pub mod handle_action; +pub mod mknod_input_device; +pub mod netlink_message; +pub mod runtime_data; diff --git a/vuinputd/src/jobs/netlink_message.rs b/vuinputd/src/actions/netlink_message.rs similarity index 100% rename from vuinputd/src/jobs/netlink_message.rs rename to vuinputd/src/actions/netlink_message.rs diff --git a/vuinputd/src/jobs/runtime_data.rs b/vuinputd/src/actions/runtime_data.rs similarity index 100% rename from vuinputd/src/jobs/runtime_data.rs rename to vuinputd/src/actions/runtime_data.rs diff --git a/vuinputd/src/job_engine/closure_job.rs b/vuinputd/src/job_engine/closure_job.rs index 5e0fb65..810cbe9 100644 --- a/vuinputd/src/job_engine/closure_job.rs +++ b/vuinputd/src/job_engine/closure_job.rs @@ -5,7 +5,7 @@ use std::future::Future; use std::pin::Pin; -use crate::job_engine::job::{Dispatcher, Job, JobTarget}; +use super::job::{Job, JobTarget}; pub struct ClosureJob { desc: String, @@ -55,34 +55,40 @@ impl Job for ClosureJob { } } -/// Example usage -#[test] -pub fn example() { - let mut dispatcher = Dispatcher::new(); +#[cfg(test)] +mod tests { + use super::super::job::{Dispatcher, JobTarget}; + use super::ClosureJob; - // Send a Host job - dispatcher.dispatch(Box::new(ClosureJob::new( - "Host maintenance", - JobTarget::Host, - false, - Box::new(|job: &ClosureJob| { - let target = job.target.clone(); - Box::pin(async move { - println!("Running host job on {:?}", target); - }) - }), - ))); + /// Example usage + #[test] + pub fn example() { + let mut dispatcher = Dispatcher::new(); - // Sending a Container job works the same - // dispatcher.dispatch(Job::new(JobTarget::Container(ns.clone()), "Container task", false, |target| async move { - // println!("Running container job for {:?}", target); - // })); + // Send a Host job + dispatcher.dispatch(Box::new(ClosureJob::new( + "Host maintenance", + JobTarget::Host, + false, + Box::new(|job: &ClosureJob| { + let target = job.target.clone(); + Box::pin(async move { + println!("Running host job on {:?}", target); + }) + }), + ))); - // - // JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(ClosureJob::new("Monitor udev events", JobTarget::BackgroundLoop,false, - // Box::new(move |_target| Box::pin(monitor_udev::udev_monitor_loop(cancel_token.clone())))))); + // Sending a Container job works the same + // dispatcher.dispatch(Job::new(JobTarget::Container(ns.clone()), "Container task", false, |target| async move { + // println!("Running container job for {:?}", target); + // })); - // Allow loops to run briefly before dropping all senders -> graceful shutdown - dispatcher.close(); - dispatcher.wait_until_finished(); + // + // JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(ClosureJob::new("Monitor udev events", JobTarget::BackgroundLoop,false, + // Box::new(move |_target| Box::pin(monitor_udev::udev_monitor_loop(cancel_token.clone())))))); + + // Allow loops to run briefly before dropping all senders -> graceful shutdown + dispatcher.close(); + dispatcher.wait_until_finished(); + } } diff --git a/vuinputd/src/jobs/inject_in_container_job.rs b/vuinputd/src/jobs/inject_in_container_job.rs index e78cad1..e557a8d 100644 --- a/vuinputd/src/jobs/inject_in_container_job.rs +++ b/vuinputd/src/jobs/inject_in_container_job.rs @@ -14,14 +14,7 @@ use async_io::Timer; use log::debug; use crate::{ - job_engine::job::{Job, JobTarget}, - jobs::{ - mknod_input_device::ensure_input_device, - monitor_udev_job::EVENT_STORE, - netlink_message::send_udev_monitor_message_with_properties, - runtime_data::{ensure_udev_structure, read_udev_data, write_udev_data}, - }, - process_tools::{await_process, run_in_net_and_mnt_namespace, Pid, RequestingProcess}, + actions::{mknod_input_device::ensure_input_device, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{ensure_udev_structure, read_udev_data, write_udev_data}}, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, process_tools::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace} }; #[derive(Clone, Debug, Copy, PartialOrd, PartialEq)] diff --git a/vuinputd/src/jobs/mod.rs b/vuinputd/src/jobs/mod.rs index 1580916..4adefe9 100644 --- a/vuinputd/src/jobs/mod.rs +++ b/vuinputd/src/jobs/mod.rs @@ -3,8 +3,5 @@ // Author: Johannes Leupolz pub mod inject_in_container_job; -pub mod mknod_input_device; pub mod monitor_udev_job; -pub mod netlink_message; pub mod remove_from_container_job; -pub mod runtime_data; diff --git a/vuinputd/src/jobs/remove_from_container_job.rs b/vuinputd/src/jobs/remove_from_container_job.rs index e4b0077..c299047 100644 --- a/vuinputd/src/jobs/remove_from_container_job.rs +++ b/vuinputd/src/jobs/remove_from_container_job.rs @@ -11,12 +11,7 @@ use std::{ use log::debug; use crate::{ - job_engine::job::{Job, JobTarget}, - jobs::{ - mknod_input_device::remove_input_device, monitor_udev_job::EVENT_STORE, - netlink_message::send_udev_monitor_message_with_properties, runtime_data::delete_udev_data, - }, - process_tools::{await_process, run_in_net_and_mnt_namespace, Pid, RequestingProcess}, + actions::{mknod_input_device::remove_input_device, netlink_message::send_udev_monitor_message_with_properties, runtime_data::delete_udev_data}, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, process_tools::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace} }; #[derive(Clone, Debug, Copy, PartialOrd, PartialEq)] diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 544fb39..c73b723 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -37,6 +37,8 @@ pub mod job_engine; use crate::job_engine::{job::*, JOB_DISPATCHER}; use crate::process_tools::*; +pub mod actions; + pub mod jobs; use clap::Parser; @@ -58,9 +60,24 @@ struct Args { /// Device name (without /dev/) #[arg(long)] devname: Option, + + /// Action to execute (JSON encoded). Note that this excludes all other options. + #[arg(long, value_name = "JSON")] + pub action: 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) => {}, + _ => { + return Err( + "--action must not be used in combination with any other argument".into(), + ); + } + } + // major/minor must appear together match (&args.major, &args.minor) { (Some(_), Some(_)) | (None, None) => {} @@ -97,6 +114,11 @@ fn main() -> std::io::Result<()> { std::process::exit(2); } + if args.action.is_some() { + let error_code=actions::handle_action::handle_cli_action(args.action.unwrap()); + std::process::exit(error_code); + } + initialize_vuinput_state(); VUINPUT_COUNTER.set(AtomicU64::new(3)).expect( From 01417e69340695eff9d15c25dd7e306733f47d79 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 17 Dec 2025 21:30:33 +0000 Subject: [PATCH 025/108] When integration-test get SIGKILLed, also SIGKILL the vuinputd-process --- vuinputd-tests/src/run_vuinputd.rs | 38 +++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/vuinputd-tests/src/run_vuinputd.rs b/vuinputd-tests/src/run_vuinputd.rs index be2ea99..e5241f4 100644 --- a/vuinputd-tests/src/run_vuinputd.rs +++ b/vuinputd-tests/src/run_vuinputd.rs @@ -3,22 +3,21 @@ // Author: Johannes Leupolz use std::{ + os::unix::process::CommandExt, process::{Child, Command}, sync::OnceLock, - time::Duration, thread, + time::Duration, }; -use nix::sys::signal::{self,Signal}; +use nix::sys::signal::{self, Signal}; use nix::unistd::Pid; /// Global singleton static VUINPUTD: OnceLock = OnceLock::new(); pub fn ensure_vuinputd_running() { - VUINPUTD.get_or_init(|| { - VuinputdGuard::start() - }); + VUINPUTD.get_or_init(|| VuinputdGuard::start()); } struct VuinputdGuard { @@ -28,14 +27,31 @@ struct VuinputdGuard { impl VuinputdGuard { fn start() -> Self { println!("Executing vuinputd located via cargo run"); - let child = Command::new("cargo") - .args(["run", "-p", "vuinputd", "--","--major","120","--minor","414796","--devname","vuinputd-test"]) - // adjust args/env if needed - .spawn() - .expect("failed to start vuinputd"); + let child = unsafe { + Command::new("cargo") + .args([ + "run", + "-p", + "vuinputd", + "--", + "--major", + "120", + "--minor", + "414796", + "--devname", + "vuinputd-test", + ]) + .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") + }; // Optional: give it time to create /dev/vuinput - thread::sleep(Duration::from_millis(300)); + thread::sleep(Duration::from_millis(1000)); Self { child } } From 045807e8480b81944b84bc8b6b37b152ec732e0c Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 17 Dec 2025 21:51:24 +0000 Subject: [PATCH 026/108] cargo fmt --- vuinputd-tests/src/bin/bwrap-ipc.rs | 4 +- vuinputd-tests/src/bin/test-keyboard.rs | 77 +++++++++++-------- vuinputd-tests/src/bwrap.rs | 6 +- vuinputd-tests/src/test_log.rs | 9 +-- vuinputd-tests/tests/integration_tests.rs | 42 ++++++---- vuinputd/src/actions/action.rs | 12 +-- vuinputd/src/actions/handle_action.rs | 25 ++---- vuinputd/src/jobs/inject_in_container_job.rs | 9 ++- .../src/jobs/remove_from_container_job.rs | 8 +- vuinputd/src/main.rs | 31 ++++---- 10 files changed, 118 insertions(+), 105 deletions(-) diff --git a/vuinputd-tests/src/bin/bwrap-ipc.rs b/vuinputd-tests/src/bin/bwrap-ipc.rs index 16d33ca..cd7a509 100644 --- a/vuinputd-tests/src/bin/bwrap-ipc.rs +++ b/vuinputd-tests/src/bin/bwrap-ipc.rs @@ -3,7 +3,7 @@ // Author: Johannes Leupolz use core::panic; -use std::{time::Duration}; +use std::time::Duration; use vuinputd_tests::bwrap::SandboxChildIpc; @@ -21,7 +21,7 @@ fn main() { ipc.send(b"ok").unwrap(); } else { ipc.send(b"nok").unwrap(); - println!("child received {}",incoming_str); + println!("child received {}", incoming_str); panic!("expected ipc message to be 'continue'"); } } diff --git a/vuinputd-tests/src/bin/test-keyboard.rs b/vuinputd-tests/src/bin/test-keyboard.rs index 18eda1f..ed039e2 100644 --- a/vuinputd-tests/src/bin/test-keyboard.rs +++ b/vuinputd-tests/src/bin/test-keyboard.rs @@ -3,9 +3,8 @@ // Author: Johannes Leupolz use clap::Parser; -use libc::{CLOCK_MONOTONIC, input_event, timespec, uinput_setup}; use libc::{c_int, close, open, write, O_NONBLOCK, O_WRONLY}; -use vuinputd_tests::test_log::{LoggedInputEvent, TestLog}; +use libc::{input_event, timespec, uinput_setup, CLOCK_MONOTONIC}; use std::ffi::{CStr, CString}; use std::fs::{self, File, OpenOptions}; use std::io::{self, ErrorKind}; @@ -14,6 +13,7 @@ use std::os::fd::AsRawFd; use std::os::raw::{c_char, c_void}; use std::ptr; pub use uinput_ioctls::*; +use vuinputd_tests::test_log::{LoggedInputEvent, TestLog}; // Constants (same numeric values as in linux headers) const EV_SYN: u16 = 0x00; @@ -276,7 +276,6 @@ unsafe fn set_standard_keyboard_keys(fd: i32) -> Result<(), std::io::Error> { Ok(()) } - #[derive(Debug, Parser)] #[command(author, version, about)] struct Args { @@ -322,17 +321,25 @@ fn emit(fd: c_int, ev_type: u16, code: u16, val: i32) -> io::Result<()> { Ok(()) } - // Note that before we can read, a SYN needs to be sent. Thus combine it. -fn emit_read_and_log(emit_to: c_int, read_from:&File, ev_type: u16, code: u16, val: i32) -> io::Result { - let (time_sent_sec,time_sent_nsec) = monotonic_time(); +fn emit_read_and_log( + emit_to: c_int, + read_from: &File, + ev_type: u16, + code: u16, + val: i32, +) -> io::Result { + let (time_sent_sec, time_sent_nsec) = monotonic_time(); emit(emit_to, ev_type, code, val)?; - emit(emit_to,EV_SYN, SYN_REPORT, 0)?; - let input_event_recv=read_event(&read_from).unwrap(); - let _syn_recv=read_event(&read_from).unwrap(); - let (time_recv_sec,time_recv_nsec) = monotonic_time(); - let duration_nsec =(time_recv_sec-time_sent_sec)*1_000_000+(time_recv_nsec-time_sent_nsec)/1000; - let send_and_receive_match = input_event_recv.type_==ev_type && input_event_recv.code==code && input_event_recv.value==val; + emit(emit_to, EV_SYN, SYN_REPORT, 0)?; + let input_event_recv = read_event(&read_from).unwrap(); + let _syn_recv = read_event(&read_from).unwrap(); + let (time_recv_sec, time_recv_nsec) = monotonic_time(); + let duration_nsec = + (time_recv_sec - time_sent_sec) * 1_000_000 + (time_recv_nsec - time_sent_nsec) / 1000; + let send_and_receive_match = input_event_recv.type_ == ev_type + && input_event_recv.code == code + && input_event_recv.value == val; Ok(LoggedInputEvent { tv_sec: time_sent_sec, @@ -341,13 +348,12 @@ fn emit_read_and_log(emit_to: c_int, read_from:&File, ev_type: u16, code: u16, v type_: ev_type, code: code, value: val, - send_and_receive_match: send_and_receive_match + send_and_receive_match: send_and_receive_match, }) } - pub fn fetch_device_node(path: &str) -> io::Result { - println!("Read dir {}",&path); + println!("Read dir {}", &path); for entry in fs::read_dir(path)? { let entry = entry?; // propagate per-entry errors if let Some(name) = entry.file_name().to_str() { @@ -360,23 +366,22 @@ pub fn fetch_device_node(path: &str) -> io::Result { Err(io::Error::new(ErrorKind::NotFound, "no device found")) } -pub fn read_event(event_dev : &File) -> io::Result { - +pub fn read_event(event_dev: &File) -> io::Result { let mut ev: input_event = unsafe { mem::zeroed() }; let ret = unsafe { - libc::read( - event_dev.as_raw_fd(), - &mut ev as *mut _ as *mut c_void, - mem::size_of::(), - ) - }; + libc::read( + event_dev.as_raw_fd(), + &mut ev as *mut _ as *mut c_void, + mem::size_of::(), + ) + }; if ret as usize != mem::size_of::() { return Err(io::Error::last_os_error()); } Ok(ev) } -fn monotonic_time() -> (i64,i64) { +fn monotonic_time() -> (i64, i64) { let mut ts = timespec { tv_sec: 0, tv_nsec: 0, @@ -385,13 +390,12 @@ fn monotonic_time() -> (i64,i64) { unsafe { libc::clock_gettime(CLOCK_MONOTONIC, &mut ts); } - (ts.tv_sec ,ts.tv_nsec) + (ts.tv_sec, ts.tv_nsec) } - fn main() -> io::Result<()> { // open device - matches: open("/dev/uinput", O_WRONLY | O_NONBLOCK); - let args=Args::parse(); + let args = Args::parse(); let device = match args.dev_path { Some(dev_path) => dev_path, @@ -468,7 +472,8 @@ fn main() -> io::Result<()> { CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() ); println!("syspath: {}", sysname); - let devnode = fetch_device_node(&sysname).unwrap_or_else(|e| panic!("failed to fetch device node!: {e}")); + let devnode = fetch_device_node(&sysname) + .unwrap_or_else(|e| panic!("failed to fetch device node!: {e}")); println!("devnode: {}", devnode); eprintln!("sysname: {}", sysname); @@ -477,17 +482,21 @@ fn main() -> io::Result<()> { // Comment this out: sleep(Duration::from_secs(12)); let event_device = OpenOptions::new() - .read(true) - .open(&devnode) - .unwrap_or_else(|err| panic!("Could not open event device {}, Error {}",&devnode,err)); + .read(true) + .open(&devnode) + .unwrap_or_else(|err| { + panic!("Could not open event device {}, Error {}", &devnode, err) + }); // Emit (press + syn) + (release + syn) let ev1 = emit_read_and_log(fd, &event_device, EV_KEY, KEY_SPACE, 1)?; - let ev2 = emit_read_and_log(fd, &event_device,EV_KEY, KEY_SPACE, 0)?; + let ev2 = emit_read_and_log(fd, &event_device, EV_KEY, KEY_SPACE, 0)?; - let eventlog = TestLog{events:vec![ev1,ev2]}; + let eventlog = TestLog { + events: vec![ev1, ev2], + }; let serialized = serde_json::to_string(&eventlog).unwrap(); - println!("Event log: {}",serialized); + println!("Event log: {}", serialized); // Destroy device and close fd ui_dev_destroy(fd).unwrap_or_else(|e| { diff --git a/vuinputd-tests/src/bwrap.rs b/vuinputd-tests/src/bwrap.rs index 6801d00..17e7065 100644 --- a/vuinputd-tests/src/bwrap.rs +++ b/vuinputd-tests/src/bwrap.rs @@ -119,7 +119,8 @@ impl BwrapBuilder { } pub fn dev_bind(mut self, src: &str, dst: &str) -> Self { - self.args.extend(["--dev-bind".into(), src.into(), dst.into()]); + self.args + .extend(["--dev-bind".into(), src.into(), dst.into()]); self } @@ -164,7 +165,6 @@ impl BwrapBuilder { let mut cmd = Command::new("bwrap"); - if let Some(fd) = self.ipc_child_fd.take() { // give up ownership of ipc_child_fd in host process. let fd = fd.into_raw_fd(); @@ -204,7 +204,7 @@ mod tests { .ro_bind("/", "/") .tmpfs("/tmp") .die_with_parent() - .command("/usr/bin/echo",&[]) + .command("/usr/bin/echo", &[]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); diff --git a/vuinputd-tests/src/test_log.rs b/vuinputd-tests/src/test_log.rs index 9da3eae..de02bea 100644 --- a/vuinputd-tests/src/test_log.rs +++ b/vuinputd-tests/src/test_log.rs @@ -2,13 +2,12 @@ // // Author: Johannes Leupolz -use serde::{Serialize, Deserialize}; - +use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug)] pub struct LoggedInputEvent { pub tv_sec: i64, - + pub tv_usec: i64, pub duration_nsec: i64, @@ -24,5 +23,5 @@ pub struct LoggedInputEvent { #[derive(Serialize, Deserialize, Debug)] pub struct TestLog { - pub events:Vec -} \ No newline at end of file + pub events: Vec, +} diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index 34ac79f..400e667 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -14,7 +14,7 @@ fn test_bwrap_simple() { .ro_bind("/", "/") .tmpfs("/tmp") .die_with_parent() - .command("/usr/bin/echo",&["test","test","test"]) + .command("/usr/bin/echo", &["test", "test", "test"]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); @@ -37,12 +37,12 @@ fn test_bwrap_ipc() { .expect("failed to create IPC"); // Note that builder.run() will block. Thus, the send needs to happen before the child process blocks - // the host process. + // the host process. ipc.send("continue".as_bytes()) .unwrap_or_else(|e| panic!("failed to send data via ipc: {e}")); let out = builder - .command(bwrap_ipc,&[]) + .command(bwrap_ipc, &[]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); @@ -54,12 +54,10 @@ fn test_bwrap_ipc() { let result = result.expect("error receiving input from ipc as host within 5 seconds"); let result_str = - str::from_utf8(&result).expect("message received from ipc is not encoded as utf8"); - println!("host received {}",result_str); - + str::from_utf8(&result).expect("message received from ipc is not encoded as utf8"); + println!("host received {}", result_str); } - #[cfg(all(feature = "requires-privileges", feature = "requires-bwrap"))] #[test] fn test_list_sys_in_container() { @@ -68,7 +66,10 @@ fn test_list_sys_in_container() { .ro_bind("/", "/") .tmpfs("/tmp") .die_with_parent() - .command("/usr/bin/ls",&["-lh","/sys/devices/virtual/input/input235"]) + .command( + "/usr/bin/ls", + &["-lh", "/sys/devices/virtual/input/input235"], + ) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); @@ -89,12 +90,15 @@ fn test_keyboard_on_host() { assert!(status.success()); } - -#[cfg(all(feature = "requires-privileges", feature = "requires-uinput", feature = "requires-bwrap"))] +#[cfg(all( + feature = "requires-privileges", + feature = "requires-uinput", + feature = "requires-bwrap" +))] #[test] fn test_keyboard_in_container_with_uinput() { let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); - + let out = bwrap::BwrapBuilder::new() .unshare_net() .ro_bind("/", "/") @@ -102,7 +106,7 @@ fn test_keyboard_in_container_with_uinput() { .dev_bind("/dev/uinput", "/dev/uinput") .dev_bind("/dev/input", "/dev/input") .die_with_parent() - .command(test_keyboard,&[]) + .command(test_keyboard, &[]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); @@ -113,14 +117,18 @@ fn test_keyboard_in_container_with_uinput() { assert!(out.status.success()); } -#[cfg(all(feature = "requires-privileges", feature = "requires-uinput", feature = "requires-bwrap"))] -#[ignore] +#[cfg(all( + feature = "requires-privileges", + feature = "requires-uinput", + feature = "requires-bwrap" +))] +//#[ignore] #[test] fn test_keyboard_in_container_with_vuinput() { run_vuinputd::ensure_vuinputd_running(); - + let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); - + let out = bwrap::BwrapBuilder::new() .unshare_net() .ro_bind("/", "/") @@ -132,7 +140,7 @@ fn test_keyboard_in_container_with_vuinput() { .tmpfs("/run") .dev_bind("/dev/vuinput-test", "/dev/uinput") .die_with_parent() - .command(test_keyboard,&[]) + .command(test_keyboard, &[]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); diff --git a/vuinputd/src/actions/action.rs b/vuinputd/src/actions/action.rs index fa57f01..6939e82 100644 --- a/vuinputd/src/actions/action.rs +++ b/vuinputd/src/actions/action.rs @@ -2,9 +2,9 @@ // // Author: Johannes Leupolz -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; -#[derive(Serialize,Deserialize)] +#[derive(Serialize, Deserialize)] #[serde(tag = "action")] pub enum Action { #[serde(rename = "mknod-device")] @@ -16,12 +16,8 @@ pub enum Action { }, #[serde(rename = "announce-via-netlink")] - AnnounceViaNetlink { - message: String, - }, + AnnounceViaNetlink { message: String }, #[serde(rename = "remove-device")] - RemoveDevice { - path: String, - }, + RemoveDevice { path: String }, } diff --git a/vuinputd/src/actions/handle_action.rs b/vuinputd/src/actions/handle_action.rs index aea5ec3..10f12e2 100644 --- a/vuinputd/src/actions/handle_action.rs +++ b/vuinputd/src/actions/handle_action.rs @@ -5,26 +5,17 @@ use super::action::Action; pub fn handle_cli_action(json: String) -> i32 { - let action: Action = serde_json::from_str(&json) - .expect("invalid action JSON"); - handle_action(action).unwrap_or_else(|err| - { - panic!("Error handling action: {}",err); - }); + let action: Action = serde_json::from_str(&json).expect("invalid action JSON"); + handle_action(action).unwrap_or_else(|err| { + panic!("Error handling action: {}", err); + }); 0 } -fn handle_action(action: Action) -> Result<(), String> { - +fn handle_action(action: Action) -> Result<(), String> { match action { - Action::MknodDevice { .. } => { - Ok(()) - } - Action::AnnounceViaNetlink { .. } => { - Ok(()) - } - Action::RemoveDevice { .. } => { - Ok(()) - } + Action::MknodDevice { .. } => Ok(()), + Action::AnnounceViaNetlink { .. } => Ok(()), + Action::RemoveDevice { .. } => Ok(()), } } diff --git a/vuinputd/src/jobs/inject_in_container_job.rs b/vuinputd/src/jobs/inject_in_container_job.rs index e557a8d..ae3067b 100644 --- a/vuinputd/src/jobs/inject_in_container_job.rs +++ b/vuinputd/src/jobs/inject_in_container_job.rs @@ -14,7 +14,14 @@ use async_io::Timer; use log::debug; use crate::{ - actions::{mknod_input_device::ensure_input_device, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{ensure_udev_structure, read_udev_data, write_udev_data}}, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, process_tools::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace} + actions::{ + mknod_input_device::ensure_input_device, + netlink_message::send_udev_monitor_message_with_properties, + runtime_data::{ensure_udev_structure, read_udev_data, write_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}, }; #[derive(Clone, Debug, Copy, PartialOrd, PartialEq)] diff --git a/vuinputd/src/jobs/remove_from_container_job.rs b/vuinputd/src/jobs/remove_from_container_job.rs index c299047..48d23d2 100644 --- a/vuinputd/src/jobs/remove_from_container_job.rs +++ b/vuinputd/src/jobs/remove_from_container_job.rs @@ -11,7 +11,13 @@ use std::{ use log::debug; use crate::{ - actions::{mknod_input_device::remove_input_device, netlink_message::send_udev_monitor_message_with_properties, runtime_data::delete_udev_data}, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, process_tools::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace} + actions::{ + mknod_input_device::remove_input_device, + netlink_message::send_udev_monitor_message_with_properties, runtime_data::delete_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}, }; #[derive(Clone, Debug, Copy, PartialOrd, PartialEq)] diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index c73b723..d80330c 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -68,13 +68,11 @@ struct Args { 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) => {}, + match (&args.major, &args.minor, &args.devname, &args.action) { + (None, None, None, Some(_)) => {} + (_, _, _, 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".into()); } } @@ -82,9 +80,7 @@ fn validate_args(args: &Args) -> Result<(), String> { match (&args.major, &args.minor) { (Some(_), Some(_)) | (None, None) => {} _ => { - return Err( - "--major and --minor must be specified together or not at all".into(), - ); + return Err("--major and --minor must be specified together or not at all".into()); } } @@ -107,7 +103,9 @@ fn main() -> std::io::Result<()> { check_permissions().expect("failed to read the capabilities of the vuinputd process"); let args = Args::parse(); - let argv0 = std::env::args_os().next().expect("Couldn't retrieve program name"); + let argv0 = std::env::args_os() + .next() + .expect("Couldn't retrieve program name"); if let Err(e) = validate_args(&args) { eprintln!("Error: {e}"); @@ -115,11 +113,10 @@ fn main() -> std::io::Result<()> { } if args.action.is_some() { - let error_code=actions::handle_action::handle_cli_action(args.action.unwrap()); + let error_code = actions::handle_action::handle_cli_action(args.action.unwrap()); std::process::exit(error_code); } - initialize_vuinput_state(); VUINPUT_COUNTER.set(AtomicU64::new(3)).expect( "failed to initialize the counter that provides the values of the CUSE file handles", @@ -144,9 +141,9 @@ fn main() -> std::io::Result<()> { let vuinput_devicename = match &args.devname { None => "vuinput", - Some(devname) => devname + Some(devname) => devname, }; - let vuinput_devicename = CString::new(format!("DEVNAME={}",vuinput_devicename)).unwrap(); + let vuinput_devicename = CString::new(format!("DEVNAME={}", vuinput_devicename)).unwrap(); let mut dev_info_argv: Vec<*const c_char> = vec![ vuinput_devicename.as_ptr(), // pointer to the C string @@ -155,9 +152,9 @@ fn main() -> std::io::Result<()> { // setting dev_major and dev_minor to 0 leads to a dynamic assignment of the major and minor, very likely beginning with 234:0 // see in https://www.kernel.org/doc/Documentation/admin-guide/devices.txt - let (major,minor) = match ((&args).major, (&args).minor) { - (Some(major), Some(minor)) => (major,minor), - _ => (0,0) + let (major, minor) = match ((&args).major, (&args).minor) { + (Some(major), Some(minor)) => (major, minor), + _ => (0, 0), }; let ci = cuse_lowlevel::cuse_info { dev_major: major, From 21349c40b94fa5c8fa37b2046054ab165997c196 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 17 Dec 2025 22:40:08 +0000 Subject: [PATCH 027/108] Improved action handlers; now also use anyhow --- vuinputd/src/actions/action.rs | 18 ++++++++--- vuinputd/src/actions/handle_action.rs | 30 ++++++++++++++++--- ...{mknod_input_device.rs => input_device.rs} | 14 +++++---- vuinputd/src/actions/mod.rs | 2 +- vuinputd/src/jobs/inject_in_container_job.rs | 2 +- .../src/jobs/remove_from_container_job.rs | 2 +- 6 files changed, 51 insertions(+), 17 deletions(-) rename vuinputd/src/actions/{mknod_input_device.rs => input_device.rs} (88%) diff --git a/vuinputd/src/actions/action.rs b/vuinputd/src/actions/action.rs index 6939e82..ba2a6f7 100644 --- a/vuinputd/src/actions/action.rs +++ b/vuinputd/src/actions/action.rs @@ -2,6 +2,8 @@ // // Author: Johannes Leupolz +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] @@ -12,12 +14,20 @@ pub enum Action { path: String, major: u32, minor: u32, - mode: u32, }, - #[serde(rename = "announce-via-netlink")] - AnnounceViaNetlink { message: String }, + #[serde(rename = "emit-udev-event")] + EmitUdevEvent { + netlink_message: HashMap, + runtime_data: Option, + major: u32, + minor: u32, + }, #[serde(rename = "remove-device")] - RemoveDevice { path: String }, + RemoveDevice { + path: String, + major: u32, + minor: u32, + }, } diff --git a/vuinputd/src/actions/handle_action.rs b/vuinputd/src/actions/handle_action.rs index 10f12e2..61d6947 100644 --- a/vuinputd/src/actions/handle_action.rs +++ b/vuinputd/src/actions/handle_action.rs @@ -3,6 +3,9 @@ // Author: Johannes Leupolz use super::action::Action; +use super::input_device; +use super::netlink_message; +use super::runtime_data; pub fn handle_cli_action(json: String) -> i32 { let action: Action = serde_json::from_str(&json).expect("invalid action JSON"); @@ -12,10 +15,29 @@ pub fn handle_cli_action(json: String) -> i32 { 0 } -fn handle_action(action: Action) -> Result<(), String> { +fn handle_action(action: Action) -> anyhow::Result<()> { match action { - Action::MknodDevice { .. } => Ok(()), - Action::AnnounceViaNetlink { .. } => Ok(()), - Action::RemoveDevice { .. } => Ok(()), + Action::MknodDevice { path, major, minor } => { + input_device::ensure_input_device(path, major.into(), minor.into())?; + Ok(()) + } + Action::EmitUdevEvent { + netlink_message, + runtime_data, + major, + minor, + } => { + netlink_message::send_udev_monitor_message_with_properties(netlink_message); + runtime_data::ensure_udev_structure()?; + match runtime_data { + Some(data) => runtime_data::write_udev_data(&data, major.into(), minor.into())?, + None => runtime_data::delete_udev_data(major.into(), minor.into())?, + } + Ok(()) + } + Action::RemoveDevice { path, major, minor } => { + input_device::remove_input_device(path, major.into(), minor.into())?; + Ok(()) + } } } diff --git a/vuinputd/src/actions/mknod_input_device.rs b/vuinputd/src/actions/input_device.rs similarity index 88% rename from vuinputd/src/actions/mknod_input_device.rs rename to vuinputd/src/actions/input_device.rs index 02fc4bd..a01efa0 100644 --- a/vuinputd/src/actions/mknod_input_device.rs +++ b/vuinputd/src/actions/input_device.rs @@ -2,13 +2,13 @@ // // Author: Johannes Leupolz +use anyhow::anyhow; use nix::sys::stat::{makedev, mknod, stat, Mode, SFlag}; -use std::error::Error; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; -pub fn ensure_input_device(dev_path: String, major: u64, minor: u64) -> Result<(), Box> { +pub fn ensure_input_device(dev_path: String, major: u64, minor: u64) -> anyhow::Result<()> { let input_dir = Path::new("/dev/input"); // Create directory like `mkdir -p` if !input_dir.exists() { @@ -70,23 +70,25 @@ pub fn ensure_input_device(dev_path: String, major: u64, minor: u64) -> Result<( Ok(()) } -pub fn remove_input_device(dev_path: String, major: u64, minor: u64) -> Result<(), Box> { +pub fn remove_input_device(dev_path: String, major: u64, minor: u64) -> anyhow::Result<()> { let path = Path::new(&dev_path); let expected_dev = makedev(major, minor); // --- Step 1: Ensure it is the correct device --- if !path.exists() { - return Err("Device does not exist".into()); + return Err(anyhow!("Device does not exist")); } match stat(path) { Ok(st) => { let is_char = (st.st_mode & libc::S_IFMT as u32) == libc::S_IFCHR as u32; let dev_ok = st.st_rdev == expected_dev; if !(is_char && dev_ok) { - return Err("Device that should be deleted has wrong major and minor".into()); + return Err(anyhow!( + "Device that should be deleted has wrong major and minor" + )); } } - Err(_x) => return Err("Could not execute stat on device file".into()), + Err(_x) => return Err(anyhow!("Could not execute stat on device file")), } let _ = fs::remove_file(path); diff --git a/vuinputd/src/actions/mod.rs b/vuinputd/src/actions/mod.rs index af3b886..a5e98d7 100644 --- a/vuinputd/src/actions/mod.rs +++ b/vuinputd/src/actions/mod.rs @@ -4,6 +4,6 @@ pub mod action; pub mod handle_action; -pub mod mknod_input_device; +pub mod input_device; pub mod netlink_message; pub mod runtime_data; diff --git a/vuinputd/src/jobs/inject_in_container_job.rs b/vuinputd/src/jobs/inject_in_container_job.rs index ae3067b..0d9f83c 100644 --- a/vuinputd/src/jobs/inject_in_container_job.rs +++ b/vuinputd/src/jobs/inject_in_container_job.rs @@ -15,7 +15,7 @@ use log::debug; use crate::{ actions::{ - mknod_input_device::ensure_input_device, + input_device::ensure_input_device, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{ensure_udev_structure, read_udev_data, write_udev_data}, }, diff --git a/vuinputd/src/jobs/remove_from_container_job.rs b/vuinputd/src/jobs/remove_from_container_job.rs index 48d23d2..990f9c9 100644 --- a/vuinputd/src/jobs/remove_from_container_job.rs +++ b/vuinputd/src/jobs/remove_from_container_job.rs @@ -12,7 +12,7 @@ use log::debug; use crate::{ actions::{ - mknod_input_device::remove_input_device, + input_device::remove_input_device, netlink_message::send_udev_monitor_message_with_properties, runtime_data::delete_udev_data, }, job_engine::job::{Job, JobTarget}, From 32119fbf9258d9b5ad4b1ca70bc75ff636fdd30f Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 18 Dec 2025 19:30:14 +0000 Subject: [PATCH 028/108] Refactored Namespace Switching After Exec. Now we have a parameter --target-namespace which contains the namespace to enter --- docs/DESIGN.md | 76 +++++++++++++++++ vuinputd/src/actions/action.rs | 12 +-- vuinputd/src/cuse_device/vuinput_open.rs | 8 +- vuinputd/src/jobs/inject_in_container_job.rs | 51 +++++------ .../src/jobs/remove_from_container_job.rs | 47 ++++++----- vuinputd/src/main.rs | 31 +++++-- vuinputd/src/process_tools/mod.rs | 84 +++++++++++-------- 7 files changed, 213 insertions(+), 96 deletions(-) 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 { From 25623bf636ff010fec2ac42dfe71ed767bdded55 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 18 Dec 2025 19:46:55 +0000 Subject: [PATCH 029/108] Split inject_in_container into emit_udev_event and mknod_device --- vuinputd/src/cuse_device/vuinput_ioctl.rs | 28 ++++- ...rs => emit_udev_event_in_container_job.rs} | 27 ++-- .../src/jobs/mknod_device_in_container_job.rs | 119 ++++++++++++++++++ vuinputd/src/jobs/mod.rs | 3 +- 4 files changed, 151 insertions(+), 26 deletions(-) rename vuinputd/src/jobs/{inject_in_container_job.rs => emit_udev_event_in_container_job.rs} (86%) create mode 100644 vuinputd/src/jobs/mknod_device_in_container_job.rs diff --git a/vuinputd/src/cuse_device/vuinput_ioctl.rs b/vuinputd/src/cuse_device/vuinput_ioctl.rs index 2ad991f..e61bf22 100644 --- a/vuinputd/src/cuse_device/vuinput_ioctl.rs +++ b/vuinputd/src/cuse_device/vuinput_ioctl.rs @@ -14,7 +14,8 @@ use uinput_ioctls::*; use crate::cuse_device::{get_vuinput_state, VuFileHandle}; use crate::job_engine::JOB_DISPATCHER; -use crate::jobs::inject_in_container_job::InjectInContainerJob; +use crate::jobs::emit_udev_event_in_container_job::EmitUdevEventInContainerJob; +use crate::jobs::mknod_device_in_container_job::MknodDeviceInContainerJob; use crate::jobs::remove_from_container_job::RemoveFromContainerJob; use crate::process_tools::SELF_NAMESPACES; use crate::{cuse_device::*, jobs}; @@ -181,25 +182,40 @@ pub unsafe extern "C" fn vuinput_ioctl( .unwrap() .equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) { - let inject_job = InjectInContainerJob::new( + let mknod_job = MknodDeviceInContainerJob::new( vuinput_state.requesting_process.clone(), devnode.clone(), sysname.clone(), major, minor, ); - let awaiter = inject_job.get_awaiter_for_state(); + let awaiter = mknod_job.get_awaiter_for_state(); JOB_DISPATCHER .get() .unwrap() .lock() .unwrap() - .dispatch(Box::new(inject_job)); - awaiter(&jobs::inject_in_container_job::State::Finished); + .dispatch(Box::new(mknod_job)); + awaiter(&jobs::mknod_device_in_container_job::State::Finished); debug!( - "fh {}: injecting dev-nodes in container has been finished ", + "fh {}: mknod_device in container has been finished ", fh ); + + // we do not wait for the udev stuff + let emit_udev_event_job = EmitUdevEventInContainerJob::new( + vuinput_state.requesting_process.clone(), + devnode.clone(), + sysname.clone(), + major, + minor, + ); + JOB_DISPATCHER + .get() + .unwrap() + .lock() + .unwrap() + .dispatch(Box::new(emit_udev_event_job)); } // write a SYN-event (which is just zeros) just for validation diff --git a/vuinputd/src/jobs/inject_in_container_job.rs b/vuinputd/src/jobs/emit_udev_event_in_container_job.rs similarity index 86% rename from vuinputd/src/jobs/inject_in_container_job.rs rename to vuinputd/src/jobs/emit_udev_event_in_container_job.rs index fe5daf8..a3af1ed 100644 --- a/vuinputd/src/jobs/inject_in_container_job.rs +++ b/vuinputd/src/jobs/emit_udev_event_in_container_job.rs @@ -31,7 +31,7 @@ pub enum State { } #[derive(Clone, Debug)] -pub struct InjectInContainerJob { +pub struct EmitUdevEventInContainerJob { requesting_process: RequestingProcess, target: JobTarget, dev_path: String, @@ -41,7 +41,7 @@ pub struct InjectInContainerJob { sync_state: Arc<(Mutex, Condvar)>, } -impl InjectInContainerJob { +impl EmitUdevEventInContainerJob { pub fn new( requesting_process: RequestingProcess, dev_path: String, @@ -82,16 +82,16 @@ impl InjectInContainerJob { } } -impl Job for InjectInContainerJob { +impl Job for EmitUdevEventInContainerJob { fn desc(&self) -> &str { - "Inject input device into container" + "emit udev event into container" } fn execute_after_cancellation(&self) -> bool { false } - fn create_task(self: &InjectInContainerJob) -> Pin>> { + fn create_task(self: &EmitUdevEventInContainerJob) -> Pin>> { Box::pin(self.clone().inject_in_container()) } @@ -100,7 +100,7 @@ impl Job for InjectInContainerJob { } } -impl InjectInContainerJob { +impl EmitUdevEventInContainerJob { async fn inject_in_container(self) { // temporary hack that needs to be replaced. We try 50 times // Should be: Wait for the device to be created, the runtime data to be written and the @@ -148,16 +148,6 @@ impl InjectInContainerJob { let netlink_data = netlink_data.unwrap(); let dev_path = self.dev_path.clone(); - 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), @@ -165,12 +155,11 @@ impl InjectInContainerJob { minor: self.minor, }; - let child_pid_2 = + let child_pid = 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(); + let _exit_info = await_process(Pid::Pid(child_pid)).await.unwrap(); self.set_state(&State::Finished); } } diff --git a/vuinputd/src/jobs/mknod_device_in_container_job.rs b/vuinputd/src/jobs/mknod_device_in_container_job.rs new file mode 100644 index 0000000..90cc424 --- /dev/null +++ b/vuinputd/src/jobs/mknod_device_in_container_job.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::{ + collections::HashMap, + future::Future, + pin::Pin, + sync::{Arc, Condvar, Mutex}, + time::Duration, +}; + +use async_io::Timer; +use log::debug; + +use crate::{ + actions::{ + action::Action, + runtime_data::{read_udev_data}, + }, + job_engine::job::{Job, JobTarget}, + jobs::monitor_udev_job::EVENT_STORE, + process_tools::{self, await_process, Pid, RequestingProcess}, +}; + +#[derive(Clone, Debug, Copy, PartialOrd, PartialEq)] +pub enum State { + Initialized, + Started, + Finished, +} + +#[derive(Clone, Debug)] +pub struct MknodDeviceInContainerJob { + requesting_process: RequestingProcess, + target: JobTarget, + dev_path: String, + sys_path: String, + major: u64, + minor: u64, + sync_state: Arc<(Mutex, Condvar)>, +} + +impl MknodDeviceInContainerJob { + pub fn new( + requesting_process: RequestingProcess, + dev_path: String, + sys_path: String, + major: u64, + minor: u64, + ) -> Self { + Self { + requesting_process: requesting_process.clone(), + target: JobTarget::Container(requesting_process), + dev_path: dev_path, + sys_path: sys_path, + major: major, + minor: minor, + sync_state: Arc::new((Mutex::new(State::Initialized), Condvar::new())), + } + } + + fn set_state(&self, new_state: &State) -> () { + let (lock, cvar) = &*self.sync_state; + let mut current_state = lock.lock().unwrap(); + *current_state = *new_state; + // We notify the condvar that the value has changed. + cvar.notify_all(); + } + + pub fn get_awaiter_for_state(&self) -> impl FnOnce(&State) -> () { + // pattern is described on https://doc.rust-lang.org/stable/std/sync/struct.Condvar.html + let sync_state = self.sync_state.clone(); + let awaiter = move |state: &State| { + let (lock, cvar) = &*sync_state; + let mut current_state = lock.lock().unwrap(); + while *current_state < *state { + current_state = cvar.wait(current_state).unwrap(); + } + }; + awaiter + } +} + +impl Job for MknodDeviceInContainerJob { + fn desc(&self) -> &str { + "mknod input device in container" + } + + fn execute_after_cancellation(&self) -> bool { + false + } + + fn create_task(self: &MknodDeviceInContainerJob) -> Pin>> { + Box::pin(self.clone().inject_in_container()) + } + + fn job_target(&self) -> JobTarget { + self.target.clone() + } +} + +impl MknodDeviceInContainerJob { + async fn inject_in_container(self) { + + let mknod_device_action = Action::MknodDevice { + path: self.dev_path.clone(), + major: self.major, + minor: self.minor, + }; + + let child_pid = + process_tools::start_action(mknod_device_action, &self.requesting_process) + .expect("subprocess should work"); + + let _exit_info = await_process(Pid::Pid(child_pid)).await.unwrap(); + self.set_state(&State::Finished); + } +} diff --git a/vuinputd/src/jobs/mod.rs b/vuinputd/src/jobs/mod.rs index 4adefe9..c1bb9e4 100644 --- a/vuinputd/src/jobs/mod.rs +++ b/vuinputd/src/jobs/mod.rs @@ -2,6 +2,7 @@ // // Author: Johannes Leupolz -pub mod inject_in_container_job; +pub mod mknod_device_in_container_job; +pub mod emit_udev_event_in_container_job; pub mod monitor_udev_job; pub mod remove_from_container_job; From 552ab12f3013c5ac89d9cde9713aa078232351cf Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 18 Dec 2025 21:25:53 +0000 Subject: [PATCH 030/108] Fix tests. Mknod in bubblewrap is not possible if it was not bind-mounted without a nodev flag. --- vuinputd-tests/src/bwrap.rs | 13 +++++++++++++ vuinputd-tests/tests/integration_tests.rs | 3 +-- vuinputd/src/cuse_device/vuinput_ioctl.rs | 5 +---- .../src/jobs/emit_udev_event_in_container_job.rs | 5 +---- vuinputd/src/jobs/mknod_device_in_container_job.rs | 11 +++-------- vuinputd/src/jobs/mod.rs | 2 +- vuinputd/src/jobs/remove_from_container_job.rs | 4 +--- vuinputd/src/process_tools/mod.rs | 2 +- 8 files changed, 22 insertions(+), 23 deletions(-) diff --git a/vuinputd-tests/src/bwrap.rs b/vuinputd-tests/src/bwrap.rs index 17e7065..2514fee 100644 --- a/vuinputd-tests/src/bwrap.rs +++ b/vuinputd-tests/src/bwrap.rs @@ -100,6 +100,19 @@ impl BwrapBuilder { self } + pub fn dev(mut self) -> Self { + // for our tests, we cannot simply use the "--dev"-flag, because it creates a tmpfs with the nodev flag + // see SETUP_MOUNT_DEV and PRIV_SEP_OP_TMPFS_MOUNT + // in https://github.com/containers/bubblewrap/blob/v0.11.0/bubblewrap.c#L1370-L1376 . + // So, we mount a temporary directory that does not have this restrictions. + self.args.extend([ + "--dev-bind".into(), + "/dev/tmp/vuinputd-test".into(), + "/dev".into(), + ]); + self + } + pub fn tmpfs(mut self, path: &str) -> Self { self.args.push("--tmpfs".into()); self.args.push(path.into()); diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index 400e667..ed39e08 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -134,8 +134,7 @@ fn test_keyboard_in_container_with_vuinput() { .ro_bind("/", "/") .tmpfs("/tmp") // dev needs to be writable for the new devices - .tmpfs("/dev") - .tmpfs("/dev/input") + .dev() // run needs to be writable for the udev devices .tmpfs("/run") .dev_bind("/dev/vuinput-test", "/dev/uinput") diff --git a/vuinputd/src/cuse_device/vuinput_ioctl.rs b/vuinputd/src/cuse_device/vuinput_ioctl.rs index e61bf22..3793cba 100644 --- a/vuinputd/src/cuse_device/vuinput_ioctl.rs +++ b/vuinputd/src/cuse_device/vuinput_ioctl.rs @@ -197,10 +197,7 @@ pub unsafe extern "C" fn vuinput_ioctl( .unwrap() .dispatch(Box::new(mknod_job)); awaiter(&jobs::mknod_device_in_container_job::State::Finished); - debug!( - "fh {}: mknod_device in container has been finished ", - fh - ); + debug!("fh {}: mknod_device in container has been finished ", fh); // we do not wait for the udev stuff let emit_udev_event_job = EmitUdevEventInContainerJob::new( diff --git a/vuinputd/src/jobs/emit_udev_event_in_container_job.rs b/vuinputd/src/jobs/emit_udev_event_in_container_job.rs index a3af1ed..fe74777 100644 --- a/vuinputd/src/jobs/emit_udev_event_in_container_job.rs +++ b/vuinputd/src/jobs/emit_udev_event_in_container_job.rs @@ -14,10 +14,7 @@ use async_io::Timer; use log::debug; use crate::{ - actions::{ - action::Action, - runtime_data::{read_udev_data}, - }, + actions::{action::Action, runtime_data::read_udev_data}, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, process_tools::{self, await_process, Pid, RequestingProcess}, diff --git a/vuinputd/src/jobs/mknod_device_in_container_job.rs b/vuinputd/src/jobs/mknod_device_in_container_job.rs index 90cc424..3159c68 100644 --- a/vuinputd/src/jobs/mknod_device_in_container_job.rs +++ b/vuinputd/src/jobs/mknod_device_in_container_job.rs @@ -14,10 +14,7 @@ use async_io::Timer; use log::debug; use crate::{ - actions::{ - action::Action, - runtime_data::{read_udev_data}, - }, + actions::{action::Action, runtime_data::read_udev_data}, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, process_tools::{self, await_process, Pid, RequestingProcess}, @@ -102,16 +99,14 @@ impl Job for MknodDeviceInContainerJob { impl MknodDeviceInContainerJob { async fn inject_in_container(self) { - let mknod_device_action = Action::MknodDevice { path: self.dev_path.clone(), major: self.major, minor: self.minor, }; - let child_pid = - process_tools::start_action(mknod_device_action, &self.requesting_process) - .expect("subprocess should work"); + let child_pid = process_tools::start_action(mknod_device_action, &self.requesting_process) + .expect("subprocess should work"); let _exit_info = await_process(Pid::Pid(child_pid)).await.unwrap(); self.set_state(&State::Finished); diff --git a/vuinputd/src/jobs/mod.rs b/vuinputd/src/jobs/mod.rs index c1bb9e4..242f0ab 100644 --- a/vuinputd/src/jobs/mod.rs +++ b/vuinputd/src/jobs/mod.rs @@ -2,7 +2,7 @@ // // Author: Johannes Leupolz -pub mod mknod_device_in_container_job; pub mod emit_udev_event_in_container_job; +pub mod mknod_device_in_container_job; pub mod monitor_udev_job; pub mod remove_from_container_job; diff --git a/vuinputd/src/jobs/remove_from_container_job.rs b/vuinputd/src/jobs/remove_from_container_job.rs index 1e9cfb0..a02836b 100644 --- a/vuinputd/src/jobs/remove_from_container_job.rs +++ b/vuinputd/src/jobs/remove_from_container_job.rs @@ -11,9 +11,7 @@ use std::{ use log::debug; use crate::{ - actions::{ - action::Action, - }, + actions::action::Action, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, process_tools::{self, await_process, Pid, RequestingProcess}, diff --git a/vuinputd/src/process_tools/mod.rs b/vuinputd/src/process_tools/mod.rs index 70e562c..f57e045 100644 --- a/vuinputd/src/process_tools/mod.rs +++ b/vuinputd/src/process_tools/mod.rs @@ -12,7 +12,7 @@ use std::{ unix::process::CommandExt, }, path::Path, - process::{Command}, + process::Command, sync::OnceLock, }; From 22ac4c23b7dbd07dbc556cb3c5336d291dc9e31b Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 18 Dec 2025 21:27:15 +0000 Subject: [PATCH 031/108] Update dependencies in github action --- .github/workflows/rust.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 4af5cc4..8d50e5c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Download dependencies - run: sudo apt install -y build-essential libc6-dev libfuse3-dev pkg-config fuse3 libudev-dev + run: sudo apt update && sudo apt install -y build-essential libc6-dev libfuse3-dev pkg-config fuse3 libudev-dev - name: Build run: cargo build --verbose - name: Run tests From e3094f898b298fcc9ad4b691206f80a18e7a33f8 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 18 Dec 2025 21:36:13 +0000 Subject: [PATCH 032/108] Fix potential race --- docs/DESIGN.md | 1 + vuinputd/src/cuse_device/vuinput_ioctl.rs | 9 +++------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 04f91ba..fdeee78 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -481,6 +481,7 @@ Under these constraints, post-exec namespace switching provides a robust and pre 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 + and https://systemd.io/ARCHITECTURE/ . The chosen approach offers the best balance between correctness, portability, and operational simplicity. diff --git a/vuinputd/src/cuse_device/vuinput_ioctl.rs b/vuinputd/src/cuse_device/vuinput_ioctl.rs index 3793cba..6d8d49d 100644 --- a/vuinputd/src/cuse_device/vuinput_ioctl.rs +++ b/vuinputd/src/cuse_device/vuinput_ioctl.rs @@ -198,6 +198,7 @@ pub unsafe extern "C" fn vuinput_ioctl( .dispatch(Box::new(mknod_job)); awaiter(&jobs::mknod_device_in_container_job::State::Finished); debug!("fh {}: mknod_device in container has been finished ", fh); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); // we do not wait for the udev stuff let emit_udev_event_job = EmitUdevEventInContainerJob::new( @@ -213,13 +214,9 @@ pub unsafe extern "C" fn vuinput_ioctl( .lock() .unwrap() .dispatch(Box::new(emit_udev_event_job)); + } else { + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); } - - // write a SYN-event (which is just zeros) just for validation - let syn_event: [u8; 24] = [0; 24]; - vuinput_state.file.write_all(&syn_event).unwrap(); - - fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); } UI_DEV_DESTROY => { debug!("fh {}: ioctl UI_DEV_DESTROY", fh); From 67db5f1885f7ccf7c9121ba6a1af5b1e227abe78 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Fri, 19 Dec 2025 20:49:57 +0000 Subject: [PATCH 033/108] First microbenchmark conducted --- README.md | 2 ++ docs/TESTS.md | 30 +++++++++++++++++++++++ vuinputd-tests/src/bin/test-keyboard.rs | 8 +++--- vuinputd-tests/src/test_log.rs | 4 +-- vuinputd-tests/tests/integration_tests.rs | 17 +++++++++---- 5 files changed, 50 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 6cf9a8a..c7f4b92 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ sequenceDiagram Kernel->>Game: 10. send input data via eventX device ``` +> **Performance note:** +> While `vuinputd` adds an extra userspace round trip via CUSE, the measured overhead is in the range of **tens of microseconds per event** in a simple integration test. This is several orders of magnitude smaller than typical sources of input latency such as frame rendering, compositor delays, scheduling jitter, or network latency. In practice, the additional cost is negligible for interactive and latency-sensitive applications, including gaming. More detailed benchmarks can be found in `TESTS.md`. --- diff --git a/docs/TESTS.md b/docs/TESTS.md index 9b403c7..142c634 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -7,6 +7,36 @@ Install bubblewrap: Run with `cargo test -p vuinputd-tests --features "requires-privileges requires-uinput requires-bwrap"`. + + +## Performance tests + +Using CUSE introduces an additional round trip between kernel and userspace, which inevitably adds overhead compared to direct uinput access. To estimate the order of magnitude of this overhead, the `vuinputd-tests` include a simple integration test that emits two input events: once using direct uinput access and once via `vuinputd` v0.3. + +The test measures the elapsed time between emitting an event and receiving it again, using `CLOCK_MONOTONIC`. The measured latencies were: + +* **First call:** 16 µs (direct uinput) vs. 90 µs (vuinputd) +* **Second call:** 7 µs (direct uinput) vs. 68 µs (vuinputd) + +As expected, `vuinputd` introduces a clearly measurable performance penalty due to the additional userspace round trip. However, even in this best-case microbenchmark, the absolute overhead remains well below 0.1 ms per event. + +In practical terms, this level of overhead is negligible for real-world usage. For latency-sensitive applications such as gaming, tens of microseconds are several orders of magnitude smaller than typical sources of latency such as frame rendering time, compositor delays, scheduling jitter, or network latency. Even a single rendered frame at 60 Hz already accounts for roughly 16.6 ms, making the additional cost introduced by `vuinputd` effectively unobservable to the user. + +It is important to note that this benchmark is intentionally minimal and primarily intended to provide a rough sense of scale. It does not model realistic workloads, higher event rates, or concurrent inputs. More comprehensive benchmarks are required to assess behavior under load and contention. Nevertheless, these results demonstrate that the architectural approach taken by `vuinputd` is sound and does not introduce prohibitive latency by design. +As long as more realistic benchmarks confirm similar behavior under load, `vuinputd` can be considered suitable even for interactive and latency-sensitive use cases. + +Detailed results: + +`integration_tests.rs#test_keyboard_in_container_with_uinput`: +``` +{"events":[{"tv_sec":3133476,"tv_nsec":947503794,"duration_usec":16,"type_":1,"code":57,"value":1,"send_and_receive_match":true},{"tv_sec":3133476,"tv_nsec":947520555,"duration_usec":7,"type_":1,"code":57,"value":0,"send_and_receive_match":true}]} +``` + +`integration_tests.rs#test_keyboard_in_container_with_vuinput`: +``` +Event log: {"events":[{"tv_sec":3133303,"tv_nsec":796108454,"duration_usec":90,"type_":1,"code":57,"value":1,"send_and_receive_match":true},{"tv_sec":3133303,"tv_nsec":796198973,"duration_usec":68,"type_":1,"code":57,"value":0,"send_and_receive_match":true}]} +``` + ## Manual end-to-end tests | vuinputd | host | input type | app that creates device | app that reads device | working | Notes | diff --git a/vuinputd-tests/src/bin/test-keyboard.rs b/vuinputd-tests/src/bin/test-keyboard.rs index ed039e2..d4112de 100644 --- a/vuinputd-tests/src/bin/test-keyboard.rs +++ b/vuinputd-tests/src/bin/test-keyboard.rs @@ -280,7 +280,7 @@ unsafe fn set_standard_keyboard_keys(fd: i32) -> Result<(), std::io::Error> { #[command(author, version, about)] struct Args { /// Use IPC - #[arg(long, default_value_t = false)] + #[arg(long)] ipc: bool, /// Device path (with /dev/) @@ -335,7 +335,7 @@ fn emit_read_and_log( let input_event_recv = read_event(&read_from).unwrap(); let _syn_recv = read_event(&read_from).unwrap(); let (time_recv_sec, time_recv_nsec) = monotonic_time(); - let duration_nsec = + let duration_usec = (time_recv_sec - time_sent_sec) * 1_000_000 + (time_recv_nsec - time_sent_nsec) / 1000; let send_and_receive_match = input_event_recv.type_ == ev_type && input_event_recv.code == code @@ -343,8 +343,8 @@ fn emit_read_and_log( Ok(LoggedInputEvent { tv_sec: time_sent_sec, - tv_usec: time_sent_nsec, - duration_nsec: duration_nsec, + tv_nsec: time_sent_nsec, + duration_usec: duration_usec, type_: ev_type, code: code, value: val, diff --git a/vuinputd-tests/src/test_log.rs b/vuinputd-tests/src/test_log.rs index de02bea..80e9fc2 100644 --- a/vuinputd-tests/src/test_log.rs +++ b/vuinputd-tests/src/test_log.rs @@ -8,9 +8,9 @@ use serde::{Deserialize, Serialize}; pub struct LoggedInputEvent { pub tv_sec: i64, - pub tv_usec: i64, + pub tv_nsec: i64, - pub duration_nsec: i64, + pub duration_usec: i64, pub type_: u16, diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index ed39e08..cef9ce6 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -99,14 +99,18 @@ fn test_keyboard_on_host() { fn test_keyboard_in_container_with_uinput() { let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); - let out = bwrap::BwrapBuilder::new() + let (builder, _ipc) = bwrap::BwrapBuilder::new() .unshare_net() .ro_bind("/", "/") .tmpfs("/tmp") .dev_bind("/dev/uinput", "/dev/uinput") .dev_bind("/dev/input", "/dev/input") .die_with_parent() - .command(test_keyboard, &[]) + .with_ipc() + .expect("failed to create IPC"); + + let out = builder + .command(test_keyboard, &["--ipc"]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); @@ -122,14 +126,13 @@ fn test_keyboard_in_container_with_uinput() { feature = "requires-uinput", feature = "requires-bwrap" ))] -//#[ignore] #[test] fn test_keyboard_in_container_with_vuinput() { run_vuinputd::ensure_vuinputd_running(); let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); - let out = bwrap::BwrapBuilder::new() + let (builder, _ipc) = bwrap::BwrapBuilder::new() .unshare_net() .ro_bind("/", "/") .tmpfs("/tmp") @@ -139,7 +142,11 @@ fn test_keyboard_in_container_with_vuinput() { .tmpfs("/run") .dev_bind("/dev/vuinput-test", "/dev/uinput") .die_with_parent() - .command(test_keyboard, &[]) + .with_ipc() + .expect("failed to create IPC"); + + let out = builder + .command(test_keyboard, &["--ipc"]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); From 78feb4f08f21cebade55bf5dc778033d98cd6bb9 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Fri, 19 Dec 2025 21:01:34 +0000 Subject: [PATCH 034/108] Add udev files to debian package --- debian/rules | 4 +++- debian/vuinputd.install | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/debian/rules b/debian/rules index 37cc0b2..3c7196c 100755 --- a/debian/rules +++ b/debian/rules @@ -8,4 +8,6 @@ override_dh_auto_build: cargo build --release override_dh_auto_install: - install -D -m 0755 target/release/vuinputd debian/tmp/usr/bin/vuinputd \ No newline at end of file + install -D -m 0755 target/release/vuinputd debian/tmp/usr/bin/vuinputd + install -D -m 0755 vuinputd/udev/90-vuinputd.hwdb debian/tmp/usr/lib/udev/hwdb.d/90-vuinputd.hwdb + install -D -m 0755 vuinputd/udev/90-vuinputd-protect.rules debian/tmp/usr/lib/udev/rules.d/90-vuinputd-protect.rules \ No newline at end of file diff --git a/debian/vuinputd.install b/debian/vuinputd.install index f16afed..b1faec9 100644 --- a/debian/vuinputd.install +++ b/debian/vuinputd.install @@ -1 +1,3 @@ -usr/bin/vuinputd \ No newline at end of file +usr/bin/vuinputd +usr/lib/udev/hwdb.d/90-vuinputd.hwdb +usr/lib/udev/rules.d/90-vuinputd-protect.rules \ No newline at end of file From 84dcfbe6e969393ea58fb9599163f8f0d6bb47e8 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Fri, 19 Dec 2025 21:08:10 +0000 Subject: [PATCH 035/108] Release v0.3.0 --- debian/changelog | 25 +++++++++++++++++++++++-- vuinputd/Cargo.toml | 2 +- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/debian/changelog b/debian/changelog index c8b5033..e94c251 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,24 @@ +vuinputd (0.3.0-1) unstable; urgency=medium + + * Refactor container and namespace handling: + - Refactoring from forked lambda execution to JSON-based job re-exec. + - Refactor namespace switching after exec; introduce --target-namespace option. + - Split inject_in_container into emit_udev_event and mknod_device. + * Add udev rules to the Debian package. + * Significant improvements to integration testing: + - Add integration tests for device creation via uinput using bubblewrap. + - Add event logging support to integration tests. + - Run a separate vuinputd instance with its own device node as part of tests. + * Improve internal structure and error handling: + - Switch to anyhow for error handling. + - General name refactorings and code cleanup. + * Add command-line options to control virtual uinput device properties + (devname, major, minor). + * Update GitHub Actions dependencies and conduct first microbenchmark. + * Minor fixes in examples and formatting cleanup. + + -- Johannes Leupolz Thu, 19 Dec 2025 21:30:00 +0000 + vuinputd (0.2.0-1) unstable; urgency=medium * Register official USB vendor/product ID (VID 0x1209, PID 0x5020) under @@ -24,11 +45,11 @@ vuinputd (0.2.0-1) unstable; urgency=medium and service file. * Updated documentation for release readiness. - -- Johannes Leupolz Thu, 06 Dec 2025 21:00:00 +0000 + -- Johannes Leupolz Thu, 06 Dec 2025 21:00:00 +0000 vuinputd (0.1.0-1) unstable; urgency=medium * Initial release. - -- Johannes Leupolz Wed, 27 Aug 2025 12:00:00 +0000 + -- Johannes Leupolz Wed, 27 Aug 2025 12:00:00 +0000 diff --git a/vuinputd/Cargo.toml b/vuinputd/Cargo.toml index b182b69..20ea5ff 100644 --- a/vuinputd/Cargo.toml +++ b/vuinputd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vuinputd" -version = "0.2.1" +version = "0.3.0" edition = "2021" authors = ["Johannes Leupolz "] license = "MIT" From 4fbb4d7389da1583075e05d7e43bd02df58e09c4 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 22 Dec 2025 10:30:43 +0000 Subject: [PATCH 036/108] Describe two possible ways to get rid of the "VT keyboard handler" problem when no graphical input session is active. My favorite candidate is "Fallback Graphical Session" --- docs/DESIGN.md | 135 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fdeee78..ab21957 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -485,6 +485,141 @@ Under these constraints, post-exec namespace switching provides a robust and pre The chosen approach offers the best balance between correctness, portability, and operational simplicity. +--- + +## **3.11 VT Guarding for Headless / No-Compositor Systems** + +### **Decision** + +When **no X11 or Wayland session is active**, `vuinputd` relies on a small, separate **VT-guard daemon** to own the currently active Virtual Terminal (VT) and switch it into **graphics mode (`KD_GRAPHICS`)**. + +If an X11 or Wayland compositor is running, **no VT guard is required**, as the compositor already owns a VT and has disabled the VT keyboard handler. + +The VT-guard: + +* runs in the foreground +* opens the active VT (`/dev/tty`) +* prints an informational notice +* switches the VT to `KD_GRAPHICS` +* registers for VT release notifications +* restores `KD_TEXT` and exits on VT switch + +It does not access evdev, uinput, or interpret key events. + +### **Rationale** + +On systems without a graphical session, keyboard input reaches `getty` via the **VT keyboard handler**, independent of evdev. +Switching the VT to `KD_GRAPHICS` disables this path and effectively starves `getty` on that VT. + +This mirrors compositor behavior (Xorg / Wayland) without requiring rendering, DRM, or input stack integration. + +### **Designed Behavior: VT Switching Is Allowed** + +VT switching (e.g. `Ctrl+Alt+Fn`) is **intentionally not blocked**. + +This preserves a recovery path: + +* users can switch to another VT +* `getty` remains accessible for local login +* physical access always overrides remote control + +On VT switch, the VT-guard restores `KD_TEXT` and exits cleanly. + +### **Constraints and Guarantees** + +The VT-guard guarantees: + +* the active VT does not accept keyboard input +* `getty` is effectively disabled on that VT +* normal console behavior resumes on VT switch + +### **Alternatives Considered** + +* **Blocking VT switching** + Rejected to preserve local recovery. + +--- + +## **3.11 Fallback Graphical Session (`fallbackdm`)** + +### **Problem Statement** + +On systems without an active graphical session (X11 or Wayland), the kernel VT subsystem remains in **text mode (`KD_TEXT`)**, and the VT keyboard handler is active. +As a result: + +* `getty` receives keyboard input on the active VT +* VT key handling (e.g. `Ctrl+Alt+Fn`) is enabled +* input devices may interact with the VT layer in unintended ways + +When a graphical session is active, these issues do not occur: +the compositor, via `systemd-logind`, owns a VT, switches it to `KD_GRAPHICS`, and the VT keyboard handler is disabled automatically. + +The missing piece is a **well-defined fallback** for the “no graphical session” case. + +--- + +### **Decision** + +`fallbackdm` is implemented as a **logind-managed fallback graphical session**. + +It runs only when **no other graphical session is active** on the seat and exists solely to: + +* open a regular logind session +* occupy the assigned VT +* let logind switch the VT to `KD_GRAPHICS` + +`fallbackdm` itself does **not** manipulate VTs, perform `KDSETMODE` ioctls, or access `/dev/tty` directly. +All VT and seat handling is delegated to `systemd-logind`. + +--- + +### **Behavior and Lifecycle** + +* `fallbackdm` starts as a normal session on the seat +* while active: + + * the VT is in `KD_GRAPHICS` + * VT keyboard handling and `getty` input are suppressed +* when a real graphical session (greeter or compositor) starts: + + * logind deactivates `fallbackdm` + * VT ownership is transferred automatically +* when the graphical session ends: + + * `fallbackdm` may be restarted to reclaim the fallback role + +`fallbackdm` is non-interactive by design but may display **minimal status information** in the future. + +--- + +### **Rationale** + +This design: + +* reuses existing, well-tested logind behavior +* avoids duplicating VT and seat logic +* guarantees compatibility with: + + * Wayland compositors + * graphical login managers + * multi-seat setups +* keeps the implementation minimal and robust + +Conceptually, `fallbackdm` acts as a **headless placeholder graphical session** that ensures consistent system behavior even when no real graphical environment is running. + +--- + +### **Alternatives Considered** + +* **Direct VT management (KDSETMODE, VT ioctls)** + Rejected due to complexity, fragility, and duplication of logind functionality. + +* **Filtering input at the evdev / uinput layer** + Rejected as insufficient: it does not address VT keyboard handling or `getty` behavior. + +* **Disabling gettys or VT switching globally** + Rejected to preserve emergency local access and standard Linux behavior. + --- From 77c3452bbbf1ef7a5534209647bb560098e4b157 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 22 Dec 2025 19:59:31 +0000 Subject: [PATCH 037/108] Add WIP and notes for fallbackdm --- docs/DESIGN.md | 55 +---------- docs/USAGE.md | 110 ++++++++++++++++++++- fallbackdm/README.md | 147 ++++++++++++++++++++++++++++ fallbackdm/systemd/pam.d_fallbackdm | 1 + 4 files changed, 257 insertions(+), 56 deletions(-) create mode 100644 fallbackdm/README.md create mode 100644 fallbackdm/systemd/pam.d_fallbackdm diff --git a/docs/DESIGN.md b/docs/DESIGN.md index ab21957..433ec10 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -487,59 +487,6 @@ The chosen approach offers the best balance between correctness, portability, an --- -## **3.11 VT Guarding for Headless / No-Compositor Systems** - -### **Decision** - -When **no X11 or Wayland session is active**, `vuinputd` relies on a small, separate **VT-guard daemon** to own the currently active Virtual Terminal (VT) and switch it into **graphics mode (`KD_GRAPHICS`)**. - -If an X11 or Wayland compositor is running, **no VT guard is required**, as the compositor already owns a VT and has disabled the VT keyboard handler. - -The VT-guard: - -* runs in the foreground -* opens the active VT (`/dev/tty`) -* prints an informational notice -* switches the VT to `KD_GRAPHICS` -* registers for VT release notifications -* restores `KD_TEXT` and exits on VT switch - -It does not access evdev, uinput, or interpret key events. - -### **Rationale** - -On systems without a graphical session, keyboard input reaches `getty` via the **VT keyboard handler**, independent of evdev. -Switching the VT to `KD_GRAPHICS` disables this path and effectively starves `getty` on that VT. - -This mirrors compositor behavior (Xorg / Wayland) without requiring rendering, DRM, or input stack integration. - -### **Designed Behavior: VT Switching Is Allowed** - -VT switching (e.g. `Ctrl+Alt+Fn`) is **intentionally not blocked**. - -This preserves a recovery path: - -* users can switch to another VT -* `getty` remains accessible for local login -* physical access always overrides remote control - -On VT switch, the VT-guard restores `KD_TEXT` and exits cleanly. - -### **Constraints and Guarantees** - -The VT-guard guarantees: - -* the active VT does not accept keyboard input -* `getty` is effectively disabled on that VT -* normal console behavior resumes on VT switch - -### **Alternatives Considered** - -* **Blocking VT switching** - Rejected to preserve local recovery. - ---- - ## **3.11 Fallback Graphical Session (`fallbackdm`)** ### **Problem Statement** @@ -620,6 +567,8 @@ Conceptually, `fallbackdm` acts as a **headless placeholder graphical session** * **Disabling gettys or VT switching globally** Rejected to preserve emergency local access and standard Linux behavior. +* **Depending on a full display manager** + Rejected, as this may require dummy display devices in headless configurations and adds unnecessary complexity. --- diff --git a/docs/USAGE.md b/docs/USAGE.md index 005645d..a047248 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -225,7 +225,111 @@ Sample output from `journalctl` showing vuinputd output: --- -## 7. Troubleshooting +## 7. Handling Phantom Input Events Caused by VTs + +On Linux systems without an active graphical session (X11 or Wayland), **virtual terminals (VTs)** remain in text mode (`KD_TEXT`) and continue to process keyboard input via the kernel VT keyboard handler. +This can lead to *phantom input events*, where injected or forwarded input (e.g. via `vuinputd`) unintentionally reaches: + +* `getty` login prompts +* inactive consoles +* kernel VT hotkeys (e.g. `Ctrl+Alt+Fn`) + +The following approaches can be used to prevent or mitigate this behavior. + +### Solution 1: Use KMSCON (DRM/KMS-based console) + +A robust solution is to replace the kernel VT text console with a **DRM/KMS-based console** such as `kmscon`. + +#### How it helps + +* The kernel VT is no longer responsible for input handling +* Keyboard input is processed via evdev, not the VT layer +* Seat assignment is respected: + + * devices on non-default seats (e.g. `seat_vuinput`) are ignored +* Phantom input events do not reach `getty` + +#### Notes + +* Requires DRM/KMS availability +* On most real GPUs, the DRM device remains available even when no monitor is connected and enters a hotplug-waiting state +* For headless systems, a virtual KMS device can be used: + + ```bash + modprobe vkms + ``` + +#### Trade-offs + +* Additional dependencies (DRM, kmscon) +* Not always desired for minimal or embedded systems + +### Solution 2: VT Guard Mode (`--vt-guard`) + +`vuinputd` can be started with the `--vt-guard` flag to explicitly neutralize VT input handling. + +#### How it works + +At startup, `vuinputd` performs a minimal VT operation such as: + +* switching the active VT into graphics mode (`KD_GRAPHICS`), or +* disabling the kernel keyboard processing for that VT + +This is done via direct VT ioctls (e.g. `KDSETMODE`), ensuring that: + +* the kernel VT keyboard handler is inactive +* `getty` does not receive injected input events + +#### Characteristics + +* Very lightweight +* No DRM, compositor, or additional services required +* Effective even on fully headless systems + +#### Caveats + +* Relies on low-level VT ioctls +* Considered **hacky**, but intentionally minimal +* Bypasses higher-level session management + +### Solution 3: fallbackdm (Work in Progress) + +`fallbackdm` is an experimental, lightweight **logind-integrated fallback display manager**. + +#### Intended behavior + +* Starts only when no graphical session is active +* Registers a proper `greeter` session with `systemd-logind` +* Takes ownership of a VT and switches it to `KD_GRAPHICS` +* Prevents `getty` and the VT keyboard handler from receiving input +* Leaves other VTs untouched for emergency local access + +#### Advantages + +* Clean integration with `systemd-logind` +* No direct VT hacks +* Compatible with standard Linux session semantics +* Designed to coexist with real display managers + +#### Status + +* Currently under development +* Intended as the long-term, principled solution + +### Summary + +| Solution | Headless | Lightweight | logind-aware | Recommended for | +| ------------ | --------- | ----------- | ------------ | ---------------------------- | +| KMSCON | ⚠️ (vkms) | ❌ | ✅ | Full console replacement | +| `--vt-guard` | ✅ | ✅ | ❌ | Minimal setups | +| fallbackdm | ✅ | ⚠️ | ✅ | Long-term, clean integration | + +Choose the approach that best fits your system constraints and deployment model. + + +--- + +## 8. Troubleshooting | Symptom | Possible Cause | Fix | | --------------------------- | ------------------------------------ | ------------------------------------------------- | @@ -246,7 +350,7 @@ Dez 14 21:33:17 wohnzimmer vuinputd[2172719]: called `Result::unwrap()` on an `E Ensure /dev and /run are writable in the container. If in doubt, use tmpfs. --- -## 8. Notes and Advanced Topics +## 9. Notes and Advanced Topics * You can safely run **multiple containers**. * Devices are automatically cleaned up when the container stops. @@ -258,7 +362,7 @@ Ensure /dev and /run are writable in the container. If in doubt, use tmpfs. --- -## 9. References +## 10. References * [mkosi manual](https://github.com/systemd/mkosi/blob/main/mkosi/resources/man/mkosi.1.md) * [Docker device rules documentation](https://docs.docker.com/engine/reference/run/#device-cgroup-rule) diff --git a/fallbackdm/README.md b/fallbackdm/README.md new file mode 100644 index 0000000..1c7afca --- /dev/null +++ b/fallbackdm/README.md @@ -0,0 +1,147 @@ +# fallbackdm + +> This crate is WIP and has not released any source, yet. + +**fallbackdm** is a minimal, headless display manager that exists solely to **own a seat and VT when no graphical session is running**. + +It prevents unintended keyboard input from reaching `getty` or the kernel VT layer by registering a proper **greeter session** with `systemd-logind`, activating a VT, and switching it to graphics mode — without starting X11 or Wayland. + +This is primarily useful for **kiosk setups, remote desktop systems, or input-virtualization scenarios** where no local user interaction is intended, but correct VT semantics must still be preserved. + +--- + +## Problem Statement + +On modern Linux systems: + +* Virtual terminals (VTs) still exist and have a kernel keyboard handler +* If **no graphical session is active**, `getty` will attach to a VT +* Input injected via `uinput` or forwarded from remote systems may: + + * Trigger `Ctrl+Alt+Fn` + * Wake or interfere with `getty` + * Cause VT switches or text-mode interaction + +Graphical compositors avoid this by: + +* Registering a session with `systemd-logind` +* Owning a VT +* Switching it to `KD_GRAPHICS` + +But when **no compositor or greeter is running**, nothing owns the VT. + +**fallbackdm fills exactly this gap.** + +--- + +## What fallbackdm Does + +* Registers a **`greeter` session** via PAM + `pam_systemd` +* Acquires a seat using **libseat** +* Activates a VT and switches it to graphics mode +* Keeps the session alive while no real graphical session exists +* Displays nothing and launches no compositor + +Once a real display manager or compositor starts, it naturally replaces `fallbackdm`. + +--- + +## What fallbackdm Does *Not* Do + +* ❌ No X11 +* ❌ No Wayland +* ❌ No greeter UI +* ❌ No input filtering (by design) +* ❌ No Device Ownership Required: Unlike a real compositor, `fallbackdm` does not need to open `/dev/dri/cardX` or `/dev/input/event*` to do its job. It only needs the TTY. This minimizes the attack surface significantly. + +It only ensures **correct session, seat, and VT ownership**. + +--- + +## When You Need This + +You **do not need fallbackdm** if: + +* X11 or Wayland is already running +* A display manager (gdm, sddm, greetd, etc.) is active + +You **do need fallbackdm** if: + +* The system boots without a graphical stack +* Input devices (especially `uinput`) must not reach `getty` +* You rely on logind-correct VT behavior without a real compositor + +--- + +## Architecture Overview + +``` +fallbackdm + ├─ PAM session (class=greeter) + ├─ pam_systemd + ├─ libseat + │ └─ seatd or systemd-logind backend + └─ VT activation + KD_GRAPHICS +``` + +This mirrors what real display managers do — just without launching anything graphical. + +--- + +## PAM Configuration + +Create `/etc/pam.d/fallbackdm`: + +``` +session required pam_systemd.so class=greeter +``` + +This is mandatory. Without it, logind will not track the session. + +--- + +## systemd Service Example + +```ini +[Unit] +Description=Fallback Display Manager +After=systemd-user-sessions.service +ConditionPathExists=!/run/graphical-session-active + +[Service] +ExecStart=/usr/bin/fallbackdm +PAMName=fallbackdm +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +> The condition is optional and can be replaced with more advanced logic later. + +--- + +## Relationship to Other Projects + +* **Display managers (gdm, sddm, greetd)** + Full login stacks with UI and session spawning. + +* **Greeters (gtkgreet, tuigreet)** + UI components launched *by* a display manager. + +* **fallbackdm** + A *headless*, compatibility-focused DM whose only job is to own the seat. + +--- + +## Future Ideas + +* Optional status output on the VT +* Signaling input-forwarding daemons (e.g. `vuinputd`) +* Conditional exit when a real session becomes active + +--- + +## License + +MIT \ No newline at end of file diff --git a/fallbackdm/systemd/pam.d_fallbackdm b/fallbackdm/systemd/pam.d_fallbackdm new file mode 100644 index 0000000..12db36c --- /dev/null +++ b/fallbackdm/systemd/pam.d_fallbackdm @@ -0,0 +1 @@ +session required pam_systemd.so class=greeter From 04bd88f17948eac3ec7e39e273486fa5fcfabe38 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 23 Dec 2025 20:26:07 +0000 Subject: [PATCH 038/108] Check for status of tty1 during startup --- docs/DESIGN.md | 20 +++++------- vuinputd/src/main.rs | 15 +++++++++ vuinputd/src/vt_tools.rs | 67 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 vuinputd/src/vt_tools.rs diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 433ec10..5f58dac 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -499,7 +499,7 @@ As a result: * input devices may interact with the VT layer in unintended ways When a graphical session is active, these issues do not occur: -the compositor, via `systemd-logind`, owns a VT, switches it to `KD_GRAPHICS`, and the VT keyboard handler is disabled automatically. +the compositor, via `systemd-logind`, owns a VT, switches it to `KD_GRAPHICS`, and the VT keyboard handler is **suppressed**. The missing piece is a **well-defined fallback** for the “no graphical session” case. @@ -511,12 +511,12 @@ The missing piece is a **well-defined fallback** for the “no graphical session It runs only when **no other graphical session is active** on the seat and exists solely to: -* open a regular logind session +* open a regular logind session **of class `greeter**` * occupy the assigned VT -* let logind switch the VT to `KD_GRAPHICS` +* let logind switch the VT to `KD_GRAPHICS` **and mute the keyboard handler** `fallbackdm` itself does **not** manipulate VTs, perform `KDSETMODE` ioctls, or access `/dev/tty` directly. -All VT and seat handling is delegated to `systemd-logind`. +All VT and seat handling is delegated to `systemd-logind` **via a standard PAM session**. --- @@ -524,15 +524,13 @@ All VT and seat handling is delegated to `systemd-logind`. * `fallbackdm` starts as a normal session on the seat * while active: - * the VT is in `KD_GRAPHICS` - * VT keyboard handling and `getty` input are suppressed + * **the kernel VT keyboard handler is muted (equivalent to `K_OFF` or `KDSKBMUTE`)** + * `getty` input is suppressed * when a real graphical session (greeter or compositor) starts: - * logind deactivates `fallbackdm` * VT ownership is transferred automatically * when the graphical session ends: - * `fallbackdm` may be restarted to reclaim the fallback role `fallbackdm` is non-interactive by design but may display **minimal status information** in the future. @@ -546,7 +544,6 @@ This design: * reuses existing, well-tested logind behavior * avoids duplicating VT and seat logic * guarantees compatibility with: - * Wayland compositors * graphical login managers * multi-seat setups @@ -560,13 +557,10 @@ Conceptually, `fallbackdm` acts as a **headless placeholder graphical session** * **Direct VT management (KDSETMODE, VT ioctls)** Rejected due to complexity, fragility, and duplication of logind functionality. - * **Filtering input at the evdev / uinput layer** Rejected as insufficient: it does not address VT keyboard handling or `getty` behavior. - * **Disabling gettys or VT switching globally** - Rejected to preserve emergency local access and standard Linux behavior. - + Rejected to preserve emergency local access and standard Linux behavior. **The logind-managed approach allows physical VT switching to remain functional for debugging.** * **Depending on a full display manager** Rejected, as this may require dummy display devices in headless configurations and adds unnecessary complexity. diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index bdf3ef6..c9601ca 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -16,10 +16,13 @@ // distinguish between cleanup jobs that must not be cancelled and other jobs (especially background jobs) // naming: dev_path vs dev_node. I guess I mean the same. // Send warning, if udev monitor does not exist +// Filter out Ctrl+Alt+Fx. "sysrq" keys or the low-level VT switching combos. use ::cuse_lowlevel::*; use log::info; use std::ffi::CString; +use std::fs::OpenOptions; +use std::os::fd::AsRawFd; use std::os::raw::c_char; use std::sync::atomic::AtomicU64; use std::sync::Mutex; @@ -40,6 +43,7 @@ use crate::process_tools::*; pub mod actions; pub mod jobs; +pub mod vt_tools; use clap::Parser; @@ -72,6 +76,12 @@ struct Args { help = "Path to /proc//ns used as the namespace source (e.g. /proc/1234/ns or /proc/self/ns)" )] pub target_namespace: Option, + + #[arg( + long = "vt-guard", + help = "Prevent leakage of uinput to VT by, sending K_OFF to /dev/tty0" + )] + pub vt_guard: bool, } fn validate_args(args: &Args) -> Result<(), String> { @@ -132,7 +142,12 @@ fn main() -> std::io::Result<()> { std::process::exit(error_code); } + if args.vt_guard { + vt_tools::mute_keyboard()?; + } + check_permissions().expect("failed to read the capabilities of the vuinputd process"); + vt_tools::check_vt_status(); initialize_vuinput_state(); VUINPUT_COUNTER.set(AtomicU64::new(3)).expect( diff --git a/vuinputd/src/vt_tools.rs b/vuinputd/src/vt_tools.rs new file mode 100644 index 0000000..60be9f5 --- /dev/null +++ b/vuinputd/src/vt_tools.rs @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::fs::OpenOptions; +use std::io; +use std::os::unix::io::AsRawFd; +use log::{error, info, warn}; + +use libc::ioctl; + +// see include/uapi/linux/kd.h +const KDSKBMODE: u64 = 0x4B45; // sets current keyboard mode +const KDGKBMODE: u64 = 0x4B44; // gets current keyboard mode + +const K_OFF: u64 = 0x04; + +pub fn check_vt_status() { + match OpenOptions::new().read(true).open("/dev/tty1") { + Err(err) if err.kind() == io::ErrorKind::NotFound => { + info!("/dev/tty1 not present — no VT-related input problem"); + } + Err(err) => { + error!("failed to open /dev/tty1: {}", err); + } + Ok(tty) => { + let fd = tty.as_raw_fd(); + let mut mode: u64 = 0; + + let rc = unsafe { ioctl(fd, KDGKBMODE, &mut mode) }; + if rc < 0 { + error!( + "KDGKBMODE ioctl failed: {}", + io::Error::last_os_error() + ); + return; + } + + if mode == K_OFF { + info!("tty1 keyboard mode is K_OFF — VT input is disabled"); + } else { + warn!( + "tty1 keyboard mode is active (mode={}) — VT may consume input", + mode + ); + } + } + } +} + +pub fn mute_keyboard() -> std::io::Result<()> { + // 1. Open the TTY (usually TTY1 for a DM) + let file = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty1")?; + let fd = file.as_raw_fd(); + + // 2. Mute the keyboard + unsafe { + if libc::ioctl(fd, KDSKBMODE, K_OFF) < 0 { + panic!("Failed to mute keyboard. Are you root?"); + } + } + println!("Keyboard muted."); + Ok(()) +} From 0c91a05ef7e3a1a38089b0ba278bd61948c377ac Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 23 Dec 2025 21:47:16 +0000 Subject: [PATCH 039/108] Improve help message and cargo fmt --- vuinputd/src/main.rs | 6 +++++- vuinputd/src/vt_tools.rs | 7 ++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index c9601ca..16f8763 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -79,7 +79,10 @@ struct Args { #[arg( long = "vt-guard", - help = "Prevent leakage of uinput to VT by, sending K_OFF to /dev/tty0" + help = "Prevent all keyboard input from reaching the VT by setting K_OFF on /dev/tty0.", + long_help = "Disable VT keyboard handling (K_OFF on /dev/tty0) to prevent uinput leakage.\n\ + This disables all keyboard input on the virtual terminals, including physical keyboards.\n\ + Loss of local access may require recovery via SSH or a rescue boot." )] pub vt_guard: bool, } @@ -144,6 +147,7 @@ fn main() -> std::io::Result<()> { if args.vt_guard { vt_tools::mute_keyboard()?; + std::process::exit(0); } check_permissions().expect("failed to read the capabilities of the vuinputd process"); diff --git a/vuinputd/src/vt_tools.rs b/vuinputd/src/vt_tools.rs index 60be9f5..ac5a558 100644 --- a/vuinputd/src/vt_tools.rs +++ b/vuinputd/src/vt_tools.rs @@ -2,10 +2,10 @@ // // Author: Johannes Leupolz +use log::{error, info, warn}; use std::fs::OpenOptions; use std::io; use std::os::unix::io::AsRawFd; -use log::{error, info, warn}; use libc::ioctl; @@ -29,10 +29,7 @@ pub fn check_vt_status() { let rc = unsafe { ioctl(fd, KDGKBMODE, &mut mode) }; if rc < 0 { - error!( - "KDGKBMODE ioctl failed: {}", - io::Error::last_os_error() - ); + error!("KDGKBMODE ioctl failed: {}", io::Error::last_os_error()); return; } From 76ba60a61493bbbdb37af0361aa503c549e69b7f Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 25 Dec 2025 21:32:07 +0000 Subject: [PATCH 040/108] Debian build scripts improved --- debian/control | 9 +++++++++ debian/rules | 18 +++++++++++++++--- debian/vuinputd.install | 3 ++- debian/vuinputd.postinst | 15 +++++++++++++++ vuinputd/udev/install_udev.sh | 15 +++++++++++++++ 5 files changed, 56 insertions(+), 4 deletions(-) create mode 100755 debian/vuinputd.postinst create mode 100644 vuinputd/udev/install_udev.sh diff --git a/debian/control b/debian/control index 721071c..5926f8d 100644 --- a/debian/control +++ b/debian/control @@ -2,6 +2,12 @@ Source: vuinputd Section: utils Priority: optional Maintainer: Johannes Leupolz +Depends: + ${misc:Depends}, + ${shlibs:Depends}, + systemd, + udev, + libfuse3-3 Build-Depends: debhelper (>= 12), dh-cargo (>= 24), dh-sequence-bash-completion, @@ -9,6 +15,9 @@ Build-Depends: debhelper (>= 12), rustc:native, pkg-config, libudev-dev, + libclang-dev, + libfuse3-dev, + libc6-dev, librust-bindgen-dev, librust-nix-dev, librust-libc-dev, diff --git a/debian/rules b/debian/rules index 3c7196c..2bd88e5 100755 --- a/debian/rules +++ b/debian/rules @@ -8,6 +8,18 @@ override_dh_auto_build: cargo build --release override_dh_auto_install: - install -D -m 0755 target/release/vuinputd debian/tmp/usr/bin/vuinputd - install -D -m 0755 vuinputd/udev/90-vuinputd.hwdb debian/tmp/usr/lib/udev/hwdb.d/90-vuinputd.hwdb - install -D -m 0755 vuinputd/udev/90-vuinputd-protect.rules debian/tmp/usr/lib/udev/rules.d/90-vuinputd-protect.rules \ No newline at end of file + # install binary + install -D -m 0755 target/release/vuinputd \ + debian/tmp/usr/bin/vuinputd + + # patch systemd unit for Debian (/usr/local/bin -> /usr/bin) + sed 's|/usr/local/bin/vuinputd|/usr/bin/vuinputd|g' \ + vuinputd/systemd/vuinputd.service \ + > debian/tmp/usr/lib/systemd/system/vuinputd.service + + # install udev rules + hwdb + install -D -m 0644 vuinputd/udev/90-vuinputd-protect.rules \ + debian/tmp/usr/lib/udev/rules.d/90-vuinputd-protect.rules + + install -D -m 0644 vuinputd/udev/90-vuinputd.hwdb \ + debian/tmp/usr/lib/udev/hwdb.d/90-vuinputd.hwdb \ No newline at end of file diff --git a/debian/vuinputd.install b/debian/vuinputd.install index b1faec9..f904965 100644 --- a/debian/vuinputd.install +++ b/debian/vuinputd.install @@ -1,3 +1,4 @@ usr/bin/vuinputd usr/lib/udev/hwdb.d/90-vuinputd.hwdb -usr/lib/udev/rules.d/90-vuinputd-protect.rules \ No newline at end of file +usr/lib/udev/rules.d/90-vuinputd-protect.rules +usr/lib/systemd/system/vuinputd.service \ No newline at end of file diff --git a/debian/vuinputd.postinst b/debian/vuinputd.postinst new file mode 100755 index 0000000..4562be8 --- /dev/null +++ b/debian/vuinputd.postinst @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +if [ "$1" = "configure" ]; then + if command -v systemd-hwdb >/dev/null 2>&1; then + systemd-hwdb update || true + fi + + if command -v udevadm >/dev/null 2>&1; then + udevadm control --reload-rules || true + udevadm trigger || true + fi +fi + +exit 0 diff --git a/vuinputd/udev/install_udev.sh b/vuinputd/udev/install_udev.sh new file mode 100644 index 0000000..0fc5d76 --- /dev/null +++ b/vuinputd/udev/install_udev.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +RULES_DIR=/usr/lib/udev/rules.d +HWDB_DIR=/usr/lib/udev/hwdb.d + +install -D -m 0644 90-vuinputd-protect.rules \ + "$RULES_DIR/90-vuinputd-protect.rules" + +install -D -m 0644 90-vuinputd.hwdb \ + "$HWDB_DIR/90-vuinputd.hwdb" + +systemd-hwdb update +udevadm control --reload-rules +udevadm trigger From 2704626d7744bd693a19d1748dbb3c9f1e059ad0 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 25 Dec 2025 21:32:40 +0000 Subject: [PATCH 041/108] Added hint regarding vt-guard in systemd service file --- vuinputd/systemd/vuinputd.service | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/vuinputd/systemd/vuinputd.service b/vuinputd/systemd/vuinputd.service index a34e9c2..d7eb792 100644 --- a/vuinputd/systemd/vuinputd.service +++ b/vuinputd/systemd/vuinputd.service @@ -4,6 +4,11 @@ After=systemd-udevd.service Requires=systemd-udevd.service [Service] +# The Flag --vt-guard disables VT keyboard handling (K_OFF on /dev/tty0) to prevent uinput leakage. +# This disables all keyboard input on the virtual terminals, including physical keyboards. +# Loss of local access may require recovery via SSH or a rescue boot. +#ExecStartPre=/usr/local/bin/vuinputd --vt-guard + # major 120 is reserved for local/experimental use. I picked minor 414795 with the use # of a random number generator to omit conflicts. ExecStart=/usr/local/bin/vuinputd --major 120 --minor 414795 @@ -16,6 +21,8 @@ Restart=on-failure # we need the permission to create all sorts of devices DeviceAllow=char-* rwm +# Enable debug logs during preproduction phase +Environment=RUST_LOG=debug [Install] WantedBy=multi-user.target From 9b1b93d44670ee9d72e0eda3289b7952a8c449ec Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 25 Dec 2025 21:35:56 +0000 Subject: [PATCH 042/108] Fix in debian/rules --- debian/rules | 1 + 1 file changed, 1 insertion(+) diff --git a/debian/rules b/debian/rules index 2bd88e5..e725841 100755 --- a/debian/rules +++ b/debian/rules @@ -13,6 +13,7 @@ override_dh_auto_install: debian/tmp/usr/bin/vuinputd # patch systemd unit for Debian (/usr/local/bin -> /usr/bin) + mkdir -p debian/tmp/usr/lib/systemd/system sed 's|/usr/local/bin/vuinputd|/usr/bin/vuinputd|g' \ vuinputd/systemd/vuinputd.service \ > debian/tmp/usr/lib/systemd/system/vuinputd.service From 00b80691e47dda88cba36b00edea5ce432ffcc1e Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 25 Dec 2025 21:41:01 +0000 Subject: [PATCH 043/108] Release pipeline for debian packages --- .github/workflows/debian-package.yml | 59 ++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/debian-package.yml diff --git a/.github/workflows/debian-package.yml b/.github/workflows/debian-package.yml new file mode 100644 index 0000000..b54ea6d --- /dev/null +++ b/.github/workflows/debian-package.yml @@ -0,0 +1,59 @@ +name: Debian Package + +on: + workflow_dispatch: + push: + tags: + - "*" + +env: + CARGO_TERM_COLOR: always + DEB_BUILD_OPTIONS: nocheck + +jobs: + build-deb: + runs-on: ubuntu-latest + + steps: + - name: Checkout source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install build dependencies + run: | + sudo apt update + sudo apt install -y \ + build-essential \ + devscripts \ + debhelper \ + dh-cargo \ + cargo \ + rustc \ + pkg-config \ + libudev-dev \ + libfuse3-dev \ + fuse3 + + - name: Show versions (debug) + run: | + rustc --version + cargo --version + dpkg-buildpackage --version + + - name: Build Debian package + run: | + dpkg-buildpackage -us -uc -b + + - name: Collect artifacts + run: | + mkdir -p artifacts + mv ../*.deb artifacts/ || true + mv ../*.buildinfo artifacts/ || true + mv ../*.changes artifacts/ || true + + - name: Upload Debian artifacts + uses: actions/upload-artifact@v4 + with: + name: vuinputd-debian-package + path: artifacts/ From 7c25eb2cfeab499400adf5ce690c26fdac9811cb Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 25 Dec 2025 21:50:47 +0000 Subject: [PATCH 044/108] Debian pipeline fixes --- .github/workflows/debian-package.yml | 6 +++++- debian/control | 13 ++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/debian-package.yml b/.github/workflows/debian-package.yml index b54ea6d..181966e 100644 --- a/.github/workflows/debian-package.yml +++ b/.github/workflows/debian-package.yml @@ -23,6 +23,7 @@ jobs: - name: Install build dependencies run: | sudo apt update + # cargo deps sudo apt install -y \ build-essential \ devscripts \ @@ -33,7 +34,10 @@ jobs: pkg-config \ libudev-dev \ libfuse3-dev \ - fuse3 + fuse3 \ + libclang-dev + # debian packages, if packages are not downloaded via cargo + sudo apt install -y librust-bindgen-dev librust-nix-dev librust-libc-dev librust-time-dev librust-log-dev librust-env-logger-dev librust-libudev-dev librust-regex-dev librust-async-channel-dev librust-futures-dev librust-async-io-dev librust-anyhow-dev librust-clap-dev - name: Show versions (debug) run: | diff --git a/debian/control b/debian/control index 5926f8d..6b594ae 100644 --- a/debian/control +++ b/debian/control @@ -2,12 +2,6 @@ Source: vuinputd Section: utils Priority: optional Maintainer: Johannes Leupolz -Depends: - ${misc:Depends}, - ${shlibs:Depends}, - systemd, - udev, - libfuse3-3 Build-Depends: debhelper (>= 12), dh-cargo (>= 24), dh-sequence-bash-completion, @@ -39,5 +33,10 @@ Rules-Requires-Root: no Package: vuinputd Architecture: amd64 -Depends: ${shlibs:Depends}, ${misc:Depends} +Depends: + ${misc:Depends}, + ${shlibs:Depends}, + systemd, + udev, + libfuse3-3 Description: Virtual input forwarder for Linux. \ No newline at end of file From ccd4adc5ec153832836c45168e55d39a5b95215b Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 25 Dec 2025 21:59:07 +0000 Subject: [PATCH 045/108] Release v0.3.1 --- debian/changelog | 18 ++++++++++++++++++ vuinputd/Cargo.toml | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/debian/changelog b/debian/changelog index e94c251..5d1814e 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,20 @@ +vuinputd (0.3.1-1) unstable; urgency=medium + + * Improve Debian packaging and release process: + - Add release pipeline for Debian packages. + - Improve Debian build scripts. + - Fix issues in debian/rules. + * Improve startup robustness: + - Check the status of tty1 during startup. + - Add hints regarding vt-guard usage to the systemd service file. + * Documentation and usability improvements: + - Improve help messages. + - Document two approaches to mitigate the VT keyboard handler issue when + no graphical input session is active. + + -- Johannes Leupolz Fri, 20 Dec 2025 22:00:00 +0000 + + vuinputd (0.3.0-1) unstable; urgency=medium * Refactor container and namespace handling: @@ -19,6 +36,7 @@ vuinputd (0.3.0-1) unstable; urgency=medium -- Johannes Leupolz Thu, 19 Dec 2025 21:30:00 +0000 + vuinputd (0.2.0-1) unstable; urgency=medium * Register official USB vendor/product ID (VID 0x1209, PID 0x5020) under diff --git a/vuinputd/Cargo.toml b/vuinputd/Cargo.toml index 20ea5ff..4f7485d 100644 --- a/vuinputd/Cargo.toml +++ b/vuinputd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vuinputd" -version = "0.3.0" +version = "0.3.1" edition = "2021" authors = ["Johannes Leupolz "] license = "MIT" From 2c85f8244873413b644a63dbf228936f27d24010 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sat, 27 Dec 2025 22:50:06 +0000 Subject: [PATCH 046/108] Remove unused code --- vuinputd/src/cuse_device/vuinput_ioctl.rs | 1 - vuinputd/src/jobs/emit_udev_event_in_container_job.rs | 1 - vuinputd/src/jobs/mknod_device_in_container_job.rs | 8 +------- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/vuinputd/src/cuse_device/vuinput_ioctl.rs b/vuinputd/src/cuse_device/vuinput_ioctl.rs index 6d8d49d..a8ca777 100644 --- a/vuinputd/src/cuse_device/vuinput_ioctl.rs +++ b/vuinputd/src/cuse_device/vuinput_ioctl.rs @@ -7,7 +7,6 @@ use libc::{iovec, size_t, EBADRQC}; use libc::{uinput_abs_setup, uinput_ff_erase, uinput_ff_upload, uinput_setup}; use log::debug; use std::ffi::CStr; -use std::io::Write; use std::os::fd::AsRawFd; use std::os::raw::{c_char, c_int, c_uint, c_void}; use uinput_ioctls::*; diff --git a/vuinputd/src/jobs/emit_udev_event_in_container_job.rs b/vuinputd/src/jobs/emit_udev_event_in_container_job.rs index fe74777..45ec2a1 100644 --- a/vuinputd/src/jobs/emit_udev_event_in_container_job.rs +++ b/vuinputd/src/jobs/emit_udev_event_in_container_job.rs @@ -143,7 +143,6 @@ impl EmitUdevEventInContainerJob { let runtime_data = runtime_data.unwrap(); let netlink_data = netlink_data.unwrap(); - let dev_path = self.dev_path.clone(); let emit_udev_event_action = Action::EmitUdevEvent { netlink_message: netlink_data.clone(), diff --git a/vuinputd/src/jobs/mknod_device_in_container_job.rs b/vuinputd/src/jobs/mknod_device_in_container_job.rs index 3159c68..d6d9fd9 100644 --- a/vuinputd/src/jobs/mknod_device_in_container_job.rs +++ b/vuinputd/src/jobs/mknod_device_in_container_job.rs @@ -3,20 +3,14 @@ // Author: Johannes Leupolz use std::{ - collections::HashMap, future::Future, pin::Pin, sync::{Arc, Condvar, Mutex}, - time::Duration, }; -use async_io::Timer; -use log::debug; - use crate::{ - actions::{action::Action, runtime_data::read_udev_data}, + actions::{action::Action}, job_engine::job::{Job, JobTarget}, - jobs::monitor_udev_job::EVENT_STORE, process_tools::{self, await_process, Pid, RequestingProcess}, }; From 66d58f074f7576227f1d0ad97198831da2d7ef07 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sat, 27 Dec 2025 23:49:20 +0000 Subject: [PATCH 047/108] Make it easier to use strace to debug the child process. Now it allows to encode the action in base64 to make it easier to enter it in a shell. Also, the debug outputs the action json as base64 encoded json whenever a mknod or another action should be called in a container. --- vuinputd/Cargo.toml | 1 + .../src/jobs/mknod_device_in_container_job.rs | 2 +- vuinputd/src/main.rs | 40 ++++++++++++++++--- vuinputd/src/process_tools/mod.rs | 15 +++++++ 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/vuinputd/Cargo.toml b/vuinputd/Cargo.toml index 4f7485d..95e917c 100644 --- a/vuinputd/Cargo.toml +++ b/vuinputd/Cargo.toml @@ -29,3 +29,4 @@ anyhow = "1.0.100" clap = { version = "4", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +base64 = "0.22" diff --git a/vuinputd/src/jobs/mknod_device_in_container_job.rs b/vuinputd/src/jobs/mknod_device_in_container_job.rs index d6d9fd9..90d3a7d 100644 --- a/vuinputd/src/jobs/mknod_device_in_container_job.rs +++ b/vuinputd/src/jobs/mknod_device_in_container_job.rs @@ -9,7 +9,7 @@ use std::{ }; use crate::{ - actions::{action::Action}, + actions::action::Action, job_engine::job::{Job, JobTarget}, process_tools::{self, await_process, Pid, RequestingProcess}, }; diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 16f8763..822c501 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -19,10 +19,10 @@ // Filter out Ctrl+Alt+Fx. "sysrq" keys or the low-level VT switching combos. use ::cuse_lowlevel::*; +use base64::prelude::BASE64_STANDARD; +use base64::Engine as _; use log::info; use std::ffi::CString; -use std::fs::OpenOptions; -use std::os::fd::AsRawFd; use std::os::raw::c_char; use std::sync::atomic::AtomicU64; use std::sync::Mutex; @@ -69,6 +69,10 @@ struct Args { #[arg(long, value_name = "JSON")] pub action: Option, + /// Action to execute (base64-encoded JSON). Note that this excludes all other options. + #[arg(long = "action-base64", value_name = "BASE64")] + pub action_base64: Option, + /// Path to the target process's /proc//ns directory used as namespace source. #[arg( long = "target-namespace", @@ -88,18 +92,27 @@ struct Args { } fn validate_args(args: &Args) -> Result<(), String> { + let action: &Option = match (&args.action, &args.action_base64) { + (None, None) => &None, + (None, Some(_)) => &args.action_base64, + (Some(_), None) => &args.action, + (Some(_), Some(_)) => { + return Err("--action and --action-base64 may not be used together".into()); + } + }; + // action might only occur with target-namespace match ( &args.major, &args.minor, &args.devname, - &args.action, + action, &args.target_namespace, ) { (None, None, None, Some(_), _) => {} (_, _, _, None, None) => {} _ => { - return Err("--action 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-namespace".into()); } } @@ -137,11 +150,26 @@ fn main() -> std::io::Result<()> { std::process::exit(2); } - if args.action.is_some() { + let action = match (&args.action, &args.action_base64) { + (Some(json), None) => Some(json.clone()), + (None, Some(b64)) => { + let decoded = BASE64_STANDARD + .decode(&b64) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + + let decoded = String::from_utf8(decoded) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + Some(decoded) + } + (None, None) => None, + _ => unreachable!("validate_args enforces mutual exclusion"), + }; + + 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(); } - let error_code = actions::handle_action::handle_cli_action(args.action.unwrap()); + let error_code = actions::handle_action::handle_cli_action(action.unwrap()); std::process::exit(error_code); } diff --git a/vuinputd/src/process_tools/mod.rs b/vuinputd/src/process_tools/mod.rs index f57e045..e54119e 100644 --- a/vuinputd/src/process_tools/mod.rs +++ b/vuinputd/src/process_tools/mod.rs @@ -3,6 +3,8 @@ // Author: Johannes Leupolz use async_io::Async; +use base64::prelude::BASE64_STANDARD; +use base64::Engine as _; use log::debug; use std::{ fs::{self, File}, @@ -252,10 +254,23 @@ pub fn get_requesting_process(pid: Pid) -> RequestingProcess { } } +fn print_debug_string(action: &str, ns: &RequestingProcess) { + 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(" --action-base64 "); + debugstring.push_str(action_base64.as_str()); + debugstring.push_str("`"); + debug!("{}", debugstring); +} + /// Runs a function inside the given network and mount namespaces. /// Returns the child PID so the caller can `waitpid` on it. pub fn start_action(action: Action, ns: &RequestingProcess) -> anyhow::Result { let action_json = serde_json::to_string(&action).unwrap(); + print_debug_string(&action_json, &ns); let child = unsafe { Command::new("/proc/self/exe") From 7fbb1616edf5a305309a3ade6f035311ef59e7ef Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sun, 28 Dec 2025 00:10:46 +0000 Subject: [PATCH 048/108] Add documentation how to debug the actions that vuinputd triggers in containers --- README.md | 1 + docs/DEBUG.md | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 docs/DEBUG.md diff --git a/README.md b/README.md index c7f4b92..dcfe264 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ sequenceDiagram See [docs/BUILD.md](https://github.com/joleuger/vuinputd/blob/main/docs/BUILD.md) for a short build and installation guide. See [docs/DESIGN.md](https://github.com/joleuger/vuinputd/blob/main/docs/DESIGN.md) for a detailed overview of the architecture, design trade-offs, and security considerations. See [docs/USAGE.md](https://github.com/joleuger/vuinputd/blob/main/docs/USAGE.md) for a short usage guide. +See [docs/DEBUG.md](https://github.com/joleuger/vuinputd/blob/main/docs/DEBUG.md) for a guide how to debug problems with containers. --- diff --git a/docs/DEBUG.md b/docs/DEBUG.md new file mode 100644 index 0000000..75deac2 --- /dev/null +++ b/docs/DEBUG.md @@ -0,0 +1,109 @@ +# DEBUG.md + +## Debugging vuinputd + +vuinputd performs low-level operations such as entering Linux namespaces and creating input device nodes. When something goes wrong, the root cause is often related to container lifetime, namespace visibility, or kernel security mechanisms. + +This document describes how to debug common issues. + +--- + +## Enable debug logging + +Run vuinputd with debug logging enabled: + +```bash +RUST_LOG=debug vuinputd ... +``` + +This will emit additional diagnostic output, especially around namespace entry and `mknod` execution. + +--- + +## Debugging `mknod` inside a container + +Whenever vuinputd is about to execute `mknod` inside a container namespace, it prints a debug message similar to: + +``` +[2025-12-27T23:45:06Z DEBUG vuinputd::job_engine::job] Executing job: mknod input device in container +[2025-12-27T23:45:06Z DEBUG vuinputd::process_tools] In case you need to debug the system calls, call strace vuinputd --target-namespace /proc/103086/ns --action-base64 eyJhY3Rpb24iOiJta25vZC1kZXZpY2UiLCJwYXRoIjoiL2Rldi9pbnB1dC9ldmVudDEyIiwibWFqb3IiOjEzLCJtaW5vciI6NzZ9 +``` + +The printed command can be used verbatim to trace the exact system calls involved. + +--- + +## Using `strace` + +Run the suggested command under `strace`: + +```bash +strace vuinputd --target-namespace /proc/103086/ns --action-base64 +``` + +Typical failure output may look like: + +```text +strace target/debug/vuinputd --target-namespace /proc/102044/ns --action-base64 eyJhY3Rpb24iOiJta25vZC1kZXZpY2UiLCJwYXRoIjoiL2Rldi9pbnB1dC9ldmVudDEyIiwibWFqb3IiOjEzLCJtaW5vciI6NzZ9 +... +statx(AT_FDCWD, "/proc/102044/ns", AT_STATX_SYNC_AS_STAT, STATX_ALL, 0x7ffeb6bea3c0) = -1 ENOENT (No such file or directory) +... +thread 'main' (103610) panicked at vuinputd/src/main.rs:170:84: +called Result::unwrap() on an Err value: the root process of the container whose namespaces we want to enter does not exist anymore +... ++++ exited with 101 +++ +``` + +### Interpretation + +In this example, `/proc/102044/ns` no longer exists, which means the container process has already terminated. Entering its namespaces is therefore impossible. + +This usually indicates: + +* the container exited before vuinputd ran, +* a race between container startup and vuinputd execution, +* or an incorrect PID being passed as `--target-namespace`. + +--- + +## Common causes of permission errors + +If `mknod` fails with `EPERM` or similar errors, possible causes include: + +* Missing `CAP_MKNOD` in the namespace where vuinputd is running +* seccomp filters blocking `mknod` +* SELinux or AppArmor policies +* eBPF-based LSM policies +* Read-only or improperly mounted `/dev` +* Missing permissions to create devices (systemd actually needs `DeviceAllow=char-* rwm` in service files) + +Using `strace` usually makes these issues visible immediately. + +--- + +## `/run/udev` and libinput + +libinput expects certain udev runtime files to exist, even if no udev daemon is running inside the container. + +In minimal or containerized environments, make sure the following paths exist and are writable: + +```bash +mkdir -p /run/udev/data +touch /run/udev/control +``` + +This is a known libinput behavior in containerized setups and not specific to vuinputd. + +--- + +## When reporting issues + +If you open an issue, please include: + +* full debug logs (`RUST_LOG=debug`) +* the exact `strace` output, if available +* whether host and container share `/dev/input` +* whether vuinputd runs on the host or inside the container +* relevant security mechanisms (seccomp / SELinux / AppArmor) + +This makes it much easier to reproduce and diagnose the problem. \ No newline at end of file From 84df502d253d01fb93d56b940890602a9c9d8799 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sun, 28 Dec 2025 00:14:28 +0000 Subject: [PATCH 049/108] Release 0.3.2 --- debian/changelog | 12 ++++++++++++ debian/control | 1 + vuinputd/Cargo.toml | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/debian/changelog b/debian/changelog index 5d1814e..8679e1b 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,15 @@ +vuinputd (0.3.2-1) unstable; urgency=medium + + * Improve debugging support for container execution: + - Add documentation describing how to debug actions triggered by + vuinputd inside containers. + - Make it easier to use strace to debug child processes. + - Log action descriptions as base64-encoded JSON when executing + container actions (e.g. mknod), simplifying shell-based debugging. + + -- Johannes Leupolz Sat, 21 Dec 2025 10:30:00 +0000 + + vuinputd (0.3.1-1) unstable; urgency=medium * Improve Debian packaging and release process: diff --git a/debian/control b/debian/control index 6b594ae..0a52053 100644 --- a/debian/control +++ b/debian/control @@ -25,6 +25,7 @@ Build-Depends: debhelper (>= 12), librust-async-io-dev, librust-anyhow-dev, librust-clap-dev, + librust-base64-dev, pkg-config, build-essential Standards-Version: 4.6.0 diff --git a/vuinputd/Cargo.toml b/vuinputd/Cargo.toml index 95e917c..69852aa 100644 --- a/vuinputd/Cargo.toml +++ b/vuinputd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vuinputd" -version = "0.3.1" +version = "0.3.2" edition = "2021" authors = ["Johannes Leupolz "] license = "MIT" From a7a207e029c64df4bc3e2343aa7ec01e3ea47af1 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sun, 28 Dec 2025 00:19:40 +0000 Subject: [PATCH 050/108] Fix github actions pipeline --- .github/workflows/debian-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/debian-package.yml b/.github/workflows/debian-package.yml index 181966e..c2d8bcb 100644 --- a/.github/workflows/debian-package.yml +++ b/.github/workflows/debian-package.yml @@ -37,7 +37,7 @@ jobs: fuse3 \ libclang-dev # debian packages, if packages are not downloaded via cargo - sudo apt install -y librust-bindgen-dev librust-nix-dev librust-libc-dev librust-time-dev librust-log-dev librust-env-logger-dev librust-libudev-dev librust-regex-dev librust-async-channel-dev librust-futures-dev librust-async-io-dev librust-anyhow-dev librust-clap-dev + sudo apt install -y librust-bindgen-dev librust-nix-dev librust-libc-dev librust-time-dev librust-log-dev librust-env-logger-dev librust-libudev-dev librust-regex-dev librust-async-channel-dev librust-futures-dev librust-async-io-dev librust-anyhow-dev librust-clap-dev librust-base64-dev - name: Show versions (debug) run: | From f8bdb04d7ef452dea50485013fb449a78c450166 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 29 Dec 2025 23:44:15 +0000 Subject: [PATCH 051/108] Improve troubleshooting --- docs/TROUBLESHOOTING.md | 62 ++++++++++++++++++++++++++++ vuinputd/src/actions/runtime_data.rs | 9 +++- 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 docs/TROUBLESHOOTING.md diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..6309b93 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,62 @@ +# Troubleshooting + +This document lists known error codes, their meaning, and how to resolve them. + +Error codes are stable identifiers intended to help diagnose problems in +different environments such as bare metal, systemd services, and containers. + +--- + +## How to use this document + +1. Locate the error code printed by the application +2. Search for it in this document +3. Follow the diagnostic steps +4. Apply the suggested resolution + +Error messages may change over time; error codes do not. + +--- + +## Error Code Index + +| Code | Area | Summary | +|------|------|--------| +| VUI-UDEV-001 | udev | udev control socket not reachable | + +--- + +## Error Codes + +--- + +### VUI-UDEV-001 — /run/udev/control/ not available. Keyboard or mouse might be unusable. + +**Symptoms** + +* No keyboard or mouse usable + +**Cause** +This might be a problem when an application that uses libinput has already been started, because libinput only checks the file existance at startup. + +**How to diagnose** + +Check in container for file existence: +```sh +ls -l /run/udev/control +``` + +**Resolution** + +* Create /run/udev/data directory and /run/udev/control file during startup. See [USAGE.md](USAGE.md). + +--- + +## Reporting Issues + +When reporting an issue, please include: + +* The error code(s) +* Full command-line invocation +* Execution environment (host, container, systemd) +* Relevant debug logs (see [DEBUG.md](DEBUG.md)) diff --git a/vuinputd/src/actions/runtime_data.rs b/vuinputd/src/actions/runtime_data.rs index 8c5903e..c9fc65b 100644 --- a/vuinputd/src/actions/runtime_data.rs +++ b/vuinputd/src/actions/runtime_data.rs @@ -6,9 +6,11 @@ use std::fs::{self, File}; use std::io::{self, Write}; use std::path::Path; +use log::{info, warn}; + /// Ensure required udev directories and files exist pub fn ensure_udev_structure() -> io::Result<()> { - // TODO: this _must_ exist, before a service using libinput is run. The time of device creation might be too late + // Note that this structure _must_ exist, before a service using libinput is run. The time of device creation might be too late. let data_dir = Path::new("/run/udev/data"); let control_file = Path::new("/run/udev/control"); @@ -20,6 +22,11 @@ pub fn ensure_udev_structure() -> io::Result<()> { // Ensure /run/udev/control exists, create empty if not if !control_file.exists() { + warn!( + "VUI-UDEV-001 — /run/udev/control/ not available. Keyboard or mouse might be unusable." + ); + warn!("Visit https://github.com/joleuger/vuinputd/blob/main/docs/TROUBLESHOOTING.md for details"); + info!("Creating file /run/udev/control anyway for subsequent runs."); File::create(control_file)?; } From 5277a13904fbd5be55c1161befb0d97c4e4ecbd1 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 6 Jan 2026 22:49:15 +0000 Subject: [PATCH 052/108] Started work on device policy #2 #4 --- docs/DESIGN.md | 21 ++++++++--- vuinputd/src/cuse_device/device_policy.rs | 3 ++ vuinputd/src/cuse_device/mod.rs | 1 + vuinputd/src/cuse_device/state.rs | 20 ++++++++++ vuinputd/src/cuse_device/vuinput_open.rs | 1 + vuinputd/src/global_config.rs | 45 +++++++++++++++++++++++ vuinputd/src/main.rs | 7 ++++ 7 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 vuinputd/src/cuse_device/device_policy.rs create mode 100644 vuinputd/src/global_config.rs diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 5f58dac..503d468 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -503,8 +503,6 @@ the compositor, via `systemd-logind`, owns a VT, switches it to `KD_GRAPHICS`, a The missing piece is a **well-defined fallback** for the “no graphical session” case. ---- - ### **Decision** `fallbackdm` is implemented as a **logind-managed fallback graphical session**. @@ -518,7 +516,6 @@ It runs only when **no other graphical session is active** on the seat and exist `fallbackdm` itself does **not** manipulate VTs, perform `KDSETMODE` ioctls, or access `/dev/tty` directly. All VT and seat handling is delegated to `systemd-logind` **via a standard PAM session**. ---- ### **Behavior and Lifecycle** @@ -535,7 +532,6 @@ All VT and seat handling is delegated to `systemd-logind` **via a standard PAM s `fallbackdm` is non-interactive by design but may display **minimal status information** in the future. ---- ### **Rationale** @@ -551,7 +547,6 @@ This design: Conceptually, `fallbackdm` acts as a **headless placeholder graphical session** that ensures consistent system behavior even when no real graphical environment is running. ---- ### **Alternatives Considered** @@ -566,6 +561,22 @@ Conceptually, `fallbackdm` acts as a **headless placeholder graphical session** --- +### 3.12 Device Policies & Input Sanitization + +Exposing raw access to `/dev/uinput` inside a container introduces significant security risks. A malicious process could theoretically emulate a keyboard to execute "BadUSB"-style attacks, trigger kernel-level commands (Magic SysRq), or switch Virtual Terminals (VT) to escape the graphical session. + +To mitigate this, `vuinputd` implements an **Active Filtering Layer** (CUSE middleware) that enforces strict device policies before requests reach the host kernel. This is controlled via the `--device-policy` flag. + +The filtering operates on two levels (Defense in Depth): + +1. **Capability Filtering (`ioctl`):** During device creation, `vuinputd` inspects `UI_SET_KEYBIT`, `UI_SET_RELBIT`, etc. If a container requests capabilities forbidden by the active policy (e.g., a gamepad trying to claim it has a SysRq key), the request is silently ignored or rejected. The resulting device on the host simply lacks those hardware capabilities. +2. **Event Filtering (`write`):** At runtime, `vuinputd` inspects the stream of input events. It maintains internal state (tracking modifiers like `Alt` or `Ctrl`) to detect and drop dangerous sequences (e.g., `Alt` + `F1-F12` for VT switching) that the capability filter alone cannot block. + +**Supported Policies:** + +* **`strict-gamepad` (Whitelist):** Designed for console-like isolation. It strictly permits only Gamepad/Joystick events (`EV_KEY` buttons, `EV_ABS` axes). It proactively blocks `EV_REL` (mouse movement) and `ABS_MT` (multitouch), effectively "neutering" complex controllers (like DualSense or Wiimotes) so they cannot be used to hijack the host mouse cursor. +* **`sanitized` (Blacklist):** Designed for desktop gaming. It allows standard Keyboard and Mouse input but strictly filters dangerous keys (`KEY_SYSRQ`, `KEY_POWER`) and host-management shortcuts (VT switching, CAD), providing a safe "sandboxed keyboard." + ## 4. Security Considerations `vuinputd` must currently run with **root privileges** to: diff --git a/vuinputd/src/cuse_device/device_policy.rs b/vuinputd/src/cuse_device/device_policy.rs new file mode 100644 index 0000000..9e775cc --- /dev/null +++ b/vuinputd/src/cuse_device/device_policy.rs @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz \ No newline at end of file diff --git a/vuinputd/src/cuse_device/mod.rs b/vuinputd/src/cuse_device/mod.rs index 5c5852e..bb71a3f 100644 --- a/vuinputd/src/cuse_device/mod.rs +++ b/vuinputd/src/cuse_device/mod.rs @@ -2,6 +2,7 @@ // // Author: Johannes Leupolz +pub mod device_policy; pub mod state; pub mod vuinput_ioctl; pub mod vuinput_open; diff --git a/vuinputd/src/cuse_device/state.rs b/vuinputd/src/cuse_device/state.rs index d296a9d..713c771 100644 --- a/vuinputd/src/cuse_device/state.rs +++ b/vuinputd/src/cuse_device/state.rs @@ -18,11 +18,31 @@ pub struct VuInputDevice { pub devnode: String, } +#[derive(Debug)] +pub struct KeyTracker { + pub left_alt_down: bool, + pub right_alt_down: bool, + pub left_ctrl_down: bool, + pub right_ctrl_down: bool, +} + +impl KeyTracker { + pub fn new() -> Self { + Self { + left_alt_down: false, + right_alt_down: false, + left_ctrl_down: false, + right_ctrl_down: false, + } + } +} + #[derive(Debug)] pub struct VuInputState { pub file: File, pub requesting_process: RequestingProcess, pub input_device: Option, + pub keytracker: KeyTracker, } #[derive(Debug, Eq, Hash, PartialEq, Clone)] diff --git a/vuinputd/src/cuse_device/vuinput_open.rs b/vuinputd/src/cuse_device/vuinput_open.rs index 6603000..666de32 100644 --- a/vuinputd/src/cuse_device/vuinput_open.rs +++ b/vuinputd/src/cuse_device/vuinput_open.rs @@ -54,6 +54,7 @@ pub unsafe extern "C" fn vuinput_open( file: v, requesting_process, input_device: None, + keytracker: KeyTracker::new() }, ) .unwrap(); diff --git a/vuinputd/src/global_config.rs b/vuinputd/src/global_config.rs new file mode 100644 index 0000000..c5fd5ad --- /dev/null +++ b/vuinputd/src/global_config.rs @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use clap::{Parser, ValueEnum}; +use std::sync::OnceLock; + +// --- 1. Define the Global State Container --- +// This struct is extensible. You can add more global settings here later. +#[derive(Debug)] +pub struct GlobalConfig { + pub policy: DevicePolicy, +} + +// The actual static variable. It starts empty and is set once in main(). +pub static CONFIG: OnceLock = OnceLock::new(); + +// --- 2. Define the Policy Enum --- +#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum, Default)] +#[clap(rename_all = "kebab-case")] // This ensures StrictGamepad becomes "strict-gamepad" +pub enum DevicePolicy { + /// Allow all device capabilities + None, + /// Default: Allow keyboards/mice but block dangerous keys (SysRq, VT switching) + #[default] + Sanitized, + /// Only allow Gamepad-like devices. Block mice and keyboards. + StrictGamepad, +} + +pub fn initialize_global_config(device_policy: &DevicePolicy) { + if CONFIG + .set(GlobalConfig { + policy: device_policy.clone(), + }) + .is_err() + { + eprintln!("Failed to initialize global config"); + std::process::exit(1); + } +} + +pub fn get_device_policy<'a>() -> &'a DevicePolicy { + &CONFIG.get().unwrap().policy +} diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 822c501..6c3559f 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -32,6 +32,7 @@ pub mod cuse_device; 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; use crate::jobs::monitor_udev_job::MonitorBackgroundLoop; pub mod process_tools; @@ -42,6 +43,7 @@ use crate::process_tools::*; pub mod actions; +pub mod global_config; pub mod jobs; pub mod vt_tools; @@ -89,6 +91,10 @@ struct Args { Loss of local access may require recovery via SSH or a rescue boot." )] pub vt_guard: bool, + + /// Enforce a device policy on created devices + #[arg(long, value_enum, default_value_t)] + device_policy: DevicePolicy, } fn validate_args(args: &Args) -> Result<(), String> { @@ -181,6 +187,7 @@ 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); initialize_vuinput_state(); VUINPUT_COUNTER.set(AtomicU64::new(3)).expect( "failed to initialize the counter that provides the values of the CUSE file handles", From 5a675aac341cf0e80b3f150419ee7ad7d60e34cc Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 7 Jan 2026 22:38:14 +0000 Subject: [PATCH 053/108] Implemented filter logic for device policy. Not used or tested, yet. --- vuinputd/src/cuse_device/device_policy.rs | 132 +++++++++++++++++++++- vuinputd/src/cuse_device/vuinput_open.rs | 2 +- vuinputd/src/global_config.rs | 2 +- 3 files changed, 133 insertions(+), 3 deletions(-) diff --git a/vuinputd/src/cuse_device/device_policy.rs b/vuinputd/src/cuse_device/device_policy.rs index 9e775cc..203100c 100644 --- a/vuinputd/src/cuse_device/device_policy.rs +++ b/vuinputd/src/cuse_device/device_policy.rs @@ -1,3 +1,133 @@ // SPDX-License-Identifier: MIT // -// Author: Johannes Leupolz \ No newline at end of file +// Author: Johannes Leupolz + +use libc::input_event; + +// event types and codes from https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h + +const EV_SYN: u16 = 0x00; +const EV_KEY: u16 = 0x01; +const EV_REL: u16 = 0x02; +const EV_ABS: u16 = 0x03; +const EV_MSC: u16 = 0x04; +const EV_SW: u16 = 0x05; +const EV_LED: u16 = 0x11; +const EV_SND: u16 = 0x12; +const EV_REP: u16 = 0x14; +const EV_FF: u16 = 0x15; +const EV_PWR: u16 = 0x16; +const EV_FF_STATUS: u16 = 0x17; +const EV_MAX: u16 = 0x1f; + +// special keyboard keys +const KEY_LEFTALT: u16 = 56; +const KEY_RIGHTALT: u16 = 100; +const KEY_LEFTCTRL: u16 = 29; +const KEY_RIGHTCTRL: u16 = 97; +const KEY_F1: u16 = 59; +const KEY_F10: u16 = 68; +const KEY_F11: u16 = 87; +const KEY_F12: u16 = 88; +const KEY_SYSRQ: u16 = 99; +const KEY_DELETE: u16 = 111; +const KEY_KPDOT: u16 = 83; +const KEY_POWER: u16 = 116; +const KEY_SLEEP: u16 = 142; +const KEY_WAKEUP: u16 = 143; + +// Gamepad keys from https://github.com/torvalds/linux/blob/master/Documentation/input/gamepad.rst +// First range +const BTN_SOUTH: u16 = 0x130; +const BTN_THUMBR: u16 = 0x13e; +// Second range +const BTN_DPAD_UP: u16 = 0x220; +const BTN_GRIPR2: u16 = 0x227; + +use crate::{cuse_device::state::KeyTracker, global_config::DevicePolicy}; + +fn is_allowed(keytracker: &mut KeyTracker, policy: &DevicePolicy, event: &input_event) -> bool { + match policy { + DevicePolicy::None => true, + DevicePolicy::Sanitized => is_allowed_in_sanitized_mode(keytracker, event), + DevicePolicy::StrictGamepad => is_allowed_in_strict_gamepad_mode(keytracker, event), + } +} + +fn is_allowed_in_sanitized_mode(keytracker: &mut KeyTracker, event: &input_event) -> bool { + let type_ = event.type_; + let code = event.code; + let value = event.value; + + if type_ == EV_KEY { + match code { + v if v == KEY_LEFTALT => keytracker.left_alt_down = value > 0, + v if v == KEY_RIGHTALT => keytracker.right_alt_down = value > 0, + v if v == KEY_LEFTCTRL => keytracker.left_ctrl_down = value > 0, + v if v == KEY_RIGHTCTRL => keytracker.right_ctrl_down = value > 0, + _ => {} + } + } + + if type_ == EV_KEY { + // 1. Block SysRq in general + if code == KEY_SYSRQ { + return false; + } + + let alt_down = keytracker.left_alt_down || keytracker.right_alt_down; + let ctrl_down = keytracker.left_ctrl_down || keytracker.right_ctrl_down; + + // 2. Block VT Switching + // To block VT Switching, all CONSOLE_ actions need to be ignored. + // In standard Linux keymaps (defkeymap) + // https://github.com/torvalds/linux/blob/master/drivers/tty/vt/defkeymap.map + // - Left Alt + F1–F12 usually maps to Console_1 – Console_12. + // - Right Alt (AltGr) + F1–F12 usually maps to Console_13 – Console_24. + // Note: Alt + Left/Right (Decr_Console / Incr_Console) is still allowed. We assume + // this is blocked in any other way. + if alt_down && (code >= KEY_F1 && code <= KEY_F10) { + return false; + } + if alt_down && (code >= KEY_F11 && code <= KEY_F12) { + return false; + } + + // 3. Block CAD (Ctrl + Alt + Del) + // Block basically all Boot from defkeymap.map + if alt_down && ctrl_down && (code == KEY_DELETE || code == KEY_KPDOT) { + return false; + } + + // 4. Block standalone dangerous keys + match code { + KEY_POWER | KEY_SLEEP | KEY_WAKEUP => return false, + _ => {} + } + } + true +} + +fn is_allowed_in_strict_gamepad_mode(keytracker: &mut KeyTracker, event: &input_event) -> bool { + let type_ = event.type_; + let code = event.code; + + if type_ == EV_SYN { + return true; + } + if type_ == EV_ABS { + return true; + } + if type_ == EV_FF { + return true; + } + + if type_ == EV_KEY { + return match code { + BTN_SOUTH..BTN_THUMBR => true, + BTN_DPAD_UP..BTN_GRIPR2 => true, + _ => false, + }; + } + false +} diff --git a/vuinputd/src/cuse_device/vuinput_open.rs b/vuinputd/src/cuse_device/vuinput_open.rs index 666de32..ae12c9f 100644 --- a/vuinputd/src/cuse_device/vuinput_open.rs +++ b/vuinputd/src/cuse_device/vuinput_open.rs @@ -54,7 +54,7 @@ pub unsafe extern "C" fn vuinput_open( file: v, requesting_process, input_device: None, - keytracker: KeyTracker::new() + keytracker: KeyTracker::new(), }, ) .unwrap(); diff --git a/vuinputd/src/global_config.rs b/vuinputd/src/global_config.rs index c5fd5ad..8330753 100644 --- a/vuinputd/src/global_config.rs +++ b/vuinputd/src/global_config.rs @@ -20,9 +20,9 @@ pub static CONFIG: OnceLock = OnceLock::new(); #[clap(rename_all = "kebab-case")] // This ensures StrictGamepad becomes "strict-gamepad" pub enum DevicePolicy { /// Allow all device capabilities + #[default] None, /// Default: Allow keyboards/mice but block dangerous keys (SysRq, VT switching) - #[default] Sanitized, /// Only allow Gamepad-like devices. Block mice and keyboards. StrictGamepad, From 0062a1741ebb73321f16406c3cce8518b6a813d0 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 7 Jan 2026 22:56:18 +0000 Subject: [PATCH 054/108] Apply device filter. No tests, yet. --- docs/DESIGN.md | 2 +- vuinputd/src/cuse_device/device_policy.rs | 10 +++++----- vuinputd/src/cuse_device/vuinput_write.rs | 13 +++++++++++-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 503d468..f489a12 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -569,7 +569,7 @@ To mitigate this, `vuinputd` implements an **Active Filtering Layer** (CUSE midd The filtering operates on two levels (Defense in Depth): -1. **Capability Filtering (`ioctl`):** During device creation, `vuinputd` inspects `UI_SET_KEYBIT`, `UI_SET_RELBIT`, etc. If a container requests capabilities forbidden by the active policy (e.g., a gamepad trying to claim it has a SysRq key), the request is silently ignored or rejected. The resulting device on the host simply lacks those hardware capabilities. +1. **Capability Filtering (`ioctl`):** During device creation, `vuinputd` inspects `UI_SET_KEYBIT`, `UI_SET_RELBIT`, etc. If a container requests capabilities forbidden by the active policy (e.g., a gamepad trying to claim it has a SysRq key), the request is silently ignored or rejected. The resulting device on the host simply lacks those hardware capabilities. This hasn't been implemented, yet. 2. **Event Filtering (`write`):** At runtime, `vuinputd` inspects the stream of input events. It maintains internal state (tracking modifiers like `Alt` or `Ctrl`) to detect and drop dangerous sequences (e.g., `Alt` + `F1-F12` for VT switching) that the capability filter alone cannot block. **Supported Policies:** diff --git a/vuinputd/src/cuse_device/device_policy.rs b/vuinputd/src/cuse_device/device_policy.rs index 203100c..5385582 100644 --- a/vuinputd/src/cuse_device/device_policy.rs +++ b/vuinputd/src/cuse_device/device_policy.rs @@ -46,7 +46,7 @@ const BTN_GRIPR2: u16 = 0x227; use crate::{cuse_device::state::KeyTracker, global_config::DevicePolicy}; -fn is_allowed(keytracker: &mut KeyTracker, policy: &DevicePolicy, event: &input_event) -> bool { +pub fn is_allowed(keytracker: &mut KeyTracker, policy: &DevicePolicy, event: &input_event) -> bool { match policy { DevicePolicy::None => true, DevicePolicy::Sanitized => is_allowed_in_sanitized_mode(keytracker, event), @@ -61,10 +61,10 @@ fn is_allowed_in_sanitized_mode(keytracker: &mut KeyTracker, event: &input_event if type_ == EV_KEY { match code { - v if v == KEY_LEFTALT => keytracker.left_alt_down = value > 0, - v if v == KEY_RIGHTALT => keytracker.right_alt_down = value > 0, - v if v == KEY_LEFTCTRL => keytracker.left_ctrl_down = value > 0, - v if v == KEY_RIGHTCTRL => keytracker.right_ctrl_down = value > 0, + KEY_LEFTALT => keytracker.left_alt_down = value > 0, + KEY_RIGHTALT => keytracker.right_alt_down = value > 0, + KEY_LEFTCTRL => keytracker.left_ctrl_down = value > 0, + KEY_RIGHTCTRL => keytracker.right_ctrl_down = value > 0, _ => {} } } diff --git a/vuinputd/src/cuse_device/vuinput_write.rs b/vuinputd/src/cuse_device/vuinput_write.rs index 671eb8b..8e884c3 100644 --- a/vuinputd/src/cuse_device/vuinput_write.rs +++ b/vuinputd/src/cuse_device/vuinput_write.rs @@ -3,6 +3,7 @@ // Author: Johannes Leupolz use crate::cuse_device::*; +use crate::global_config::get_device_policy; use ::cuse_lowlevel::*; use libc::{__s32, __u16, input_event}; use libc::{off_t, size_t, EIO}; @@ -87,9 +88,15 @@ pub unsafe extern "C" fn vuinput_write( let is_compat = vuinput_state.requesting_process.is_compat; // TODO: ARM: && !compat_uses_64bit_time() + let policy = get_device_policy(); + if !is_compat { while bytes + normal_size <= _size && result.is_ok() { - result = vuinput_state.file.write(&slice[bytes..bytes + normal_size]); + let position = _buf.byte_add(bytes); + let input_event = position as *const input_event; + if device_policy::is_allowed(&mut vuinput_state.keytracker, policy, &*input_event) { + result = vuinput_state.file.write(&slice[bytes..bytes + normal_size]); + } bytes += normal_size; } } else { @@ -99,7 +106,9 @@ pub unsafe extern "C" fn vuinput_write( let normal = map_to_64_bit(&*compat); let normal_ptr = (&normal as *const libc::input_event) as *const u8; let slice = std::slice::from_raw_parts(normal_ptr, normal_size); - result = vuinput_state.file.write(&slice); + if device_policy::is_allowed(&mut vuinput_state.keytracker, policy, &normal) { + result = vuinput_state.file.write(&slice); + } bytes += compat_size; } }; From 11144bd6941f72d8d9c1b56584db7eccea5a9e92 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 8 Jan 2026 21:13:07 +0000 Subject: [PATCH 055/108] Add design considerations for #4 --- docs/DESIGN.md | 46 ++++++++++++++++++- vuinputd/src/cuse_device/device_policy.rs | 54 ++++++++++++++--------- 2 files changed, 79 insertions(+), 21 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f489a12..09fea37 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -499,10 +499,54 @@ As a result: * input devices may interact with the VT layer in unintended ways When a graphical session is active, these issues do not occur: -the compositor, via `systemd-logind`, owns a VT, switches it to `KD_GRAPHICS`, and the VT keyboard handler is **suppressed**. +the compositor, via `systemd-logind`, owns a VT, switches it to `KD_GRAPHICS` and `K_OFF`, and the VT keyboard handler is **suppressed**. The missing piece is a **well-defined fallback** for the “no graphical session” case. +### **Effect of K_OFF in Linux VT subsystem** + +- ioctl KDSKBMODE on /dev/ttyX leads to call of vt_do_kdskbmode +- kb->kbdmode = VC_OFF + +This suppresses the following chains in [keyboard.c](https://github.com/torvalds/linux/blob/master/drivers/tty/vt/keyboard.c) +Lets take Console_1 via ALT+F2 as an example: +- kbd_event (Entry Point) +- kbd_keycode (Translation) + - Job: looks up the Keysym in the keymap based on the current modifier state (ALT+F2 is 0xf501 in [defkeymap.c_shipped](https://github.com/torvalds/linux/blob/master/drivers/tty/vt/defkeymap.c_shipped)) + - type = KTYP(keysym) takes the first 16 bits (which is 0xf5). + - type -= 0xf0. (which is 0x05). Note hat the kernel uses the ** offset** `0xf0` to differentiate between characters and special handlers in the keymap. When `type -= 0xf0` is called, it "normalizes" the keysym into an index for the `k_handler` array. + - index = KVAL(keysym) takes the last 16 bits (which is 0x01) + - return if ((raw_mode || kbd->kbdmode == VC_OFF) && type != KT_SPEC && type != KT_SHIFT), so suppression happens here. + - Note that K_HANDLERS[type] == K_HANDLERS[0x05] == k_cons. + - if no suppression: call (*k_handler[type])(vc, KVAL(keysym), !down), which is k_cons(vc,0x01) + +Lets take Decr_Console via ALT+Left as second example: +- kbd_event (Entry Point) +- kbd_keycode (Translation) + - Job: looks up the Keysym in the keymap based on the current modifier state (ALT+Left is 0xf210 in [defkeymap.c_shipped](https://github.com/torvalds/linux/blob/master/drivers/tty/vt/defkeymap.c_shipped)) + - type = KTYP(keysym) takes the first 16 bits (which is 0xf2). + - type -= 0xf0. (which is 0x02) + - index = KVAL(keysym) takes the last 16 bits (which is 0x10) + - return if ((raw_mode || kbd->kbdmode == VC_OFF) && type != KT_SPEC && type != KT_SHIFT), so suppression does *not* happen here. + - Note that K_HANDLERS[type] == K_HANDLERS[0x02] == k_spec. + - call (*k_handler[type])(vc, KVAL(keysym), !down), which is k_spec(vc,0x10) +- k_spec (Handling) + - Condition if ((... || kbd->kbdmode == VC_OFF) && value != KVAL(K_SAK)) evaluates to false, so suppression happens here + - if no suppression: call fn_handler[value](...) which is fn_dec_console(...) + +In the KT_SHIFT-case of "return if ((raw_mode || kbd->kbdmode == VC_OFF) && type != KT_SPEC && type != KT_SHIFT", nothing interesting happens in our case: it might enable and disable caps lock. This means uinput can still enable disable caps, which is a bit odd, but nothing tragic in our use cases. + +sysrq has an own handler. It is not affected by K_OFF. +https://github.com/torvalds/linux/blob/master/drivers/tty/sysrq.c#L1048 + +Raw mode is not relevant. + +### The Risk of Non-Standard or User-Loaded Keymaps + +While `vuinputd` relies on the default kernel keymap logic for its internal filtering, it is important to note that the host's active keymap can be modified at runtime (e.g., via `loadkeys` or `systemd-vconsole-setup`). Because the kernel's `K_OFF` logic (triggered by `KDSKBMODE`) explicitly whitelists the `K_SAK` (Secure Attention Key) keysym, any user-defined key combination mapped to `SAK` will bypass the kernel's own input suppression. + +To further mitigate these risks, `vuinputd` could be extended to parse the host's active keymap during startup, allowing the sanitizer to dynamically identify and filter any physical keycode mapped to a sensitive keysym like `K_SAK`. This is currently not planned. Alternatively, on systems dedicated to containerization where full host TTY access is not required, administrators can load a "hardened" keymap stripped of all `Console_N`, `Boot`, and `SAK` assignments. By combining a minimized host keymap with `vuinputd`'s CUSE-level filtering, the system achieves a robust "Defense in Depth" that protects against both accidental triggers and intentional container escapes. + ### **Decision** `fallbackdm` is implemented as a **logind-managed fallback graphical session**. diff --git a/vuinputd/src/cuse_device/device_policy.rs b/vuinputd/src/cuse_device/device_policy.rs index 5385582..9a622c2 100644 --- a/vuinputd/src/cuse_device/device_policy.rs +++ b/vuinputd/src/cuse_device/device_policy.rs @@ -93,13 +93,20 @@ fn is_allowed_in_sanitized_mode(keytracker: &mut KeyTracker, event: &input_event return false; } - // 3. Block CAD (Ctrl + Alt + Del) + // 3. Block CAD (Ctrl + Alt + Del). // Block basically all Boot from defkeymap.map if alt_down && ctrl_down && (code == KEY_DELETE || code == KEY_KPDOT) { return false; } - // 4. Block standalone dangerous keys + // 4. Block SAK (CTRL+ALT+PAUSE). seems not to be mapped + // This one is crucial, because it is allowed even in K_OFF mode. + // https://github.com/torvalds/linux/blob/master/drivers/tty/vt/keyboard.c + // It seems not to be mapped by default, but close the potential hole if a + // distro follows the ancient https://www.kernel.org/doc/Documentation/SAK.txt + + + // 5. Block standalone dangerous keys match code { KEY_POWER | KEY_SLEEP | KEY_WAKEUP => return false, _ => {} @@ -108,26 +115,33 @@ fn is_allowed_in_sanitized_mode(keytracker: &mut KeyTracker, event: &input_event true } -fn is_allowed_in_strict_gamepad_mode(keytracker: &mut KeyTracker, event: &input_event) -> bool { - let type_ = event.type_; - let code = event.code; +fn is_allowed_in_strict_gamepad_mode( + _keytracker: &mut KeyTracker, + event: &input_event, +) -> bool { + match event.type_ { + EV_SYN => true, - if type_ == EV_SYN { - return true; - } - if type_ == EV_ABS { - return true; - } - if type_ == EV_FF { - return true; - } + // Analog sticks, triggers + EV_ABS => true, - if type_ == EV_KEY { - return match code { - BTN_SOUTH..BTN_THUMBR => true, - BTN_DPAD_UP..BTN_GRIPR2 => true, + // Force feedback + EV_FF => true, + + // Digital buttons only + EV_KEY => match event.code { + // Standard gamepad face + shoulder + stick buttons + BTN_SOUTH..=BTN_THUMBR => true, + + // D-Pad + extended gamepad buttons (triggers, paddles) + BTN_DPAD_UP..=BTN_GRIPR2 => true, + + // Everything else is rejected (KEY_*, mouse buttons, etc.) _ => false, - }; + }, + + // Explicitly reject everything else (EV_REL, EV_MSC, etc.) + _ => false, } - false } + From d139906834afcfd144cfc8be2fad357048ef1729 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 8 Jan 2026 21:21:17 +0000 Subject: [PATCH 056/108] Some remarks for the future of fallbackdm --- docs/DESIGN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 09fea37..d528854 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -555,7 +555,7 @@ It runs only when **no other graphical session is active** on the seat and exist * open a regular logind session **of class `greeter**` * occupy the assigned VT -* let logind switch the VT to `KD_GRAPHICS` **and mute the keyboard handler** +* let logind switch the VT to `KD_GRAPHICS` and `K_OFF` **and mute the keyboard handler** `fallbackdm` itself does **not** manipulate VTs, perform `KDSETMODE` ioctls, or access `/dev/tty` directly. All VT and seat handling is delegated to `systemd-logind` **via a standard PAM session**. @@ -574,7 +574,7 @@ All VT and seat handling is delegated to `systemd-logind` **via a standard PAM s * when the graphical session ends: * `fallbackdm` may be restarted to reclaim the fallback role -`fallbackdm` is non-interactive by design but may display **minimal status information** in the future. +`fallbackdm` is non-interactive by design but may display **minimal status information** in the future. `fallbackdm` could also be extended to listen itself to the console switches from evdev devices that are actually connected to the seat and signal vuinputd to mute virtual devices accordingly. ### **Rationale** From a1667bf4ba9d22b9becfc02add7cdfc557e33ee8 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 15 Jan 2026 22:39:57 +0000 Subject: [PATCH 057/108] Improve device policies: Block more keys in sanitized mode and introduce MuteSysRq mode that only mutes sysrq --- vuinputd/src/cuse_device/device_policy.rs | 30 +++++++++++++++++------ vuinputd/src/global_config.rs | 4 ++- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/vuinputd/src/cuse_device/device_policy.rs b/vuinputd/src/cuse_device/device_policy.rs index 9a622c2..15b540f 100644 --- a/vuinputd/src/cuse_device/device_policy.rs +++ b/vuinputd/src/cuse_device/device_policy.rs @@ -35,6 +35,12 @@ const KEY_KPDOT: u16 = 83; const KEY_POWER: u16 = 116; const KEY_SLEEP: u16 = 142; const KEY_WAKEUP: u16 = 143; +const KEY_BREAK: u16 = 0x19b; +const KEY_PAUSE: u16 = 119; +const KEY_RESTART: u16 = 0x198; + +const KEY_FN: u16 = 0x1d0; +// TODO: Should we block range until KEY_FN_RIGHT_SHIFT? // Gamepad keys from https://github.com/torvalds/linux/blob/master/Documentation/input/gamepad.rst // First range @@ -49,11 +55,19 @@ use crate::{cuse_device::state::KeyTracker, global_config::DevicePolicy}; pub fn is_allowed(keytracker: &mut KeyTracker, policy: &DevicePolicy, event: &input_event) -> bool { match policy { DevicePolicy::None => true, + DevicePolicy::MuteSysRq => is_allowed_in_mute_sysrq(keytracker, event), DevicePolicy::Sanitized => is_allowed_in_sanitized_mode(keytracker, event), DevicePolicy::StrictGamepad => is_allowed_in_strict_gamepad_mode(keytracker, event), } } +fn is_allowed_in_mute_sysrq(keytracker: &mut KeyTracker, event: &input_event) -> bool { + if event.type_ == EV_KEY && event.code == KEY_SYSRQ { + return false; + } + true +} + fn is_allowed_in_sanitized_mode(keytracker: &mut KeyTracker, event: &input_event) -> bool { let type_ = event.type_; let code = event.code; @@ -93,6 +107,11 @@ fn is_allowed_in_sanitized_mode(keytracker: &mut KeyTracker, event: &input_event return false; } + // TODO: + // keycode 84 = Last_Console + // alt keycode 105 = Decr_Console + // alt keycode 106 = Incr_Console + // 3. Block CAD (Ctrl + Alt + Del). // Block basically all Boot from defkeymap.map if alt_down && ctrl_down && (code == KEY_DELETE || code == KEY_KPDOT) { @@ -105,20 +124,18 @@ fn is_allowed_in_sanitized_mode(keytracker: &mut KeyTracker, event: &input_event // It seems not to be mapped by default, but close the potential hole if a // distro follows the ancient https://www.kernel.org/doc/Documentation/SAK.txt - // 5. Block standalone dangerous keys match code { - KEY_POWER | KEY_SLEEP | KEY_WAKEUP => return false, + KEY_POWER | KEY_SLEEP | KEY_WAKEUP | KEY_FN | KEY_BREAK | KEY_PAUSE | KEY_RESTART => { + return false + } _ => {} } } true } -fn is_allowed_in_strict_gamepad_mode( - _keytracker: &mut KeyTracker, - event: &input_event, -) -> bool { +fn is_allowed_in_strict_gamepad_mode(_keytracker: &mut KeyTracker, event: &input_event) -> bool { match event.type_ { EV_SYN => true, @@ -144,4 +161,3 @@ fn is_allowed_in_strict_gamepad_mode( _ => false, } } - diff --git a/vuinputd/src/global_config.rs b/vuinputd/src/global_config.rs index 8330753..93f6368 100644 --- a/vuinputd/src/global_config.rs +++ b/vuinputd/src/global_config.rs @@ -20,8 +20,10 @@ pub static CONFIG: OnceLock = OnceLock::new(); #[clap(rename_all = "kebab-case")] // This ensures StrictGamepad becomes "strict-gamepad" pub enum DevicePolicy { /// Allow all device capabilities - #[default] None, + #[default] + /// Default: Block SysRq + MuteSysRq, /// Default: Allow keyboards/mice but block dangerous keys (SysRq, VT switching) Sanitized, /// Only allow Gamepad-like devices. Block mice and keyboards. From 3283aa0dc24ca428554dcad795077f993d6ec486 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Fri, 16 Jan 2026 10:53:51 +0000 Subject: [PATCH 058/108] Add infrastructure to add integration tests with podman --- docs/TESTS.md | 13 ++ vuinputd-tests/Cargo.toml | 8 +- vuinputd-tests/podman/Containerfile | 8 + .../src/bin/{bwrap-ipc.rs => test-ipc.rs} | 4 +- vuinputd-tests/src/bin/test-ok.rs | 7 + vuinputd-tests/src/bwrap.rs | 55 +---- vuinputd-tests/src/ipc.rs | 62 ++++++ vuinputd-tests/src/lib.rs | 2 + vuinputd-tests/src/podman.rs | 190 ++++++++++++++++++ vuinputd-tests/tests/integration_tests.rs | 2 +- 10 files changed, 294 insertions(+), 57 deletions(-) create mode 100644 vuinputd-tests/podman/Containerfile rename vuinputd-tests/src/bin/{bwrap-ipc.rs => test-ipc.rs} (90%) create mode 100644 vuinputd-tests/src/bin/test-ok.rs create mode 100644 vuinputd-tests/src/ipc.rs create mode 100644 vuinputd-tests/src/podman.rs diff --git a/docs/TESTS.md b/docs/TESTS.md index 142c634..27d6518 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -2,12 +2,25 @@ ## Integration tests +### With bubblewrap + Install bubblewrap: `apt-get install bubblewrap`. Run with `cargo test -p vuinputd-tests --features "requires-privileges requires-uinput requires-bwrap"`. +### With podman +Install podman: +`apt-get install podman`. + +Create test container for podman +``` +cargo build -p vuinputd-tests +podman build -t vuinputd-tests -f vuinputd-tests/podman/Containerfile . +``` + +Run with `cargo test -p vuinputd-tests --features "requires-privileges requires-uinput requires-podman"`. ## Performance tests diff --git a/vuinputd-tests/Cargo.toml b/vuinputd-tests/Cargo.toml index e6da9c4..5352278 100644 --- a/vuinputd-tests/Cargo.toml +++ b/vuinputd-tests/Cargo.toml @@ -3,11 +3,14 @@ name = "vuinputd-tests" version = "0.1.0" edition = "2021" +[[bin]] +name = "test-ipc" + [[bin]] name = "test-keyboard" [[bin]] -name = "bwrap-ipc" +name = "test-ok" [dependencies] uinput-ioctls = { path = "../uinput-ioctls" } @@ -22,4 +25,5 @@ serde_json = "1.0" [features] requires-privileges = [] requires-uinput = [] -requires-bwrap = [] \ No newline at end of file +requires-bwrap = [] +requires-podman = [] \ No newline at end of file diff --git a/vuinputd-tests/podman/Containerfile b/vuinputd-tests/podman/Containerfile new file mode 100644 index 0000000..283e593 --- /dev/null +++ b/vuinputd-tests/podman/Containerfile @@ -0,0 +1,8 @@ +# Build from project root +# > cargo build -p vuinputd-tests +# > podman build -t vuinputd-tests -f vuinputd-tests/podman/Containerfile . + +FROM ubuntu:24.04 +COPY target/debug/test-ipc /test-ipc +COPY target/debug/test-keyboard /test-keyboard +COPY target/debug/test-ok /test-ok diff --git a/vuinputd-tests/src/bin/bwrap-ipc.rs b/vuinputd-tests/src/bin/test-ipc.rs similarity index 90% rename from vuinputd-tests/src/bin/bwrap-ipc.rs rename to vuinputd-tests/src/bin/test-ipc.rs index cd7a509..dcb7cf9 100644 --- a/vuinputd-tests/src/bin/bwrap-ipc.rs +++ b/vuinputd-tests/src/bin/test-ipc.rs @@ -5,10 +5,10 @@ use core::panic; use std::time::Duration; -use vuinputd_tests::bwrap::SandboxChildIpc; +use vuinputd_tests::ipc::SandboxChildIpc; fn main() { - println!("starting bwrap-ipc"); + println!("starting test-ipc"); let ipc = unsafe { SandboxChildIpc::from_fd() }; let incoming = ipc diff --git a/vuinputd-tests/src/bin/test-ok.rs b/vuinputd-tests/src/bin/test-ok.rs new file mode 100644 index 0000000..379f7b8 --- /dev/null +++ b/vuinputd-tests/src/bin/test-ok.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +fn main() { + println!("test-ok"); +} diff --git a/vuinputd-tests/src/bwrap.rs b/vuinputd-tests/src/bwrap.rs index 2514fee..8ccdcbe 100644 --- a/vuinputd-tests/src/bwrap.rs +++ b/vuinputd-tests/src/bwrap.rs @@ -3,16 +3,17 @@ // Author: Johannes Leupolz use std::io; -use std::os::fd::{FromRawFd, IntoRawFd, OwnedFd, RawFd}; +use std::os::fd::{FromRawFd, IntoRawFd, OwnedFd}; use std::os::unix::net::UnixDatagram; use std::os::unix::process::CommandExt; use std::process::{Command, Output}; -use std::time::Duration; use nix::errno::Errno; use nix::sys::socket::{socketpair, AddressFamily, SockFlag, SockType}; use nix::unistd::close; +use crate::ipc::{SandboxChildIpc, SandboxIpc}; + /// Check if bubblewrap is available. pub fn bwrap_available() -> bool { Command::new("bwrap") @@ -22,56 +23,6 @@ pub fn bwrap_available() -> bool { .unwrap_or(false) } -/// IPC handle kept by the parent. -pub struct SandboxIpc { - sock: UnixDatagram, -} - -impl SandboxIpc { - pub fn recv(&self, read_timeout: Option) -> io::Result> { - let mut buf = vec![0u8; 4096]; - self.sock.set_read_timeout(read_timeout)?; - let n = self.sock.recv(&mut buf)?; - buf.truncate(n); - Ok(buf) - } - - pub fn send(&self, data: &[u8]) -> io::Result<()> { - self.sock.send(data)?; - Ok(()) - } -} - -/// IPC handle inside the container. -pub struct SandboxChildIpc { - sock: UnixDatagram, -} - -impl SandboxChildIpc { - /// FD number is fixed and known. - pub const FD: RawFd = 3; - - /// # Safety - /// Must only be called once in the child. - pub unsafe fn from_fd() -> Self { - let sock = UnixDatagram::from_raw_fd(Self::FD); - Self { sock } - } - - pub fn send(&self, data: &[u8]) -> io::Result<()> { - self.sock.send(data)?; - Ok(()) - } - - pub fn recv(&self, read_timeout: Option) -> io::Result> { - let mut buf = vec![0u8; 4096]; - self.sock.set_read_timeout(read_timeout)?; - let n = self.sock.recv(&mut buf)?; - buf.truncate(n); - Ok(buf) - } -} - /// Builder for bubblewrap invocations. #[derive(Default)] pub struct BwrapBuilder { diff --git a/vuinputd-tests/src/ipc.rs b/vuinputd-tests/src/ipc.rs new file mode 100644 index 0000000..d7c7208 --- /dev/null +++ b/vuinputd-tests/src/ipc.rs @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::{ + io, + os::{ + fd::{FromRawFd, RawFd}, + unix::net::UnixDatagram, + }, + time::Duration, +}; + +/// IPC handle kept by the parent. +pub struct SandboxIpc { + pub sock: UnixDatagram, +} + +impl SandboxIpc { + pub fn recv(&self, read_timeout: Option) -> io::Result> { + let mut buf = vec![0u8; 4096]; + self.sock.set_read_timeout(read_timeout)?; + let n = self.sock.recv(&mut buf)?; + buf.truncate(n); + Ok(buf) + } + + pub fn send(&self, data: &[u8]) -> io::Result<()> { + self.sock.send(data)?; + Ok(()) + } +} + +/// IPC handle inside the container. +pub struct SandboxChildIpc { + sock: UnixDatagram, +} + +impl SandboxChildIpc { + /// FD number is fixed and known. + pub const FD: RawFd = 3; + + /// # Safety + /// Must only be called once in the child. + pub unsafe fn from_fd() -> Self { + let sock = UnixDatagram::from_raw_fd(Self::FD); + Self { sock } + } + + pub fn send(&self, data: &[u8]) -> io::Result<()> { + self.sock.send(data)?; + Ok(()) + } + + pub fn recv(&self, read_timeout: Option) -> io::Result> { + let mut buf = vec![0u8; 4096]; + self.sock.set_read_timeout(read_timeout)?; + let n = self.sock.recv(&mut buf)?; + buf.truncate(n); + Ok(buf) + } +} diff --git a/vuinputd-tests/src/lib.rs b/vuinputd-tests/src/lib.rs index 9e87813..65112a5 100644 --- a/vuinputd-tests/src/lib.rs +++ b/vuinputd-tests/src/lib.rs @@ -3,5 +3,7 @@ // Author: Johannes Leupolz pub mod bwrap; +pub mod ipc; +pub mod podman; pub mod run_vuinputd; pub mod test_log; diff --git a/vuinputd-tests/src/podman.rs b/vuinputd-tests/src/podman.rs new file mode 100644 index 0000000..7a5d102 --- /dev/null +++ b/vuinputd-tests/src/podman.rs @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use nix::errno::Errno; +use nix::sys::socket::{AddressFamily, SockFlag, SockType}; +use nix::unistd::close; +use std::io; +use std::os::fd::{FromRawFd, IntoRawFd, OwnedFd}; +use std::os::unix::net::UnixDatagram; +use std::os::unix::process::CommandExt; +use std::process::{Command, Output}; + +use crate::ipc::{SandboxChildIpc, SandboxIpc}; + +/// Check if podman is available. +pub fn podman_available() -> bool { + Command::new("podman") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Builder for podman run invocations. +#[derive(Default)] +pub struct PodmanBuilder { + args: Vec, + ipc_child_fd: Option, +} + +impl PodmanBuilder { + pub fn new() -> Self { + Self::default() + } + + /// `podman run` + fn run_cmd(mut self) -> Self { + self.args.push("run".into()); + self + } + + pub fn rm(mut self) -> Self { + self.args.push("--rm".into()); + self + } + + pub fn detach(mut self) -> Self { + self.args.push("--detach".into()); + self + } + + pub fn name(mut self, name: &str) -> Self { + self.args.push("--name".into()); + self.args.push(name.into()); + self + } + + pub fn tty(mut self) -> Self { + self.args.push("--tty".into()); + self + } + + pub fn interactive(mut self) -> Self { + self.args.push("--interactive".into()); + self + } + + pub fn device(mut self, spec: &str) -> Self { + self.args.push("--device".into()); + self.args.push(spec.into()); + self + } + + pub fn volume(mut self, spec: &str) -> Self { + self.args.push("-v".into()); + self.args.push(spec.into()); + self + } + + pub fn publish(mut self, spec: &str) -> Self { + self.args.push("--publish".into()); + self.args.push(spec.into()); + self + } + + pub fn env(mut self, key: &str, value: &str) -> Self { + self.args.push("-e".into()); + self.args.push(format!("{key}={value}")); + self + } + + pub fn group_add(mut self, group: u32) -> Self { + self.args.push("--group-add".into()); + self.args.push(group.to_string()); + self + } + + pub fn security_opt(mut self, opt: &str) -> Self { + self.args.push("--security-opt".into()); + self.args.push(opt.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( + AddressFamily::Unix, + SockType::SeqPacket, + None, + SockFlag::empty(), + ) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + + // Parent side + let parent_sock = unsafe { UnixDatagram::from_raw_fd(parent.into_raw_fd()) }; + + // Child side must become FD 3 inside container + self.ipc_child_fd = Some(child); + + Ok((self, SandboxIpc { sock: parent_sock })) + } + + /// Final image reference + pub fn image(mut self, image: &str) -> Self { + self.args.push(image.into()); + self + } + + /// Optional command override inside the container + pub fn command(mut self, cmd: &[&str]) -> Self { + self.args.extend(cmd.iter().map(|s| s.to_string())); + self + } + + pub fn run(mut self) -> io::Result { + println!("Arguments for podman: {:?}", &self.args); + + let mut cmd = Command::new("podman"); + + if let Some(fd) = self.ipc_child_fd.take() { + // give up ownership of ipc_child_fd in host process. + let fd = fd.into_raw_fd(); + + // Move child FD to 3. Note that the FD 3 needs to be linked at the + // beginning of the child program. + unsafe { + cmd.pre_exec(move || { + let res = libc::dup2(fd, SandboxChildIpc::FD); + Errno::result(res) + .map(drop) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + close(fd).ok(); + Ok(()) + }) + }; + } + + cmd.args(&self.args).output() + } +} + +#[cfg(feature = "requires-podman")] +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn podman_builder_smoke() { + if !podman_available() { + panic!("podman not available"); + } + + let out = PodmanBuilder::new() + .run_cmd() + .rm() + //.detach() + .name(&format!("vuinputd-podman-tests")) + .image("localhost/vuinputd-tests:latest") + .command(&["/test-ok"]) + .run() + .unwrap(); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); + + assert!(out.status.success()); + } +} diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index cef9ce6..6100df1 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -26,7 +26,7 @@ fn test_bwrap_simple() { #[cfg(all(feature = "requires-privileges", feature = "requires-bwrap"))] #[test] fn test_bwrap_ipc() { - let bwrap_ipc = env!("CARGO_BIN_EXE_bwrap-ipc"); + let bwrap_ipc = env!("CARGO_BIN_EXE_test-ipc"); let (builder, ipc) = bwrap::BwrapBuilder::new() .unshare_all() From d6423197bb6080780c97e1309b887c68dc9d4e76 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sat, 17 Jan 2026 06:38:13 +0000 Subject: [PATCH 059/108] Add podman tests --- docs/TESTS.md | 2 +- vuinputd-tests/podman/Containerfile | 3 +- vuinputd-tests/src/podman.rs | 9 ++- vuinputd-tests/tests/podman_tests.rs | 93 ++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 vuinputd-tests/tests/podman_tests.rs diff --git a/docs/TESTS.md b/docs/TESTS.md index 27d6518..e68222a 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -17,7 +17,7 @@ Install podman: Create test container for podman ``` cargo build -p vuinputd-tests -podman build -t vuinputd-tests -f vuinputd-tests/podman/Containerfile . +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"`. diff --git a/vuinputd-tests/podman/Containerfile b/vuinputd-tests/podman/Containerfile index 283e593..0a8e4a1 100644 --- a/vuinputd-tests/podman/Containerfile +++ b/vuinputd-tests/podman/Containerfile @@ -1,8 +1,9 @@ # Build from project root # > cargo build -p vuinputd-tests -# > podman build -t vuinputd-tests -f vuinputd-tests/podman/Containerfile . +# > podman build --dns 1.1.1.1 -t vuinputd-tests -f vuinputd-tests/podman/Containerfile . FROM ubuntu:24.04 +RUN apt-get update && apt-get install -yy strace && apt-get clean COPY target/debug/test-ipc /test-ipc COPY target/debug/test-keyboard /test-keyboard COPY target/debug/test-ok /test-ok diff --git a/vuinputd-tests/src/podman.rs b/vuinputd-tests/src/podman.rs index 7a5d102..fc3821d 100644 --- a/vuinputd-tests/src/podman.rs +++ b/vuinputd-tests/src/podman.rs @@ -35,7 +35,7 @@ impl PodmanBuilder { } /// `podman run` - fn run_cmd(mut self) -> Self { + pub fn run_cmd(mut self) -> Self { self.args.push("run".into()); self } @@ -72,6 +72,11 @@ impl PodmanBuilder { self } + pub fn allow_input_devices(mut self) -> Self { + self.args.push("--device-cgroup-rule=\"c 13:* rwm\"".into()); + self + } + pub fn volume(mut self, spec: &str) -> Self { self.args.push("-v".into()); self.args.push(spec.into()); @@ -118,6 +123,8 @@ impl PodmanBuilder { // Child side must become FD 3 inside container self.ipc_child_fd = Some(child); + self.args.push("--preserve-fds=1".into()); + Ok((self, SandboxIpc { sock: parent_sock })) } diff --git a/vuinputd-tests/tests/podman_tests.rs b/vuinputd-tests/tests/podman_tests.rs new file mode 100644 index 0000000..bf974fe --- /dev/null +++ b/vuinputd-tests/tests/podman_tests.rs @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::{process::Command, time::Duration}; +use vuinputd_tests::podman; +use vuinputd_tests::run_vuinputd; + +#[cfg(all(feature = "requires-privileges", feature = "requires-podman"))] +#[test] +fn test_podman_simple() { + let out = podman::PodmanBuilder::new() + .run_cmd() + .rm() + //.detach() + //.name(&format!("vuinputd-podman-tests")) + .image("localhost/vuinputd-tests:latest") + .command(&["/test-ok"]) + .run() + .unwrap(); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); +} + +#[cfg(all(feature = "requires-privileges", feature = "requires-podman"))] +#[test] +fn test_podman_ipc() { + let (builder, ipc) = podman::PodmanBuilder::new() + .run_cmd() + .rm() + .with_ipc() + .expect("failed to create IPC"); + let builder = builder + //.detach() + //.name(&format!("vuinputd-podman-tests")) + .image("localhost/vuinputd-tests:latest") + .command(&["/test-ipc"]); + + // Note that builder.run() will block. Thus, the send needs to happen before the child process blocks + // the host process. + ipc.send("continue".as_bytes()) + .unwrap_or_else(|e| panic!("failed to send data via ipc: {e}")); + + let out = builder + .run() + .unwrap_or_else(|e| panic!("failed to run podman!: {e}")); + + let result = ipc.recv(Some(Duration::from_secs(5))); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); + + let result = result.expect("error receiving input from ipc as host within 5 seconds"); + let result_str = + str::from_utf8(&result).expect("message received from ipc is not encoded as utf8"); + println!("host received {}", result_str); +} + +#[cfg(all( + feature = "requires-privileges", + feature = "requires-uinput", + feature = "requires-podman" +))] +#[test] +fn test_keyboard_in_container_with_vuinput() { + run_vuinputd::ensure_vuinputd_running(); + + let (builder, ipc) = podman::PodmanBuilder::new() + .run_cmd() + .rm() + .with_ipc() + .expect("failed to create IPC"); + let builder = builder + //.detach() + //.name(&format!("vuinputd-podman-tests")) + .device("/dev/vuinput-test:/dev/uinput") + .allow_input_devices() + .image("localhost/vuinputd-tests:latest") + .command(&["/test-keyboard"]); + + let out = builder + .run() + .unwrap_or_else(|e| panic!("failed to run podman!: {e}")); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); + + assert!(out.status.success()); +} From 0d0d1b489c5694836df902aaaa5128f8e71b19a1 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 19 Jan 2026 19:57:10 +0000 Subject: [PATCH 060/108] Add configuration for placement --- vuinputd/src/global_config.rs | 23 +++++++++++++++++++---- vuinputd/src/main.rs | 8 ++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/vuinputd/src/global_config.rs b/vuinputd/src/global_config.rs index 93f6368..424fb7d 100644 --- a/vuinputd/src/global_config.rs +++ b/vuinputd/src/global_config.rs @@ -5,17 +5,16 @@ use clap::{Parser, ValueEnum}; use std::sync::OnceLock; -// --- 1. Define the Global State Container --- -// This struct is extensible. You can add more global settings here later. #[derive(Debug)] pub struct GlobalConfig { pub policy: DevicePolicy, + pub placement: Placement, } // The actual static variable. It starts empty and is set once in main(). pub static CONFIG: OnceLock = OnceLock::new(); -// --- 2. Define the Policy Enum --- +/// The device policy decides what events stay and what is filtered out. #[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum, Default)] #[clap(rename_all = "kebab-case")] // This ensures StrictGamepad becomes "strict-gamepad" pub enum DevicePolicy { @@ -29,11 +28,23 @@ pub enum DevicePolicy { /// Only allow Gamepad-like devices. Block mice and keyboards. StrictGamepad, } +/// Where to create runtime artifacts (device nodes + udev data) +#[derive(Debug, Clone, ValueEnum, Default)] +pub enum Placement { + #[default] + /// Create inside the container + Inject, + /// Create on the host (user is expected to bind-mount) + Host, + /// Do not create any artifacts + None, +} -pub fn initialize_global_config(device_policy: &DevicePolicy) { +pub fn initialize_global_config(device_policy: &DevicePolicy, placement: &Placement) { if CONFIG .set(GlobalConfig { policy: device_policy.clone(), + placement: placement.clone(), }) .is_err() { @@ -45,3 +56,7 @@ pub fn initialize_global_config(device_policy: &DevicePolicy) { pub fn get_device_policy<'a>() -> &'a DevicePolicy { &CONFIG.get().unwrap().policy } + +pub fn get_placement<'a>() -> &'a Placement { + &CONFIG.get().unwrap().placement +} diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 6c3559f..2a4f2b9 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -32,7 +32,7 @@ pub mod cuse_device; 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; +use crate::global_config::{DevicePolicy, Placement}; use crate::jobs::monitor_udev_job::MonitorBackgroundLoop; pub mod process_tools; @@ -95,6 +95,10 @@ struct Args { /// Enforce a device policy on created devices #[arg(long, value_enum, default_value_t)] device_policy: DevicePolicy, + + /// Placement of device nodes and udev data + #[arg(long, value_enum, default_value_t)] + pub placement: Placement, } fn validate_args(args: &Args) -> Result<(), String> { @@ -187,7 +191,7 @@ 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); + global_config::initialize_global_config(&args.device_policy, &args.placement); initialize_vuinput_state(); VUINPUT_COUNTER.set(AtomicU64::new(3)).expect( "failed to initialize the counter that provides the values of the CUSE file handles", From b4c6c32431f731c51280e966f4a6620e1c0b4ff1 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 19 Jan 2026 19:59:24 +0000 Subject: [PATCH 061/108] Document --placement, --devname, and --device-policy in USAGE.md --- docs/USAGE.md | 170 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 163 insertions(+), 7 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index a047248..10ea22d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -16,6 +16,25 @@ This guide shows how to: 2. Connect it to the host’s virtual `/dev/uinput` 3. Verify that device creation and input forwarding work correctly +### Runtime Artifact Placement + +`vuinputd` supports different **placement modes** that control where runtime artifacts +(device nodes *and* associated udev data) are created. + +This is configured via the `--placement` command-line option and affects: + +* the virtual input device nodes +* the corresponding `/run/udev` runtime data used by libudev-based applications + +### Device Policies + +`vuinputd` can enforce **device policies** that control which input capabilities +and events are exposed to applications. + +Policies are applied at device creation time and operate independently of +container runtime or placement mode. + + --- ## 2. Prerequisites @@ -90,7 +109,140 @@ The `vuinputd` daemon on the host should provide some logs. The following sectio --- -## 4. Runtime-Specific Setup +## 4. Special command line settings + +### Placement Modes + +`vuinputd` can be configured to place runtime artifacts in different locations depending +on your container setup and isolation model. + +#### `--placement in-container` (default) + +* Device nodes and udev runtime data are created **inside the container** +* Requires writable `/dev` and `/run` inside the container +* No bind-mounts required +* Best suited for tightly integrated or ephemeral containers + +#### `--placement on-host` + +* Device nodes and udev runtime data are created **on the host** under: + * `/run/vuinputd/{devname}/dev` + * `/run/vuinputd/{devname}/udev/data` +* The user is expected to **bind-mount these directories** into the container +* Suitable for: + * read-only containers + * advanced sandboxing scenarios + +#### `--placement none` + +* No device nodes or udev runtime data are created +* Useful when: + * devices are managed externally + * running in dry-run or control-only mode + * debugging or testing non-input-related functionality + +### Device Policies + +Device policies define which input capabilities are allowed and which events +are filtered out for devices created by `vuinputd`. + +They are configured using the `--device-policy` command-line option. + +#### Available Policies + +`--device-policy none` +* Allows **all device capabilities** +* No filtering is applied +* Useful for debugging or trusted environments + +`--device-policy mute-sys-rq` (default) + +* Blocks **SysRq** key handling +* Allows all other input events +* Prevents accidental or malicious kernel-level hotkeys +* Please read the section 'Handling Phantom Input Events Caused by VTs' + +`--device-policy sanitized` + +* Allows keyboards and mice +* Filters out dangerous key combinations, including: + * SysRq + * Virtual terminal switching (e.g. `Ctrl+Alt+Fn`) +* Recommended for most containerized desktop or streaming workloads +* Caution: This is **experimental**; in case there are combos that should be filtered as well, please post an issue + +`--device-policy strict-gamepad` + +* Only allows **gamepad-like devices** +* Blocks keyboards and mice entirely +* Intended for: + * gaming-focused containers + * sandboxed input forwarding + * untrusted workloads + +### Multiple Independent `vuinputd` Instances + +`vuinputd` supports running **multiple independent daemon instances**, each managing its **own virtual uinput device**. +This is achieved by explicitly configuring the device name and (optionally) the major/minor numbers. + +This feature is primarily intended for: + +* strong fault isolation between containers +* per-container `vuinputd` instances (especially with `--placement on-host`) +* development and testing, +* integration testing with multiple concurrent input stacks + +#### Device Identification Options + +The following command-line options control the identity of the virtual device created by `vuinputd`: + +* `--devname ` + Name of the device node **without** the `/dev/` prefix + (e.g. `vuinput0` → `/dev/vuinput0`) + +* `--major ` + Explicit major device number. Using 0 for both major and minor means auto assign. + +* `--minor ` + Explicit minor device number. Using 0 for both major and minor means auto assign. + +If not specified, `vuinputd` uses the default device identity `vuinput`. + +#### Why This Matters + +By default, all containers share the same virtual uinput endpoint. +While this is sufficient for many setups, it couples failure domains: + +* a bug or crash in one workload may affect others +* reproducing issues becomes harder when state is shared + +Using explicit device identities ensures failures and misbehaving clients are contained per instance. + +#### Example: One `vuinputd` Instance per Container (Host Placement) + +```bash +vuinputd --placement on-host --devname vuinput-container-a +``` + +The container would then bind-mount: + +```text +/run/vuinputd/vuinput/dev/vuinput-container-a → /dev/uinput +``` + +A second container can run its own instance with a different device: + +```bash +vuinputd \ +vuinputd --placement on-host --devname vuinput-container-b +``` + +No state, devices, or udev data are shared between the two instances. + + +--- + +## 5. Runtime-Specific Setup ### 🐳 Docker @@ -153,7 +305,7 @@ Then restart the container. --- -## 5. Inside the Container +## 6. Inside the Container Once inside the container shell: @@ -165,13 +317,17 @@ apt-get update apt-get install libinput-tools udev evtest tmux # Prepare udev stubs +# Note: +# The following steps are only required when using `--placement in-container`. +# When using `--placement on-host`, the udev runtime data is created on the host +# and must be bind-mounted into the container instead. mkdir -p /run/udev/data/ touch /run/udev/control ``` --- -## 6. Verifying Operation +## 7. Verifying Operation To test everything, use multiple `tmux` windows for parallel monitoring. @@ -225,7 +381,7 @@ Sample output from `journalctl` showing vuinputd output: --- -## 7. Handling Phantom Input Events Caused by VTs +## 8. Handling Phantom Input Events Caused by VTs On Linux systems without an active graphical session (X11 or Wayland), **virtual terminals (VTs)** remain in text mode (`KD_TEXT`) and continue to process keyboard input via the kernel VT keyboard handler. This can lead to *phantom input events*, where injected or forwarded input (e.g. via `vuinputd`) unintentionally reaches: @@ -329,7 +485,7 @@ Choose the approach that best fits your system constraints and deployment model. --- -## 8. Troubleshooting +## 9. Troubleshooting | Symptom | Possible Cause | Fix | | --------------------------- | ------------------------------------ | ------------------------------------------------- | @@ -350,7 +506,7 @@ Dez 14 21:33:17 wohnzimmer vuinputd[2172719]: called `Result::unwrap()` on an `E Ensure /dev and /run are writable in the container. If in doubt, use tmpfs. --- -## 9. Notes and Advanced Topics +## 10. Notes and Advanced Topics * You can safely run **multiple containers**. * Devices are automatically cleaned up when the container stops. @@ -362,7 +518,7 @@ Ensure /dev and /run are writable in the container. If in doubt, use tmpfs. --- -## 10. References +## 11. References * [mkosi manual](https://github.com/systemd/mkosi/blob/main/mkosi/resources/man/mkosi.1.md) * [Docker device rules documentation](https://docs.docker.com/engine/reference/run/#device-cgroup-rule) From 6bde733b09f32ba77a38b5bdaee061981fd1d16e Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 19 Jan 2026 22:50:05 +0000 Subject: [PATCH 062/108] Start implementation of --placement on-host. Not complete and no automated tests, yet. #1 --- docs/USAGE.md | 5 +- vuinputd/src/actions/action.rs | 10 ++- vuinputd/src/actions/handle_action.rs | 14 ++-- vuinputd/src/actions/runtime_data.rs | 16 +++-- vuinputd/src/cuse_device/device_policy.rs | 4 +- vuinputd/src/cuse_device/vuinput_ioctl.rs | 24 +++---- vuinputd/src/cuse_device/vuinput_release.rs | 6 +- vuinputd/src/global_config.rs | 18 +++-- ...ontainer_job.rs => emit_udev_event_job.rs} | 63 +++++++++++++----- ...n_container_job.rs => mknod_device_job.rs} | 58 ++++++++++------ vuinputd/src/jobs/mod.rs | 6 +- ..._container_job.rs => remove_device_job.rs} | 66 ++++++++++++------- vuinputd/src/main.rs | 2 +- 13 files changed, 193 insertions(+), 99 deletions(-) rename vuinputd/src/jobs/{emit_udev_event_in_container_job.rs => emit_udev_event_job.rs} (70%) rename vuinputd/src/jobs/{mknod_device_in_container_job.rs => mknod_device_job.rs} (57%) rename vuinputd/src/jobs/{remove_from_container_job.rs => remove_device_job.rs} (66%) diff --git a/docs/USAGE.md b/docs/USAGE.md index 10ea22d..ab940a0 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -126,8 +126,9 @@ on your container setup and isolation model. #### `--placement on-host` * Device nodes and udev runtime data are created **on the host** under: - * `/run/vuinputd/{devname}/dev` - * `/run/vuinputd/{devname}/udev/data` + * `/run/vuinputd/{devname}/dev-input` + * `/run/vuinputd/{devname}/udev` +* `/run/vuinputd/{devname}/dev-input` **must** have the mount option `dev` * The user is expected to **bind-mount these directories** into the container * Suitable for: * read-only containers diff --git a/vuinputd/src/actions/action.rs b/vuinputd/src/actions/action.rs index ae1889c..c4d6bb5 100644 --- a/vuinputd/src/actions/action.rs +++ b/vuinputd/src/actions/action.rs @@ -16,14 +16,18 @@ pub enum Action { minor: u64, }, - #[serde(rename = "emit-udev-event")] - EmitUdevEvent { - netlink_message: HashMap, + #[serde(rename = "write-udev-runtime-data")] + WriteUdevRuntimeData { runtime_data: Option, major: u64, minor: u64, }, + #[serde(rename = "emit-netlink-message")] + EmitNetlinkMessage { + netlink_message: HashMap, + }, + #[serde(rename = "remove-device")] RemoveDevice { path: String, diff --git a/vuinputd/src/actions/handle_action.rs b/vuinputd/src/actions/handle_action.rs index 61d6947..011ca44 100644 --- a/vuinputd/src/actions/handle_action.rs +++ b/vuinputd/src/actions/handle_action.rs @@ -21,20 +21,22 @@ fn handle_action(action: Action) -> anyhow::Result<()> { input_device::ensure_input_device(path, major.into(), minor.into())?; Ok(()) } - Action::EmitUdevEvent { - netlink_message, + Action::WriteUdevRuntimeData { runtime_data, major, minor, } => { - netlink_message::send_udev_monitor_message_with_properties(netlink_message); - runtime_data::ensure_udev_structure()?; + runtime_data::ensure_udev_structure("/run",true)?; match runtime_data { - Some(data) => runtime_data::write_udev_data(&data, major.into(), minor.into())?, - None => runtime_data::delete_udev_data(major.into(), minor.into())?, + Some(data) => runtime_data::write_udev_data("/run",&data, major.into(), minor.into())?, + None => runtime_data::delete_udev_data("/run",major.into(), minor.into())?, } Ok(()) } + Action::EmitNetlinkMessage { netlink_message } => { + netlink_message::send_udev_monitor_message_with_properties(netlink_message); + Ok(()) + } Action::RemoveDevice { path, major, minor } => { input_device::remove_input_device(path, major.into(), minor.into())?; Ok(()) diff --git a/vuinputd/src/actions/runtime_data.rs b/vuinputd/src/actions/runtime_data.rs index c9fc65b..2ad44db 100644 --- a/vuinputd/src/actions/runtime_data.rs +++ b/vuinputd/src/actions/runtime_data.rs @@ -9,11 +9,13 @@ use std::path::Path; use log::{info, warn}; /// Ensure required udev directories and files exist -pub fn ensure_udev_structure() -> io::Result<()> { +pub fn ensure_udev_structure(path_prefix: &str, warn_if_nonexistent: bool) -> io::Result<()> { // Note that this structure _must_ exist, before a service using libinput is run. The time of device creation might be too late. - let data_dir = Path::new("/run/udev/data"); - let control_file = Path::new("/run/udev/control"); + let data_dir = format!("{}/udev/data", path_prefix); + let data_dir = Path::new(&data_dir); + let control_file = format!("{}/udev/control", path_prefix); + let control_file = Path::new(&control_file); // Create directory like `mkdir -p` if !data_dir.exists() { @@ -42,7 +44,7 @@ pub fn ensure_udev_structure() -> io::Result<()> { /// - remove all lines containing `seat_` references (G:, Q: lines) /// - replace ID_VUINPUT_* with ID_INPUT_* /// - write updated content to `/run/udev/data/c:` -pub fn write_udev_data(content: &str, major: u64, minor: u64) -> io::Result<()> { +pub fn write_udev_data(path_prefix: &str, content: &str, major: u64, minor: u64) -> io::Result<()> { let mut cleaned = String::new(); for line in content.lines() { @@ -60,7 +62,7 @@ pub fn write_udev_data(content: &str, major: u64, minor: u64) -> io::Result<()> cleaned.push('\n'); } - let path = format!("/run/udev/data/c{}:{}", major, minor); + let path = format!("{}/udev/data/c{}:{}", path_prefix, major, minor); let mut file = File::create(&path)?; file.write_all(cleaned.as_bytes())?; @@ -69,8 +71,8 @@ pub fn write_udev_data(content: &str, major: u64, minor: u64) -> io::Result<()> /// Delete udev data for a given major/minor number /// - `major`, `minor` = device numbers -pub fn delete_udev_data(major: u64, minor: u64) -> io::Result<()> { - let path = format!("/run/udev/data/c{}:{}", major, minor); +pub fn delete_udev_data(path_prefix: &str, major: u64, minor: u64) -> io::Result<()> { + let path = format!("{}/udev/data/c{}:{}", path_prefix, major, minor); fs::remove_file(&path)?; Ok(()) } diff --git a/vuinputd/src/cuse_device/device_policy.rs b/vuinputd/src/cuse_device/device_policy.rs index 15b540f..61d3aea 100644 --- a/vuinputd/src/cuse_device/device_policy.rs +++ b/vuinputd/src/cuse_device/device_policy.rs @@ -61,10 +61,10 @@ pub fn is_allowed(keytracker: &mut KeyTracker, policy: &DevicePolicy, event: &in } } -fn is_allowed_in_mute_sysrq(keytracker: &mut KeyTracker, event: &input_event) -> bool { +fn is_allowed_in_mute_sysrq(_keytracker: &mut KeyTracker, event: &input_event) -> bool { if event.type_ == EV_KEY && event.code == KEY_SYSRQ { return false; - } + } true } diff --git a/vuinputd/src/cuse_device/vuinput_ioctl.rs b/vuinputd/src/cuse_device/vuinput_ioctl.rs index a8ca777..b4ae890 100644 --- a/vuinputd/src/cuse_device/vuinput_ioctl.rs +++ b/vuinputd/src/cuse_device/vuinput_ioctl.rs @@ -13,9 +13,9 @@ use uinput_ioctls::*; use crate::cuse_device::{get_vuinput_state, VuFileHandle}; use crate::job_engine::JOB_DISPATCHER; -use crate::jobs::emit_udev_event_in_container_job::EmitUdevEventInContainerJob; -use crate::jobs::mknod_device_in_container_job::MknodDeviceInContainerJob; -use crate::jobs::remove_from_container_job::RemoveFromContainerJob; +use crate::jobs::emit_udev_event_job::EmitUdevEventJob; +use crate::jobs::mknod_device_job::MknodDeviceJob; +use crate::jobs::remove_device_job::RemoveDeviceJob; use crate::process_tools::SELF_NAMESPACES; use crate::{cuse_device::*, jobs}; @@ -164,7 +164,7 @@ pub unsafe extern "C" fn vuinput_ioctl( CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() ); debug!("fh {}: syspath: {}", fh, sysname); - let devnode = fetch_device_node(&sysname).unwrap(); + let (devname, devnode) = fetch_device_node(&sysname).unwrap(); debug!("fh {}: devnode: {}", fh, devnode); let (major, minor) = fetch_major_minor(&devnode).unwrap(); debug!("fh {}: major: {} minor: {} ", fh, major, minor); @@ -181,9 +181,9 @@ pub unsafe extern "C" fn vuinput_ioctl( .unwrap() .equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) { - let mknod_job = MknodDeviceInContainerJob::new( + let mknod_job = MknodDeviceJob::new( vuinput_state.requesting_process.clone(), - devnode.clone(), + devname.clone(), sysname.clone(), major, minor, @@ -195,12 +195,12 @@ pub unsafe extern "C" fn vuinput_ioctl( .lock() .unwrap() .dispatch(Box::new(mknod_job)); - awaiter(&jobs::mknod_device_in_container_job::State::Finished); + awaiter(&jobs::mknod_device_job::State::Finished); debug!("fh {}: mknod_device in container has been finished ", fh); fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); // we do not wait for the udev stuff - let emit_udev_event_job = EmitUdevEventInContainerJob::new( + let emit_udev_event_job = EmitUdevEventJob::new( vuinput_state.requesting_process.clone(), devnode.clone(), sysname.clone(), @@ -229,7 +229,7 @@ pub unsafe extern "C" fn vuinput_ioctl( .equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) { let input_device = input_device.unwrap(); - let remove_job = RemoveFromContainerJob::new( + let remove_job = RemoveDeviceJob::new( vuinput_state.requesting_process.clone(), input_device.devnode.clone(), input_device.syspath.clone(), @@ -243,7 +243,7 @@ pub unsafe extern "C" fn vuinput_ioctl( .lock() .unwrap() .dispatch(Box::new(remove_job)); - awaiter(&jobs::remove_from_container_job::State::Finished); + awaiter(&jobs::remove_device_job::State::Finished); debug!( "fh {}: removing dev-nodes from container has been finished ", fh @@ -415,12 +415,12 @@ pub unsafe extern "C" fn vuinput_ioctl( } } -pub fn fetch_device_node(path: &str) -> io::Result { +pub fn fetch_device_node(path: &str) -> io::Result<(String, String)> { for entry in fs::read_dir(path)? { let entry = entry?; // propagate per-entry errors if let Some(name) = entry.file_name().to_str() { if name.starts_with("event") { - return Ok(format!("/dev/input/{}", name)); + return Ok((name.to_string(), format!("/dev/input/{}", name))); } } } diff --git a/vuinputd/src/cuse_device/vuinput_release.rs b/vuinputd/src/cuse_device/vuinput_release.rs index 5994e2f..51e5906 100644 --- a/vuinputd/src/cuse_device/vuinput_release.rs +++ b/vuinputd/src/cuse_device/vuinput_release.rs @@ -3,7 +3,7 @@ // Author: Johannes Leupolz use crate::job_engine::JOB_DISPATCHER; -use crate::jobs::remove_from_container_job::RemoveFromContainerJob; +use crate::jobs::remove_device_job::{self, RemoveDeviceJob}; use crate::process_tools::SELF_NAMESPACES; use crate::{cuse_device::*, jobs}; use ::cuse_lowlevel::*; @@ -32,7 +32,7 @@ pub unsafe extern "C" fn vuinput_release( .equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) { let input_device = input_device.unwrap(); - let remove_job = RemoveFromContainerJob::new( + let remove_job = RemoveDeviceJob::new( vuinput_state.requesting_process.clone(), input_device.devnode.clone(), input_device.syspath.clone(), @@ -46,7 +46,7 @@ pub unsafe extern "C" fn vuinput_release( .lock() .unwrap() .dispatch(Box::new(remove_job)); - awaiter(&jobs::remove_from_container_job::State::Finished); + awaiter(&jobs::remove_device_job::State::Finished); } drop(vuinput_state); diff --git a/vuinputd/src/global_config.rs b/vuinputd/src/global_config.rs index 424fb7d..ee7cc34 100644 --- a/vuinputd/src/global_config.rs +++ b/vuinputd/src/global_config.rs @@ -9,6 +9,7 @@ use std::sync::OnceLock; pub struct GlobalConfig { pub policy: DevicePolicy, pub placement: Placement, + pub devname: String, } // The actual static variable. It starts empty and is set once in main(). @@ -33,18 +34,23 @@ pub enum DevicePolicy { pub enum Placement { #[default] /// Create inside the container - Inject, + InContainer, /// Create on the host (user is expected to bind-mount) - Host, - /// Do not create any artifacts + OnHost, + /// Do not create any artifacts (netlink message in container is unaffected) None, } -pub fn initialize_global_config(device_policy: &DevicePolicy, placement: &Placement) { +pub fn initialize_global_config( + device_policy: &DevicePolicy, + placement: &Placement, + devname: &Option, +) { if CONFIG .set(GlobalConfig { policy: device_policy.clone(), placement: placement.clone(), + devname: devname.clone().unwrap_or("vuinput".to_string()), }) .is_err() { @@ -60,3 +66,7 @@ pub fn get_device_policy<'a>() -> &'a DevicePolicy { pub fn get_placement<'a>() -> &'a Placement { &CONFIG.get().unwrap().placement } + +pub fn get_devname<'a>() -> &'a String { + &CONFIG.get().unwrap().devname +} diff --git a/vuinputd/src/jobs/emit_udev_event_in_container_job.rs b/vuinputd/src/jobs/emit_udev_event_job.rs similarity index 70% rename from vuinputd/src/jobs/emit_udev_event_in_container_job.rs rename to vuinputd/src/jobs/emit_udev_event_job.rs index 45ec2a1..4aceb15 100644 --- a/vuinputd/src/jobs/emit_udev_event_in_container_job.rs +++ b/vuinputd/src/jobs/emit_udev_event_job.rs @@ -14,7 +14,12 @@ use async_io::Timer; use log::debug; use crate::{ - actions::{action::Action, runtime_data::read_udev_data}, + actions::{ + self, + action::Action, + runtime_data::{self, read_udev_data}, + }, + global_config::{self, get_placement, Placement}, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, process_tools::{self, await_process, Pid, RequestingProcess}, @@ -28,7 +33,7 @@ pub enum State { } #[derive(Clone, Debug)] -pub struct EmitUdevEventInContainerJob { +pub struct EmitUdevEventJob { requesting_process: RequestingProcess, target: JobTarget, dev_path: String, @@ -38,7 +43,7 @@ pub struct EmitUdevEventInContainerJob { sync_state: Arc<(Mutex, Condvar)>, } -impl EmitUdevEventInContainerJob { +impl EmitUdevEventJob { pub fn new( requesting_process: RequestingProcess, dev_path: String, @@ -79,7 +84,7 @@ impl EmitUdevEventInContainerJob { } } -impl Job for EmitUdevEventInContainerJob { +impl Job for EmitUdevEventJob { fn desc(&self) -> &str { "emit udev event into container" } @@ -88,8 +93,8 @@ impl Job for EmitUdevEventInContainerJob { false } - fn create_task(self: &EmitUdevEventInContainerJob) -> Pin>> { - Box::pin(self.clone().inject_in_container()) + fn create_task(self: &EmitUdevEventJob) -> Pin>> { + Box::pin(self.clone().emit_udev_event()) } fn job_target(&self) -> JobTarget { @@ -97,8 +102,8 @@ impl Job for EmitUdevEventInContainerJob { } } -impl EmitUdevEventInContainerJob { - async fn inject_in_container(self) { +impl EmitUdevEventJob { + async fn emit_udev_event(self) { // temporary hack that needs to be replaced. We try 50 times // Should be: Wait for the device to be created, the runtime data to be written and the // netlink message to be sent @@ -144,18 +149,46 @@ impl EmitUdevEventInContainerJob { let runtime_data = runtime_data.unwrap(); let netlink_data = netlink_data.unwrap(); - let emit_udev_event_action = Action::EmitUdevEvent { + match get_placement() { + Placement::InContainer => { + let write_udev_runtime_data = Action::WriteUdevRuntimeData { + runtime_data: Some(runtime_data), + major: self.major, + minor: self.minor, + }; + + let child_pid = + process_tools::start_action(write_udev_runtime_data, &self.requesting_process) + .expect("subprocess should work"); + + let _exit_info = await_process(Pid::Pid(child_pid)).await.unwrap(); + } + Placement::OnHost => { + let path_prefix = format!("/run/vuinputd/{}", global_config::get_devname()); + runtime_data::write_udev_data( + &path_prefix, + &runtime_data, + self.major.into(), + self.minor.into(), + ) + .expect(&format!( + "VUI-UDEV-002: could not write into {}", + &path_prefix + )); //TODO: somewhat costly + } + Placement::None => {} + } + + // this is always in the container + let emit_netlink_message = Action::EmitNetlinkMessage { netlink_message: netlink_data.clone(), - runtime_data: Some(runtime_data), - major: self.major, - minor: self.minor, }; - let child_pid = - process_tools::start_action(emit_udev_event_action, &self.requesting_process) - .expect("subprocess should work"); + let child_pid = process_tools::start_action(emit_netlink_message, &self.requesting_process) + .expect("subprocess should work"); let _exit_info = await_process(Pid::Pid(child_pid)).await.unwrap(); + self.set_state(&State::Finished); } } diff --git a/vuinputd/src/jobs/mknod_device_in_container_job.rs b/vuinputd/src/jobs/mknod_device_job.rs similarity index 57% rename from vuinputd/src/jobs/mknod_device_in_container_job.rs rename to vuinputd/src/jobs/mknod_device_job.rs index 90d3a7d..98bdc40 100644 --- a/vuinputd/src/jobs/mknod_device_in_container_job.rs +++ b/vuinputd/src/jobs/mknod_device_job.rs @@ -9,11 +9,14 @@ use std::{ }; use crate::{ - actions::action::Action, + actions::{action::Action, input_device}, + global_config::{self, Placement}, job_engine::job::{Job, JobTarget}, process_tools::{self, await_process, Pid, RequestingProcess}, }; +use crate::actions::runtime_data::write_udev_data; + #[derive(Clone, Debug, Copy, PartialOrd, PartialEq)] pub enum State { Initialized, @@ -22,20 +25,20 @@ pub enum State { } #[derive(Clone, Debug)] -pub struct MknodDeviceInContainerJob { +pub struct MknodDeviceJob { requesting_process: RequestingProcess, target: JobTarget, - dev_path: String, + devname: String, sys_path: String, major: u64, minor: u64, sync_state: Arc<(Mutex, Condvar)>, } -impl MknodDeviceInContainerJob { +impl MknodDeviceJob { pub fn new( requesting_process: RequestingProcess, - dev_path: String, + devname: String, sys_path: String, major: u64, minor: u64, @@ -43,7 +46,7 @@ impl MknodDeviceInContainerJob { Self { requesting_process: requesting_process.clone(), target: JobTarget::Container(requesting_process), - dev_path: dev_path, + devname: devname, sys_path: sys_path, major: major, minor: minor, @@ -73,7 +76,7 @@ impl MknodDeviceInContainerJob { } } -impl Job for MknodDeviceInContainerJob { +impl Job for MknodDeviceJob { fn desc(&self) -> &str { "mknod input device in container" } @@ -82,8 +85,8 @@ impl Job for MknodDeviceInContainerJob { false } - fn create_task(self: &MknodDeviceInContainerJob) -> Pin>> { - Box::pin(self.clone().inject_in_container()) + fn create_task(self: &MknodDeviceJob) -> Pin>> { + Box::pin(self.clone().mknod_device()) } fn job_target(&self) -> JobTarget { @@ -91,18 +94,35 @@ impl Job for MknodDeviceInContainerJob { } } -impl MknodDeviceInContainerJob { - async fn inject_in_container(self) { - let mknod_device_action = Action::MknodDevice { - path: self.dev_path.clone(), - major: self.major, - minor: self.minor, - }; +impl MknodDeviceJob { + async fn mknod_device(self) { + match global_config::get_placement() { + Placement::InContainer => { + let mknod_device_action = Action::MknodDevice { + path: format!("/dev/input/{}", &self.devname), + major: self.major, + minor: self.minor, + }; - let child_pid = process_tools::start_action(mknod_device_action, &self.requesting_process) - .expect("subprocess should work"); + let child_pid = + process_tools::start_action(mknod_device_action, &self.requesting_process) + .expect("subprocess should work"); + + let _exit_info = await_process(Pid::Pid(child_pid)).await.unwrap(); + } + Placement::OnHost => { + let path = format!( + "/run/vuinputd/{}/dev-input/{}", + global_config::get_devname(), + self.devname + ); + input_device::ensure_input_device(path.clone(), self.major, self.minor) + .expect(&format!("VUI-DEV-001: could not create {}", &path)); + //TODO: somewhat costly + } + Placement::None => {} + } - let _exit_info = await_process(Pid::Pid(child_pid)).await.unwrap(); self.set_state(&State::Finished); } } diff --git a/vuinputd/src/jobs/mod.rs b/vuinputd/src/jobs/mod.rs index 242f0ab..390a43c 100644 --- a/vuinputd/src/jobs/mod.rs +++ b/vuinputd/src/jobs/mod.rs @@ -2,7 +2,7 @@ // // Author: Johannes Leupolz -pub mod emit_udev_event_in_container_job; -pub mod mknod_device_in_container_job; +pub mod emit_udev_event_job; +pub mod mknod_device_job; pub mod monitor_udev_job; -pub mod remove_from_container_job; +pub mod remove_device_job; diff --git a/vuinputd/src/jobs/remove_from_container_job.rs b/vuinputd/src/jobs/remove_device_job.rs similarity index 66% rename from vuinputd/src/jobs/remove_from_container_job.rs rename to vuinputd/src/jobs/remove_device_job.rs index a02836b..62a8345 100644 --- a/vuinputd/src/jobs/remove_from_container_job.rs +++ b/vuinputd/src/jobs/remove_device_job.rs @@ -12,6 +12,7 @@ use log::debug; use crate::{ actions::action::Action, + global_config::{self, Placement}, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, process_tools::{self, await_process, Pid, RequestingProcess}, @@ -25,7 +26,7 @@ pub enum State { } #[derive(Clone, Debug)] -pub struct RemoveFromContainerJob { +pub struct RemoveDeviceJob { requesting_process: RequestingProcess, target: JobTarget, dev_path: String, @@ -35,7 +36,7 @@ pub struct RemoveFromContainerJob { sync_state: Arc<(Mutex, Condvar)>, } -impl RemoveFromContainerJob { +impl RemoveDeviceJob { pub fn new( requesting_process: RequestingProcess, dev_path: String, @@ -75,7 +76,7 @@ impl RemoveFromContainerJob { } } -impl Job for RemoveFromContainerJob { +impl Job for RemoveDeviceJob { fn desc(&self) -> &str { "Remove input device from container" } @@ -84,8 +85,8 @@ impl Job for RemoveFromContainerJob { false } - fn create_task(self: &RemoveFromContainerJob) -> Pin>> { - Box::pin(self.clone().remove_from_container()) + fn create_task(self: &RemoveDeviceJob) -> Pin>> { + Box::pin(self.clone().remove_device()) } fn job_target(&self) -> JobTarget { @@ -93,8 +94,8 @@ impl Job for RemoveFromContainerJob { } } -impl RemoveFromContainerJob { - async fn remove_from_container(self) { +impl RemoveDeviceJob { + async fn remove_device(self) { self.set_state(&State::Started); let netlink_event = match EVENT_STORE @@ -124,29 +125,50 @@ impl RemoveFromContainerJob { let _ = netlink_data.insert("ACTION".to_string(), "remove".to_string()); - let remove_device_action = Action::RemoveDevice { - path: dev_path.clone(), - major: self.major, - minor: self.minor, - }; + match global_config::get_placement() { + Placement::InContainer => { + 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) + let child_pid_1 = + process_tools::start_action(remove_device_action, &self.requesting_process) + .expect("subprocess should work"); + + let write_udev_runtime_data_action = Action::WriteUdevRuntimeData { + runtime_data: None, + major: self.major, + minor: self.minor, + }; + + let child_pid_2 = process_tools::start_action( + write_udev_runtime_data_action, + &self.requesting_process, + ) .expect("subprocess should work"); - let emit_udev_event_action = Action::EmitUdevEvent { + let _exit_info = await_process(Pid::Pid(child_pid_1)).await; + let _exit_info = await_process(Pid::Pid(child_pid_2)).await; + } + Placement::OnHost => { + todo!(); + } + Placement::None => {} + } + + // this is always in the container + let emit_netlink_message = Action::EmitNetlinkMessage { 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) + let child_pid_netlink = + process_tools::start_action(emit_netlink_message, &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; + let _exit_info = await_process(Pid::Pid(child_pid_netlink)).await; + self.set_state(&State::Finished); } } diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 2a4f2b9..3c6acbb 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -191,7 +191,7 @@ 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); + global_config::initialize_global_config(&args.device_policy, &args.placement, &args.devname); initialize_vuinput_state(); VUINPUT_COUNTER.set(AtomicU64::new(3)).expect( "failed to initialize the counter that provides the values of the CUSE file handles", From 350a80644a2fb43976d07f62daf1ce165f3a64d6 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 20 Jan 2026 11:59:07 +0000 Subject: [PATCH 063/108] Refactor: move stuff that actually does something into input_realizer. Action is now the entry point for cli-actions. Maybe I should rename it in the future, too. --- docs/TROUBLESHOOTING.md | 3 +++ vuinputd/src/actions/handle_action.rs | 6 +++--- vuinputd/src/actions/mod.rs | 3 --- vuinputd/src/global_config.rs | 2 +- vuinputd/src/{actions => input_realizer}/input_device.rs | 0 vuinputd/src/input_realizer/mod.rs | 7 +++++++ .../src/{actions => input_realizer}/netlink_message.rs | 0 vuinputd/src/{actions => input_realizer}/runtime_data.rs | 0 vuinputd/src/jobs/emit_udev_event_job.rs | 9 +++------ vuinputd/src/jobs/mknod_device_job.rs | 5 +++-- vuinputd/src/jobs/remove_device_job.rs | 3 +++ vuinputd/src/main.rs | 5 +++++ 12 files changed, 28 insertions(+), 15 deletions(-) rename vuinputd/src/{actions => input_realizer}/input_device.rs (100%) create mode 100644 vuinputd/src/input_realizer/mod.rs rename vuinputd/src/{actions => input_realizer}/netlink_message.rs (100%) rename vuinputd/src/{actions => input_realizer}/runtime_data.rs (100%) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 6309b93..77d7036 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -23,6 +23,7 @@ Error messages may change over time; error codes do not. | Code | Area | Summary | |------|------|--------| | VUI-UDEV-001 | udev | udev control socket not reachable | +| VUI-UDEV-002 | udev | could not write into /run/vuinputd/... | --- @@ -60,3 +61,5 @@ When reporting an issue, please include: * Full command-line invocation * Execution environment (host, container, systemd) * Relevant debug logs (see [DEBUG.md](DEBUG.md)) + +### VUI-UDEV-002 - could not write into /run/vuinputd/... diff --git a/vuinputd/src/actions/handle_action.rs b/vuinputd/src/actions/handle_action.rs index 011ca44..6b95bc9 100644 --- a/vuinputd/src/actions/handle_action.rs +++ b/vuinputd/src/actions/handle_action.rs @@ -3,9 +3,9 @@ // Author: Johannes Leupolz use super::action::Action; -use super::input_device; -use super::netlink_message; -use super::runtime_data; +use crate::input_realizer::input_device; +use crate::input_realizer::netlink_message; +use crate::input_realizer::runtime_data; pub fn handle_cli_action(json: String) -> i32 { let action: Action = serde_json::from_str(&json).expect("invalid action JSON"); diff --git a/vuinputd/src/actions/mod.rs b/vuinputd/src/actions/mod.rs index a5e98d7..f7ec18c 100644 --- a/vuinputd/src/actions/mod.rs +++ b/vuinputd/src/actions/mod.rs @@ -4,6 +4,3 @@ pub mod action; pub mod handle_action; -pub mod input_device; -pub mod netlink_message; -pub mod runtime_data; diff --git a/vuinputd/src/global_config.rs b/vuinputd/src/global_config.rs index ee7cc34..8305284 100644 --- a/vuinputd/src/global_config.rs +++ b/vuinputd/src/global_config.rs @@ -30,7 +30,7 @@ pub enum DevicePolicy { StrictGamepad, } /// Where to create runtime artifacts (device nodes + udev data) -#[derive(Debug, Clone, ValueEnum, Default)] +#[derive(Debug, Clone, ValueEnum, Default, PartialEq, Eq)] pub enum Placement { #[default] /// Create inside the container diff --git a/vuinputd/src/actions/input_device.rs b/vuinputd/src/input_realizer/input_device.rs similarity index 100% rename from vuinputd/src/actions/input_device.rs rename to vuinputd/src/input_realizer/input_device.rs diff --git a/vuinputd/src/input_realizer/mod.rs b/vuinputd/src/input_realizer/mod.rs new file mode 100644 index 0000000..0be3f87 --- /dev/null +++ b/vuinputd/src/input_realizer/mod.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +pub mod input_device; +pub mod netlink_message; +pub mod runtime_data; diff --git a/vuinputd/src/actions/netlink_message.rs b/vuinputd/src/input_realizer/netlink_message.rs similarity index 100% rename from vuinputd/src/actions/netlink_message.rs rename to vuinputd/src/input_realizer/netlink_message.rs diff --git a/vuinputd/src/actions/runtime_data.rs b/vuinputd/src/input_realizer/runtime_data.rs similarity index 100% rename from vuinputd/src/actions/runtime_data.rs rename to vuinputd/src/input_realizer/runtime_data.rs diff --git a/vuinputd/src/jobs/emit_udev_event_job.rs b/vuinputd/src/jobs/emit_udev_event_job.rs index 4aceb15..25034b9 100644 --- a/vuinputd/src/jobs/emit_udev_event_job.rs +++ b/vuinputd/src/jobs/emit_udev_event_job.rs @@ -14,12 +14,9 @@ use async_io::Timer; use log::debug; use crate::{ - actions::{ - self, - action::Action, - runtime_data::{self, read_udev_data}, - }, + actions::{self, action::Action}, global_config::{self, get_placement, Placement}, + input_realizer::runtime_data, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, process_tools::{self, await_process, Pid, RequestingProcess}, @@ -128,7 +125,7 @@ impl EmitUdevEventJob { }; } if runtime_data.is_none() { - runtime_data = read_udev_data(self.major, self.minor).ok(); + runtime_data = runtime_data::read_udev_data(self.major, self.minor).ok(); } number_of_attempt += 1; diff --git a/vuinputd/src/jobs/mknod_device_job.rs b/vuinputd/src/jobs/mknod_device_job.rs index 98bdc40..bc443c1 100644 --- a/vuinputd/src/jobs/mknod_device_job.rs +++ b/vuinputd/src/jobs/mknod_device_job.rs @@ -9,13 +9,14 @@ use std::{ }; use crate::{ - actions::{action::Action, input_device}, + actions::action::Action, global_config::{self, Placement}, + input_realizer::input_device, job_engine::job::{Job, JobTarget}, process_tools::{self, await_process, Pid, RequestingProcess}, }; -use crate::actions::runtime_data::write_udev_data; +use crate::input_realizer::runtime_data::write_udev_data; #[derive(Clone, Debug, Copy, PartialOrd, PartialEq)] pub enum State { diff --git a/vuinputd/src/jobs/remove_device_job.rs b/vuinputd/src/jobs/remove_device_job.rs index 62a8345..e3c7281 100644 --- a/vuinputd/src/jobs/remove_device_job.rs +++ b/vuinputd/src/jobs/remove_device_job.rs @@ -13,6 +13,7 @@ use log::debug; use crate::{ actions::action::Action, global_config::{self, Placement}, + input_realizer::{input_device, runtime_data}, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, process_tools::{self, await_process, Pid, RequestingProcess}, @@ -154,6 +155,8 @@ impl RemoveDeviceJob { } Placement::OnHost => { todo!(); + //input_device::remove_input_device(path, major.into(), minor.into())?; + //runtime_data::delete_udev_data("/run",major.into(), minor.into())?; } Placement::None => {} } diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 3c6acbb..43c2aa5 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -42,6 +42,7 @@ use crate::job_engine::{job::*, JOB_DISPATCHER}; use crate::process_tools::*; pub mod actions; +pub mod input_realizer; pub mod global_config; pub mod jobs; @@ -218,6 +219,10 @@ fn main() -> std::io::Result<()> { None => "vuinput", Some(devname) => devname, }; + if args.placement==Placement::OnHost { + todo!("ensure structure and writablity of dev-input") + } + let vuinput_devicename = CString::new(format!("DEVNAME={}", vuinput_devicename)).unwrap(); let mut dev_info_argv: Vec<*const c_char> = vec![ From fd55bffddb9e3511ba57b7e6c2af3dfe1382e903 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 20 Jan 2026 21:26:18 +0000 Subject: [PATCH 064/108] Implementation of the remove-device-step for --placement on-host. Not tested, yet --- docs/TROUBLESHOOTING.md | 2 + vuinputd/src/actions/handle_action.rs | 8 ++- vuinputd/src/cuse_device/state.rs | 1 + vuinputd/src/cuse_device/vuinput_ioctl.rs | 3 +- vuinputd/src/cuse_device/vuinput_release.rs | 4 +- vuinputd/src/global_config.rs | 10 +-- vuinputd/src/input_realizer/host_fs.rs | 80 +++++++++++++++++++++ vuinputd/src/input_realizer/mod.rs | 1 + vuinputd/src/input_realizer/runtime_data.rs | 6 +- vuinputd/src/jobs/emit_udev_event_job.rs | 4 +- vuinputd/src/jobs/mknod_device_job.rs | 9 +-- vuinputd/src/jobs/remove_device_job.rs | 24 ++++--- vuinputd/src/main.rs | 6 +- 13 files changed, 125 insertions(+), 33 deletions(-) create mode 100644 vuinputd/src/input_realizer/host_fs.rs diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 77d7036..5ba8194 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -24,6 +24,8 @@ Error messages may change over time; error codes do not. |------|------|--------| | VUI-UDEV-001 | udev | udev control socket not reachable | | VUI-UDEV-002 | udev | could not write into /run/vuinputd/... | +| VUI-UDEV-003 | udev | could not remove udev data from ... | +| VUI-DEV-001 | ddev | could not remove device node ... | --- diff --git a/vuinputd/src/actions/handle_action.rs b/vuinputd/src/actions/handle_action.rs index 6b95bc9..3e12026 100644 --- a/vuinputd/src/actions/handle_action.rs +++ b/vuinputd/src/actions/handle_action.rs @@ -26,10 +26,12 @@ fn handle_action(action: Action) -> anyhow::Result<()> { major, minor, } => { - runtime_data::ensure_udev_structure("/run",true)?; + runtime_data::ensure_udev_structure()?; match runtime_data { - Some(data) => runtime_data::write_udev_data("/run",&data, major.into(), minor.into())?, - None => runtime_data::delete_udev_data("/run",major.into(), minor.into())?, + Some(data) => { + runtime_data::write_udev_data("/run", &data, major.into(), minor.into())? + } + None => runtime_data::delete_udev_data("/run", major.into(), minor.into())?, } Ok(()) } diff --git a/vuinputd/src/cuse_device/state.rs b/vuinputd/src/cuse_device/state.rs index 713c771..36a1410 100644 --- a/vuinputd/src/cuse_device/state.rs +++ b/vuinputd/src/cuse_device/state.rs @@ -15,6 +15,7 @@ pub struct VuInputDevice { pub major: u64, pub minor: u64, pub syspath: String, + pub devname: String, pub devnode: String, } diff --git a/vuinputd/src/cuse_device/vuinput_ioctl.rs b/vuinputd/src/cuse_device/vuinput_ioctl.rs index b4ae890..93a3961 100644 --- a/vuinputd/src/cuse_device/vuinput_ioctl.rs +++ b/vuinputd/src/cuse_device/vuinput_ioctl.rs @@ -172,6 +172,7 @@ pub unsafe extern "C" fn vuinput_ioctl( major: major, minor: minor, syspath: sysname.clone(), + devname: devname.clone(), devnode: devnode.clone(), }); @@ -231,7 +232,7 @@ pub unsafe extern "C" fn vuinput_ioctl( let input_device = input_device.unwrap(); let remove_job = RemoveDeviceJob::new( vuinput_state.requesting_process.clone(), - input_device.devnode.clone(), + input_device.devname.clone(), input_device.syspath.clone(), input_device.major, input_device.minor, diff --git a/vuinputd/src/cuse_device/vuinput_release.rs b/vuinputd/src/cuse_device/vuinput_release.rs index 51e5906..2857064 100644 --- a/vuinputd/src/cuse_device/vuinput_release.rs +++ b/vuinputd/src/cuse_device/vuinput_release.rs @@ -3,7 +3,7 @@ // Author: Johannes Leupolz use crate::job_engine::JOB_DISPATCHER; -use crate::jobs::remove_device_job::{self, RemoveDeviceJob}; +use crate::jobs::remove_device_job::RemoveDeviceJob; use crate::process_tools::SELF_NAMESPACES; use crate::{cuse_device::*, jobs}; use ::cuse_lowlevel::*; @@ -34,7 +34,7 @@ pub unsafe extern "C" fn vuinput_release( let input_device = input_device.unwrap(); let remove_job = RemoveDeviceJob::new( vuinput_state.requesting_process.clone(), - input_device.devnode.clone(), + input_device.devname.clone(), input_device.syspath.clone(), input_device.major, input_device.minor, diff --git a/vuinputd/src/global_config.rs b/vuinputd/src/global_config.rs index 8305284..d91de99 100644 --- a/vuinputd/src/global_config.rs +++ b/vuinputd/src/global_config.rs @@ -2,14 +2,14 @@ // // Author: Johannes Leupolz -use clap::{Parser, ValueEnum}; +use clap::ValueEnum; use std::sync::OnceLock; #[derive(Debug)] pub struct GlobalConfig { pub policy: DevicePolicy, pub placement: Placement, - pub devname: String, + pub vudevname: String, } // The actual static variable. It starts empty and is set once in main(). @@ -50,7 +50,7 @@ pub fn initialize_global_config( .set(GlobalConfig { policy: device_policy.clone(), placement: placement.clone(), - devname: devname.clone().unwrap_or("vuinput".to_string()), + vudevname: devname.clone().unwrap_or("vuinput".to_string()), }) .is_err() { @@ -67,6 +67,6 @@ pub fn get_placement<'a>() -> &'a Placement { &CONFIG.get().unwrap().placement } -pub fn get_devname<'a>() -> &'a String { - &CONFIG.get().unwrap().devname +pub fn get_vudevname<'a>() -> &'a String { + &CONFIG.get().unwrap().vudevname } diff --git a/vuinputd/src/input_realizer/host_fs.rs b/vuinputd/src/input_realizer/host_fs.rs new file mode 100644 index 0000000..06df7d1 --- /dev/null +++ b/vuinputd/src/input_realizer/host_fs.rs @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::io::{self, BufRead}; +use std::{ + fs::{self, File}, + path::Path, +}; + +/// Ensure required dev-input, udev directories and files exist +pub fn ensure_host_fs_structure(path_prefix: &str) -> io::Result<()> { + let _ = check_if_path_allows_char_devs(&path_prefix); + let dev_input_dir = format!("{}/dev-input", path_prefix); + let dev_input_dir = Path::new(&dev_input_dir); + // Create directory like `mkdir -p` + if !dev_input_dir.exists() { + fs::create_dir_all(dev_input_dir)?; + } + + // Note that this structure _must_ exist, before a service using libinput is run. + let data_dir = format!("{}/udev/data", path_prefix); + let data_dir = Path::new(&data_dir); + // Create directory like `mkdir -p` + if !data_dir.exists() { + fs::create_dir_all(data_dir)?; + } + + let control_file = format!("{}/udev/control", path_prefix); + let control_file = Path::new(&control_file); + // Ensure /run/udev/control exists, create empty if not + if !control_file.exists() { + File::create(control_file)?; + } + + Ok(()) +} + +/// simple heuristic that checks whether path_prefix allows the hosting of character devices +/// This heuristic is not 100%, but a simple indicator +pub fn check_if_path_allows_char_devs(path: &str) -> io::Result<()> { + let file = File::open("/proc/self/mountinfo")?; + let reader = io::BufReader::new(file); + + for line in reader.lines() { + let line = line?; + + let (left, _) = match line.split_once(" - ") { + Some(v) => v, + None => continue, + }; + + let fields: Vec<&str> = left.split_whitespace().collect(); + + // mount point is field 5 + let mount_point = fields.get(4).copied().unwrap_or(""); + // mount options are field 6 + let options = fields.get(5).copied().unwrap_or(""); + + if mount_point.contains(path) { + if options.split(',').any(|o| o == "nodev") { + log::warn!( + "mount {} is present but mounted with nodev; device nodes will not work", + path + ); + } else { + log::info!("mount {} is present and allows device nodes", path); + } + + return Ok(()); + } + } + + log::warn!( + "expected mount {} not found; user likely forgot to mount tmpfs with dev-option on it", + path + ); + + Ok(()) +} diff --git a/vuinputd/src/input_realizer/mod.rs b/vuinputd/src/input_realizer/mod.rs index 0be3f87..97565e8 100644 --- a/vuinputd/src/input_realizer/mod.rs +++ b/vuinputd/src/input_realizer/mod.rs @@ -2,6 +2,7 @@ // // Author: Johannes Leupolz +pub mod host_fs; pub mod input_device; pub mod netlink_message; pub mod runtime_data; diff --git a/vuinputd/src/input_realizer/runtime_data.rs b/vuinputd/src/input_realizer/runtime_data.rs index 2ad44db..b0be8cc 100644 --- a/vuinputd/src/input_realizer/runtime_data.rs +++ b/vuinputd/src/input_realizer/runtime_data.rs @@ -9,12 +9,12 @@ use std::path::Path; use log::{info, warn}; /// Ensure required udev directories and files exist -pub fn ensure_udev_structure(path_prefix: &str, warn_if_nonexistent: bool) -> io::Result<()> { +pub fn ensure_udev_structure() -> io::Result<()> { // Note that this structure _must_ exist, before a service using libinput is run. The time of device creation might be too late. - let data_dir = format!("{}/udev/data", path_prefix); + let data_dir = format!("/run/udev/data"); let data_dir = Path::new(&data_dir); - let control_file = format!("{}/udev/control", path_prefix); + let control_file = format!("/run/udev/control"); let control_file = Path::new(&control_file); // Create directory like `mkdir -p` diff --git a/vuinputd/src/jobs/emit_udev_event_job.rs b/vuinputd/src/jobs/emit_udev_event_job.rs index 25034b9..a441d81 100644 --- a/vuinputd/src/jobs/emit_udev_event_job.rs +++ b/vuinputd/src/jobs/emit_udev_event_job.rs @@ -14,7 +14,7 @@ use async_io::Timer; use log::debug; use crate::{ - actions::{self, action::Action}, + actions::action::Action, global_config::{self, get_placement, Placement}, input_realizer::runtime_data, job_engine::job::{Job, JobTarget}, @@ -161,7 +161,7 @@ impl EmitUdevEventJob { let _exit_info = await_process(Pid::Pid(child_pid)).await.unwrap(); } Placement::OnHost => { - let path_prefix = format!("/run/vuinputd/{}", global_config::get_devname()); + let path_prefix = format!("/run/vuinputd/{}", global_config::get_vudevname()); runtime_data::write_udev_data( &path_prefix, &runtime_data, diff --git a/vuinputd/src/jobs/mknod_device_job.rs b/vuinputd/src/jobs/mknod_device_job.rs index bc443c1..1146a36 100644 --- a/vuinputd/src/jobs/mknod_device_job.rs +++ b/vuinputd/src/jobs/mknod_device_job.rs @@ -16,8 +16,6 @@ use crate::{ process_tools::{self, await_process, Pid, RequestingProcess}, }; -use crate::input_realizer::runtime_data::write_udev_data; - #[derive(Clone, Debug, Copy, PartialOrd, PartialEq)] pub enum State { Initialized, @@ -112,11 +110,8 @@ impl MknodDeviceJob { let _exit_info = await_process(Pid::Pid(child_pid)).await.unwrap(); } Placement::OnHost => { - let path = format!( - "/run/vuinputd/{}/dev-input/{}", - global_config::get_devname(), - self.devname - ); + let path_prefix = format!("/run/vuinputd/{}", global_config::get_vudevname()); + let path = format!("{}/dev-input/{}", path_prefix, self.devname); input_device::ensure_input_device(path.clone(), self.major, self.minor) .expect(&format!("VUI-DEV-001: could not create {}", &path)); //TODO: somewhat costly diff --git a/vuinputd/src/jobs/remove_device_job.rs b/vuinputd/src/jobs/remove_device_job.rs index e3c7281..4e76260 100644 --- a/vuinputd/src/jobs/remove_device_job.rs +++ b/vuinputd/src/jobs/remove_device_job.rs @@ -30,7 +30,7 @@ pub enum State { pub struct RemoveDeviceJob { requesting_process: RequestingProcess, target: JobTarget, - dev_path: String, + dev_name: String, sys_path: String, major: u64, minor: u64, @@ -40,7 +40,7 @@ pub struct RemoveDeviceJob { impl RemoveDeviceJob { pub fn new( requesting_process: RequestingProcess, - dev_path: String, + dev_name: String, sys_path: String, major: u64, minor: u64, @@ -48,7 +48,7 @@ impl RemoveDeviceJob { Self { requesting_process: requesting_process.clone(), target: JobTarget::Container(requesting_process), - dev_path: dev_path, + dev_name: dev_name, sys_path: sys_path, major: major, minor: minor, @@ -122,14 +122,14 @@ impl RemoveDeviceJob { let netlink_data = netlink_event.add_data; let mut netlink_data = netlink_data.unwrap().clone(); - let dev_path = self.dev_path.clone(); let _ = netlink_data.insert("ACTION".to_string(), "remove".to_string()); match global_config::get_placement() { Placement::InContainer => { + let dev_path = format!("/dev/input/{}", &self.dev_name); let remove_device_action = Action::RemoveDevice { - path: dev_path.clone(), + path: dev_path, major: self.major, minor: self.minor, }; @@ -154,9 +154,17 @@ impl RemoveDeviceJob { let _exit_info = await_process(Pid::Pid(child_pid_2)).await; } Placement::OnHost => { - todo!(); - //input_device::remove_input_device(path, major.into(), minor.into())?; - //runtime_data::delete_udev_data("/run",major.into(), minor.into())?; + let path_prefix = format!("/run/vuinputd/{}", global_config::get_vudevname()); + let devnode = format!("{}/dev-input/{}", path_prefix, self.dev_name); + input_device::remove_input_device(devnode.clone(), self.major, self.minor).expect( + &format!("VUI-DEV-003: could not remove device node {}", &devnode), + ); + runtime_data::delete_udev_data(&path_prefix, self.major, self.minor).expect( + &format!( + "VUI-UDEV-003: could not remove udev data from {}", + &path_prefix + ), + ); } Placement::None => {} } diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 43c2aa5..a6e69cc 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -33,6 +33,7 @@ use crate::cuse_device::state::{initialize_dedup_last_error, initialize_vuinput_ use crate::cuse_device::vuinput_make_cuse_ops; use crate::cuse_device::vuinput_open::VUINPUT_COUNTER; use crate::global_config::{DevicePolicy, Placement}; +use crate::input_realizer::host_fs; use crate::jobs::monitor_udev_job::MonitorBackgroundLoop; pub mod process_tools; @@ -219,8 +220,9 @@ fn main() -> std::io::Result<()> { None => "vuinput", Some(devname) => devname, }; - if args.placement==Placement::OnHost { - todo!("ensure structure and writablity of dev-input") + if args.placement == Placement::OnHost { + let path_prefix = format!("/run/vuinputd/{}", global_config::get_vudevname()); + let _ = host_fs::ensure_host_fs_structure(&path_prefix); } let vuinput_devicename = CString::new(format!("DEVNAME={}", vuinput_devicename)).unwrap(); From 6e23a02e3fd67a93e4b937d030abcf4c1b64f542 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 20 Jan 2026 22:52:09 +0000 Subject: [PATCH 065/108] Wrote integration test to test --placement on-host to fix #1. Looks good so far. --- docs/TESTS.md | 9 +++++ vuinputd-tests/src/bwrap.rs | 5 ++- vuinputd-tests/src/run_vuinputd.rs | 40 ++++++++++++---------- vuinputd-tests/tests/integration_tests.rs | 41 +++++++++++++++++++++-- vuinputd-tests/tests/podman_tests.rs | 6 ++-- vuinputd/src/jobs/emit_udev_event_job.rs | 2 +- vuinputd/src/jobs/mknod_device_job.rs | 2 +- vuinputd/src/jobs/remove_device_job.rs | 2 +- 8 files changed, 80 insertions(+), 27 deletions(-) diff --git a/docs/TESTS.md b/docs/TESTS.md index e68222a..1400ea2 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -2,6 +2,15 @@ ## Integration tests +Ensure, you have a `/run/vuinputd/vuinput-test`-folder that allows the usage of character devices: +``` +mkdir -p /run/vuinputd/vuinput-test +mount -t tmpfs -o mode=755,size=1M tmpfs /run/vuinputd/vuinput-test +mkdir -p /run/vuinputd/vuinput-test/dev +mkdir -p /run/vuinputd/vuinput-test/dev-input +mkdir -p /run/vuinputd/vuinput-test/udev +``` + ### With bubblewrap Install bubblewrap: diff --git a/vuinputd-tests/src/bwrap.rs b/vuinputd-tests/src/bwrap.rs index 8ccdcbe..a777efc 100644 --- a/vuinputd-tests/src/bwrap.rs +++ b/vuinputd-tests/src/bwrap.rs @@ -58,8 +58,11 @@ impl BwrapBuilder { // So, we mount a temporary directory that does not have this restrictions. self.args.extend([ "--dev-bind".into(), - "/dev/tmp/vuinputd-test".into(), + "/run/vuinputd/vuinput-test/dev".into(), "/dev".into(), + "--dev-bind".into(), + "/run/vuinputd/vuinput-test/dev-input".into(), + "/dev/input".into(), ]); self } diff --git a/vuinputd-tests/src/run_vuinputd.rs b/vuinputd-tests/src/run_vuinputd.rs index e5241f4..08397bb 100644 --- a/vuinputd-tests/src/run_vuinputd.rs +++ b/vuinputd-tests/src/run_vuinputd.rs @@ -5,7 +5,7 @@ use std::{ os::unix::process::CommandExt, process::{Child, Command}, - sync::OnceLock, + sync::Mutex, thread, time::Duration, }; @@ -14,33 +14,37 @@ use nix::sys::signal::{self, Signal}; use nix::unistd::Pid; /// Global singleton -static VUINPUTD: OnceLock = OnceLock::new(); +static VUINPUTD_LOCK: Mutex<()> = Mutex::new(()); -pub fn ensure_vuinputd_running() { - VUINPUTD.get_or_init(|| VuinputdGuard::start()); +pub fn ensure_vuinputd_running(args: &[&str]) -> VuinputdGuard { + VuinputdGuard::start(args) } -struct VuinputdGuard { +pub struct VuinputdGuard { child: Child, } impl VuinputdGuard { - fn start() -> Self { + fn start(args: &[&str]) -> Self { + println!("Acquiring lock to ensure only one vuinputd test instance is running"); + let _mutex = VUINPUTD_LOCK.lock().unwrap(); println!("Executing vuinputd located via cargo run"); + let mut concat_args = vec![ + "run", + "-p", + "vuinputd", + "--", + "--major", + "120", + "--minor", + "414796", + "--devname", + "vuinput-test", + ]; + concat_args.extend(args); let child = unsafe { Command::new("cargo") - .args([ - "run", - "-p", - "vuinputd", - "--", - "--major", - "120", - "--minor", - "414796", - "--devname", - "vuinputd-test", - ]) + .args(concat_args) .pre_exec(|| { // Last resort, if the parent just is killed. libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL); diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index 6100df1..4e96b58 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -127,8 +127,8 @@ fn test_keyboard_in_container_with_uinput() { feature = "requires-bwrap" ))] #[test] -fn test_keyboard_in_container_with_vuinput() { - run_vuinputd::ensure_vuinputd_running(); +fn test_keyboard_in_container_with_vuinput_placement_in_container() { + let _guard: run_vuinputd::VuinputdGuard=run_vuinputd::ensure_vuinputd_running(&[]); let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); @@ -156,3 +156,40 @@ fn test_keyboard_in_container_with_vuinput() { assert!(out.status.success()); } + +#[cfg(all( + feature = "requires-privileges", + feature = "requires-uinput", + feature = "requires-bwrap" +))] +#[test] +fn test_keyboard_in_container_with_vuinput_placement_on_host() { + let _guard=run_vuinputd::ensure_vuinputd_running(&["--placement","on-host"]); + + let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); + + let (builder, _ipc) = bwrap::BwrapBuilder::new() + .unshare_net() + .ro_bind("/", "/") + .tmpfs("/tmp") + // dev needs to be writable for the new devices + .dev() + // run needs to be writable for the udev devices + .tmpfs("/run") + .bind("/run/vuinputd/vuinput-test/udev", "/run/udev") + .dev_bind("/dev/vuinput-test", "/dev/uinput") + .die_with_parent() + .with_ipc() + .expect("failed to create IPC"); + + let out = builder + .command(test_keyboard, &["--ipc"]) + .run() + .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); + + assert!(out.status.success()); +} diff --git a/vuinputd-tests/tests/podman_tests.rs b/vuinputd-tests/tests/podman_tests.rs index bf974fe..291d2c1 100644 --- a/vuinputd-tests/tests/podman_tests.rs +++ b/vuinputd-tests/tests/podman_tests.rs @@ -2,7 +2,7 @@ // // Author: Johannes Leupolz -use std::{process::Command, time::Duration}; +use std::time::Duration; use vuinputd_tests::podman; use vuinputd_tests::run_vuinputd; @@ -66,9 +66,9 @@ fn test_podman_ipc() { ))] #[test] fn test_keyboard_in_container_with_vuinput() { - run_vuinputd::ensure_vuinputd_running(); + let _guard=run_vuinputd::ensure_vuinputd_running(&[]); - let (builder, ipc) = podman::PodmanBuilder::new() + let (builder, _ipc) = podman::PodmanBuilder::new() .run_cmd() .rm() .with_ipc() diff --git a/vuinputd/src/jobs/emit_udev_event_job.rs b/vuinputd/src/jobs/emit_udev_event_job.rs index a441d81..6132230 100644 --- a/vuinputd/src/jobs/emit_udev_event_job.rs +++ b/vuinputd/src/jobs/emit_udev_event_job.rs @@ -83,7 +83,7 @@ impl EmitUdevEventJob { impl Job for EmitUdevEventJob { fn desc(&self) -> &str { - "emit udev event into container" + "emit udev event" } fn execute_after_cancellation(&self) -> bool { diff --git a/vuinputd/src/jobs/mknod_device_job.rs b/vuinputd/src/jobs/mknod_device_job.rs index 1146a36..6b63963 100644 --- a/vuinputd/src/jobs/mknod_device_job.rs +++ b/vuinputd/src/jobs/mknod_device_job.rs @@ -77,7 +77,7 @@ impl MknodDeviceJob { impl Job for MknodDeviceJob { fn desc(&self) -> &str { - "mknod input device in container" + "mknod input device" } fn execute_after_cancellation(&self) -> bool { diff --git a/vuinputd/src/jobs/remove_device_job.rs b/vuinputd/src/jobs/remove_device_job.rs index 4e76260..9097899 100644 --- a/vuinputd/src/jobs/remove_device_job.rs +++ b/vuinputd/src/jobs/remove_device_job.rs @@ -79,7 +79,7 @@ impl RemoveDeviceJob { impl Job for RemoveDeviceJob { fn desc(&self) -> &str { - "Remove input device from container" + "Remove input device" } fn execute_after_cancellation(&self) -> bool { From 4d87dfea44693c83fd901ca3eca80582abf331b3 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 26 Jan 2026 20:23:45 +0100 Subject: [PATCH 066/108] Add CodeQL analysis workflow configuration --- .github/workflows/codeql.yml | 101 +++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..70185a2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,101 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '30 12 * * 2' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: rust + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Run manual build steps + if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" From 62b5de7bf41eb33c782f3a907107ca60663db334 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 26 Jan 2026 21:46:41 +0000 Subject: [PATCH 067/108] A fallbackdm: Add research on how TakeControl works to grab the keyboard input --- fallbackdm/DESIGN.md | 103 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 fallbackdm/DESIGN.md diff --git a/fallbackdm/DESIGN.md b/fallbackdm/DESIGN.md new file mode 100644 index 0000000..2514f48 --- /dev/null +++ b/fallbackdm/DESIGN.md @@ -0,0 +1,103 @@ +# Background + +Motivation +When a display server (like X11 or Wayland) takes control of a session, it needs exclusive access to the virtual terminal (VT). Without this, the kernel could still process keyboard input directly at the VT level, causing interference with the display server's input handling. This creates a conflict where both the VT subsystem and display server try to handle the same keystrokes, leading to unpredictable behavior. + +Details +The D-Bus TakeControl method initiates this process by validating the caller's permissions and delegating to session_set_controller() [1a]. This function sets up bus tracking to monitor the controller's lifecycle and critically calls session_prepare_vt() [1b] to configure the VT hardware. + +The VT preparation performs three essential operations: it changes ownership of the VT device file to the session user, then issues two key ioctl calls. The first, ioctl(vt, KDSKBMODE, K_OFF) [1c], disables keyboard input at the VT level - this is the "muting" that prevents the kernel from processing keystrokes directly. The second, ioctl(vt, KDSETMODE, KD_GRAPHICS) [1d], switches the VT to graphics mode. Finally, it sets VT process mode [1e] to handle VT switches through signals rather than the default kernel mechanism. + +This sequence ensures the display server has uninterrupted control over input and output, preventing the kernel from interfering with session management. + +Detailed code path: + +1 +D-Bus TakeControl to VT Muting +Traces the complete flow from D-Bus TakeControl call to VT keyboard muting via ioctl. See guide + +method_take_control() entry point +Permission validation + +1a +TakeControl calls session_set_controller +logind-session-dbus.c:404 +r = session_set_controller(s, sd_bus_message_get_sender(message), force, true); +Bus tracking setup + +1b +session_set_controller prepares VT +logind-session.c:1506 +if (prepare) { r = session_prepare_vt(s); +session_open_vt() +fchown() for ownership +VT configuration sequence + +1c +VT keyboard is disabled +logind-session.c:1339 +r = ioctl(vt, KDSKBMODE, K_OFF); + +1d +VT set to graphics mode +logind-session.c:1347 +r = ioctl(vt, KDSETMODE, KD_GRAPHICS); + +1e +VT process mode configured +logind-session.c:1358 + + +## Example Mutter from Gnome + +Motivation +When Mutter runs as a Wayland compositor on a Linux system, it needs to control the session to manage things like VT switching and device access. Without registering as the session controller, Mutter couldn't properly handle these system-level operations, leading to broken display switching and input device management. + +Details +The registration happens during backend initialization when the native backend creates a launcher instance [1a]. This launcher obtains a D-Bus proxy to the systemd-logind session and calls the TakeControl method to register as the session controller [1b]. + +The process is: + +Native backend starts up and creates a MetaLauncher +Launcher gets the session proxy from systemd-logind +Calls TakeControl D-Bus method with force=FALSE +Stores the result to track if control was acquired +If registration fails, Mutter continues running but with limited capabilities - it won't be able to switch VTs or manage device permissions. The launcher exposes the control status via meta_launcher_is_session_controller() so other components can check if Mutter successfully became the session controller. + + +## GDM Greeter + +how does the greeter take over the seat when it starts (not how it communicates with systemd-logind to create a user session once the user enters his credentials. +Fast +The greeter takes over the seat through GDM's display management system when it starts. This process involves creating a display for the seat and launching the greeter session on that display. + +Seat Takeover Process +1. Display Creation for Seat +When a greeter needs to start, ensure_display_for_seat() in daemon/gdm-local-display-factory.c is called to create a display for the specific seat gdm-local-display-factory.c:814-880 . This function: + +Checks if the seat supports graphics via sd_seat_can_graphical() gdm-local-display-factory.c:863-876 +Creates a new GdmDisplay object for the seat +Sets up the display environment +2. Greeter Session Startup +Once the display is created, gdm_display_start_greeter_session() in daemon/gdm-display.c starts the greeter process gdm-display.c:1682-1743 . This function: + +Gets the seat ID and display name for the greeter gdm-display.c:1698-1708 +Sets up signal handlers for the launch environment lifecycle gdm-display.c:1709-1728 +Starts the greeter via gdm_launch_environment_start() gdm-display.c:1736 +3. Manager Coordination +The GdmManager coordinates this process through set_up_greeter_session() which: gdm-manager.c:1432-1449 + +Determines the appropriate user for the greeter display +Creates a user session for the greeter +Calls gdm_display_start_greeter_session() to start the greeter +Seat Activation Mechanism +The greeter gains control of the seat through the display server (X11 or Wayland) that runs as part of the launch environment. When gdm_launch_environment_start() is called, it: + +Starts the appropriate display server for the seat +Launches the greeter program on that display server +The display server provides the greeter with access to the seat's graphics hardware and input devices +This gives the greeter exclusive control of the seat's display and input devices until the user authenticates and a user session is started. + +Notes +The seat takeover is essentially the process of creating a display-server instance for the seat and launching the greeter as a client of that display server. The greeter runs as the unprivileged "gdm" user but gets control of the seat through its connection to the display server that manages the seat's hardware resources. + From e5bb81f86e4cd9c95f4b00fbe3dcd362ec4daf06 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 26 Jan 2026 23:06:46 +0000 Subject: [PATCH 068/108] Add DESIGN.md document for fallbackdm. Still no implementation committed. --- fallbackdm/DESIGN.md | 197 +++++++++++++++++----------- fallbackdm/systemd/pam.d_fallbackdm | 9 +- 2 files changed, 127 insertions(+), 79 deletions(-) diff --git a/fallbackdm/DESIGN.md b/fallbackdm/DESIGN.md index 2514f48..3c157fb 100644 --- a/fallbackdm/DESIGN.md +++ b/fallbackdm/DESIGN.md @@ -1,103 +1,144 @@ -# Background +# DESIGN.md +## 1. Design Philosophy: Standardized Infrastructure -Motivation -When a display server (like X11 or Wayland) takes control of a session, it needs exclusive access to the virtual terminal (VT). Without this, the kernel could still process keyboard input directly at the VT level, causing interference with the display server's input handling. This creates a conflict where both the VT subsystem and display server try to handle the same keystrokes, leading to unpredictable behavior. +The core design principle of `fallbackdm` is to **utilize established systemd interfaces** to solve the "Empty Seat" problem. -Details -The D-Bus TakeControl method initiates this process by validating the caller's permissions and delegating to session_set_controller() [1a]. This function sets up bus tracking to monitor the controller's lifecycle and critically calls session_prepare_vt() [1b] to configure the VT hardware. +In a modern Linux ecosystem, hardware resource arbitration has moved away from direct device manipulation by individual applications. `systemd-logind` now acts as the central arbiter for seats, sessions, and terminal states. -The VT preparation performs three essential operations: it changes ownership of the VT device file to the session user, then issues two key ioctl calls. The first, ioctl(vt, KDSKBMODE, K_OFF) [1c], disables keyboard input at the VT level - this is the "muting" that prevents the kernel from processing keystrokes directly. The second, ioctl(vt, KDSETMODE, KD_GRAPHICS) [1d], switches the VT to graphics mode. Finally, it sets VT process mode [1e] to handle VT switches through signals rather than the default kernel mechanism. +`fallbackdm` is designed to be a "minimalist citizen" of this architecture. Instead of implementing a full graphical stack to "own" the hardware, it leverages the fact that the kernel and `logind` already provide a mechanism to mute VTs and protect input streams. By registering as a formal session controller, `fallbackdm` ensures the system remains in a "graphical-ready" state—silencing the legacy text console—without the overhead of an actual display server. -This sequence ensures the display server has uninterrupted control over input and output, preventing the kernel from interfering with session management. +## 2. The Mechanism: The `TakeControl` Handshake -Detailed code path: +The critical functionality of `fallbackdm`—silencing the kernel terminal to prevent input leakage—is achieved through a single, standardized D-Bus handshake: `TakeControl`. -1 -D-Bus TakeControl to VT Muting -Traces the complete flow from D-Bus TakeControl call to VT keyboard muting via ioctl. See guide +This mechanism replaces manual `ioctl` calls. By invoking the `TakeControl` method on the `org.freedesktop.login1.Session` interface, `fallbackdm` triggers a privileged workflow inside `logind` that safely transitions the machine state. -method_take_control() entry point -Permission validation +### 2.1 The Internal Workflow -1a -TakeControl calls session_set_controller -logind-session-dbus.c:404 -r = session_set_controller(s, sd_bus_message_get_sender(message), force, true); -Bus tracking setup +When `fallbackdm` calls `TakeControl`, it triggers the following verified code path within `systemd-logind` (as referenced in `logind-session.c`): -1b -session_set_controller prepares VT -logind-session.c:1506 -if (prepare) { r = session_prepare_vt(s); -session_open_vt() -fchown() for ownership -VT configuration sequence - -1c -VT keyboard is disabled -logind-session.c:1339 -r = ioctl(vt, KDSKBMODE, K_OFF); - -1d -VT set to graphics mode -logind-session.c:1347 -r = ioctl(vt, KDSETMODE, KD_GRAPHICS); - -1e -VT process mode configured -logind-session.c:1358 +1. **Permission Check:** `logind` verifies the caller owns the session. +2. **Controller Assignment:** `session_set_controller()` marks `fallbackdm` as the active display server. +3. **VT Preparation:** `logind` executes `session_prepare_vt()`, which performs the privileged operations: +* **Mute Input:** Calls `ioctl(vt, KDSKBMODE, K_OFF)`. This effectively disconnects the kernel console from the keyboard, preventing `getty` or the kernel from interpreting keystrokes. +* **Graphics Mode:** Calls `ioctl(vt, KDSETMODE, KD_GRAPHICS)`. This disables the blinking cursor and text rendering. +* **Signal Handling:** Configures the VT to send signals (like `SIGUSR1`) for switching, rather than automatically switching context. -## Example Mutter from Gnome -Motivation -When Mutter runs as a Wayland compositor on a Linux system, it needs to control the session to manage things like VT switching and device access. Without registering as the session controller, Mutter couldn't properly handle these system-level operations, leading to broken display switching and input device management. +**Result:** `fallbackdm` achieves a "muted" state without ever needing to open a device file or possess `CAP_SYS_TTY_CONFIG` capabilities directly. -Details -The registration happens during backend initialization when the native backend creates a launcher instance [1a]. This launcher obtains a D-Bus proxy to the systemd-logind session and calls the TakeControl method to register as the session controller [1b]. +## 3. Industry Precedent: The Standard Stack -The process is: +`fallbackdm` does not invent a new protocol; it isolates the infrastructure logic used by modern Linux desktops. To understand why `fallbackdm` works, we must look at how the **Wayland Native** stack (GDM and Mutter) handles seat ownership. -Native backend starts up and creates a MetaLauncher -Launcher gets the session proxy from systemd-logind -Calls TakeControl D-Bus method with force=FALSE -Stores the result to track if control was acquired -If registration fails, Mutter continues running but with limited capabilities - it won't be able to switch VTs or manage device permissions. The launcher exposes the control status via meta_launcher_is_session_controller() so other components can check if Mutter successfully became the session controller. +### 3.1 Foundational Concepts: Seats, Sessions, and Leaders + +On a modern system, hardware access is governed by several distinct layers: + +* **The Seat (e.g., `seat0`):** A collection of hardware (GPU, Keyboard, Mouse). +* **The Session:** An instance of a user (or service) interacting with a seat. +* **The Session Leader:** The primary process responsible for the session. In a graphical world, this is the **Compositor** (Mutter). +* **The Display Manager (GDM):** A supervisor that manages the lifecycle of sessions. + +In the **Wayland Native** workflow, the compositor (Mutter) serves as the "Display Server." It talks directly to the kernel for graphics (DRM/KMS) and input (libinput). However, it does not "steal" these resources; it asks `systemd-logind` for permission. + +### 3.2 Technical Execution: PAM and the Handshake + +The transition from a text-based boot to a graphical environment follows a strict sequence. `fallbackdm` mimics the first half of this cycle: + +#### 1. Simplified Session Registration (The PAM Layer) + +Before a process can "Take Control" of a seat, a session must exist. GDM initiates this via a specialized, minimalist PAM stack (e.g., `gdm-launch-environment.pam`). + +Following the GDM precedent, `fallbackdm` uses a simplified PAM stack because a greeter/placeholder session **has no password and cannot be locked.** This removes the overhead of full `system-auth` account and password modules, focusing strictly on: + +* Setting up the environment (`pam_env.so`). +* Permitting the session entry (`pam_permit.so`). +* Registering the session with `logind` via `pam_systemd.so` as `class=greeter`. + +#### 2. Claiming the Seat (The D-Bus Layer) + +Once registered, the Session Leader (Mutter in a standard setup, `fallbackdm` in ours) must claim the seat. The process calls `TakeControl(force=true)` on its own Session object via D-Bus. `logind` validates that the caller is the registered leader and then performs the privileged "silencing" of the VT. + +### 3.3 Sequence & Implementation Mapping + +```mermaid +sequenceDiagram + participant P as PAM (Simplified Stack) + participant F as fallbackdm (Session Leader) + participant L as systemd-logind + participant K as Kernel (VT Layer) + + Note over P, L: 1. Registration (class=greeter) + P->>L: CreateSession (via pam_systemd) + L-->>P: Session Path / ID + + Note over F, L: 2. The Handshake (Wayland Native Style) + F->>L: D-Bus: TakeControl(force=true) + + Note over L: Validate Caller (session_set_controller) + + Note over L, K: 3. VT Preparation (session_prepare_vt) + L->>K: ioctl(vt, KDSKBMODE, K_OFF) + L->>K: ioctl(vt, KDSETMODE, KD_GRAPHICS) + + Note over K: Keyboard Muted / Console Silenced + + L-->>F: Method Return (Success) + Note over F: Hold Seat (Idle) + +``` + +#### Source References for Verification + +* **GDM (PAM):** `data/gdm-launch-environment.pam` — Demonstrates the minimalist "don't run full account/password stacks" approach for greeters. +* **GDM (PAM):** `src/daemon/gdm-session-worker.c` — Shows how the minimalist pam session is started via `gdm_session_worker_initialize_pam`. +* **systemd-logind:** `src/login/logind-session-dbus.c` — see `method_take_control()`. +* **systemd-logind:** `src/login/logind-session.c` — see `session_prepare_vt()` (where the `K_OFF` and `KD_GRAPHICS` ioctls live). +* **Mutter:** `src/backends/native/meta-launcher.c` — see `meta_launcher_new()` where the D-Bus proxy for the session is created and `TakeControl` is called. +* **GDM:** `daemon/gdm-manager.c` — see `set_up_greeter_session()` which coordinates the PAM transition. + +### 3.4 The Parallel + +* **Standard DM:** `PAM` → `Mutter` → **`TakeControl`** → *Open DRM/Input Devices* +* **fallbackdm:** `PAM` → `fallbackdm` → **`TakeControl`** → *Wait/Idle* + +By stopping after the `TakeControl` handshake, `fallbackdm` provides the exact same system-level protection as a full desktop environment with zero overhead and a vastly smaller attack surface. -## GDM Greeter +## 4. Alignment with Systemd Guidelines -how does the greeter take over the seat when it starts (not how it communicates with systemd-logind to create a user session once the user enters his credentials. -Fast -The greeter takes over the seat through GDM's display management system when it starts. This process involves creating a display for the seat and launching the greeter session on that display. +The design strictly adheres to the *[Writing Display Managers](https://systemd.io/WRITING_DISPLAY_MANAGERS/)* specification provided by the systemd project. -Seat Takeover Process -1. Display Creation for Seat -When a greeter needs to start, ensure_display_for_seat() in daemon/gdm-local-display-factory.c is called to create a display for the specific seat gdm-local-display-factory.c:814-880 . This function: +| Systemd Requirement | `fallbackdm` Implementation | +| --- | --- | +| **"Register via PAM"** | `fallbackdm` uses `pam_systemd.so` with `class=greeter` to register a valid session. | +| **"Take possession"** | We use the `TakeControl` D-Bus method to explicitly claim the seat. | +| **"Passive C API"** | We use `sd-login` (or equivalent D-Bus calls) to identify the seat, avoiding manual parsing of `/var/run` or `/proc`. | +| **"Minimal Porting"** | By offloading VT management to `logind`, we achieve the "Minimal porting" goal described in the docs, removing legacy ConsoleKit/ioctl code. | -Checks if the seat supports graphics via sd_seat_can_graphical() gdm-local-display-factory.c:863-876 -Creates a new GdmDisplay object for the seat -Sets up the display environment -2. Greeter Session Startup -Once the display is created, gdm_display_start_greeter_session() in daemon/gdm-display.c starts the greeter process gdm-display.c:1682-1743 . This function: +## 5. Security & Stability Implications -Gets the seat ID and display name for the greeter gdm-display.c:1698-1708 -Sets up signal handlers for the launch environment lifecycle gdm-display.c:1709-1728 -Starts the greeter via gdm_launch_environment_start() gdm-display.c:1736 -3. Manager Coordination -The GdmManager coordinates this process through set_up_greeter_session() which: gdm-manager.c:1432-1449 +### 5.1 Adherence to Least Privilege -Determines the appropriate user for the greeter display -Creates a user session for the greeter -Calls gdm_display_start_greeter_session() to start the greeter -Seat Activation Mechanism -The greeter gains control of the seat through the display server (X11 or Wayland) that runs as part of the launch environment. When gdm_launch_environment_start() is called, it: +By using `logind` as a proxy for hardware configuration, `fallbackdm` avoids the need for: -Starts the appropriate display server for the seat -Launches the greeter program on that display server -The display server provides the greeter with access to the seat's graphics hardware and input devices -This gives the greeter exclusive control of the seat's display and input devices until the user authenticates and a user session is started. +* `CAP_SYS_TTY_CONFIG`: No need to configure TTYs directly. +* Device Node Access: No need to open `/dev/ttyX` or `/dev/input/eventX`. +* Root Privileges: `fallbackdm` can run as a dedicated unprivileged system user, as `logind` validates the `TakeControl` request based on session ownership, not UID 0. -Notes -The seat takeover is essentially the process of creating a display-server instance for the seat and launching the greeter as a client of that display server. The greeter runs as the unprivileged "gdm" user but gets control of the seat through its connection to the display server that manages the seat's hardware resources. +### 5.2 Graceful Handover +Because `fallbackdm` is a "polite" session controller: + +* When a real DM (like GDM) starts, it triggers a new session or requests the seat. +* `logind` manages the transition, and `fallbackdm` yields or exits based on standard D-Bus signals (`ReleaseSession`). +* This ensures no "input blips" where the keyboard reverts to text mode during the split-second transition between `fallbackdm` and a real compositor. + + +## 6. Summary + +The design of `fallbackdm` is not a workaround; it is a **canonical implementation of a headless systemd session controller**. + +By leveraging the `TakeControl` API, we utilize the exact mechanism built for this purpose, supported by the kernel and `systemd` developers, and battle-tested by GNOME and KDE. This ensures that the "Input Leakage" problem is solved at the infrastructure level, where it belongs. \ No newline at end of file diff --git a/fallbackdm/systemd/pam.d_fallbackdm b/fallbackdm/systemd/pam.d_fallbackdm index 12db36c..6edefd3 100644 --- a/fallbackdm/systemd/pam.d_fallbackdm +++ b/fallbackdm/systemd/pam.d_fallbackdm @@ -1 +1,8 @@ -session required pam_systemd.so class=greeter +# /etc/pam.d/fallbackdm +# https://man.archlinux.org/man/pam_systemd.8.en +# alternative class=lock-screen +# debug=yes +# https://github.com/GNOME/gdm/blob/main/daemon/gdm-local-display-factory.c +auth required pam_permit.so +account required pam_permit.so +session required pam_systemd.so class=greeter type=wayland \ No newline at end of file From 8d1627ee42b01998b72c0dc85fdea4332a648371 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 28 Jan 2026 20:52:33 +0000 Subject: [PATCH 069/108] Move fallbackdm into own repository. --- docs/DESIGN.md | 2 +- docs/USAGE.md | 4 +- fallbackdm/DESIGN.md | 144 --------------------------- fallbackdm/README.md | 147 ---------------------------- fallbackdm/systemd/pam.d_fallbackdm | 8 -- 5 files changed, 3 insertions(+), 302 deletions(-) delete mode 100644 fallbackdm/DESIGN.md delete mode 100644 fallbackdm/README.md delete mode 100644 fallbackdm/systemd/pam.d_fallbackdm diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d528854..16bc56e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -549,7 +549,7 @@ To further mitigate these risks, `vuinputd` could be extended to parse the host' ### **Decision** -`fallbackdm` is implemented as a **logind-managed fallback graphical session**. +`fallbackdm` is implemented as a **logind-managed fallback graphical session**. It is available at https://github.com/joleuger/fallbackdm. It runs only when **no other graphical session is active** on the seat and exists solely to: diff --git a/docs/USAGE.md b/docs/USAGE.md index ab940a0..f9b1c96 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -395,7 +395,7 @@ The following approaches can be used to prevent or mitigate this behavior. ### Solution 1: Use KMSCON (DRM/KMS-based console) -A robust solution is to replace the kernel VT text console with a **DRM/KMS-based console** such as `kmscon`. +A robust solution is to replace the kernel VT text console with a **DRM/KMS-based console** such as `kmscon`. This is very likely the solution that works natively with Fedora 44+ (see [phoronix.com](https://www.phoronix.com/news/Fedora-44-Considers-KMSCON)). #### How it helps @@ -451,7 +451,7 @@ This is done via direct VT ioctls (e.g. `KDSETMODE`), ensuring that: ### Solution 3: fallbackdm (Work in Progress) -`fallbackdm` is an experimental, lightweight **logind-integrated fallback display manager**. +`fallbackdm` is an experimental, lightweight **logind-integrated fallback display manager**. `fallbackdm` is available at https://github.com/joleuger/fallbackdm. #### Intended behavior diff --git a/fallbackdm/DESIGN.md b/fallbackdm/DESIGN.md deleted file mode 100644 index 3c157fb..0000000 --- a/fallbackdm/DESIGN.md +++ /dev/null @@ -1,144 +0,0 @@ -# DESIGN.md -## 1. Design Philosophy: Standardized Infrastructure - -The core design principle of `fallbackdm` is to **utilize established systemd interfaces** to solve the "Empty Seat" problem. - -In a modern Linux ecosystem, hardware resource arbitration has moved away from direct device manipulation by individual applications. `systemd-logind` now acts as the central arbiter for seats, sessions, and terminal states. - -`fallbackdm` is designed to be a "minimalist citizen" of this architecture. Instead of implementing a full graphical stack to "own" the hardware, it leverages the fact that the kernel and `logind` already provide a mechanism to mute VTs and protect input streams. By registering as a formal session controller, `fallbackdm` ensures the system remains in a "graphical-ready" state—silencing the legacy text console—without the overhead of an actual display server. - -## 2. The Mechanism: The `TakeControl` Handshake - -The critical functionality of `fallbackdm`—silencing the kernel terminal to prevent input leakage—is achieved through a single, standardized D-Bus handshake: `TakeControl`. - -This mechanism replaces manual `ioctl` calls. By invoking the `TakeControl` method on the `org.freedesktop.login1.Session` interface, `fallbackdm` triggers a privileged workflow inside `logind` that safely transitions the machine state. - -### 2.1 The Internal Workflow - -When `fallbackdm` calls `TakeControl`, it triggers the following verified code path within `systemd-logind` (as referenced in `logind-session.c`): - -1. **Permission Check:** `logind` verifies the caller owns the session. -2. **Controller Assignment:** `session_set_controller()` marks `fallbackdm` as the active display server. -3. **VT Preparation:** `logind` executes `session_prepare_vt()`, which performs the privileged operations: -* **Mute Input:** Calls `ioctl(vt, KDSKBMODE, K_OFF)`. This effectively disconnects the kernel console from the keyboard, preventing `getty` or the kernel from interpreting keystrokes. -* **Graphics Mode:** Calls `ioctl(vt, KDSETMODE, KD_GRAPHICS)`. This disables the blinking cursor and text rendering. -* **Signal Handling:** Configures the VT to send signals (like `SIGUSR1`) for switching, rather than automatically switching context. - - - -**Result:** `fallbackdm` achieves a "muted" state without ever needing to open a device file or possess `CAP_SYS_TTY_CONFIG` capabilities directly. - -## 3. Industry Precedent: The Standard Stack - -`fallbackdm` does not invent a new protocol; it isolates the infrastructure logic used by modern Linux desktops. To understand why `fallbackdm` works, we must look at how the **Wayland Native** stack (GDM and Mutter) handles seat ownership. - -### 3.1 Foundational Concepts: Seats, Sessions, and Leaders - -On a modern system, hardware access is governed by several distinct layers: - -* **The Seat (e.g., `seat0`):** A collection of hardware (GPU, Keyboard, Mouse). -* **The Session:** An instance of a user (or service) interacting with a seat. -* **The Session Leader:** The primary process responsible for the session. In a graphical world, this is the **Compositor** (Mutter). -* **The Display Manager (GDM):** A supervisor that manages the lifecycle of sessions. - -In the **Wayland Native** workflow, the compositor (Mutter) serves as the "Display Server." It talks directly to the kernel for graphics (DRM/KMS) and input (libinput). However, it does not "steal" these resources; it asks `systemd-logind` for permission. - -### 3.2 Technical Execution: PAM and the Handshake - -The transition from a text-based boot to a graphical environment follows a strict sequence. `fallbackdm` mimics the first half of this cycle: - -#### 1. Simplified Session Registration (The PAM Layer) - -Before a process can "Take Control" of a seat, a session must exist. GDM initiates this via a specialized, minimalist PAM stack (e.g., `gdm-launch-environment.pam`). - -Following the GDM precedent, `fallbackdm` uses a simplified PAM stack because a greeter/placeholder session **has no password and cannot be locked.** This removes the overhead of full `system-auth` account and password modules, focusing strictly on: - -* Setting up the environment (`pam_env.so`). -* Permitting the session entry (`pam_permit.so`). -* Registering the session with `logind` via `pam_systemd.so` as `class=greeter`. - -#### 2. Claiming the Seat (The D-Bus Layer) - -Once registered, the Session Leader (Mutter in a standard setup, `fallbackdm` in ours) must claim the seat. The process calls `TakeControl(force=true)` on its own Session object via D-Bus. `logind` validates that the caller is the registered leader and then performs the privileged "silencing" of the VT. - -### 3.3 Sequence & Implementation Mapping - -```mermaid -sequenceDiagram - participant P as PAM (Simplified Stack) - participant F as fallbackdm (Session Leader) - participant L as systemd-logind - participant K as Kernel (VT Layer) - - Note over P, L: 1. Registration (class=greeter) - P->>L: CreateSession (via pam_systemd) - L-->>P: Session Path / ID - - Note over F, L: 2. The Handshake (Wayland Native Style) - F->>L: D-Bus: TakeControl(force=true) - - Note over L: Validate Caller (session_set_controller) - - Note over L, K: 3. VT Preparation (session_prepare_vt) - L->>K: ioctl(vt, KDSKBMODE, K_OFF) - L->>K: ioctl(vt, KDSETMODE, KD_GRAPHICS) - - Note over K: Keyboard Muted / Console Silenced - - L-->>F: Method Return (Success) - Note over F: Hold Seat (Idle) - -``` - -#### Source References for Verification - -* **GDM (PAM):** `data/gdm-launch-environment.pam` — Demonstrates the minimalist "don't run full account/password stacks" approach for greeters. -* **GDM (PAM):** `src/daemon/gdm-session-worker.c` — Shows how the minimalist pam session is started via `gdm_session_worker_initialize_pam`. -* **systemd-logind:** `src/login/logind-session-dbus.c` — see `method_take_control()`. -* **systemd-logind:** `src/login/logind-session.c` — see `session_prepare_vt()` (where the `K_OFF` and `KD_GRAPHICS` ioctls live). -* **Mutter:** `src/backends/native/meta-launcher.c` — see `meta_launcher_new()` where the D-Bus proxy for the session is created and `TakeControl` is called. -* **GDM:** `daemon/gdm-manager.c` — see `set_up_greeter_session()` which coordinates the PAM transition. - -### 3.4 The Parallel - -* **Standard DM:** `PAM` → `Mutter` → **`TakeControl`** → *Open DRM/Input Devices* -* **fallbackdm:** `PAM` → `fallbackdm` → **`TakeControl`** → *Wait/Idle* - -By stopping after the `TakeControl` handshake, `fallbackdm` provides the exact same system-level protection as a full desktop environment with zero overhead and a vastly smaller attack surface. - - -## 4. Alignment with Systemd Guidelines - -The design strictly adheres to the *[Writing Display Managers](https://systemd.io/WRITING_DISPLAY_MANAGERS/)* specification provided by the systemd project. - -| Systemd Requirement | `fallbackdm` Implementation | -| --- | --- | -| **"Register via PAM"** | `fallbackdm` uses `pam_systemd.so` with `class=greeter` to register a valid session. | -| **"Take possession"** | We use the `TakeControl` D-Bus method to explicitly claim the seat. | -| **"Passive C API"** | We use `sd-login` (or equivalent D-Bus calls) to identify the seat, avoiding manual parsing of `/var/run` or `/proc`. | -| **"Minimal Porting"** | By offloading VT management to `logind`, we achieve the "Minimal porting" goal described in the docs, removing legacy ConsoleKit/ioctl code. | - -## 5. Security & Stability Implications - -### 5.1 Adherence to Least Privilege - -By using `logind` as a proxy for hardware configuration, `fallbackdm` avoids the need for: - -* `CAP_SYS_TTY_CONFIG`: No need to configure TTYs directly. -* Device Node Access: No need to open `/dev/ttyX` or `/dev/input/eventX`. -* Root Privileges: `fallbackdm` can run as a dedicated unprivileged system user, as `logind` validates the `TakeControl` request based on session ownership, not UID 0. - -### 5.2 Graceful Handover - -Because `fallbackdm` is a "polite" session controller: - -* When a real DM (like GDM) starts, it triggers a new session or requests the seat. -* `logind` manages the transition, and `fallbackdm` yields or exits based on standard D-Bus signals (`ReleaseSession`). -* This ensures no "input blips" where the keyboard reverts to text mode during the split-second transition between `fallbackdm` and a real compositor. - - -## 6. Summary - -The design of `fallbackdm` is not a workaround; it is a **canonical implementation of a headless systemd session controller**. - -By leveraging the `TakeControl` API, we utilize the exact mechanism built for this purpose, supported by the kernel and `systemd` developers, and battle-tested by GNOME and KDE. This ensures that the "Input Leakage" problem is solved at the infrastructure level, where it belongs. \ No newline at end of file diff --git a/fallbackdm/README.md b/fallbackdm/README.md deleted file mode 100644 index 1c7afca..0000000 --- a/fallbackdm/README.md +++ /dev/null @@ -1,147 +0,0 @@ -# fallbackdm - -> This crate is WIP and has not released any source, yet. - -**fallbackdm** is a minimal, headless display manager that exists solely to **own a seat and VT when no graphical session is running**. - -It prevents unintended keyboard input from reaching `getty` or the kernel VT layer by registering a proper **greeter session** with `systemd-logind`, activating a VT, and switching it to graphics mode — without starting X11 or Wayland. - -This is primarily useful for **kiosk setups, remote desktop systems, or input-virtualization scenarios** where no local user interaction is intended, but correct VT semantics must still be preserved. - ---- - -## Problem Statement - -On modern Linux systems: - -* Virtual terminals (VTs) still exist and have a kernel keyboard handler -* If **no graphical session is active**, `getty` will attach to a VT -* Input injected via `uinput` or forwarded from remote systems may: - - * Trigger `Ctrl+Alt+Fn` - * Wake or interfere with `getty` - * Cause VT switches or text-mode interaction - -Graphical compositors avoid this by: - -* Registering a session with `systemd-logind` -* Owning a VT -* Switching it to `KD_GRAPHICS` - -But when **no compositor or greeter is running**, nothing owns the VT. - -**fallbackdm fills exactly this gap.** - ---- - -## What fallbackdm Does - -* Registers a **`greeter` session** via PAM + `pam_systemd` -* Acquires a seat using **libseat** -* Activates a VT and switches it to graphics mode -* Keeps the session alive while no real graphical session exists -* Displays nothing and launches no compositor - -Once a real display manager or compositor starts, it naturally replaces `fallbackdm`. - ---- - -## What fallbackdm Does *Not* Do - -* ❌ No X11 -* ❌ No Wayland -* ❌ No greeter UI -* ❌ No input filtering (by design) -* ❌ No Device Ownership Required: Unlike a real compositor, `fallbackdm` does not need to open `/dev/dri/cardX` or `/dev/input/event*` to do its job. It only needs the TTY. This minimizes the attack surface significantly. - -It only ensures **correct session, seat, and VT ownership**. - ---- - -## When You Need This - -You **do not need fallbackdm** if: - -* X11 or Wayland is already running -* A display manager (gdm, sddm, greetd, etc.) is active - -You **do need fallbackdm** if: - -* The system boots without a graphical stack -* Input devices (especially `uinput`) must not reach `getty` -* You rely on logind-correct VT behavior without a real compositor - ---- - -## Architecture Overview - -``` -fallbackdm - ├─ PAM session (class=greeter) - ├─ pam_systemd - ├─ libseat - │ └─ seatd or systemd-logind backend - └─ VT activation + KD_GRAPHICS -``` - -This mirrors what real display managers do — just without launching anything graphical. - ---- - -## PAM Configuration - -Create `/etc/pam.d/fallbackdm`: - -``` -session required pam_systemd.so class=greeter -``` - -This is mandatory. Without it, logind will not track the session. - ---- - -## systemd Service Example - -```ini -[Unit] -Description=Fallback Display Manager -After=systemd-user-sessions.service -ConditionPathExists=!/run/graphical-session-active - -[Service] -ExecStart=/usr/bin/fallbackdm -PAMName=fallbackdm -Restart=always - -[Install] -WantedBy=multi-user.target -``` - -> The condition is optional and can be replaced with more advanced logic later. - ---- - -## Relationship to Other Projects - -* **Display managers (gdm, sddm, greetd)** - Full login stacks with UI and session spawning. - -* **Greeters (gtkgreet, tuigreet)** - UI components launched *by* a display manager. - -* **fallbackdm** - A *headless*, compatibility-focused DM whose only job is to own the seat. - ---- - -## Future Ideas - -* Optional status output on the VT -* Signaling input-forwarding daemons (e.g. `vuinputd`) -* Conditional exit when a real session becomes active - ---- - -## License - -MIT \ No newline at end of file diff --git a/fallbackdm/systemd/pam.d_fallbackdm b/fallbackdm/systemd/pam.d_fallbackdm deleted file mode 100644 index 6edefd3..0000000 --- a/fallbackdm/systemd/pam.d_fallbackdm +++ /dev/null @@ -1,8 +0,0 @@ -# /etc/pam.d/fallbackdm -# https://man.archlinux.org/man/pam_systemd.8.en -# alternative class=lock-screen -# debug=yes -# https://github.com/GNOME/gdm/blob/main/daemon/gdm-local-display-factory.c -auth required pam_permit.so -account required pam_permit.so -session required pam_systemd.so class=greeter type=wayland \ No newline at end of file From 8ada4a8624430562aa95ea72bc2eafec5fbe27e2 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 3 Feb 2026 20:39:11 +0000 Subject: [PATCH 070/108] Prepare cloud-init iso file to be able to initialize VMs with various distributions for automated tests --- .../cloud-init/create-cloud-init-iso.sh | 60 +++++++++++++++++++ .../cloud-init/template/meta-data.tmpl | 2 + .../cloud-init/template/user-data.tmpl | 21 +++++++ 3 files changed, 83 insertions(+) create mode 100644 distro-tests/cloud-init/create-cloud-init-iso.sh create mode 100644 distro-tests/cloud-init/template/meta-data.tmpl create mode 100644 distro-tests/cloud-init/template/user-data.tmpl diff --git a/distro-tests/cloud-init/create-cloud-init-iso.sh b/distro-tests/cloud-init/create-cloud-init-iso.sh new file mode 100644 index 0000000..8696a70 --- /dev/null +++ b/distro-tests/cloud-init/create-cloud-init-iso.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# SPDX-License-Identifier: MIT +set -eu + +# How to do it is documented on https://cloudinit.readthedocs.io/en/latest/howto/launch_qemu.html + + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PREPARED_DIR="${ROOT_DIR}/prepared" +IDENTITY_DIR="${PREPARED_DIR}/identity" +TMP_DIR="${ROOT_DIR}/tmp" + +KEY_PRIV="${IDENTITY_DIR}/ssh_ed25519" +KEY_PUB="${IDENTITY_DIR}/ssh_ed25519.pub" + +USER_DATA_SRC="${ROOT_DIR}/cloud-init/template/user-data.tmpl" +META_DATA_SRC="${ROOT_DIR}/cloud-init/template/meta-data.tmpl" + +mkdir -p ${TMP_DIR} + +# -------------------------------------------------------------------- +# SSH identity (per user, stable) +# -------------------------------------------------------------------- +if [ ! -f "${KEY_PRIV}" ]; then + echo "[*] Generating SSH keypair" + ssh-keygen -t ed25519 -N "" -f "${KEY_PRIV}" -C "distro-tests" +else + echo "[*] Reusing existing SSH key" +fi + +SSH_KEY="$(cat "${KEY_PUB}")" + +# -------------------------------------------------------------------- +# Build user-data with injected SSH key +# -------------------------------------------------------------------- +USER_DATA_TMP="${TMP_DIR}/user-data" +META_DATA_TMP="${TMP_DIR}/meta-data" + +awk -v key="${SSH_KEY}" ' + /^users:/ { print; users=1; next } + users && /ssh_authorized_keys:/ { + print + print " - " key + next + } + { print } +' "${USER_DATA_SRC}" > "${USER_DATA_TMP}" + +cp "${META_DATA_SRC}" "${META_DATA_TMP}" + +# -------------------------------------------------------------------- +# Create seed ISO +# -------------------------------------------------------------------- +echo "[*] Creating cloud-init seed.iso" +cloud-localds \ + "${PREPARED_DIR}/seed.iso" \ + "${USER_DATA_TMP}" \ + "${META_DATA_TMP}" + +echo "[✓] cloud-init ISO ready: ${PREPARED_DIR}/seed.iso" diff --git a/distro-tests/cloud-init/template/meta-data.tmpl b/distro-tests/cloud-init/template/meta-data.tmpl new file mode 100644 index 0000000..0769594 --- /dev/null +++ b/distro-tests/cloud-init/template/meta-data.tmpl @@ -0,0 +1,2 @@ +instance-id: vuinputd-distro-tests +local-hostname: vuinputd-distro-tests diff --git a/distro-tests/cloud-init/template/user-data.tmpl b/distro-tests/cloud-init/template/user-data.tmpl new file mode 100644 index 0000000..ed7c399 --- /dev/null +++ b/distro-tests/cloud-init/template/user-data.tmpl @@ -0,0 +1,21 @@ +#cloud-config +# +# Shared cloud-init configuration for distro-tests +# SSH keys are injected dynamically by create-cloud-init-iso.sh +# + +users: + - name: testuser + groups: users, adm + shell: /bin/bash + sudo: ALL=(ALL) NOPASSWD:ALL + ssh_authorized_keys: + +disable_root: true +ssh_pwauth: false + +package_update: false +package_upgrade: false + +runcmd: + - echo "cloud-init bootstrap complete" > /var/tmp/cloud-init-ok From 4c4fefb0c6590ec1f273fa80e82d090539033523 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 3 Feb 2026 20:52:22 +0000 Subject: [PATCH 071/108] distro tests: download ubuntu image --- distro-tests/ubuntu-24.04/download.sh | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 distro-tests/ubuntu-24.04/download.sh diff --git a/distro-tests/ubuntu-24.04/download.sh b/distro-tests/ubuntu-24.04/download.sh new file mode 100644 index 0000000..2295e26 --- /dev/null +++ b/distro-tests/ubuntu-24.04/download.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# SPDX-License-Identifier: MIT +set -eu + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PREPARED_IMG_DIR="${ROOT_DIR}/prepared" + +IMG_NAME="ubuntu-24.04-noble-base.qcow2" +IMG_PATH="${PREPARED_IMG_DIR}/${IMG_NAME}" + +SRC_URL="https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img" + +mkdir -p "${PREPARED_IMG_DIR}" + +if [ -f "${IMG_PATH}" ]; then + echo "[*] Ubuntu 24.04 base image already present" + exit 0 +fi + +echo "[*] Downloading Ubuntu 24.04 cloud image" +curl -L "${SRC_URL}" -o "${IMG_PATH}.tmp" + +echo "[*] Converting to qcow2" +qemu-img convert -c -O qcow2 "${IMG_PATH}.tmp" "${IMG_PATH}" + +rm -f "${IMG_PATH}.tmp" + +echo "[✓] Ubuntu 24.04 base image ready" From 134b41fa98b4832217969d482dc93734e77a2299 Mon Sep 17 00:00:00 2001 From: Lunyaaa~ Date: Mon, 23 Mar 2026 15:05:41 +0100 Subject: [PATCH 072/108] fix build failure with nixos libfuse3 headers it pulls in reference to libfuse_version which is currently blacklisted --- cuse-lowlevel/build.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cuse-lowlevel/build.rs b/cuse-lowlevel/build.rs index 9813c1b..7c3b359 100644 --- a/cuse-lowlevel/build.rs +++ b/cuse-lowlevel/build.rs @@ -23,7 +23,8 @@ fn fuse_binding_filter(builder: bindgen::Builder) -> bindgen::Builder { .allowlist_function("(?i)^fuse.*") .allowlist_var("(?i)^fuse.*") .blocklist_type("fuse_log_func_t") - .blocklist_function("fuse_set_log_func"); + .blocklist_function("fuse_set_log_func") + .allowlist_type("^libfuse_version$"); builder } From 9070c4e8360b7b3c3e17f12cfd685874b97311dc Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 26 Mar 2026 21:19:40 +0000 Subject: [PATCH 073/108] First draft of the test tool test-scenarios that should make it easier to test new scenarios in case we have an issue. --- vuinputd-tests/Cargo.toml | 4 + vuinputd-tests/src/bin/test-scenarios.rs | 77 ++++ vuinputd-tests/src/devices/keyboard.rs | 342 ++++++++++++++++++ vuinputd-tests/src/devices/mod.rs | 38 ++ vuinputd-tests/src/devices/mouse.rs | 114 ++++++ vuinputd-tests/src/devices/utils.rs | 143 ++++++++ vuinputd-tests/src/ipc.rs | 2 + vuinputd-tests/src/lib.rs | 2 + .../src/scenarios/basic_keyboard.rs | 40 ++ vuinputd-tests/src/scenarios/basic_mouse.rs | 40 ++ .../src/scenarios/basic_ps4_gamepad.rs | 40 ++ .../src/scenarios/basic_xbox_gamepad.rs | 41 +++ vuinputd-tests/src/scenarios/mod.rs | 33 ++ 13 files changed, 916 insertions(+) create mode 100644 vuinputd-tests/src/bin/test-scenarios.rs create mode 100644 vuinputd-tests/src/devices/keyboard.rs create mode 100644 vuinputd-tests/src/devices/mod.rs create mode 100644 vuinputd-tests/src/devices/mouse.rs create mode 100644 vuinputd-tests/src/devices/utils.rs create mode 100644 vuinputd-tests/src/scenarios/basic_keyboard.rs create mode 100644 vuinputd-tests/src/scenarios/basic_mouse.rs create mode 100644 vuinputd-tests/src/scenarios/basic_ps4_gamepad.rs create mode 100644 vuinputd-tests/src/scenarios/basic_xbox_gamepad.rs create mode 100644 vuinputd-tests/src/scenarios/mod.rs diff --git a/vuinputd-tests/Cargo.toml b/vuinputd-tests/Cargo.toml index 5352278..d53c2cb 100644 --- a/vuinputd-tests/Cargo.toml +++ b/vuinputd-tests/Cargo.toml @@ -12,6 +12,9 @@ name = "test-keyboard" [[bin]] name = "test-ok" +[[bin]] +name = "test-scenarios" + [dependencies] uinput-ioctls = { path = "../uinput-ioctls" } nix = { version = "0.30", features = ["ioctl","socket","signal"] } # ioctl & libc bindings @@ -24,6 +27,7 @@ serde_json = "1.0" [features] requires-privileges = [] +requires-rootless = [] requires-uinput = [] requires-bwrap = [] requires-podman = [] \ No newline at end of file diff --git a/vuinputd-tests/src/bin/test-scenarios.rs b/vuinputd-tests/src/bin/test-scenarios.rs new file mode 100644 index 0000000..e9e41a7 --- /dev/null +++ b/vuinputd-tests/src/bin/test-scenarios.rs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use clap::{Parser, Subcommand}; +use vuinputd_tests::scenarios::{ + ScenarioArgs, basic_keyboard::BasicKeyboard, basic_mouse::BasicMouse, basic_ps4_gamepad::BasicPs4Gamepad, basic_xbox_gamepad::BasicXboxGamepad/* + reuse_keyboard::ReuseKeyboard, reuse_xbox_gamepad::ReuseXboxGamepad, + ScenarioArgs, stress_keyboard::StressKeyboard, stress_xbox_gamepad::StressXboxGamepad, */ +}; + +#[derive(Parser)] +#[command(name = "test-scenarios")] +#[command(about = "Test scenarios for vuinputd", long_about = None)] +struct Cli { + /// Run scenarios in IPC mode (communicate with vuinputd daemon) + #[arg(short, long, default_value_t = false)] + ipc: bool, + + /// Path to uinput device + #[arg(short, long, default_value = "/dev/uinput")] + dev_path: String, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Basic keyboard test + BasicKeyboard, + + /// Basic mouse test + BasicMouse, + + /// Basic PS4 gamepad test + BasicPs4Gamepad, + + /// Basic Xbox gamepad test + BasicXboxGamepad, + + /* + /// Reuse keyboard test (create, destroy, recreate) + ReuseKeyboard, + + /// Reuse Xbox gamepad test (create, destroy, recreate) + ReuseXboxGamepad, + + /// Stress test for keyboard (3000 events) + StressKeyboard, + + /// Stress test for Xbox gamepad (3000 events) + StressXboxGamepad, + */ +} + +fn main() -> Result<(), std::io::Error> { + let cli = Cli::parse(); + + let args = ScenarioArgs { + ipc: cli.ipc, + dev_path: Some(cli.dev_path), + }; + + match cli.command { + Commands::BasicKeyboard => BasicKeyboard::run(&args), + Commands::BasicMouse => BasicMouse::run(&args), + Commands::BasicPs4Gamepad => BasicPs4Gamepad::run(&args), + Commands::BasicXboxGamepad => BasicXboxGamepad::run(&args), + /* + Commands::ReuseKeyboard => ReuseKeyboard::run(&args), + Commands::ReuseXboxGamepad => ReuseXboxGamepad::run(&args), + Commands::StressKeyboard => StressKeyboard::run(&args), + Commands::StressXboxGamepad => StressXboxGamepad::run(&args), + */ + } +} diff --git a/vuinputd-tests/src/devices/keyboard.rs b/vuinputd-tests/src/devices/keyboard.rs new file mode 100644 index 0000000..bb384da --- /dev/null +++ b/vuinputd-tests/src/devices/keyboard.rs @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use super::Device; +use std::{ffi::CStr, fs::File}; +use std::io; +use uinput_ioctls::*; + +/// Key codes. Those are used by udev to recognize a device as a keyboard. +pub const KEY_ESC: u16 = 1; +pub const KEY_1: u16 = 2; +pub const KEY_2: u16 = 3; +pub const KEY_3: u16 = 4; +pub const KEY_4: u16 = 5; +pub const KEY_5: u16 = 6; +pub const KEY_6: u16 = 7; +pub const KEY_7: u16 = 8; +pub const KEY_8: u16 = 9; +pub const KEY_9: u16 = 10; +pub const KEY_0: u16 = 11; +pub const KEY_MINUS: u16 = 12; +pub const KEY_EQUAL: u16 = 13; +pub const KEY_BACKSPACE: u16 = 14; +pub const KEY_TAB: u16 = 15; +pub const KEY_Q: u16 = 16; +pub const KEY_W: u16 = 17; +pub const KEY_E: u16 = 18; +pub const KEY_R: u16 = 19; +pub const KEY_T: u16 = 20; +pub const KEY_Y: u16 = 21; +pub const KEY_U: u16 = 22; +pub const KEY_I: u16 = 23; +pub const KEY_O: u16 = 24; +pub const KEY_P: u16 = 25; +pub const KEY_LEFTBRACE: u16 = 26; +pub const KEY_RIGHTBRACE: u16 = 27; +pub const KEY_ENTER: u16 = 28; +pub const KEY_LEFTCTRL: u16 = 29; +pub const KEY_A: u16 = 30; +pub const KEY_S: u16 = 31; + +/// Space and other common keys +pub const KEY_D: u16 = 32; +pub const KEY_F: u16 = 33; +pub const KEY_G: u16 = 34; +pub const KEY_H: u16 = 35; +pub const KEY_J: u16 = 36; +pub const KEY_K: u16 = 37; +pub const KEY_L: u16 = 38; +pub const KEY_SEMICOLON: u16 = 39; +pub const KEY_APOSTROPHE: u16 = 40; +pub const KEY_GRAVE: u16 = 41; +pub const KEY_LEFTSHIFT: u16 = 42; +pub const KEY_BACKSLASH: u16 = 43; +pub const KEY_Z: u16 = 44; +pub const KEY_X: u16 = 45; +pub const KEY_C: u16 = 46; +pub const KEY_V: u16 = 47; +pub const KEY_B: u16 = 48; +pub const KEY_N: u16 = 49; +pub const KEY_M: u16 = 50; +pub const KEY_COMMA: u16 = 51; +pub const KEY_DOT: u16 = 52; +pub const KEY_SLASH: u16 = 53; +pub const KEY_RIGHTSHIFT: u16 = 54; +pub const KEY_KPASTERISK: u16 = 55; +pub const KEY_LEFTALT: u16 = 56; +pub const KEY_SPACE: u16 = 57; +pub const KEY_CAPSLOCK: u16 = 58; + +/// Function keys +pub const KEY_F1: u16 = 59; +pub const KEY_F2: u16 = 60; +pub const KEY_F3: u16 = 61; +pub const KEY_F4: u16 = 62; +pub const KEY_F5: u16 = 63; +pub const KEY_F6: u16 = 64; +pub const KEY_F7: u16 = 65; +pub const KEY_F8: u16 = 66; +pub const KEY_F9: u16 = 67; +pub const KEY_F10: u16 = 68; +pub const KEY_NUMLOCK: u16 = 69; +pub const KEY_SCROLLLOCK: u16 = 70; +pub const KEY_KP7: u16 = 71; +pub const KEY_KP8: u16 = 72; +pub const KEY_KP9: u16 = 73; +pub const KEY_KPMINUS: u16 = 74; +pub const KEY_KP4: u16 = 75; +pub const KEY_KP5: u16 = 76; +pub const KEY_KP6: u16 = 77; +pub const KEY_KPPLUS: u16 = 78; +pub const KEY_KP1: u16 = 79; +pub const KEY_KP2: u16 = 80; +pub const KEY_KP3: u16 = 81; +pub const KEY_KP0: u16 = 82; +pub const KEY_KPDOT: u16 = 83; + +/// Arrow keys and navigation +pub const KEY_ZENKAKUHANKAKU: u16 = 85; +pub const KEY_102ND: u16 = 86; +pub const KEY_F11: u16 = 87; +pub const KEY_F12: u16 = 88; +pub const KEY_RO: u16 = 89; +pub const KEY_KATAKANA: u16 = 90; +pub const KEY_HIRAGANA: u16 = 91; +pub const KEY_HENKAN: u16 = 92; +pub const KEY_KATAKANAHIRAGANA: u16 = 93; +pub const KEY_MUHENKAN: u16 = 94; +pub const KEY_KPJPCOMMA: u16 = 95; +pub const KEY_KPENTER: u16 = 96; +pub const KEY_RIGHTCTRL: u16 = 97; +pub const KEY_KPSLASH: u16 = 98; +pub const KEY_SYSRQ: u16 = 99; +pub const KEY_RIGHTALT: u16 = 100; +pub const KEY_LINEFEED: u16 = 101; +pub const KEY_HOME: u16 = 102; +pub const KEY_UP: u16 = 103; +pub const KEY_PAGEUP: u16 = 104; +pub const KEY_LEFT: u16 = 105; +pub const KEY_RIGHT: u16 = 106; +pub const KEY_END: u16 = 107; +pub const KEY_DOWN: u16 = 108; +pub const KEY_PAGEDOWN: u16 = 109; +pub const KEY_INSERT: u16 = 110; +pub const KEY_DELETE: u16 = 111; + + +/// Configure a full 101-key standard keyboard +unsafe fn set_standard_keyboard_keys(fd: i32) -> Result<(), std::io::Error> { + // We need to set more bits so that systemd recognizes a keyboard as a keyboard. + // At least the first 32 bits are ESC, numbers, and Q to D, except KEY_RESERVED need to be considered. + // udev-builtin-input_id.c consideres the mask = 0xFFFFFFFE + + // EV_KEY + ui_set_evbit(fd, super::EV_KEY.try_into().unwrap())?; + + // All standard keys (1..101+) + let all_keys = [ + // Modifier + main keys + KEY_ESC, + KEY_1, + KEY_2, + KEY_3, + KEY_4, + KEY_5, + KEY_6, + KEY_7, + KEY_8, + KEY_9, + KEY_0, + KEY_MINUS, + KEY_EQUAL, + KEY_BACKSPACE, + KEY_TAB, + KEY_Q, + KEY_W, + KEY_E, + KEY_R, + KEY_T, + KEY_Y, + KEY_U, + KEY_I, + KEY_O, + KEY_P, + KEY_LEFTBRACE, + KEY_RIGHTBRACE, + KEY_ENTER, + KEY_LEFTCTRL, + KEY_A, + KEY_S, + KEY_D, + KEY_F, + KEY_G, + KEY_H, + KEY_J, + KEY_K, + KEY_L, + KEY_SEMICOLON, + KEY_APOSTROPHE, + KEY_GRAVE, + KEY_LEFTSHIFT, + KEY_BACKSLASH, + KEY_Z, + KEY_X, + KEY_C, + KEY_V, + KEY_B, + KEY_N, + KEY_M, + KEY_COMMA, + KEY_DOT, + KEY_SLASH, + KEY_RIGHTSHIFT, + KEY_KPASTERISK, + KEY_LEFTALT, + KEY_SPACE, + KEY_CAPSLOCK, + // Function keys + KEY_F1, + KEY_F2, + KEY_F3, + KEY_F4, + KEY_F5, + KEY_F6, + KEY_F7, + KEY_F8, + KEY_F9, + KEY_F10, + KEY_F11, + KEY_F12, + KEY_NUMLOCK, + KEY_SCROLLLOCK, + // Keypad + KEY_KP7, + KEY_KP8, + KEY_KP9, + KEY_KPMINUS, + KEY_KP4, + KEY_KP5, + KEY_KP6, + KEY_KPPLUS, + KEY_KP1, + KEY_KP2, + KEY_KP3, + KEY_KP0, + KEY_KPDOT, + KEY_KPENTER, + KEY_KPSLASH, + KEY_KPJPCOMMA, + // Arrows / navigation + KEY_HOME, + KEY_UP, + KEY_PAGEUP, + KEY_LEFT, + KEY_RIGHT, + KEY_END, + KEY_DOWN, + KEY_PAGEDOWN, + KEY_INSERT, + KEY_DELETE, + KEY_RIGHTCTRL, + KEY_RIGHTALT, + // Optional Japanese / additional keys + KEY_ZENKAKUHANKAKU, + KEY_102ND, + KEY_RO, + KEY_KATAKANA, + KEY_HIRAGANA, + KEY_HENKAN, + KEY_KATAKANAHIRAGANA, + KEY_MUHENKAN, + KEY_LINEFEED, + KEY_SYSRQ, + ]; + + for &key in all_keys.iter() { + ui_set_keybit(fd, key.try_into().unwrap())?; + } + + Ok(()) +} + +pub struct KeyboardDevice; + +impl Device for KeyboardDevice { + fn name() -> &'static str { + "Keyboard" + } + + fn get_event_device(sysname: &str) -> Result { + super::utils::fetch_device_node(sysname) + .and_then(|devnode| File::open(&devnode)) + } + + fn setup(device:Option<&str>,name: &str) -> Result { + let fd = super::utils::open_uinput(device)?; + unsafe { set_standard_keyboard_keys(fd) }?; + + unsafe { + let mut usetup: libc::uinput_setup = std::mem::zeroed(); + usetup.id.bustype = BUS_USB; + usetup.id.vendor = 0xbeef; + usetup.id.product = 0xdead; + + let name_cstr = CString::new(name).unwrap(); + let name_ptr = usetup.name.as_mut_ptr() as *mut c_char; + std::ptr::copy_nonoverlapping( + name_cstr.as_ptr(), + name_ptr, + name_cstr.to_bytes_with_nul().len(), + ); + + let usetup_ptr = &mut usetup as *mut libc::uinput_setup; + ui_dev_setup(fd, usetup_ptr).map_err(|e| { + eprintln!("ui_dev_setup failed: {:?}", e); + e + })?; + } + + Ok(fd) + } + + fn create(fd: i32) -> Result { + unsafe { + ui_dev_create(fd).map_err(|e| { + eprintln!("ui_dev_create failed: {:?}", e); + e + })?; + + let mut resultbuf: [c_char; 64] = [0; 64]; + ui_get_sysname(fd, resultbuf.as_mut_slice()).map_err(|e| { + eprintln!("ui_get_sysname failed: {:?}", e); + e + })?; + + let sysname = format!( + "{}{}", + SYS_INPUT_DIR, + CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() + ); + + Ok(sysname) + } + } + + fn destroy(fd: i32) { + unsafe { + ui_dev_destroy(fd).unwrap_or_else(|e| { + eprintln!("ui_dev_destroy failed: {:?}", e); + std::process::exit(1); + }); + close(fd); + } + } +} + +use libc::{c_char, close}; +use std::ffi::CString; + +const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; +const BUS_USB: u16 = 0x03; diff --git a/vuinputd-tests/src/devices/mod.rs b/vuinputd-tests/src/devices/mod.rs new file mode 100644 index 0000000..43ffd5c --- /dev/null +++ b/vuinputd-tests/src/devices/mod.rs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::fs::File; +use std::io; + +pub mod keyboard; +pub mod mouse; +pub mod ps4_gamepad; +pub mod xbox_gamepad; +pub mod utils; + +pub use keyboard::KeyboardDevice; +pub use mouse::MouseDevice; +pub use ps4_gamepad::Ps4GamepadDevice; +pub use xbox_gamepad::XboxGamepadDevice; + +// Constants (same numeric values as in linux headers) +pub const EV_SYN: u16 = 0x00; +pub const EV_KEY: u16 = 0x01; +pub const EV_REL: u16 = 0x02; +pub const EV_ABS: u16 = 0x03; + +/// Trait for input devices +pub trait Device { + fn name() -> &'static str; + fn get_event_device(sysname: &str) -> Result; + + /// Phase 1: open uinput, configure keys, call ui_dev_setup + fn setup(device:Option<&str>,name: &str) -> Result; + + /// Phase 2: call ui_dev_create, get sysname and devnode + fn create(fd: i32) -> Result; + + /// Phase 3: call ui_dev_destroy and close fd + fn destroy(fd: i32); +} \ No newline at end of file diff --git a/vuinputd-tests/src/devices/mouse.rs b/vuinputd-tests/src/devices/mouse.rs new file mode 100644 index 0000000..5861bd3 --- /dev/null +++ b/vuinputd-tests/src/devices/mouse.rs @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use super::{ Device}; +use std::{ffi::CStr, fs::File}; +use std::io; +use libc::c_int; +use uinput_ioctls::*; + +// Mouse codes +pub const BTN_LEFT: u16 = 272; +pub const BTN_RIGHT: u16 = 273; +pub const BTN_MIDDLE: u16 = 274; +pub const REL_X: u16 = 0; +pub const REL_Y: u16 = 1; + +/// Setup mouse device +unsafe fn setup_mouse(fd: c_int) -> io::Result<()> { + // EV_SYN + ui_set_evbit(fd, super::EV_SYN.try_into().unwrap())?; + // EV_KEY + ui_set_evbit(fd, super::EV_KEY.try_into().unwrap())?; + ui_set_keybit(fd, BTN_LEFT.try_into().unwrap())?; + ui_set_keybit(fd, BTN_RIGHT.try_into().unwrap())?; + ui_set_keybit(fd, BTN_MIDDLE.try_into().unwrap())?; + // EV_REL + ui_set_evbit(fd, super::EV_REL.try_into().unwrap())?; + ui_set_relbit(fd, REL_X.try_into().unwrap())?; + ui_set_relbit(fd, REL_Y.try_into().unwrap())?; + + Ok(()) +} + + +pub struct MouseDevice; + +impl Device for MouseDevice { + fn name() -> &'static str { + "Mouse" + } + + fn get_event_device(sysname: &str) -> Result { + super::utils::fetch_device_node(sysname) + .and_then(|devnode| File::open(&devnode)) + } + + fn setup(device: Option<&str>, name: &str) -> Result { + let fd = super::utils::open_uinput(device)?; + unsafe { setup_mouse(fd) }?; + + unsafe { + let mut usetup: libc::uinput_setup = std::mem::zeroed(); + usetup.id.bustype = BUS_USB; + usetup.id.vendor = 0xbeef; + usetup.id.product = 0xdead; + + let name_cstr = CString::new(name).unwrap(); + let name_ptr = usetup.name.as_mut_ptr() as *mut c_char; + std::ptr::copy_nonoverlapping( + name_cstr.as_ptr(), + name_ptr, + name_cstr.to_bytes_with_nul().len(), + ); + + let usetup_ptr = &mut usetup as *mut libc::uinput_setup; + ui_dev_setup(fd, usetup_ptr).map_err(|e| { + eprintln!("ui_dev_setup failed: {:?}", e); + e + })?; + } + + Ok(fd) + } + + fn create(fd: i32) -> Result { + unsafe { + ui_dev_create(fd).map_err(|e| { + eprintln!("ui_dev_create failed: {:?}", e); + e + })?; + + let mut resultbuf: [c_char; 64] = [0; 64]; + ui_get_sysname(fd, resultbuf.as_mut_slice()).map_err(|e| { + eprintln!("ui_get_sysname failed: {:?}", e); + e + })?; + + let sysname = format!( + "{}{}", + SYS_INPUT_DIR, + CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() + ); + + Ok(sysname) + } + } + + fn destroy(fd: i32) { + unsafe { + ui_dev_destroy(fd).unwrap_or_else(|e| { + eprintln!("ui_dev_destroy failed: {:?}", e); + std::process::exit(1); + }); + close(fd); + } + } +} + +use libc::{c_char, close}; +use std::ffi::CString; + +const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; +const BUS_USB: u16 = 0x03; diff --git a/vuinputd-tests/src/devices/utils.rs b/vuinputd-tests/src/devices/utils.rs new file mode 100644 index 0000000..33234d8 --- /dev/null +++ b/vuinputd-tests/src/devices/utils.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use libc::{c_int, close, open, write, O_NONBLOCK, O_WRONLY}; +use libc::{input_event, timespec, uinput_setup, CLOCK_MONOTONIC}; +use std::ffi::{CStr, CString}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, ErrorKind}; +use std::mem::{self, size_of, zeroed}; +use std::os::fd::AsRawFd; +use std::os::raw::{c_char, c_void}; +use std::ptr; +pub use uinput_ioctls::*; +use crate::test_log::{LoggedInputEvent, TestLog}; + +// Constants (same numeric values as in linux headers) +const EV_SYN: u16 = 0x00; +const EV_KEY: u16 = 0x01; +const SYN_REPORT: u16 = 0; +const BUS_USB: u16 = 0x03; + + +pub fn emit(fd: c_int, ev_type: u16, code: u16, val: i32) -> io::Result<()> { + // libc's input_event struct layout: + // struct input_event { + // struct timeval time; + // __u16 type; + // __u16 code; + // __s32 value; + // }; + // + // libc provides input_event as `libc::input_event` on Linux. + let mut ie: libc::input_event = unsafe { zeroed() }; + + // time fields are ignored by kernel for synthetic events - set zero + ie.time.tv_sec = 0; + ie.time.tv_usec = 0; + + ie.type_ = ev_type; // note: in libc the field is `type_` + ie.code = code; + ie.value = val; + + // write the struct to the uinput fd + let buf_ptr = &ie as *const libc::input_event as *const c_void; + let bytes = size_of::(); + + //println!("write to {} {} {} {} ",fd,ev_type,code,val); + let written = unsafe { write(fd, buf_ptr, bytes) }; + //println!("written"); + if written as usize != bytes { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +// Note that before we can read, a SYN needs to be sent. Thus combine it. +pub fn emit_read_and_log( + emit_to: c_int, + read_from: &File, + ev_type: u16, + code: u16, + val: i32, +) -> io::Result { + let (time_sent_sec, time_sent_nsec) = monotonic_time(); + emit(emit_to, ev_type, code, val)?; + emit(emit_to, EV_SYN, SYN_REPORT, 0)?; + let input_event_recv = read_event(&read_from).unwrap(); + let _syn_recv = read_event(&read_from).unwrap(); + let (time_recv_sec, time_recv_nsec) = monotonic_time(); + let duration_usec = + (time_recv_sec - time_sent_sec) * 1_000_000 + (time_recv_nsec - time_sent_nsec) / 1000; + let send_and_receive_match = input_event_recv.type_ == ev_type + && input_event_recv.code == code + && input_event_recv.value == val; + + Ok(LoggedInputEvent { + tv_sec: time_sent_sec, + tv_nsec: time_sent_nsec, + duration_usec: duration_usec, + type_: ev_type, + code: code, + value: val, + send_and_receive_match: send_and_receive_match, + }) +} + +pub fn fetch_device_node(path: &str) -> io::Result { + println!("Read dir {}", &path); + for entry in fs::read_dir(path)? { + let entry = entry?; // propagate per-entry errors + if let Some(name) = entry.file_name().to_str() { + if name.starts_with("event") { + return Ok(format!("/dev/input/{}", name)); + } + } + } + // If no device is found, return an error + Err(io::Error::new(ErrorKind::NotFound, "no device found")) +} + +pub fn read_event(event_dev: &File) -> io::Result { + let mut ev: input_event = unsafe { mem::zeroed() }; + let ret = unsafe { + libc::read( + event_dev.as_raw_fd(), + &mut ev as *mut _ as *mut c_void, + mem::size_of::(), + ) + }; + if ret as usize != mem::size_of::() { + return Err(io::Error::last_os_error()); + } + Ok(ev) +} + +pub fn monotonic_time() -> (i64, i64) { + let mut ts = timespec { + tv_sec: 0, + tv_nsec: 0, + }; + + unsafe { + libc::clock_gettime(CLOCK_MONOTONIC, &mut ts); + } + (ts.tv_sec, ts.tv_nsec) +} + + +pub fn open_uinput(device:Option<&str>) -> io::Result { + let device = match device { + Some(dev_path) => dev_path, + _ => "/dev/uinput", + }; + + let path = CString::new(device).unwrap(); + let fd = unsafe { open(path.as_ptr(), O_WRONLY | O_NONBLOCK) }; + if fd < 0 { + eprintln!("error opening uinput"); + return Err(io::Error::last_os_error()); + } + Ok(fd) +} \ No newline at end of file diff --git a/vuinputd-tests/src/ipc.rs b/vuinputd-tests/src/ipc.rs index d7c7208..165fced 100644 --- a/vuinputd-tests/src/ipc.rs +++ b/vuinputd-tests/src/ipc.rs @@ -2,6 +2,8 @@ // // Author: Johannes Leupolz +// TODO: Use https://varlink.org/ which also supports bridges over ssh, which is nice + use std::{ io, os::{ diff --git a/vuinputd-tests/src/lib.rs b/vuinputd-tests/src/lib.rs index 65112a5..c3afdc5 100644 --- a/vuinputd-tests/src/lib.rs +++ b/vuinputd-tests/src/lib.rs @@ -2,6 +2,8 @@ // // Author: Johannes Leupolz +pub mod devices; +pub mod scenarios; pub mod bwrap; pub mod ipc; pub mod podman; diff --git a/vuinputd-tests/src/scenarios/basic_keyboard.rs b/vuinputd-tests/src/scenarios/basic_keyboard.rs new file mode 100644 index 0000000..149873f --- /dev/null +++ b/vuinputd-tests/src/scenarios/basic_keyboard.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::thread; +use std::time::Duration; + +use crate::devices::keyboard::KeyboardDevice; +use crate::scenarios::ScenarioArgs; +use crate::devices::{Device, utils}; +use crate::test_log::{TestLog}; + +const KEY_SPACE: u16 = 57; + +pub struct BasicKeyboard; + +impl BasicKeyboard { + pub fn run(args: &ScenarioArgs) -> Result<(), std::io::Error> { + let device = args.dev_path.clone().unwrap_or_else(|| "/dev/uinput".to_string()); + let fd = KeyboardDevice::setup(Some(&device),"Example Keyboard")?; + let sysname = KeyboardDevice::create(fd)?; + eprintln!("sysname: {}", sysname); + + thread::sleep(Duration::from_secs(1)); + + let event_device = std::fs::OpenOptions::new() + .read(true) + .open(&utils::fetch_device_node(&sysname)?)?; + + let ev1 = utils::emit_read_and_log(fd, &event_device, 0x01, KEY_SPACE, 1)?; + let ev2 = utils::emit_read_and_log(fd, &event_device, 0x01, KEY_SPACE, 0)?; + + let eventlog = TestLog { events: vec![ev1, ev2] }; + let serialized = serde_json::to_string(&eventlog).unwrap(); + println!("Event log: {}", serialized); + + KeyboardDevice::destroy(fd); + Ok(()) + } +} \ No newline at end of file diff --git a/vuinputd-tests/src/scenarios/basic_mouse.rs b/vuinputd-tests/src/scenarios/basic_mouse.rs new file mode 100644 index 0000000..3a46d40 --- /dev/null +++ b/vuinputd-tests/src/scenarios/basic_mouse.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz +use std::thread; +use std::time::Duration; + +use crate::devices::mouse::MouseDevice; +use crate::scenarios::ScenarioArgs; +use crate::devices::{Device, utils}; +use crate::test_log::{LoggedInputEvent, TestLog}; + +const BTN_LEFT: u16 = 272; + +pub struct BasicMouse; + +impl BasicMouse { + pub fn run(args: &ScenarioArgs) -> Result<(), std::io::Error> { + let device = args.dev_path.clone().unwrap_or_else(|| "/dev/uinput".to_string()); + + let fd = MouseDevice::setup(Some(&device), "Example Mouse")?; + let sysname = MouseDevice::create(fd)?; + eprintln!("sysname: {}", sysname); + + thread::sleep(Duration::from_secs(1)); + + let event_device = std::fs::OpenOptions::new() + .read(true) + .open(&utils::fetch_device_node(&sysname)?)?; + + let ev1 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_LEFT, 1)?; + let ev2 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_LEFT, 0)?; + + let eventlog = TestLog { events: vec![ev1, ev2] }; + let serialized = serde_json::to_string(&eventlog).unwrap(); + println!("Event log: {}", serialized); + + MouseDevice::destroy(fd); + Ok(()) + } +} diff --git a/vuinputd-tests/src/scenarios/basic_ps4_gamepad.rs b/vuinputd-tests/src/scenarios/basic_ps4_gamepad.rs new file mode 100644 index 0000000..8952dd3 --- /dev/null +++ b/vuinputd-tests/src/scenarios/basic_ps4_gamepad.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz +use std::thread; +use std::time::Duration; + +use crate::devices::ps4_gamepad::Ps4GamepadDevice; +use crate::scenarios::ScenarioArgs; +use crate::devices::{Device, utils}; +use crate::test_log::{LoggedInputEvent, TestLog}; + +const BTN_SOUTH: u16 = 304; + +pub struct BasicPs4Gamepad; + +impl BasicPs4Gamepad { + pub fn run(args: &ScenarioArgs) -> Result<(), std::io::Error> { + let device = args.dev_path.clone().unwrap_or_else(|| "/dev/uinput".to_string()); + + let fd = Ps4GamepadDevice::setup(Some(&device), "PS4 Gamepad")?; + let sysname = Ps4GamepadDevice::create(fd)?; + eprintln!("sysname: {}", sysname); + + thread::sleep(Duration::from_secs(1)); + + let event_device = std::fs::OpenOptions::new() + .read(true) + .open(&utils::fetch_device_node(&sysname)?)?; + + let ev1 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_SOUTH, 1)?; + let ev2 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_SOUTH, 0)?; + + let eventlog = TestLog { events: vec![ev1, ev2] }; + let serialized = serde_json::to_string(&eventlog).unwrap(); + println!("Event log: {}", serialized); + + Ps4GamepadDevice::destroy(fd); + Ok(()) + } +} \ No newline at end of file diff --git a/vuinputd-tests/src/scenarios/basic_xbox_gamepad.rs b/vuinputd-tests/src/scenarios/basic_xbox_gamepad.rs new file mode 100644 index 0000000..76dfb6e --- /dev/null +++ b/vuinputd-tests/src/scenarios/basic_xbox_gamepad.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::thread; +use std::time::Duration; + +use crate::devices::xbox_gamepad::XboxGamepadDevice; +use crate::scenarios::ScenarioArgs; +use crate::devices::{Device, utils}; +use crate::test_log::{LoggedInputEvent, TestLog}; + +const BTN_A: u16 = 304; + +pub struct BasicXboxGamepad; + +impl BasicXboxGamepad { + pub fn run(args: &ScenarioArgs) -> Result<(), std::io::Error> { + let device = args.dev_path.clone().unwrap_or_else(|| "/dev/uinput".to_string()); + + let fd = XboxGamepadDevice::setup(Some(&device), "Xbox Gamepad")?; + let sysname = XboxGamepadDevice::create(fd)?; + eprintln!("sysname: {}", sysname); + + thread::sleep(Duration::from_secs(1)); + + let event_device = std::fs::OpenOptions::new() + .read(true) + .open(&utils::fetch_device_node(&sysname)?)?; + + let ev1 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_A, 1)?; + let ev2 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_A, 0)?; + + let eventlog = TestLog { events: vec![ev1, ev2] }; + let serialized = serde_json::to_string(&eventlog).unwrap(); + println!("Event log: {}", serialized); + + XboxGamepadDevice::destroy(fd); + Ok(()) + } +} diff --git a/vuinputd-tests/src/scenarios/mod.rs b/vuinputd-tests/src/scenarios/mod.rs new file mode 100644 index 0000000..1e82bd8 --- /dev/null +++ b/vuinputd-tests/src/scenarios/mod.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +pub mod basic_keyboard; +pub mod basic_mouse; +pub mod basic_ps4_gamepad; +pub mod basic_xbox_gamepad; +/* +pub mod reuse_keyboard; +pub mod reuse_xbox_gamepad; +pub mod stress_keyboard; +pub mod stress_xbox_gamepad; +*/ + +// Re-exports for type checking +pub use basic_keyboard::BasicKeyboard; +pub use basic_mouse::BasicMouse; +pub use basic_ps4_gamepad::BasicPs4Gamepad; +pub use basic_xbox_gamepad::BasicXboxGamepad; +/* +pub use reuse_keyboard::ReuseKeyboard; +pub use reuse_xbox_gamepad::ReuseXboxGamepad; +pub use stress_keyboard::StressKeyboard; +pub use stress_xbox_gamepad::StressXboxGamepad; + */ + +/// Common scenario arguments passed from CLI +#[derive(Debug, Clone)] +pub struct ScenarioArgs { + pub ipc: bool, + pub dev_path: Option, +} From 643c506a23893e210fe7a97bc16e9f414bfad8b0 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 26 Mar 2026 21:25:00 +0000 Subject: [PATCH 074/108] cargo fmt --- vuinputd-tests/src/bin/test-scenarios.rs | 9 +- vuinputd-tests/src/devices/keyboard.rs | 8 +- vuinputd-tests/src/devices/mod.rs | 12 +- vuinputd-tests/src/devices/mouse.rs | 10 +- vuinputd-tests/src/devices/ps4_gamepad.rs | 176 ++++++++++++++++++ vuinputd-tests/src/devices/utils.rs | 8 +- vuinputd-tests/src/devices/xbox_gamepad.rs | 170 +++++++++++++++++ .../src/scenarios/basic_keyboard.rs | 17 +- vuinputd-tests/src/scenarios/basic_mouse.rs | 13 +- .../src/scenarios/basic_ps4_gamepad.rs | 15 +- .../src/scenarios/basic_xbox_gamepad.rs | 13 +- vuinputd-tests/tests/integration_tests.rs | 4 +- vuinputd-tests/tests/podman_tests.rs | 36 +++- 13 files changed, 443 insertions(+), 48 deletions(-) create mode 100644 vuinputd-tests/src/devices/ps4_gamepad.rs create mode 100644 vuinputd-tests/src/devices/xbox_gamepad.rs diff --git a/vuinputd-tests/src/bin/test-scenarios.rs b/vuinputd-tests/src/bin/test-scenarios.rs index e9e41a7..6b1b145 100644 --- a/vuinputd-tests/src/bin/test-scenarios.rs +++ b/vuinputd-tests/src/bin/test-scenarios.rs @@ -4,9 +4,11 @@ use clap::{Parser, Subcommand}; use vuinputd_tests::scenarios::{ - ScenarioArgs, basic_keyboard::BasicKeyboard, basic_mouse::BasicMouse, basic_ps4_gamepad::BasicPs4Gamepad, basic_xbox_gamepad::BasicXboxGamepad/* - reuse_keyboard::ReuseKeyboard, reuse_xbox_gamepad::ReuseXboxGamepad, - ScenarioArgs, stress_keyboard::StressKeyboard, stress_xbox_gamepad::StressXboxGamepad, */ + basic_keyboard::BasicKeyboard, basic_mouse::BasicMouse, basic_ps4_gamepad::BasicPs4Gamepad, + basic_xbox_gamepad::BasicXboxGamepad, /* + reuse_keyboard::ReuseKeyboard, reuse_xbox_gamepad::ReuseXboxGamepad, + ScenarioArgs, stress_keyboard::StressKeyboard, stress_xbox_gamepad::StressXboxGamepad, */ + ScenarioArgs, }; #[derive(Parser)] @@ -38,7 +40,6 @@ enum Commands { /// Basic Xbox gamepad test BasicXboxGamepad, - /* /// Reuse keyboard test (create, destroy, recreate) ReuseKeyboard, diff --git a/vuinputd-tests/src/devices/keyboard.rs b/vuinputd-tests/src/devices/keyboard.rs index bb384da..2dd1376 100644 --- a/vuinputd-tests/src/devices/keyboard.rs +++ b/vuinputd-tests/src/devices/keyboard.rs @@ -3,8 +3,8 @@ // Author: Johannes Leupolz use super::Device; -use std::{ffi::CStr, fs::File}; use std::io; +use std::{ffi::CStr, fs::File}; use uinput_ioctls::*; /// Key codes. Those are used by udev to recognize a device as a keyboard. @@ -125,7 +125,6 @@ pub const KEY_PAGEDOWN: u16 = 109; pub const KEY_INSERT: u16 = 110; pub const KEY_DELETE: u16 = 111; - /// Configure a full 101-key standard keyboard unsafe fn set_standard_keyboard_keys(fd: i32) -> Result<(), std::io::Error> { // We need to set more bits so that systemd recognizes a keyboard as a keyboard. @@ -269,11 +268,10 @@ impl Device for KeyboardDevice { } fn get_event_device(sysname: &str) -> Result { - super::utils::fetch_device_node(sysname) - .and_then(|devnode| File::open(&devnode)) + super::utils::fetch_device_node(sysname).and_then(|devnode| File::open(&devnode)) } - fn setup(device:Option<&str>,name: &str) -> Result { + fn setup(device: Option<&str>, name: &str) -> Result { let fd = super::utils::open_uinput(device)?; unsafe { set_standard_keyboard_keys(fd) }?; diff --git a/vuinputd-tests/src/devices/mod.rs b/vuinputd-tests/src/devices/mod.rs index 43ffd5c..9d77113 100644 --- a/vuinputd-tests/src/devices/mod.rs +++ b/vuinputd-tests/src/devices/mod.rs @@ -8,8 +8,8 @@ use std::io; pub mod keyboard; pub mod mouse; pub mod ps4_gamepad; -pub mod xbox_gamepad; pub mod utils; +pub mod xbox_gamepad; pub use keyboard::KeyboardDevice; pub use mouse::MouseDevice; @@ -26,13 +26,13 @@ pub const EV_ABS: u16 = 0x03; pub trait Device { fn name() -> &'static str; fn get_event_device(sysname: &str) -> Result; - + /// Phase 1: open uinput, configure keys, call ui_dev_setup - fn setup(device:Option<&str>,name: &str) -> Result; - + fn setup(device: Option<&str>, name: &str) -> Result; + /// Phase 2: call ui_dev_create, get sysname and devnode fn create(fd: i32) -> Result; - + /// Phase 3: call ui_dev_destroy and close fd fn destroy(fd: i32); -} \ No newline at end of file +} diff --git a/vuinputd-tests/src/devices/mouse.rs b/vuinputd-tests/src/devices/mouse.rs index 5861bd3..352e792 100644 --- a/vuinputd-tests/src/devices/mouse.rs +++ b/vuinputd-tests/src/devices/mouse.rs @@ -2,10 +2,10 @@ // // Author: Johannes Leupolz -use super::{ Device}; -use std::{ffi::CStr, fs::File}; -use std::io; +use super::Device; use libc::c_int; +use std::io; +use std::{ffi::CStr, fs::File}; use uinput_ioctls::*; // Mouse codes @@ -32,7 +32,6 @@ unsafe fn setup_mouse(fd: c_int) -> io::Result<()> { Ok(()) } - pub struct MouseDevice; impl Device for MouseDevice { @@ -41,8 +40,7 @@ impl Device for MouseDevice { } fn get_event_device(sysname: &str) -> Result { - super::utils::fetch_device_node(sysname) - .and_then(|devnode| File::open(&devnode)) + super::utils::fetch_device_node(sysname).and_then(|devnode| File::open(&devnode)) } fn setup(device: Option<&str>, name: &str) -> Result { diff --git a/vuinputd-tests/src/devices/ps4_gamepad.rs b/vuinputd-tests/src/devices/ps4_gamepad.rs new file mode 100644 index 0000000..b08106e --- /dev/null +++ b/vuinputd-tests/src/devices/ps4_gamepad.rs @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use super::Device; +use libc::c_int; +use std::io; +use std::{ffi::CStr, fs::File}; +use uinput_ioctls::*; + +// PS4 Gamepad codes +const BTN_SOUTH: u16 = 304; // Cross +const BTN_EAST: u16 = 305; // Circle +const BTN_NORTH: u16 = 306; // Square +const BTN_WEST: u16 = 307; // Triangle +const BTN_TOP: u16 = 310; // L1 +const BTN_TOP2: u16 = 311; // R1 +const BTN_BASE: u16 = 312; // Share +const BTN_BASE2: u16 = 313; // Options +const BTN_BASE3: u16 = 314; // L3 +const BTN_BASE23: u16 = 315; // R3 +const BTN_TL: u16 = 316; // L2 +const BTN_TR: u16 = 317; // R2 +const BTN_SELECT: u16 = 318; +const BTN_START: u16 = 319; +const BTN_THUMBL: u16 = 320; +const BTN_THUMBR: u16 = 321; +const BTN_TOUCH: u16 = 322; +const BTN_TR2: u16 = 323; +const BTN_DPAD_UP: u16 = 325; +const BTN_DPAD_DOWN: u16 = 326; +const BTN_DPAD_LEFT: u16 = 327; +const BTN_DPAD_RIGHT: u16 = 328; + +const ABS_X: u16 = 0; +const ABS_Y: u16 = 1; +const ABS_Z: u16 = 2; +const ABS_RX: u16 = 3; +const ABS_RY: u16 = 4; +const ABS_RZ: u16 = 5; +const ABS_THROTTLE: u16 = 6; +const ABS_RUDDER: u16 = 7; +const ABS_PRESSURE: u16 = 24; +const ABS_DISTANCE: u16 = 32; +const ABS_MT_POSITION_X: u16 = 47; +const ABS_MT_POSITION_Y: u16 = 48; +const ABS_MT_TRACKING_ID: u16 = 57; +const ABS_MT_PRESSURE: u16 = 47; +const ABS_MT_TOOL_TYPE: u16 = 55; +const ABS_MT_WIDTH: u16 = 56; + +// Xbox Gamepad codes +const BTN_A: u16 = 304; // A +const BTN_B: u16 = 305; // B +const BTN_X: u16 = 306; // X +const BTN_Y: u16 = 307; // Y +const BTN_TL2: u16 = 319; // LT + +pub struct Ps4GamepadDevice; + +/// Setup PS4 gamepad device +unsafe fn setup_ps4_gamepad(fd: c_int) -> io::Result<()> { + // EV_SYN + ui_set_evbit(fd, super::EV_SYN.try_into().unwrap())?; + // EV_KEY + ui_set_evbit(fd, super::EV_KEY.try_into().unwrap())?; + ui_set_keybit(fd, BTN_SOUTH.try_into().unwrap())?; + ui_set_keybit(fd, BTN_EAST.try_into().unwrap())?; + ui_set_keybit(fd, BTN_NORTH.try_into().unwrap())?; + ui_set_keybit(fd, BTN_WEST.try_into().unwrap())?; + ui_set_keybit(fd, BTN_TOP.try_into().unwrap())?; + ui_set_keybit(fd, BTN_TOP2.try_into().unwrap())?; + ui_set_keybit(fd, BTN_BASE.try_into().unwrap())?; + ui_set_keybit(fd, BTN_BASE2.try_into().unwrap())?; + ui_set_keybit(fd, BTN_BASE3.try_into().unwrap())?; + ui_set_keybit(fd, BTN_BASE23.try_into().unwrap())?; + ui_set_keybit(fd, BTN_TL.try_into().unwrap())?; + ui_set_keybit(fd, BTN_TR.try_into().unwrap())?; + ui_set_keybit(fd, BTN_SELECT.try_into().unwrap())?; + ui_set_keybit(fd, BTN_START.try_into().unwrap())?; + ui_set_keybit(fd, BTN_THUMBL.try_into().unwrap())?; + ui_set_keybit(fd, BTN_THUMBR.try_into().unwrap())?; + ui_set_keybit(fd, BTN_TOUCH.try_into().unwrap())?; + ui_set_keybit(fd, BTN_TR2.try_into().unwrap())?; + ui_set_keybit(fd, BTN_DPAD_UP.try_into().unwrap())?; + ui_set_keybit(fd, BTN_DPAD_DOWN.try_into().unwrap())?; + ui_set_keybit(fd, BTN_DPAD_LEFT.try_into().unwrap())?; + ui_set_keybit(fd, BTN_DPAD_RIGHT.try_into().unwrap())?; + // EV_ABS + ui_set_evbit(fd, super::EV_ABS.try_into().unwrap())?; + ui_set_absbit(fd, ABS_X.try_into().unwrap())?; + ui_set_absbit(fd, ABS_Y.try_into().unwrap())?; + ui_set_absbit(fd, ABS_RX.try_into().unwrap())?; + ui_set_absbit(fd, ABS_RY.try_into().unwrap())?; + ui_set_absbit(fd, ABS_PRESSURE.try_into().unwrap())?; + ui_set_absbit(fd, ABS_DISTANCE.try_into().unwrap())?; + + Ok(()) +} + +impl Device for Ps4GamepadDevice { + fn name() -> &'static str { + "PS4 Gamepad" + } + + fn get_event_device(sysname: &str) -> Result { + super::utils::fetch_device_node(sysname).and_then(|devnode| File::open(&devnode)) + } + + fn setup(device: Option<&str>, name: &str) -> Result { + let fd = super::utils::open_uinput(device)?; + unsafe { setup_ps4_gamepad(fd) }?; + + unsafe { + let mut usetup: libc::uinput_setup = std::mem::zeroed(); + usetup.id.bustype = BUS_USB; + usetup.id.vendor = 0xbeef; + usetup.id.product = 0xdead; + + let name_cstr = CString::new(name).unwrap(); + let name_ptr = usetup.name.as_mut_ptr() as *mut c_char; + std::ptr::copy_nonoverlapping( + name_cstr.as_ptr(), + name_ptr, + name_cstr.to_bytes_with_nul().len(), + ); + + let usetup_ptr = &mut usetup as *mut libc::uinput_setup; + ui_dev_setup(fd, usetup_ptr).map_err(|e| { + eprintln!("ui_dev_setup failed: {:?}", e); + e + })?; + } + + Ok(fd) + } + + fn create(fd: i32) -> Result { + unsafe { + ui_dev_create(fd).map_err(|e| { + eprintln!("ui_dev_create failed: {:?}", e); + e + })?; + + let mut resultbuf: [c_char; 64] = [0; 64]; + ui_get_sysname(fd, resultbuf.as_mut_slice()).map_err(|e| { + eprintln!("ui_get_sysname failed: {:?}", e); + e + })?; + + let sysname = format!( + "{}{}", + SYS_INPUT_DIR, + CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() + ); + + Ok(sysname) + } + } + + fn destroy(fd: i32) { + unsafe { + ui_dev_destroy(fd).unwrap_or_else(|e| { + eprintln!("ui_dev_destroy failed: {:?}", e); + std::process::exit(1); + }); + close(fd); + } + } +} + +use libc::{c_char, close}; +use std::ffi::CString; + +const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; +const BUS_USB: u16 = 0x03; diff --git a/vuinputd-tests/src/devices/utils.rs b/vuinputd-tests/src/devices/utils.rs index 33234d8..ff4fdec 100644 --- a/vuinputd-tests/src/devices/utils.rs +++ b/vuinputd-tests/src/devices/utils.rs @@ -2,6 +2,7 @@ // // Author: Johannes Leupolz +use crate::test_log::{LoggedInputEvent, TestLog}; use libc::{c_int, close, open, write, O_NONBLOCK, O_WRONLY}; use libc::{input_event, timespec, uinput_setup, CLOCK_MONOTONIC}; use std::ffi::{CStr, CString}; @@ -12,7 +13,6 @@ use std::os::fd::AsRawFd; use std::os::raw::{c_char, c_void}; use std::ptr; pub use uinput_ioctls::*; -use crate::test_log::{LoggedInputEvent, TestLog}; // Constants (same numeric values as in linux headers) const EV_SYN: u16 = 0x00; @@ -20,7 +20,6 @@ const EV_KEY: u16 = 0x01; const SYN_REPORT: u16 = 0; const BUS_USB: u16 = 0x03; - pub fn emit(fd: c_int, ev_type: u16, code: u16, val: i32) -> io::Result<()> { // libc's input_event struct layout: // struct input_event { @@ -126,8 +125,7 @@ pub fn monotonic_time() -> (i64, i64) { (ts.tv_sec, ts.tv_nsec) } - -pub fn open_uinput(device:Option<&str>) -> io::Result { +pub fn open_uinput(device: Option<&str>) -> io::Result { let device = match device { Some(dev_path) => dev_path, _ => "/dev/uinput", @@ -140,4 +138,4 @@ pub fn open_uinput(device:Option<&str>) -> io::Result { return Err(io::Error::last_os_error()); } Ok(fd) -} \ No newline at end of file +} diff --git a/vuinputd-tests/src/devices/xbox_gamepad.rs b/vuinputd-tests/src/devices/xbox_gamepad.rs new file mode 100644 index 0000000..bca3931 --- /dev/null +++ b/vuinputd-tests/src/devices/xbox_gamepad.rs @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use super::Device; +use libc::c_int; +use std::io; +use std::{ffi::CStr, fs::File}; +use uinput_ioctls::*; + +// PS4 Gamepad codes +const BTN_SOUTH: u16 = 304; // Cross +const BTN_EAST: u16 = 305; // Circle +const BTN_NORTH: u16 = 306; // Square +const BTN_WEST: u16 = 307; // Triangle +const BTN_TOP: u16 = 310; // L1 +const BTN_TOP2: u16 = 311; // R1 +const BTN_BASE: u16 = 312; // Share +const BTN_BASE2: u16 = 313; // Options +const BTN_BASE3: u16 = 314; // L3 +const BTN_BASE23: u16 = 315; // R3 +const BTN_TL: u16 = 316; // L2 +const BTN_TR: u16 = 317; // R2 +const BTN_SELECT: u16 = 318; +const BTN_START: u16 = 319; +const BTN_THUMBL: u16 = 320; +const BTN_THUMBR: u16 = 321; +const BTN_TOUCH: u16 = 322; +const BTN_TR2: u16 = 323; +const BTN_DPAD_UP: u16 = 325; +const BTN_DPAD_DOWN: u16 = 326; +const BTN_DPAD_LEFT: u16 = 327; +const BTN_DPAD_RIGHT: u16 = 328; + +const ABS_X: u16 = 0; +const ABS_Y: u16 = 1; +const ABS_Z: u16 = 2; +const ABS_RX: u16 = 3; +const ABS_RY: u16 = 4; +const ABS_RZ: u16 = 5; +const ABS_THROTTLE: u16 = 6; +const ABS_RUDDER: u16 = 7; +const ABS_PRESSURE: u16 = 24; +const ABS_DISTANCE: u16 = 32; +const ABS_MT_POSITION_X: u16 = 47; +const ABS_MT_POSITION_Y: u16 = 48; +const ABS_MT_TRACKING_ID: u16 = 57; +const ABS_MT_PRESSURE: u16 = 47; +const ABS_MT_TOOL_TYPE: u16 = 55; +const ABS_MT_WIDTH: u16 = 56; + +// Xbox Gamepad codes +const BTN_A: u16 = 304; // A +const BTN_B: u16 = 305; // B +const BTN_X: u16 = 306; // X +const BTN_Y: u16 = 307; // Y +const BTN_TL2: u16 = 319; // LT + +/// Setup Xbox gamepad device +unsafe fn setup_xbox_gamepad(fd: c_int) -> io::Result<()> { + // EV_SYN + ui_set_evbit(fd, super::EV_SYN.try_into().unwrap())?; + // EV_KEY + ui_set_evbit(fd, super::EV_KEY.try_into().unwrap())?; + ui_set_keybit(fd, BTN_A.try_into().unwrap())?; + ui_set_keybit(fd, BTN_B.try_into().unwrap())?; + ui_set_keybit(fd, BTN_X.try_into().unwrap())?; + ui_set_keybit(fd, BTN_Y.try_into().unwrap())?; + ui_set_keybit(fd, BTN_TL.try_into().unwrap())?; + ui_set_keybit(fd, BTN_TR.try_into().unwrap())?; + ui_set_keybit(fd, BTN_SELECT.try_into().unwrap())?; + ui_set_keybit(fd, BTN_START.try_into().unwrap())?; + ui_set_keybit(fd, BTN_THUMBL.try_into().unwrap())?; + ui_set_keybit(fd, BTN_THUMBR.try_into().unwrap())?; + ui_set_keybit(fd, BTN_TL2.try_into().unwrap())?; + ui_set_keybit(fd, BTN_TR2.try_into().unwrap())?; + ui_set_keybit(fd, BTN_DPAD_UP.try_into().unwrap())?; + ui_set_keybit(fd, BTN_DPAD_DOWN.try_into().unwrap())?; + ui_set_keybit(fd, BTN_DPAD_LEFT.try_into().unwrap())?; + ui_set_keybit(fd, BTN_DPAD_RIGHT.try_into().unwrap())?; + // EV_ABS + ui_set_evbit(fd, super::EV_ABS.try_into().unwrap())?; + ui_set_absbit(fd, ABS_X.try_into().unwrap())?; + ui_set_absbit(fd, ABS_Y.try_into().unwrap())?; + ui_set_absbit(fd, ABS_RX.try_into().unwrap())?; + ui_set_absbit(fd, ABS_RY.try_into().unwrap())?; + ui_set_absbit(fd, ABS_PRESSURE.try_into().unwrap())?; + ui_set_absbit(fd, ABS_DISTANCE.try_into().unwrap())?; + + Ok(()) +} + +pub struct XboxGamepadDevice; + +impl Device for XboxGamepadDevice { + fn name() -> &'static str { + "Xbox Gamepad" + } + + fn get_event_device(sysname: &str) -> Result { + super::utils::fetch_device_node(sysname).and_then(|devnode| File::open(&devnode)) + } + + fn setup(device: Option<&str>, name: &str) -> Result { + let fd = super::utils::open_uinput(device)?; + unsafe { setup_xbox_gamepad(fd) }?; + + unsafe { + let mut usetup: libc::uinput_setup = std::mem::zeroed(); + usetup.id.bustype = BUS_USB; + usetup.id.vendor = 0xbeef; + usetup.id.product = 0xdead; + + let name_cstr = CString::new(name).unwrap(); + let name_ptr = usetup.name.as_mut_ptr() as *mut c_char; + std::ptr::copy_nonoverlapping( + name_cstr.as_ptr(), + name_ptr, + name_cstr.to_bytes_with_nul().len(), + ); + + let usetup_ptr = &mut usetup as *mut libc::uinput_setup; + ui_dev_setup(fd, usetup_ptr).map_err(|e| { + eprintln!("ui_dev_setup failed: {:?}", e); + e + })?; + } + + Ok(fd) + } + + fn create(fd: i32) -> Result { + unsafe { + ui_dev_create(fd).map_err(|e| { + eprintln!("ui_dev_create failed: {:?}", e); + e + })?; + + let mut resultbuf: [c_char; 64] = [0; 64]; + ui_get_sysname(fd, resultbuf.as_mut_slice()).map_err(|e| { + eprintln!("ui_get_sysname failed: {:?}", e); + e + })?; + + let sysname = format!( + "{}{}", + SYS_INPUT_DIR, + CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() + ); + + Ok(sysname) + } + } + + fn destroy(fd: i32) { + unsafe { + ui_dev_destroy(fd).unwrap_or_else(|e| { + eprintln!("ui_dev_destroy failed: {:?}", e); + std::process::exit(1); + }); + close(fd); + } + } +} + +use libc::{c_char, close}; +use std::ffi::CString; + +const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; +const BUS_USB: u16 = 0x03; diff --git a/vuinputd-tests/src/scenarios/basic_keyboard.rs b/vuinputd-tests/src/scenarios/basic_keyboard.rs index 149873f..2abfd85 100644 --- a/vuinputd-tests/src/scenarios/basic_keyboard.rs +++ b/vuinputd-tests/src/scenarios/basic_keyboard.rs @@ -6,9 +6,9 @@ use std::thread; use std::time::Duration; use crate::devices::keyboard::KeyboardDevice; +use crate::devices::{utils, Device}; use crate::scenarios::ScenarioArgs; -use crate::devices::{Device, utils}; -use crate::test_log::{TestLog}; +use crate::test_log::TestLog; const KEY_SPACE: u16 = 57; @@ -16,8 +16,11 @@ pub struct BasicKeyboard; impl BasicKeyboard { pub fn run(args: &ScenarioArgs) -> Result<(), std::io::Error> { - let device = args.dev_path.clone().unwrap_or_else(|| "/dev/uinput".to_string()); - let fd = KeyboardDevice::setup(Some(&device),"Example Keyboard")?; + let device = args + .dev_path + .clone() + .unwrap_or_else(|| "/dev/uinput".to_string()); + let fd = KeyboardDevice::setup(Some(&device), "Example Keyboard")?; let sysname = KeyboardDevice::create(fd)?; eprintln!("sysname: {}", sysname); @@ -30,11 +33,13 @@ impl BasicKeyboard { let ev1 = utils::emit_read_and_log(fd, &event_device, 0x01, KEY_SPACE, 1)?; let ev2 = utils::emit_read_and_log(fd, &event_device, 0x01, KEY_SPACE, 0)?; - let eventlog = TestLog { events: vec![ev1, ev2] }; + let eventlog = TestLog { + events: vec![ev1, ev2], + }; let serialized = serde_json::to_string(&eventlog).unwrap(); println!("Event log: {}", serialized); KeyboardDevice::destroy(fd); Ok(()) } -} \ No newline at end of file +} diff --git a/vuinputd-tests/src/scenarios/basic_mouse.rs b/vuinputd-tests/src/scenarios/basic_mouse.rs index 3a46d40..be23d64 100644 --- a/vuinputd-tests/src/scenarios/basic_mouse.rs +++ b/vuinputd-tests/src/scenarios/basic_mouse.rs @@ -5,8 +5,8 @@ use std::thread; use std::time::Duration; use crate::devices::mouse::MouseDevice; +use crate::devices::{utils, Device}; use crate::scenarios::ScenarioArgs; -use crate::devices::{Device, utils}; use crate::test_log::{LoggedInputEvent, TestLog}; const BTN_LEFT: u16 = 272; @@ -15,8 +15,11 @@ pub struct BasicMouse; impl BasicMouse { pub fn run(args: &ScenarioArgs) -> Result<(), std::io::Error> { - let device = args.dev_path.clone().unwrap_or_else(|| "/dev/uinput".to_string()); - + let device = args + .dev_path + .clone() + .unwrap_or_else(|| "/dev/uinput".to_string()); + let fd = MouseDevice::setup(Some(&device), "Example Mouse")?; let sysname = MouseDevice::create(fd)?; eprintln!("sysname: {}", sysname); @@ -30,7 +33,9 @@ impl BasicMouse { let ev1 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_LEFT, 1)?; let ev2 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_LEFT, 0)?; - let eventlog = TestLog { events: vec![ev1, ev2] }; + let eventlog = TestLog { + events: vec![ev1, ev2], + }; let serialized = serde_json::to_string(&eventlog).unwrap(); println!("Event log: {}", serialized); diff --git a/vuinputd-tests/src/scenarios/basic_ps4_gamepad.rs b/vuinputd-tests/src/scenarios/basic_ps4_gamepad.rs index 8952dd3..3224b76 100644 --- a/vuinputd-tests/src/scenarios/basic_ps4_gamepad.rs +++ b/vuinputd-tests/src/scenarios/basic_ps4_gamepad.rs @@ -5,8 +5,8 @@ use std::thread; use std::time::Duration; use crate::devices::ps4_gamepad::Ps4GamepadDevice; +use crate::devices::{utils, Device}; use crate::scenarios::ScenarioArgs; -use crate::devices::{Device, utils}; use crate::test_log::{LoggedInputEvent, TestLog}; const BTN_SOUTH: u16 = 304; @@ -15,8 +15,11 @@ pub struct BasicPs4Gamepad; impl BasicPs4Gamepad { pub fn run(args: &ScenarioArgs) -> Result<(), std::io::Error> { - let device = args.dev_path.clone().unwrap_or_else(|| "/dev/uinput".to_string()); - + let device = args + .dev_path + .clone() + .unwrap_or_else(|| "/dev/uinput".to_string()); + let fd = Ps4GamepadDevice::setup(Some(&device), "PS4 Gamepad")?; let sysname = Ps4GamepadDevice::create(fd)?; eprintln!("sysname: {}", sysname); @@ -30,11 +33,13 @@ impl BasicPs4Gamepad { let ev1 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_SOUTH, 1)?; let ev2 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_SOUTH, 0)?; - let eventlog = TestLog { events: vec![ev1, ev2] }; + let eventlog = TestLog { + events: vec![ev1, ev2], + }; let serialized = serde_json::to_string(&eventlog).unwrap(); println!("Event log: {}", serialized); Ps4GamepadDevice::destroy(fd); Ok(()) } -} \ No newline at end of file +} diff --git a/vuinputd-tests/src/scenarios/basic_xbox_gamepad.rs b/vuinputd-tests/src/scenarios/basic_xbox_gamepad.rs index 76dfb6e..d7fbb74 100644 --- a/vuinputd-tests/src/scenarios/basic_xbox_gamepad.rs +++ b/vuinputd-tests/src/scenarios/basic_xbox_gamepad.rs @@ -6,8 +6,8 @@ use std::thread; use std::time::Duration; use crate::devices::xbox_gamepad::XboxGamepadDevice; +use crate::devices::{utils, Device}; use crate::scenarios::ScenarioArgs; -use crate::devices::{Device, utils}; use crate::test_log::{LoggedInputEvent, TestLog}; const BTN_A: u16 = 304; @@ -16,8 +16,11 @@ pub struct BasicXboxGamepad; impl BasicXboxGamepad { pub fn run(args: &ScenarioArgs) -> Result<(), std::io::Error> { - let device = args.dev_path.clone().unwrap_or_else(|| "/dev/uinput".to_string()); - + let device = args + .dev_path + .clone() + .unwrap_or_else(|| "/dev/uinput".to_string()); + let fd = XboxGamepadDevice::setup(Some(&device), "Xbox Gamepad")?; let sysname = XboxGamepadDevice::create(fd)?; eprintln!("sysname: {}", sysname); @@ -31,7 +34,9 @@ impl BasicXboxGamepad { let ev1 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_A, 1)?; let ev2 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_A, 0)?; - let eventlog = TestLog { events: vec![ev1, ev2] }; + let eventlog = TestLog { + events: vec![ev1, ev2], + }; let serialized = serde_json::to_string(&eventlog).unwrap(); println!("Event log: {}", serialized); diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index 4e96b58..c88bc04 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -128,7 +128,7 @@ fn test_keyboard_in_container_with_uinput() { ))] #[test] fn test_keyboard_in_container_with_vuinput_placement_in_container() { - let _guard: run_vuinputd::VuinputdGuard=run_vuinputd::ensure_vuinputd_running(&[]); + let _guard: run_vuinputd::VuinputdGuard = run_vuinputd::ensure_vuinputd_running(&[]); let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); @@ -164,7 +164,7 @@ fn test_keyboard_in_container_with_vuinput_placement_in_container() { ))] #[test] fn test_keyboard_in_container_with_vuinput_placement_on_host() { - let _guard=run_vuinputd::ensure_vuinputd_running(&["--placement","on-host"]); + let _guard = run_vuinputd::ensure_vuinputd_running(&["--placement", "on-host"]); let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); diff --git a/vuinputd-tests/tests/podman_tests.rs b/vuinputd-tests/tests/podman_tests.rs index 291d2c1..4951a46 100644 --- a/vuinputd-tests/tests/podman_tests.rs +++ b/vuinputd-tests/tests/podman_tests.rs @@ -66,7 +66,7 @@ fn test_podman_ipc() { ))] #[test] fn test_keyboard_in_container_with_vuinput() { - let _guard=run_vuinputd::ensure_vuinputd_running(&[]); + let _guard = run_vuinputd::ensure_vuinputd_running(&[]); let (builder, _ipc) = podman::PodmanBuilder::new() .run_cmd() @@ -91,3 +91,37 @@ fn test_keyboard_in_container_with_vuinput() { assert!(out.status.success()); } + +#[cfg(all( + feature = "requires-rootless", + feature = "requires-uinput", + feature = "requires-podman" +))] +#[test] +fn test_keyboard_in_container_with_vuinput_rootless_with_userns() { + let _guard = run_vuinputd::ensure_vuinputd_running(&[]); + + let (builder, _ipc) = podman::PodmanBuilder::new() + .run_cmd() + .rm() + .userns("auto") + .with_ipc() + .expect("failed to create IPC"); + let builder = builder + //.detach() + //.name(&format!("vuinputd-podman-tests")) + .device("/dev/vuinput-test:/dev/uinput") + .allow_input_devices() + .image("localhost/vuinputd-tests:latest") + .command(&["/test-keyboard"]); + + let out = builder + .run() + .unwrap_or_else(|e| panic!("failed to run podman!: {e}")); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); + + assert!(out.status.success()); +} From 88737be70b02f26a2cb43bc095502d5a1daaf781 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Fri, 27 Mar 2026 08:17:17 +0000 Subject: [PATCH 075/108] Manual fix of ai hallucinations of event codes --- vuinputd-tests/src/devices/ps4_gamepad.rs | 4 +- vuinputd-tests/src/devices/xbox_gamepad.rs | 139 +++++++++++---------- 2 files changed, 74 insertions(+), 69 deletions(-) diff --git a/vuinputd-tests/src/devices/ps4_gamepad.rs b/vuinputd-tests/src/devices/ps4_gamepad.rs index b08106e..da549bc 100644 --- a/vuinputd-tests/src/devices/ps4_gamepad.rs +++ b/vuinputd-tests/src/devices/ps4_gamepad.rs @@ -2,6 +2,8 @@ // // Author: Johannes Leupolz +// this file is ai genrated and contains many mistake that need to be fixed manually + use super::Device; use libc::c_int; use std::io; @@ -173,4 +175,4 @@ use libc::{c_char, close}; use std::ffi::CString; const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; -const BUS_USB: u16 = 0x03; +const BUS_USB: u16 = 0x03; \ No newline at end of file diff --git a/vuinputd-tests/src/devices/xbox_gamepad.rs b/vuinputd-tests/src/devices/xbox_gamepad.rs index bca3931..0caaf57 100644 --- a/vuinputd-tests/src/devices/xbox_gamepad.rs +++ b/vuinputd-tests/src/devices/xbox_gamepad.rs @@ -8,84 +8,87 @@ use std::io; use std::{ffi::CStr, fs::File}; use uinput_ioctls::*; -// PS4 Gamepad codes -const BTN_SOUTH: u16 = 304; // Cross -const BTN_EAST: u16 = 305; // Circle -const BTN_NORTH: u16 = 306; // Square -const BTN_WEST: u16 = 307; // Triangle -const BTN_TOP: u16 = 310; // L1 -const BTN_TOP2: u16 = 311; // R1 -const BTN_BASE: u16 = 312; // Share -const BTN_BASE2: u16 = 313; // Options -const BTN_BASE3: u16 = 314; // L3 -const BTN_BASE23: u16 = 315; // R3 -const BTN_TL: u16 = 316; // L2 -const BTN_TR: u16 = 317; // R2 -const BTN_SELECT: u16 = 318; -const BTN_START: u16 = 319; -const BTN_THUMBL: u16 = 320; -const BTN_THUMBR: u16 = 321; -const BTN_TOUCH: u16 = 322; -const BTN_TR2: u16 = 323; -const BTN_DPAD_UP: u16 = 325; -const BTN_DPAD_DOWN: u16 = 326; -const BTN_DPAD_LEFT: u16 = 327; -const BTN_DPAD_RIGHT: u16 = 328; - -const ABS_X: u16 = 0; -const ABS_Y: u16 = 1; -const ABS_Z: u16 = 2; -const ABS_RX: u16 = 3; -const ABS_RY: u16 = 4; -const ABS_RZ: u16 = 5; -const ABS_THROTTLE: u16 = 6; -const ABS_RUDDER: u16 = 7; -const ABS_PRESSURE: u16 = 24; -const ABS_DISTANCE: u16 = 32; -const ABS_MT_POSITION_X: u16 = 47; -const ABS_MT_POSITION_Y: u16 = 48; -const ABS_MT_TRACKING_ID: u16 = 57; -const ABS_MT_PRESSURE: u16 = 47; -const ABS_MT_TOOL_TYPE: u16 = 55; -const ABS_MT_WIDTH: u16 = 56; - // Xbox Gamepad codes -const BTN_A: u16 = 304; // A -const BTN_B: u16 = 305; // B -const BTN_X: u16 = 306; // X -const BTN_Y: u16 = 307; // Y -const BTN_TL2: u16 = 319; // LT +// https://github.com/torvalds/linux/blob/master/Documentation/input/gamepad.rst +// https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h// Keys and Buttons +// https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c +pub const BTN_SOUTH: u16 = 0x130; +pub const BTN_EAST: u16 = 0x131; +pub const BTN_NORTH: u16 = 0x133; +pub const BTN_WEST: u16 = 0x134; +pub const BTN_TL: u16 = 0x136; +pub const BTN_TR: u16 = 0x137; +pub const BTN_SELECT: u16 = 0x13a; +pub const BTN_START: u16 = 0x13b; +pub const BTN_MODE: u16 = 0x13c; +pub const BTN_THUMBL: u16 = 0x13d; +pub const BTN_THUMBR: u16 = 0x13e; + +// Absolute Axes +pub const ABS_X: u16 = 0x00; +pub const ABS_Y: u16 = 0x01; +pub const ABS_Z: u16 = 0x02; +pub const ABS_RX: u16 = 0x03; +pub const ABS_RY: u16 = 0x04; +pub const ABS_RZ: u16 = 0x05; +pub const ABS_HAT0X: u16 = 0x10; +pub const ABS_HAT0Y: u16 = 0x11; + +// Force Feedback +// https://github.com/torvalds/linux/blob/master/include/uapi/linux/input.h +pub const FF_RUMBLE: u16 = 0x50; +pub const FF_PERIODIC: u16 = 0x51; +pub const FF_CONSTANT: u16 = 0x52; +pub const FF_RAMP: u16 = 0x57; +pub const FF_SINE: u16 = 0x5a; +pub const FF_GAIN: u16 = 0x60; /// Setup Xbox gamepad device +/// https://github.com/LizardByte/Sunshine/blob/master/src/platform/linux/input/inputtino_gamepad.cpp +/// https://github.com/games-on-whales/inputtino/blob/stable/src/uinput/joypad_xbox.cpp unsafe fn setup_xbox_gamepad(fd: c_int) -> io::Result<()> { // EV_SYN ui_set_evbit(fd, super::EV_SYN.try_into().unwrap())?; + // EV_KEY ui_set_evbit(fd, super::EV_KEY.try_into().unwrap())?; - ui_set_keybit(fd, BTN_A.try_into().unwrap())?; - ui_set_keybit(fd, BTN_B.try_into().unwrap())?; - ui_set_keybit(fd, BTN_X.try_into().unwrap())?; - ui_set_keybit(fd, BTN_Y.try_into().unwrap())?; - ui_set_keybit(fd, BTN_TL.try_into().unwrap())?; - ui_set_keybit(fd, BTN_TR.try_into().unwrap())?; - ui_set_keybit(fd, BTN_SELECT.try_into().unwrap())?; - ui_set_keybit(fd, BTN_START.try_into().unwrap())?; + ui_set_keybit(fd, BTN_WEST.try_into().unwrap())?; + ui_set_keybit(fd, BTN_EAST.try_into().unwrap())?; + ui_set_keybit(fd, BTN_NORTH.try_into().unwrap())?; + ui_set_keybit(fd, BTN_SOUTH.try_into().unwrap())?; ui_set_keybit(fd, BTN_THUMBL.try_into().unwrap())?; ui_set_keybit(fd, BTN_THUMBR.try_into().unwrap())?; - ui_set_keybit(fd, BTN_TL2.try_into().unwrap())?; - ui_set_keybit(fd, BTN_TR2.try_into().unwrap())?; - ui_set_keybit(fd, BTN_DPAD_UP.try_into().unwrap())?; - ui_set_keybit(fd, BTN_DPAD_DOWN.try_into().unwrap())?; - ui_set_keybit(fd, BTN_DPAD_LEFT.try_into().unwrap())?; - ui_set_keybit(fd, BTN_DPAD_RIGHT.try_into().unwrap())?; + ui_set_keybit(fd, BTN_TR.try_into().unwrap())?; + ui_set_keybit(fd, BTN_TL.try_into().unwrap())?; + ui_set_keybit(fd, BTN_SELECT.try_into().unwrap())?; + ui_set_keybit(fd, BTN_MODE.try_into().unwrap())?; + ui_set_keybit(fd, BTN_START.try_into().unwrap())?; + // EV_ABS ui_set_evbit(fd, super::EV_ABS.try_into().unwrap())?; - ui_set_absbit(fd, ABS_X.try_into().unwrap())?; - ui_set_absbit(fd, ABS_Y.try_into().unwrap())?; - ui_set_absbit(fd, ABS_RX.try_into().unwrap())?; - ui_set_absbit(fd, ABS_RY.try_into().unwrap())?; - ui_set_absbit(fd, ABS_PRESSURE.try_into().unwrap())?; - ui_set_absbit(fd, ABS_DISTANCE.try_into().unwrap())?; + // EV_ABS dpad + let abs_info_dpad = libc::input_absinfo { value: 0, minimum: -1, maximum: 1, fuzz: 0, flat: 0, resolution: 0 }; + ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_HAT0Y, absinfo: abs_info_dpad.clone() })?; + ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_HAT0X, absinfo: abs_info_dpad.clone() })?; + // EV_ABS stick + let abs_info_stick = libc::input_absinfo { value: 0, minimum: -32768, maximum: 32767, fuzz: 16, flat: 128, resolution: 0 }; + ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_X, absinfo: abs_info_stick.clone() })?; + ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_RX, absinfo: abs_info_stick.clone() })?; + ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_Y, absinfo: abs_info_stick.clone() })?; + ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_RY, absinfo: abs_info_stick.clone() })?; + // EV_ABS trigger + let abs_info_trigger = libc::input_absinfo { value: 0, minimum: 0, maximum: 255, fuzz: 0, flat: 0, resolution: 0 }; + ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_Z, absinfo: abs_info_trigger.clone() })?; + ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_RZ, absinfo: abs_info_trigger.clone() })?; + + // EV_FF + ui_set_evbit(fd, super::EV_FF.try_into().unwrap())?; + ui_set_ffbit(fd, FF_RUMBLE.try_into().unwrap())?; + ui_set_ffbit(fd, FF_CONSTANT.try_into().unwrap())?; + ui_set_ffbit(fd, FF_PERIODIC.try_into().unwrap())?; + ui_set_ffbit(fd, FF_SINE.try_into().unwrap())?; + ui_set_ffbit(fd, FF_RAMP.try_into().unwrap())?; + ui_set_ffbit(fd, FF_GAIN.try_into().unwrap())?; Ok(()) } @@ -166,5 +169,5 @@ impl Device for XboxGamepadDevice { use libc::{c_char, close}; use std::ffi::CString; -const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; -const BUS_USB: u16 = 0x03; +pub const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; +pub const BUS_USB: u16 = 0x03; From 7182cac6fc6b110831a46ff57113f7790af37dd6 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Fri, 27 Mar 2026 09:59:18 +0000 Subject: [PATCH 076/108] Fix build: Add missing constant --- vuinputd-tests/src/devices/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/vuinputd-tests/src/devices/mod.rs b/vuinputd-tests/src/devices/mod.rs index 9d77113..5d8fd9b 100644 --- a/vuinputd-tests/src/devices/mod.rs +++ b/vuinputd-tests/src/devices/mod.rs @@ -21,6 +21,7 @@ pub const EV_SYN: u16 = 0x00; pub const EV_KEY: u16 = 0x01; pub const EV_REL: u16 = 0x02; pub const EV_ABS: u16 = 0x03; +pub const EV_FF: u16 = 0x15; /// Trait for input devices pub trait Device { From e4eb1d77dab4cd9c660e332b3cabb3859ce307c1 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sun, 29 Mar 2026 22:26:12 +0000 Subject: [PATCH 077/108] Refactored test-scenarios.rs. Added stub for force feedback test. --- vuinputd-tests/src/bin/test-scenarios.rs | 11 +- vuinputd-tests/src/devices/device_base.rs | 262 ++++++++++++++++++ vuinputd-tests/src/devices/keyboard.rs | 117 ++++---- vuinputd-tests/src/devices/mod.rs | 30 +- vuinputd-tests/src/devices/mouse.rs | 114 ++++---- vuinputd-tests/src/devices/ps4_gamepad.rs | 114 ++++---- vuinputd-tests/src/devices/utils.rs | 128 +-------- vuinputd-tests/src/devices/xbox_gamepad.rs | 205 +++++++++----- vuinputd-tests/src/lib.rs | 4 +- .../src/scenarios/basic_keyboard.rs | 19 +- vuinputd-tests/src/scenarios/basic_mouse.rs | 19 +- .../src/scenarios/basic_ps4_gamepad.rs | 19 +- .../src/scenarios/basic_xbox_gamepad.rs | 19 +- .../src/scenarios/ff_xbox_gamepad.rs | 51 ++++ vuinputd-tests/src/scenarios/mod.rs | 2 + vuinputd-tests/src/test_log.rs | 2 +- 16 files changed, 678 insertions(+), 438 deletions(-) create mode 100644 vuinputd-tests/src/devices/device_base.rs create mode 100644 vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs diff --git a/vuinputd-tests/src/bin/test-scenarios.rs b/vuinputd-tests/src/bin/test-scenarios.rs index 6b1b145..39402d7 100644 --- a/vuinputd-tests/src/bin/test-scenarios.rs +++ b/vuinputd-tests/src/bin/test-scenarios.rs @@ -5,9 +5,10 @@ use clap::{Parser, Subcommand}; use vuinputd_tests::scenarios::{ basic_keyboard::BasicKeyboard, basic_mouse::BasicMouse, basic_ps4_gamepad::BasicPs4Gamepad, - basic_xbox_gamepad::BasicXboxGamepad, /* - reuse_keyboard::ReuseKeyboard, reuse_xbox_gamepad::ReuseXboxGamepad, - ScenarioArgs, stress_keyboard::StressKeyboard, stress_xbox_gamepad::StressXboxGamepad, */ + basic_xbox_gamepad::BasicXboxGamepad, + ff_xbox_gamepad::FfXboxGamepad, /* + reuse_keyboard::ReuseKeyboard, reuse_xbox_gamepad::ReuseXboxGamepad, + ScenarioArgs, stress_keyboard::StressKeyboard, stress_xbox_gamepad::StressXboxGamepad, */ ScenarioArgs, }; @@ -40,6 +41,9 @@ enum Commands { /// Basic Xbox gamepad test BasicXboxGamepad, + + /// Force feedback / Vibration Xbox gamepad test + FfXboxGamepad, /* /// Reuse keyboard test (create, destroy, recreate) ReuseKeyboard, @@ -68,6 +72,7 @@ fn main() -> Result<(), std::io::Error> { Commands::BasicMouse => BasicMouse::run(&args), Commands::BasicPs4Gamepad => BasicPs4Gamepad::run(&args), Commands::BasicXboxGamepad => BasicXboxGamepad::run(&args), + Commands::FfXboxGamepad => FfXboxGamepad::run(&args), /* Commands::ReuseKeyboard => ReuseKeyboard::run(&args), Commands::ReuseXboxGamepad => ReuseXboxGamepad::run(&args), diff --git a/vuinputd-tests/src/devices/device_base.rs b/vuinputd-tests/src/devices/device_base.rs new file mode 100644 index 0000000..314bda3 --- /dev/null +++ b/vuinputd-tests/src/devices/device_base.rs @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use crate::test_log::LoggedInputEvent; +use libc::{c_int, close, open, write, O_NONBLOCK, O_WRONLY}; +use libc::{input_event, timespec, uinput_setup, CLOCK_MONOTONIC}; +use std::ffi::{CStr, CString}; +use std::fs::File; +use std::io; +use std::mem::{size_of, zeroed}; +use std::os::fd::AsRawFd; +use std::os::raw::{c_char, c_void}; +use uinput_ioctls::*; + +// Constants (same numeric values as in linux headers) +pub const EV_SYN: u16 = 0x00; +pub const EV_KEY: u16 = 0x01; +pub const EV_REL: u16 = 0x02; +pub const EV_ABS: u16 = 0x03; +pub const EV_FF: u16 = 0x15; +pub const SYN_REPORT: u16 = 0; +pub const BUS_USB: u16 = 0x03; +pub const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; + +/// Struct holding device state +pub struct DeviceState { + pub uinput_fd: i32, + pub sysname: String, + pub device_name: String, + pub event_device_node: String, + pub event_device_fd: i32, + pub events: Vec, +} + +/// Trait for input devices +pub trait Device: Sized { + fn name() -> &'static str; + + /// open uinput, configure keys, call ui_dev_setup, call ui_dev_create, get sysname and devnode + /// open event device + fn create(device: Option<&str>, name: &str) -> Result; + + /// call ui_dev_destroy and close fds + fn destroy(self); + + /// Get the device state for internal operations + fn state(&self) -> &DeviceState; + + /// Get mutable access to device state for updating events + fn state_mut(&mut self) -> &mut DeviceState; + + /// Get the uinput file descriptor + fn uinput_fd(&self) -> i32 { + self.state().uinput_fd + } + + /// Get the sysname path + fn sysname(&self) -> &str { + &self.state().sysname + } + + /// Get the device name + fn device_name(&self) -> &str { + &self.state().device_name + } + + fn get_event_device(&self) -> Result; + + /// Emit an event to the device + fn emit(&self, ev_type: u16, code: u16, val: i32) -> io::Result<()> { + emit(self.uinput_fd(), ev_type, code, val) + } + + /// Read an event from the event device + fn read_event(&self) -> io::Result { + let event_device_fd = self.get_event_device()?; + read_event(event_device_fd) + } + + /// Emit and read an event with logging + fn emit_read_and_log( + &mut self, + ev_type: u16, + code: u16, + val: i32, + ) -> io::Result { + let event_device_fd = self.get_event_device()?; + let event = emit_read_and_log(self.uinput_fd(), event_device_fd, ev_type, code, val)?; + self.state_mut().events.push(event.clone()); + Ok(event) + } + + /// Get the event log + fn event_log(&self) -> &[LoggedInputEvent] { + &self.state().events + } + + /// Reset the event log + fn reset_event_log(&mut self) { + self.state_mut().events.clear(); + } + + /// Get the event log as mutable slice + fn event_log_mut(&mut self) -> &mut Vec { + &mut self.state_mut().events + } + + /// Setup the uinput device (calls ui_dev_setup and ui_get_sysname) + fn setup_device(&self, name: &str, vendor: u16, product: u16, bustype: u16) -> io::Result<()> { + unsafe { + let mut usetup: uinput_setup = zeroed(); + usetup.id.bustype = bustype; + usetup.id.vendor = vendor; + usetup.id.product = product; + + let name_cstr = CString::new(name).unwrap(); + let name_ptr = usetup.name.as_mut_ptr() as *mut c_char; + std::ptr::copy_nonoverlapping( + name_cstr.as_ptr(), + name_ptr, + name_cstr.to_bytes_with_nul().len(), + ); + + let usetup_ptr = &mut usetup as *mut uinput_setup; + ui_dev_setup(self.uinput_fd(), usetup_ptr).map_err(|e| { + eprintln!("ui_dev_setup failed: {:?}", e); + e + })?; + Ok(()) + } + } + + /// Get the sysname from the uinput fd + fn get_sysname(&self) -> io::Result { + unsafe { + let mut resultbuf: [c_char; 64] = [0; 64]; + ui_get_sysname(self.uinput_fd(), resultbuf.as_mut_slice()).map_err(|e| { + eprintln!("ui_get_sysname failed: {:?}", e); + e + })?; + Ok(format!( + "{}{}", + SYS_INPUT_DIR, + CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() + )) + } + } +} + +/// Emit an event to the uinput device +pub fn emit(fd: c_int, ev_type: u16, code: u16, val: i32) -> io::Result<()> { + let mut ie: libc::input_event = unsafe { zeroed() }; + + ie.time.tv_sec = 0; + ie.time.tv_usec = 0; + + ie.type_ = ev_type; + ie.code = code; + ie.value = val; + + let buf_ptr = &ie as *const libc::input_event as *const c_void; + let bytes = size_of::(); + + let written = unsafe { write(fd, buf_ptr, bytes) }; + if written as usize != bytes { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +/// Emit event, sync, and read back with logging +pub fn emit_read_and_log( + emit_to: c_int, + read_from: c_int, + ev_type: u16, + code: u16, + val: i32, +) -> io::Result { + let (time_sent_sec, time_sent_nsec) = monotonic_time(); + emit(emit_to, ev_type, code, val)?; + emit(emit_to, EV_SYN, SYN_REPORT, 0)?; + let input_event_recv = read_event(read_from).unwrap(); + let _syn_recv = read_event(read_from).unwrap(); + let (time_recv_sec, time_recv_nsec) = monotonic_time(); + let duration_usec = + (time_recv_sec - time_sent_sec) * 1_000_000 + (time_recv_nsec - time_sent_nsec) / 1000; + let send_and_receive_match = input_event_recv.type_ == ev_type + && input_event_recv.code == code + && input_event_recv.value == val; + + Ok(LoggedInputEvent { + tv_sec: time_sent_sec, + tv_nsec: time_sent_nsec, + duration_usec, + type_: ev_type, + code, + value: val, + send_and_receive_match, + }) +} + +/// Read an event from the event device +pub fn read_event(event_dev_fd: c_int) -> io::Result { + let mut ev: input_event = unsafe { zeroed() }; + let ret = unsafe { + libc::read( + event_dev_fd, + &mut ev as *mut _ as *mut c_void, + size_of::(), + ) + }; + if ret as usize != size_of::() { + return Err(io::Error::last_os_error()); + } + Ok(ev) +} + +/// Get monotonic time +pub fn monotonic_time() -> (i64, i64) { + let mut ts = timespec { + tv_sec: 0, + tv_nsec: 0, + }; + + unsafe { + libc::clock_gettime(CLOCK_MONOTONIC, &mut ts); + } + (ts.tv_sec, ts.tv_nsec) +} + +/// Open uinput device +pub fn open_uinput(device: Option<&str>) -> io::Result { + let device = match device { + Some(dev_path) => dev_path, + _ => "/dev/uinput", + }; + + let path = std::ffi::CString::new(device).unwrap(); + let fd = unsafe { open(path.as_ptr(), O_WRONLY | O_NONBLOCK) }; + if fd < 0 { + eprintln!("error opening uinput"); + return Err(io::Error::last_os_error()); + } + Ok(fd) +} + +/// Fetch the event device node from the sysname path +pub fn fetch_device_node(sysname: &str) -> io::Result { + use std::fs; + use std::io::ErrorKind; + + for entry in fs::read_dir(sysname)? { + let entry = entry?; + if let Some(name) = entry.file_name().to_str() { + if name.starts_with("event") { + return Ok(format!("/dev/input/{}", name)); + } + } + } + Err(io::Error::new(ErrorKind::NotFound, "no device found")) +} diff --git a/vuinputd-tests/src/devices/keyboard.rs b/vuinputd-tests/src/devices/keyboard.rs index 2dd1376..49b7c1d 100644 --- a/vuinputd-tests/src/devices/keyboard.rs +++ b/vuinputd-tests/src/devices/keyboard.rs @@ -2,9 +2,11 @@ // // Author: Johannes Leupolz -use super::Device; +use crate::devices::device_base::{ + fetch_device_node, open_uinput, Device, DeviceState, BUS_USB, SYS_INPUT_DIR, +}; +use libc::{c_int, close, open}; use std::io; -use std::{ffi::CStr, fs::File}; use uinput_ioctls::*; /// Key codes. Those are used by udev to recognize a device as a keyboard. @@ -126,7 +128,7 @@ pub const KEY_INSERT: u16 = 110; pub const KEY_DELETE: u16 = 111; /// Configure a full 101-key standard keyboard -unsafe fn set_standard_keyboard_keys(fd: i32) -> Result<(), std::io::Error> { +unsafe fn set_standard_keyboard_keys(fd: c_int) -> Result<(), std::io::Error> { // We need to set more bits so that systemd recognizes a keyboard as a keyboard. // At least the first 32 bits are ESC, numbers, and Q to D, except KEY_RESERVED need to be considered. // udev-builtin-input_id.c consideres the mask = 0xFFFFFFFE @@ -260,81 +262,84 @@ unsafe fn set_standard_keyboard_keys(fd: i32) -> Result<(), std::io::Error> { Ok(()) } -pub struct KeyboardDevice; +pub struct KeyboardDevice { + state: DeviceState, +} impl Device for KeyboardDevice { fn name() -> &'static str { "Keyboard" } - fn get_event_device(sysname: &str) -> Result { - super::utils::fetch_device_node(sysname).and_then(|devnode| File::open(&devnode)) + fn state(&self) -> &DeviceState { + &self.state } - fn setup(device: Option<&str>, name: &str) -> Result { - let fd = super::utils::open_uinput(device)?; - unsafe { set_standard_keyboard_keys(fd) }?; - - unsafe { - let mut usetup: libc::uinput_setup = std::mem::zeroed(); - usetup.id.bustype = BUS_USB; - usetup.id.vendor = 0xbeef; - usetup.id.product = 0xdead; - - let name_cstr = CString::new(name).unwrap(); - let name_ptr = usetup.name.as_mut_ptr() as *mut c_char; - std::ptr::copy_nonoverlapping( - name_cstr.as_ptr(), - name_ptr, - name_cstr.to_bytes_with_nul().len(), - ); - - let usetup_ptr = &mut usetup as *mut libc::uinput_setup; - ui_dev_setup(fd, usetup_ptr).map_err(|e| { - eprintln!("ui_dev_setup failed: {:?}", e); - e - })?; - } - - Ok(fd) + fn state_mut(&mut self) -> &mut DeviceState { + &mut self.state } - fn create(fd: i32) -> Result { + fn get_event_device(&self) -> Result { + Ok(self.state.event_device_fd) + } + + fn create(device: Option<&str>, name: &str) -> Result { + let fd = open_uinput(device)?; + + unsafe { set_standard_keyboard_keys(fd)? }; + + let temp_device = KeyboardDevice { + state: DeviceState { + uinput_fd: fd, + sysname: String::new(), + device_name: name.to_string(), + event_device_node: String::new(), + event_device_fd: -1, + events: Vec::new(), + }, + }; + temp_device.setup_device(name, 0xbeef, 0xdead, BUS_USB)?; + unsafe { ui_dev_create(fd).map_err(|e| { eprintln!("ui_dev_create failed: {:?}", e); e })?; - - let mut resultbuf: [c_char; 64] = [0; 64]; - ui_get_sysname(fd, resultbuf.as_mut_slice()).map_err(|e| { - eprintln!("ui_get_sysname failed: {:?}", e); - e - })?; - - let sysname = format!( - "{}{}", - SYS_INPUT_DIR, - CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() - ); - - Ok(sysname) } + + let sysname = temp_device.get_sysname()?; + + let event_device_node = fetch_device_node(&sysname)?; + let event_device_fd = unsafe { + open( + event_device_node.as_ptr() as *const i8, + libc::O_RDONLY | libc::O_NONBLOCK, + ) + }; + if event_device_fd < 0 { + return Err(io::Error::last_os_error()); + } + + Ok(KeyboardDevice { + state: DeviceState { + uinput_fd: fd, + sysname, + device_name: name.to_string(), + event_device_node, + event_device_fd, + events: Vec::new(), + }, + }) } - fn destroy(fd: i32) { + fn destroy(self) { unsafe { - ui_dev_destroy(fd).unwrap_or_else(|e| { + ui_dev_destroy(self.state.uinput_fd).unwrap_or_else(|e| { eprintln!("ui_dev_destroy failed: {:?}", e); std::process::exit(1); }); - close(fd); + close(self.state.uinput_fd); + close(self.state.event_device_fd); } } } - -use libc::{c_char, close}; -use std::ffi::CString; - -const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; -const BUS_USB: u16 = 0x03; diff --git a/vuinputd-tests/src/devices/mod.rs b/vuinputd-tests/src/devices/mod.rs index 5d8fd9b..c0e7cbe 100644 --- a/vuinputd-tests/src/devices/mod.rs +++ b/vuinputd-tests/src/devices/mod.rs @@ -2,38 +2,20 @@ // // Author: Johannes Leupolz -use std::fs::File; -use std::io; - +pub mod device_base; pub mod keyboard; pub mod mouse; pub mod ps4_gamepad; pub mod utils; pub mod xbox_gamepad; +pub use device_base::Device; +// Keep DeviceState exported for backward compatibility +pub use device_base::DeviceState; pub use keyboard::KeyboardDevice; pub use mouse::MouseDevice; pub use ps4_gamepad::Ps4GamepadDevice; pub use xbox_gamepad::XboxGamepadDevice; -// Constants (same numeric values as in linux headers) -pub const EV_SYN: u16 = 0x00; -pub const EV_KEY: u16 = 0x01; -pub const EV_REL: u16 = 0x02; -pub const EV_ABS: u16 = 0x03; -pub const EV_FF: u16 = 0x15; - -/// Trait for input devices -pub trait Device { - fn name() -> &'static str; - fn get_event_device(sysname: &str) -> Result; - - /// Phase 1: open uinput, configure keys, call ui_dev_setup - fn setup(device: Option<&str>, name: &str) -> Result; - - /// Phase 2: call ui_dev_create, get sysname and devnode - fn create(fd: i32) -> Result; - - /// Phase 3: call ui_dev_destroy and close fd - fn destroy(fd: i32); -} +// Re-export constants from device_base for backward compatibility +pub use device_base::{BUS_USB, EV_ABS, EV_FF, EV_KEY, EV_REL, EV_SYN, SYN_REPORT, SYS_INPUT_DIR}; diff --git a/vuinputd-tests/src/devices/mouse.rs b/vuinputd-tests/src/devices/mouse.rs index 352e792..60a1ed4 100644 --- a/vuinputd-tests/src/devices/mouse.rs +++ b/vuinputd-tests/src/devices/mouse.rs @@ -2,10 +2,9 @@ // // Author: Johannes Leupolz -use super::Device; -use libc::c_int; +use crate::devices::device_base::{fetch_device_node, open_uinput, Device, DeviceState, BUS_USB}; +use libc::{c_int, close, open}; use std::io; -use std::{ffi::CStr, fs::File}; use uinput_ioctls::*; // Mouse codes @@ -32,81 +31,84 @@ unsafe fn setup_mouse(fd: c_int) -> io::Result<()> { Ok(()) } -pub struct MouseDevice; +pub struct MouseDevice { + state: DeviceState, +} impl Device for MouseDevice { fn name() -> &'static str { "Mouse" } - fn get_event_device(sysname: &str) -> Result { - super::utils::fetch_device_node(sysname).and_then(|devnode| File::open(&devnode)) + fn state(&self) -> &DeviceState { + &self.state } - fn setup(device: Option<&str>, name: &str) -> Result { - let fd = super::utils::open_uinput(device)?; - unsafe { setup_mouse(fd) }?; - - unsafe { - let mut usetup: libc::uinput_setup = std::mem::zeroed(); - usetup.id.bustype = BUS_USB; - usetup.id.vendor = 0xbeef; - usetup.id.product = 0xdead; - - let name_cstr = CString::new(name).unwrap(); - let name_ptr = usetup.name.as_mut_ptr() as *mut c_char; - std::ptr::copy_nonoverlapping( - name_cstr.as_ptr(), - name_ptr, - name_cstr.to_bytes_with_nul().len(), - ); - - let usetup_ptr = &mut usetup as *mut libc::uinput_setup; - ui_dev_setup(fd, usetup_ptr).map_err(|e| { - eprintln!("ui_dev_setup failed: {:?}", e); - e - })?; - } - - Ok(fd) + fn state_mut(&mut self) -> &mut DeviceState { + &mut self.state } - fn create(fd: i32) -> Result { + fn get_event_device(&self) -> Result { + Ok(self.state.event_device_fd) + } + + fn create(device: Option<&str>, name: &str) -> Result { + let fd = open_uinput(device)?; + + unsafe { setup_mouse(fd)? }; + + let temp_device = MouseDevice { + state: DeviceState { + uinput_fd: fd, + sysname: String::new(), + device_name: name.to_string(), + event_device_node: String::new(), + event_device_fd: -1, + events: Vec::new(), + }, + }; + temp_device.setup_device(name, 0xbeef, 0xdead, BUS_USB)?; + unsafe { ui_dev_create(fd).map_err(|e| { eprintln!("ui_dev_create failed: {:?}", e); e })?; - - let mut resultbuf: [c_char; 64] = [0; 64]; - ui_get_sysname(fd, resultbuf.as_mut_slice()).map_err(|e| { - eprintln!("ui_get_sysname failed: {:?}", e); - e - })?; - - let sysname = format!( - "{}{}", - SYS_INPUT_DIR, - CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() - ); - - Ok(sysname) } + + let sysname = temp_device.get_sysname()?; + + let event_device_node = fetch_device_node(&sysname)?; + let event_device_fd = unsafe { + open( + event_device_node.as_ptr() as *const i8, + libc::O_RDONLY | libc::O_NONBLOCK, + ) + }; + if event_device_fd < 0 { + return Err(io::Error::last_os_error()); + } + + Ok(MouseDevice { + state: DeviceState { + uinput_fd: fd, + sysname, + device_name: name.to_string(), + event_device_node, + event_device_fd, + events: Vec::new(), + }, + }) } - fn destroy(fd: i32) { + fn destroy(self) { unsafe { - ui_dev_destroy(fd).unwrap_or_else(|e| { + ui_dev_destroy(self.state.uinput_fd).unwrap_or_else(|e| { eprintln!("ui_dev_destroy failed: {:?}", e); std::process::exit(1); }); - close(fd); + close(self.state.uinput_fd); + close(self.state.event_device_fd); } } } - -use libc::{c_char, close}; -use std::ffi::CString; - -const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; -const BUS_USB: u16 = 0x03; diff --git a/vuinputd-tests/src/devices/ps4_gamepad.rs b/vuinputd-tests/src/devices/ps4_gamepad.rs index da549bc..11f02b0 100644 --- a/vuinputd-tests/src/devices/ps4_gamepad.rs +++ b/vuinputd-tests/src/devices/ps4_gamepad.rs @@ -4,10 +4,9 @@ // this file is ai genrated and contains many mistake that need to be fixed manually -use super::Device; -use libc::c_int; +use crate::devices::device_base::{fetch_device_node, open_uinput, Device, DeviceState, BUS_USB}; +use libc::{c_int, close, open}; use std::io; -use std::{ffi::CStr, fs::File}; use uinput_ioctls::*; // PS4 Gamepad codes @@ -58,7 +57,9 @@ const BTN_X: u16 = 306; // X const BTN_Y: u16 = 307; // Y const BTN_TL2: u16 = 319; // LT -pub struct Ps4GamepadDevice; +pub struct Ps4GamepadDevice { + state: DeviceState, +} /// Setup PS4 gamepad device unsafe fn setup_ps4_gamepad(fd: c_int) -> io::Result<()> { @@ -105,74 +106,75 @@ impl Device for Ps4GamepadDevice { "PS4 Gamepad" } - fn get_event_device(sysname: &str) -> Result { - super::utils::fetch_device_node(sysname).and_then(|devnode| File::open(&devnode)) + fn state(&self) -> &DeviceState { + &self.state } - fn setup(device: Option<&str>, name: &str) -> Result { - let fd = super::utils::open_uinput(device)?; - unsafe { setup_ps4_gamepad(fd) }?; - - unsafe { - let mut usetup: libc::uinput_setup = std::mem::zeroed(); - usetup.id.bustype = BUS_USB; - usetup.id.vendor = 0xbeef; - usetup.id.product = 0xdead; - - let name_cstr = CString::new(name).unwrap(); - let name_ptr = usetup.name.as_mut_ptr() as *mut c_char; - std::ptr::copy_nonoverlapping( - name_cstr.as_ptr(), - name_ptr, - name_cstr.to_bytes_with_nul().len(), - ); - - let usetup_ptr = &mut usetup as *mut libc::uinput_setup; - ui_dev_setup(fd, usetup_ptr).map_err(|e| { - eprintln!("ui_dev_setup failed: {:?}", e); - e - })?; - } - - Ok(fd) + fn state_mut(&mut self) -> &mut DeviceState { + &mut self.state } - fn create(fd: i32) -> Result { + fn get_event_device(&self) -> Result { + Ok(self.state.event_device_fd) + } + + fn create(device: Option<&str>, name: &str) -> Result { + let fd = open_uinput(device)?; + + unsafe { setup_ps4_gamepad(fd)? }; + + let temp_device = Ps4GamepadDevice { + state: DeviceState { + uinput_fd: fd, + sysname: String::new(), + device_name: name.to_string(), + event_device_node: String::new(), + event_device_fd: -1, + events: Vec::new(), + }, + }; + temp_device.setup_device(name, 0xbeef, 0xdead, BUS_USB)?; + unsafe { ui_dev_create(fd).map_err(|e| { eprintln!("ui_dev_create failed: {:?}", e); e })?; - - let mut resultbuf: [c_char; 64] = [0; 64]; - ui_get_sysname(fd, resultbuf.as_mut_slice()).map_err(|e| { - eprintln!("ui_get_sysname failed: {:?}", e); - e - })?; - - let sysname = format!( - "{}{}", - SYS_INPUT_DIR, - CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() - ); - - Ok(sysname) } + + let sysname = temp_device.get_sysname()?; + + let event_device_node = fetch_device_node(&sysname)?; + let event_device_fd = unsafe { + open( + event_device_node.as_ptr() as *const i8, + libc::O_RDONLY | libc::O_NONBLOCK, + ) + }; + if event_device_fd < 0 { + return Err(io::Error::last_os_error()); + } + + Ok(Ps4GamepadDevice { + state: DeviceState { + uinput_fd: fd, + sysname, + device_name: name.to_string(), + event_device_node, + event_device_fd, + events: Vec::new(), + }, + }) } - fn destroy(fd: i32) { + fn destroy(self) { unsafe { - ui_dev_destroy(fd).unwrap_or_else(|e| { + ui_dev_destroy(self.state.uinput_fd).unwrap_or_else(|e| { eprintln!("ui_dev_destroy failed: {:?}", e); std::process::exit(1); }); - close(fd); + close(self.state.uinput_fd); + close(self.state.event_device_fd); } } } - -use libc::{c_char, close}; -use std::ffi::CString; - -const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; -const BUS_USB: u16 = 0x03; \ No newline at end of file diff --git a/vuinputd-tests/src/devices/utils.rs b/vuinputd-tests/src/devices/utils.rs index ff4fdec..5fe5b26 100644 --- a/vuinputd-tests/src/devices/utils.rs +++ b/vuinputd-tests/src/devices/utils.rs @@ -2,6 +2,7 @@ // // Author: Johannes Leupolz +pub use crate::devices::device_base::*; use crate::test_log::{LoggedInputEvent, TestLog}; use libc::{c_int, close, open, write, O_NONBLOCK, O_WRONLY}; use libc::{input_event, timespec, uinput_setup, CLOCK_MONOTONIC}; @@ -14,128 +15,5 @@ use std::os::raw::{c_char, c_void}; use std::ptr; pub use uinput_ioctls::*; -// Constants (same numeric values as in linux headers) -const EV_SYN: u16 = 0x00; -const EV_KEY: u16 = 0x01; -const SYN_REPORT: u16 = 0; -const BUS_USB: u16 = 0x03; - -pub fn emit(fd: c_int, ev_type: u16, code: u16, val: i32) -> io::Result<()> { - // libc's input_event struct layout: - // struct input_event { - // struct timeval time; - // __u16 type; - // __u16 code; - // __s32 value; - // }; - // - // libc provides input_event as `libc::input_event` on Linux. - let mut ie: libc::input_event = unsafe { zeroed() }; - - // time fields are ignored by kernel for synthetic events - set zero - ie.time.tv_sec = 0; - ie.time.tv_usec = 0; - - ie.type_ = ev_type; // note: in libc the field is `type_` - ie.code = code; - ie.value = val; - - // write the struct to the uinput fd - let buf_ptr = &ie as *const libc::input_event as *const c_void; - let bytes = size_of::(); - - //println!("write to {} {} {} {} ",fd,ev_type,code,val); - let written = unsafe { write(fd, buf_ptr, bytes) }; - //println!("written"); - if written as usize != bytes { - return Err(io::Error::last_os_error()); - } - Ok(()) -} - -// Note that before we can read, a SYN needs to be sent. Thus combine it. -pub fn emit_read_and_log( - emit_to: c_int, - read_from: &File, - ev_type: u16, - code: u16, - val: i32, -) -> io::Result { - let (time_sent_sec, time_sent_nsec) = monotonic_time(); - emit(emit_to, ev_type, code, val)?; - emit(emit_to, EV_SYN, SYN_REPORT, 0)?; - let input_event_recv = read_event(&read_from).unwrap(); - let _syn_recv = read_event(&read_from).unwrap(); - let (time_recv_sec, time_recv_nsec) = monotonic_time(); - let duration_usec = - (time_recv_sec - time_sent_sec) * 1_000_000 + (time_recv_nsec - time_sent_nsec) / 1000; - let send_and_receive_match = input_event_recv.type_ == ev_type - && input_event_recv.code == code - && input_event_recv.value == val; - - Ok(LoggedInputEvent { - tv_sec: time_sent_sec, - tv_nsec: time_sent_nsec, - duration_usec: duration_usec, - type_: ev_type, - code: code, - value: val, - send_and_receive_match: send_and_receive_match, - }) -} - -pub fn fetch_device_node(path: &str) -> io::Result { - println!("Read dir {}", &path); - for entry in fs::read_dir(path)? { - let entry = entry?; // propagate per-entry errors - if let Some(name) = entry.file_name().to_str() { - if name.starts_with("event") { - return Ok(format!("/dev/input/{}", name)); - } - } - } - // If no device is found, return an error - Err(io::Error::new(ErrorKind::NotFound, "no device found")) -} - -pub fn read_event(event_dev: &File) -> io::Result { - let mut ev: input_event = unsafe { mem::zeroed() }; - let ret = unsafe { - libc::read( - event_dev.as_raw_fd(), - &mut ev as *mut _ as *mut c_void, - mem::size_of::(), - ) - }; - if ret as usize != mem::size_of::() { - return Err(io::Error::last_os_error()); - } - Ok(ev) -} - -pub fn monotonic_time() -> (i64, i64) { - let mut ts = timespec { - tv_sec: 0, - tv_nsec: 0, - }; - - unsafe { - libc::clock_gettime(CLOCK_MONOTONIC, &mut ts); - } - (ts.tv_sec, ts.tv_nsec) -} - -pub fn open_uinput(device: Option<&str>) -> io::Result { - let device = match device { - Some(dev_path) => dev_path, - _ => "/dev/uinput", - }; - - let path = CString::new(device).unwrap(); - let fd = unsafe { open(path.as_ptr(), O_WRONLY | O_NONBLOCK) }; - if fd < 0 { - eprintln!("error opening uinput"); - return Err(io::Error::last_os_error()); - } - Ok(fd) -} +// Re-export constants from device_base for backward compatibility +pub use crate::devices::device_base::{BUS_USB, EV_ABS, EV_FF, EV_KEY, EV_REL, EV_SYN, SYN_REPORT}; diff --git a/vuinputd-tests/src/devices/xbox_gamepad.rs b/vuinputd-tests/src/devices/xbox_gamepad.rs index 0caaf57..19ba583 100644 --- a/vuinputd-tests/src/devices/xbox_gamepad.rs +++ b/vuinputd-tests/src/devices/xbox_gamepad.rs @@ -2,10 +2,9 @@ // // Author: Johannes Leupolz -use super::Device; -use libc::c_int; +use crate::devices::device_base::{fetch_device_node, open_uinput, Device, DeviceState, BUS_USB}; +use libc::{c_int, close, open}; use std::io; -use std::{ffi::CStr, fs::File}; use uinput_ioctls::*; // Xbox Gamepad codes @@ -67,19 +66,88 @@ unsafe fn setup_xbox_gamepad(fd: c_int) -> io::Result<()> { // EV_ABS ui_set_evbit(fd, super::EV_ABS.try_into().unwrap())?; // EV_ABS dpad - let abs_info_dpad = libc::input_absinfo { value: 0, minimum: -1, maximum: 1, fuzz: 0, flat: 0, resolution: 0 }; - ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_HAT0Y, absinfo: abs_info_dpad.clone() })?; - ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_HAT0X, absinfo: abs_info_dpad.clone() })?; + let abs_info_dpad = libc::input_absinfo { + value: 0, + minimum: -1, + maximum: 1, + fuzz: 0, + flat: 0, + resolution: 0, + }; + ui_abs_setup( + fd, + &libc::uinput_abs_setup { + code: ABS_HAT0Y, + absinfo: abs_info_dpad.clone(), + }, + )?; + ui_abs_setup( + fd, + &libc::uinput_abs_setup { + code: ABS_HAT0X, + absinfo: abs_info_dpad.clone(), + }, + )?; // EV_ABS stick - let abs_info_stick = libc::input_absinfo { value: 0, minimum: -32768, maximum: 32767, fuzz: 16, flat: 128, resolution: 0 }; - ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_X, absinfo: abs_info_stick.clone() })?; - ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_RX, absinfo: abs_info_stick.clone() })?; - ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_Y, absinfo: abs_info_stick.clone() })?; - ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_RY, absinfo: abs_info_stick.clone() })?; + let abs_info_stick = libc::input_absinfo { + value: 0, + minimum: -32768, + maximum: 32767, + fuzz: 16, + flat: 128, + resolution: 0, + }; + ui_abs_setup( + fd, + &libc::uinput_abs_setup { + code: ABS_X, + absinfo: abs_info_stick.clone(), + }, + )?; + ui_abs_setup( + fd, + &libc::uinput_abs_setup { + code: ABS_RX, + absinfo: abs_info_stick.clone(), + }, + )?; + ui_abs_setup( + fd, + &libc::uinput_abs_setup { + code: ABS_Y, + absinfo: abs_info_stick.clone(), + }, + )?; + ui_abs_setup( + fd, + &libc::uinput_abs_setup { + code: ABS_RY, + absinfo: abs_info_stick.clone(), + }, + )?; // EV_ABS trigger - let abs_info_trigger = libc::input_absinfo { value: 0, minimum: 0, maximum: 255, fuzz: 0, flat: 0, resolution: 0 }; - ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_Z, absinfo: abs_info_trigger.clone() })?; - ui_abs_setup(fd, &libc::uinput_abs_setup { code: ABS_RZ, absinfo: abs_info_trigger.clone() })?; + let abs_info_trigger = libc::input_absinfo { + value: 0, + minimum: 0, + maximum: 255, + fuzz: 0, + flat: 0, + resolution: 0, + }; + ui_abs_setup( + fd, + &libc::uinput_abs_setup { + code: ABS_Z, + absinfo: abs_info_trigger.clone(), + }, + )?; + ui_abs_setup( + fd, + &libc::uinput_abs_setup { + code: ABS_RZ, + absinfo: abs_info_trigger.clone(), + }, + )?; // EV_FF ui_set_evbit(fd, super::EV_FF.try_into().unwrap())?; @@ -93,81 +161,84 @@ unsafe fn setup_xbox_gamepad(fd: c_int) -> io::Result<()> { Ok(()) } -pub struct XboxGamepadDevice; +pub struct XboxGamepadDevice { + state: DeviceState, +} impl Device for XboxGamepadDevice { fn name() -> &'static str { "Xbox Gamepad" } - fn get_event_device(sysname: &str) -> Result { - super::utils::fetch_device_node(sysname).and_then(|devnode| File::open(&devnode)) + fn state(&self) -> &DeviceState { + &self.state } - fn setup(device: Option<&str>, name: &str) -> Result { - let fd = super::utils::open_uinput(device)?; - unsafe { setup_xbox_gamepad(fd) }?; - - unsafe { - let mut usetup: libc::uinput_setup = std::mem::zeroed(); - usetup.id.bustype = BUS_USB; - usetup.id.vendor = 0xbeef; - usetup.id.product = 0xdead; - - let name_cstr = CString::new(name).unwrap(); - let name_ptr = usetup.name.as_mut_ptr() as *mut c_char; - std::ptr::copy_nonoverlapping( - name_cstr.as_ptr(), - name_ptr, - name_cstr.to_bytes_with_nul().len(), - ); - - let usetup_ptr = &mut usetup as *mut libc::uinput_setup; - ui_dev_setup(fd, usetup_ptr).map_err(|e| { - eprintln!("ui_dev_setup failed: {:?}", e); - e - })?; - } - - Ok(fd) + fn state_mut(&mut self) -> &mut DeviceState { + &mut self.state } - fn create(fd: i32) -> Result { + fn get_event_device(&self) -> Result { + Ok(self.state.event_device_fd) + } + + fn create(device: Option<&str>, name: &str) -> Result { + let fd = open_uinput(device)?; + + unsafe { setup_xbox_gamepad(fd)? }; + + let temp_device = XboxGamepadDevice { + state: DeviceState { + uinput_fd: fd, + sysname: String::new(), + device_name: name.to_string(), + event_device_node: String::new(), + event_device_fd: -1, + events: Vec::new(), + }, + }; + temp_device.setup_device(name, 0xbeef, 0xdead, BUS_USB)?; + unsafe { ui_dev_create(fd).map_err(|e| { eprintln!("ui_dev_create failed: {:?}", e); e })?; - - let mut resultbuf: [c_char; 64] = [0; 64]; - ui_get_sysname(fd, resultbuf.as_mut_slice()).map_err(|e| { - eprintln!("ui_get_sysname failed: {:?}", e); - e - })?; - - let sysname = format!( - "{}{}", - SYS_INPUT_DIR, - CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() - ); - - Ok(sysname) } + + let sysname = temp_device.get_sysname()?; + + let event_device_node = fetch_device_node(&sysname)?; + let event_device_fd = unsafe { + open( + event_device_node.as_ptr() as *const i8, + libc::O_RDONLY | libc::O_NONBLOCK, + ) + }; + if event_device_fd < 0 { + return Err(io::Error::last_os_error()); + } + + Ok(XboxGamepadDevice { + state: DeviceState { + uinput_fd: fd, + sysname, + device_name: name.to_string(), + event_device_node, + event_device_fd, + events: Vec::new(), + }, + }) } - fn destroy(fd: i32) { + fn destroy(self) { unsafe { - ui_dev_destroy(fd).unwrap_or_else(|e| { + ui_dev_destroy(self.state.uinput_fd).unwrap_or_else(|e| { eprintln!("ui_dev_destroy failed: {:?}", e); std::process::exit(1); }); - close(fd); + close(self.state.uinput_fd); + close(self.state.event_device_fd); } } } - -use libc::{c_char, close}; -use std::ffi::CString; - -pub const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; -pub const BUS_USB: u16 = 0x03; diff --git a/vuinputd-tests/src/lib.rs b/vuinputd-tests/src/lib.rs index c3afdc5..0e1a7af 100644 --- a/vuinputd-tests/src/lib.rs +++ b/vuinputd-tests/src/lib.rs @@ -2,10 +2,10 @@ // // Author: Johannes Leupolz -pub mod devices; -pub mod scenarios; pub mod bwrap; +pub mod devices; pub mod ipc; pub mod podman; pub mod run_vuinputd; +pub mod scenarios; pub mod test_log; diff --git a/vuinputd-tests/src/scenarios/basic_keyboard.rs b/vuinputd-tests/src/scenarios/basic_keyboard.rs index 2abfd85..2641187 100644 --- a/vuinputd-tests/src/scenarios/basic_keyboard.rs +++ b/vuinputd-tests/src/scenarios/basic_keyboard.rs @@ -6,7 +6,7 @@ use std::thread; use std::time::Duration; use crate::devices::keyboard::KeyboardDevice; -use crate::devices::{utils, Device}; +use crate::devices::{Device, EV_KEY}; use crate::scenarios::ScenarioArgs; use crate::test_log::TestLog; @@ -20,26 +20,21 @@ impl BasicKeyboard { .dev_path .clone() .unwrap_or_else(|| "/dev/uinput".to_string()); - let fd = KeyboardDevice::setup(Some(&device), "Example Keyboard")?; - let sysname = KeyboardDevice::create(fd)?; - eprintln!("sysname: {}", sysname); + let mut keyboard = KeyboardDevice::create(Some(&device), "Example Keyboard")?; + eprintln!("sysname: {}", keyboard.sysname()); thread::sleep(Duration::from_secs(1)); - let event_device = std::fs::OpenOptions::new() - .read(true) - .open(&utils::fetch_device_node(&sysname)?)?; - - let ev1 = utils::emit_read_and_log(fd, &event_device, 0x01, KEY_SPACE, 1)?; - let ev2 = utils::emit_read_and_log(fd, &event_device, 0x01, KEY_SPACE, 0)?; + let _ev1 = keyboard.emit_read_and_log(EV_KEY, KEY_SPACE, 1)?; + let _ev2 = keyboard.emit_read_and_log(EV_KEY, KEY_SPACE, 0)?; let eventlog = TestLog { - events: vec![ev1, ev2], + events: keyboard.event_log().to_vec(), }; let serialized = serde_json::to_string(&eventlog).unwrap(); println!("Event log: {}", serialized); - KeyboardDevice::destroy(fd); + KeyboardDevice::destroy(keyboard); Ok(()) } } diff --git a/vuinputd-tests/src/scenarios/basic_mouse.rs b/vuinputd-tests/src/scenarios/basic_mouse.rs index be23d64..5643451 100644 --- a/vuinputd-tests/src/scenarios/basic_mouse.rs +++ b/vuinputd-tests/src/scenarios/basic_mouse.rs @@ -5,7 +5,7 @@ use std::thread; use std::time::Duration; use crate::devices::mouse::MouseDevice; -use crate::devices::{utils, Device}; +use crate::devices::{Device, EV_KEY}; use crate::scenarios::ScenarioArgs; use crate::test_log::{LoggedInputEvent, TestLog}; @@ -20,26 +20,21 @@ impl BasicMouse { .clone() .unwrap_or_else(|| "/dev/uinput".to_string()); - let fd = MouseDevice::setup(Some(&device), "Example Mouse")?; - let sysname = MouseDevice::create(fd)?; - eprintln!("sysname: {}", sysname); + let mut mouse = MouseDevice::create(Some(&device), "Example Mouse")?; + eprintln!("sysname: {}", mouse.sysname()); thread::sleep(Duration::from_secs(1)); - let event_device = std::fs::OpenOptions::new() - .read(true) - .open(&utils::fetch_device_node(&sysname)?)?; - - let ev1 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_LEFT, 1)?; - let ev2 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_LEFT, 0)?; + let _ev1 = mouse.emit_read_and_log(EV_KEY, BTN_LEFT, 1)?; + let _ev2 = mouse.emit_read_and_log(EV_KEY, BTN_LEFT, 0)?; let eventlog = TestLog { - events: vec![ev1, ev2], + events: mouse.event_log().to_vec(), }; let serialized = serde_json::to_string(&eventlog).unwrap(); println!("Event log: {}", serialized); - MouseDevice::destroy(fd); + MouseDevice::destroy(mouse); Ok(()) } } diff --git a/vuinputd-tests/src/scenarios/basic_ps4_gamepad.rs b/vuinputd-tests/src/scenarios/basic_ps4_gamepad.rs index 3224b76..e7e7ee5 100644 --- a/vuinputd-tests/src/scenarios/basic_ps4_gamepad.rs +++ b/vuinputd-tests/src/scenarios/basic_ps4_gamepad.rs @@ -5,7 +5,7 @@ use std::thread; use std::time::Duration; use crate::devices::ps4_gamepad::Ps4GamepadDevice; -use crate::devices::{utils, Device}; +use crate::devices::{Device, EV_KEY}; use crate::scenarios::ScenarioArgs; use crate::test_log::{LoggedInputEvent, TestLog}; @@ -20,26 +20,21 @@ impl BasicPs4Gamepad { .clone() .unwrap_or_else(|| "/dev/uinput".to_string()); - let fd = Ps4GamepadDevice::setup(Some(&device), "PS4 Gamepad")?; - let sysname = Ps4GamepadDevice::create(fd)?; - eprintln!("sysname: {}", sysname); + let mut gamepad = Ps4GamepadDevice::create(Some(&device), "PS4 Gamepad")?; + eprintln!("sysname: {}", gamepad.sysname()); thread::sleep(Duration::from_secs(1)); - let event_device = std::fs::OpenOptions::new() - .read(true) - .open(&utils::fetch_device_node(&sysname)?)?; - - let ev1 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_SOUTH, 1)?; - let ev2 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_SOUTH, 0)?; + let _ev1 = gamepad.emit_read_and_log(EV_KEY, BTN_SOUTH, 1)?; + let _ev2 = gamepad.emit_read_and_log(EV_KEY, BTN_SOUTH, 0)?; let eventlog = TestLog { - events: vec![ev1, ev2], + events: gamepad.event_log().to_vec(), }; let serialized = serde_json::to_string(&eventlog).unwrap(); println!("Event log: {}", serialized); - Ps4GamepadDevice::destroy(fd); + Ps4GamepadDevice::destroy(gamepad); Ok(()) } } diff --git a/vuinputd-tests/src/scenarios/basic_xbox_gamepad.rs b/vuinputd-tests/src/scenarios/basic_xbox_gamepad.rs index d7fbb74..48cd156 100644 --- a/vuinputd-tests/src/scenarios/basic_xbox_gamepad.rs +++ b/vuinputd-tests/src/scenarios/basic_xbox_gamepad.rs @@ -6,7 +6,7 @@ use std::thread; use std::time::Duration; use crate::devices::xbox_gamepad::XboxGamepadDevice; -use crate::devices::{utils, Device}; +use crate::devices::{Device, EV_KEY}; use crate::scenarios::ScenarioArgs; use crate::test_log::{LoggedInputEvent, TestLog}; @@ -21,26 +21,21 @@ impl BasicXboxGamepad { .clone() .unwrap_or_else(|| "/dev/uinput".to_string()); - let fd = XboxGamepadDevice::setup(Some(&device), "Xbox Gamepad")?; - let sysname = XboxGamepadDevice::create(fd)?; - eprintln!("sysname: {}", sysname); + let mut gamepad = XboxGamepadDevice::create(Some(&device), "Xbox Gamepad")?; + eprintln!("sysname: {}", gamepad.sysname()); thread::sleep(Duration::from_secs(1)); - let event_device = std::fs::OpenOptions::new() - .read(true) - .open(&utils::fetch_device_node(&sysname)?)?; - - let ev1 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_A, 1)?; - let ev2 = utils::emit_read_and_log(fd, &event_device, 0x01, BTN_A, 0)?; + let _ev1 = gamepad.emit_read_and_log(EV_KEY, BTN_A, 1)?; + let _ev2 = gamepad.emit_read_and_log(EV_KEY, BTN_A, 0)?; let eventlog = TestLog { - events: vec![ev1, ev2], + events: gamepad.event_log().to_vec(), }; let serialized = serde_json::to_string(&eventlog).unwrap(); println!("Event log: {}", serialized); - XboxGamepadDevice::destroy(fd); + XboxGamepadDevice::destroy(gamepad); Ok(()) } } diff --git a/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs b/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs new file mode 100644 index 0000000..de255ad --- /dev/null +++ b/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::thread; +use std::time::Duration; + +use crate::devices::xbox_gamepad::XboxGamepadDevice; +use crate::devices::{Device, EV_FF}; +use crate::scenarios::ScenarioArgs; +use crate::test_log::{LoggedInputEvent, TestLog}; + +const BTN_A: u16 = 304; + +pub struct FfXboxGamepad; + +impl FfXboxGamepad { + pub fn run(args: &ScenarioArgs) -> Result<(), std::io::Error> { + let device = args + .dev_path + .clone() + .unwrap_or_else(|| "/dev/uinput".to_string()); + + let mut gamepad = XboxGamepadDevice::create(Some(&device), "Xbox Gamepad")?; + eprintln!("sysname: {}", gamepad.sysname()); + + thread::sleep(Duration::from_secs(1)); + + let effect = libc::ff_effect { + type_: todo!(), + id: todo!(), + direction: todo!(), + trigger: todo!(), + replay: todo!(), + u: todo!(), + }; + + let _ev_play_effect = gamepad.emit_read_and_log(EV_FF, effect.id.try_into().unwrap(), 3)?; + thread::sleep(Duration::from_secs(1)); + let _ev_stop_effect = gamepad.emit_read_and_log(EV_FF, effect.id.try_into().unwrap(), 0)?; + + let eventlog = TestLog { + events: gamepad.event_log().to_vec(), + }; + let serialized = serde_json::to_string(&eventlog).unwrap(); + println!("Event log: {}", serialized); + + XboxGamepadDevice::destroy(gamepad); + Ok(()) + } +} diff --git a/vuinputd-tests/src/scenarios/mod.rs b/vuinputd-tests/src/scenarios/mod.rs index 1e82bd8..1d3d2f0 100644 --- a/vuinputd-tests/src/scenarios/mod.rs +++ b/vuinputd-tests/src/scenarios/mod.rs @@ -6,6 +6,7 @@ pub mod basic_keyboard; pub mod basic_mouse; pub mod basic_ps4_gamepad; pub mod basic_xbox_gamepad; +pub mod ff_xbox_gamepad; /* pub mod reuse_keyboard; pub mod reuse_xbox_gamepad; @@ -18,6 +19,7 @@ pub use basic_keyboard::BasicKeyboard; pub use basic_mouse::BasicMouse; pub use basic_ps4_gamepad::BasicPs4Gamepad; pub use basic_xbox_gamepad::BasicXboxGamepad; +pub use ff_xbox_gamepad::FfXboxGamepad; /* pub use reuse_keyboard::ReuseKeyboard; pub use reuse_xbox_gamepad::ReuseXboxGamepad; diff --git a/vuinputd-tests/src/test_log.rs b/vuinputd-tests/src/test_log.rs index 80e9fc2..940fd0d 100644 --- a/vuinputd-tests/src/test_log.rs +++ b/vuinputd-tests/src/test_log.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct LoggedInputEvent { pub tv_sec: i64, From 21bcff7fa3fa4c523d292febec6f9f4a78452518 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 31 Mar 2026 21:21:26 +0000 Subject: [PATCH 078/108] Add simple force feedback test scenario in test-scenarios --- vuinputd-tests/src/devices/device_base.rs | 14 ++- vuinputd-tests/src/devices/keyboard.rs | 2 +- vuinputd-tests/src/devices/mouse.rs | 2 +- vuinputd-tests/src/devices/ps4_gamepad.rs | 2 +- vuinputd-tests/src/devices/xbox_gamepad.rs | 91 ++++++++++++++++++- .../src/scenarios/ff_xbox_gamepad.rs | 39 +++++--- 6 files changed, 129 insertions(+), 21 deletions(-) diff --git a/vuinputd-tests/src/devices/device_base.rs b/vuinputd-tests/src/devices/device_base.rs index 314bda3..e239a54 100644 --- a/vuinputd-tests/src/devices/device_base.rs +++ b/vuinputd-tests/src/devices/device_base.rs @@ -3,7 +3,7 @@ // Author: Johannes Leupolz use crate::test_log::LoggedInputEvent; -use libc::{c_int, close, open, write, O_NONBLOCK, O_WRONLY}; +use libc::{c_int, close, open, write, O_NONBLOCK, O_RDWR, O_WRONLY}; use libc::{input_event, timespec, uinput_setup, CLOCK_MONOTONIC}; use std::ffi::{CStr, CString}; use std::fs::File; @@ -107,12 +107,20 @@ pub trait Device: Sized { } /// Setup the uinput device (calls ui_dev_setup and ui_get_sysname) - fn setup_device(&self, name: &str, vendor: u16, product: u16, bustype: u16) -> io::Result<()> { + fn setup_device( + &self, + name: &str, + vendor: u16, + product: u16, + bustype: u16, + ff_effects_max: u32, + ) -> io::Result<()> { unsafe { let mut usetup: uinput_setup = zeroed(); usetup.id.bustype = bustype; usetup.id.vendor = vendor; usetup.id.product = product; + usetup.ff_effects_max = ff_effects_max; let name_cstr = CString::new(name).unwrap(); let name_ptr = usetup.name.as_mut_ptr() as *mut c_char; @@ -237,7 +245,7 @@ pub fn open_uinput(device: Option<&str>) -> io::Result { }; let path = std::ffi::CString::new(device).unwrap(); - let fd = unsafe { open(path.as_ptr(), O_WRONLY | O_NONBLOCK) }; + let fd = unsafe { open(path.as_ptr(), O_RDWR | O_NONBLOCK) }; if fd < 0 { eprintln!("error opening uinput"); return Err(io::Error::last_os_error()); diff --git a/vuinputd-tests/src/devices/keyboard.rs b/vuinputd-tests/src/devices/keyboard.rs index 49b7c1d..2d6eb01 100644 --- a/vuinputd-tests/src/devices/keyboard.rs +++ b/vuinputd-tests/src/devices/keyboard.rs @@ -298,7 +298,7 @@ impl Device for KeyboardDevice { events: Vec::new(), }, }; - temp_device.setup_device(name, 0xbeef, 0xdead, BUS_USB)?; + temp_device.setup_device(name, 0xbeef, 0xdead, BUS_USB, 0)?; unsafe { ui_dev_create(fd).map_err(|e| { diff --git a/vuinputd-tests/src/devices/mouse.rs b/vuinputd-tests/src/devices/mouse.rs index 60a1ed4..b0bf082 100644 --- a/vuinputd-tests/src/devices/mouse.rs +++ b/vuinputd-tests/src/devices/mouse.rs @@ -67,7 +67,7 @@ impl Device for MouseDevice { events: Vec::new(), }, }; - temp_device.setup_device(name, 0xbeef, 0xdead, BUS_USB)?; + temp_device.setup_device(name, 0xbeef, 0xdead, BUS_USB, 0)?; unsafe { ui_dev_create(fd).map_err(|e| { diff --git a/vuinputd-tests/src/devices/ps4_gamepad.rs b/vuinputd-tests/src/devices/ps4_gamepad.rs index 11f02b0..bc5c03a 100644 --- a/vuinputd-tests/src/devices/ps4_gamepad.rs +++ b/vuinputd-tests/src/devices/ps4_gamepad.rs @@ -133,7 +133,7 @@ impl Device for Ps4GamepadDevice { events: Vec::new(), }, }; - temp_device.setup_device(name, 0xbeef, 0xdead, BUS_USB)?; + temp_device.setup_device(name, 0xbeef, 0xdead, BUS_USB, 10)?; unsafe { ui_dev_create(fd).map_err(|e| { diff --git a/vuinputd-tests/src/devices/xbox_gamepad.rs b/vuinputd-tests/src/devices/xbox_gamepad.rs index 19ba583..69d3a23 100644 --- a/vuinputd-tests/src/devices/xbox_gamepad.rs +++ b/vuinputd-tests/src/devices/xbox_gamepad.rs @@ -3,7 +3,8 @@ // Author: Johannes Leupolz use crate::devices::device_base::{fetch_device_node, open_uinput, Device, DeviceState, BUS_USB}; -use libc::{c_int, close, open}; +use libc::{c_int, close, ff_effect, input_event, open, uinput_ff_upload}; +use nix::{ioctl_write_int, ioctl_write_ptr}; use std::io; use uinput_ioctls::*; @@ -42,6 +43,15 @@ pub const FF_RAMP: u16 = 0x57; pub const FF_SINE: u16 = 0x5a; pub const FF_GAIN: u16 = 0x60; +const EV_UINPUT: u16 = 0x0101; +const UI_FF_UPLOAD: u16 = 1; +const UI_FF_ERASE: u16 = 2; + +// EVIOCSFF ioctl command for Force Feedback Upload +ioctl_write_ptr!(eviocsff, b'E', 0x80, ff_effect); +// EVIOCRMFF ioctl command for Force Feedback erase +ioctl_write_int!(eviocrmff, b'E', 0x81); + /// Setup Xbox gamepad device /// https://github.com/LizardByte/Sunshine/blob/master/src/platform/linux/input/inputtino_gamepad.cpp /// https://github.com/games-on-whales/inputtino/blob/stable/src/uinput/joypad_xbox.cpp @@ -161,6 +171,36 @@ unsafe fn setup_xbox_gamepad(fd: c_int) -> io::Result<()> { Ok(()) } +/// Generates the opaque `u` array for a rumble effect on 64-bit systems +#[cfg(target_pointer_width = "64")] +pub fn create_rumble_array(strong_magnitude: u16, weak_magnitude: u16) -> [u64; 4] { + let mut u = [0u64; 4]; + + // Create an 8-byte array representing the memory of a u64 + let mut bytes = [0u8; 8]; + + // Place the strong magnitude at offset 0 and weak at offset 2 + // using native endianness to match exactly what the kernel expects. + bytes[0..2].copy_from_slice(&strong_magnitude.to_ne_bytes()); + bytes[2..4].copy_from_slice(&weak_magnitude.to_ne_bytes()); + + // Convert those bytes back into a native u64 and place it in the union array + u[0] = u64::from_ne_bytes(bytes); + + u +} + +/// Upload a force feedback effect to the device +/// Returns the effect id on success +pub fn upload_effect(fd: c_int, effect: *mut ff_effect) -> io::Result { + unsafe { + eviocsff(fd, effect).unwrap(); + } + // Effect id is saved as effect.id + let id = unsafe { (*effect).id }; + Ok(id) +} + pub struct XboxGamepadDevice { state: DeviceState, } @@ -197,7 +237,7 @@ impl Device for XboxGamepadDevice { events: Vec::new(), }, }; - temp_device.setup_device(name, 0xbeef, 0xdead, BUS_USB)?; + temp_device.setup_device(name, 0xbeef, 0xdead, BUS_USB, 10)?; unsafe { ui_dev_create(fd).map_err(|e| { @@ -242,3 +282,50 @@ impl Device for XboxGamepadDevice { } } } + +impl XboxGamepadDevice { + pub fn read_process_ff_event_from_uinput(&self) { + // Copy the i32 file descriptor so we can move it into the thread safely + let fd = self.state().uinput_fd; + + std::thread::spawn(move || { + // Buffer for the raw bytes + let mut buffer = [0u8; 256]; + + // Calling C functions always requires an unsafe block + let result = + unsafe { libc::read(fd, buffer.as_mut_ptr() as *mut libc::c_void, buffer.len()) }; + + // libc::read returns an isize (ssize_t in C) + if result < 0 { + // result < 0 means an error occurred. We use std::io::Error::last_os_error() + // to get the correct OS error message based on the C `errno`. + eprintln!( + "Error reading in thread: {}", + std::io::Error::last_os_error() + ); + return; + } else if result == 0 { + // 0 bytes usually means End-Of-File (EOF) or that the device was closed + println!("0 bytes (EOF) - Terminating thread"); + return; + } else if result == 24 { + println!("read_process_ff_event_from_uinput: processing input event (read)"); + let input_event = buffer.as_ptr() as *const libc::input_event; + let input_event = unsafe { *input_event }; + if input_event.type_ == EV_UINPUT && input_event.code == UI_FF_UPLOAD { + let mut upload: uinput_ff_upload = unsafe { std::mem::zeroed() }; + upload.request_id = input_event.value.try_into().unwrap(); + unsafe { + let ptr = &mut upload as *mut uinput_ff_upload; + ui_begin_ff_upload(fd, ptr).unwrap(); + println!("effect type: {}", upload.effect.type_); + ui_end_ff_upload(fd, ptr).unwrap(); + }; + } + } else { + println!("Read {} bytes", result); + } + }); + } +} diff --git a/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs b/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs index de255ad..91ab78b 100644 --- a/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs +++ b/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs @@ -5,12 +5,11 @@ use std::thread; use std::time::Duration; -use crate::devices::xbox_gamepad::XboxGamepadDevice; +use crate::devices::xbox_gamepad::{self, upload_effect, XboxGamepadDevice, FF_RUMBLE}; use crate::devices::{Device, EV_FF}; use crate::scenarios::ScenarioArgs; use crate::test_log::{LoggedInputEvent, TestLog}; - -const BTN_A: u16 = 304; +use libc::{self, ff_effect, ff_replay, ff_trigger}; pub struct FfXboxGamepad; @@ -26,18 +25,32 @@ impl FfXboxGamepad { thread::sleep(Duration::from_secs(1)); - let effect = libc::ff_effect { - type_: todo!(), - id: todo!(), - direction: todo!(), - trigger: todo!(), - replay: todo!(), - u: todo!(), - }; + eprintln!("upload a simple RUMBLE effect"); + let mut effect: ff_effect = unsafe { std::mem::zeroed() }; + effect.type_ = FF_RUMBLE; + effect.id = -1; // new effect + effect.direction = 0; + effect.trigger.button = 0; + effect.trigger.interval = 0; + effect.replay.length = 5000; + effect.replay.delay = 1000; + effect.u = xbox_gamepad::create_rumble_array(0x8000, 0x0); - let _ev_play_effect = gamepad.emit_read_and_log(EV_FF, effect.id.try_into().unwrap(), 3)?; + // ensure uploaded effect gets processed + gamepad.read_process_ff_event_from_uinput(); + // Upload effect via ioctl + let effect_id = upload_effect(gamepad.state().event_device_fd, &mut effect)?; + + eprintln!("Uploaded effect with id: {} {}", effect_id, effect.id); + thread::sleep(Duration::from_secs(1)); + + // Play effect (value=1) + let _play_effect_event = + gamepad.emit_read_and_log(EV_FF, effect_id.try_into().unwrap(), 1)?; + thread::sleep(Duration::from_secs(1)); + let _stop_effect_event = + gamepad.emit_read_and_log(EV_FF, effect_id.try_into().unwrap(), 0)?; thread::sleep(Duration::from_secs(1)); - let _ev_stop_effect = gamepad.emit_read_and_log(EV_FF, effect.id.try_into().unwrap(), 0)?; let eventlog = TestLog { events: gamepad.event_log().to_vec(), From 33b0a016c360b64484eef1e0e25a6838c4bcc891 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 31 Mar 2026 21:39:03 +0000 Subject: [PATCH 079/108] Add integration test for vibration/force feedback functionality --- README.md | 2 ++ vuinputd-tests/tests/integration_tests.rs | 38 +++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/README.md b/README.md index dcfe264..860f546 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ It reliably demonstrates the core concept — exposing `/dev/uinput` devices ins ### ✅ Goals for Production Readiness +* [ ] **Vibration/Force Feedback support:** + * [ ] **Steam input support:** Steam input is not supported, yet. For some strange reasons, steam creates 16 virtual devices. Maybe a race. diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index c88bc04..e0da2e0 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -193,3 +193,41 @@ fn test_keyboard_in_container_with_vuinput_placement_on_host() { assert!(out.status.success()); } + + +#[ignore = "not implemented yet"] +#[cfg(all( + feature = "requires-privileges", + feature = "requires-uinput", + feature = "requires-bwrap" +))] +#[test] +fn test_gamepad_with_ff_in_container() { + let _guard: run_vuinputd::VuinputdGuard = run_vuinputd::ensure_vuinputd_running(&[]); + + let test_scenarios = env!("CARGO_BIN_EXE_test-scenarios"); + + let (builder, _ipc) = bwrap::BwrapBuilder::new() + .unshare_net() + .ro_bind("/", "/") + .tmpfs("/tmp") + // dev needs to be writable for the new devices + .dev() + // run needs to be writable for the udev devices + .tmpfs("/run") + .dev_bind("/dev/vuinput-test", "/dev/uinput") + .die_with_parent() + .with_ipc() + .expect("failed to create IPC"); + + let out = builder + .command(test_scenarios, &["--ipc","ff-xbox-gamepad"]) + .run() + .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); + + assert!(out.status.success()); +} \ No newline at end of file From 6f60635ff2a12149e9d9b1b981daa3383a0cbbe3 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 1 Apr 2026 09:57:09 +0000 Subject: [PATCH 080/108] Implement vuinput_read for vibration support. poll missing --- vuinputd/src/cuse_device/mod.rs | 3 +- vuinputd/src/cuse_device/vuinput_read.rs | 66 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 vuinputd/src/cuse_device/vuinput_read.rs diff --git a/vuinputd/src/cuse_device/mod.rs b/vuinputd/src/cuse_device/mod.rs index bb71a3f..6a92c1b 100644 --- a/vuinputd/src/cuse_device/mod.rs +++ b/vuinputd/src/cuse_device/mod.rs @@ -6,6 +6,7 @@ pub mod device_policy; pub mod state; pub mod vuinput_ioctl; pub mod vuinput_open; +pub mod vuinput_read; pub mod vuinput_release; pub mod vuinput_write; @@ -29,7 +30,7 @@ pub fn vuinput_make_cuse_ops() -> cuse_lowlevel::cuse_lowlevel_ops { init_done: None, destroy: None, open: Some(vuinput_open::vuinput_open), - read: None, + read: Some(vuinput_read::vuinput_read), write: Some(vuinput_write::vuinput_write), flush: None, release: Some(vuinput_release::vuinput_release), diff --git a/vuinputd/src/cuse_device/vuinput_read.rs b/vuinputd/src/cuse_device/vuinput_read.rs new file mode 100644 index 0000000..3c47048 --- /dev/null +++ b/vuinputd/src/cuse_device/vuinput_read.rs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use crate::cuse_device::*; +use crate::global_config::get_device_policy; +use ::cuse_lowlevel::*; +use libc::{__s32, __u16, input_event}; +use libc::{off_t, size_t, EIO}; +use libc::{uinput_abs_setup, uinput_setup}; +use log::{debug, trace}; +use std::io::{Read, Write}; +use std::os::fd::AsRawFd; +use std::os::raw::c_char; +use uinput_ioctls::*; + +// TODO: compat-mode+ ensure sizeof(struct input_event) +pub unsafe extern "C" fn vuinput_read( + _req: fuse_lowlevel::fuse_req_t, + _size: size_t, + _off: off_t, + _fi: *mut fuse_lowlevel::fuse_file_info, +) { + assert!( + _off == 0, + "vuinput_read: offset needs to be 0 but is {}", + _off + ); + + let fh = &(*_fi).fh; + let vuinput_state_mutex = + get_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap(); + let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); + + let normal_size = std::mem::size_of::(); + let is_compat = vuinput_state.requesting_process.is_compat; + // TODO: ARM: && !compat_uses_64bit_time() + + let mut buffer: [u8; 24] = [0; 24]; + + // read up to 24 bytes + let result = vuinput_state.file.read(&mut buffer); + match result { + Ok(normal_size) => { + if (!is_compat) { + let buffer = buffer.as_ptr() as *const i8; + fuse_lowlevel::fuse_reply_buf(_req, buffer, 24); + } else { + debug!( + "fh {}: error reading from uinput: not implemented yet for 32 bit users", + fh + ); + // details how to implement it can be found in vuinput_write.rs + fuse_lowlevel::fuse_reply_err(_req, EIO); + } + } + Err(e) => { + debug!("fh {}: error reading from uinput: {e:?}", fh); + fuse_lowlevel::fuse_reply_err(_req, EIO); + } + Ok(_) => { + debug!("fh {}: error reading from uinput: wrong size", fh); + fuse_lowlevel::fuse_reply_err(_req, EIO); + } + } +} From 8d4a0c9413acc7d523db6d7bdd91bef219a624ec Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 2 Apr 2026 22:18:41 +0000 Subject: [PATCH 081/108] Started to implement poll --- .github/workflows/debian-package.yml | 2 +- debian/control | 1 + docs/DESIGN.md | 83 ++++++++++++ .../src/scenarios/ff_xbox_gamepad.rs | 1 + vuinputd/Cargo.toml | 8 ++ .../src/cuse_device/evdev_write_watcher.rs | 121 ++++++++++++++++++ vuinputd/src/cuse_device/mod.rs | 3 + vuinputd/src/cuse_device/state.rs | 48 +++++++ vuinputd/src/cuse_device/vuinput_open.rs | 17 ++- vuinputd/src/cuse_device/vuinput_poll.rs | 33 +++++ vuinputd/src/cuse_device/vuinput_read.rs | 11 +- vuinputd/src/cuse_device/vuinput_release.rs | 14 +- vuinputd/src/main.rs | 8 ++ 13 files changed, 343 insertions(+), 7 deletions(-) create mode 100644 vuinputd/src/cuse_device/evdev_write_watcher.rs create mode 100644 vuinputd/src/cuse_device/vuinput_poll.rs diff --git a/.github/workflows/debian-package.yml b/.github/workflows/debian-package.yml index c2d8bcb..aac9722 100644 --- a/.github/workflows/debian-package.yml +++ b/.github/workflows/debian-package.yml @@ -37,7 +37,7 @@ jobs: fuse3 \ libclang-dev # debian packages, if packages are not downloaded via cargo - sudo apt install -y librust-bindgen-dev librust-nix-dev librust-libc-dev librust-time-dev librust-log-dev librust-env-logger-dev librust-libudev-dev librust-regex-dev librust-async-channel-dev librust-futures-dev librust-async-io-dev librust-anyhow-dev librust-clap-dev librust-base64-dev + sudo apt install -y librust-bindgen-dev librust-nix-dev librust-libc-dev librust-time-dev librust-log-dev librust-env-logger-dev librust-libudev-dev librust-regex-dev librust-async-channel-dev librust-futures-dev librust-async-io-dev librust-anyhow-dev librust-clap-dev librust-base64-dev librust-smallvec-dev - name: Show versions (debug) run: | diff --git a/debian/control b/debian/control index 0a52053..4646dfa 100644 --- a/debian/control +++ b/debian/control @@ -26,6 +26,7 @@ Build-Depends: debhelper (>= 12), librust-anyhow-dev, librust-clap-dev, librust-base64-dev, + librust-smallvec-dev, pkg-config, build-essential Standards-Version: 4.6.0 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 16bc56e..77f42ef 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -397,6 +397,14 @@ When mapping 32-bit compat input_event formats into 64-bit representation, copy No high volume of events expected where we could benefit from multiple threads. But much of the code is already prepared for multithreading, if there is really demand. +**Poll / event readiness handling** + +- For operations that wait on host device readiness (e.g., force feedback, rumble, vibration, or reading back event state), the CUSE callback must **never block**. +- A **poll/wakeup watcher** in a background thread monitors underlying `/dev/uinput` FDs and updates per-handle readiness (`PollState`) in `VuInputState` (see section 3.13). +- FUSE poll callbacks may save the provided poll handle and immediately return; the background watcher later invokes `fuse_notify_poll()` to wake the kernel when data arrives. + +*Why:* this separates the fast data-plane (CUSE callbacks) from the asynchronous event-plane (poll watcher) and prevents hanging the filesystem. + --- ## 3.9 Overriding the type, vendor id, and product id @@ -621,6 +629,80 @@ The filtering operates on two levels (Defense in Depth): * **`strict-gamepad` (Whitelist):** Designed for console-like isolation. It strictly permits only Gamepad/Joystick events (`EV_KEY` buttons, `EV_ABS` axes). It proactively blocks `EV_REL` (mouse movement) and `ABS_MT` (multitouch), effectively "neutering" complex controllers (like DualSense or Wiimotes) so they cannot be used to hijack the host mouse cursor. * **`sanitized` (Blacklist):** Designed for desktop gaming. It allows standard Keyboard and Mouse input but strictly filters dangerous keys (`KEY_SYSRQ`, `KEY_POWER`) and host-management shortcuts (VT switching, CAD), providing a safe "sandboxed keyboard." +--- + +## 3.13 Polling & Readiness Watcher + +**Purpose** + +- Provide non-blocking detection of device readiness on `/dev/vuinput` for operations like force feedback / rumble / vibration. +- Ensure CUSE callbacks (`poll`, `read`) never block and the FUSE filesystem remains responsive. + +**Core design** + +- Each `VuInputState` includes a `PollState` struct: + +```rust +#[derive(Debug, Default)] +pub struct PollState { + /// A FUSE poll request is currently waiting to be woken. + pub waiting: bool, + + /// Sticky readiness latch: true once evdev became readable, false after read/drain. + pub readable: bool, +} +``` + +* A **single background thread** watches all active uinput device file descriptors using **epoll** (or poll). +* When data arrives: + + * The watcher locks the corresponding `VuInputState` via `VUINPUT_STATE`. + * Marks `poll.readable = true`. + * If `poll.waiting = true`, the watcher clears the flag and optionally calls `fuse_notify_poll()` to wake any waiting FUSE poll requests. + +**Adding / removing devices** + +* When creating a new uinput device, the thread performs `epoll_ctl(ADD)` for the file descriptor. +* On device close, it performs `epoll_ctl(DEL)` to remove the descriptor from monitoring. +* No separate registry is maintained; the **global `VUINPUT_STATE` HashMap** is the single source of truth. + +**Poll callback behavior** + +* FUSE poll callbacks **do not block**: they may store the poll handle and immediately return. +* The background watcher ensures that any pending poll handles are notified asynchronously when data is ready. + +**Read handling** + +* Reads from `/dev/vuinput` are non-blocking: + + * `poll()` detects readiness. + * `read()` uses `O_NONBLOCK` and drains all available events. + * `EAGAIN` indicates the buffer is empty, at which point `poll.readable` is reset to false. + +**Threading / shutdown** + +* The background watcher uses a 500ms epoll_wait timeout to allow clean shutdown. +* It is safe to have a single watcher thread for all devices; epoll scales efficiently with multiple descriptors. + +**Benefits** + +* Fully non-blocking CUSE front-end. +* Lightweight: epoll manages file descriptors; no extra registry is needed. +* Correctly wakes FUSE poll requests for force feedback / rumble operations. +* Consistent with existing dispatcher rules: data-plane operations remain fast; control-plane updates scheduled as jobs if needed. + +Poll/read race rule: +`PollState` is the single source of truth for readiness and pending poll waiters. +Both `poll()` and `read()` must update it only while holding the per-handle `VuInputState` mutex. + +- `poll()` must first check `readable`; only if false may it enqueue a poll handle. +- the watcher sets `readable = true` and atomically drains pending waiters for notification. +- `read()` may clear `readable` only after draining the proxied evdev fd until `EAGAIN` (or equivalent proof of emptiness). + +This prevents lost wakeups and stale readiness in the proxy. + +--- + ## 4. Security Considerations `vuinputd` must currently run with **root privileges** to: @@ -645,6 +727,7 @@ While this design is necessary for mediation, it introduces potential attack sur * [ ] Use **seccomp** or `systemd` sandboxing (`ProtectSystem`, `ProtectKernelTunables`, `RestrictNamespaces`, etc.). * [ ] Eventually migrate to **Rust-native FUSE/Netlink** bindings to remove unsafe dependencies. +--- ## 5. Background: How are input devices created by the kernel using uinput diff --git a/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs b/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs index 91ab78b..0ab0a2e 100644 --- a/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs +++ b/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs @@ -11,6 +11,7 @@ use crate::scenarios::ScenarioArgs; use crate::test_log::{LoggedInputEvent, TestLog}; use libc::{self, ff_effect, ff_replay, ff_trigger}; +//TODO: poll and erase pub struct FfXboxGamepad; impl FfXboxGamepad { diff --git a/vuinputd/Cargo.toml b/vuinputd/Cargo.toml index 69852aa..ad9f3d5 100644 --- a/vuinputd/Cargo.toml +++ b/vuinputd/Cargo.toml @@ -30,3 +30,11 @@ clap = { version = "4", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" base64 = "0.22" +smallvec = "1.15.1" + +[features] +requires-privileges = [] +requires-rootless = [] +requires-uinput = [] +requires-bwrap = [] +requires-podman = [] \ No newline at end of file diff --git a/vuinputd/src/cuse_device/evdev_write_watcher.rs b/vuinputd/src/cuse_device/evdev_write_watcher.rs new file mode 100644 index 0000000..8ca7563 --- /dev/null +++ b/vuinputd/src/cuse_device/evdev_write_watcher.rs @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::{ + fs::File, + os::fd::{AsFd, BorrowedFd}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, OnceLock, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use anyhow::Context; + +use cuse_lowlevel::fuse_lowlevel; +use nix::sys::epoll::{Epoll, EpollCreateFlags, EpollEvent, EpollFlags}; + +use crate::cuse_device::state::{get_vuinput_state, VuFileHandle}; + +pub static EVDEV_WRITE_WATCHER: OnceLock> = OnceLock::new(); + +pub fn initialize_evdev_write_watcher() -> anyhow::Result<()> { + EVDEV_WRITE_WATCHER + .set(Mutex::new(EvdevWriteWatcher::new()?)) // Convert the error from Mutex to a simple string + .map_err(|_| anyhow::anyhow!("cell already full")) + // Now .context() works because &str is compatible + .context("failed to initialize evdev write watcher")?; + Ok(()) +} + +#[derive(Debug)] +pub struct EvdevWriteWatcher { + epoll: Arc, + shutdown: Arc, + thread_handle: Option>, +} + +impl EvdevWriteWatcher { + fn new() -> anyhow::Result { + let epoll = Arc::new(Epoll::new(EpollCreateFlags::empty())?); + let shutdown = Arc::new(AtomicBool::new(false)); + let epoll_thread = epoll.clone(); + let shutdown_thread = shutdown.clone(); + let thread_handle = Some(thread::spawn(move || { + evdev_write_watch_loop(shutdown_thread, epoll_thread); + })); + Ok(Self { + thread_handle: thread_handle, + shutdown: shutdown, + epoll: epoll, + }) + } + + pub fn add_device(&self, vu_fh: VuFileHandle) -> nix::Result<()> { + let VuFileHandle::Fh(fh) = vu_fh; + + let vuinput_state_mutex = get_vuinput_state(&vu_fh).unwrap(); + let vuinput_state = vuinput_state_mutex.lock().unwrap(); + + self.epoll.add( + &vuinput_state.file, + EpollEvent::new(EpollFlags::EPOLLIN, fh), + ) + } + + pub fn remove_device(&self, uinput_fd: Fd) -> nix::Result<()> { + self.epoll.delete(uinput_fd) + } + + pub fn stop(&mut self) { + self.shutdown.store(true, Ordering::SeqCst); + + if let Some(handle) = self.thread_handle.take() { + let _ = handle.join(); + } + } + + pub fn is_running(&self) -> bool { + self.thread_handle.is_some() + } +} + +fn evdev_write_watch_loop(shutdown: Arc, epoll: Arc) { + let mut events = vec![EpollEvent::empty(); 64]; + + loop { + if shutdown.load(Ordering::SeqCst) { + break; + } + + let n = match epoll.wait(&mut events, 500u16) { + Ok(n) => n, + Err(err) => { + eprintln!("evdev_write_watcher: epoll_wait failed: {err}"); + thread::sleep(Duration::from_millis(100)); + continue; + } + }; + + for ev in &events[..n] { + let fh_val = ev.data() as u64; + let fh = VuFileHandle::Fh(fh_val); + let state = super::state::get_vuinput_state(&fh); + if let Ok(state) = state { + let mut state = state.lock().unwrap(); + + for handle in state.poll.take_waiters() { + unsafe { + fuse_lowlevel::fuse_lowlevel_notify_poll(handle.as_ptr()); + fuse_lowlevel::fuse_pollhandle_destroy(handle.as_ptr()); + } + } + state.poll.readable = true; + state.poll.pending.clear(); + } + } + } +} diff --git a/vuinputd/src/cuse_device/mod.rs b/vuinputd/src/cuse_device/mod.rs index 6a92c1b..fe3cb9d 100644 --- a/vuinputd/src/cuse_device/mod.rs +++ b/vuinputd/src/cuse_device/mod.rs @@ -3,9 +3,11 @@ // Author: Johannes Leupolz pub mod device_policy; +pub mod evdev_write_watcher; pub mod state; pub mod vuinput_ioctl; pub mod vuinput_open; +pub mod vuinput_poll; pub mod vuinput_read; pub mod vuinput_release; pub mod vuinput_write; @@ -36,6 +38,7 @@ pub fn vuinput_make_cuse_ops() -> cuse_lowlevel::cuse_lowlevel_ops { release: Some(vuinput_release::vuinput_release), fsync: None, ioctl: Some(vuinput_ioctl::vuinput_ioctl), + //poll: Some(vuinput_poll::vuinput_poll), poll: None, } } diff --git a/vuinputd/src/cuse_device/state.rs b/vuinputd/src/cuse_device/state.rs index 36a1410..1f1f3bc 100644 --- a/vuinputd/src/cuse_device/state.rs +++ b/vuinputd/src/cuse_device/state.rs @@ -4,11 +4,16 @@ use std::collections::HashMap; use std::fs::File; +use std::ptr::NonNull; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use ::cuse_lowlevel::*; +use smallvec::SmallVec; use crate::process_tools::RequestingProcess; +use smallvec::smallvec; + +pub type PendingPollHandles = SmallVec<[*mut fuse_lowlevel::fuse_pollhandle; 1]>; #[derive(Debug)] pub struct VuInputDevice { @@ -38,12 +43,55 @@ impl KeyTracker { } } +/// this data structure ensures poll and read are synchronized. +/// poll() and read() must synchronize through one shared readines +/// state, and the state transitions must be done under the same per-handle mutex. +/// Ensure, we have no lost-wakeup races like: +/// 1) watcher sets readable +/// 2) read() drains and clears readable +/// 3) poll() stores waiter too late +/// 4) nobody wakes it anymore +#[derive(Debug, Default)] +pub struct PollState { + /// Sticky readiness latch: + /// true once evdev became readable, false again after read/drain. + pub readable: bool, + + /// Pending FUSE poll waiters for this device. + /// Optimized for the common case of 0 or 1 waiter, but supports + /// multiple concurrent poll() callers correctly. + pub pending: SmallVec<[NonNull; 1]>, +} + +impl PollState { + pub fn new() -> PollState { + PollState { + readable: false, + pending: smallvec![], + } + } + pub fn has_waiters(&self) -> bool { + !self.pending.is_empty() + } + + pub fn add_waiter(&mut self, handle: NonNull) { + self.pending.push(handle); + } + + pub fn take_waiters(&mut self) -> SmallVec<[NonNull; 1]> { + std::mem::take(&mut self.pending) + } +} + +unsafe impl Send for PollState {} + #[derive(Debug)] pub struct VuInputState { pub file: File, pub requesting_process: RequestingProcess, pub input_device: Option, pub keytracker: KeyTracker, + pub poll: PollState, } #[derive(Debug, Eq, Hash, PartialEq, Clone)] diff --git a/vuinputd/src/cuse_device/vuinput_open.rs b/vuinputd/src/cuse_device/vuinput_open.rs index ae12c9f..532351a 100644 --- a/vuinputd/src/cuse_device/vuinput_open.rs +++ b/vuinputd/src/cuse_device/vuinput_open.rs @@ -5,13 +5,16 @@ use ::cuse_lowlevel::*; use libc::ENOENT; use libc::O_CLOEXEC; +use libc::O_NONBLOCK; use log::{debug, error}; use std::fs::OpenOptions; +use std::os::fd::AsFd; use std::os::unix::fs::OpenOptionsExt; use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::OnceLock; +use crate::cuse_device::evdev_write_watcher::EVDEV_WRITE_WATCHER; use crate::cuse_device::*; use crate::process_tools::{get_requesting_process, Pid}; @@ -43,21 +46,31 @@ pub unsafe extern "C" fn vuinput_open( let open_vuinput_result = OpenOptions::new() .read(true) .write(true) - //.custom_flags(O_NONBLOCK) + .custom_flags(O_NONBLOCK) .custom_flags(O_CLOEXEC) .open(Path::new("/dev/uinput")); match open_vuinput_result { Ok(v) => { + let vu_fh: VuFileHandle = VuFileHandle::Fh(fh); + let uinput_fd: std::os::unix::prelude::BorrowedFd<'_> = v.as_fd(); insert_vuinput_state( - &VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap()), + &vu_fh, VuInputState { file: v, requesting_process, input_device: None, keytracker: KeyTracker::new(), + poll: PollState::new(), }, ) .unwrap(); + EVDEV_WRITE_WATCHER + .get() + .unwrap() + .lock() + .unwrap() + .add_device(vu_fh) + .unwrap(); fuse_lowlevel::fuse_reply_open(_req, _fi); } Err(e) => { diff --git a/vuinputd/src/cuse_device/vuinput_poll.rs b/vuinputd/src/cuse_device/vuinput_poll.rs new file mode 100644 index 0000000..51af16f --- /dev/null +++ b/vuinputd/src/cuse_device/vuinput_poll.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use crate::cuse_device::*; +use crate::global_config::get_device_policy; +use ::cuse_lowlevel::*; +use libc::{__s32, __u16, input_event}; +use libc::{off_t, size_t, EIO}; +use libc::{uinput_abs_setup, uinput_setup}; +use log::{debug, trace}; +use std::io::{Read, Write}; +use std::os::fd::AsRawFd; +use std::os::raw::c_char; +use uinput_ioctls::*; + +// https://github.com/libfuse/libfuse/blob/master/example/poll.c +pub unsafe extern "C" fn vuinput_poll( + req: fuse_lowlevel::fuse_req_t, + fi: *mut fuse_lowlevel::fuse_file_info, + ph: *mut fuse_lowlevel::fuse_pollhandle, +) { + + /* + let vuinput_state_mutex = + get_vuinput_state(&VuFileHandle::from_fuse_file_info(fi.as_ref().unwrap())).unwrap(); + let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); + + if state.poll.readable { + // return POLLIN immediately + } else if let Some(handle) = NonNull::new(ph) { + state.poll.add_waiter(handle); */ +} diff --git a/vuinputd/src/cuse_device/vuinput_read.rs b/vuinputd/src/cuse_device/vuinput_read.rs index 3c47048..8f7dcdc 100644 --- a/vuinputd/src/cuse_device/vuinput_read.rs +++ b/vuinputd/src/cuse_device/vuinput_read.rs @@ -32,16 +32,17 @@ pub unsafe extern "C" fn vuinput_read( get_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap(); let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); - let normal_size = std::mem::size_of::(); + const NORMAL_SIZE: usize = std::mem::size_of::(); let is_compat = vuinput_state.requesting_process.is_compat; // TODO: ARM: && !compat_uses_64bit_time() let mut buffer: [u8; 24] = [0; 24]; // read up to 24 bytes + // todo: non-blocking or timeout let result = vuinput_state.file.read(&mut buffer); match result { - Ok(normal_size) => { + Ok(NORMAL_SIZE) => { if (!is_compat) { let buffer = buffer.as_ptr() as *const i8; fuse_lowlevel::fuse_reply_buf(_req, buffer, 24); @@ -63,4 +64,10 @@ pub unsafe extern "C" fn vuinput_read( fuse_lowlevel::fuse_reply_err(_req, EIO); } } + /* + if drained_to_eagain { + state.poll.readable = false; + } else { + state.poll.readable = true; + } */ } diff --git a/vuinputd/src/cuse_device/vuinput_release.rs b/vuinputd/src/cuse_device/vuinput_release.rs index 2857064..85dde27 100644 --- a/vuinputd/src/cuse_device/vuinput_release.rs +++ b/vuinputd/src/cuse_device/vuinput_release.rs @@ -2,12 +2,14 @@ // // Author: Johannes Leupolz +use crate::cuse_device::evdev_write_watcher::EVDEV_WRITE_WATCHER; use crate::job_engine::JOB_DISPATCHER; use crate::jobs::remove_device_job::RemoveDeviceJob; use crate::process_tools::SELF_NAMESPACES; use crate::{cuse_device::*, jobs}; use ::cuse_lowlevel::*; use log::debug; +use std::os::fd::AsFd; use std::sync::Arc; pub unsafe extern "C" fn vuinput_release( @@ -15,8 +17,8 @@ pub unsafe extern "C" fn vuinput_release( _fi: *mut fuse_lowlevel::fuse_file_info, ) { let fh = &(*_fi).fh; - let vuinput_state_mutex = - remove_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap(); + let vu_fh = VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap()); + let vuinput_state_mutex = remove_vuinput_state(&vu_fh).unwrap(); let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); let input_device = vuinput_state.input_device.take(); @@ -49,6 +51,14 @@ pub unsafe extern "C" fn vuinput_release( awaiter(&jobs::remove_device_job::State::Finished); } + EVDEV_WRITE_WATCHER + .get() + .unwrap() + .lock() + .unwrap() + .remove_device(vuinput_state.file.as_fd()) + .unwrap(); + drop(vuinput_state); debug!( diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index a6e69cc..199bd79 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -29,6 +29,9 @@ use std::sync::Mutex; pub mod cuse_device; +use crate::cuse_device::evdev_write_watcher::{ + initialize_evdev_write_watcher, 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; @@ -194,6 +197,9 @@ fn main() -> std::io::Result<()> { vt_tools::check_vt_status(); global_config::initialize_global_config(&args.device_policy, &args.placement, &args.devname); + initialize_evdev_write_watcher().expect( + "failed to initialize the watcher that watches for writes on the created evdev devices", + ); initialize_vuinput_state(); VUINPUT_COUNTER.set(AtomicU64::new(3)).expect( "failed to initialize the counter that provides the values of the CUSE file handles", @@ -280,5 +286,7 @@ fn main() -> std::io::Result<()> { .unwrap() .wait_until_finished(); + EVDEV_WRITE_WATCHER.get().unwrap().lock().unwrap().stop(); + Ok(()) } From 5cffaec3da1f61fbf4de67db6ec65d6eb842c4c0 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Fri, 3 Apr 2026 20:37:40 +0000 Subject: [PATCH 082/108] poll: First alpha implementation --- .../src/cuse_device/evdev_write_watcher.rs | 4 +-- vuinputd/src/cuse_device/mod.rs | 3 +-- vuinputd/src/cuse_device/state.rs | 22 +++++++++++++-- vuinputd/src/cuse_device/vuinput_poll.rs | 27 ++++++++++++------- vuinputd/src/cuse_device/vuinput_read.rs | 26 +++++++++--------- 5 files changed, 53 insertions(+), 29 deletions(-) diff --git a/vuinputd/src/cuse_device/evdev_write_watcher.rs b/vuinputd/src/cuse_device/evdev_write_watcher.rs index 8ca7563..485ec03 100644 --- a/vuinputd/src/cuse_device/evdev_write_watcher.rs +++ b/vuinputd/src/cuse_device/evdev_write_watcher.rs @@ -18,7 +18,7 @@ use anyhow::Context; use cuse_lowlevel::fuse_lowlevel; use nix::sys::epoll::{Epoll, EpollCreateFlags, EpollEvent, EpollFlags}; -use crate::cuse_device::state::{get_vuinput_state, VuFileHandle}; +use crate::cuse_device::state::{get_vuinput_state, PollPhase, VuFileHandle}; pub static EVDEV_WRITE_WATCHER: OnceLock> = OnceLock::new(); @@ -113,7 +113,7 @@ fn evdev_write_watch_loop(shutdown: Arc, epoll: Arc) { fuse_lowlevel::fuse_pollhandle_destroy(handle.as_ptr()); } } - state.poll.readable = true; + state.poll.pollphase = PollPhase::Readable; state.poll.pending.clear(); } } diff --git a/vuinputd/src/cuse_device/mod.rs b/vuinputd/src/cuse_device/mod.rs index fe3cb9d..209ec00 100644 --- a/vuinputd/src/cuse_device/mod.rs +++ b/vuinputd/src/cuse_device/mod.rs @@ -38,7 +38,6 @@ pub fn vuinput_make_cuse_ops() -> cuse_lowlevel::cuse_lowlevel_ops { release: Some(vuinput_release::vuinput_release), fsync: None, ioctl: Some(vuinput_ioctl::vuinput_ioctl), - //poll: Some(vuinput_poll::vuinput_poll), - poll: None, + poll: Some(vuinput_poll::vuinput_poll), } } diff --git a/vuinputd/src/cuse_device/state.rs b/vuinputd/src/cuse_device/state.rs index 1f1f3bc..7e9ce53 100644 --- a/vuinputd/src/cuse_device/state.rs +++ b/vuinputd/src/cuse_device/state.rs @@ -43,6 +43,24 @@ impl KeyTracker { } } +/// EMPTY -> READY -> READING -> { EMPTY | READY } +/// EMPTY -> READABLE (new data arrives / watcher can observe) +/// READABLE -> READING (read callback starts draining) +/// READING -> EMPTY (read drained everything) +/// READING -> READABLE (read finished, but more data still remains) +#[derive(Debug)] +pub enum PollPhase { + Empty, + Readable, + Reading, +} + +impl Default for PollPhase { + fn default() -> Self { + Self::Empty + } +} + /// this data structure ensures poll and read are synchronized. /// poll() and read() must synchronize through one shared readines /// state, and the state transitions must be done under the same per-handle mutex. @@ -55,7 +73,7 @@ impl KeyTracker { pub struct PollState { /// Sticky readiness latch: /// true once evdev became readable, false again after read/drain. - pub readable: bool, + pub pollphase: PollPhase, /// Pending FUSE poll waiters for this device. /// Optimized for the common case of 0 or 1 waiter, but supports @@ -66,7 +84,7 @@ pub struct PollState { impl PollState { pub fn new() -> PollState { PollState { - readable: false, + pollphase: PollPhase::Empty, pending: smallvec![], } } diff --git a/vuinputd/src/cuse_device/vuinput_poll.rs b/vuinputd/src/cuse_device/vuinput_poll.rs index 51af16f..507415d 100644 --- a/vuinputd/src/cuse_device/vuinput_poll.rs +++ b/vuinputd/src/cuse_device/vuinput_poll.rs @@ -12,6 +12,7 @@ use log::{debug, trace}; use std::io::{Read, Write}; use std::os::fd::AsRawFd; use std::os::raw::c_char; +use std::ptr::NonNull; use uinput_ioctls::*; // https://github.com/libfuse/libfuse/blob/master/example/poll.c @@ -20,14 +21,22 @@ pub unsafe extern "C" fn vuinput_poll( fi: *mut fuse_lowlevel::fuse_file_info, ph: *mut fuse_lowlevel::fuse_pollhandle, ) { + let vuinput_state_mutex = + get_vuinput_state(&VuFileHandle::from_fuse_file_info(fi.as_ref().unwrap())).unwrap(); + let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); - /* - let vuinput_state_mutex = - get_vuinput_state(&VuFileHandle::from_fuse_file_info(fi.as_ref().unwrap())).unwrap(); - let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); - - if state.poll.readable { - // return POLLIN immediately - } else if let Some(handle) = NonNull::new(ph) { - state.poll.add_waiter(handle); */ + match vuinput_state.poll.pollphase { + PollPhase::Empty => { + let ph = NonNull::::new(ph); + vuinput_state.poll.add_waiter(ph.unwrap()); + } + PollPhase::Readable => { + fuse_lowlevel::fuse_lowlevel_notify_poll(ph); + fuse_lowlevel::fuse_pollhandle_destroy(ph); + } + PollPhase::Reading => { + fuse_lowlevel::fuse_lowlevel_notify_poll(ph); + fuse_lowlevel::fuse_pollhandle_destroy(ph); + } + } } diff --git a/vuinputd/src/cuse_device/vuinput_read.rs b/vuinputd/src/cuse_device/vuinput_read.rs index 8f7dcdc..bc34dfa 100644 --- a/vuinputd/src/cuse_device/vuinput_read.rs +++ b/vuinputd/src/cuse_device/vuinput_read.rs @@ -3,15 +3,12 @@ // Author: Johannes Leupolz use crate::cuse_device::*; -use crate::global_config::get_device_policy; use ::cuse_lowlevel::*; -use libc::{__s32, __u16, input_event}; +use libc::{__s32, __u16, input_event, EAGAIN}; use libc::{off_t, size_t, EIO}; -use libc::{uinput_abs_setup, uinput_setup}; use log::{debug, trace}; use std::io::{Read, Write}; use std::os::fd::AsRawFd; -use std::os::raw::c_char; use uinput_ioctls::*; // TODO: compat-mode+ ensure sizeof(struct input_event) @@ -38,12 +35,12 @@ pub unsafe extern "C" fn vuinput_read( let mut buffer: [u8; 24] = [0; 24]; + vuinput_state.poll.pollphase = PollPhase::Reading; // read up to 24 bytes - // todo: non-blocking or timeout let result = vuinput_state.file.read(&mut buffer); match result { Ok(NORMAL_SIZE) => { - if (!is_compat) { + if !is_compat { let buffer = buffer.as_ptr() as *const i8; fuse_lowlevel::fuse_reply_buf(_req, buffer, 24); } else { @@ -56,18 +53,19 @@ pub unsafe extern "C" fn vuinput_read( } } Err(e) => { - debug!("fh {}: error reading from uinput: {e:?}", fh); - fuse_lowlevel::fuse_reply_err(_req, EIO); + if e.kind() == io::ErrorKind::WouldBlock { + // EAGAIN / EWOULDBLOCK + //println!("Received EAGAIN: The read would block!"); + vuinput_state.poll.pollphase = PollPhase::Empty; + fuse_lowlevel::fuse_reply_err(_req, EAGAIN); + } else { + debug!("fh {}: error reading from uinput: {e:?}", fh); + fuse_lowlevel::fuse_reply_err(_req, EIO); + } } Ok(_) => { debug!("fh {}: error reading from uinput: wrong size", fh); fuse_lowlevel::fuse_reply_err(_req, EIO); } } - /* - if drained_to_eagain { - state.poll.readable = false; - } else { - state.poll.readable = true; - } */ } From 96719c0a9c6226c46684a5eacba9b1370cfc40a8 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Fri, 3 Apr 2026 22:34:24 +0000 Subject: [PATCH 083/108] Fixes for polling and ff example. Not working, yet. --- vuinputd-tests/src/devices/xbox_gamepad.rs | 103 ++++++++++++------ .../src/scenarios/ff_xbox_gamepad.rs | 21 +++- vuinputd/src/cuse_device/vuinput_open.rs | 3 +- vuinputd/src/cuse_device/vuinput_poll.rs | 2 + 4 files changed, 86 insertions(+), 43 deletions(-) diff --git a/vuinputd-tests/src/devices/xbox_gamepad.rs b/vuinputd-tests/src/devices/xbox_gamepad.rs index 69d3a23..b14c3a3 100644 --- a/vuinputd-tests/src/devices/xbox_gamepad.rs +++ b/vuinputd-tests/src/devices/xbox_gamepad.rs @@ -3,9 +3,13 @@ // Author: Johannes Leupolz use crate::devices::device_base::{fetch_device_node, open_uinput, Device, DeviceState, BUS_USB}; -use libc::{c_int, close, ff_effect, input_event, open, uinput_ff_upload}; -use nix::{ioctl_write_int, ioctl_write_ptr}; -use std::io; +use libc::{c_int, close, ff_effect, input_event, open, uinput_ff_upload, EAGAIN}; +use nix::{ioctl_write_int, ioctl_write_ptr, poll::{PollFd, PollFlags, PollTimeout, poll}}; +use std::{ + io, os::fd::BorrowedFd, sync::{ + Arc, atomic::{AtomicBool, Ordering} + }, thread, time::Duration +}; use uinput_ioctls::*; // Xbox Gamepad codes @@ -252,7 +256,7 @@ impl Device for XboxGamepadDevice { let event_device_fd = unsafe { open( event_device_node.as_ptr() as *const i8, - libc::O_RDONLY | libc::O_NONBLOCK, + libc::O_RDWR | libc::O_NONBLOCK, ) }; if event_device_fd < 0 { @@ -284,7 +288,7 @@ impl Device for XboxGamepadDevice { } impl XboxGamepadDevice { - pub fn read_process_ff_event_from_uinput(&self) { + pub fn read_process_ff_event_from_uinput(&self, shutdown: Arc,use_poll:bool) { // Copy the i32 file descriptor so we can move it into the thread safely let fd = self.state().uinput_fd; @@ -292,39 +296,66 @@ impl XboxGamepadDevice { // Buffer for the raw bytes let mut buffer = [0u8; 256]; - // Calling C functions always requires an unsafe block - let result = - unsafe { libc::read(fd, buffer.as_mut_ptr() as *mut libc::c_void, buffer.len()) }; + let mut pollfds = [ + PollFd::new(unsafe { BorrowedFd::borrow_raw(fd) }, PollFlags::POLLIN), + ]; - // libc::read returns an isize (ssize_t in C) - if result < 0 { - // result < 0 means an error occurred. We use std::io::Error::last_os_error() - // to get the correct OS error message based on the C `errno`. - eprintln!( - "Error reading in thread: {}", - std::io::Error::last_os_error() - ); - return; - } else if result == 0 { - // 0 bytes usually means End-Of-File (EOF) or that the device was closed - println!("0 bytes (EOF) - Terminating thread"); - return; - } else if result == 24 { - println!("read_process_ff_event_from_uinput: processing input event (read)"); - let input_event = buffer.as_ptr() as *const libc::input_event; - let input_event = unsafe { *input_event }; - if input_event.type_ == EV_UINPUT && input_event.code == UI_FF_UPLOAD { - let mut upload: uinput_ff_upload = unsafe { std::mem::zeroed() }; - upload.request_id = input_event.value.try_into().unwrap(); - unsafe { - let ptr = &mut upload as *mut uinput_ff_upload; - ui_begin_ff_upload(fd, ptr).unwrap(); - println!("effect type: {}", upload.effect.type_); - ui_end_ff_upload(fd, ptr).unwrap(); - }; + + loop { + if shutdown.load(Ordering::SeqCst) { + break; + } + println!("Loop in read_process_ff_event_from_uinput"); + + if use_poll { + let _ = poll(&mut pollfds, 500u16); + } else { + thread::sleep(Duration::from_millis(200)); + } + + // Calling C functions always requires an unsafe block + let result = unsafe { + libc::read(fd, buffer.as_mut_ptr() as *mut libc::c_void, buffer.len()) + }; + if result < 0 { + // result < 0 means an error occurred. We use std::io::Error::last_os_error() + // to get the correct OS error message based on the C `errno`. + let error = std::io::Error::last_os_error(); + match error.kind() { + io::ErrorKind::WouldBlock => { + eprintln!("a read would block. waiting for the next real event"); + continue; + } + _ => { + eprintln!("Error reading in thread: {}", error); + return; + } + } + } else if result == 0 { + // 0 bytes usually means End-Of-File (EOF) or that the device was closed + println!("0 bytes (EOF) - Terminating thread"); + return; + } else if result == 24 { + println!("read_process_ff_event_from_uinput: processing input event (read)"); + let input_event = buffer.as_ptr() as *const libc::input_event; + let input_event = unsafe { *input_event }; + if input_event.type_ == EV_UINPUT && input_event.code == UI_FF_UPLOAD { + let mut upload: uinput_ff_upload = unsafe { std::mem::zeroed() }; + upload.request_id = input_event.value.try_into().unwrap(); + unsafe { + let ptr = &mut upload as *mut uinput_ff_upload; + ui_begin_ff_upload(fd, ptr).unwrap(); + println!("effect type: {}", upload.effect.type_); + ui_end_ff_upload(fd, ptr).unwrap(); + }; + } + else { + println!("event: {} {} {}",input_event.type_,input_event.code,input_event.value); + + } + } else { + println!("Read {} bytes", result); } - } else { - println!("Read {} bytes", result); } }); } diff --git a/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs b/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs index 0ab0a2e..b2e26ab 100644 --- a/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs +++ b/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs @@ -2,6 +2,8 @@ // // Author: Johannes Leupolz +use std::sync::atomic::AtomicBool; +use std::sync::Arc; use std::thread; use std::time::Duration; @@ -38,7 +40,9 @@ impl FfXboxGamepad { effect.u = xbox_gamepad::create_rumble_array(0x8000, 0x0); // ensure uploaded effect gets processed - gamepad.read_process_ff_event_from_uinput(); + let shutdown = Arc::new(AtomicBool::new(false)); + gamepad.read_process_ff_event_from_uinput(shutdown.clone(),false); + // Upload effect via ioctl let effect_id = upload_effect(gamepad.state().event_device_fd, &mut effect)?; @@ -46,12 +50,19 @@ impl FfXboxGamepad { thread::sleep(Duration::from_secs(1)); // Play effect (value=1) - let _play_effect_event = - gamepad.emit_read_and_log(EV_FF, effect_id.try_into().unwrap(), 1)?; + let _play_effect_event = gamepad.emit_to_evdev_read_from_uinput_and_log( + EV_FF, + effect_id.try_into().unwrap(), + 1, + )?; thread::sleep(Duration::from_secs(1)); - let _stop_effect_event = - gamepad.emit_read_and_log(EV_FF, effect_id.try_into().unwrap(), 0)?; + let _stop_effect_event = gamepad.emit_to_evdev_read_from_uinput_and_log( + EV_FF, + effect_id.try_into().unwrap(), + 0, + )?; thread::sleep(Duration::from_secs(1)); + shutdown.store(true, std::sync::atomic::Ordering::SeqCst); let eventlog = TestLog { events: gamepad.event_log().to_vec(), diff --git a/vuinputd/src/cuse_device/vuinput_open.rs b/vuinputd/src/cuse_device/vuinput_open.rs index 532351a..223d9ac 100644 --- a/vuinputd/src/cuse_device/vuinput_open.rs +++ b/vuinputd/src/cuse_device/vuinput_open.rs @@ -42,7 +42,7 @@ pub unsafe extern "C" fn vuinput_open( 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; - // Open the path in read-only mode, returns `io::Result` + // Open the path, returns `io::Result` let open_vuinput_result = OpenOptions::new() .read(true) .write(true) @@ -52,7 +52,6 @@ pub unsafe extern "C" fn vuinput_open( match open_vuinput_result { Ok(v) => { let vu_fh: VuFileHandle = VuFileHandle::Fh(fh); - let uinput_fd: std::os::unix::prelude::BorrowedFd<'_> = v.as_fd(); insert_vuinput_state( &vu_fh, VuInputState { diff --git a/vuinputd/src/cuse_device/vuinput_poll.rs b/vuinputd/src/cuse_device/vuinput_poll.rs index 507415d..1d24e50 100644 --- a/vuinputd/src/cuse_device/vuinput_poll.rs +++ b/vuinputd/src/cuse_device/vuinput_poll.rs @@ -25,6 +25,8 @@ pub unsafe extern "C" fn vuinput_poll( get_vuinput_state(&VuFileHandle::from_fuse_file_info(fi.as_ref().unwrap())).unwrap(); let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); + debug!("poll"); + match vuinput_state.poll.pollphase { PollPhase::Empty => { let ph = NonNull::::new(ph); From 0fda223d4efd232ebe635f0c008247ead6e58a42 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Fri, 3 Apr 2026 23:03:58 +0000 Subject: [PATCH 084/108] Fixes for rumble test scenario --- vuinputd-tests/src/devices/device_base.rs | 26 ++++++++++++++++++---- vuinputd-tests/src/devices/xbox_gamepad.rs | 1 + vuinputd/src/cuse_device/vuinput_open.rs | 3 +-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/vuinputd-tests/src/devices/device_base.rs b/vuinputd-tests/src/devices/device_base.rs index e239a54..bdd38a7 100644 --- a/vuinputd-tests/src/devices/device_base.rs +++ b/vuinputd-tests/src/devices/device_base.rs @@ -78,7 +78,7 @@ pub trait Device: Sized { read_event(event_device_fd) } - /// Emit and read an event with logging + /// Emit (to uinput) and read (from evdev) an event with logging fn emit_read_and_log( &mut self, ev_type: u16, @@ -86,7 +86,20 @@ pub trait Device: Sized { val: i32, ) -> io::Result { let event_device_fd = self.get_event_device()?; - let event = emit_read_and_log(self.uinput_fd(), event_device_fd, ev_type, code, val)?; + let event = emit_read_and_log(self.uinput_fd(), event_device_fd, ev_type, code, val, true)?; + self.state_mut().events.push(event.clone()); + Ok(event) + } + + /// Emit and read an event with logging + fn emit_to_evdev_read_from_uinput_and_log( + &mut self, + ev_type: u16, + code: u16, + val: i32, + ) -> io::Result { + let event_device_fd = self.get_event_device()?; + let event = emit_read_and_log(event_device_fd, self.uinput_fd(), ev_type, code, val, false)?; self.state_mut().events.push(event.clone()); Ok(event) } @@ -184,12 +197,17 @@ pub fn emit_read_and_log( ev_type: u16, code: u16, val: i32, + emit_syn: bool, ) -> io::Result { let (time_sent_sec, time_sent_nsec) = monotonic_time(); emit(emit_to, ev_type, code, val)?; - emit(emit_to, EV_SYN, SYN_REPORT, 0)?; + if emit_syn { + emit(emit_to, EV_SYN, SYN_REPORT, 0)?; + } let input_event_recv = read_event(read_from).unwrap(); - let _syn_recv = read_event(read_from).unwrap(); + if emit_syn { + let _syn_recv = read_event(read_from).unwrap(); + } let (time_recv_sec, time_recv_nsec) = monotonic_time(); let duration_usec = (time_recv_sec - time_sent_sec) * 1_000_000 + (time_recv_nsec - time_sent_nsec) / 1000; diff --git a/vuinputd-tests/src/devices/xbox_gamepad.rs b/vuinputd-tests/src/devices/xbox_gamepad.rs index b14c3a3..44be126 100644 --- a/vuinputd-tests/src/devices/xbox_gamepad.rs +++ b/vuinputd-tests/src/devices/xbox_gamepad.rs @@ -351,6 +351,7 @@ impl XboxGamepadDevice { } else { println!("event: {} {} {}",input_event.type_,input_event.code,input_event.value); + crate::devices::utils::emit(fd, input_event.type_,input_event.code,input_event.value).unwrap(); } } else { diff --git a/vuinputd/src/cuse_device/vuinput_open.rs b/vuinputd/src/cuse_device/vuinput_open.rs index 223d9ac..0d36929 100644 --- a/vuinputd/src/cuse_device/vuinput_open.rs +++ b/vuinputd/src/cuse_device/vuinput_open.rs @@ -46,8 +46,7 @@ pub unsafe extern "C" fn vuinput_open( let open_vuinput_result = OpenOptions::new() .read(true) .write(true) - .custom_flags(O_NONBLOCK) - .custom_flags(O_CLOEXEC) + .custom_flags(O_NONBLOCK | O_CLOEXEC) .open(Path::new("/dev/uinput")); match open_vuinput_result { Ok(v) => { From 65802bfa63ac248506cebf34a4d49eeda91aaba5 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 6 Apr 2026 21:22:15 +0000 Subject: [PATCH 085/108] Test for absolute mouse device added --- vuinputd-tests/src/bin/test-scenarios.rs | 9 +- vuinputd-tests/src/devices/device_base.rs | 13 +- vuinputd-tests/src/devices/mod.rs | 2 + vuinputd-tests/src/devices/mouse_absolute.rs | 161 ++++++++++++++++++ vuinputd-tests/src/devices/xbox_gamepad.rs | 52 +++--- .../src/scenarios/basic_mouse_absolute.rs | 39 +++++ .../src/scenarios/ff_xbox_gamepad.rs | 2 +- vuinputd-tests/src/scenarios/mod.rs | 2 + vuinputd-tests/tests/integration_tests.rs | 41 ++++- 9 files changed, 287 insertions(+), 34 deletions(-) create mode 100644 vuinputd-tests/src/devices/mouse_absolute.rs create mode 100644 vuinputd-tests/src/scenarios/basic_mouse_absolute.rs diff --git a/vuinputd-tests/src/bin/test-scenarios.rs b/vuinputd-tests/src/bin/test-scenarios.rs index 39402d7..408376a 100644 --- a/vuinputd-tests/src/bin/test-scenarios.rs +++ b/vuinputd-tests/src/bin/test-scenarios.rs @@ -5,10 +5,7 @@ use clap::{Parser, Subcommand}; use vuinputd_tests::scenarios::{ basic_keyboard::BasicKeyboard, basic_mouse::BasicMouse, basic_ps4_gamepad::BasicPs4Gamepad, - basic_xbox_gamepad::BasicXboxGamepad, - ff_xbox_gamepad::FfXboxGamepad, /* - reuse_keyboard::ReuseKeyboard, reuse_xbox_gamepad::ReuseXboxGamepad, - ScenarioArgs, stress_keyboard::StressKeyboard, stress_xbox_gamepad::StressXboxGamepad, */ + basic_xbox_gamepad::BasicXboxGamepad, ff_xbox_gamepad::FfXboxGamepad, BasicMouseAbsolute, ScenarioArgs, }; @@ -36,6 +33,9 @@ enum Commands { /// Basic mouse test BasicMouse, + /// Basic mouse (absolute) test + BasicMouseAbsolute, + /// Basic PS4 gamepad test BasicPs4Gamepad, @@ -70,6 +70,7 @@ fn main() -> Result<(), std::io::Error> { match cli.command { Commands::BasicKeyboard => BasicKeyboard::run(&args), Commands::BasicMouse => BasicMouse::run(&args), + Commands::BasicMouseAbsolute => BasicMouseAbsolute::run(&args), Commands::BasicPs4Gamepad => BasicPs4Gamepad::run(&args), Commands::BasicXboxGamepad => BasicXboxGamepad::run(&args), Commands::FfXboxGamepad => FfXboxGamepad::run(&args), diff --git a/vuinputd-tests/src/devices/device_base.rs b/vuinputd-tests/src/devices/device_base.rs index bdd38a7..8359db5 100644 --- a/vuinputd-tests/src/devices/device_base.rs +++ b/vuinputd-tests/src/devices/device_base.rs @@ -23,6 +23,16 @@ pub const SYN_REPORT: u16 = 0; pub const BUS_USB: u16 = 0x03; pub const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/"; +// Absolute Axes +pub const ABS_X: u16 = 0x00; +pub const ABS_Y: u16 = 0x01; +pub const ABS_Z: u16 = 0x02; +pub const ABS_RX: u16 = 0x03; +pub const ABS_RY: u16 = 0x04; +pub const ABS_RZ: u16 = 0x05; +pub const ABS_HAT0X: u16 = 0x10; +pub const ABS_HAT0Y: u16 = 0x11; + /// Struct holding device state pub struct DeviceState { pub uinput_fd: i32, @@ -99,7 +109,8 @@ pub trait Device: Sized { val: i32, ) -> io::Result { let event_device_fd = self.get_event_device()?; - let event = emit_read_and_log(event_device_fd, self.uinput_fd(), ev_type, code, val, false)?; + let event = + emit_read_and_log(event_device_fd, self.uinput_fd(), ev_type, code, val, false)?; self.state_mut().events.push(event.clone()); Ok(event) } diff --git a/vuinputd-tests/src/devices/mod.rs b/vuinputd-tests/src/devices/mod.rs index c0e7cbe..14addab 100644 --- a/vuinputd-tests/src/devices/mod.rs +++ b/vuinputd-tests/src/devices/mod.rs @@ -5,6 +5,7 @@ pub mod device_base; pub mod keyboard; pub mod mouse; +pub mod mouse_absolute; pub mod ps4_gamepad; pub mod utils; pub mod xbox_gamepad; @@ -14,6 +15,7 @@ pub use device_base::Device; pub use device_base::DeviceState; pub use keyboard::KeyboardDevice; pub use mouse::MouseDevice; +pub use mouse_absolute::MouseAbsoluteDevice; pub use ps4_gamepad::Ps4GamepadDevice; pub use xbox_gamepad::XboxGamepadDevice; diff --git a/vuinputd-tests/src/devices/mouse_absolute.rs b/vuinputd-tests/src/devices/mouse_absolute.rs new file mode 100644 index 0000000..1101047 --- /dev/null +++ b/vuinputd-tests/src/devices/mouse_absolute.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use crate::devices::{ + device_base::{fetch_device_node, open_uinput, Device, DeviceState, BUS_USB}, + utils::{ABS_X, ABS_Y}, +}; +use libc::{c_int, close, input_absinfo, open, uinput_abs_setup, INPUT_PROP_DIRECT}; +use std::io; +use uinput_ioctls::*; + +// Mouse codes +pub const BTN_LEFT: u16 = 272; +pub const BTN_RIGHT: u16 = 273; +pub const BTN_MIDDLE: u16 = 274; +pub const REL_X: u16 = 0; +pub const REL_Y: u16 = 1; + +// non linux constants used this way in inputtino +pub const ABS_MAX_WIDTH: i32 = 19200; +pub const ABS_MAX_HEIGHT: i32 = 12000; + +/// Setup absolute mouse device +unsafe fn setup_mouse_absolute(fd: c_int) -> io::Result<()> { + // EV_SYN (implicitly handled by libevdev, but required manually for uinput) + ui_set_evbit(fd, super::EV_SYN.try_into().unwrap())?; + + // INPUT_PROP_DIRECT + ui_set_propbit(fd, INPUT_PROP_DIRECT.try_into().unwrap())?; + + // EV_KEY + ui_set_evbit(fd, super::EV_KEY.try_into().unwrap())?; + ui_set_keybit(fd, BTN_LEFT.try_into().unwrap())?; + + // EV_ABS + ui_set_evbit(fd, super::EV_ABS.try_into().unwrap())?; + ui_set_absbit(fd, ABS_X.try_into().unwrap())?; + ui_set_absbit(fd, ABS_Y.try_into().unwrap())?; + + // Setup absolute axis parameters (min, max, fuzz, flat, resolution) + let abs_x_setup = uinput_abs_setup { + code: ABS_X, + absinfo: input_absinfo { + value: 0, + minimum: 0, + maximum: ABS_MAX_WIDTH, + fuzz: 1, + flat: 0, + resolution: 28, + }, + }; + ui_abs_setup(fd, &abs_x_setup).map_err(|e| { + io::Error::new( + io::ErrorKind::Other, + format!("ui_abs_setup X failed: {:?}", e), + ) + })?; + + let abs_y_setup = uinput_abs_setup { + code: ABS_Y, + absinfo: input_absinfo { + value: 0, + minimum: 0, + maximum: ABS_MAX_HEIGHT, + fuzz: 1, + flat: 0, + resolution: 28, + }, + }; + ui_abs_setup(fd, &abs_y_setup).map_err(|e| { + io::Error::new( + io::ErrorKind::Other, + format!("ui_abs_setup Y failed: {:?}", e), + ) + })?; + + Ok(()) +} + +pub struct MouseAbsoluteDevice { + state: DeviceState, +} + +impl Device for MouseAbsoluteDevice { + fn name() -> &'static str { + "Mouse Absolute" + } + + fn state(&self) -> &DeviceState { + &self.state + } + + fn state_mut(&mut self) -> &mut DeviceState { + &mut self.state + } + + fn get_event_device(&self) -> Result { + Ok(self.state.event_device_fd) + } + + fn create(device: Option<&str>, name: &str) -> Result { + let fd = open_uinput(device)?; + + unsafe { setup_mouse_absolute(fd)? }; + + let temp_device = MouseAbsoluteDevice { + state: DeviceState { + uinput_fd: fd, + sysname: String::new(), + device_name: name.to_string(), + event_device_node: String::new(), + event_device_fd: -1, + events: Vec::new(), + }, + }; + temp_device.setup_device(name, 0xbeef, 0xdead, BUS_USB, 0)?; + + unsafe { + ui_dev_create(fd).map_err(|e| { + eprintln!("ui_dev_create failed: {:?}", e); + e + })?; + } + + let sysname = temp_device.get_sysname()?; + + let event_device_node = fetch_device_node(&sysname)?; + let event_device_fd = unsafe { + open( + event_device_node.as_ptr() as *const i8, + libc::O_RDONLY | libc::O_NONBLOCK, + ) + }; + if event_device_fd < 0 { + return Err(io::Error::last_os_error()); + } + + Ok(MouseAbsoluteDevice { + state: DeviceState { + uinput_fd: fd, + sysname, + device_name: name.to_string(), + event_device_node, + event_device_fd, + events: Vec::new(), + }, + }) + } + + fn destroy(self) { + unsafe { + ui_dev_destroy(self.state.uinput_fd).unwrap_or_else(|e| { + eprintln!("ui_dev_destroy failed: {:?}", e); + std::process::exit(1); + }); + close(self.state.uinput_fd); + close(self.state.event_device_fd); + } + } +} diff --git a/vuinputd-tests/src/devices/xbox_gamepad.rs b/vuinputd-tests/src/devices/xbox_gamepad.rs index 44be126..bc1f07c 100644 --- a/vuinputd-tests/src/devices/xbox_gamepad.rs +++ b/vuinputd-tests/src/devices/xbox_gamepad.rs @@ -2,13 +2,24 @@ // // Author: Johannes Leupolz -use crate::devices::device_base::{fetch_device_node, open_uinput, Device, DeviceState, BUS_USB}; +use crate::devices::{ + device_base::{fetch_device_node, open_uinput, Device, DeviceState, BUS_USB}, + utils::{ABS_HAT0X, ABS_HAT0Y, ABS_RX, ABS_RY, ABS_RZ, ABS_X, ABS_Y, ABS_Z}, +}; use libc::{c_int, close, ff_effect, input_event, open, uinput_ff_upload, EAGAIN}; -use nix::{ioctl_write_int, ioctl_write_ptr, poll::{PollFd, PollFlags, PollTimeout, poll}}; +use nix::{ + ioctl_write_int, ioctl_write_ptr, + poll::{poll, PollFd, PollFlags, PollTimeout}, +}; use std::{ - io, os::fd::BorrowedFd, sync::{ - Arc, atomic::{AtomicBool, Ordering} - }, thread, time::Duration + io, + os::fd::BorrowedFd, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread, + time::Duration, }; use uinput_ioctls::*; @@ -28,16 +39,6 @@ pub const BTN_MODE: u16 = 0x13c; pub const BTN_THUMBL: u16 = 0x13d; pub const BTN_THUMBR: u16 = 0x13e; -// Absolute Axes -pub const ABS_X: u16 = 0x00; -pub const ABS_Y: u16 = 0x01; -pub const ABS_Z: u16 = 0x02; -pub const ABS_RX: u16 = 0x03; -pub const ABS_RY: u16 = 0x04; -pub const ABS_RZ: u16 = 0x05; -pub const ABS_HAT0X: u16 = 0x10; -pub const ABS_HAT0Y: u16 = 0x11; - // Force Feedback // https://github.com/torvalds/linux/blob/master/include/uapi/linux/input.h pub const FF_RUMBLE: u16 = 0x50; @@ -288,7 +289,7 @@ impl Device for XboxGamepadDevice { } impl XboxGamepadDevice { - pub fn read_process_ff_event_from_uinput(&self, shutdown: Arc,use_poll:bool) { + pub fn read_process_ff_event_from_uinput(&self, shutdown: Arc, use_poll: bool) { // Copy the i32 file descriptor so we can move it into the thread safely let fd = self.state().uinput_fd; @@ -296,10 +297,10 @@ impl XboxGamepadDevice { // Buffer for the raw bytes let mut buffer = [0u8; 256]; - let mut pollfds = [ - PollFd::new(unsafe { BorrowedFd::borrow_raw(fd) }, PollFlags::POLLIN), - ]; - + let mut pollfds = [PollFd::new( + unsafe { BorrowedFd::borrow_raw(fd) }, + PollFlags::POLLIN, + )]; loop { if shutdown.load(Ordering::SeqCst) { @@ -348,11 +349,12 @@ impl XboxGamepadDevice { println!("effect type: {}", upload.effect.type_); ui_end_ff_upload(fd, ptr).unwrap(); }; - } - else { - println!("event: {} {} {}",input_event.type_,input_event.code,input_event.value); - crate::devices::utils::emit(fd, input_event.type_,input_event.code,input_event.value).unwrap(); - + } else { + println!( + "event: {} {} {}", + input_event.type_, input_event.code, input_event.value + ); + //crate::devices::utils::emit(fd, input_event.type_,input_event.code,input_event.value).unwrap(); } } else { println!("Read {} bytes", result); diff --git a/vuinputd-tests/src/scenarios/basic_mouse_absolute.rs b/vuinputd-tests/src/scenarios/basic_mouse_absolute.rs new file mode 100644 index 0000000..af7dcd4 --- /dev/null +++ b/vuinputd-tests/src/scenarios/basic_mouse_absolute.rs @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz +use std::thread; +use std::time::Duration; + +use crate::devices::{Device, MouseAbsoluteDevice, EV_KEY}; +use crate::scenarios::ScenarioArgs; +use crate::test_log::{LoggedInputEvent, TestLog}; + +const BTN_LEFT: u16 = 272; + +pub struct BasicMouseAbsolute; + +impl BasicMouseAbsolute { + pub fn run(args: &ScenarioArgs) -> Result<(), std::io::Error> { + let device = args + .dev_path + .clone() + .unwrap_or_else(|| "/dev/uinput".to_string()); + + let mut mouse = MouseAbsoluteDevice::create(Some(&device), "Example Mouse (absolute)")?; + eprintln!("sysname: {}", mouse.sysname()); + + thread::sleep(Duration::from_secs(1)); + + let _ev1 = mouse.emit_read_and_log(EV_KEY, BTN_LEFT, 1)?; + let _ev2 = mouse.emit_read_and_log(EV_KEY, BTN_LEFT, 0)?; + + let eventlog = TestLog { + events: mouse.event_log().to_vec(), + }; + let serialized = serde_json::to_string(&eventlog).unwrap(); + println!("Event log: {}", serialized); + + MouseAbsoluteDevice::destroy(mouse); + Ok(()) + } +} diff --git a/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs b/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs index b2e26ab..4ddc8bb 100644 --- a/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs +++ b/vuinputd-tests/src/scenarios/ff_xbox_gamepad.rs @@ -41,7 +41,7 @@ impl FfXboxGamepad { // ensure uploaded effect gets processed let shutdown = Arc::new(AtomicBool::new(false)); - gamepad.read_process_ff_event_from_uinput(shutdown.clone(),false); + gamepad.read_process_ff_event_from_uinput(shutdown.clone(), false); // Upload effect via ioctl let effect_id = upload_effect(gamepad.state().event_device_fd, &mut effect)?; diff --git a/vuinputd-tests/src/scenarios/mod.rs b/vuinputd-tests/src/scenarios/mod.rs index 1d3d2f0..121be5c 100644 --- a/vuinputd-tests/src/scenarios/mod.rs +++ b/vuinputd-tests/src/scenarios/mod.rs @@ -4,6 +4,7 @@ pub mod basic_keyboard; pub mod basic_mouse; +pub mod basic_mouse_absolute; pub mod basic_ps4_gamepad; pub mod basic_xbox_gamepad; pub mod ff_xbox_gamepad; @@ -17,6 +18,7 @@ pub mod stress_xbox_gamepad; // Re-exports for type checking pub use basic_keyboard::BasicKeyboard; pub use basic_mouse::BasicMouse; +pub use basic_mouse_absolute::BasicMouseAbsolute; pub use basic_ps4_gamepad::BasicPs4Gamepad; pub use basic_xbox_gamepad::BasicXboxGamepad; pub use ff_xbox_gamepad::FfXboxGamepad; diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index e0da2e0..a165797 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -194,7 +194,6 @@ fn test_keyboard_in_container_with_vuinput_placement_on_host() { assert!(out.status.success()); } - #[ignore = "not implemented yet"] #[cfg(all( feature = "requires-privileges", @@ -221,7 +220,7 @@ fn test_gamepad_with_ff_in_container() { .expect("failed to create IPC"); let out = builder - .command(test_scenarios, &["--ipc","ff-xbox-gamepad"]) + .command(test_scenarios, &["--ipc", "ff-xbox-gamepad"]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); @@ -230,4 +229,40 @@ fn test_gamepad_with_ff_in_container() { println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); assert!(out.status.success()); -} \ No newline at end of file +} +#[ignore = "not implemented yet"] +#[cfg(all( + feature = "requires-privileges", + feature = "requires-uinput", + feature = "requires-bwrap" +))] +#[test] +fn test_mouse_absolute_in_container() { + let _guard: run_vuinputd::VuinputdGuard = run_vuinputd::ensure_vuinputd_running(&[]); + + let test_scenarios = env!("CARGO_BIN_EXE_test-scenarios"); + + let (builder, _ipc) = bwrap::BwrapBuilder::new() + .unshare_net() + .ro_bind("/", "/") + .tmpfs("/tmp") + // dev needs to be writable for the new devices + .dev() + // run needs to be writable for the udev devices + .tmpfs("/run") + .dev_bind("/dev/vuinput-test", "/dev/uinput") + .die_with_parent() + .with_ipc() + .expect("failed to create IPC"); + + let out = builder + .command(test_scenarios, &["--ipc", "basic-mouse-absolute"]) + .run() + .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); + + assert!(out.status.success()); +} From 0ce20344070778bce83e1ec7fb8ab4feaeedd610 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 6 Apr 2026 22:06:43 +0000 Subject: [PATCH 086/108] Removed ipc-support from test automation, because I don't need it yet and it is buggy. --- docs/TESTS.md | 4 +-- vuinputd-tests/tests/integration_tests.rs | 44 ++++++----------------- 2 files changed, 13 insertions(+), 35 deletions(-) diff --git a/docs/TESTS.md b/docs/TESTS.md index 1400ea2..f94670b 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -16,7 +16,7 @@ mkdir -p /run/vuinputd/vuinput-test/udev Install bubblewrap: `apt-get install bubblewrap`. -Run with `cargo test -p vuinputd-tests --features "requires-privileges requires-uinput requires-bwrap"`. +Run with `cargo test -p vuinputd-tests --features "requires-privileges requires-uinput requires-bwrap" -- --test-threads=1`. ### With podman @@ -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"`. +Run with `cargo test -p vuinputd-tests --features "requires-privileges requires-uinput requires-podman -- --test-threads=1"`. ## Performance tests diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index a165797..a8242f5 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -99,18 +99,14 @@ fn test_keyboard_on_host() { fn test_keyboard_in_container_with_uinput() { let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); - let (builder, _ipc) = bwrap::BwrapBuilder::new() + let out = bwrap::BwrapBuilder::new() .unshare_net() .ro_bind("/", "/") .tmpfs("/tmp") .dev_bind("/dev/uinput", "/dev/uinput") .dev_bind("/dev/input", "/dev/input") .die_with_parent() - .with_ipc() - .expect("failed to create IPC"); - - let out = builder - .command(test_keyboard, &["--ipc"]) + .command(test_keyboard, &[]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); @@ -132,7 +128,7 @@ fn test_keyboard_in_container_with_vuinput_placement_in_container() { let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); - let (builder, _ipc) = bwrap::BwrapBuilder::new() + let out = bwrap::BwrapBuilder::new() .unshare_net() .ro_bind("/", "/") .tmpfs("/tmp") @@ -142,11 +138,7 @@ fn test_keyboard_in_container_with_vuinput_placement_in_container() { .tmpfs("/run") .dev_bind("/dev/vuinput-test", "/dev/uinput") .die_with_parent() - .with_ipc() - .expect("failed to create IPC"); - - let out = builder - .command(test_keyboard, &["--ipc"]) + .command(test_keyboard, &[]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); @@ -168,7 +160,7 @@ fn test_keyboard_in_container_with_vuinput_placement_on_host() { let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); - let (builder, _ipc) = bwrap::BwrapBuilder::new() + let out = bwrap::BwrapBuilder::new() .unshare_net() .ro_bind("/", "/") .tmpfs("/tmp") @@ -179,11 +171,7 @@ fn test_keyboard_in_container_with_vuinput_placement_on_host() { .bind("/run/vuinputd/vuinput-test/udev", "/run/udev") .dev_bind("/dev/vuinput-test", "/dev/uinput") .die_with_parent() - .with_ipc() - .expect("failed to create IPC"); - - let out = builder - .command(test_keyboard, &["--ipc"]) + .command(test_keyboard, &[]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); @@ -194,7 +182,6 @@ fn test_keyboard_in_container_with_vuinput_placement_on_host() { assert!(out.status.success()); } -#[ignore = "not implemented yet"] #[cfg(all( feature = "requires-privileges", feature = "requires-uinput", @@ -206,7 +193,7 @@ fn test_gamepad_with_ff_in_container() { let test_scenarios = env!("CARGO_BIN_EXE_test-scenarios"); - let (builder, _ipc) = bwrap::BwrapBuilder::new() + let out = bwrap::BwrapBuilder::new() .unshare_net() .ro_bind("/", "/") .tmpfs("/tmp") @@ -215,12 +202,7 @@ fn test_gamepad_with_ff_in_container() { // run needs to be writable for the udev devices .tmpfs("/run") .dev_bind("/dev/vuinput-test", "/dev/uinput") - .die_with_parent() - .with_ipc() - .expect("failed to create IPC"); - - let out = builder - .command(test_scenarios, &["--ipc", "ff-xbox-gamepad"]) + .command(test_scenarios, &["ff-xbox-gamepad"]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); @@ -230,7 +212,7 @@ fn test_gamepad_with_ff_in_container() { assert!(out.status.success()); } -#[ignore = "not implemented yet"] + #[cfg(all( feature = "requires-privileges", feature = "requires-uinput", @@ -242,7 +224,7 @@ fn test_mouse_absolute_in_container() { let test_scenarios = env!("CARGO_BIN_EXE_test-scenarios"); - let (builder, _ipc) = bwrap::BwrapBuilder::new() + let out = bwrap::BwrapBuilder::new() .unshare_net() .ro_bind("/", "/") .tmpfs("/tmp") @@ -252,11 +234,7 @@ fn test_mouse_absolute_in_container() { .tmpfs("/run") .dev_bind("/dev/vuinput-test", "/dev/uinput") .die_with_parent() - .with_ipc() - .expect("failed to create IPC"); - - let out = builder - .command(test_scenarios, &["--ipc", "basic-mouse-absolute"]) + .command(test_scenarios, &["basic-mouse-absolute"]) .run() .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); From 8e0a00e817f1ac2187332df3f2c7700a5340b1c4 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 6 Apr 2026 22:25:05 +0000 Subject: [PATCH 087/108] Tryfix for #7. Untested --- vuinputd/src/cuse_device/vuinput_ioctl.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/vuinputd/src/cuse_device/vuinput_ioctl.rs b/vuinputd/src/cuse_device/vuinput_ioctl.rs index 93a3961..e1398ec 100644 --- a/vuinputd/src/cuse_device/vuinput_ioctl.rs +++ b/vuinputd/src/cuse_device/vuinput_ioctl.rs @@ -3,7 +3,7 @@ // Author: Johannes Leupolz use ::cuse_lowlevel::*; -use libc::{iovec, size_t, EBADRQC}; +use libc::{EBADRQC, input_absinfo, iovec, size_t}; use libc::{uinput_abs_setup, uinput_ff_erase, uinput_ff_upload, uinput_setup}; use log::debug; use std::ffi::CStr; @@ -275,6 +275,10 @@ pub unsafe extern "C" fn vuinput_ioctl( //todo: i guess this needs to be reworked as this is variable size. i guess it is not reachable at all debug!("fh {}: ioctl UI_ABS_SETUP", fh); assert!(_in_bufsz != 0, "should have _in_bufsz"); + + let abs_setup_ptr = _in_buf as *const uinput_abs_setup; + ui_abs_setup(fd, abs_setup_ptr).unwrap(); + fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); } UI_GET_SYSNAME_WITHOUT_SIZE => { From b75806cc098889429c101cc4e65123ddb639a364 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 9 Apr 2026 21:06:13 +0000 Subject: [PATCH 088/108] - 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) --- docs/TESTS.md | 2 +- vuinputd-tests/src/podman.rs | 12 ++ vuinputd-tests/src/run_vuinputd.rs | 2 + vuinputd/src/global_config.rs | 29 +++++ vuinputd/src/main.rs | 35 +++--- vuinputd/src/process_tools/mod.rs | 97 ++++++++++------ vuinputd/src/process_tools/ns_fscreds.rs | 141 +++++++++++++++++++++++ 7 files changed, 271 insertions(+), 47 deletions(-) create mode 100644 vuinputd/src/process_tools/ns_fscreds.rs diff --git a/docs/TESTS.md b/docs/TESTS.md index f94670b..c279e08 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -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 diff --git a/vuinputd-tests/src/podman.rs b/vuinputd-tests/src/podman.rs index fc3821d..839ba3c 100644 --- a/vuinputd-tests/src/podman.rs +++ b/vuinputd-tests/src/podman.rs @@ -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( diff --git a/vuinputd-tests/src/run_vuinputd.rs b/vuinputd-tests/src/run_vuinputd.rs index 08397bb..8cb8b78 100644 --- a/vuinputd-tests/src/run_vuinputd.rs +++ b/vuinputd-tests/src/run_vuinputd.rs @@ -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"); } } diff --git a/vuinputd/src/global_config.rs b/vuinputd/src/global_config.rs index d91de99..c851c6e 100644 --- a/vuinputd/src/global_config.rs +++ b/vuinputd/src/global_config.rs @@ -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, + 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 +} diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 199bd79..1b3c519 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -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, - /// Path to the target process's /proc//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//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, + pub target_pid: Option, #[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 diff --git a/vuinputd/src/process_tools/mod.rs b/vuinputd/src/process_tools/mod.rs index e54119e..ad78125 100644 --- a/vuinputd/src/process_tools/mod.rs +++ b/vuinputd/src/process_tools/mod.rs @@ -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 = 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 { 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 { 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 anyhow::Result 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 { Ok(si.si_status()) } } - Pid::SelfPid => { - unreachable!(); - } } } diff --git a/vuinputd/src/process_tools/ns_fscreds.rs b/vuinputd/src/process_tools/ns_fscreds.rs new file mode 100644 index 0000000..7cf3268 --- /dev/null +++ b/vuinputd/src/process_tools/ns_fscreds.rs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +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> { + 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 { + 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 { + 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 { + 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 { + 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 + ); + } +} From 1cbd0540ab383f383c316a64452c0b67572870c7 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 14 Apr 2026 19:29:13 +0000 Subject: [PATCH 089/108] Comment out read and poll for now, because it blocks the sunshine shutdown for some reason --- vuinputd/src/cuse_device/vuinput_poll.rs | 3 +++ vuinputd/src/cuse_device/vuinput_read.rs | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/vuinputd/src/cuse_device/vuinput_poll.rs b/vuinputd/src/cuse_device/vuinput_poll.rs index 1d24e50..d01b3e7 100644 --- a/vuinputd/src/cuse_device/vuinput_poll.rs +++ b/vuinputd/src/cuse_device/vuinput_poll.rs @@ -21,6 +21,9 @@ pub unsafe extern "C" fn vuinput_poll( fi: *mut fuse_lowlevel::fuse_file_info, ph: *mut fuse_lowlevel::fuse_pollhandle, ) { + fuse_lowlevel::fuse_reply_err(req, EIO); + return; + let vuinput_state_mutex = get_vuinput_state(&VuFileHandle::from_fuse_file_info(fi.as_ref().unwrap())).unwrap(); let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); diff --git a/vuinputd/src/cuse_device/vuinput_read.rs b/vuinputd/src/cuse_device/vuinput_read.rs index bc34dfa..8d5d451 100644 --- a/vuinputd/src/cuse_device/vuinput_read.rs +++ b/vuinputd/src/cuse_device/vuinput_read.rs @@ -23,11 +23,15 @@ pub unsafe extern "C" fn vuinput_read( "vuinput_read: offset needs to be 0 but is {}", _off ); + fuse_lowlevel::fuse_reply_err(_req, EIO); + return; let fh = &(*_fi).fh; let vuinput_state_mutex = get_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap(); + println!("vuinput_read: lock"); let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); + println!("vuinput_read: locked"); const NORMAL_SIZE: usize = std::mem::size_of::(); let is_compat = vuinput_state.requesting_process.is_compat; @@ -37,7 +41,10 @@ pub unsafe extern "C" fn vuinput_read( vuinput_state.poll.pollphase = PollPhase::Reading; // read up to 24 bytes + println!("vuinput_read: read"); let result = vuinput_state.file.read(&mut buffer); + + println!("vuinput_read: read finished"); match result { Ok(NORMAL_SIZE) => { if !is_compat { From 86c4e1c712e190775f2abdac55c99d62631ae5b4 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 14 Apr 2026 19:35:25 +0000 Subject: [PATCH 090/108] Add --container-runtime. Currently the behavior should be the same, but in the future, there should be individual behavior for for example incus or helpful infos during the startup. Also add --container-name and --strategy-file, which are currently unused --- .github/workflows/debian-package.yml | 2 +- debian/control | 1 + vuinputd/Cargo.toml | 3 +- .../container_runtime/injection_strategy.rs | 255 ++++++++++++++++++ vuinputd/src/container_runtime/mod.rs | 78 ++++++ vuinputd/src/global_config.rs | 24 +- vuinputd/src/jobs/emit_udev_event_job.rs | 40 +-- vuinputd/src/jobs/mknod_device_job.rs | 34 +-- vuinputd/src/jobs/remove_device_job.rs | 57 ++-- vuinputd/src/main.rs | 140 ++++++---- 10 files changed, 485 insertions(+), 149 deletions(-) create mode 100644 vuinputd/src/container_runtime/injection_strategy.rs create mode 100644 vuinputd/src/container_runtime/mod.rs diff --git a/.github/workflows/debian-package.yml b/.github/workflows/debian-package.yml index aac9722..1a6b91e 100644 --- a/.github/workflows/debian-package.yml +++ b/.github/workflows/debian-package.yml @@ -37,7 +37,7 @@ jobs: fuse3 \ libclang-dev # debian packages, if packages are not downloaded via cargo - sudo apt install -y librust-bindgen-dev librust-nix-dev librust-libc-dev librust-time-dev librust-log-dev librust-env-logger-dev librust-libudev-dev librust-regex-dev librust-async-channel-dev librust-futures-dev librust-async-io-dev librust-anyhow-dev librust-clap-dev librust-base64-dev librust-smallvec-dev + sudo apt install -y librust-bindgen-dev librust-nix-dev librust-libc-dev librust-time-dev librust-log-dev librust-env-logger-dev librust-libudev-dev librust-regex-dev librust-async-channel-dev librust-futures-dev librust-async-io-dev librust-anyhow-dev librust-clap-dev librust-base64-dev librust-smallvec-dev librust-async-trait-dev - name: Show versions (debug) run: | diff --git a/debian/control b/debian/control index 4646dfa..710de08 100644 --- a/debian/control +++ b/debian/control @@ -27,6 +27,7 @@ Build-Depends: debhelper (>= 12), librust-clap-dev, librust-base64-dev, librust-smallvec-dev, + librust-async-trait-dev, pkg-config, build-essential Standards-Version: 4.6.0 diff --git a/vuinputd/Cargo.toml b/vuinputd/Cargo.toml index ad9f3d5..93b691f 100644 --- a/vuinputd/Cargo.toml +++ b/vuinputd/Cargo.toml @@ -31,10 +31,11 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" base64 = "0.22" smallvec = "1.15.1" +async-trait = "0.1.89" [features] requires-privileges = [] requires-rootless = [] requires-uinput = [] requires-bwrap = [] -requires-podman = [] \ No newline at end of file +requires-podman = [] diff --git a/vuinputd/src/container_runtime/injection_strategy.rs b/vuinputd/src/container_runtime/injection_strategy.rs new file mode 100644 index 0000000..9645e9c --- /dev/null +++ b/vuinputd/src/container_runtime/injection_strategy.rs @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use async_trait::async_trait; + +use crate::{ + actions::action::Action, + global_config, + input_realizer::{input_device, runtime_data}, + process_tools::{self, Pid, RequestingProcess}, +}; +pub static PLACEMENT_IN_CONTAINER: GenericPlacementInContainer = GenericPlacementInContainer {}; +pub static PLACEMENT_ON_HOST: GenericPlacementOnHost = GenericPlacementOnHost {}; +pub static SEND_NETLINK_ONLY: GenericSendNetlinkMessageOnly = GenericSendNetlinkMessageOnly {}; + +#[async_trait] +pub trait InjectionStrategy { + /// Create the device node. + async fn mknod_device_node( + &self, + requesting_process: &RequestingProcess, + devname: &str, + major: u64, + minor: u64, + ) -> anyhow::Result<()>; + + /// Remove device. + async fn remove_device_node( + &self, + requesting_process: &RequestingProcess, + devname: &str, + major: u64, + minor: u64, + ) -> anyhow::Result<()>; + + /// Write udev metadata. + async fn write_udev_runtime_data( + &self, + requesting_process: &RequestingProcess, + runtime_data: &str, + major: u64, + minor: u64, + ) -> anyhow::Result<()>; + + /// Emit netlink message. + /// emit_netlink_message is the same for all container engines, we add + /// a method to enable more flexibility in the future and also allow tests. + // async fn emit_netlink_message(&self, netlink_message: HashMap,) -> anyhow::Result<()>; + + /// Remove runtime data. + async fn remove_udev_runtime_data( + &self, + requesting_process: &RequestingProcess, + major: u64, + minor: u64, + ) -> anyhow::Result<()>; +} + +pub struct GenericPlacementInContainer {} +pub struct GenericPlacementOnHost {} +pub struct GenericSendNetlinkMessageOnly {} + +#[async_trait] +impl InjectionStrategy for GenericPlacementInContainer { + async fn mknod_device_node( + &self, + requesting_process: &RequestingProcess, + devname: &str, + major: u64, + minor: u64, + ) -> anyhow::Result<()> { + let mknod_device_action = Action::MknodDevice { + path: format!("/dev/input/{}", &devname), + major: major, + minor: minor, + }; + + let child_pid = process_tools::start_action(mknod_device_action, &requesting_process) + .expect("subprocess should work"); + + let _exit_info = process_tools::await_process(Pid::Pid(child_pid)) + .await + .unwrap(); + Ok(()) + } + + async fn remove_device_node( + &self, + requesting_process: &RequestingProcess, + devname: &str, + major: u64, + minor: u64, + ) -> anyhow::Result<()> { + let dev_path = format!("/dev/input/{}", devname); + let remove_device_action = Action::RemoveDevice { + path: dev_path, + major: major, + minor: minor, + }; + + let child_pid_1 = process_tools::start_action(remove_device_action, &requesting_process) + .expect("subprocess should work"); + + let _exit_info = process_tools::await_process(Pid::Pid(child_pid_1)).await; + Ok(()) + } + + async fn write_udev_runtime_data( + &self, + requesting_process: &RequestingProcess, + runtime_data: &str, + major: u64, + minor: u64, + ) -> anyhow::Result<()> { + let write_udev_runtime_data = Action::WriteUdevRuntimeData { + runtime_data: Some(runtime_data.to_string()), + major: major, + minor: minor, + }; + + let child_pid = process_tools::start_action(write_udev_runtime_data, &requesting_process) + .expect("subprocess should work"); + + let _exit_info = process_tools::await_process(Pid::Pid(child_pid)) + .await + .unwrap(); + Ok(()) + } + + async fn remove_udev_runtime_data( + &self, + requesting_process: &RequestingProcess, + major: u64, + minor: u64, + ) -> anyhow::Result<()> { + let write_udev_runtime_data_action = Action::WriteUdevRuntimeData { + runtime_data: None, + major: major, + minor: minor, + }; + + let child_pid_2 = + process_tools::start_action(write_udev_runtime_data_action, &requesting_process) + .expect("subprocess should work"); + let _exit_info = process_tools::await_process(Pid::Pid(child_pid_2)).await; + Ok(()) + } +} + +#[async_trait] +impl InjectionStrategy for GenericPlacementOnHost { + async fn mknod_device_node( + &self, + _requesting_process: &RequestingProcess, + devname: &str, + major: u64, + minor: u64, + ) -> anyhow::Result<()> { + let path_prefix = format!("/run/vuinputd/{}", global_config::get_vudevname()); + let path = format!("{}/dev-input/{}", path_prefix, devname); + input_device::ensure_input_device(path.clone(), major, minor) + .expect(&format!("VUI-DEV-001: could not create {}", &path)); + //TODO: somewhat costly + Ok(()) + } + + async fn remove_device_node( + &self, + _requesting_process: &RequestingProcess, + devname: &str, + major: u64, + minor: u64, + ) -> anyhow::Result<()> { + let path_prefix = format!("/run/vuinputd/{}", global_config::get_vudevname()); + let devnode = format!("{}/dev-input/{}", path_prefix, devname); + input_device::remove_input_device(devnode.clone(), major, minor).expect(&format!( + "VUI-DEV-003: could not remove device node {}", + &devnode + )); + Ok(()) + } + + async fn write_udev_runtime_data( + &self, + _requesting_process: &RequestingProcess, + runtime_data: &str, + major: u64, + minor: u64, + ) -> anyhow::Result<()> { + let path_prefix = format!("/run/vuinputd/{}", global_config::get_vudevname()); + runtime_data::write_udev_data(&path_prefix, &runtime_data, major.into(), minor.into()) + .expect(&format!( + "VUI-UDEV-002: could not write into {}", + &path_prefix + )); //TODO: somewhat costly + Ok(()) + } + + async fn remove_udev_runtime_data( + &self, + _requesting_process: &RequestingProcess, + major: u64, + minor: u64, + ) -> anyhow::Result<()> { + let path_prefix = format!("/run/vuinputd/{}", global_config::get_vudevname()); + runtime_data::delete_udev_data(&path_prefix, major, minor).expect(&format!( + "VUI-UDEV-003: could not remove udev data from {}", + &path_prefix + )); + Ok(()) + } +} + +#[async_trait] +impl InjectionStrategy for GenericSendNetlinkMessageOnly { + async fn mknod_device_node( + &self, + _requesting_process: &RequestingProcess, + _devname: &str, + _major: u64, + _minor: u64, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn remove_device_node( + &self, + _requesting_process: &RequestingProcess, + _devname: &str, + _major: u64, + _minor: u64, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn write_udev_runtime_data( + &self, + _requesting_process: &RequestingProcess, + _runtime_data: &str, + _major: u64, + _minor: u64, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn remove_udev_runtime_data( + &self, + _requesting_process: &RequestingProcess, + _major: u64, + _minor: u64, + ) -> anyhow::Result<()> { + Ok(()) + } +} diff --git a/vuinputd/src/container_runtime/mod.rs b/vuinputd/src/container_runtime/mod.rs new file mode 100644 index 0000000..3d4de8c --- /dev/null +++ b/vuinputd/src/container_runtime/mod.rs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use crate::{ + container_runtime::injection_strategy::{ + GenericPlacementInContainer, GenericPlacementOnHost, GenericSendNetlinkMessageOnly, + InjectionStrategy, PLACEMENT_IN_CONTAINER, PLACEMENT_ON_HOST, SEND_NETLINK_ONLY, + }, + global_config::get_vudevname, +}; + +pub mod injection_strategy; + +/// Container runtime used for name resolution and lifecycle events +#[derive(Debug, Clone, clap::ValueEnum, Default, PartialEq, Eq)] +pub enum ContainerRuntime { + #[default] + /// Probe for installed runtimes (default). Currently just falls back to "generic placement in container" + Auto, + /// Generic linux namespaces. This technique uses nsenter and tries to create files and devices directly in the filesystem inside the container. + GenericPlacementInContainer, + /// Generic linux namespaces. This technique creates files and devices directly in the filesystem of the host. It is the job of the user to bind mount those devices to make them available in the container. + GenericPlacementOnHost, + /// Generic linux namespaces. This technique just sends the netlink message. Works if the user bind mounds the whole /dev/input and /var/run/udev-folder + GenericSendNetlinkMessageOnly, + /// Incus (incus info / incus list). Not implemented, yet. + Incus, + /// Docker (docker inspect / Docker socket). This currently falls back to GenericPlacementInContainer. + Docker, + /// Podman (podman inspect / Podman socket). This currently falls back to GenericPlacementOnHost + Podman, + /// systemd-nspawn via machinectl. This currently falls back to GenericPlacementInContainer. + Nspawn, + /// bubblewrap. This currently falls back to GenericPlacementOnHost + Bubblewrap, + /// Custom engine, please define a --strategie-file + CustomEngine, +} + +impl ContainerRuntime { + fn uses_run_folder(&self) -> bool { + match self { + ContainerRuntime::Auto => false, + ContainerRuntime::GenericPlacementInContainer => false, + ContainerRuntime::GenericPlacementOnHost => true, + ContainerRuntime::GenericSendNetlinkMessageOnly => false, + ContainerRuntime::Incus => false, + ContainerRuntime::Docker => false, + ContainerRuntime::Podman => false, + ContainerRuntime::Nspawn => false, + ContainerRuntime::Bubblewrap => true, + ContainerRuntime::CustomEngine => false, + } + } + + pub fn initialize(&self) { + if self.uses_run_folder() { + let path_prefix = format!("/run/vuinputd/{}", get_vudevname()); + let _ = crate::input_realizer::host_fs::ensure_host_fs_structure(&path_prefix); + } + } + + pub fn injection_strategy(&self) -> &'static dyn InjectionStrategy { + match self { + ContainerRuntime::Auto => &PLACEMENT_IN_CONTAINER, + ContainerRuntime::GenericPlacementInContainer => &PLACEMENT_IN_CONTAINER, + ContainerRuntime::GenericPlacementOnHost => &PLACEMENT_ON_HOST, + ContainerRuntime::GenericSendNetlinkMessageOnly => &SEND_NETLINK_ONLY, + ContainerRuntime::Incus => todo!("not implemented yet"), + ContainerRuntime::Docker => &PLACEMENT_IN_CONTAINER, + ContainerRuntime::Podman => &PLACEMENT_IN_CONTAINER, + ContainerRuntime::Nspawn => &PLACEMENT_IN_CONTAINER, + ContainerRuntime::Bubblewrap => &PLACEMENT_ON_HOST, + ContainerRuntime::CustomEngine => todo!("not implemented yet"), + } + } +} diff --git a/vuinputd/src/global_config.rs b/vuinputd/src/global_config.rs index c851c6e..0bbac32 100644 --- a/vuinputd/src/global_config.rs +++ b/vuinputd/src/global_config.rs @@ -5,10 +5,12 @@ use clap::ValueEnum; use std::sync::OnceLock; +use crate::container_runtime::ContainerRuntime; + #[derive(Debug)] pub struct GlobalConfig { pub policy: DevicePolicy, - pub placement: Placement, + pub container_runtime: ContainerRuntime, pub vudevname: String, pub device_owner: DeviceOwner, } @@ -16,6 +18,16 @@ pub struct GlobalConfig { // The actual static variable. It starts empty and is set once in main(). pub static CONFIG: OnceLock = OnceLock::new(); +/// Defines the operational scope of the vuinputd instance +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum Scope { + #[default] + /// Watch all running containers of the configured runtime and manage lifecycle. + Multi, + /// Bind to a single named container. The name is passed directly to the engine's CLI/API. + Single(String), +} + /// The device policy decides what events stay and what is filtered out. #[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum, Default)] #[clap(rename_all = "kebab-case")] // This ensures StrictGamepad becomes "strict-gamepad" @@ -31,6 +43,8 @@ pub enum DevicePolicy { StrictGamepad, } /// Where to create runtime artifacts (device nodes + udev data) +/// Deprecated, use --container-runtime instead. Currently just maps to +/// --container-runtime #[derive(Debug, Clone, ValueEnum, Default, PartialEq, Eq)] pub enum Placement { #[default] @@ -66,14 +80,14 @@ impl DeviceOwner { pub fn initialize_global_config( device_policy: &DevicePolicy, - placement: &Placement, + container_runtime: &ContainerRuntime, devname: &Option, device_owner: &DeviceOwner, ) { if CONFIG .set(GlobalConfig { policy: device_policy.clone(), - placement: placement.clone(), + container_runtime: container_runtime.clone(), vudevname: devname.clone().unwrap_or("vuinput".to_string()), device_owner: device_owner.clone(), }) @@ -88,8 +102,8 @@ pub fn get_device_policy<'a>() -> &'a DevicePolicy { &CONFIG.get().unwrap().policy } -pub fn get_placement<'a>() -> &'a Placement { - &CONFIG.get().unwrap().placement +pub fn get_container_runtime<'a>() -> &'a ContainerRuntime { + &CONFIG.get().unwrap().container_runtime } pub fn get_vudevname<'a>() -> &'a String { diff --git a/vuinputd/src/jobs/emit_udev_event_job.rs b/vuinputd/src/jobs/emit_udev_event_job.rs index 6132230..786b413 100644 --- a/vuinputd/src/jobs/emit_udev_event_job.rs +++ b/vuinputd/src/jobs/emit_udev_event_job.rs @@ -15,7 +15,7 @@ use log::debug; use crate::{ actions::action::Action, - global_config::{self, get_placement, Placement}, + global_config::get_container_runtime, input_realizer::runtime_data, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, @@ -146,35 +146,17 @@ impl EmitUdevEventJob { let runtime_data = runtime_data.unwrap(); let netlink_data = netlink_data.unwrap(); - match get_placement() { - Placement::InContainer => { - let write_udev_runtime_data = Action::WriteUdevRuntimeData { - runtime_data: Some(runtime_data), - major: self.major, - minor: self.minor, - }; + let injector = get_container_runtime().injection_strategy(); - let child_pid = - process_tools::start_action(write_udev_runtime_data, &self.requesting_process) - .expect("subprocess should work"); - - let _exit_info = await_process(Pid::Pid(child_pid)).await.unwrap(); - } - Placement::OnHost => { - let path_prefix = format!("/run/vuinputd/{}", global_config::get_vudevname()); - runtime_data::write_udev_data( - &path_prefix, - &runtime_data, - self.major.into(), - self.minor.into(), - ) - .expect(&format!( - "VUI-UDEV-002: could not write into {}", - &path_prefix - )); //TODO: somewhat costly - } - Placement::None => {} - } + injector + .write_udev_runtime_data( + &self.requesting_process, + &runtime_data, + self.major, + self.minor, + ) + .await + .unwrap(); // this is always in the container let emit_netlink_message = Action::EmitNetlinkMessage { diff --git a/vuinputd/src/jobs/mknod_device_job.rs b/vuinputd/src/jobs/mknod_device_job.rs index 6b63963..2a4eddb 100644 --- a/vuinputd/src/jobs/mknod_device_job.rs +++ b/vuinputd/src/jobs/mknod_device_job.rs @@ -10,7 +10,7 @@ use std::{ use crate::{ actions::action::Action, - global_config::{self, Placement}, + global_config::{self, get_container_runtime, Placement}, input_realizer::input_device, job_engine::job::{Job, JobTarget}, process_tools::{self, await_process, Pid, RequestingProcess}, @@ -95,29 +95,17 @@ impl Job for MknodDeviceJob { impl MknodDeviceJob { async fn mknod_device(self) { - match global_config::get_placement() { - Placement::InContainer => { - let mknod_device_action = Action::MknodDevice { - path: format!("/dev/input/{}", &self.devname), - major: self.major, - minor: self.minor, - }; + let injector = get_container_runtime().injection_strategy(); - let child_pid = - process_tools::start_action(mknod_device_action, &self.requesting_process) - .expect("subprocess should work"); - - let _exit_info = await_process(Pid::Pid(child_pid)).await.unwrap(); - } - Placement::OnHost => { - let path_prefix = format!("/run/vuinputd/{}", global_config::get_vudevname()); - let path = format!("{}/dev-input/{}", path_prefix, self.devname); - input_device::ensure_input_device(path.clone(), self.major, self.minor) - .expect(&format!("VUI-DEV-001: could not create {}", &path)); - //TODO: somewhat costly - } - Placement::None => {} - } + injector + .mknod_device_node( + &self.requesting_process, + &self.devname, + self.major, + self.minor, + ) + .await + .unwrap(); self.set_state(&State::Finished); } diff --git a/vuinputd/src/jobs/remove_device_job.rs b/vuinputd/src/jobs/remove_device_job.rs index 9097899..3e610b9 100644 --- a/vuinputd/src/jobs/remove_device_job.rs +++ b/vuinputd/src/jobs/remove_device_job.rs @@ -12,7 +12,7 @@ use log::debug; use crate::{ actions::action::Action, - global_config::{self, Placement}, + global_config::{self, get_container_runtime, Placement}, input_realizer::{input_device, runtime_data}, job_engine::job::{Job, JobTarget}, jobs::monitor_udev_job::EVENT_STORE, @@ -125,49 +125,22 @@ impl RemoveDeviceJob { let _ = netlink_data.insert("ACTION".to_string(), "remove".to_string()); - match global_config::get_placement() { - Placement::InContainer => { - let dev_path = format!("/dev/input/{}", &self.dev_name); - let remove_device_action = Action::RemoveDevice { - path: dev_path, - major: self.major, - minor: self.minor, - }; + let injector = get_container_runtime().injection_strategy(); - let child_pid_1 = - process_tools::start_action(remove_device_action, &self.requesting_process) - .expect("subprocess should work"); + injector + .remove_device_node( + &self.requesting_process, + &self.dev_name, + self.major, + self.minor, + ) + .await + .unwrap(); - let write_udev_runtime_data_action = Action::WriteUdevRuntimeData { - runtime_data: None, - major: self.major, - minor: self.minor, - }; - - let child_pid_2 = process_tools::start_action( - write_udev_runtime_data_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; - } - Placement::OnHost => { - let path_prefix = format!("/run/vuinputd/{}", global_config::get_vudevname()); - let devnode = format!("{}/dev-input/{}", path_prefix, self.dev_name); - input_device::remove_input_device(devnode.clone(), self.major, self.minor).expect( - &format!("VUI-DEV-003: could not remove device node {}", &devnode), - ); - runtime_data::delete_udev_data(&path_prefix, self.major, self.minor).expect( - &format!( - "VUI-UDEV-003: could not remove udev data from {}", - &path_prefix - ), - ); - } - Placement::None => {} - } + injector + .remove_udev_runtime_data(&self.requesting_process, self.major, self.minor) + .await + .unwrap(); // this is always in the container let emit_netlink_message = Action::EmitNetlinkMessage { diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 1b3c519..8e3f4e5 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -24,19 +24,20 @@ use base64::Engine as _; use log::info; use std::ffi::CString; use std::os::raw::c_char; +use std::path::PathBuf; use std::sync::atomic::AtomicU64; use std::sync::Mutex; pub mod cuse_device; +use crate::container_runtime::ContainerRuntime; use crate::cuse_device::evdev_write_watcher::{ initialize_evdev_write_watcher, 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::{DeviceOwner, DevicePolicy, Placement}; -use crate::input_realizer::host_fs; +use crate::global_config::{DeviceOwner, DevicePolicy, Placement, Scope}; use crate::jobs::monitor_udev_job::MonitorBackgroundLoop; pub mod process_tools; @@ -48,6 +49,7 @@ use crate::process_tools::*; pub mod actions; pub mod input_realizer; +pub mod container_runtime; pub mod global_config; pub mod jobs; pub mod vt_tools; @@ -101,59 +103,100 @@ struct Args { #[arg(long, value_enum, default_value_t)] device_policy: DevicePolicy, - /// Placement of device nodes and udev data - #[arg(long, value_enum, default_value_t)] - pub placement: Placement, + /// [DEPRECATED] Placement of device nodes and udev data. Maps to --container-runtime. Will be removed in a future version. + #[arg(long, value_enum)] + pub placement: Option, /// Owner of the created devices #[arg(long = "device-owner", value_enum, default_value_t)] pub device_owner: DeviceOwner, + + /// Container runtime used for name resolution and lifecycle events + #[arg(long, default_value_t = ContainerRuntime::Auto, value_enum)] + pub container_runtime: ContainerRuntime, + + /// Path to a custom strategy configuration file (used when runtime is 'custom-engine') + #[arg(long)] + pub strategy_file: Option, + + /// Bind to a single named container. If omitted, the daemon watches all running containers (Multi mode). + #[arg(long, value_name = "CONTAINER_NAME")] + pub target_container: Option, } -fn validate_args(args: &Args) -> Result<(), String> { - let action: &Option = match (&args.action, &args.action_base64) { - (None, None) => &None, - (None, Some(_)) => &args.action_base64, - (Some(_), None) => &args.action, - (Some(_), Some(_)) => { - return Err("--action and --action-base64 may not be used together".into()); - } - }; - - // action might only occur with target-pid - match ( - &args.major, - &args.minor, - &args.devname, - action, - &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-pid".into()); +impl Args { + pub fn get_scope(&self) -> Scope { + match &self.target_container { + Some(name) => Scope::Single(name.clone()), + None => Scope::Multi, } } - // major/minor must appear together - match (&args.major, &args.minor) { - (Some(_), Some(_)) | (None, None) => {} - _ => { - return Err("--major and --minor must be specified together or not at all".into()); + pub fn resolve_runtime(&self) -> ContainerRuntime { + if let Some(legacy_placement) = &self.placement { + return match legacy_placement { + Placement::InContainer => ContainerRuntime::GenericPlacementInContainer, + Placement::OnHost => ContainerRuntime::GenericPlacementOnHost, + Placement::None => ContainerRuntime::GenericSendNetlinkMessageOnly, + }; } + + self.container_runtime.clone() } - // devname length constraint - if let Some(devname) = &args.devname { - if devname.len() >= DEVNAME_MAX_LEN { - return Err(format!( - "--devname must be shorter than {} bytes", - DEVNAME_MAX_LEN - )); + fn validate_args(&self) -> Result<(), String> { + if self.placement.is_some() && self.container_runtime != ContainerRuntime::Auto { + return Err( + "Conflict: --placement and --container-runtime cannot be used together. \ + Please use only --container-runtime (the --placement flag is deprecated)." + .into(), + ); } - } - Ok(()) + let action: &Option = match (&self.action, &self.action_base64) { + (None, None) => &None, + (None, Some(_)) => &self.action_base64, + (Some(_), None) => &self.action, + (Some(_), Some(_)) => { + return Err("--action and --action-base64 may not be used together".into()); + } + }; + + // action might only occur with target-pid + match ( + self.major, + self.minor, + &self.devname, + action, + &self.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-pid".into()); + } + } + + // major/minor must appear together + match (self.major, self.minor) { + (Some(_), Some(_)) | (None, None) => {} + _ => { + return Err("--major and --minor must be specified together or not at all".into()); + } + } + + // devname length constraint + if let Some(devname) = &self.devname { + if devname.len() >= DEVNAME_MAX_LEN { + return Err(format!( + "--devname must be shorter than {} bytes", + DEVNAME_MAX_LEN + )); + } + } + + Ok(()) + } } fn main() -> std::io::Result<()> { @@ -164,7 +207,7 @@ fn main() -> std::io::Result<()> { .next() .expect("Couldn't retrieve program name"); - if let Err(e) = validate_args(&args) { + if let Err(e) = args.validate_args() { eprintln!("Error: {e}"); std::process::exit(2); } @@ -186,7 +229,8 @@ fn main() -> std::io::Result<()> { if action.is_some() { if let Some(target_pid) = args.target_pid { - process_tools::run_in_net_and_mnt_namespace(target_pid.as_str(),&args.device_owner).unwrap(); + 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); @@ -200,9 +244,11 @@ fn main() -> std::io::Result<()> { check_permissions().expect("failed to read the capabilities of the vuinputd process"); vt_tools::check_vt_status(); + let container_runtime = args.resolve_runtime(); + global_config::initialize_global_config( &args.device_policy, - &args.placement, + &container_runtime, &args.devname, &args.device_owner, ); @@ -235,10 +281,8 @@ fn main() -> std::io::Result<()> { None => "vuinput", Some(devname) => devname, }; - if args.placement == Placement::OnHost { - let path_prefix = format!("/run/vuinputd/{}", global_config::get_vudevname()); - let _ = host_fs::ensure_host_fs_structure(&path_prefix); - } + + container_runtime.initialize(); let vuinput_devicename = CString::new(format!("DEVNAME={}", vuinput_devicename)).unwrap(); From c5693d534f34b74ebdfdaaf6f81c840818ec450d Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 14 Apr 2026 20:21:21 +0000 Subject: [PATCH 091/108] Work on injection for container engine "incus" --- .../container_runtime/injection_strategy.rs | 68 +++++++++++++++++++ vuinputd/src/container_runtime/mod.rs | 5 +- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/vuinputd/src/container_runtime/injection_strategy.rs b/vuinputd/src/container_runtime/injection_strategy.rs index 9645e9c..a244663 100644 --- a/vuinputd/src/container_runtime/injection_strategy.rs +++ b/vuinputd/src/container_runtime/injection_strategy.rs @@ -13,6 +13,7 @@ use crate::{ pub static PLACEMENT_IN_CONTAINER: GenericPlacementInContainer = GenericPlacementInContainer {}; pub static PLACEMENT_ON_HOST: GenericPlacementOnHost = GenericPlacementOnHost {}; pub static SEND_NETLINK_ONLY: GenericSendNetlinkMessageOnly = GenericSendNetlinkMessageOnly {}; +pub static INCUS: Incus = Incus {}; #[async_trait] pub trait InjectionStrategy { @@ -60,6 +61,7 @@ pub trait InjectionStrategy { pub struct GenericPlacementInContainer {} pub struct GenericPlacementOnHost {} pub struct GenericSendNetlinkMessageOnly {} +pub struct Incus {} #[async_trait] impl InjectionStrategy for GenericPlacementInContainer { @@ -253,3 +255,69 @@ impl InjectionStrategy for GenericSendNetlinkMessageOnly { Ok(()) } } + +#[async_trait] +impl InjectionStrategy for Incus { + async fn mknod_device_node( + &self, + _requesting_process: &RequestingProcess, + devname: &str, + _major: u64, + _minor: u64, + ) -> anyhow::Result<()> { + let hostpath = format!("path=/dev/input/{}", devname); + let incuspath = format!("path=/dev/input/{}", devname); + let child = std::process::Command::new("/proc/self/exe") + .args([ + "incus", + "config", + "device", + "add", + "first", + devname, + "unix-char", + &incuspath, + &hostpath, + "mode=\"666\"", + ]) + .spawn()?; + let output = child.wait_with_output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + println!("incus\n {}\n{}\n", stdout, stderr); + Ok(()) + } + + async fn remove_device_node( + &self, + _requesting_process: &RequestingProcess, + _devname: &str, + _major: u64, + _minor: u64, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn write_udev_runtime_data( + &self, + requesting_process: &RequestingProcess, + runtime_data: &str, + major: u64, + minor: u64, + ) -> anyhow::Result<()> { + PLACEMENT_IN_CONTAINER + .write_udev_runtime_data(requesting_process, runtime_data, major, minor) + .await + } + + async fn remove_udev_runtime_data( + &self, + requesting_process: &RequestingProcess, + major: u64, + minor: u64, + ) -> anyhow::Result<()> { + PLACEMENT_IN_CONTAINER + .remove_udev_runtime_data(requesting_process, major, minor) + .await + } +} diff --git a/vuinputd/src/container_runtime/mod.rs b/vuinputd/src/container_runtime/mod.rs index 3d4de8c..43e4497 100644 --- a/vuinputd/src/container_runtime/mod.rs +++ b/vuinputd/src/container_runtime/mod.rs @@ -4,8 +4,7 @@ use crate::{ container_runtime::injection_strategy::{ - GenericPlacementInContainer, GenericPlacementOnHost, GenericSendNetlinkMessageOnly, - InjectionStrategy, PLACEMENT_IN_CONTAINER, PLACEMENT_ON_HOST, SEND_NETLINK_ONLY, + GenericPlacementInContainer, GenericPlacementOnHost, GenericSendNetlinkMessageOnly, INCUS, InjectionStrategy, PLACEMENT_IN_CONTAINER, PLACEMENT_ON_HOST, SEND_NETLINK_ONLY }, global_config::get_vudevname, }; @@ -67,7 +66,7 @@ impl ContainerRuntime { ContainerRuntime::GenericPlacementInContainer => &PLACEMENT_IN_CONTAINER, ContainerRuntime::GenericPlacementOnHost => &PLACEMENT_ON_HOST, ContainerRuntime::GenericSendNetlinkMessageOnly => &SEND_NETLINK_ONLY, - ContainerRuntime::Incus => todo!("not implemented yet"), + ContainerRuntime::Incus => &INCUS, ContainerRuntime::Docker => &PLACEMENT_IN_CONTAINER, ContainerRuntime::Podman => &PLACEMENT_IN_CONTAINER, ContainerRuntime::Nspawn => &PLACEMENT_IN_CONTAINER, From 53eb3c710105a0e18672077db1580601de9711c0 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 14 Apr 2026 20:42:26 +0000 Subject: [PATCH 092/108] Use --target-container when using incus --- .../src/container_runtime/injection_strategy.rs | 13 +++++++++---- vuinputd/src/global_config.rs | 7 +++++++ vuinputd/src/main.rs | 2 ++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/vuinputd/src/container_runtime/injection_strategy.rs b/vuinputd/src/container_runtime/injection_strategy.rs index a244663..5bb114a 100644 --- a/vuinputd/src/container_runtime/injection_strategy.rs +++ b/vuinputd/src/container_runtime/injection_strategy.rs @@ -2,11 +2,12 @@ // // Author: Johannes Leupolz +use anyhow::bail; use async_trait::async_trait; use crate::{ actions::action::Action, - global_config, + global_config::{self, get_scope}, input_realizer::{input_device, runtime_data}, process_tools::{self, Pid, RequestingProcess}, }; @@ -267,13 +268,17 @@ impl InjectionStrategy for Incus { ) -> anyhow::Result<()> { let hostpath = format!("path=/dev/input/{}", devname); let incuspath = format!("path=/dev/input/{}", devname); - let child = std::process::Command::new("/proc/self/exe") + let container_name = get_scope(); + let container_name = match container_name { + global_config::Scope::Multi => bail!("no container name given"), + global_config::Scope::Single(container_name) => container_name, + }; + let child = std::process::Command::new("/usr/bin/incus") .args([ - "incus", "config", "device", "add", - "first", + container_name, devname, "unix-char", &incuspath, diff --git a/vuinputd/src/global_config.rs b/vuinputd/src/global_config.rs index 0bbac32..84f7030 100644 --- a/vuinputd/src/global_config.rs +++ b/vuinputd/src/global_config.rs @@ -13,6 +13,7 @@ pub struct GlobalConfig { pub container_runtime: ContainerRuntime, pub vudevname: String, pub device_owner: DeviceOwner, + pub scope: Scope, } // The actual static variable. It starts empty and is set once in main(). @@ -83,6 +84,7 @@ pub fn initialize_global_config( container_runtime: &ContainerRuntime, devname: &Option, device_owner: &DeviceOwner, + scope: &Scope, ) { if CONFIG .set(GlobalConfig { @@ -90,6 +92,7 @@ pub fn initialize_global_config( container_runtime: container_runtime.clone(), vudevname: devname.clone().unwrap_or("vuinput".to_string()), device_owner: device_owner.clone(), + scope: scope.clone(), }) .is_err() { @@ -113,3 +116,7 @@ pub fn get_vudevname<'a>() -> &'a String { pub fn get_device_owner<'a>() -> &'a DeviceOwner { &CONFIG.get().unwrap().device_owner } + +pub fn get_scope<'a>() -> &'a Scope { + &CONFIG.get().unwrap().scope +} diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 8e3f4e5..875768b 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -245,12 +245,14 @@ fn main() -> std::io::Result<()> { vt_tools::check_vt_status(); let container_runtime = args.resolve_runtime(); + let scope= args.get_scope(); global_config::initialize_global_config( &args.device_policy, &container_runtime, &args.devname, &args.device_owner, + &scope, ); initialize_evdev_write_watcher().expect( "failed to initialize the watcher that watches for writes on the created evdev devices", From ab72e0bfc8edd540cdf8b613fcfeda85200acbbf Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Tue, 14 Apr 2026 20:55:15 +0000 Subject: [PATCH 093/108] Add cargo.lock --- Cargo.lock | 1244 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1244 insertions(+) create mode 100644 Cargo.lock diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9c75c43 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1244 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.60.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062dddbc1ba4aca46de6338e2bf87771414c335f7b2f2036e8f3e9befebf88e6" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "clap 3.2.25", + "env_logger 0.9.3", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "which", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "3.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" +dependencies = [ + "atty", + "bitflags 1.3.2", + "clap_lex 0.2.4", + "indexmap", + "strsim 0.10.0", + "termcolor", + "textwrap", +] + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex 0.7.6", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "cuse-lowlevel" +version = "0.1.0" +dependencies = [ + "bindgen", + "libc", + "pkg-config", +] + +[[package]] +name = "deranged" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libudev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +dependencies = [ + "libc", + "libudev-sys", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "uinput-ioctls" +version = "0.1.0" +dependencies = [ + "libc", + "nix", +] + +[[package]] +name = "unicode-ident" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vuinput-examples" +version = "0.1.0" +dependencies = [ + "libc", + "libudev", + "nix", + "uinput-ioctls", +] + +[[package]] +name = "vuinputd" +version = "0.3.2" +dependencies = [ + "anyhow", + "async-channel", + "async-io", + "async-trait", + "base64", + "clap 4.5.53", + "cuse-lowlevel", + "env_logger 0.11.8", + "futures", + "libc", + "libudev", + "log", + "nix", + "regex", + "serde", + "serde_json", + "smallvec", + "time", + "uinput-ioctls", +] + +[[package]] +name = "vuinputd-tests" +version = "0.1.0" +dependencies = [ + "clap 4.5.53", + "libc", + "libudev", + "nix", + "serde", + "serde_json", + "uinput-ioctls", + "vuinputd", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" From 5e79b0d1cb1f4f665e49e8706febbd53308d3b3b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:54:14 +0000 Subject: [PATCH 094/108] Bump time from 0.3.44 to 0.3.47 Bumps [time](https://github.com/time-rs/time) from 0.3.44 to 0.3.47. - [Release notes](https://github.com/time-rs/time/releases) - [Changelog](https://github.com/time-rs/time/blob/main/CHANGELOG.md) - [Commits](https://github.com/time-rs/time/compare/v0.3.44...v0.3.47) --- updated-dependencies: - dependency-name: time dependency-version: 0.3.47 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Cargo.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9c75c43..0325bd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -679,9 +679,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "once_cell" @@ -952,22 +952,22 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "uinput-ioctls" From 8c40fdc12005319ea16dceb752a8822abfc6039a Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 15 Apr 2026 22:19:13 +0000 Subject: [PATCH 095/108] Add banner for deepwiki --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 860f546..f37d734 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/joleuger/vuinputd) # vuinputd **Run Sunshine and other uinput-based apps inside containers — with full input isolation and zero kernel patches.** From 080a72906d9cd5b0b48fbbe85b2b6909e08efd6b Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 15 Apr 2026 22:19:51 +0000 Subject: [PATCH 096/108] Fix for incus --- vuinputd/src/container_runtime/injection_strategy.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vuinputd/src/container_runtime/injection_strategy.rs b/vuinputd/src/container_runtime/injection_strategy.rs index 5bb114a..939870e 100644 --- a/vuinputd/src/container_runtime/injection_strategy.rs +++ b/vuinputd/src/container_runtime/injection_strategy.rs @@ -283,7 +283,7 @@ impl InjectionStrategy for Incus { "unix-char", &incuspath, &hostpath, - "mode=\"666\"", + "mode=666", ]) .spawn()?; let output = child.wait_with_output()?; From 9e55cd84abbfe780ed71540fc0916cbefd09fcf3 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 15 Apr 2026 22:20:39 +0000 Subject: [PATCH 097/108] Fix for poll and read. --- .../src/cuse_device/evdev_write_watcher.rs | 10 ++-- vuinputd/src/cuse_device/state.rs | 52 +++++++++++++++---- vuinputd/src/cuse_device/vuinput_poll.rs | 32 ++++++++---- vuinputd/src/cuse_device/vuinput_read.rs | 8 +-- 4 files changed, 73 insertions(+), 29 deletions(-) diff --git a/vuinputd/src/cuse_device/evdev_write_watcher.rs b/vuinputd/src/cuse_device/evdev_write_watcher.rs index 485ec03..ed4517b 100644 --- a/vuinputd/src/cuse_device/evdev_write_watcher.rs +++ b/vuinputd/src/cuse_device/evdev_write_watcher.rs @@ -106,15 +106,11 @@ fn evdev_write_watch_loop(shutdown: Arc, epoll: Arc) { let state = super::state::get_vuinput_state(&fh); if let Ok(state) = state { let mut state = state.lock().unwrap(); - - for handle in state.poll.take_waiters() { - unsafe { - fuse_lowlevel::fuse_lowlevel_notify_poll(handle.as_ptr()); - fuse_lowlevel::fuse_pollhandle_destroy(handle.as_ptr()); - } + let handle = state.poll.take_waiters(); + if let Some(mut handle)= handle { + handle.notify(); } state.poll.pollphase = PollPhase::Readable; - state.poll.pending.clear(); } } } diff --git a/vuinputd/src/cuse_device/state.rs b/vuinputd/src/cuse_device/state.rs index 7e9ce53..3fd4870 100644 --- a/vuinputd/src/cuse_device/state.rs +++ b/vuinputd/src/cuse_device/state.rs @@ -11,7 +11,6 @@ use ::cuse_lowlevel::*; use smallvec::SmallVec; use crate::process_tools::RequestingProcess; -use smallvec::smallvec; pub type PendingPollHandles = SmallVec<[*mut fuse_lowlevel::fuse_pollhandle; 1]>; @@ -61,6 +60,43 @@ impl Default for PollPhase { } } +#[derive(Debug)] +pub struct PollHandle { + ptr: NonNull, + has_been_completed: bool, +} + +impl PollHandle { + pub fn new(ptr: NonNull) -> Self { + Self { + ptr: ptr, + has_been_completed: false, + } + } + pub fn notify(&mut self) { + if !self.has_been_completed { + unsafe { + fuse_lowlevel::fuse_lowlevel_notify_poll(self.ptr.as_ptr()); + fuse_lowlevel::fuse_pollhandle_destroy(self.ptr.as_ptr()); + } + self.has_been_completed = true; + } + } +} + +impl Drop for PollHandle { + fn drop(&mut self) { + if !self.has_been_completed { + unsafe { + fuse_lowlevel::fuse_pollhandle_destroy(self.ptr.as_ptr()); + } + self.has_been_completed = true; + } + } +} + +unsafe impl Send for PollHandle {} + /// this data structure ensures poll and read are synchronized. /// poll() and read() must synchronize through one shared readines /// state, and the state transitions must be done under the same per-handle mutex. @@ -78,31 +114,29 @@ pub struct PollState { /// Pending FUSE poll waiters for this device. /// Optimized for the common case of 0 or 1 waiter, but supports /// multiple concurrent poll() callers correctly. - pub pending: SmallVec<[NonNull; 1]>, + pending: Option, } impl PollState { pub fn new() -> PollState { PollState { pollphase: PollPhase::Empty, - pending: smallvec![], + pending: None, } } pub fn has_waiters(&self) -> bool { - !self.pending.is_empty() + !self.pending.is_some() } - pub fn add_waiter(&mut self, handle: NonNull) { - self.pending.push(handle); + pub fn set_waiter(&mut self, handle: NonNull) { + self.pending = Some(PollHandle::new(handle)); } - pub fn take_waiters(&mut self) -> SmallVec<[NonNull; 1]> { + pub fn take_waiters(&mut self) -> Option { std::mem::take(&mut self.pending) } } -unsafe impl Send for PollState {} - #[derive(Debug)] pub struct VuInputState { pub file: File, diff --git a/vuinputd/src/cuse_device/vuinput_poll.rs b/vuinputd/src/cuse_device/vuinput_poll.rs index d01b3e7..80c6ccf 100644 --- a/vuinputd/src/cuse_device/vuinput_poll.rs +++ b/vuinputd/src/cuse_device/vuinput_poll.rs @@ -5,7 +5,7 @@ use crate::cuse_device::*; use crate::global_config::get_device_policy; use ::cuse_lowlevel::*; -use libc::{__s32, __u16, input_event}; +use libc::{__s32, __u16, input_event, POLLIN}; use libc::{off_t, size_t, EIO}; use libc::{uinput_abs_setup, uinput_setup}; use log::{debug, trace}; @@ -16,13 +16,18 @@ use std::ptr::NonNull; use uinput_ioctls::*; // https://github.com/libfuse/libfuse/blob/master/example/poll.c +// https://github.com/torvalds/linux/blob/f82b61de0f5dc58930fdb773b9e843573fcc374b/fs/fuse/file.c + +// Note that poll in fuse blocks (because it calls fuse_simple_request, which is designed to block) +// until the handle is notified. + pub unsafe extern "C" fn vuinput_poll( req: fuse_lowlevel::fuse_req_t, fi: *mut fuse_lowlevel::fuse_file_info, ph: *mut fuse_lowlevel::fuse_pollhandle, ) { - fuse_lowlevel::fuse_reply_err(req, EIO); - return; + //fuse_lowlevel::fuse_reply_err(req, EIO); + //return; let vuinput_state_mutex = get_vuinput_state(&VuFileHandle::from_fuse_file_info(fi.as_ref().unwrap())).unwrap(); @@ -32,16 +37,25 @@ pub unsafe extern "C" fn vuinput_poll( match vuinput_state.poll.pollphase { PollPhase::Empty => { - let ph = NonNull::::new(ph); - vuinput_state.poll.add_waiter(ph.unwrap()); + if ph != std::ptr::null_mut() { + let ph = NonNull::::new(ph); + vuinput_state.poll.set_waiter(ph.unwrap()); + } + fuse_lowlevel::fuse_reply_poll(req, 0); } PollPhase::Readable => { - fuse_lowlevel::fuse_lowlevel_notify_poll(ph); - fuse_lowlevel::fuse_pollhandle_destroy(ph); + if ph != std::ptr::null_mut() { + fuse_lowlevel::fuse_lowlevel_notify_poll(ph); + fuse_lowlevel::fuse_pollhandle_destroy(ph); + } + fuse_lowlevel::fuse_reply_poll(req, POLLIN.try_into().unwrap()); } PollPhase::Reading => { - fuse_lowlevel::fuse_lowlevel_notify_poll(ph); - fuse_lowlevel::fuse_pollhandle_destroy(ph); + if ph != std::ptr::null_mut() { + fuse_lowlevel::fuse_lowlevel_notify_poll(ph); + fuse_lowlevel::fuse_pollhandle_destroy(ph); + } + fuse_lowlevel::fuse_reply_poll(req, POLLIN.try_into().unwrap()); } } } diff --git a/vuinputd/src/cuse_device/vuinput_read.rs b/vuinputd/src/cuse_device/vuinput_read.rs index 8d5d451..9a6b411 100644 --- a/vuinputd/src/cuse_device/vuinput_read.rs +++ b/vuinputd/src/cuse_device/vuinput_read.rs @@ -23,8 +23,8 @@ pub unsafe extern "C" fn vuinput_read( "vuinput_read: offset needs to be 0 but is {}", _off ); - fuse_lowlevel::fuse_reply_err(_req, EIO); - return; + //fuse_lowlevel::fuse_reply_err(_req, EIO); + //return; let fh = &(*_fi).fh; let vuinput_state_mutex = @@ -41,10 +41,10 @@ pub unsafe extern "C" fn vuinput_read( vuinput_state.poll.pollphase = PollPhase::Reading; // read up to 24 bytes - println!("vuinput_read: read"); + //println!("vuinput_read: read"); let result = vuinput_state.file.read(&mut buffer); - println!("vuinput_read: read finished"); + //println!("vuinput_read: read finished"); match result { Ok(NORMAL_SIZE) => { if !is_compat { From 919b420e43cc955faaf864de357f66f53e32e135 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 15 Apr 2026 22:25:17 +0000 Subject: [PATCH 098/108] Remove tracing code for a mutex in read --- vuinputd/src/cuse_device/vuinput_read.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/vuinputd/src/cuse_device/vuinput_read.rs b/vuinputd/src/cuse_device/vuinput_read.rs index 9a6b411..08eacfb 100644 --- a/vuinputd/src/cuse_device/vuinput_read.rs +++ b/vuinputd/src/cuse_device/vuinput_read.rs @@ -29,9 +29,7 @@ pub unsafe extern "C" fn vuinput_read( let fh = &(*_fi).fh; let vuinput_state_mutex = get_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap(); - println!("vuinput_read: lock"); let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); - println!("vuinput_read: locked"); const NORMAL_SIZE: usize = std::mem::size_of::(); let is_compat = vuinput_state.requesting_process.is_compat; From 218fbdbcbf9701330ec88b8dd0ae7289cc4ae8b6 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 16 Apr 2026 07:41:54 +0000 Subject: [PATCH 099/108] incus: add device removal --- .../container_runtime/injection_strategy.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/vuinputd/src/container_runtime/injection_strategy.rs b/vuinputd/src/container_runtime/injection_strategy.rs index 939870e..c6bd05c 100644 --- a/vuinputd/src/container_runtime/injection_strategy.rs +++ b/vuinputd/src/container_runtime/injection_strategy.rs @@ -296,10 +296,28 @@ impl InjectionStrategy for Incus { async fn remove_device_node( &self, _requesting_process: &RequestingProcess, - _devname: &str, + devname: &str, _major: u64, _minor: u64, ) -> anyhow::Result<()> { + let container_name = get_scope(); + let container_name = match container_name { + global_config::Scope::Multi => bail!("no container name given"), + global_config::Scope::Single(container_name) => container_name, + }; + let child = std::process::Command::new("/usr/bin/incus") + .args([ + "config", + "device", + "remove", + container_name, + devname, + ]) + .spawn()?; + let output = child.wait_with_output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + println!("incus\n {}\n{}\n", stdout, stderr); Ok(()) } From e6c38be17291fcf08195e1856e490e882d4e034f Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 16 Apr 2026 08:38:04 +0000 Subject: [PATCH 100/108] Make udev_event (aka netlink message) container engine aware --- .../container_runtime/injection_strategy.rs | 73 ++++++++++++++++--- vuinputd/src/jobs/emit_udev_event_job.rs | 10 +-- 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/vuinputd/src/container_runtime/injection_strategy.rs b/vuinputd/src/container_runtime/injection_strategy.rs index c6bd05c..e0847b8 100644 --- a/vuinputd/src/container_runtime/injection_strategy.rs +++ b/vuinputd/src/container_runtime/injection_strategy.rs @@ -2,6 +2,8 @@ // // Author: Johannes Leupolz +use std::collections::HashMap; + use anyhow::bail; use async_trait::async_trait; @@ -45,11 +47,6 @@ pub trait InjectionStrategy { minor: u64, ) -> anyhow::Result<()>; - /// Emit netlink message. - /// emit_netlink_message is the same for all container engines, we add - /// a method to enable more flexibility in the future and also allow tests. - // async fn emit_netlink_message(&self, netlink_message: HashMap,) -> anyhow::Result<()>; - /// Remove runtime data. async fn remove_udev_runtime_data( &self, @@ -57,6 +54,13 @@ pub trait InjectionStrategy { major: u64, minor: u64, ) -> anyhow::Result<()>; + + /// Emit netlink message. + async fn emit_netlink_message( + &self, + requesting_process: &RequestingProcess, + netlink_message: HashMap, + ) -> anyhow::Result<()>; } pub struct GenericPlacementInContainer {} @@ -149,6 +153,23 @@ impl InjectionStrategy for GenericPlacementInContainer { let _exit_info = process_tools::await_process(Pid::Pid(child_pid_2)).await; Ok(()) } + + /// Emit netlink message. + async fn emit_netlink_message( + &self, + requesting_process: &RequestingProcess, + netlink_message: HashMap, + ) -> anyhow::Result<()> { + let emit_netlink_message = Action::EmitNetlinkMessage { + netlink_message: netlink_message, + }; + + let child_pid = process_tools::start_action(emit_netlink_message, requesting_process) + .expect("subprocess should work"); + + let _exit_info = process_tools::await_process(Pid::Pid(child_pid)).await; + Ok(()) + } } #[async_trait] @@ -213,6 +234,18 @@ impl InjectionStrategy for GenericPlacementOnHost { )); Ok(()) } + + + /// Emit netlink message. + async fn emit_netlink_message( + &self, + requesting_process: &RequestingProcess, + netlink_message: HashMap, + ) -> anyhow::Result<()> { + PLACEMENT_IN_CONTAINER + .emit_netlink_message(requesting_process, netlink_message) + .await + } } #[async_trait] @@ -255,6 +288,17 @@ impl InjectionStrategy for GenericSendNetlinkMessageOnly { ) -> anyhow::Result<()> { Ok(()) } + + /// Emit netlink message. + async fn emit_netlink_message( + &self, + requesting_process: &RequestingProcess, + netlink_message: HashMap, + ) -> anyhow::Result<()> { + PLACEMENT_IN_CONTAINER + .emit_netlink_message(requesting_process, netlink_message) + .await + } } #[async_trait] @@ -306,13 +350,7 @@ impl InjectionStrategy for Incus { global_config::Scope::Single(container_name) => container_name, }; let child = std::process::Command::new("/usr/bin/incus") - .args([ - "config", - "device", - "remove", - container_name, - devname, - ]) + .args(["config", "device", "remove", container_name, devname]) .spawn()?; let output = child.wait_with_output()?; let stdout = String::from_utf8_lossy(&output.stdout); @@ -343,4 +381,15 @@ impl InjectionStrategy for Incus { .remove_udev_runtime_data(requesting_process, major, minor) .await } + + /// Emit netlink message. + async fn emit_netlink_message( + &self, + requesting_process: &RequestingProcess, + netlink_message: HashMap, + ) -> anyhow::Result<()> { + PLACEMENT_IN_CONTAINER + .emit_netlink_message(requesting_process, netlink_message) + .await + } } diff --git a/vuinputd/src/jobs/emit_udev_event_job.rs b/vuinputd/src/jobs/emit_udev_event_job.rs index 786b413..18d0389 100644 --- a/vuinputd/src/jobs/emit_udev_event_job.rs +++ b/vuinputd/src/jobs/emit_udev_event_job.rs @@ -158,15 +158,7 @@ impl EmitUdevEventJob { .await .unwrap(); - // this is always in the container - let emit_netlink_message = Action::EmitNetlinkMessage { - netlink_message: netlink_data.clone(), - }; - - let child_pid = process_tools::start_action(emit_netlink_message, &self.requesting_process) - .expect("subprocess should work"); - - let _exit_info = await_process(Pid::Pid(child_pid)).await.unwrap(); + injector.emit_netlink_message(&self.requesting_process, netlink_data).await.unwrap(); self.set_state(&State::Finished); } From 6eb5a2985af13fd1c1331154b068f235f2f48dad Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 16 Apr 2026 19:35:01 +0000 Subject: [PATCH 101/108] Fix 100% CPU bug --- vuinputd/src/cuse_device/evdev_write_watcher.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vuinputd/src/cuse_device/evdev_write_watcher.rs b/vuinputd/src/cuse_device/evdev_write_watcher.rs index ed4517b..e6160bd 100644 --- a/vuinputd/src/cuse_device/evdev_write_watcher.rs +++ b/vuinputd/src/cuse_device/evdev_write_watcher.rs @@ -62,7 +62,7 @@ impl EvdevWriteWatcher { self.epoll.add( &vuinput_state.file, - EpollEvent::new(EpollFlags::EPOLLIN, fh), + EpollEvent::new(EpollFlags::EPOLLIN | EpollFlags::EPOLLET, fh), ) } @@ -107,7 +107,7 @@ fn evdev_write_watch_loop(shutdown: Arc, epoll: Arc) { if let Ok(state) = state { let mut state = state.lock().unwrap(); let handle = state.poll.take_waiters(); - if let Some(mut handle)= handle { + if let Some(mut handle) = handle { handle.notify(); } state.poll.pollphase = PollPhase::Readable; From 152ffd971141696bbe9a7bd5c64b42ccd5078c3d Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Thu, 16 Apr 2026 20:29:48 +0000 Subject: [PATCH 102/108] Enter user namespace in the incus case --- .../container_runtime/injection_strategy.rs | 36 +++++++---- vuinputd/src/cuse_device/vuinput_poll.rs | 2 - vuinputd/src/jobs/emit_udev_event_job.rs | 5 +- vuinputd/src/jobs/remove_device_job.rs | 14 ++--- vuinputd/src/main.rs | 22 +++++-- vuinputd/src/process_tools/mod.rs | 63 ++++++++++++------- 6 files changed, 88 insertions(+), 54 deletions(-) diff --git a/vuinputd/src/container_runtime/injection_strategy.rs b/vuinputd/src/container_runtime/injection_strategy.rs index e0847b8..896a633 100644 --- a/vuinputd/src/container_runtime/injection_strategy.rs +++ b/vuinputd/src/container_runtime/injection_strategy.rs @@ -83,8 +83,9 @@ impl InjectionStrategy for GenericPlacementInContainer { minor: minor, }; - let child_pid = process_tools::start_action(mknod_device_action, &requesting_process) - .expect("subprocess should work"); + let child_pid = + process_tools::start_action(mknod_device_action, &requesting_process, false) + .expect("subprocess should work"); let _exit_info = process_tools::await_process(Pid::Pid(child_pid)) .await @@ -106,8 +107,9 @@ impl InjectionStrategy for GenericPlacementInContainer { minor: minor, }; - let child_pid_1 = process_tools::start_action(remove_device_action, &requesting_process) - .expect("subprocess should work"); + let child_pid_1 = + process_tools::start_action(remove_device_action, &requesting_process, false) + .expect("subprocess should work"); let _exit_info = process_tools::await_process(Pid::Pid(child_pid_1)).await; Ok(()) @@ -126,8 +128,9 @@ impl InjectionStrategy for GenericPlacementInContainer { minor: minor, }; - let child_pid = process_tools::start_action(write_udev_runtime_data, &requesting_process) - .expect("subprocess should work"); + let child_pid = + process_tools::start_action(write_udev_runtime_data, &requesting_process, false) + .expect("subprocess should work"); let _exit_info = process_tools::await_process(Pid::Pid(child_pid)) .await @@ -148,7 +151,7 @@ impl InjectionStrategy for GenericPlacementInContainer { }; let child_pid_2 = - process_tools::start_action(write_udev_runtime_data_action, &requesting_process) + process_tools::start_action(write_udev_runtime_data_action, &requesting_process, false) .expect("subprocess should work"); let _exit_info = process_tools::await_process(Pid::Pid(child_pid_2)).await; Ok(()) @@ -164,8 +167,9 @@ impl InjectionStrategy for GenericPlacementInContainer { netlink_message: netlink_message, }; - let child_pid = process_tools::start_action(emit_netlink_message, requesting_process) - .expect("subprocess should work"); + let child_pid = + process_tools::start_action(emit_netlink_message, requesting_process, false) + .expect("subprocess should work"); let _exit_info = process_tools::await_process(Pid::Pid(child_pid)).await; Ok(()) @@ -235,7 +239,6 @@ impl InjectionStrategy for GenericPlacementOnHost { Ok(()) } - /// Emit netlink message. async fn emit_netlink_message( &self, @@ -382,14 +385,21 @@ impl InjectionStrategy for Incus { .await } + /// Emit netlink message. /// Emit netlink message. async fn emit_netlink_message( &self, requesting_process: &RequestingProcess, netlink_message: HashMap, ) -> anyhow::Result<()> { - PLACEMENT_IN_CONTAINER - .emit_netlink_message(requesting_process, netlink_message) - .await + let emit_netlink_message = Action::EmitNetlinkMessage { + netlink_message: netlink_message, + }; + + let child_pid = process_tools::start_action(emit_netlink_message, requesting_process, true) + .expect("subprocess should work"); + + let _exit_info = process_tools::await_process(Pid::Pid(child_pid)).await; + Ok(()) } } diff --git a/vuinputd/src/cuse_device/vuinput_poll.rs b/vuinputd/src/cuse_device/vuinput_poll.rs index 80c6ccf..2dd658d 100644 --- a/vuinputd/src/cuse_device/vuinput_poll.rs +++ b/vuinputd/src/cuse_device/vuinput_poll.rs @@ -33,8 +33,6 @@ pub unsafe extern "C" fn vuinput_poll( get_vuinput_state(&VuFileHandle::from_fuse_file_info(fi.as_ref().unwrap())).unwrap(); let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); - debug!("poll"); - match vuinput_state.poll.pollphase { PollPhase::Empty => { if ph != std::ptr::null_mut() { diff --git a/vuinputd/src/jobs/emit_udev_event_job.rs b/vuinputd/src/jobs/emit_udev_event_job.rs index 18d0389..ca5ff90 100644 --- a/vuinputd/src/jobs/emit_udev_event_job.rs +++ b/vuinputd/src/jobs/emit_udev_event_job.rs @@ -158,7 +158,10 @@ impl EmitUdevEventJob { .await .unwrap(); - injector.emit_netlink_message(&self.requesting_process, netlink_data).await.unwrap(); + injector + .emit_netlink_message(&self.requesting_process, netlink_data) + .await + .unwrap(); self.set_state(&State::Finished); } diff --git a/vuinputd/src/jobs/remove_device_job.rs b/vuinputd/src/jobs/remove_device_job.rs index 3e610b9..503e9cd 100644 --- a/vuinputd/src/jobs/remove_device_job.rs +++ b/vuinputd/src/jobs/remove_device_job.rs @@ -142,16 +142,10 @@ impl RemoveDeviceJob { .await .unwrap(); - // this is always in the container - let emit_netlink_message = Action::EmitNetlinkMessage { - netlink_message: netlink_data.clone(), - }; - - let child_pid_netlink = - process_tools::start_action(emit_netlink_message, &self.requesting_process) - .expect("subprocess should work"); - - let _exit_info = await_process(Pid::Pid(child_pid_netlink)).await; + injector + .emit_netlink_message(&self.requesting_process, netlink_data) + .await + .unwrap(); self.set_state(&State::Finished); } diff --git a/vuinputd/src/main.rs b/vuinputd/src/main.rs index 875768b..701d58f 100644 --- a/vuinputd/src/main.rs +++ b/vuinputd/src/main.rs @@ -82,14 +82,22 @@ struct Args { #[arg(long = "action-base64", value_name = "BASE64")] pub action_base64: Option, - /// Process id that is used as the namespace source (e.g. 1234 is used to read the namespaces from /proc/1234/ns). + /// Process id that is used as the namespace source (e.g. 1234 is used to read the namespaces from /proc/1234/ns). Enters net and + /// mnt namespaces by default. #[arg( 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)." + help = "Process id that is used as the namespace source (e.g. 1234 is used to read the namespaces from /proc/1234/ns). Enters net and mnt namespaces by default." )] pub target_pid: Option, + /// Enter also the user namespace. Used together with --target-pid. + #[arg( + long = "enter-user-namespace", + help = "Enter also the user namespace. Used together with --target-pid." + )] + pub enter_user_namespace: bool, + #[arg( long = "vt-guard", help = "Prevent all keyboard input from reaching the VT by setting K_OFF on /dev/tty0.", @@ -229,8 +237,12 @@ fn main() -> std::io::Result<()> { if action.is_some() { if let Some(target_pid) = args.target_pid { - process_tools::run_in_net_and_mnt_namespace(target_pid.as_str(), &args.device_owner) - .unwrap(); + process_tools::run_in_net_and_mnt_namespace( + target_pid.as_str(), + &args.device_owner, + args.enter_user_namespace, + ) + .unwrap(); } let error_code = actions::handle_action::handle_cli_action(action.unwrap()); std::process::exit(error_code); @@ -245,7 +257,7 @@ fn main() -> std::io::Result<()> { vt_tools::check_vt_status(); let container_runtime = args.resolve_runtime(); - let scope= args.get_scope(); + let scope = args.get_scope(); global_config::initialize_global_config( &args.device_policy, diff --git a/vuinputd/src/process_tools/mod.rs b/vuinputd/src/process_tools/mod.rs index ad78125..d0dac8b 100644 --- a/vuinputd/src/process_tools/mod.rs +++ b/vuinputd/src/process_tools/mod.rs @@ -282,46 +282,57 @@ fn print_debug_string(action: &str, ns: &RequestingProcess) { /// Runs a function inside the given network and mount namespaces. /// Returns the child PID so the caller can `waitpid` on it. -pub fn start_action(action: Action, ns: &RequestingProcess) -> anyhow::Result { +pub fn start_action( + action: Action, + ns: &RequestingProcess, + enter_user_ns: bool, +) -> anyhow::Result { 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-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. - libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL); - Ok(()) - }) - .spawn() - .expect("failed to start vuinputd") + let mut cmd = Command::new("/proc/self/exe"); + cmd.args([ + "--action", + action_json.as_str(), + "--target-pid", + ns.pid_requestor_root.to_string_rep().as_str(), + "--device-owner", + device_owner.as_str(), + ]); + if enter_user_ns { + cmd.arg("--enter-user-namespace"); + } + cmd.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") }; Result::Ok(child.id()) } -pub fn run_in_net_and_mnt_namespace(target_pid: &str, device_owner: &DeviceOwner) -> anyhow::Result<()> { +pub fn run_in_net_and_mnt_namespace( + target_pid: &str, + device_owner: &DeviceOwner, + enter_user_ns: bool, +) -> anyhow::Result<()> { debug!( "Entering namespaces of process {}. We assume this is the root process of the container.", target_pid ); let fs_uid_gid = if *device_owner == DeviceOwner::ContainerDevFolder { - let pid:u32 = target_pid.trim().parse()?; + 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)) + 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 }; @@ -331,16 +342,22 @@ pub fn run_in_net_and_mnt_namespace(target_pid: &str, device_owner: &DeviceOwner 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 user = File::open(nspath.to_string() + "/user")?; let net = File::open(nspath.to_string() + "/net")?; let mnt = File::open(nspath.to_string() + "/mnt")?; unsafe { // enter namespaces + if enter_user_ns { + libc::setns(user.as_raw_fd(), libc::CLONE_NEWUSER); + libc::setresgid(0, 0, 0); + libc::setresuid(0, 0, 0); + } 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 { + if let Some((fs_uid, fs_gid)) = fs_uid_gid { ns_fscreds::acquire_uid_and_gid(fs_uid, fs_gid)?; } From aea990946de936a075cd42458c2988372b952210 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Fri, 17 Apr 2026 13:15:31 +0000 Subject: [PATCH 103/108] Notify all pending waiters on close --- vuinputd/src/cuse_device/state.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/vuinputd/src/cuse_device/state.rs b/vuinputd/src/cuse_device/state.rs index 3fd4870..d4ac899 100644 --- a/vuinputd/src/cuse_device/state.rs +++ b/vuinputd/src/cuse_device/state.rs @@ -137,6 +137,16 @@ impl PollState { } } +impl Drop for PollState { + fn drop(&mut self) { + //when the device closes, notify all pending waiters + let old_handle = self.take_waiters(); + if let Some(mut old_handle) = old_handle { + old_handle.notify(); + } + } +} + #[derive(Debug)] pub struct VuInputState { pub file: File, From 341ba075234b59da1349a1572331c5d18cd93939 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Wed, 6 May 2026 20:19:07 +0000 Subject: [PATCH 104/108] Remark on NixOS --- README.md | 5 +++-- docs/USAGE-NIXOS.md | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 docs/USAGE-NIXOS.md diff --git a/README.md b/README.md index f37d734..af96208 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,9 @@ sequenceDiagram ## Documentation See [docs/BUILD.md](https://github.com/joleuger/vuinputd/blob/main/docs/BUILD.md) for a short build and installation guide. -See [docs/DESIGN.md](https://github.com/joleuger/vuinputd/blob/main/docs/DESIGN.md) for a detailed overview of the architecture, design trade-offs, and security considerations. -See [docs/USAGE.md](https://github.com/joleuger/vuinputd/blob/main/docs/USAGE.md) for a short usage guide. +See [docs/DESIGN.md](https://github.com/joleuger/vuinputd/blob/main/docs/DESIGN.md) for a detailed overview of the architecture, design trade-offs, and security considerations. +See [docs/USAGE.md](https://github.com/joleuger/vuinputd/blob/main/docs/USAGE.md) for a short usage guide. +See [docs/USAGE-NIXOS.md](https://github.com/joleuger/vuinputd/blob/main/docs/USAGE-NIXOS.md) for a short usage guide for NixOS. See [docs/DEBUG.md](https://github.com/joleuger/vuinputd/blob/main/docs/DEBUG.md) for a guide how to debug problems with containers. --- diff --git a/docs/USAGE-NIXOS.md b/docs/USAGE-NIXOS.md new file mode 100644 index 0000000..c8408c8 --- /dev/null +++ b/docs/USAGE-NIXOS.md @@ -0,0 +1,13 @@ +# Usage Guide for NixOS + +This guide will explain how to use `vuinputd` on NixOS. For general remarks, please refer to [USAGE.md](USAGE.md). + +Currently, this is WIP. The plan is to make NixOS one of the main platforms for vuinputd. +--- + +## Configurations in the community + +* [ShaneTRS](https://github.com/ShaneTRS/nixos-config/) +* [griffi-gh](https://github.com/girl-pp-ua/nixos-infra/) + +Feel free to create a github issue or a PR to add your configuration or your remarks. \ No newline at end of file From 87694e1a9681c549fb1e0030ed4bc9f10a001ad0 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Fri, 8 May 2026 10:27:43 +0000 Subject: [PATCH 105/108] Test description for NixOS --- distro-tests/nixos/README.md | 16 ++++ .../nixos/vuinputd-test-automation.nix | 90 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 distro-tests/nixos/README.md create mode 100644 distro-tests/nixos/vuinputd-test-automation.nix diff --git a/distro-tests/nixos/README.md b/distro-tests/nixos/README.md new file mode 100644 index 0000000..6460ad1 --- /dev/null +++ b/distro-tests/nixos/README.md @@ -0,0 +1,16 @@ + +> incus image copy images:nixos/25.11 local: --alias nixos/25.11 --vm +> incus launch local:nixos/25.11 nixos-vm --vm +> incus stop local:nixos/25.11 nixos-vm +> incus config set nixos-vm limits.memory 3GiB +> incus config set nixos-vm security.secureboot false +> incus start nixos-vm + +> incus exec nixos-vm sed -- -i '/imports = \[/a\ ./vuinputd-test-automation.nix' /etc/nixos/configuration.nix + +> incus file push vuinputd-test-automation.nix nixos-vm/etc/nixos/vuinputd-test-automation.nix + +> incus exec nixos-vm nixos-rebuild -- switch --max-jobs 1 + +Execute the test +> incus exec nixos-vm bwrap -- --unshare-net --ro-bind / / --tmpfs /tmp --tmpfs /run/udev --dev-bind /run/vuinputd/vuinput/dev-input /dev/input --dev-bind /dev/vuinput /dev/uinput /run/current-system/sw/bin/test-scenarios basic-keyboard \ No newline at end of file diff --git a/distro-tests/nixos/vuinputd-test-automation.nix b/distro-tests/nixos/vuinputd-test-automation.nix new file mode 100644 index 0000000..97dbe32 --- /dev/null +++ b/distro-tests/nixos/vuinputd-test-automation.nix @@ -0,0 +1,90 @@ +{ config, pkgs, lib, ... }: +let + vuinputd = pkgs.rustPlatform.buildRustPackage { + pname = "vuinputd"; + version = "0.3.2-git"; + + buildType = "debug"; + + nativeBuildInputs = [ + pkgs.pkg-config + pkgs.rustPlatform.bindgenHook + # breakpointHook + ]; + + buildInputs = [pkgs.udev pkgs.fuse3]; + + src = pkgs.fetchFromGitHub { + owner = "joleuger"; + repo = "vuinputd"; + rev = "8c40fdc12005319ea16dceb752a8822abfc6039a"; + hash = "sha256-8Q34B04BngZqRLyixeFq8F1t5wFnk6JpaG3EEbgKRcU="; + }; + + cargoHash = "sha256-nJw9bRh6Yn9g1H5SeoT6zxgZLCqV3AtAs9gMfE+P+CU="; + + # Recent versions of fuse3 can also have libfuse_* types + postPatch = '' + substituteInPlace cuse-lowlevel/build.rs \ + --replace-fail '.allowlist_type("(?i)^fuse.*")' '.allowlist_type("(?i)^(fuse|libfuse).*")' + ''; + + postInstall = '' + mkdir -p $out/lib/udev/rules.d + mkdir $out/lib/udev/hwdb.d + cp vuinputd/udev/*.rules $out/lib/udev/rules.d/ + cp vuinputd/udev/*.hwdb $out/lib/udev/hwdb.d/ + ''; +}; +in +{ + environment.systemPackages = with pkgs; [ + vim + pkg-config + udev + fuse3 + git + rustc + cargo + bubblewrap # for testing + + vuinputd + ]; + + systemd.services.vuinputd = { + enable = true; + wantedBy = ["multi-user.target"]; + unitConfig = { + Description = "Virtual input (/dev/vuinput) daemon"; + }; + serviceConfig = { + Type = "exec"; + ExecStartPre = pkgs.writeShellScript "mount-tmpfs-dev-input" '' + mkdir -p /run/vuinputd/vuinput/dev-input + ${pkgs.util-linux}/bin/mount -t tmpfs -o rw,dev,nosuid tpmfs /run/vuinputd/vuinput/dev-input + ''; + ExecStart = "${lib.getExe vuinputd} --major 120 --minor 414795 --placement on-host"; + ExecStopPost = pkgs.writeShellScript "umount-dev-input" '' + ${pkgs.util-linux}/bin/umount /run/vuinputd/vuinput/dev-input + ''; + Restart = "on-failure"; + DeviceAllow = "char-* rwm"; + + Environment = [ + "RUST_LOG=debug" + ]; + }; + }; + systemd.services.vuinputd-chmod = { + unitConfig.Description = "Chmod 666 the /dev/vuinput"; + wantedBy = ["vuinputd.service"]; + after = ["vuinputd.service"]; + + serviceConfig = { + ExecStart = pkgs.writeShellScript "chmod-vuinput" '' + sleep 2 && chmod 666 /dev/vuinput + ''; + }; + }; +} + From 2c8d0f9af1b879ff5cc6c8712837e9989ffaf81b Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sat, 9 May 2026 06:44:44 +0000 Subject: [PATCH 106/108] Improved README.md for testing NixOS using incus --- distro-tests/nixos/README.md | 278 +++++++++++++++++++++++++++++++++-- 1 file changed, 267 insertions(+), 11 deletions(-) diff --git a/distro-tests/nixos/README.md b/distro-tests/nixos/README.md index 6460ad1..3b0ec54 100644 --- a/distro-tests/nixos/README.md +++ b/distro-tests/nixos/README.md @@ -1,16 +1,272 @@ +# Testing vuinputd on NixOS with Incus -> incus image copy images:nixos/25.11 local: --alias nixos/25.11 --vm -> incus launch local:nixos/25.11 nixos-vm --vm -> incus stop local:nixos/25.11 nixos-vm -> incus config set nixos-vm limits.memory 3GiB -> incus config set nixos-vm security.secureboot false -> incus start nixos-vm +This guide walks through setting up a reproducible NixOS test environment for `vuinputd` +using [Incus](https://linuxcontainers.org/incus/), an open-source system container and VM manager. -> incus exec nixos-vm sed -- -i '/imports = \[/a\ ./vuinputd-test-automation.nix' /etc/nixos/configuration.nix +--- -> incus file push vuinputd-test-automation.nix nixos-vm/etc/nixos/vuinputd-test-automation.nix +## Complete Script -> incus exec nixos-vm nixos-rebuild -- switch --max-jobs 1 +If you are already familiar with Incus and NixOS, here is the full sequence at a glance. +The sections below explain each step in detail. -Execute the test -> incus exec nixos-vm bwrap -- --unshare-net --ro-bind / / --tmpfs /tmp --tmpfs /run/udev --dev-bind /run/vuinputd/vuinput/dev-input /dev/input --dev-bind /dev/vuinput /dev/uinput /run/current-system/sw/bin/test-scenarios basic-keyboard \ No newline at end of file +```bash +# --- Setup --- +# Download NixOS VM image (only needed once) +incus image copy images:nixos/25.11 local: --alias nixos/25.11 --vm + +# Create and start the VM +incus launch local:nixos/25.11 nixos-vm --vm + +# Adjust resources (requires a stop/start cycle) +incus stop nixos-vm +incus config set nixos-vm limits.memory 3GiB +incus config set nixos-vm security.secureboot false +incus start nixos-vm + +# --- Configuration --- +# Extend the NixOS configuration to include the vuinputd test module +incus exec nixos-vm -- sed -i '/imports = \[/a\ ./vuinputd-test-automation.nix' \ + /etc/nixos/configuration.nix + +# Push the test module into the VM +incus file push vuinputd-test-automation.nix nixos-vm/etc/nixos/vuinputd-test-automation.nix + +# Apply the configuration (takes a few minutes on first run) +incus exec nixos-vm -- nixos-rebuild switch --max-jobs 1 + +# --- Run the test --- +incus exec nixos-vm -- bwrap \ + --unshare-net \ + --ro-bind / / \ + --tmpfs /tmp \ + --tmpfs /run/udev \ + --dev-bind /run/vuinputd/vuinput/dev-input /dev/input \ + --dev-bind /dev/vuinput /dev/uinput \ + /run/current-system/sw/bin/test-scenarios basic-keyboard +``` + +--- + +## Why Incus? + +Testing `vuinputd` requires a full Linux system stack: a running kernel, udev, `/dev/uinput`, +CUSE support, and a container runtime. A plain Docker container or unit test harness is not +sufficient because many of these subsystems only exist in a fully booted environment. + +[Incus](https://linuxcontainers.org/incus/) is used here for several reasons: + +- **Full VM support:** Incus can launch proper virtual machines (not just containers), which + gives each test environment its own kernel, udev tree, and device namespace — exactly what + `vuinputd` needs to operate. +- **Clean image lifecycle:** Incus pulls pre-built NixOS images from the + [Linux Containers image server](https://images.linuxcontainers.org/), so there is no need + to build a NixOS ISO or maintain a local image manually. +- **Easy configuration injection:** `incus file push` and `incus exec` allow pushing NixOS + configuration files into the VM and triggering a `nixos-rebuild switch` without requiring + SSH or manual setup. +- **Reproducibility:** Each test run can start from a fresh image, ensuring that leftover + state from a previous run does not affect results. +- **Isolation from the host:** The VM is fully isolated from the host system, so test runs + cannot accidentally interfere with the host's input devices or udev state. + +> **Note:** While `vuinputd` is compatible with other container runtimes such as +> `systemd-nspawn`, `Docker`, `LXC`, and `Podman`, Incus VMs are the recommended environment +> for automated NixOS testing because they provide a fully booted NixOS system with minimal +> setup effort. + +--- + +## Prerequisites + +- **Incus** installed and initialized on the host (`incus admin init` completed). +- The `vuinputd-test-automation.nix` NixOS module available in your working directory. + This module configures `vuinputd` and the test tooling inside the VM. +- Sufficient disk space for the NixOS VM image (roughly 3–4 GiB). + +--- + +## Step-by-Step Guide + +### 1. Download the NixOS VM Image + +```bash +incus image copy images:nixos/25.11 local: --alias nixos/25.11 --vm +``` + +This pulls the NixOS 25.11 image from the public Linux Containers image server and stores it +locally under the alias `nixos/25.11`. The `--vm` flag ensures the image is treated as a +full virtual machine image rather than a system container rootfs. + +--- + +### 2. Launch the VM + +```bash +incus launch local:nixos/25.11 nixos-vm --vm +``` + +This creates and starts a new VM instance named `nixos-vm` from the downloaded image. +At this point the VM boots NixOS with its default configuration. + +--- + +### 3. Adjust VM Resources + +Stop the VM briefly to apply resource limits before running the NixOS rebuild, which is +memory-intensive: + +```bash +incus stop nixos-vm +incus config set nixos-vm limits.memory 3GiB +``` + +NixOS system builds (`nixos-rebuild switch`) evaluate a large Nix expression tree and compile +Rust code. Without sufficient memory, the build may be killed by the OOM reaper. + +Secure Boot must also be disabled because the NixOS kernel modules required by `vuinputd` +(CUSE/FUSE) are not signed for Secure Boot by default: + +```bash +incus config set nixos-vm security.secureboot false +``` + +Then restart the VM: + +```bash +incus start nixos-vm +``` + +--- + +### 4. Inject the Test Configuration + +The NixOS configuration inside the VM needs to be extended to include the `vuinputd` test +module. Commands are run inside the VM via `incus exec`. The `--` separator tells Incus where +its own arguments end and where the command to execute inside the VM begins — everything after +`--` is passed verbatim to the VM shell. + +First, append the import to the existing `configuration.nix`: + +```bash +incus exec nixos-vm -- sed -i '/imports = \[/a\ ./vuinputd-test-automation.nix' \ + /etc/nixos/configuration.nix +``` + +This uses `sed` to insert `./vuinputd-test-automation.nix` immediately after the `imports = [` +line in the NixOS configuration, so NixOS will pick it up during the next rebuild. + +Next, push the test module file into the VM: + +```bash +incus file push vuinputd-test-automation.nix nixos-vm/etc/nixos/vuinputd-test-automation.nix +``` + +The `vuinputd-test-automation.nix` module is responsible for: +- Installing and enabling `vuinputd` as a systemd service. +- Providing any additional packages needed by the test scenarios (e.g., `bwrap`, `test-scenarios`). +- Configuring udev rules for device isolation. + +--- + +### 5. Apply the NixOS Configuration + +Trigger a NixOS system rebuild inside the VM: + +```bash +incus exec nixos-vm -- nixos-rebuild switch --max-jobs 1 +``` + +The `--max-jobs 1` flag limits parallel build jobs to avoid exhausting VM memory during +compilation. This step may take several minutes on first run because Nix will fetch and +build all required dependencies. + +After the rebuild completes, the VM is running a fully configured NixOS system with +`vuinputd` installed and active. + +--- + +### 6. Run the Test Scenarios + +Execute a test scenario inside a restricted sandbox using `bwrap` (Bubblewrap). + +[Bubblewrap](https://github.com/containers/bubblewrap) was chosen as the sandboxing tool for +several reasons: + +- **Minimal and universally available:** `bwrap` is a small, single binary with no daemon and + no runtime dependencies beyond the kernel. It is packaged in virtually every Linux + distribution, so there is no need to install a full container runtime just to run tests. +- **Reuses host binaries directly:** Because `bwrap` can bind-mount the host (or VM) filesystem + read-only into the sandbox, the test binary and all its dependencies are taken straight from + the running NixOS system — no separate rootfs or image needs to be prepared. +- **Good proxy for heavier runtimes:** The namespace isolation that `bwrap` provides (mount, + network, udev) is the same fundamental mechanism used by Docker, Podman, and systemd-nspawn. + If `vuinputd` works correctly inside a `bwrap` sandbox, the same behavior can be expected + from more heavyweight container runtimes, making `bwrap` a lightweight but representative + stand-in for integration testing. + + + +```bash +incus exec nixos-vm -- bwrap \ + --unshare-net \ + --ro-bind / / \ + --tmpfs /tmp \ + --tmpfs /run/udev \ + --dev-bind /run/vuinputd/vuinput/dev-input /dev/input \ + --dev-bind /dev/vuinput /dev/uinput \ + /run/current-system/sw/bin/test-scenarios basic-keyboard +``` + +#### What this command does + +| Flag | Purpose | +|---|---| +| `--unshare-net` | Removes network access from the sandbox, ensuring the test is self-contained. | +| `--ro-bind / /` | Mounts the entire VM filesystem read-only as the sandbox root. | +| `--tmpfs /tmp` | Provides a writable temporary directory. | +| `--tmpfs /run/udev` | Provides a writable udev runtime directory, isolating the sandbox from the VM's real udev socket. | +| `--dev-bind /run/vuinputd/vuinput/dev-input /dev/input` | Exposes the input devices managed by `vuinputd` (the container-scoped `/dev/input` subtree) at the expected path inside the sandbox. | +| `--dev-bind /dev/vuinput /dev/uinput` | Exposes the CUSE-backed virtual `/dev/uinput` device provided by `vuinputd` at the standard `/dev/uinput` path inside the sandbox. | + +This sandbox mimics what a containerized application would see: it has access to the virtual +`/dev/uinput` provided by `vuinputd` and the corresponding `/dev/input` event devices, but +cannot reach the host's real uinput or other system resources. + +The final argument `basic-keyboard` selects which test scenario to run. Additional scenarios +may be available; refer to the `test-scenarios` binary's help output for the full list. + +--- + +## Cleaning Up + +To delete the VM and free disk space after testing: + +```bash +incus delete --force nixos-vm +``` + +To also remove the cached image: + +```bash +incus image delete nixos/25.11 +``` + +--- + +## Troubleshooting + +If the test fails or `vuinputd` does not appear to be running inside the VM, refer to +[docs/DEBUG.md](https://github.com/joleuger/vuinputd/blob/main/docs/DEBUG.md) for debugging +strategies applicable to container environments. + +Common issues: + +- **CUSE not available:** Ensure that the `cuse` kernel module is loaded inside the VM + (`modprobe cuse`). The test automation module should handle this automatically. +- **`nixos-rebuild` runs out of memory:** Increase the memory limit above 3 GiB or reduce + parallelism further with `--max-jobs 1 --cores 1`. +- **Secure Boot blocking the kernel module:** Verify that `security.secureboot` is set to + `false` on the VM instance. +- **`/dev/vuinput` not present:** Check that the `vuinputd` systemd service is active + (`systemctl status vuinputd`) and that CUSE is loaded. \ No newline at end of file From ef060ce9d6658e25a7b3343554b85ff5d7a84819 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sat, 9 May 2026 08:04:06 +0000 Subject: [PATCH 107/108] Improved usage guide for nixos --- docs/USAGE-NIXOS.md | 280 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 276 insertions(+), 4 deletions(-) diff --git a/docs/USAGE-NIXOS.md b/docs/USAGE-NIXOS.md index c8408c8..d391997 100644 --- a/docs/USAGE-NIXOS.md +++ b/docs/USAGE-NIXOS.md @@ -1,13 +1,285 @@ # Usage Guide for NixOS -This guide will explain how to use `vuinputd` on NixOS. For general remarks, please refer to [USAGE.md](USAGE.md). +This guide explains how to set up and use `vuinputd` on NixOS. +For general remarks about `vuinputd` and how it works, please refer to [USAGE.md](USAGE.md). + +> **Status:** NixOS is one of the primary target platforms for `vuinputd`. Native packaging +> is planned; for now, the configuration below builds `vuinputd` directly from source as part +> of the NixOS system. -Currently, this is WIP. The plan is to make NixOS one of the main platforms for vuinputd. --- -## Configurations in the community +## Configurations in the Community + +The following community members have shared their NixOS configurations including `vuinputd`: * [ShaneTRS](https://github.com/ShaneTRS/nixos-config/) * [griffi-gh](https://github.com/girl-pp-ua/nixos-infra/) +* [Markus328](https://github.com/joleuger/vuinputd/issues/14) -Feel free to create a github issue or a PR to add your configuration or your remarks. \ No newline at end of file +Feel free to open a GitHub issue or pull request to add your own configuration or remarks. + +--- + +## NixOS Configuration + +The example below is a self-contained NixOS module that: + +- Builds `vuinputd` from source using `rustPlatform.buildRustPackage` +- Installs the required udev rules and hwdb entries +- Runs `vuinputd` as a systemd service with a tmpfs for the container-scoped `/dev/input` tree +- Applies the correct permissions to `/dev/vuinput` after the daemon starts + +Add the module to your `configuration.nix` imports and run `nixos-rebuild switch`. + +```nix +{ config, pkgs, lib, ... }: +let + vuinputd = pkgs.rustPlatform.buildRustPackage { + pname = "vuinputd"; + version = "0.3.2-git"; + + buildType = "debug"; + + nativeBuildInputs = [ + pkgs.pkg-config + pkgs.rustPlatform.bindgenHook + ]; + + buildInputs = [ pkgs.udev pkgs.fuse3 ]; + + src = pkgs.fetchFromGitHub { + owner = "joleuger"; + repo = "vuinputd"; + rev = "8c40fdc12005319ea16dceb752a8822abfc6039a"; + hash = "sha256-8Q34B04BngZqRLyixeFq8F1t5wFnk6JpaG3EEbgKRcU="; + }; + + cargoHash = "sha256-nJw9bRh6Yn9g1H5SeoT6zxgZLCqV3AtAs9gMfE+P+CU="; + + # Recent versions of fuse3 expose additional libfuse_* types that bindgen + # needs to allowlist alongside the standard fuse_* types. + postPatch = '' + substituteInPlace cuse-lowlevel/build.rs \ + --replace-fail '.allowlist_type("(?i)^fuse.*")' '.allowlist_type("(?i)^(fuse|libfuse).*")' + ''; + + postInstall = '' + mkdir -p $out/lib/udev/rules.d + mkdir $out/lib/udev/hwdb.d + cp vuinputd/udev/*.rules $out/lib/udev/rules.d/ + cp vuinputd/udev/*.hwdb $out/lib/udev/hwdb.d/ + ''; + }; +in +{ + environment.systemPackages = with pkgs; [ + vuinputd + bubblewrap # required for running containerized applications via bwrap + ]; + + # Main vuinputd daemon. + # Before starting, a tmpfs is mounted at /run/vuinputd/vuinput/dev-input. + # This directory serves as the container-scoped /dev/input tree: input devices + # created by vuinputd are placed here instead of the host's /dev/input, so + # that containers see only their own devices. + systemd.services.vuinputd = { + enable = true; + wantedBy = [ "multi-user.target" ]; + unitConfig = { + Description = "Virtual input (/dev/vuinput) daemon"; + }; + serviceConfig = { + Type = "exec"; + ExecStartPre = pkgs.writeShellScript "mount-tmpfs-dev-input" '' + mkdir -p /run/vuinputd/vuinput/dev-input + ${pkgs.util-linux}/bin/mount -t tmpfs -o rw,dev,nosuid tmpfs /run/vuinputd/vuinput/dev-input + ''; + ExecStart = "${lib.getExe vuinputd} --major 120 --minor 414795 --placement on-host"; + ExecStopPost = pkgs.writeShellScript "umount-dev-input" '' + ${pkgs.util-linux}/bin/umount /run/vuinputd/vuinput/dev-input + ''; + Restart = "on-failure"; + # Required to allow vuinputd to access character devices (uinput, CUSE). + DeviceAllow = "char-* rwm"; + Environment = [ + "RUST_LOG=debug" + ]; + }; + }; + + # vuinputd creates /dev/vuinput via CUSE. The device initially has restrictive + # permissions, so a one-shot service applies chmod 666 shortly after startup. + # A proper udev-based solution is planned to replace this workaround. + systemd.services.vuinputd-chmod = { + unitConfig.Description = "Chmod 666 /dev/vuinput"; + wantedBy = [ "vuinputd.service" ]; + after = [ "vuinputd.service" ]; + serviceConfig = { + ExecStart = pkgs.writeShellScript "chmod-vuinput" '' + sleep 2 && chmod 666 /dev/vuinput + ''; + }; + }; +} +``` + +### Key Configuration Notes + +**`--major` and `--minor`** +These are the device numbers assigned to the virtual `/dev/uinput` character device exposed +inside the container. The values `120` and `414795` are chosen to avoid conflicts with +real devices on the host. Refer to [USAGE.md](USAGE.md) for details on choosing these values. + +**`--placement on-host`** +Tells `vuinputd` to place the resulting `/dev/input/event*` devices on the host side (under +the tmpfs at `/run/vuinputd/vuinput/dev-input`) rather than directly in the host's `/dev/input`. +This is what enables per-container input isolation. + +**`DeviceAllow = "char-* rwm"`** +`vuinputd` needs access to `/dev/uinput` (to create real input devices on the host) and to +the CUSE subsystem (to expose the virtual `/dev/uinput` inside containers). Both are character +devices, so this broad allowlist is currently required. Reducing the attack surface here is a +[planned hardening step](https://github.com/joleuger/vuinputd/blob/main/docs/DESIGN.md). + +**`--device-policy`** +The `ExecStart` line can be extended with a `--device-policy` flag to control which input +capabilities and events the daemon exposes to containerized applications: + +| Policy | Effect | +|---|---| +| `none` | All capabilities allowed; no filtering. Useful for debugging. | +| `mute-sys-rq` | Blocks SysRq key handling. All other input passes through. **(default)** | +| `sanitized` | Keyboards and mice only; filters SysRq and VT-switching combos. Recommended for desktop/streaming workloads. | +| `strict-gamepad` | Gamepad-like devices only; blocks keyboards and mice entirely. | + +For example, to use the recommended policy for a Sunshine streaming container: + +```nix +ExecStart = "${lib.getExe vuinputd} --major 120 --minor 414795 --placement on-host --device-policy sanitized"; +``` + +See [USAGE.md](USAGE.md) for a full description of each policy. + +**The `vuinputd-chmod` service** +The CUSE device `/dev/vuinput` is created by the kernel with root-only permissions. Until +a proper udev rule handles this, a small one-shot service applies `chmod 666` two seconds +after the daemon starts. This is a known rough edge and will be improved. + +--- + +## Running a Containerized Application + +Once `vuinputd` is running, start a containerized application by binding the virtual devices +into its namespace. The example below uses `bwrap` (Bubblewrap) as a lightweight container: + +```bash +bwrap \ + --unshare-net \ + --ro-bind / / \ + --tmpfs /tmp \ + --tmpfs /run/udev \ + --dev-bind /run/vuinputd/vuinput/dev-input /dev/input \ + --dev-bind /dev/vuinput /dev/uinput \ + +``` + +The two `--dev-bind` flags are the core of the integration: + +| Bind | Purpose | +|---|---| +| `/run/vuinputd/vuinput/dev-input` → `/dev/input` | Gives the container its own isolated `/dev/input` tree populated by `vuinputd`. | +| `/dev/vuinput` → `/dev/uinput` | Exposes the CUSE-backed virtual `/dev/uinput` at the standard path the application expects. | + +The `--tmpfs /run/udev` flag provides a writable but empty udev runtime directory inside the +sandbox. This is sufficient when using `--placement on-host`, because `vuinputd` forwards udev +events into the container directly. If you switch to `--placement in-container`, replace this +flag with a bind-mount of the actual udev runtime directory instead, and create the required +stubs inside the container: + +```bash +mkdir -p /run/udev/data/ +touch /run/udev/control +``` + +For instructions on testing this setup in an isolated VM, see +[Testing vuinputd on NixOS with Incus](https://github.com/joleuger/vuinputd/blob/main/distro-tests/nixos/README.md). + +--- + +## Verifying Operation + +To confirm that `vuinputd` and the container integration are working correctly, run the +following checks inside the container (install `libinput-tools` and `evtest` if needed): + +```bash +# Watch for device creation and input events +libinput debug-events + +# Observe udev announcements in a second terminal +udevadm monitor -p + +# Read raw events from the input device +evtest /dev/input/event* +``` + +Then trigger some input from within the container (e.g. run a test binary or move a virtual +mouse). You should see device creation reported by `libinput` and `udevadm`, and raw event +data in `evtest`. On the host, `journalctl -u vuinputd` should show corresponding log lines +about device creation and event forwarding. + +For a more detailed walkthrough with example output, see the [Verifying Operation](USAGE.md#7-verifying-operation) +section in the main usage guide. + +--- + +## Phantom Input Events and VT Handling + +On headless NixOS systems (no active graphical session), the Linux kernel's virtual terminal +(VT) layer remains active and continues to process keyboard input. This can cause injected +input forwarded by `vuinputd` to reach `getty` login prompts or trigger kernel hotkeys such +as `Ctrl+Alt+Fn`. + +The quickest mitigation is to start `vuinputd` with the `--vt-guard` flag: + +```nix +ExecStart = "${lib.getExe vuinputd} --major 120 --minor 414795 --placement on-host --vt-guard"; +``` + +`--vt-guard` switches the active VT into graphics mode via a direct ioctl, which disables +the kernel keyboard handler for that VT without requiring a compositor or DRM device. + +For a full discussion of all available approaches (including KMSCON and the experimental +`fallbackdm`), see the [Phantom Input Events](USAGE.md#8-handling-phantom-input-events-caused-by-vts) +section in the main usage guide. + +--- + +## Troubleshooting + +If `vuinputd` does not behave as expected, refer to +[DEBUG.md](https://github.com/joleuger/vuinputd/blob/main/docs/DEBUG.md) for general +debugging guidance. + +Common NixOS-specific issues: + +- **CUSE module not loaded:** NixOS should load `cuse` automatically via udev, but if + `/dev/vuinput` does not appear after the service starts, run `modprobe cuse` and restart + the service. +- **`/dev/vuinput` is not accessible:** The `vuinputd-chmod` service applies permissions + 2 seconds after startup. If it fails, check `systemctl status vuinputd-chmod` and apply + `chmod 666 /dev/vuinput` manually for debugging. +- **Input devices not visible inside the container:** Verify that + `/run/vuinputd/vuinput/dev-input` is mounted as a tmpfs (`mount | grep vuinputd`) and that + the `bwrap` `--dev-bind` flags point to the correct paths. +- **Read-only filesystem error from `vuinputd`:** If the daemon logs an error like + `ReadOnlyFilesystem` when creating a device node, the directory where it tries to write + (typically `/dev/input` or `/run`) is not writable inside the container. Ensure the + `--dev-bind` and `--tmpfs` flags in your `bwrap` command cover all paths `vuinputd` writes + to, or switch to `--placement on-host` so writes happen on the host side instead. + + ``` + Error creating input device /dev/input/event12: Read-only file system + ``` +- **Build failures due to bindgen/fuse3 mismatch:** Ensure the `postPatch` block in the + derivation is present; it is required for recent versions of `fuse3`. \ No newline at end of file From a563c442f84dd94ff4a1bca0c3f8a34fe160eb85 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Sat, 9 May 2026 11:53:55 +0000 Subject: [PATCH 108/108] Limit permissions for github workflow --- .github/workflows/debian-package.yml | 3 +++ .github/workflows/rust.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/debian-package.yml b/.github/workflows/debian-package.yml index 1a6b91e..2effc3e 100644 --- a/.github/workflows/debian-package.yml +++ b/.github/workflows/debian-package.yml @@ -6,6 +6,9 @@ on: tags: - "*" +permissions: + contents: read + env: CARGO_TERM_COLOR: always DEB_BUILD_OPTIONS: nocheck diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8d50e5c..2d6a40d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [ "main" ] +permissions: + contents: read + env: CARGO_TERM_COLOR: always