From 4fbb4d7389da1583075e05d7e43bd02df58e09c4 Mon Sep 17 00:00:00 2001 From: Johannes Leupolz Date: Mon, 22 Dec 2025 10:30:43 +0000 Subject: [PATCH 01/73] 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 02/73] 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 03/73] 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 04/73] 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 05/73] 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 06/73] 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 07/73] 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 08/73] 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 09/73] 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 10/73] 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 11/73] 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 12/73] 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 13/73] 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 14/73] 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 15/73] 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 16/73] 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 17/73] 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 18/73] 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 19/73] 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 20/73] 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 21/73] 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 22/73] 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 23/73] 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 24/73] 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 25/73] 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 26/73] 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 27/73] 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 28/73] 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 29/73] 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 30/73] 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 31/73] 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 32/73] 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 33/73] 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 34/73] 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 35/73] 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 36/73] 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 37/73] 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 38/73] 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 39/73] 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 40/73] 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 41/73] 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 42/73] 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 43/73] 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 44/73] 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 45/73] 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 46/73] 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 47/73] 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 48/73] 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 49/73] 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 50/73] 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 51/73] 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 52/73] 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 53/73] - 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 54/73] 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 55/73] 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 56/73] 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 57/73] 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 58/73] 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 59/73] 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 60/73] 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 61/73] 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 62/73] 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 63/73] 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 64/73] 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 65/73] 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 66/73] 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 67/73] 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 68/73] 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 69/73] 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 70/73] 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 71/73] 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 72/73] 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 73/73] 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