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(()) }