Compare commits

...

75 commits
0.3.0 ... main

Author SHA1 Message Date
Johannes Leupolz
b9b4d1a07b
Merge pull request #13 from joleuger/dependabot/cargo/time-0.3.47
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (rust) (push) Has been cancelled
Rust / build (push) Has been cancelled
Bump time from 0.3.44 to 0.3.47
2026-05-09 13:55:55 +02:00
Johannes Leupolz
a563c442f8 Limit permissions for github workflow 2026-05-09 11:53:55 +00:00
Johannes Leupolz
ef060ce9d6 Improved usage guide for nixos 2026-05-09 08:04:06 +00:00
Johannes Leupolz
2c8d0f9af1 Improved README.md for testing NixOS using incus 2026-05-09 06:44:44 +00:00
Johannes Leupolz
87694e1a96 Test description for NixOS
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
Rust / build (push) Waiting to run
2026-05-08 10:27:43 +00:00
Johannes Leupolz
341ba07523 Remark on NixOS
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (rust) (push) Has been cancelled
Rust / build (push) Has been cancelled
2026-05-06 20:23:56 +00:00
Johannes Leupolz
aea990946d Notify all pending waiters on close 2026-04-17 13:15:31 +00:00
Johannes Leupolz
152ffd9711 Enter user namespace in the incus case 2026-04-16 20:29:48 +00:00
Johannes Leupolz
6eb5a2985a Fix 100% CPU bug 2026-04-16 19:35:01 +00:00
Johannes Leupolz
e6c38be172 Make udev_event (aka netlink message) container engine aware 2026-04-16 08:38:04 +00:00
Johannes Leupolz
218fbdbcbf incus: add device removal 2026-04-16 07:41:54 +00:00
Johannes Leupolz
919b420e43 Remove tracing code for a mutex in read 2026-04-15 22:36:39 +00:00
Johannes Leupolz
9e55cd84ab Fix for poll and read. 2026-04-15 22:20:39 +00:00
Johannes Leupolz
080a72906d Fix for incus 2026-04-15 22:19:51 +00:00
Johannes Leupolz
8c40fdc120 Add banner for deepwiki 2026-04-15 22:19:13 +00:00
dependabot[bot]
5e79b0d1cb
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] <support@github.com>
2026-04-15 18:54:14 +00:00
Johannes Leupolz
ab72e0bfc8 Add cargo.lock 2026-04-14 20:55:15 +00:00
Johannes Leupolz
53eb3c7101 Use --target-container when using incus 2026-04-14 20:46:17 +00:00
Johannes Leupolz
c5693d534f Work on injection for container engine "incus" 2026-04-14 20:21:21 +00:00
Johannes Leupolz
86c4e1c712 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 2026-04-14 19:35:25 +00:00
Johannes Leupolz
1cbd0540ab Comment out read and poll for now, because it blocks the sunshine shutdown for some reason 2026-04-14 19:29:13 +00:00
Johannes Leupolz
b75806cc09 - 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)
2026-04-09 21:06:13 +00:00
Johannes Leupolz
8e0a00e817 Tryfix for #7. Untested 2026-04-06 22:25:05 +00:00
Johannes Leupolz
0ce2034407 Removed ipc-support from test automation, because I don't need it yet and it is buggy. 2026-04-06 22:06:43 +00:00
Johannes Leupolz
65802bfa63 Test for absolute mouse device added 2026-04-06 21:22:15 +00:00
Johannes Leupolz
0fda223d4e Fixes for rumble test scenario 2026-04-03 23:03:58 +00:00
Johannes Leupolz
96719c0a9c Fixes for polling and ff example. Not working, yet. 2026-04-03 22:34:24 +00:00
Johannes Leupolz
5cffaec3da poll: First alpha implementation 2026-04-03 20:37:40 +00:00
Johannes Leupolz
8d4a0c9413 Started to implement poll 2026-04-02 22:18:41 +00:00
Johannes Leupolz
6f60635ff2 Implement vuinput_read for vibration support. poll missing 2026-04-01 09:57:09 +00:00
Johannes Leupolz
33b0a016c3 Add integration test for vibration/force feedback functionality 2026-03-31 21:40:27 +00:00
Johannes Leupolz
21bcff7fa3 Add simple force feedback test scenario in test-scenarios 2026-03-31 21:21:26 +00:00
Johannes Leupolz
e4eb1d77da Refactored test-scenarios.rs. Added stub for force feedback test. 2026-03-29 22:26:12 +00:00
Johannes Leupolz
7182cac6fc Fix build: Add missing constant 2026-03-27 09:59:18 +00:00
Johannes Leupolz
88737be70b Manual fix of ai hallucinations of event codes 2026-03-27 08:18:24 +00:00
Johannes Leupolz
643c506a23 cargo fmt 2026-03-26 21:25:00 +00:00
Johannes Leupolz
9070c4e836 First draft of the test tool test-scenarios that should make it easier to test new scenarios in case we have an issue. 2026-03-26 21:20:04 +00:00
Johannes Leupolz
b5889e6189
Merge pull request #8 from griffi-gh/patch-1
fix build failure with nixos libfuse3 headers
2026-03-24 00:47:10 +01:00
Lunyaaa~
134b41fa98
fix build failure with nixos libfuse3 headers
it pulls in reference to libfuse_version which is currently blacklisted
2026-03-23 15:05:41 +01:00
Johannes Leupolz
4c4fefb0c6 distro tests: download ubuntu image 2026-02-03 20:52:22 +00:00
Johannes Leupolz
8ada4a8624 Prepare cloud-init iso file to be able to initialize VMs with various distributions for automated tests 2026-02-03 20:39:11 +00:00
Johannes Leupolz
8d1627ee42 Move fallbackdm into own repository. 2026-01-28 20:52:33 +00:00
Johannes Leupolz
e5bb81f86e Add DESIGN.md document for fallbackdm. Still no implementation committed. 2026-01-26 23:07:12 +00:00
Johannes Leupolz
62b5de7bf4 A
fallbackdm: Add research on how TakeControl works to grab the keyboard
input
2026-01-26 23:07:12 +00:00
Johannes Leupolz
4d87dfea44
Add CodeQL analysis workflow configuration 2026-01-26 20:23:45 +01:00
Johannes Leupolz
6e23a02e3f Wrote integration test to test --placement on-host to fix #1. Looks good so far. 2026-01-20 22:52:09 +00:00
Johannes Leupolz
fd55bffddb Implementation of the remove-device-step for --placement on-host. Not tested, yet 2026-01-20 21:34:39 +00:00
Johannes Leupolz
350a80644a 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. 2026-01-20 11:59:07 +00:00
Johannes Leupolz
6bde733b09 Start implementation of --placement on-host. Not complete and no automated tests, yet. #1 2026-01-19 22:55:37 +00:00
Johannes Leupolz
b4c6c32431 Document --placement, --devname, and --device-policy in USAGE.md 2026-01-19 19:59:24 +00:00
Johannes Leupolz
0d0d1b489c Add configuration for placement 2026-01-19 19:57:10 +00:00
Johannes Leupolz
d6423197bb Add podman tests 2026-01-17 06:38:13 +00:00
Johannes Leupolz
3283aa0dc2 Add infrastructure to add integration tests with podman 2026-01-16 10:59:12 +00:00
Johannes Leupolz
a1667bf4ba Improve device policies: Block more keys in sanitized mode and introduce MuteSysRq mode that only mutes sysrq 2026-01-15 22:39:57 +00:00
Johannes Leupolz
d139906834 Some remarks for the future of fallbackdm 2026-01-08 21:21:17 +00:00
Johannes Leupolz
11144bd694 Add design considerations for #4 2026-01-08 21:13:07 +00:00
Johannes Leupolz
0062a1741e Apply device filter. No tests, yet. 2026-01-07 22:56:18 +00:00
Johannes Leupolz
5a675aac34 Implemented filter logic for device policy. Not used or tested, yet. 2026-01-07 22:38:14 +00:00
Johannes Leupolz
5277a13904 Started work on device policy #2 #4 2026-01-06 22:49:15 +00:00
Johannes Leupolz
f8bdb04d7e Improve troubleshooting 2025-12-29 23:44:15 +00:00
Johannes Leupolz
a7a207e029 Fix github actions pipeline 2025-12-28 00:19:40 +00:00
Johannes Leupolz
84df502d25 Release 0.3.2 2025-12-28 00:14:28 +00:00
Johannes Leupolz
7fbb1616ed Add documentation how to debug the actions that vuinputd triggers in containers 2025-12-28 00:10:46 +00:00
Johannes Leupolz
66d58f074f 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. 2025-12-27 23:49:20 +00:00
Johannes Leupolz
2c85f82448 Remove unused code 2025-12-27 22:50:06 +00:00
Johannes Leupolz
ccd4adc5ec Release v0.3.1 2025-12-25 21:59:07 +00:00
Johannes Leupolz
7c25eb2cfe Debian pipeline fixes 2025-12-25 21:50:47 +00:00
Johannes Leupolz
00b80691e4 Release pipeline for debian packages 2025-12-25 21:41:01 +00:00
Johannes Leupolz
9b1b93d446 Fix in debian/rules 2025-12-25 21:35:56 +00:00
Johannes Leupolz
2704626d77 Added hint regarding vt-guard in systemd service file 2025-12-25 21:32:40 +00:00
Johannes Leupolz
76ba60a614 Debian build scripts improved 2025-12-25 21:32:07 +00:00
Johannes Leupolz
0c91a05ef7 Improve help message and cargo fmt 2025-12-23 21:47:16 +00:00
Johannes Leupolz
04bd88f179 Check for status of tty1 during startup 2025-12-23 20:26:07 +00:00
Johannes Leupolz
77c3452bbb Add WIP and notes for fallbackdm 2025-12-22 19:59:31 +00:00
Johannes Leupolz
4fbb4d7389 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" 2025-12-22 10:30:43 +00:00
83 changed files with 7358 additions and 320 deletions

101
.github/workflows/codeql.yml vendored Normal file
View file

@ -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}}"

66
.github/workflows/debian-package.yml vendored Normal file
View file

@ -0,0 +1,66 @@
name: Debian Package
on:
workflow_dispatch:
push:
tags:
- "*"
permissions:
contents: read
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
# cargo deps
sudo apt install -y \
build-essential \
devscripts \
debhelper \
dh-cargo \
cargo \
rustc \
pkg-config \
libudev-dev \
libfuse3-dev \
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 librust-async-trait-dev
- 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/

View file

@ -6,6 +6,9 @@ on:
pull_request: pull_request:
branches: [ "main" ] branches: [ "main" ]
permissions:
contents: read
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always

1244
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/joleuger/vuinputd)
# vuinputd # vuinputd
**Run Sunshine and other uinput-based apps inside containers — with full input isolation and zero kernel patches.** **Run Sunshine and other uinput-based apps inside containers — with full input isolation and zero kernel patches.**
@ -75,8 +77,10 @@ sequenceDiagram
## Documentation ## Documentation
See [docs/BUILD.md](https://github.com/joleuger/vuinputd/blob/main/docs/BUILD.md) for a short build and installation guide. 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/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.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.
--- ---
@ -89,6 +93,8 @@ It reliably demonstrates the core concept — exposing `/dev/uinput` devices ins
### ✅ Goals for Production Readiness ### ✅ Goals for Production Readiness
* [ ] **Vibration/Force Feedback support:**
* [ ] **Steam input support:** * [ ] **Steam input support:**
Steam input is not supported, yet. For some strange reasons, steam creates 16 virtual devices. Maybe a race. Steam input is not supported, yet. For some strange reasons, steam creates 16 virtual devices. Maybe a race.

View file

@ -23,7 +23,8 @@ fn fuse_binding_filter(builder: bindgen::Builder) -> bindgen::Builder {
.allowlist_function("(?i)^fuse.*") .allowlist_function("(?i)^fuse.*")
.allowlist_var("(?i)^fuse.*") .allowlist_var("(?i)^fuse.*")
.blocklist_type("fuse_log_func_t") .blocklist_type("fuse_log_func_t")
.blocklist_function("fuse_set_log_func"); .blocklist_function("fuse_set_log_func")
.allowlist_type("^libfuse_version$");
builder builder
} }

30
debian/changelog vendored
View file

@ -1,3 +1,32 @@
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 <vinput@leupolz.eu> Sat, 21 Dec 2025 10:30:00 +0000
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 <dev@leupolz.eu> Fri, 20 Dec 2025 22:00:00 +0000
vuinputd (0.3.0-1) unstable; urgency=medium vuinputd (0.3.0-1) unstable; urgency=medium
* Refactor container and namespace handling: * Refactor container and namespace handling:
@ -19,6 +48,7 @@ vuinputd (0.3.0-1) unstable; urgency=medium
-- Johannes Leupolz <dev@leupolz.eu> Thu, 19 Dec 2025 21:30:00 +0000 -- Johannes Leupolz <dev@leupolz.eu> Thu, 19 Dec 2025 21:30:00 +0000
vuinputd (0.2.0-1) unstable; urgency=medium vuinputd (0.2.0-1) unstable; urgency=medium
* Register official USB vendor/product ID (VID 0x1209, PID 0x5020) under * Register official USB vendor/product ID (VID 0x1209, PID 0x5020) under

13
debian/control vendored
View file

@ -9,6 +9,9 @@ Build-Depends: debhelper (>= 12),
rustc:native, rustc:native,
pkg-config, pkg-config,
libudev-dev, libudev-dev,
libclang-dev,
libfuse3-dev,
libc6-dev,
librust-bindgen-dev, librust-bindgen-dev,
librust-nix-dev, librust-nix-dev,
librust-libc-dev, librust-libc-dev,
@ -22,6 +25,9 @@ Build-Depends: debhelper (>= 12),
librust-async-io-dev, librust-async-io-dev,
librust-anyhow-dev, librust-anyhow-dev,
librust-clap-dev, librust-clap-dev,
librust-base64-dev,
librust-smallvec-dev,
librust-async-trait-dev,
pkg-config, pkg-config,
build-essential build-essential
Standards-Version: 4.6.0 Standards-Version: 4.6.0
@ -30,5 +36,10 @@ Rules-Requires-Root: no
Package: vuinputd Package: vuinputd
Architecture: amd64 Architecture: amd64
Depends: ${shlibs:Depends}, ${misc:Depends} Depends:
${misc:Depends},
${shlibs:Depends},
systemd,
udev,
libfuse3-3
Description: Virtual input forwarder for Linux. Description: Virtual input forwarder for Linux.

19
debian/rules vendored
View file

@ -8,6 +8,19 @@ override_dh_auto_build:
cargo build --release cargo build --release
override_dh_auto_install: override_dh_auto_install:
install -D -m 0755 target/release/vuinputd debian/tmp/usr/bin/vuinputd # install binary
install -D -m 0755 vuinputd/udev/90-vuinputd.hwdb debian/tmp/usr/lib/udev/hwdb.d/90-vuinputd.hwdb install -D -m 0755 target/release/vuinputd \
install -D -m 0755 vuinputd/udev/90-vuinputd-protect.rules debian/tmp/usr/lib/udev/rules.d/90-vuinputd-protect.rules 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
# 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

View file

@ -1,3 +1,4 @@
usr/bin/vuinputd usr/bin/vuinputd
usr/lib/udev/hwdb.d/90-vuinputd.hwdb usr/lib/udev/hwdb.d/90-vuinputd.hwdb
usr/lib/udev/rules.d/90-vuinputd-protect.rules usr/lib/udev/rules.d/90-vuinputd-protect.rules
usr/lib/systemd/system/vuinputd.service

15
debian/vuinputd.postinst vendored Executable file
View file

@ -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

View file

@ -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"

View file

@ -0,0 +1,2 @@
instance-id: vuinputd-distro-tests
local-hostname: vuinputd-distro-tests

View file

@ -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

View file

@ -0,0 +1,272 @@
# Testing vuinputd on NixOS with Incus
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.
---
## Complete Script
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.
```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 34 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.

View file

@ -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
'';
};
};
}

View file

@ -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"

109
docs/DEBUG.md Normal file
View file

@ -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 <BASE64_PAYLOAD>
```
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.

View file

@ -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. 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 ## 3.9 Overriding the type, vendor id, and product id
@ -485,6 +493,213 @@ 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. The chosen approach offers the best balance between correctness, portability, and operational simplicity.
---
## **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 `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**. 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:
* open a regular logind session **of class `greeter**`
* occupy the assigned VT
* 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**.
### **Behavior and Lifecycle**
* `fallbackdm` starts as a normal session on the seat
* while active:
* the VT is in `KD_GRAPHICS`
* **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. `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**
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. **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.
---
### 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. 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:**
* **`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.
--- ---
@ -512,6 +727,7 @@ While this design is necessary for mediation, it introduces potential attack sur
* [ ] Use **seccomp** or `systemd` sandboxing (`ProtectSystem`, `ProtectKernelTunables`, `RestrictNamespaces`, etc.). * [ ] Use **seccomp** or `systemd` sandboxing (`ProtectSystem`, `ProtectKernelTunables`, `RestrictNamespaces`, etc.).
* [ ] Eventually migrate to **Rust-native FUSE/Netlink** bindings to remove unsafe dependencies. * [ ] Eventually migrate to **Rust-native FUSE/Netlink** bindings to remove unsafe dependencies.
---
## 5. Background: How are input devices created by the kernel using uinput ## 5. Background: How are input devices created by the kernel using uinput

View file

@ -2,12 +2,34 @@
## Integration tests ## 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: Install bubblewrap:
`apt-get 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
Install podman:
`apt-get install podman`.
Create test container for podman
```
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`.
## Performance tests ## Performance tests

67
docs/TROUBLESHOOTING.md Normal file
View file

@ -0,0 +1,67 @@
# 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 |
| 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 ... |
---
## 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))
### VUI-UDEV-002 - could not write into /run/vuinputd/...

285
docs/USAGE-NIXOS.md Normal file
View file

@ -0,0 +1,285 @@
# Usage Guide for NixOS
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.
---
## 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 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 \
<your-application>
```
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`.

View file

@ -16,6 +16,25 @@ This guide shows how to:
2. Connect it to the hosts virtual `/dev/uinput` 2. Connect it to the hosts virtual `/dev/uinput`
3. Verify that device creation and input forwarding work correctly 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 ## 2. Prerequisites
@ -90,7 +109,141 @@ 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-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
* 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>`
Name of the device node **without** the `/dev/` prefix
(e.g. `vuinput0``/dev/vuinput0`)
* `--major <number>`
Explicit major device number. Using 0 for both major and minor means auto assign.
* `--minor <number>`
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 ### 🐳 Docker
@ -153,7 +306,7 @@ Then restart the container.
--- ---
## 5. Inside the Container ## 6. Inside the Container
Once inside the container shell: Once inside the container shell:
@ -165,13 +318,17 @@ apt-get update
apt-get install libinput-tools udev evtest tmux apt-get install libinput-tools udev evtest tmux
# Prepare udev stubs # 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/ mkdir -p /run/udev/data/
touch /run/udev/control touch /run/udev/control
``` ```
--- ---
## 6. Verifying Operation ## 7. Verifying Operation
To test everything, use multiple `tmux` windows for parallel monitoring. To test everything, use multiple `tmux` windows for parallel monitoring.
@ -225,7 +382,111 @@ Sample output from `journalctl` showing vuinputd output:
--- ---
## 7. Troubleshooting ## 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:
* `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`. 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
* 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**. `fallbackdm` is available at https://github.com/joleuger/fallbackdm.
#### 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.
---
## 9. Troubleshooting
| Symptom | Possible Cause | Fix | | Symptom | Possible Cause | Fix |
| --------------------------- | ------------------------------------ | ------------------------------------------------- | | --------------------------- | ------------------------------------ | ------------------------------------------------- |
@ -246,7 +507,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. Ensure /dev and /run are writable in the container. If in doubt, use tmpfs.
--- ---
## 8. Notes and Advanced Topics ## 10. Notes and Advanced Topics
* You can safely run **multiple containers**. * You can safely run **multiple containers**.
* Devices are automatically cleaned up when the container stops. * Devices are automatically cleaned up when the container stops.
@ -258,7 +519,7 @@ Ensure /dev and /run are writable in the container. If in doubt, use tmpfs.
--- ---
## 9. References ## 11. References
* [mkosi manual](https://github.com/systemd/mkosi/blob/main/mkosi/resources/man/mkosi.1.md) * [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) * [Docker device rules documentation](https://docs.docker.com/engine/reference/run/#device-cgroup-rule)

View file

@ -3,11 +3,17 @@ name = "vuinputd-tests"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[[bin]]
name = "test-ipc"
[[bin]] [[bin]]
name = "test-keyboard" name = "test-keyboard"
[[bin]] [[bin]]
name = "bwrap-ipc" name = "test-ok"
[[bin]]
name = "test-scenarios"
[dependencies] [dependencies]
uinput-ioctls = { path = "../uinput-ioctls" } uinput-ioctls = { path = "../uinput-ioctls" }
@ -21,5 +27,7 @@ serde_json = "1.0"
[features] [features]
requires-privileges = [] requires-privileges = []
requires-rootless = []
requires-uinput = [] requires-uinput = []
requires-bwrap = [] requires-bwrap = []
requires-podman = []

View file

@ -0,0 +1,9 @@
# Build from project root
# > cargo build -p vuinputd-tests
# > 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

View file

@ -5,10 +5,10 @@
use core::panic; use core::panic;
use std::time::Duration; use std::time::Duration;
use vuinputd_tests::bwrap::SandboxChildIpc; use vuinputd_tests::ipc::SandboxChildIpc;
fn main() { fn main() {
println!("starting bwrap-ipc"); println!("starting test-ipc");
let ipc = unsafe { SandboxChildIpc::from_fd() }; let ipc = unsafe { SandboxChildIpc::from_fd() };
let incoming = ipc let incoming = ipc

View file

@ -0,0 +1,7 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
fn main() {
println!("test-ok");
}

View file

@ -0,0 +1,84 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
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, BasicMouseAbsolute,
ScenarioArgs,
};
#[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 mouse (absolute) test
BasicMouseAbsolute,
/// Basic PS4 gamepad test
BasicPs4Gamepad,
/// Basic Xbox gamepad test
BasicXboxGamepad,
/// Force feedback / Vibration Xbox gamepad test
FfXboxGamepad,
/*
/// 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::BasicMouseAbsolute => BasicMouseAbsolute::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),
Commands::StressKeyboard => StressKeyboard::run(&args),
Commands::StressXboxGamepad => StressXboxGamepad::run(&args),
*/
}
}

View file

@ -3,16 +3,17 @@
// Author: Johannes Leupolz <dev@leupolz.eu> // Author: Johannes Leupolz <dev@leupolz.eu>
use std::io; 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::net::UnixDatagram;
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
use std::process::{Command, Output}; use std::process::{Command, Output};
use std::time::Duration;
use nix::errno::Errno; use nix::errno::Errno;
use nix::sys::socket::{socketpair, AddressFamily, SockFlag, SockType}; use nix::sys::socket::{socketpair, AddressFamily, SockFlag, SockType};
use nix::unistd::close; use nix::unistd::close;
use crate::ipc::{SandboxChildIpc, SandboxIpc};
/// Check if bubblewrap is available. /// Check if bubblewrap is available.
pub fn bwrap_available() -> bool { pub fn bwrap_available() -> bool {
Command::new("bwrap") Command::new("bwrap")
@ -22,56 +23,6 @@ pub fn bwrap_available() -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// IPC handle kept by the parent.
pub struct SandboxIpc {
sock: UnixDatagram,
}
impl SandboxIpc {
pub fn recv(&self, read_timeout: Option<Duration>) -> io::Result<Vec<u8>> {
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<Duration>) -> io::Result<Vec<u8>> {
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. /// Builder for bubblewrap invocations.
#[derive(Default)] #[derive(Default)]
pub struct BwrapBuilder { pub struct BwrapBuilder {
@ -107,8 +58,11 @@ impl BwrapBuilder {
// So, we mount a temporary directory that does not have this restrictions. // So, we mount a temporary directory that does not have this restrictions.
self.args.extend([ self.args.extend([
"--dev-bind".into(), "--dev-bind".into(),
"/dev/tmp/vuinputd-test".into(), "/run/vuinputd/vuinput-test/dev".into(),
"/dev".into(), "/dev".into(),
"--dev-bind".into(),
"/run/vuinputd/vuinput-test/dev-input".into(),
"/dev/input".into(),
]); ]);
self self
} }

View file

@ -0,0 +1,299 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use crate::test_log::LoggedInputEvent;
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;
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/";
// 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,
pub sysname: String,
pub device_name: String,
pub event_device_node: String,
pub event_device_fd: i32,
pub events: Vec<LoggedInputEvent>,
}
/// 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<Self, io::Error>;
/// 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<c_int, io::Error>;
/// 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<input_event> {
let event_device_fd = self.get_event_device()?;
read_event(event_device_fd)
}
/// Emit (to uinput) and read (from evdev) an event with logging
fn emit_read_and_log(
&mut self,
ev_type: u16,
code: u16,
val: i32,
) -> io::Result<LoggedInputEvent> {
let event_device_fd = self.get_event_device()?;
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<LoggedInputEvent> {
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)
}
/// 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<LoggedInputEvent> {
&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,
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;
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<String> {
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::<libc::input_event>();
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,
emit_syn: bool,
) -> io::Result<LoggedInputEvent> {
let (time_sent_sec, time_sent_nsec) = monotonic_time();
emit(emit_to, ev_type, code, val)?;
if emit_syn {
emit(emit_to, EV_SYN, SYN_REPORT, 0)?;
}
let input_event_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;
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<input_event> {
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::<input_event>(),
)
};
if ret as usize != size_of::<input_event>() {
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<i32> {
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_RDWR | 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<String> {
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"))
}

View file

@ -0,0 +1,345 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
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 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: 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
// 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 {
state: DeviceState,
}
impl Device for KeyboardDevice {
fn name() -> &'static str {
"Keyboard"
}
fn state(&self) -> &DeviceState {
&self.state
}
fn state_mut(&mut self) -> &mut DeviceState {
&mut self.state
}
fn get_event_device(&self) -> Result<c_int, io::Error> {
Ok(self.state.event_device_fd)
}
fn create(device: Option<&str>, name: &str) -> Result<Self, io::Error> {
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, 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(KeyboardDevice {
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);
}
}
}

View file

@ -0,0 +1,23 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
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;
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 mouse_absolute::MouseAbsoluteDevice;
pub use ps4_gamepad::Ps4GamepadDevice;
pub use xbox_gamepad::XboxGamepadDevice;
// 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};

View file

@ -0,0 +1,114 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use crate::devices::device_base::{fetch_device_node, open_uinput, Device, DeviceState, BUS_USB};
use libc::{c_int, close, open};
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;
/// 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 {
state: DeviceState,
}
impl Device for MouseDevice {
fn name() -> &'static str {
"Mouse"
}
fn state(&self) -> &DeviceState {
&self.state
}
fn state_mut(&mut self) -> &mut DeviceState {
&mut self.state
}
fn get_event_device(&self) -> Result<c_int, io::Error> {
Ok(self.state.event_device_fd)
}
fn create(device: Option<&str>, name: &str) -> Result<Self, io::Error> {
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, 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(MouseDevice {
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);
}
}
}

View file

@ -0,0 +1,161 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
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<c_int, io::Error> {
Ok(self.state.event_device_fd)
}
fn create(device: Option<&str>, name: &str) -> Result<Self, io::Error> {
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);
}
}
}

View file

@ -0,0 +1,180 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
// this file is ai genrated and contains many mistake that need to be fixed manually
use crate::devices::device_base::{fetch_device_node, open_uinput, Device, DeviceState, BUS_USB};
use libc::{c_int, close, open};
use std::io;
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 {
state: DeviceState,
}
/// 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 state(&self) -> &DeviceState {
&self.state
}
fn state_mut(&mut self) -> &mut DeviceState {
&mut self.state
}
fn get_event_device(&self) -> Result<c_int, io::Error> {
Ok(self.state.event_device_fd)
}
fn create(device: Option<&str>, name: &str) -> Result<Self, io::Error> {
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, 10)?;
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(Ps4GamepadDevice {
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);
}
}
}

View file

@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
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};
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::*;
// 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};

View file

@ -0,0 +1,365 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
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::{poll, PollFd, PollFlags, PollTimeout},
};
use std::{
io,
os::fd::BorrowedFd,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
thread,
time::Duration,
};
use uinput_ioctls::*;
// Xbox Gamepad codes
// 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;
// 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;
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
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_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_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())?;
// 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(())
}
/// 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<i16> {
unsafe {
eviocsff(fd, effect).unwrap();
}
// Effect id is saved as effect.id
let id = unsafe { (*effect).id };
Ok(id)
}
pub struct XboxGamepadDevice {
state: DeviceState,
}
impl Device for XboxGamepadDevice {
fn name() -> &'static str {
"Xbox Gamepad"
}
fn state(&self) -> &DeviceState {
&self.state
}
fn state_mut(&mut self) -> &mut DeviceState {
&mut self.state
}
fn get_event_device(&self) -> Result<c_int, io::Error> {
Ok(self.state.event_device_fd)
}
fn create(device: Option<&str>, name: &str) -> Result<Self, io::Error> {
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, 10)?;
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_RDWR | 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(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);
}
}
}
impl XboxGamepadDevice {
pub fn read_process_ff_event_from_uinput(&self, shutdown: Arc<AtomicBool>, use_poll: bool) {
// 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];
let mut pollfds = [PollFd::new(
unsafe { BorrowedFd::borrow_raw(fd) },
PollFlags::POLLIN,
)];
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
);
//crate::devices::utils::emit(fd, input_event.type_,input_event.code,input_event.value).unwrap();
}
} else {
println!("Read {} bytes", result);
}
}
});
}
}

64
vuinputd-tests/src/ipc.rs Normal file
View file

@ -0,0 +1,64 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
// TODO: Use https://varlink.org/ which also supports bridges over ssh, which is nice
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<Duration>) -> io::Result<Vec<u8>> {
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<Duration>) -> io::Result<Vec<u8>> {
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)
}
}

View file

@ -3,5 +3,9 @@
// Author: Johannes Leupolz <dev@leupolz.eu> // Author: Johannes Leupolz <dev@leupolz.eu>
pub mod bwrap; pub mod bwrap;
pub mod devices;
pub mod ipc;
pub mod podman;
pub mod run_vuinputd; pub mod run_vuinputd;
pub mod scenarios;
pub mod test_log; pub mod test_log;

View file

@ -0,0 +1,209 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
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<String>,
ipc_child_fd: Option<OwnedFd>,
}
impl PodmanBuilder {
pub fn new() -> Self {
Self::default()
}
/// `podman run`
pub 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 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());
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
}
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(
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);
self.args.push("--preserve-fds=1".into());
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<Output> {
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());
}
}

View file

@ -5,7 +5,7 @@
use std::{ use std::{
os::unix::process::CommandExt, os::unix::process::CommandExt,
process::{Child, Command}, process::{Child, Command},
sync::OnceLock, sync::Mutex,
thread, thread,
time::Duration, time::Duration,
}; };
@ -14,33 +14,37 @@ use nix::sys::signal::{self, Signal};
use nix::unistd::Pid; use nix::unistd::Pid;
/// Global singleton /// Global singleton
static VUINPUTD: OnceLock<VuinputdGuard> = OnceLock::new(); static VUINPUTD_LOCK: Mutex<()> = Mutex::new(());
pub fn ensure_vuinputd_running() { pub fn ensure_vuinputd_running(args: &[&str]) -> VuinputdGuard {
VUINPUTD.get_or_init(|| VuinputdGuard::start()); VuinputdGuard::start(args)
} }
struct VuinputdGuard { pub struct VuinputdGuard {
child: Child, child: Child,
} }
impl VuinputdGuard { 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"); 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 { let child = unsafe {
Command::new("cargo") Command::new("cargo")
.args([ .args(concat_args)
"run",
"-p",
"vuinputd",
"--",
"--major",
"120",
"--minor",
"414796",
"--devname",
"vuinputd-test",
])
.pre_exec(|| { .pre_exec(|| {
// Last resort, if the parent just is killed. // Last resort, if the parent just is killed.
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL); libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL);
@ -67,6 +71,7 @@ impl Drop for VuinputdGuard {
// Wait a bit // Wait a bit
for _ in 0..10 { for _ in 0..10 {
if let Ok(Some(_)) = self.child.try_wait() { if let Ok(Some(_)) = self.child.try_wait() {
println!("vuinputd for tests shutdown gracefully");
return; return;
} }
thread::sleep(Duration::from_millis(100)); thread::sleep(Duration::from_millis(100));
@ -75,5 +80,6 @@ impl Drop for VuinputdGuard {
// Still alive → SIGKILL // Still alive → SIGKILL
let _ = signal::kill(pid, Signal::SIGKILL); let _ = signal::kill(pid, Signal::SIGKILL);
let _ = self.child.wait(); let _ = self.child.wait();
println!("vuinputd for tests killed");
} }
} }

View file

@ -0,0 +1,40 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::thread;
use std::time::Duration;
use crate::devices::keyboard::KeyboardDevice;
use crate::devices::{Device, EV_KEY};
use crate::scenarios::ScenarioArgs;
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 mut keyboard = KeyboardDevice::create(Some(&device), "Example Keyboard")?;
eprintln!("sysname: {}", keyboard.sysname());
thread::sleep(Duration::from_secs(1));
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: keyboard.event_log().to_vec(),
};
let serialized = serde_json::to_string(&eventlog).unwrap();
println!("Event log: {}", serialized);
KeyboardDevice::destroy(keyboard);
Ok(())
}
}

View file

@ -0,0 +1,40 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::thread;
use std::time::Duration;
use crate::devices::mouse::MouseDevice;
use crate::devices::{Device, EV_KEY};
use crate::scenarios::ScenarioArgs;
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 mut mouse = MouseDevice::create(Some(&device), "Example Mouse")?;
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);
MouseDevice::destroy(mouse);
Ok(())
}
}

View file

@ -0,0 +1,39 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
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(())
}
}

View file

@ -0,0 +1,40 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::thread;
use std::time::Duration;
use crate::devices::ps4_gamepad::Ps4GamepadDevice;
use crate::devices::{Device, EV_KEY};
use crate::scenarios::ScenarioArgs;
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 mut gamepad = Ps4GamepadDevice::create(Some(&device), "PS4 Gamepad")?;
eprintln!("sysname: {}", gamepad.sysname());
thread::sleep(Duration::from_secs(1));
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: gamepad.event_log().to_vec(),
};
let serialized = serde_json::to_string(&eventlog).unwrap();
println!("Event log: {}", serialized);
Ps4GamepadDevice::destroy(gamepad);
Ok(())
}
}

View file

@ -0,0 +1,41 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::thread;
use std::time::Duration;
use crate::devices::xbox_gamepad::XboxGamepadDevice;
use crate::devices::{Device, EV_KEY};
use crate::scenarios::ScenarioArgs;
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 mut gamepad = XboxGamepadDevice::create(Some(&device), "Xbox Gamepad")?;
eprintln!("sysname: {}", gamepad.sysname());
thread::sleep(Duration::from_secs(1));
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: gamepad.event_log().to_vec(),
};
let serialized = serde_json::to_string(&eventlog).unwrap();
println!("Event log: {}", serialized);
XboxGamepadDevice::destroy(gamepad);
Ok(())
}
}

View file

@ -0,0 +1,76 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
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};
use libc::{self, ff_effect, ff_replay, ff_trigger};
//TODO: poll and erase
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));
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);
// ensure uploaded effect gets processed
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)?;
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_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_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(),
};
let serialized = serde_json::to_string(&eventlog).unwrap();
println!("Event log: {}", serialized);
XboxGamepadDevice::destroy(gamepad);
Ok(())
}
}

View file

@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
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;
/*
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_mouse_absolute::BasicMouseAbsolute;
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;
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<String>,
}

View file

@ -4,7 +4,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LoggedInputEvent { pub struct LoggedInputEvent {
pub tv_sec: i64, pub tv_sec: i64,

View file

@ -26,7 +26,7 @@ fn test_bwrap_simple() {
#[cfg(all(feature = "requires-privileges", feature = "requires-bwrap"))] #[cfg(all(feature = "requires-privileges", feature = "requires-bwrap"))]
#[test] #[test]
fn test_bwrap_ipc() { 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() let (builder, ipc) = bwrap::BwrapBuilder::new()
.unshare_all() .unshare_all()
@ -99,18 +99,14 @@ fn test_keyboard_on_host() {
fn test_keyboard_in_container_with_uinput() { fn test_keyboard_in_container_with_uinput() {
let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard");
let (builder, _ipc) = bwrap::BwrapBuilder::new() let out = bwrap::BwrapBuilder::new()
.unshare_net() .unshare_net()
.ro_bind("/", "/") .ro_bind("/", "/")
.tmpfs("/tmp") .tmpfs("/tmp")
.dev_bind("/dev/uinput", "/dev/uinput") .dev_bind("/dev/uinput", "/dev/uinput")
.dev_bind("/dev/input", "/dev/input") .dev_bind("/dev/input", "/dev/input")
.die_with_parent() .die_with_parent()
.with_ipc() .command(test_keyboard, &[])
.expect("failed to create IPC");
let out = builder
.command(test_keyboard, &["--ipc"])
.run() .run()
.unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}"));
@ -127,12 +123,12 @@ fn test_keyboard_in_container_with_uinput() {
feature = "requires-bwrap" feature = "requires-bwrap"
))] ))]
#[test] #[test]
fn test_keyboard_in_container_with_vuinput() { fn test_keyboard_in_container_with_vuinput_placement_in_container() {
run_vuinputd::ensure_vuinputd_running(); let _guard: run_vuinputd::VuinputdGuard = run_vuinputd::ensure_vuinputd_running(&[]);
let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard"); let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard");
let (builder, _ipc) = bwrap::BwrapBuilder::new() let out = bwrap::BwrapBuilder::new()
.unshare_net() .unshare_net()
.ro_bind("/", "/") .ro_bind("/", "/")
.tmpfs("/tmp") .tmpfs("/tmp")
@ -142,11 +138,103 @@ fn test_keyboard_in_container_with_vuinput() {
.tmpfs("/run") .tmpfs("/run")
.dev_bind("/dev/vuinput-test", "/dev/uinput") .dev_bind("/dev/vuinput-test", "/dev/uinput")
.die_with_parent() .die_with_parent()
.with_ipc() .command(test_keyboard, &[])
.expect("failed to create IPC"); .run()
.unwrap_or_else(|e| panic!("failed to run bwrap!: {e}"));
let out = builder
.command(test_keyboard, &["--ipc"]) println!("Output");
println!("stdout: {}", str::from_utf8(&out.stdout).unwrap());
println!("stderr: {}", str::from_utf8(&out.stderr).unwrap());
assert!(out.status.success());
}
#[cfg(all(
feature = "requires-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 out = 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()
.command(test_keyboard, &[])
.run()
.unwrap_or_else(|e| panic!("failed to run bwrap!: {e}"));
println!("Output");
println!("stdout: {}", str::from_utf8(&out.stdout).unwrap());
println!("stderr: {}", str::from_utf8(&out.stderr).unwrap());
assert!(out.status.success());
}
#[cfg(all(
feature = "requires-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 out = 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")
.command(test_scenarios, &["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());
}
#[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 out = 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()
.command(test_scenarios, &["basic-mouse-absolute"])
.run() .run()
.unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}"));

View file

@ -0,0 +1,127 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::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() {
let _guard = 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());
}
#[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());
}

View file

@ -1,6 +1,6 @@
[package] [package]
name = "vuinputd" name = "vuinputd"
version = "0.3.0" version = "0.3.2"
edition = "2021" edition = "2021"
authors = ["Johannes Leupolz <dev@leupolz.eu>"] authors = ["Johannes Leupolz <dev@leupolz.eu>"]
license = "MIT" license = "MIT"
@ -29,3 +29,13 @@ anyhow = "1.0.100"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" 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 = []

View file

@ -16,14 +16,18 @@ pub enum Action {
minor: u64, minor: u64,
}, },
#[serde(rename = "emit-udev-event")] #[serde(rename = "write-udev-runtime-data")]
EmitUdevEvent { WriteUdevRuntimeData {
netlink_message: HashMap<String, String>,
runtime_data: Option<String>, runtime_data: Option<String>,
major: u64, major: u64,
minor: u64, minor: u64,
}, },
#[serde(rename = "emit-netlink-message")]
EmitNetlinkMessage {
netlink_message: HashMap<String, String>,
},
#[serde(rename = "remove-device")] #[serde(rename = "remove-device")]
RemoveDevice { RemoveDevice {
path: String, path: String,

View file

@ -3,9 +3,9 @@
// Author: Johannes Leupolz <dev@leupolz.eu> // Author: Johannes Leupolz <dev@leupolz.eu>
use super::action::Action; use super::action::Action;
use super::input_device; use crate::input_realizer::input_device;
use super::netlink_message; use crate::input_realizer::netlink_message;
use super::runtime_data; use crate::input_realizer::runtime_data;
pub fn handle_cli_action(json: String) -> i32 { pub fn handle_cli_action(json: String) -> i32 {
let action: Action = serde_json::from_str(&json).expect("invalid action JSON"); let action: Action = serde_json::from_str(&json).expect("invalid action JSON");
@ -21,20 +21,24 @@ fn handle_action(action: Action) -> anyhow::Result<()> {
input_device::ensure_input_device(path, major.into(), minor.into())?; input_device::ensure_input_device(path, major.into(), minor.into())?;
Ok(()) Ok(())
} }
Action::EmitUdevEvent { Action::WriteUdevRuntimeData {
netlink_message,
runtime_data, runtime_data,
major, major,
minor, minor,
} => { } => {
netlink_message::send_udev_monitor_message_with_properties(netlink_message);
runtime_data::ensure_udev_structure()?; runtime_data::ensure_udev_structure()?;
match runtime_data { match runtime_data {
Some(data) => runtime_data::write_udev_data(&data, major.into(), minor.into())?, Some(data) => {
None => runtime_data::delete_udev_data(major.into(), minor.into())?, runtime_data::write_udev_data("/run", &data, major.into(), minor.into())?
}
None => runtime_data::delete_udev_data("/run", major.into(), minor.into())?,
} }
Ok(()) Ok(())
} }
Action::EmitNetlinkMessage { netlink_message } => {
netlink_message::send_udev_monitor_message_with_properties(netlink_message);
Ok(())
}
Action::RemoveDevice { path, major, minor } => { Action::RemoveDevice { path, major, minor } => {
input_device::remove_input_device(path, major.into(), minor.into())?; input_device::remove_input_device(path, major.into(), minor.into())?;
Ok(()) Ok(())

View file

@ -4,6 +4,3 @@
pub mod action; pub mod action;
pub mod handle_action; pub mod handle_action;
pub mod input_device;
pub mod netlink_message;
pub mod runtime_data;

View file

@ -0,0 +1,405 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::collections::HashMap;
use anyhow::bail;
use async_trait::async_trait;
use crate::{
actions::action::Action,
global_config::{self, get_scope},
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 {};
pub static INCUS: Incus = Incus {};
#[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<()>;
/// Remove runtime data.
async fn remove_udev_runtime_data(
&self,
requesting_process: &RequestingProcess,
major: u64,
minor: u64,
) -> anyhow::Result<()>;
/// Emit netlink message.
async fn emit_netlink_message(
&self,
requesting_process: &RequestingProcess,
netlink_message: HashMap<String, String>,
) -> anyhow::Result<()>;
}
pub struct GenericPlacementInContainer {}
pub struct GenericPlacementOnHost {}
pub struct GenericSendNetlinkMessageOnly {}
pub struct Incus {}
#[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, false)
.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, false)
.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, false)
.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, false)
.expect("subprocess should work");
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<String, String>,
) -> anyhow::Result<()> {
let emit_netlink_message = Action::EmitNetlinkMessage {
netlink_message: netlink_message,
};
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(())
}
}
#[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(())
}
/// Emit netlink message.
async fn emit_netlink_message(
&self,
requesting_process: &RequestingProcess,
netlink_message: HashMap<String, String>,
) -> anyhow::Result<()> {
PLACEMENT_IN_CONTAINER
.emit_netlink_message(requesting_process, netlink_message)
.await
}
}
#[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(())
}
/// Emit netlink message.
async fn emit_netlink_message(
&self,
requesting_process: &RequestingProcess,
netlink_message: HashMap<String, String>,
) -> anyhow::Result<()> {
PLACEMENT_IN_CONTAINER
.emit_netlink_message(requesting_process, netlink_message)
.await
}
}
#[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 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",
"add",
container_name,
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<()> {
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(())
}
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
}
/// Emit netlink message.
/// Emit netlink message.
async fn emit_netlink_message(
&self,
requesting_process: &RequestingProcess,
netlink_message: HashMap<String, String>,
) -> anyhow::Result<()> {
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(())
}
}

View file

@ -0,0 +1,77 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use crate::{
container_runtime::injection_strategy::{
GenericPlacementInContainer, GenericPlacementOnHost, GenericSendNetlinkMessageOnly, INCUS, 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 => &INCUS,
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"),
}
}
}

View file

@ -0,0 +1,163 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
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;
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
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};
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;
let value = event.value;
if type_ == EV_KEY {
match code {
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,
_ => {}
}
}
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 + F1F12 usually maps to Console_1 Console_12.
// - Right Alt (AltGr) + F1F12 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;
}
// 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) {
return false;
}
// 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 | 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 {
match event.type_ {
EV_SYN => true,
// Analog sticks, triggers
EV_ABS => 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,
}
}

View file

@ -0,0 +1,117 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
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, PollPhase, VuFileHandle};
pub static EVDEV_WRITE_WATCHER: OnceLock<Mutex<EvdevWriteWatcher>> = OnceLock::new();
pub fn initialize_evdev_write_watcher() -> anyhow::Result<()> {
EVDEV_WRITE_WATCHER
.set(Mutex::new(EvdevWriteWatcher::new()?)) // Convert the error from Mutex<T> 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<Epoll>,
shutdown: Arc<AtomicBool>,
thread_handle: Option<JoinHandle<()>>,
}
impl EvdevWriteWatcher {
fn new() -> anyhow::Result<Self> {
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 | EpollFlags::EPOLLET, fh),
)
}
pub fn remove_device<Fd: AsFd>(&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<AtomicBool>, epoll: Arc<Epoll>) {
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();
let handle = state.poll.take_waiters();
if let Some(mut handle) = handle {
handle.notify();
}
state.poll.pollphase = PollPhase::Readable;
}
}
}
}

View file

@ -2,9 +2,13 @@
// //
// Author: Johannes Leupolz <dev@leupolz.eu> // Author: Johannes Leupolz <dev@leupolz.eu>
pub mod device_policy;
pub mod evdev_write_watcher;
pub mod state; pub mod state;
pub mod vuinput_ioctl; pub mod vuinput_ioctl;
pub mod vuinput_open; pub mod vuinput_open;
pub mod vuinput_poll;
pub mod vuinput_read;
pub mod vuinput_release; pub mod vuinput_release;
pub mod vuinput_write; pub mod vuinput_write;
@ -28,12 +32,12 @@ pub fn vuinput_make_cuse_ops() -> cuse_lowlevel::cuse_lowlevel_ops {
init_done: None, init_done: None,
destroy: None, destroy: None,
open: Some(vuinput_open::vuinput_open), open: Some(vuinput_open::vuinput_open),
read: None, read: Some(vuinput_read::vuinput_read),
write: Some(vuinput_write::vuinput_write), write: Some(vuinput_write::vuinput_write),
flush: None, flush: None,
release: Some(vuinput_release::vuinput_release), release: Some(vuinput_release::vuinput_release),
fsync: None, fsync: None,
ioctl: Some(vuinput_ioctl::vuinput_ioctl), ioctl: Some(vuinput_ioctl::vuinput_ioctl),
poll: None, poll: Some(vuinput_poll::vuinput_poll),
} }
} }

View file

@ -4,25 +4,156 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::File; use std::fs::File;
use std::ptr::NonNull;
use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::sync::{Arc, Mutex, OnceLock, RwLock};
use ::cuse_lowlevel::*; use ::cuse_lowlevel::*;
use smallvec::SmallVec;
use crate::process_tools::RequestingProcess; use crate::process_tools::RequestingProcess;
pub type PendingPollHandles = SmallVec<[*mut fuse_lowlevel::fuse_pollhandle; 1]>;
#[derive(Debug)] #[derive(Debug)]
pub struct VuInputDevice { pub struct VuInputDevice {
pub major: u64, pub major: u64,
pub minor: u64, pub minor: u64,
pub syspath: String, pub syspath: String,
pub devname: String,
pub devnode: String, 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,
}
}
}
/// 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
}
}
#[derive(Debug)]
pub struct PollHandle {
ptr: NonNull<fuse_lowlevel::fuse_pollhandle>,
has_been_completed: bool,
}
impl PollHandle {
pub fn new(ptr: NonNull<fuse_lowlevel::fuse_pollhandle>) -> 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.
/// 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 pollphase: PollPhase,
/// Pending FUSE poll waiters for this device.
/// Optimized for the common case of 0 or 1 waiter, but supports
/// multiple concurrent poll() callers correctly.
pending: Option<PollHandle>,
}
impl PollState {
pub fn new() -> PollState {
PollState {
pollphase: PollPhase::Empty,
pending: None,
}
}
pub fn has_waiters(&self) -> bool {
!self.pending.is_some()
}
pub fn set_waiter(&mut self, handle: NonNull<fuse_lowlevel::fuse_pollhandle>) {
self.pending = Some(PollHandle::new(handle));
}
pub fn take_waiters(&mut self) -> Option<PollHandle> {
std::mem::take(&mut self.pending)
}
}
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)] #[derive(Debug)]
pub struct VuInputState { pub struct VuInputState {
pub file: File, pub file: File,
pub requesting_process: RequestingProcess, pub requesting_process: RequestingProcess,
pub input_device: Option<VuInputDevice>, pub input_device: Option<VuInputDevice>,
pub keytracker: KeyTracker,
pub poll: PollState,
} }
#[derive(Debug, Eq, Hash, PartialEq, Clone)] #[derive(Debug, Eq, Hash, PartialEq, Clone)]

View file

@ -3,20 +3,19 @@
// Author: Johannes Leupolz <dev@leupolz.eu> // Author: Johannes Leupolz <dev@leupolz.eu>
use ::cuse_lowlevel::*; 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 libc::{uinput_abs_setup, uinput_ff_erase, uinput_ff_upload, uinput_setup};
use log::debug; use log::debug;
use std::ffi::CStr; use std::ffi::CStr;
use std::io::Write;
use std::os::fd::AsRawFd; use std::os::fd::AsRawFd;
use std::os::raw::{c_char, c_int, c_uint, c_void}; use std::os::raw::{c_char, c_int, c_uint, c_void};
use uinput_ioctls::*; use uinput_ioctls::*;
use crate::cuse_device::{get_vuinput_state, VuFileHandle}; use crate::cuse_device::{get_vuinput_state, VuFileHandle};
use crate::job_engine::JOB_DISPATCHER; use crate::job_engine::JOB_DISPATCHER;
use crate::jobs::emit_udev_event_in_container_job::EmitUdevEventInContainerJob; use crate::jobs::emit_udev_event_job::EmitUdevEventJob;
use crate::jobs::mknod_device_in_container_job::MknodDeviceInContainerJob; use crate::jobs::mknod_device_job::MknodDeviceJob;
use crate::jobs::remove_from_container_job::RemoveFromContainerJob; use crate::jobs::remove_device_job::RemoveDeviceJob;
use crate::process_tools::SELF_NAMESPACES; use crate::process_tools::SELF_NAMESPACES;
use crate::{cuse_device::*, jobs}; use crate::{cuse_device::*, jobs};
@ -165,7 +164,7 @@ pub unsafe extern "C" fn vuinput_ioctl(
CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy() CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy()
); );
debug!("fh {}: syspath: {}", fh, sysname); 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); debug!("fh {}: devnode: {}", fh, devnode);
let (major, minor) = fetch_major_minor(&devnode).unwrap(); let (major, minor) = fetch_major_minor(&devnode).unwrap();
debug!("fh {}: major: {} minor: {} ", fh, major, minor); debug!("fh {}: major: {} minor: {} ", fh, major, minor);
@ -173,6 +172,7 @@ pub unsafe extern "C" fn vuinput_ioctl(
major: major, major: major,
minor: minor, minor: minor,
syspath: sysname.clone(), syspath: sysname.clone(),
devname: devname.clone(),
devnode: devnode.clone(), devnode: devnode.clone(),
}); });
@ -182,9 +182,9 @@ pub unsafe extern "C" fn vuinput_ioctl(
.unwrap() .unwrap()
.equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) .equal_mnt_and_net(&vuinput_state.requesting_process.namespaces)
{ {
let mknod_job = MknodDeviceInContainerJob::new( let mknod_job = MknodDeviceJob::new(
vuinput_state.requesting_process.clone(), vuinput_state.requesting_process.clone(),
devnode.clone(), devname.clone(),
sysname.clone(), sysname.clone(),
major, major,
minor, minor,
@ -196,12 +196,12 @@ pub unsafe extern "C" fn vuinput_ioctl(
.lock() .lock()
.unwrap() .unwrap()
.dispatch(Box::new(mknod_job)); .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); debug!("fh {}: mknod_device in container has been finished ", fh);
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0); fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
// we do not wait for the udev stuff // 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(), vuinput_state.requesting_process.clone(),
devnode.clone(), devnode.clone(),
sysname.clone(), sysname.clone(),
@ -230,9 +230,9 @@ pub unsafe extern "C" fn vuinput_ioctl(
.equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) .equal_mnt_and_net(&vuinput_state.requesting_process.namespaces)
{ {
let input_device = input_device.unwrap(); let input_device = input_device.unwrap();
let remove_job = RemoveFromContainerJob::new( let remove_job = RemoveDeviceJob::new(
vuinput_state.requesting_process.clone(), vuinput_state.requesting_process.clone(),
input_device.devnode.clone(), input_device.devname.clone(),
input_device.syspath.clone(), input_device.syspath.clone(),
input_device.major, input_device.major,
input_device.minor, input_device.minor,
@ -244,7 +244,7 @@ pub unsafe extern "C" fn vuinput_ioctl(
.lock() .lock()
.unwrap() .unwrap()
.dispatch(Box::new(remove_job)); .dispatch(Box::new(remove_job));
awaiter(&jobs::remove_from_container_job::State::Finished); awaiter(&jobs::remove_device_job::State::Finished);
debug!( debug!(
"fh {}: removing dev-nodes from container has been finished ", "fh {}: removing dev-nodes from container has been finished ",
fh fh
@ -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 //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); debug!("fh {}: ioctl UI_ABS_SETUP", fh);
assert!(_in_bufsz != 0, "should have _in_bufsz"); 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); fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
} }
UI_GET_SYSNAME_WITHOUT_SIZE => { UI_GET_SYSNAME_WITHOUT_SIZE => {
@ -416,12 +420,12 @@ pub unsafe extern "C" fn vuinput_ioctl(
} }
} }
pub fn fetch_device_node(path: &str) -> io::Result<String> { pub fn fetch_device_node(path: &str) -> io::Result<(String, String)> {
for entry in fs::read_dir(path)? { for entry in fs::read_dir(path)? {
let entry = entry?; // propagate per-entry errors let entry = entry?; // propagate per-entry errors
if let Some(name) = entry.file_name().to_str() { if let Some(name) = entry.file_name().to_str() {
if name.starts_with("event") { if name.starts_with("event") {
return Ok(format!("/dev/input/{}", name)); return Ok((name.to_string(), format!("/dev/input/{}", name)));
} }
} }
} }

View file

@ -5,13 +5,16 @@
use ::cuse_lowlevel::*; use ::cuse_lowlevel::*;
use libc::ENOENT; use libc::ENOENT;
use libc::O_CLOEXEC; use libc::O_CLOEXEC;
use libc::O_NONBLOCK;
use log::{debug, error}; use log::{debug, error};
use std::fs::OpenOptions; use std::fs::OpenOptions;
use std::os::fd::AsFd;
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
use std::path::Path; use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock; use std::sync::OnceLock;
use crate::cuse_device::evdev_write_watcher::EVDEV_WRITE_WATCHER;
use crate::cuse_device::*; use crate::cuse_device::*;
use crate::process_tools::{get_requesting_process, Pid}; use crate::process_tools::{get_requesting_process, Pid};
@ -39,24 +42,33 @@ pub unsafe extern "C" fn vuinput_open(
debug!("fh {}: namespaces {}", fh, requesting_process); 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 // 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; (*_fi).fh = fh;
// Open the path in read-only mode, returns `io::Result<File>` // Open the path, returns `io::Result<File>`
let open_vuinput_result = OpenOptions::new() let open_vuinput_result = OpenOptions::new()
.read(true) .read(true)
.write(true) .write(true)
//.custom_flags(O_NONBLOCK) .custom_flags(O_NONBLOCK | O_CLOEXEC)
.custom_flags(O_CLOEXEC)
.open(Path::new("/dev/uinput")); .open(Path::new("/dev/uinput"));
match open_vuinput_result { match open_vuinput_result {
Ok(v) => { Ok(v) => {
let vu_fh: VuFileHandle = VuFileHandle::Fh(fh);
insert_vuinput_state( insert_vuinput_state(
&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap()), &vu_fh,
VuInputState { VuInputState {
file: v, file: v,
requesting_process, requesting_process,
input_device: None, input_device: None,
keytracker: KeyTracker::new(),
poll: PollState::new(),
}, },
) )
.unwrap(); .unwrap();
EVDEV_WRITE_WATCHER
.get()
.unwrap()
.lock()
.unwrap()
.add_device(vu_fh)
.unwrap();
fuse_lowlevel::fuse_reply_open(_req, _fi); fuse_lowlevel::fuse_reply_open(_req, _fi);
} }
Err(e) => { Err(e) => {

View file

@ -0,0 +1,59 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use crate::cuse_device::*;
use crate::global_config::get_device_policy;
use ::cuse_lowlevel::*;
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};
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
// 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;
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();
match vuinput_state.poll.pollphase {
PollPhase::Empty => {
if ph != std::ptr::null_mut() {
let ph = NonNull::<fuse_lowlevel::fuse_pollhandle>::new(ph);
vuinput_state.poll.set_waiter(ph.unwrap());
}
fuse_lowlevel::fuse_reply_poll(req, 0);
}
PollPhase::Readable => {
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 => {
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());
}
}
}

View file

@ -0,0 +1,76 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use crate::cuse_device::*;
use ::cuse_lowlevel::*;
use libc::{__s32, __u16, input_event, EAGAIN};
use libc::{off_t, size_t, EIO};
use log::{debug, trace};
use std::io::{Read, Write};
use std::os::fd::AsRawFd;
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
);
//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();
let mut vuinput_state = vuinput_state_mutex.lock().unwrap();
const NORMAL_SIZE: usize = std::mem::size_of::<libc::input_event>();
let is_compat = vuinput_state.requesting_process.is_compat;
// TODO: ARM: && !compat_uses_64bit_time()
let mut buffer: [u8; 24] = [0; 24];
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 {
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) => {
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);
}
}
}

View file

@ -2,12 +2,14 @@
// //
// Author: Johannes Leupolz <dev@leupolz.eu> // Author: Johannes Leupolz <dev@leupolz.eu>
use crate::cuse_device::evdev_write_watcher::EVDEV_WRITE_WATCHER;
use crate::job_engine::JOB_DISPATCHER; use crate::job_engine::JOB_DISPATCHER;
use crate::jobs::remove_from_container_job::RemoveFromContainerJob; use crate::jobs::remove_device_job::RemoveDeviceJob;
use crate::process_tools::SELF_NAMESPACES; use crate::process_tools::SELF_NAMESPACES;
use crate::{cuse_device::*, jobs}; use crate::{cuse_device::*, jobs};
use ::cuse_lowlevel::*; use ::cuse_lowlevel::*;
use log::debug; use log::debug;
use std::os::fd::AsFd;
use std::sync::Arc; use std::sync::Arc;
pub unsafe extern "C" fn vuinput_release( 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, _fi: *mut fuse_lowlevel::fuse_file_info,
) { ) {
let fh = &(*_fi).fh; let fh = &(*_fi).fh;
let vuinput_state_mutex = let vu_fh = VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap());
remove_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap(); let vuinput_state_mutex = remove_vuinput_state(&vu_fh).unwrap();
let mut vuinput_state = vuinput_state_mutex.lock().unwrap(); let mut vuinput_state = vuinput_state_mutex.lock().unwrap();
let input_device = vuinput_state.input_device.take(); let input_device = vuinput_state.input_device.take();
@ -32,9 +34,9 @@ pub unsafe extern "C" fn vuinput_release(
.equal_mnt_and_net(&vuinput_state.requesting_process.namespaces) .equal_mnt_and_net(&vuinput_state.requesting_process.namespaces)
{ {
let input_device = input_device.unwrap(); let input_device = input_device.unwrap();
let remove_job = RemoveFromContainerJob::new( let remove_job = RemoveDeviceJob::new(
vuinput_state.requesting_process.clone(), vuinput_state.requesting_process.clone(),
input_device.devnode.clone(), input_device.devname.clone(),
input_device.syspath.clone(), input_device.syspath.clone(),
input_device.major, input_device.major,
input_device.minor, input_device.minor,
@ -46,9 +48,17 @@ pub unsafe extern "C" fn vuinput_release(
.lock() .lock()
.unwrap() .unwrap()
.dispatch(Box::new(remove_job)); .dispatch(Box::new(remove_job));
awaiter(&jobs::remove_from_container_job::State::Finished); 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); drop(vuinput_state);
debug!( debug!(

View file

@ -3,6 +3,7 @@
// Author: Johannes Leupolz <dev@leupolz.eu> // Author: Johannes Leupolz <dev@leupolz.eu>
use crate::cuse_device::*; use crate::cuse_device::*;
use crate::global_config::get_device_policy;
use ::cuse_lowlevel::*; use ::cuse_lowlevel::*;
use libc::{__s32, __u16, input_event}; use libc::{__s32, __u16, input_event};
use libc::{off_t, size_t, EIO}; 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; let is_compat = vuinput_state.requesting_process.is_compat;
// TODO: ARM: && !compat_uses_64bit_time() // TODO: ARM: && !compat_uses_64bit_time()
let policy = get_device_policy();
if !is_compat { if !is_compat {
while bytes + normal_size <= _size && result.is_ok() { 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; bytes += normal_size;
} }
} else { } else {
@ -99,7 +106,9 @@ pub unsafe extern "C" fn vuinput_write(
let normal = map_to_64_bit(&*compat); let normal = map_to_64_bit(&*compat);
let normal_ptr = (&normal as *const libc::input_event) as *const u8; let normal_ptr = (&normal as *const libc::input_event) as *const u8;
let slice = std::slice::from_raw_parts(normal_ptr, normal_size); 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; bytes += compat_size;
} }
}; };

View file

@ -0,0 +1,122 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use clap::ValueEnum;
use std::sync::OnceLock;
use crate::container_runtime::ContainerRuntime;
#[derive(Debug)]
pub struct GlobalConfig {
pub policy: DevicePolicy,
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().
pub static CONFIG: OnceLock<GlobalConfig> = 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"
pub enum DevicePolicy {
/// Allow all device capabilities
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.
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]
/// Create inside the container
InContainer,
/// Create on the host (user is expected to bind-mount)
OnHost,
/// Do not create any artifacts (netlink message in container is unaffected)
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,
container_runtime: &ContainerRuntime,
devname: &Option<String>,
device_owner: &DeviceOwner,
scope: &Scope,
) {
if CONFIG
.set(GlobalConfig {
policy: device_policy.clone(),
container_runtime: container_runtime.clone(),
vudevname: devname.clone().unwrap_or("vuinput".to_string()),
device_owner: device_owner.clone(),
scope: scope.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
}
pub fn get_container_runtime<'a>() -> &'a ContainerRuntime {
&CONFIG.get().unwrap().container_runtime
}
pub fn get_vudevname<'a>() -> &'a String {
&CONFIG.get().unwrap().vudevname
}
pub fn get_device_owner<'a>() -> &'a DeviceOwner {
&CONFIG.get().unwrap().device_owner
}
pub fn get_scope<'a>() -> &'a Scope {
&CONFIG.get().unwrap().scope
}

View file

@ -0,0 +1,80 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
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(())
}

View file

@ -0,0 +1,8 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
pub mod host_fs;
pub mod input_device;
pub mod netlink_message;
pub mod runtime_data;

View file

@ -6,12 +6,16 @@ use std::fs::{self, File};
use std::io::{self, Write}; use std::io::{self, Write};
use std::path::Path; use std::path::Path;
use log::{info, warn};
/// Ensure required udev directories and files exist /// Ensure required udev directories and files exist
pub fn ensure_udev_structure() -> io::Result<()> { 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 data_dir = format!("/run/udev/data");
let control_file = Path::new("/run/udev/control"); let data_dir = Path::new(&data_dir);
let control_file = format!("/run/udev/control");
let control_file = Path::new(&control_file);
// Create directory like `mkdir -p` // Create directory like `mkdir -p`
if !data_dir.exists() { if !data_dir.exists() {
@ -20,6 +24,11 @@ pub fn ensure_udev_structure() -> io::Result<()> {
// Ensure /run/udev/control exists, create empty if not // Ensure /run/udev/control exists, create empty if not
if !control_file.exists() { 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)?; File::create(control_file)?;
} }
@ -35,7 +44,7 @@ pub fn ensure_udev_structure() -> io::Result<()> {
/// - remove all lines containing `seat_` references (G:, Q: lines) /// - remove all lines containing `seat_` references (G:, Q: lines)
/// - replace ID_VUINPUT_* with ID_INPUT_* /// - replace ID_VUINPUT_* with ID_INPUT_*
/// - write updated content to `/run/udev/data/c<major>:<minor>` /// - write updated content to `/run/udev/data/c<major>:<minor>`
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(); let mut cleaned = String::new();
for line in content.lines() { for line in content.lines() {
@ -53,7 +62,7 @@ pub fn write_udev_data(content: &str, major: u64, minor: u64) -> io::Result<()>
cleaned.push('\n'); 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)?; let mut file = File::create(&path)?;
file.write_all(cleaned.as_bytes())?; file.write_all(cleaned.as_bytes())?;
@ -62,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 /// Delete udev data for a given major/minor number
/// - `major`, `minor` = device numbers /// - `major`, `minor` = device numbers
pub fn delete_udev_data(major: u64, minor: u64) -> io::Result<()> { pub fn delete_udev_data(path_prefix: &str, major: u64, minor: u64) -> io::Result<()> {
let path = format!("/run/udev/data/c{}:{}", major, minor); let path = format!("{}/udev/data/c{}:{}", path_prefix, major, minor);
fs::remove_file(&path)?; fs::remove_file(&path)?;
Ok(()) Ok(())
} }

View file

@ -14,7 +14,9 @@ use async_io::Timer;
use log::debug; use log::debug;
use crate::{ use crate::{
actions::{action::Action, runtime_data::read_udev_data}, actions::action::Action,
global_config::get_container_runtime,
input_realizer::runtime_data,
job_engine::job::{Job, JobTarget}, job_engine::job::{Job, JobTarget},
jobs::monitor_udev_job::EVENT_STORE, jobs::monitor_udev_job::EVENT_STORE,
process_tools::{self, await_process, Pid, RequestingProcess}, process_tools::{self, await_process, Pid, RequestingProcess},
@ -28,7 +30,7 @@ pub enum State {
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct EmitUdevEventInContainerJob { pub struct EmitUdevEventJob {
requesting_process: RequestingProcess, requesting_process: RequestingProcess,
target: JobTarget, target: JobTarget,
dev_path: String, dev_path: String,
@ -38,7 +40,7 @@ pub struct EmitUdevEventInContainerJob {
sync_state: Arc<(Mutex<State>, Condvar)>, sync_state: Arc<(Mutex<State>, Condvar)>,
} }
impl EmitUdevEventInContainerJob { impl EmitUdevEventJob {
pub fn new( pub fn new(
requesting_process: RequestingProcess, requesting_process: RequestingProcess,
dev_path: String, dev_path: String,
@ -79,17 +81,17 @@ impl EmitUdevEventInContainerJob {
} }
} }
impl Job for EmitUdevEventInContainerJob { impl Job for EmitUdevEventJob {
fn desc(&self) -> &str { fn desc(&self) -> &str {
"emit udev event into container" "emit udev event"
} }
fn execute_after_cancellation(&self) -> bool { fn execute_after_cancellation(&self) -> bool {
false false
} }
fn create_task(self: &EmitUdevEventInContainerJob) -> Pin<Box<dyn Future<Output = ()>>> { fn create_task(self: &EmitUdevEventJob) -> Pin<Box<dyn Future<Output = ()>>> {
Box::pin(self.clone().inject_in_container()) Box::pin(self.clone().emit_udev_event())
} }
fn job_target(&self) -> JobTarget { fn job_target(&self) -> JobTarget {
@ -97,8 +99,8 @@ impl Job for EmitUdevEventInContainerJob {
} }
} }
impl EmitUdevEventInContainerJob { impl EmitUdevEventJob {
async fn inject_in_container(self) { async fn emit_udev_event(self) {
// temporary hack that needs to be replaced. We try 50 times // 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 // Should be: Wait for the device to be created, the runtime data to be written and the
// netlink message to be sent // netlink message to be sent
@ -123,7 +125,7 @@ impl EmitUdevEventInContainerJob {
}; };
} }
if runtime_data.is_none() { 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; number_of_attempt += 1;
@ -143,20 +145,24 @@ impl EmitUdevEventInContainerJob {
let runtime_data = runtime_data.unwrap(); let runtime_data = runtime_data.unwrap();
let netlink_data = netlink_data.unwrap(); let netlink_data = netlink_data.unwrap();
let dev_path = self.dev_path.clone();
let emit_udev_event_action = Action::EmitUdevEvent { let injector = get_container_runtime().injection_strategy();
netlink_message: netlink_data.clone(),
runtime_data: Some(runtime_data),
major: self.major,
minor: self.minor,
};
let child_pid = injector
process_tools::start_action(emit_udev_event_action, &self.requesting_process) .write_udev_runtime_data(
.expect("subprocess should work"); &self.requesting_process,
&runtime_data,
self.major,
self.minor,
)
.await
.unwrap();
injector
.emit_netlink_message(&self.requesting_process, netlink_data)
.await
.unwrap();
let _exit_info = await_process(Pid::Pid(child_pid)).await.unwrap();
self.set_state(&State::Finished); self.set_state(&State::Finished);
} }
} }

View file

@ -3,20 +3,16 @@
// Author: Johannes Leupolz <dev@leupolz.eu> // Author: Johannes Leupolz <dev@leupolz.eu>
use std::{ use std::{
collections::HashMap,
future::Future, future::Future,
pin::Pin, pin::Pin,
sync::{Arc, Condvar, Mutex}, sync::{Arc, Condvar, Mutex},
time::Duration,
}; };
use async_io::Timer;
use log::debug;
use crate::{ use crate::{
actions::{action::Action, runtime_data::read_udev_data}, actions::action::Action,
global_config::{self, get_container_runtime, Placement},
input_realizer::input_device,
job_engine::job::{Job, JobTarget}, job_engine::job::{Job, JobTarget},
jobs::monitor_udev_job::EVENT_STORE,
process_tools::{self, await_process, Pid, RequestingProcess}, process_tools::{self, await_process, Pid, RequestingProcess},
}; };
@ -28,20 +24,20 @@ pub enum State {
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct MknodDeviceInContainerJob { pub struct MknodDeviceJob {
requesting_process: RequestingProcess, requesting_process: RequestingProcess,
target: JobTarget, target: JobTarget,
dev_path: String, devname: String,
sys_path: String, sys_path: String,
major: u64, major: u64,
minor: u64, minor: u64,
sync_state: Arc<(Mutex<State>, Condvar)>, sync_state: Arc<(Mutex<State>, Condvar)>,
} }
impl MknodDeviceInContainerJob { impl MknodDeviceJob {
pub fn new( pub fn new(
requesting_process: RequestingProcess, requesting_process: RequestingProcess,
dev_path: String, devname: String,
sys_path: String, sys_path: String,
major: u64, major: u64,
minor: u64, minor: u64,
@ -49,7 +45,7 @@ impl MknodDeviceInContainerJob {
Self { Self {
requesting_process: requesting_process.clone(), requesting_process: requesting_process.clone(),
target: JobTarget::Container(requesting_process), target: JobTarget::Container(requesting_process),
dev_path: dev_path, devname: devname,
sys_path: sys_path, sys_path: sys_path,
major: major, major: major,
minor: minor, minor: minor,
@ -79,17 +75,17 @@ impl MknodDeviceInContainerJob {
} }
} }
impl Job for MknodDeviceInContainerJob { impl Job for MknodDeviceJob {
fn desc(&self) -> &str { fn desc(&self) -> &str {
"mknod input device in container" "mknod input device"
} }
fn execute_after_cancellation(&self) -> bool { fn execute_after_cancellation(&self) -> bool {
false false
} }
fn create_task(self: &MknodDeviceInContainerJob) -> Pin<Box<dyn Future<Output = ()>>> { fn create_task(self: &MknodDeviceJob) -> Pin<Box<dyn Future<Output = ()>>> {
Box::pin(self.clone().inject_in_container()) Box::pin(self.clone().mknod_device())
} }
fn job_target(&self) -> JobTarget { fn job_target(&self) -> JobTarget {
@ -97,18 +93,20 @@ impl Job for MknodDeviceInContainerJob {
} }
} }
impl MknodDeviceInContainerJob { impl MknodDeviceJob {
async fn inject_in_container(self) { async fn mknod_device(self) {
let mknod_device_action = Action::MknodDevice { let injector = get_container_runtime().injection_strategy();
path: self.dev_path.clone(),
major: self.major,
minor: self.minor,
};
let child_pid = process_tools::start_action(mknod_device_action, &self.requesting_process) injector
.expect("subprocess should work"); .mknod_device_node(
&self.requesting_process,
&self.devname,
self.major,
self.minor,
)
.await
.unwrap();
let _exit_info = await_process(Pid::Pid(child_pid)).await.unwrap();
self.set_state(&State::Finished); self.set_state(&State::Finished);
} }
} }

View file

@ -2,7 +2,7 @@
// //
// Author: Johannes Leupolz <dev@leupolz.eu> // Author: Johannes Leupolz <dev@leupolz.eu>
pub mod emit_udev_event_in_container_job; pub mod emit_udev_event_job;
pub mod mknod_device_in_container_job; pub mod mknod_device_job;
pub mod monitor_udev_job; pub mod monitor_udev_job;
pub mod remove_from_container_job; pub mod remove_device_job;

View file

@ -12,6 +12,8 @@ use log::debug;
use crate::{ use crate::{
actions::action::Action, actions::action::Action,
global_config::{self, get_container_runtime, Placement},
input_realizer::{input_device, runtime_data},
job_engine::job::{Job, JobTarget}, job_engine::job::{Job, JobTarget},
jobs::monitor_udev_job::EVENT_STORE, jobs::monitor_udev_job::EVENT_STORE,
process_tools::{self, await_process, Pid, RequestingProcess}, process_tools::{self, await_process, Pid, RequestingProcess},
@ -25,20 +27,20 @@ pub enum State {
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RemoveFromContainerJob { pub struct RemoveDeviceJob {
requesting_process: RequestingProcess, requesting_process: RequestingProcess,
target: JobTarget, target: JobTarget,
dev_path: String, dev_name: String,
sys_path: String, sys_path: String,
major: u64, major: u64,
minor: u64, minor: u64,
sync_state: Arc<(Mutex<State>, Condvar)>, sync_state: Arc<(Mutex<State>, Condvar)>,
} }
impl RemoveFromContainerJob { impl RemoveDeviceJob {
pub fn new( pub fn new(
requesting_process: RequestingProcess, requesting_process: RequestingProcess,
dev_path: String, dev_name: String,
sys_path: String, sys_path: String,
major: u64, major: u64,
minor: u64, minor: u64,
@ -46,7 +48,7 @@ impl RemoveFromContainerJob {
Self { Self {
requesting_process: requesting_process.clone(), requesting_process: requesting_process.clone(),
target: JobTarget::Container(requesting_process), target: JobTarget::Container(requesting_process),
dev_path: dev_path, dev_name: dev_name,
sys_path: sys_path, sys_path: sys_path,
major: major, major: major,
minor: minor, minor: minor,
@ -75,17 +77,17 @@ impl RemoveFromContainerJob {
} }
} }
impl Job for RemoveFromContainerJob { impl Job for RemoveDeviceJob {
fn desc(&self) -> &str { fn desc(&self) -> &str {
"Remove input device from container" "Remove input device"
} }
fn execute_after_cancellation(&self) -> bool { fn execute_after_cancellation(&self) -> bool {
false false
} }
fn create_task(self: &RemoveFromContainerJob) -> Pin<Box<dyn Future<Output = ()>>> { fn create_task(self: &RemoveDeviceJob) -> Pin<Box<dyn Future<Output = ()>>> {
Box::pin(self.clone().remove_from_container()) Box::pin(self.clone().remove_device())
} }
fn job_target(&self) -> JobTarget { fn job_target(&self) -> JobTarget {
@ -93,8 +95,8 @@ impl Job for RemoveFromContainerJob {
} }
} }
impl RemoveFromContainerJob { impl RemoveDeviceJob {
async fn remove_from_container(self) { async fn remove_device(self) {
self.set_state(&State::Started); self.set_state(&State::Started);
let netlink_event = match EVENT_STORE let netlink_event = match EVENT_STORE
@ -120,33 +122,31 @@ impl RemoveFromContainerJob {
let netlink_data = netlink_event.add_data; let netlink_data = netlink_event.add_data;
let mut netlink_data = netlink_data.unwrap().clone(); 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()); let _ = netlink_data.insert("ACTION".to_string(), "remove".to_string());
let remove_device_action = Action::RemoveDevice { let injector = get_container_runtime().injection_strategy();
path: dev_path.clone(),
major: self.major,
minor: self.minor,
};
let child_pid_1 = injector
process_tools::start_action(remove_device_action, &self.requesting_process) .remove_device_node(
.expect("subprocess should work"); &self.requesting_process,
&self.dev_name,
self.major,
self.minor,
)
.await
.unwrap();
let emit_udev_event_action = Action::EmitUdevEvent { injector
netlink_message: netlink_data.clone(), .remove_udev_runtime_data(&self.requesting_process, self.major, self.minor)
runtime_data: None, .await
major: self.major, .unwrap();
minor: self.minor,
};
let child_pid_2 = injector
process_tools::start_action(emit_udev_event_action, &self.requesting_process) .emit_netlink_message(&self.requesting_process, netlink_data)
.expect("subprocess should work"); .await
.unwrap();
let _exit_info = await_process(Pid::Pid(child_pid_1)).await;
let _exit_info = await_process(Pid::Pid(child_pid_2)).await;
self.set_state(&State::Finished); self.set_state(&State::Finished);
} }
} }

View file

@ -16,19 +16,28 @@
// distinguish between cleanup jobs that must not be cancelled and other jobs (especially background jobs) // 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. // naming: dev_path vs dev_node. I guess I mean the same.
// Send warning, if udev monitor does not exist // 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 ::cuse_lowlevel::*;
use base64::prelude::BASE64_STANDARD;
use base64::Engine as _;
use log::info; use log::info;
use std::ffi::CString; use std::ffi::CString;
use std::os::raw::c_char; use std::os::raw::c_char;
use std::path::PathBuf;
use std::sync::atomic::AtomicU64; use std::sync::atomic::AtomicU64;
use std::sync::Mutex; use std::sync::Mutex;
pub mod cuse_device; 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::state::{initialize_dedup_last_error, initialize_vuinput_state};
use crate::cuse_device::vuinput_make_cuse_ops; use crate::cuse_device::vuinput_make_cuse_ops;
use crate::cuse_device::vuinput_open::VUINPUT_COUNTER; use crate::cuse_device::vuinput_open::VUINPUT_COUNTER;
use crate::global_config::{DeviceOwner, DevicePolicy, Placement, Scope};
use crate::jobs::monitor_udev_job::MonitorBackgroundLoop; use crate::jobs::monitor_udev_job::MonitorBackgroundLoop;
pub mod process_tools; pub mod process_tools;
@ -38,8 +47,12 @@ use crate::job_engine::{job::*, JOB_DISPATCHER};
use crate::process_tools::*; use crate::process_tools::*;
pub mod actions; pub mod actions;
pub mod input_realizer;
pub mod container_runtime;
pub mod global_config;
pub mod jobs; pub mod jobs;
pub mod vt_tools;
use clap::Parser; use clap::Parser;
@ -65,50 +78,133 @@ struct Args {
#[arg(long, value_name = "JSON")] #[arg(long, value_name = "JSON")]
pub action: Option<String>, pub action: Option<String>,
/// Path to the target process's /proc/<pid>/ns directory used as namespace source. /// Action to execute (base64-encoded JSON). Note that this excludes all other options.
#[arg(long = "action-base64", value_name = "BASE64")]
pub action_base64: Option<String>,
/// 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( #[arg(
long = "target-namespace", long = "target-pid",
value_name = "NS_PATH", value_name = "PID",
help = "Path to /proc/<pid>/ns used as the namespace source (e.g. /proc/1234/ns or /proc/self/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_namespace: Option<String>, pub target_pid: Option<String>,
/// 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.",
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,
/// Enforce a device policy on created devices
#[arg(long, value_enum, default_value_t)]
device_policy: DevicePolicy,
/// [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<Placement>,
/// 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<PathBuf>,
/// 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<String>,
} }
fn validate_args(args: &Args) -> Result<(), String> { impl Args {
// action might only occur with target-namespace pub fn get_scope(&self) -> Scope {
match ( match &self.target_container {
&args.major, Some(name) => Scope::Single(name.clone()),
&args.minor, None => Scope::Multi,
&args.devname,
&args.action,
&args.target_namespace,
) {
(None, None, None, Some(_), _) => {}
(_, _, _, None, None) => {}
_ => {
return Err("--action must not be used in combination with any other argument other than target-namespace".into());
} }
} }
// major/minor must appear together pub fn resolve_runtime(&self) -> ContainerRuntime {
match (&args.major, &args.minor) { if let Some(legacy_placement) = &self.placement {
(Some(_), Some(_)) | (None, None) => {} return match legacy_placement {
_ => { Placement::InContainer => ContainerRuntime::GenericPlacementInContainer,
return Err("--major and --minor must be specified together or not at all".into()); Placement::OnHost => ContainerRuntime::GenericPlacementOnHost,
Placement::None => ContainerRuntime::GenericSendNetlinkMessageOnly,
};
} }
self.container_runtime.clone()
} }
// devname length constraint fn validate_args(&self) -> Result<(), String> {
if let Some(devname) = &args.devname { if self.placement.is_some() && self.container_runtime != ContainerRuntime::Auto {
if devname.len() >= DEVNAME_MAX_LEN { return Err(
return Err(format!( "Conflict: --placement and --container-runtime cannot be used together. \
"--devname must be shorter than {} bytes", Please use only --container-runtime (the --placement flag is deprecated)."
DEVNAME_MAX_LEN .into(),
)); );
} }
}
Ok(()) let action: &Option<String> = 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<()> { fn main() -> std::io::Result<()> {
@ -119,21 +215,60 @@ fn main() -> std::io::Result<()> {
.next() .next()
.expect("Couldn't retrieve program name"); .expect("Couldn't retrieve program name");
if let Err(e) = validate_args(&args) { if let Err(e) = args.validate_args() {
eprintln!("Error: {e}"); eprintln!("Error: {e}");
std::process::exit(2); std::process::exit(2);
} }
if args.action.is_some() { let action = match (&args.action, &args.action_base64) {
if let Some(target_namespace) = args.target_namespace { (Some(json), None) => Some(json.clone()),
process_tools::run_in_net_and_mnt_namespace(target_namespace.as_str()).unwrap(); (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)
} }
let error_code = actions::handle_action::handle_cli_action(args.action.unwrap()); (None, None) => None,
_ => unreachable!("validate_args enforces mutual exclusion"),
};
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,
args.enter_user_namespace,
)
.unwrap();
}
let error_code = actions::handle_action::handle_cli_action(action.unwrap());
std::process::exit(error_code); std::process::exit(error_code);
} }
check_permissions().expect("failed to read the capabilities of the vuinputd process"); if args.vt_guard {
vt_tools::mute_keyboard()?;
std::process::exit(0);
}
check_permissions().expect("failed to read the capabilities of the vuinputd process");
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",
);
initialize_vuinput_state(); initialize_vuinput_state();
VUINPUT_COUNTER.set(AtomicU64::new(3)).expect( VUINPUT_COUNTER.set(AtomicU64::new(3)).expect(
"failed to initialize the counter that provides the values of the CUSE file handles", "failed to initialize the counter that provides the values of the CUSE file handles",
@ -142,7 +277,7 @@ fn main() -> std::io::Result<()> {
.set(Mutex::new(Dispatcher::new())) .set(Mutex::new(Dispatcher::new()))
.expect("failed to initialize the job dispatcher"); .expect("failed to initialize the job dispatcher");
SELF_NAMESPACES SELF_NAMESPACES
.set(get_namespace(Pid::SelfPid)) .set(get_self_namespace())
.expect("failed to retrieve the namespaces of the vuinputd process"); .expect("failed to retrieve the namespaces of the vuinputd process");
initialize_dedup_last_error(); initialize_dedup_last_error();
JOB_DISPATCHER JOB_DISPATCHER
@ -160,6 +295,9 @@ fn main() -> std::io::Result<()> {
None => "vuinput", None => "vuinput",
Some(devname) => devname, Some(devname) => devname,
}; };
container_runtime.initialize();
let vuinput_devicename = CString::new(format!("DEVNAME={}", vuinput_devicename)).unwrap(); let vuinput_devicename = CString::new(format!("DEVNAME={}", vuinput_devicename)).unwrap();
let mut dev_info_argv: Vec<*const c_char> = vec![ let mut dev_info_argv: Vec<*const c_char> = vec![
@ -215,5 +353,7 @@ fn main() -> std::io::Result<()> {
.unwrap() .unwrap()
.wait_until_finished(); .wait_until_finished();
EVDEV_WRITE_WATCHER.get().unwrap().lock().unwrap().stop();
Ok(()) Ok(())
} }

View file

@ -3,13 +3,15 @@
// Author: Johannes Leupolz <dev@leupolz.eu> // Author: Johannes Leupolz <dev@leupolz.eu>
use async_io::Async; use async_io::Async;
use base64::prelude::BASE64_STANDARD;
use base64::Engine as _;
use log::debug; use log::debug;
use std::{ use std::{
fs::{self, File}, fs::{self, File},
io::Read, io::Read,
os::{ os::{
fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}, fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
unix::process::CommandExt, unix::{fs::MetadataExt, process::CommandExt},
}, },
path::Path, path::Path,
process::Command, process::Command,
@ -19,23 +21,34 @@ use std::{
use anyhow::anyhow; use anyhow::anyhow;
use std::io; use std::io;
use crate::actions::action::Action; use crate::{
actions::action::Action,
global_config::{get_device_owner, DeviceOwner},
};
pub mod ns_fscreds;
pub static SELF_NAMESPACES: OnceLock<Namespaces> = OnceLock::new(); pub static SELF_NAMESPACES: OnceLock<Namespaces> = OnceLock::new();
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] #[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum Pid { pub enum Pid {
SelfPid,
Pid(u32), Pid(u32),
} }
impl Pid { impl Pid {
pub fn path(&self) -> String { pub fn path(&self) -> String {
match self { match self {
Pid::SelfPid => "/proc/self".to_string(),
Pid::Pid(pid_no) => format!("/proc/{}", pid_no), 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)] #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
@ -78,15 +91,13 @@ pub fn is_compat_process(pid: Pid) -> Option<bool> {
Err(_) => None, Err(_) => None,
} }
} }
Pid::SelfPid => unreachable!(),
} }
} }
// TODO: Rename to capture all relevant process information #[derive(Debug, Clone, Eq, PartialEq, Hash)]
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
pub struct RequestingProcess { pub struct RequestingProcess {
pub nspath: String, pub pid_requestor: Pid,
pub nsroot: String, pub pid_requestor_root: Pid,
pub namespaces: Namespaces, pub namespaces: Namespaces,
pub is_compat: bool, pub is_compat: bool,
} }
@ -132,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 { pub fn get_namespace(pid: Pid) -> Namespaces {
let pid: String = match pid { let Pid::Pid(pid) = pid;
Pid::Pid(pid) => pid.to_string(), get_namespace_of_pid_or_self(PidOrSelf::Pid(pid))
Pid::SelfPid => "self".to_string(), }
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); let nspath = format!("/proc/{}/ns", pid);
@ -179,7 +199,6 @@ pub fn get_namespace(pid: Pid) -> Namespaces {
fn get_ppid(pid: Pid) -> Option<Pid> { fn get_ppid(pid: Pid) -> Option<Pid> {
let content = match pid { let content = match pid {
Pid::SelfPid => fs::read_to_string(format!("/proc/self/status")).ok()?,
Pid::Pid(pid) => fs::read_to_string(format!("/proc/{}/status", pid)).ok()?, Pid::Pid(pid) => fs::read_to_string(format!("/proc/{}/status", pid)).ok()?,
}; };
let ppid = content let ppid = content
@ -237,64 +256,111 @@ pub fn get_requesting_process(pid: Pid) -> RequestingProcess {
pid.path() pid.path()
); );
let nspath = format!("{}/ns", pid.path());
let nsroot = format!("{}/ns", ppid.path());
RequestingProcess { RequestingProcess {
nspath: nspath, pid_requestor: pid,
nsroot: nsroot, pid_requestor_root: ppid,
namespaces: nsinodes, namespaces: nsinodes,
is_compat: is_compat, is_compat: is_compat,
} }
} }
Pid::SelfPid => {
unreachable!();
}
} }
} }
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-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);
}
/// Runs a function inside the given network and mount namespaces. /// Runs a function inside the given network and mount namespaces.
/// Returns the child PID so the caller can `waitpid` on it. /// Returns the child PID so the caller can `waitpid` on it.
pub fn start_action(action: Action, ns: &RequestingProcess) -> anyhow::Result<u32> { pub fn start_action(
action: Action,
ns: &RequestingProcess,
enter_user_ns: bool,
) -> anyhow::Result<u32> {
let action_json = serde_json::to_string(&action).unwrap(); 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 { let child = unsafe {
Command::new("/proc/self/exe") let mut cmd = Command::new("/proc/self/exe");
.args([ cmd.args([
"--action", "--action",
action_json.as_str(), action_json.as_str(),
"--target-namespace", "--target-pid",
ns.nsroot.as_str(), ns.pid_requestor_root.to_string_rep().as_str(),
]) "--device-owner",
.pre_exec(|| { device_owner.as_str(),
// Last resort, if the parent just is killed. ]);
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL); if enter_user_ns {
Ok(()) cmd.arg("--enter-user-namespace");
}) }
.spawn() cmd.pre_exec(|| {
.expect("failed to start vuinputd") // 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()) Result::Ok(child.id())
} }
pub fn run_in_net_and_mnt_namespace(target_namespace: &str) -> anyhow::Result<()> { pub fn run_in_net_and_mnt_namespace(
target_pid: &str,
device_owner: &DeviceOwner,
enter_user_ns: bool,
) -> anyhow::Result<()> {
debug!( debug!(
"Entering namespaces of process {}. We assume this is the root process of the container.", "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() { if !fs::exists(path).unwrap() {
return Err(anyhow!("the root process of the container whose namespaces we want to enter does not exist anymore")); 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 user = File::open(nspath.to_string() + "/user")?;
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 { unsafe {
// enter namespaces // 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(net.as_raw_fd(), libc::CLONE_NEWNET);
libc::setns(mnt.as_raw_fd(), libc::CLONE_NEWNS); 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(()) anyhow::Ok(())
} }
@ -323,9 +389,6 @@ pub async fn await_process(pid: Pid) -> io::Result<i32> {
Ok(si.si_status()) Ok(si.si_status())
} }
} }
Pid::SelfPid => {
unreachable!();
}
} }
} }

View file

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

64
vuinputd/src/vt_tools.rs Normal file
View file

@ -0,0 +1,64 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use log::{error, info, warn};
use std::fs::OpenOptions;
use std::io;
use std::os::unix::io::AsRawFd;
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(())
}

View file

@ -4,6 +4,11 @@ After=systemd-udevd.service
Requires=systemd-udevd.service Requires=systemd-udevd.service
[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 # major 120 is reserved for local/experimental use. I picked minor 414795 with the use
# of a random number generator to omit conflicts. # of a random number generator to omit conflicts.
ExecStart=/usr/local/bin/vuinputd --major 120 --minor 414795 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 # we need the permission to create all sorts of devices
DeviceAllow=char-* rwm DeviceAllow=char-* rwm
# Enable debug logs during preproduction phase
Environment=RUST_LOG=debug
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -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