Compare commits

...

110 commits
0.2.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
Johannes Leupolz
84dcfbe6e9 Release v0.3.0 2025-12-19 21:08:10 +00:00
Johannes Leupolz
78feb4f08f Add udev files to debian package 2025-12-19 21:01:34 +00:00
Johannes Leupolz
67db5f1885 First microbenchmark conducted 2025-12-19 20:49:57 +00:00
Johannes Leupolz
e3094f898b Fix potential race 2025-12-18 21:36:13 +00:00
Johannes Leupolz
22ac4c23b7 Update dependencies in github action 2025-12-18 21:28:35 +00:00
Johannes Leupolz
552ab12f30 Fix tests. Mknod in bubblewrap is not possible if it was not bind-mounted without a nodev flag. 2025-12-18 21:25:53 +00:00
Johannes Leupolz
25623bf636 Split inject_in_container into emit_udev_event and mknod_device 2025-12-18 19:46:55 +00:00
Johannes Leupolz
32119fbf92 Refactored Namespace Switching After Exec. Now we have a parameter --target-namespace which contains the namespace to enter 2025-12-18 19:30:14 +00:00
Johannes Leupolz
21349c40b9 Improved action handlers; now also use anyhow 2025-12-17 22:40:08 +00:00
Johannes Leupolz
045807e848 cargo fmt 2025-12-17 21:51:24 +00:00
Johannes Leupolz
01417e6934 When integration-test get SIGKILLed, also SIGKILL the vuinputd-process 2025-12-17 21:30:33 +00:00
Johannes Leupolz
d86a90e2b1 Some refactorings to prepare the switch from calling a lambda function from the forked process to reinvoking the whole process with the job description as json 2025-12-17 21:17:05 +00:00
Johannes Leupolz
364b86a190 Fix integration test to ensure SYN is sent before we read. 2025-12-17 11:43:58 +00:00
Johannes Leupolz
e4501ce795 Extend integration tests with some nice features as event logging. Not everything working, yet. 2025-12-17 00:02:50 +00:00
Johannes Leupolz
97115609ed Name refactorings 2025-12-16 14:45:12 +00:00
Johannes Leupolz
0183da2477 Now a separate instance of vuinputd with its own device node runs as part of the integration tests. 2025-12-15 22:43:19 +00:00
Johannes Leupolz
16afd2a482 Add command line options for vuinputd (devname, major, and minor) to control the name and properties of the virtual uinput character device 2025-12-15 22:02:36 +00:00
Johannes Leupolz
0e6abf1d85 Fix integration test for vuinput. 2025-12-14 21:44:10 +00:00
Johannes Leupolz
e4335df8e6 Add integration tests that just try to create a device using uinput using bubblewrap. No further checks. 2025-12-14 21:22:09 +00:00
Johannes Leupolz
8784475eda bubblewrap wrapper supports command arguments 2025-12-13 23:10:11 +00:00
Johannes Leupolz
13a9921a5c Fixes in examples 2025-12-13 23:09:31 +00:00
Johannes Leupolz
1683829780 Fix bwrap-ipc test. 2025-12-13 22:36:01 +00:00
Johannes Leupolz
ad6a4cc696 Started to add wrapper for bubblewrap that I want to use to make my integration tests without a heavy container engine as docker. IPC with the client application is not working, yet. 2025-12-13 20:36:06 +00:00
Johannes Leupolz
61cfb84cff Add .gitignore 2025-12-12 11:11:05 +00:00
Johannes Leupolz
cb73fde9af Setup code for integration tests 2025-12-12 11:10:30 +00:00
Johannes Leupolz
f0bff128a6 cargo fmt 2025-12-10 22:46:52 +00:00
Johannes Leupolz
b3d0a0225d Small refactorings and note in SECURITY.md 2025-12-10 22:46:17 +00:00
Johannes Leupolz
2fe9a70006 Remove unused imports 2025-12-09 09:20:51 +00:00
Johannes Leupolz
c792529395 Structural refactoring. Now everything related to cuse is in its own
module. After the refacoring, a lot of ugly imports need to be cleaned
up.
2025-12-08 23:46:57 +00:00
Johannes Leupolz
320a77ef63 Files renamed for more clarity 2025-12-08 23:03:34 +00:00
Johannes Leupolz
ee38a7147c Some name refactorings 2025-12-08 22:48:22 +00:00
Johannes Leupolz
2c3531152b Revert last change and add comment. Althought it looks cleaner, it is
wrong and actually blocks. Added comment to highlight that fact.
2025-12-07 23:18:26 +00:00
Johannes Leupolz
3f6186ef86 Change in release handler to make it semantically cleaner 2025-12-07 22:56:57 +00:00
Johannes Leupolz
7b8ad17b79 Add some files with empty sections I want to talk about. 2025-12-07 22:12:35 +00:00
Johannes Leupolz
4c01ecb301 Add some sections in the DESIGN.md for topics I want to talk about. 2025-12-07 22:11:47 +00:00
103 changed files with 10571 additions and 1352 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:
branches: [ "main" ]
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
@ -17,7 +20,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Download dependencies
run: sudo apt install -y build-essential libc6-dev libfuse3-dev pkg-config fuse3 libudev-dev
run: sudo apt update && sudo apt install -y build-essential libc6-dev libfuse3-dev pkg-config fuse3 libudev-dev
- name: Build
run: cargo build --verbose
- name: Run tests

57
.gitignore vendored Normal file
View file

@ -0,0 +1,57 @@
# Ignore build artifacts and editor files for this Rust workspace
# Rust / Cargo
/target/
**/target/
**/target/*
**/debug/
**/release/
**/incremental/
# Generated package crates / archives
/package/
/package/*
# tarpaulin coverage output
/tarpaulin/
# VS Code and JetBrains
.vscode/
.idea/
# Editor swap / backup files
*~
*.swp
*.swo
# macOS
.DS_Store
# Windows
Thumbs.db
# Temporary files
/tmp/
# Build artifacts
*.o
*.rlib
*.so
*.dylib
*.dll
*.exe
# Coverage/profiler data
*.profraw
*.profdata
# Environment files (optional)
.env
.env.local
# Misc
.cache/
.cargo/
# Debian build files
ex/*

1244
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ members = [
"cuse-lowlevel",
"vuinputd",
"vuinput-examples",
"vuinputd-tests",
]
# Optional: force same version for all crates

View file

@ -1,3 +1,5 @@
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/joleuger/vuinputd)
# vuinputd
**Run Sunshine and other uinput-based apps inside containers — with full input isolation and zero kernel patches.**
@ -58,6 +60,8 @@ sequenceDiagram
Kernel->>Game: 10. send input data via eventX device
```
> **Performance note:**
> While `vuinputd` adds an extra userspace round trip via CUSE, the measured overhead is in the range of **tens of microseconds per event** in a simple integration test. This is several orders of magnitude smaller than typical sources of input latency such as frame rendering, compositor delays, scheduling jitter, or network latency. In practice, the additional cost is negligible for interactive and latency-sensitive applications, including gaming. More detailed benchmarks can be found in `TESTS.md`.
---
@ -73,8 +77,10 @@ sequenceDiagram
## Documentation
See [docs/BUILD.md](https://github.com/joleuger/vuinputd/blob/main/docs/BUILD.md) for a short build and installation guide.
See [docs/DESIGN.md](https://github.com/joleuger/vuinputd/blob/main/docs/DESIGN.md) for a detailed overview of the architecture, design trade-offs, and security considerations.
See [docs/USAGE.md](https://github.com/joleuger/vuinputd/blob/main/docs/USAGE.md) for a short usage guide.
See [docs/DESIGN.md](https://github.com/joleuger/vuinputd/blob/main/docs/DESIGN.md) for a detailed overview of the architecture, design trade-offs, and security considerations.
See [docs/USAGE.md](https://github.com/joleuger/vuinputd/blob/main/docs/USAGE.md) for a short usage guide.
See [docs/USAGE-NIXOS.md](https://github.com/joleuger/vuinputd/blob/main/docs/USAGE-NIXOS.md) for a short usage guide for NixOS.
See [docs/DEBUG.md](https://github.com/joleuger/vuinputd/blob/main/docs/DEBUG.md) for a guide how to debug problems with containers.
---
@ -87,6 +93,8 @@ It reliably demonstrates the core concept — exposing `/dev/uinput` devices ins
### ✅ Goals for Production Readiness
* [ ] **Vibration/Force Feedback support:**
* [ ] **Steam input support:**
Steam input is not supported, yet. For some strange reasons, steam creates 16 virtual devices. Maybe a race.
@ -115,11 +123,6 @@ It reliably demonstrates the core concept — exposing `/dev/uinput` devices ins
Provide a deb/rpm package for simple deployment.
* [ ] **Check for compatibility with steam runtime:**
https://gitlab.steamos.cloud/steamrt/steam-runtime-tools/-/blob/main/steam-runtime-tools/input-device.c
https://gitlab.steamos.cloud/steamrt/steam-runtime-tools/-/blob/main/docs/container-runtime.md
https://gitlab.steamos.cloud/steamrt/steam-runtime-tools/-/blob/main/docs/ld-library-path-runtime.md
https://github.com/ValveSoftware/steam-for-linux/issues/10175?utm_source=chatgpt.com
https://github.com/ValveSoftware/steam-for-linux/issues/8042
* [ ] **Forward known controller pids automatically:**
The main reason that vuinputd overrides pids is to ensure that those are not used by the host by accident, especially for keyboards that otherwise might get a seat assigned. This is irrelevant for gamepads. So the pids of known gamepads can just be forwarded. This is relevant for the 360 input devices that are created by steam.

View file

@ -4,7 +4,7 @@
// Author: Johannes Leupolz <dev@leupolz.eu>
//
// This library is heavily baased on https://github.com/richard-w/libfuse-sys
// but adopted to only provide the low-level modules of fuse and cuse.
// but adopted to only provide the low-level modules of fuse and cuse.
extern crate bindgen;
extern crate pkg_config;
@ -15,7 +15,6 @@ use std::path::PathBuf;
const FUSE_USE_VERSION: u32 = 314; //fuse version of ubuntu 24.04
fn fuse_binding_filter(builder: bindgen::Builder) -> bindgen::Builder {
let builder = builder
// Whitelist "fuse_*" symbols and blocklist everything else
@ -24,7 +23,8 @@ fn fuse_binding_filter(builder: bindgen::Builder) -> bindgen::Builder {
.allowlist_function("(?i)^fuse.*")
.allowlist_var("(?i)^fuse.*")
.blocklist_type("fuse_log_func_t")
.blocklist_function("fuse_set_log_func");
.blocklist_function("fuse_set_log_func")
.allowlist_type("^libfuse_version$");
builder
}
@ -103,18 +103,13 @@ fn main() {
let mut pkgcfg = pkg_config::Config::new();
// Find libfuse
let fuse3_lib = pkgcfg.cargo_metadata(true).probe("fuse3").expect("Failed to find pkg-config module fuse3");
let fuse3_lib = pkgcfg
.cargo_metadata(true)
.probe("fuse3")
.expect("Failed to find pkg-config module fuse3");
// Generate lowlevel bindings
generate_fuse_bindings(
"fuse_lowlevel.h",
&fuse3_lib,
fuse_binding_filter,
);
generate_fuse_bindings("fuse_lowlevel.h", &fuse3_lib, fuse_binding_filter);
// Generate lowlevel cuse bindings
generate_fuse_bindings(
"cuse_lowlevel.h",
&fuse3_lib,
cuse_binding_filter,
);
}
generate_fuse_bindings("cuse_lowlevel.h", &fuse3_lib, cuse_binding_filter);
}

View file

@ -4,7 +4,7 @@
// Author: Johannes Leupolz <dev@leupolz.eu>
//
// This library is heavily baased on https://github.com/richard-w/libfuse-sys
// but adopted to only provide the low-level modules of fuse and cuse.
// but adopted to only provide the low-level modules of fuse and cuse.
#![allow(non_snake_case)]
#![allow(non_camel_case_types)]
@ -15,7 +15,6 @@
use libc::*;
pub mod fuse_lowlevel {
use super::*;
include!(concat!(env!("OUT_DIR"), "/fuse_lowlevel.rs"));
@ -24,8 +23,8 @@ pub mod fuse_lowlevel {
pub mod cuse_lowlevel {
use super::*;
include!(concat!(env!("OUT_DIR"), "/cuse_lowlevel.rs"));
use fuse_lowlevel::{
fuse_args, fuse_conn_info, fuse_file_info, fuse_pollhandle, fuse_req_t, fuse_session,
};
}
}

55
debian/changelog vendored
View file

@ -1,3 +1,54 @@
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
* Refactor container and namespace handling:
- Refactoring from forked lambda execution to JSON-based job re-exec.
- Refactor namespace switching after exec; introduce --target-namespace option.
- Split inject_in_container into emit_udev_event and mknod_device.
* Add udev rules to the Debian package.
* Significant improvements to integration testing:
- Add integration tests for device creation via uinput using bubblewrap.
- Add event logging support to integration tests.
- Run a separate vuinputd instance with its own device node as part of tests.
* Improve internal structure and error handling:
- Switch to anyhow for error handling.
- General name refactorings and code cleanup.
* Add command-line options to control virtual uinput device properties
(devname, major, minor).
* Update GitHub Actions dependencies and conduct first microbenchmark.
* Minor fixes in examples and formatting cleanup.
-- Johannes Leupolz <dev@leupolz.eu> Thu, 19 Dec 2025 21:30:00 +0000
vuinputd (0.2.0-1) unstable; urgency=medium
* Register official USB vendor/product ID (VID 0x1209, PID 0x5020) under
@ -24,11 +75,11 @@ vuinputd (0.2.0-1) unstable; urgency=medium
and service file.
* Updated documentation for release readiness.
-- Johannes Leupolz <vinput@leupolz.eu> Thu, 06 Dec 2025 21:00:00 +0000
-- Johannes Leupolz <dev@leupolz.eu> Thu, 06 Dec 2025 21:00:00 +0000
vuinputd (0.1.0-1) unstable; urgency=medium
* Initial release.
-- Johannes Leupolz <vinput@leupolz.eu> Wed, 27 Aug 2025 12:00:00 +0000
-- Johannes Leupolz <dev@leupolz.eu> Wed, 27 Aug 2025 12:00:00 +0000

14
debian/control vendored
View file

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

17
debian/rules vendored
View file

@ -8,4 +8,19 @@ override_dh_auto_build:
cargo build --release
override_dh_auto_install:
install -D -m 0755 target/release/vuinputd debian/tmp/usr/bin/vuinputd
# install binary
install -D -m 0755 target/release/vuinputd \
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 +1,4 @@
usr/bin/vuinputd
usr/bin/vuinputd
usr/lib/udev/hwdb.d/90-vuinputd.hwdb
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.
**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
@ -408,6 +416,290 @@ During the creation of the device, the type, vendor id, and product id will be
BUS_VIRTUAL 0x6 is not used, because I couldn't find a place where I could register a vendor and product id. The now used combination is unique, as the product id is registered under [pid.codes](https://pid.codes/1209/5020/). So, there is no problem to use it in a system-wide hwdb-file for udev.
---
## **3.10 Namespace Switching After Exec**
### **Decision**
`vuinputd` and its helper actions are executed in the **host mount namespace**, and only **after process startup** do they switch into the target containers namespaces using `setns()` (e.g. `CLONE_NEWNS`, `CLONE_NEWNET`).
Dynamic libraries (e.g. `libc`, `libfuse3`, `libudev`) are therefore resolved and mapped **before** entering the containers mount namespace.
After the namespace switch, the process guarantees that it only performs filesystem operations intended for the container environment.
### **Rationale**
This design intentionally separates **code loading** from **runtime filesystem semantics**:
* ELF loading and dynamic linking are one-time operations performed at `execve()`
* already-mapped libraries are unaffected by later namespace changes
* mount namespaces only affect *future* path resolution, not existing mappings
By switching namespaces after startup, `vuinputd` avoids assumptions about:
* the presence of shared libraries inside the container
* libc / dynamic loader compatibility across distributions
* static linking availability for complex dependencies like `libfuse`
At the same time, runtime behavior (device access, `/dev`, `/sys`, `/proc`) correctly reflects the containers view once `setns()` has completed.
### **Why post-exec `setns()` is correct**
* Widely used by container runtimes and helpers (e.g. `runc`, `crun`, `systemd-nspawn`, `nsenter`)
* Ensures maximum compatibility with heterogeneous container filesystems
* Avoids brittle static builds and duplicated dependency trees
* Preserves security boundaries: namespace changes are explicit and minimal
### **Constraints and Guarantees**
To keep this model correct, `vuinputd` enforces:
* namespace switching occurs before spawning threads
* no unintended filesystem access occurs before `setns()`
* all container-visible paths are accessed only after entering the target namespace
* required kernel interfaces (`/dev/fuse`, `/sys`, `/proc`) are provided by the container
Under these constraints, post-exec namespace switching provides a robust and predictable execution model.
### **Alternatives Considered**
* **Fully static binaries**
Rejected due to complexity, limited library support, and reduced portability.
* **Executing entirely inside the container filesystem**
Rejected due to dependency availability, loader ABI mismatch, and tighter coupling between host and container environments.
* **Executing the logic directly without an exec**
This was the approach used in vuinputd releases 0.1 and 0.2:
the daemon would `fork()` and immediately execute the action logic in the child
without performing an `execve()`.
While this avoids process re-initialization overhead, it is fundamentally unsafe
in a multi-threaded program.
In particular:
* `fork()` only duplicates the calling thread
* other threads may hold internal libc locks at the time of the fork
* common subsystems (notably `malloc`) are not async-signal-safe after `fork()`
* any allocation or lock acquisition in the child can deadlock permanently
This is not a theoretical concern: if another thread holds the `malloc` arena lock
at the time of `fork()`, the child process may block forever on its first allocation,
including implicit allocations inside libc or Rust runtime code.
See also [https://github.com/rust-lang/rust/blob/c1e865c/src/libstd/sys/unix/process.rs#L202
and https://systemd.io/ARCHITECTURE/ .
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.
---
@ -435,6 +727,7 @@ While this design is necessary for mediation, it introduces potential attack sur
* [ ] Use **seccomp** or `systemd` sandboxing (`ProtectSystem`, `ProtectKernelTunables`, `RestrictNamespaces`, etc.).
* [ ] Eventually migrate to **Rust-native FUSE/Netlink** bindings to remove unsafe dependencies.
---
## 5. Background: How are input devices created by the kernel using uinput
@ -564,9 +857,16 @@ Library used by wolf and sunshine
#### 5.3.2 Steam
https://github.com/games-on-whales/inputtino
https://gitlab.steamos.cloud/steamrt/steam-runtime-tools/-/blob/main/steam-runtime-tools/input-device.c
https://gitlab.steamos.cloud/steamrt/steam-runtime-tools/-/blob/main/docs/container-runtime.md
https://gitlab.steamos.cloud/steamrt/steam-runtime-tools/-/blob/main/docs/ld-library-path-runtime.md
https://github.com/ValveSoftware/steam-for-linux/issues/10175
https://github.com/ValveSoftware/steam-for-linux/issues/8042
Library used by wolf and sunshine
#### 5.3.3 Selkies Project
Absolutely untested.
https://github.com/selkies-project/selkies/pull/173
### 5.4 Applications that use the created devices
@ -574,15 +874,41 @@ Library used by wolf and sunshine
https://github.com/libsdl-org/SDL/blob/main/src/joystick/linux/SDL_sysjoystick.c
https://github.com/libsdl-org/SDL/blob/main/src/joystick/SDL_joystick.c
#### 5.4.2 libudev and netlink
#### 5.4.3 libinput
https://github.com/systemd/systemd/tree/main/src/libudev
https://insujang.github.io/2018-11-27/udev-device-manager-for-the-linux-kernel-in-userspace/
https://games-on-whales.github.io/wolf/stable/dev/fake-udev.html
https://github.com/JohnCMcDonough/virtual-gamepad
#### 5.4.3 libinput, libevdev
https://gitlab.freedesktop.org/libinput/libinput/-/tree/main/src?ref_type=heads
https://gitlab.freedesktop.org/libevdev/libevdev/-/blob/master/libevdev/libevdev-uinput.c?ref_type=heads
#### 5.4.4. Proton
## 6. Alternative Approaches
https://github.com/GloriousEggroll/proton-ge-custom/blob/master/docs/CONTROLLERS.md
### 6.1 trace accesses of /dev/uinput with eBPF
wine control
https://github.com/flatpak/xdg-desktop-portal/issues/536
## 6. HIDAPI
https://github.com/libusb/hidapi
https://abeltra.me/blog/inputtino-uhid-1/
## 7. Alternative Approaches
### 7.1 trace accesses of /dev/uinput with eBPF
**Idea (short):** attach an eBPF program to the syscall tracepoint for `ioctl` (`tracepoint/syscalls/sys_enter_ioctl`), filter by container cgroup, and send small events (pid, tgid, fd, cmd, timestamp, short payload sample) to userspace using the BPF ring buffer. A privileged host agent consumes the ringbuf events, duplicates the target FD via `pidfd_getfd()` and proceeds with UI_GET_SYSNAME / sysfs resolution to retrieve the sys-path and the dev-path. Having the dev-path and the pid of the container, the solution could proceed as in the current solution.
@ -606,11 +932,20 @@ Inside the trace program you will typically use:
The **`pidfd_getfd()`** syscall (introduced in Linux 5.6, see `man pidfd_getfd(2)`) allows one process to **duplicate a file descriptor from another process** into its own FD table. It takes a *pidfd* (obtained via `pidfd_open()` or from `CLONE_PIDFD`), the target FD number in the remote process, and optional flags. The resulting descriptor refers to the **same open file description**—sharing offset, status flags, and driver state—exactly as if the target process had called `dup()`. Permission checks apply: the caller must either share credentials (same UID) or hold `CAP_SYS_PTRACE` or an equivalent capability over the target. This makes `pidfd_getfd()` the canonical and race-free way to inspect or reuse another processs device handles (for example, to run `UI_GET_SYSNAME` on a client apps' fd on `/dev/uinput` ) without invasive ptrace tricks.
### 6.2 LD_PRELOAD
### 7.2 LD_PRELOAD
See src/fake-uinput/README.md on wolf
https://github.com/games-on-whales/wolf/issues/81
https://github.com/games-on-whales/wolf/pull/88
https://github.com/zerofltexx/wolf/commit/5b3282ceef6373c5afd2a860365c886fa942f59c#diff-2446d8f27f6ac4efff38510458548cea92179eddf38c187f5ad90d6bdd4b3d69
https://github.com/zerofltexx/wolf/commit/5b3282ceef6373c5afd2a860365c886fa942f59c#diff-2446d8f27f6ac4efff38510458548cea92179eddf38c187f5ad90d6bdd4b3d69
### 7.3 Custom kernel modul
https://github.com/dkms-project/dkms
https://github.com/torvalds/linux/blob/master/drivers/input/misc/uinput.c
https://lore.kernel.org/linux-bluetooth/20191201145357.ybq5gfty4ulnfasq@pali/t/#u

22
docs/SECURITY.md Normal file
View file

@ -0,0 +1,22 @@
# Security
## Small code base
## Fuzzing
## Audit
## Unsafe code
Known problem: A lot of unsafe code. Open are ideas to mitigate this issue.
## seccomp, AppArmor, SELinux, cgroups mounts, /sys read-write
This is a big TODO. Which permissions can be reduced. Now we assume we are quite privileagued:
- We have all Linux kernel capabilities,
- The default seccomp profile is disabled,
- The default AppArmor profile is disabled,
- The default SELinux process label is disabled,
- all host devices are accessible,
- /sys is read-write,
- cgroups mount is read-write.

View file

@ -1,5 +1,64 @@
# 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:
`apt-get install bubblewrap`.
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
Using CUSE introduces an additional round trip between kernel and userspace, which inevitably adds overhead compared to direct uinput access. To estimate the order of magnitude of this overhead, the `vuinputd-tests` include a simple integration test that emits two input events: once using direct uinput access and once via `vuinputd` v0.3.
The test measures the elapsed time between emitting an event and receiving it again, using `CLOCK_MONOTONIC`. The measured latencies were:
* **First call:** 16 µs (direct uinput) vs. 90 µs (vuinputd)
* **Second call:** 7 µs (direct uinput) vs. 68 µs (vuinputd)
As expected, `vuinputd` introduces a clearly measurable performance penalty due to the additional userspace round trip. However, even in this best-case microbenchmark, the absolute overhead remains well below 0.1 ms per event.
In practical terms, this level of overhead is negligible for real-world usage. For latency-sensitive applications such as gaming, tens of microseconds are several orders of magnitude smaller than typical sources of latency such as frame rendering time, compositor delays, scheduling jitter, or network latency. Even a single rendered frame at 60 Hz already accounts for roughly 16.6 ms, making the additional cost introduced by `vuinputd` effectively unobservable to the user.
It is important to note that this benchmark is intentionally minimal and primarily intended to provide a rough sense of scale. It does not model realistic workloads, higher event rates, or concurrent inputs. More comprehensive benchmarks are required to assess behavior under load and contention. Nevertheless, these results demonstrate that the architectural approach taken by `vuinputd` is sound and does not introduce prohibitive latency by design.
As long as more realistic benchmarks confirm similar behavior under load, `vuinputd` can be considered suitable even for interactive and latency-sensitive use cases.
Detailed results:
`integration_tests.rs#test_keyboard_in_container_with_uinput`:
```
{"events":[{"tv_sec":3133476,"tv_nsec":947503794,"duration_usec":16,"type_":1,"code":57,"value":1,"send_and_receive_match":true},{"tv_sec":3133476,"tv_nsec":947520555,"duration_usec":7,"type_":1,"code":57,"value":0,"send_and_receive_match":true}]}
```
`integration_tests.rs#test_keyboard_in_container_with_vuinput`:
```
Event log: {"events":[{"tv_sec":3133303,"tv_nsec":796108454,"duration_usec":90,"type_":1,"code":57,"value":1,"send_and_receive_match":true},{"tv_sec":3133303,"tv_nsec":796198973,"duration_usec":68,"type_":1,"code":57,"value":0,"send_and_receive_match":true}]}
```
## Manual end-to-end tests
| vuinputd | host | input type | app that creates device | app that reads device | working | Notes |

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/...

19
docs/TRUST.md Normal file
View file

@ -0,0 +1,19 @@
# Trust
## Supply-chain attacks
Only crates available in the debian eco system are used.
## Reproducable builds
## Code coverage
## Attestation
SLSA Attestation
https://github.com/actions/attest-build-provenance
## Signed artifacts
Sigstore / cosign

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`
3. Verify that device creation and input forwarding work correctly
### Runtime Artifact Placement
`vuinputd` supports different **placement modes** that control where runtime artifacts
(device nodes *and* associated udev data) are created.
This is configured via the `--placement` command-line option and affects:
* the virtual input device nodes
* the corresponding `/run/udev` runtime data used by libudev-based applications
### Device Policies
`vuinputd` can enforce **device policies** that control which input capabilities
and events are exposed to applications.
Policies are applied at device creation time and operate independently of
container runtime or placement mode.
---
## 2. Prerequisites
@ -90,7 +109,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
@ -153,7 +306,7 @@ Then restart the container.
---
## 5. Inside the Container
## 6. Inside the Container
Once inside the container shell:
@ -165,13 +318,17 @@ apt-get update
apt-get install libinput-tools udev evtest tmux
# Prepare udev stubs
# Note:
# The following steps are only required when using `--placement in-container`.
# When using `--placement on-host`, the udev runtime data is created on the host
# and must be bind-mounted into the container instead.
mkdir -p /run/udev/data/
touch /run/udev/control
```
---
## 6. Verifying Operation
## 7. Verifying Operation
To test everything, use multiple `tmux` windows for parallel monitoring.
@ -225,7 +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 |
| --------------------------- | ------------------------------------ | ------------------------------------------------- |
@ -235,9 +496,18 @@ Sample output from `journalctl` showing vuinputd output:
| Device appears on host seat | udev rules not isolating | Add udev rules from vuinputd/udev-folder |
| Input delayed or missing | CUSE layer error | Check host logs via `journalctl -u vuinputd` |
```
Dez 14 21:33:17 wohnzimmer vuinputd[2172719]: Create /dev/input
Dez 14 21:33:17 wohnzimmer vuinputd[2172719]: [2025-12-14T21:33:17Z DEBUG vuinputd::jobs::inject_in_container_job] Error creating input device /dev/input/event12: Read-o>
Dez 14 21:33:17 wohnzimmer vuinputd[2172719]: thread '<unnamed>' panicked at vuinputd/src/jobs/inject_in_container_job.rs:161:41:
Dez 14 21:33:17 wohnzimmer vuinputd[2172719]: called `Result::unwrap()` on an `Err` value: Os { code: 30, kind: ReadOnlyFilesystem, message: "Read-only file system" }
```
Ensure /dev and /run are writable in the container. If in doubt, use tmpfs.
---
## 8. Notes and Advanced Topics
## 10. Notes and Advanced Topics
* You can safely run **multiple containers**.
* Devices are automatically cleaned up when the container stops.
@ -249,7 +519,7 @@ Sample output from `journalctl` showing vuinputd output:
---
## 9. References
## 11. References
* [mkosi manual](https://github.com/systemd/mkosi/blob/main/mkosi/resources/man/mkosi.1.md)
* [Docker device rules documentation](https://docs.docker.com/engine/reference/run/#device-cgroup-rule)

View file

@ -305,8 +305,14 @@ fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> {
}
fn main() -> io::Result<()> {
// open device - matches: open("/dev/uinput-test", O_WRONLY | O_NONBLOCK);
let path = CString::new("/dev/uinput-test").unwrap();
// open device - matches: open("/dev/uinput", O_WRONLY | O_NONBLOCK);
let args: Vec<String> = std::env::args().collect();
let device = match args.len() {
2 => args[1].clone(),
_ => "/dev/uinput".to_string(),
};
let path = CString::new(device).unwrap();
let fd = unsafe { open(path.as_ptr(), O_WRONLY | O_NONBLOCK) };
if fd < 0 {
eprintln!("error opening uinput");

View file

@ -23,8 +23,7 @@ const REL_Y: i32 = 1;
const SYN_REPORT: i32 = 0;
const BUS_USB: u16 = 0x03;
///
///
fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> {
// libc's input_event struct layout:
@ -59,14 +58,13 @@ fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> {
}
fn main() -> io::Result<()> {
// open device - matches: open("/dev/uinput-test", O_WRONLY | O_NONBLOCK);
// open device - matches: open("/dev/uinput", O_WRONLY | O_NONBLOCK);
let args: Vec<String> = std::env::args().collect();
let device=
match args.len() {
2 => args[1].clone(),
_ => "/dev/uinput".to_string()
};
let device = match args.len() {
2 => args[1].clone(),
_ => "/dev/uinput".to_string(),
};
let path = CString::new(device).unwrap();
let fd = unsafe { open(path.as_ptr(), O_WRONLY | O_NONBLOCK) };
@ -97,19 +95,16 @@ fn main() -> io::Result<()> {
std::process::exit(1);
});
ui_set_evbit(fd, EV_REL.try_into().unwrap()).unwrap_or_else(|e| {
eprintln!("ui_set_evbit(EV_REL) failed: {:?}", e);
std::process::exit(1);
});
ui_set_relbit(fd, REL_X.try_into().unwrap()).unwrap_or_else(|e| {
eprintln!("ui_set_relbit(REL_X) failed: {:?}", e);
std::process::exit(1);
});
ui_set_relbit(fd, REL_Y.try_into().unwrap()).unwrap_or_else(|e| {
eprintln!("ui_set_relbit(REL_Y) failed: {:?}", e);
std::process::exit(1);

View file

@ -23,8 +23,7 @@ const REL_Y: i32 = 1;
const SYN_REPORT: i32 = 0;
const BUS_USB: u16 = 0x03;
///
///
fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> {
// libc's input_event struct layout:
@ -59,14 +58,13 @@ fn emit(fd: c_int, ev_type: i32, code: i32, val: i32) -> io::Result<()> {
}
fn main() -> io::Result<()> {
// open device - matches: open("/dev/uinput-test", O_WRONLY | O_NONBLOCK);
// open device - matches: open("/dev/uinput", O_WRONLY | O_NONBLOCK);
let args: Vec<String> = std::env::args().collect();
let device=
match args.len() {
2 => args[1].clone(),
_ => "/dev/uinput".to_string()
};
let device = match args.len() {
2 => args[1].clone(),
_ => "/dev/uinput".to_string(),
};
let path = CString::new(device).unwrap();
let fd = unsafe { open(path.as_ptr(), O_WRONLY | O_NONBLOCK) };
@ -97,19 +95,16 @@ fn main() -> io::Result<()> {
std::process::exit(1);
});
ui_set_evbit(fd, EV_REL.try_into().unwrap()).unwrap_or_else(|e| {
eprintln!("ui_set_evbit(EV_REL) failed: {:?}", e);
std::process::exit(1);
});
ui_set_relbit(fd, REL_X.try_into().unwrap()).unwrap_or_else(|e| {
eprintln!("ui_set_relbit(REL_X) failed: {:?}", e);
std::process::exit(1);
});
ui_set_relbit(fd, REL_Y.try_into().unwrap()).unwrap_or_else(|e| {
eprintln!("ui_set_relbit(REL_Y) failed: {:?}", e);
std::process::exit(1);
@ -217,7 +212,6 @@ fn main() -> io::Result<()> {
sleep(Duration::from_millis(300));
}
// Give userspace time to read events
sleep(Duration::from_secs(5));

33
vuinputd-tests/Cargo.toml Normal file
View file

@ -0,0 +1,33 @@
[package]
name = "vuinputd-tests"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "test-ipc"
[[bin]]
name = "test-keyboard"
[[bin]]
name = "test-ok"
[[bin]]
name = "test-scenarios"
[dependencies]
uinput-ioctls = { path = "../uinput-ioctls" }
nix = { version = "0.30", features = ["ioctl","socket","signal"] } # ioctl & libc bindings
libc = "0.2" # raw system calls
libudev = "0.3" # enumerate-udev
vuinputd = { path = "../vuinputd" }
clap = { version = "4", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[features]
requires-privileges = []
requires-rootless = []
requires-uinput = []
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

@ -0,0 +1,27 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use core::panic;
use std::time::Duration;
use vuinputd_tests::ipc::SandboxChildIpc;
fn main() {
println!("starting test-ipc");
let ipc = unsafe { SandboxChildIpc::from_fd() };
let incoming = ipc
.recv(Some(Duration::from_secs(5)))
.expect("error receiving input from ipc as child within 5 seconds");
let incoming_str =
str::from_utf8(&incoming).expect("message received from ipc is not encoded as utf8");
if incoming_str == "continue" {
println!("child received continue");
ipc.send(b"ok").unwrap();
} else {
ipc.send(b"nok").unwrap();
println!("child received {}", incoming_str);
panic!("expected ipc message to be 'continue'");
}
}

View file

@ -0,0 +1,512 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use clap::Parser;
use libc::{c_int, close, open, write, O_NONBLOCK, O_WRONLY};
use libc::{input_event, timespec, uinput_setup, CLOCK_MONOTONIC};
use std::ffi::{CStr, CString};
use std::fs::{self, File, OpenOptions};
use std::io::{self, ErrorKind};
use std::mem::{self, size_of, zeroed};
use std::os::fd::AsRawFd;
use std::os::raw::{c_char, c_void};
use std::ptr;
pub use uinput_ioctls::*;
use vuinputd_tests::test_log::{LoggedInputEvent, TestLog};
// Constants (same numeric values as in linux headers)
const EV_SYN: u16 = 0x00;
const EV_KEY: u16 = 0x01;
const SYN_REPORT: u16 = 0;
const BUS_USB: u16 = 0x03;
/// Key codes. Those are used by udev to recognize a device as a keyboard.
const KEY_ESC: u16 = 1;
const KEY_1: u16 = 2;
const KEY_2: u16 = 3;
const KEY_3: u16 = 4;
const KEY_4: u16 = 5;
const KEY_5: u16 = 6;
const KEY_6: u16 = 7;
const KEY_7: u16 = 8;
const KEY_8: u16 = 9;
const KEY_9: u16 = 10;
const KEY_0: u16 = 11;
const KEY_MINUS: u16 = 12;
const KEY_EQUAL: u16 = 13;
const KEY_BACKSPACE: u16 = 14;
const KEY_TAB: u16 = 15;
const KEY_Q: u16 = 16;
const KEY_W: u16 = 17;
const KEY_E: u16 = 18;
const KEY_R: u16 = 19;
const KEY_T: u16 = 20;
const KEY_Y: u16 = 21;
const KEY_U: u16 = 22;
const KEY_I: u16 = 23;
const KEY_O: u16 = 24;
const KEY_P: u16 = 25;
const KEY_LEFTBRACE: u16 = 26;
const KEY_RIGHTBRACE: u16 = 27;
const KEY_ENTER: u16 = 28;
const KEY_LEFTCTRL: u16 = 29;
const KEY_A: u16 = 30;
const KEY_S: u16 = 31;
/// Space and other common keys
const KEY_D: u16 = 32;
const KEY_F: u16 = 33;
const KEY_G: u16 = 34;
const KEY_H: u16 = 35;
const KEY_J: u16 = 36;
const KEY_K: u16 = 37;
const KEY_L: u16 = 38;
const KEY_SEMICOLON: u16 = 39;
const KEY_APOSTROPHE: u16 = 40;
const KEY_GRAVE: u16 = 41;
const KEY_LEFTSHIFT: u16 = 42;
const KEY_BACKSLASH: u16 = 43;
const KEY_Z: u16 = 44;
const KEY_X: u16 = 45;
const KEY_C: u16 = 46;
const KEY_V: u16 = 47;
const KEY_B: u16 = 48;
const KEY_N: u16 = 49;
const KEY_M: u16 = 50;
const KEY_COMMA: u16 = 51;
const KEY_DOT: u16 = 52;
const KEY_SLASH: u16 = 53;
const KEY_RIGHTSHIFT: u16 = 54;
const KEY_KPASTERISK: u16 = 55;
const KEY_LEFTALT: u16 = 56;
const KEY_SPACE: u16 = 57;
const KEY_CAPSLOCK: u16 = 58;
/// Function keys
const KEY_F1: u16 = 59;
const KEY_F2: u16 = 60;
const KEY_F3: u16 = 61;
const KEY_F4: u16 = 62;
const KEY_F5: u16 = 63;
const KEY_F6: u16 = 64;
const KEY_F7: u16 = 65;
const KEY_F8: u16 = 66;
const KEY_F9: u16 = 67;
const KEY_F10: u16 = 68;
const KEY_NUMLOCK: u16 = 69;
const KEY_SCROLLLOCK: u16 = 70;
const KEY_KP7: u16 = 71;
const KEY_KP8: u16 = 72;
const KEY_KP9: u16 = 73;
const KEY_KPMINUS: u16 = 74;
const KEY_KP4: u16 = 75;
const KEY_KP5: u16 = 76;
const KEY_KP6: u16 = 77;
const KEY_KPPLUS: u16 = 78;
const KEY_KP1: u16 = 79;
const KEY_KP2: u16 = 80;
const KEY_KP3: u16 = 81;
const KEY_KP0: u16 = 82;
const KEY_KPDOT: u16 = 83;
/// Arrow keys and navigation
const KEY_ZENKAKUHANKAKU: u16 = 85;
const KEY_102ND: u16 = 86;
const KEY_F11: u16 = 87;
const KEY_F12: u16 = 88;
const KEY_RO: u16 = 89;
const KEY_KATAKANA: u16 = 90;
const KEY_HIRAGANA: u16 = 91;
const KEY_HENKAN: u16 = 92;
const KEY_KATAKANAHIRAGANA: u16 = 93;
const KEY_MUHENKAN: u16 = 94;
const KEY_KPJPCOMMA: u16 = 95;
const KEY_KPENTER: u16 = 96;
const KEY_RIGHTCTRL: u16 = 97;
const KEY_KPSLASH: u16 = 98;
const KEY_SYSRQ: u16 = 99;
const KEY_RIGHTALT: u16 = 100;
const KEY_LINEFEED: u16 = 101;
const KEY_HOME: u16 = 102;
const KEY_UP: u16 = 103;
const KEY_PAGEUP: u16 = 104;
const KEY_LEFT: u16 = 105;
const KEY_RIGHT: u16 = 106;
const KEY_END: u16 = 107;
const KEY_DOWN: u16 = 108;
const KEY_PAGEDOWN: u16 = 109;
const KEY_INSERT: u16 = 110;
const KEY_DELETE: u16 = 111;
const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/";
/// Configure a full 101-key standard keyboard
unsafe fn set_standard_keyboard_keys(fd: i32) -> Result<(), std::io::Error> {
// We need to set more bits so that systemd recognizes a keyboard as a keyboard.
// At least the first 32 bits are ESC, numbers, and Q to D, except KEY_RESERVED need to be considered.
// udev-builtin-input_id.c consideres the mask = 0xFFFFFFFE
// EV_KEY
ui_set_evbit(fd, EV_KEY.try_into().unwrap())?;
// All standard keys (1..101+)
let all_keys = [
// Modifier + main keys
KEY_ESC,
KEY_1,
KEY_2,
KEY_3,
KEY_4,
KEY_5,
KEY_6,
KEY_7,
KEY_8,
KEY_9,
KEY_0,
KEY_MINUS,
KEY_EQUAL,
KEY_BACKSPACE,
KEY_TAB,
KEY_Q,
KEY_W,
KEY_E,
KEY_R,
KEY_T,
KEY_Y,
KEY_U,
KEY_I,
KEY_O,
KEY_P,
KEY_LEFTBRACE,
KEY_RIGHTBRACE,
KEY_ENTER,
KEY_LEFTCTRL,
KEY_A,
KEY_S,
KEY_D,
KEY_F,
KEY_G,
KEY_H,
KEY_J,
KEY_K,
KEY_L,
KEY_SEMICOLON,
KEY_APOSTROPHE,
KEY_GRAVE,
KEY_LEFTSHIFT,
KEY_BACKSLASH,
KEY_Z,
KEY_X,
KEY_C,
KEY_V,
KEY_B,
KEY_N,
KEY_M,
KEY_COMMA,
KEY_DOT,
KEY_SLASH,
KEY_RIGHTSHIFT,
KEY_KPASTERISK,
KEY_LEFTALT,
KEY_SPACE,
KEY_CAPSLOCK,
// Function keys
KEY_F1,
KEY_F2,
KEY_F3,
KEY_F4,
KEY_F5,
KEY_F6,
KEY_F7,
KEY_F8,
KEY_F9,
KEY_F10,
KEY_F11,
KEY_F12,
KEY_NUMLOCK,
KEY_SCROLLLOCK,
// Keypad
KEY_KP7,
KEY_KP8,
KEY_KP9,
KEY_KPMINUS,
KEY_KP4,
KEY_KP5,
KEY_KP6,
KEY_KPPLUS,
KEY_KP1,
KEY_KP2,
KEY_KP3,
KEY_KP0,
KEY_KPDOT,
KEY_KPENTER,
KEY_KPSLASH,
KEY_KPJPCOMMA,
// Arrows / navigation
KEY_HOME,
KEY_UP,
KEY_PAGEUP,
KEY_LEFT,
KEY_RIGHT,
KEY_END,
KEY_DOWN,
KEY_PAGEDOWN,
KEY_INSERT,
KEY_DELETE,
KEY_RIGHTCTRL,
KEY_RIGHTALT,
// Optional Japanese / additional keys
KEY_ZENKAKUHANKAKU,
KEY_102ND,
KEY_RO,
KEY_KATAKANA,
KEY_HIRAGANA,
KEY_HENKAN,
KEY_KATAKANAHIRAGANA,
KEY_MUHENKAN,
KEY_LINEFEED,
KEY_SYSRQ,
];
for &key in all_keys.iter() {
ui_set_keybit(fd, key.try_into().unwrap())?;
}
Ok(())
}
#[derive(Debug, Parser)]
#[command(author, version, about)]
struct Args {
/// Use IPC
#[arg(long)]
ipc: bool,
/// Device path (with /dev/)
#[arg(long)]
dev_path: Option<String>,
}
fn emit(fd: c_int, ev_type: u16, code: u16, val: i32) -> io::Result<()> {
// libc's input_event struct layout:
// struct input_event {
// struct timeval time;
// __u16 type;
// __u16 code;
// __s32 value;
// };
//
// libc provides input_event as `libc::input_event` on Linux.
let mut ie: libc::input_event = unsafe { zeroed() };
// time fields are ignored by kernel for synthetic events - set zero
ie.time.tv_sec = 0;
ie.time.tv_usec = 0;
ie.type_ = ev_type; // note: in libc the field is `type_`
ie.code = code;
ie.value = val;
// write the struct to the uinput fd
let buf_ptr = &ie as *const libc::input_event as *const c_void;
let bytes = size_of::<libc::input_event>();
//println!("write to {} {} {} {} ",fd,ev_type,code,val);
let written = unsafe { write(fd, buf_ptr, bytes) };
//println!("written");
if written as usize != bytes {
return Err(io::Error::last_os_error());
}
Ok(())
}
// Note that before we can read, a SYN needs to be sent. Thus combine it.
fn emit_read_and_log(
emit_to: c_int,
read_from: &File,
ev_type: u16,
code: u16,
val: i32,
) -> io::Result<LoggedInputEvent> {
let (time_sent_sec, time_sent_nsec) = monotonic_time();
emit(emit_to, ev_type, code, val)?;
emit(emit_to, EV_SYN, SYN_REPORT, 0)?;
let input_event_recv = read_event(&read_from).unwrap();
let _syn_recv = read_event(&read_from).unwrap();
let (time_recv_sec, time_recv_nsec) = monotonic_time();
let duration_usec =
(time_recv_sec - time_sent_sec) * 1_000_000 + (time_recv_nsec - time_sent_nsec) / 1000;
let send_and_receive_match = input_event_recv.type_ == ev_type
&& input_event_recv.code == code
&& input_event_recv.value == val;
Ok(LoggedInputEvent {
tv_sec: time_sent_sec,
tv_nsec: time_sent_nsec,
duration_usec: duration_usec,
type_: ev_type,
code: code,
value: val,
send_and_receive_match: send_and_receive_match,
})
}
pub fn fetch_device_node(path: &str) -> io::Result<String> {
println!("Read dir {}", &path);
for entry in fs::read_dir(path)? {
let entry = entry?; // propagate per-entry errors
if let Some(name) = entry.file_name().to_str() {
if name.starts_with("event") {
return Ok(format!("/dev/input/{}", name));
}
}
}
// If no device is found, return an error
Err(io::Error::new(ErrorKind::NotFound, "no device found"))
}
pub fn read_event(event_dev: &File) -> io::Result<input_event> {
let mut ev: input_event = unsafe { mem::zeroed() };
let ret = unsafe {
libc::read(
event_dev.as_raw_fd(),
&mut ev as *mut _ as *mut c_void,
mem::size_of::<input_event>(),
)
};
if ret as usize != mem::size_of::<input_event>() {
return Err(io::Error::last_os_error());
}
Ok(ev)
}
fn monotonic_time() -> (i64, i64) {
let mut ts = timespec {
tv_sec: 0,
tv_nsec: 0,
};
unsafe {
libc::clock_gettime(CLOCK_MONOTONIC, &mut ts);
}
(ts.tv_sec, ts.tv_nsec)
}
fn main() -> io::Result<()> {
// open device - matches: open("/dev/uinput", O_WRONLY | O_NONBLOCK);
let args = Args::parse();
let device = match args.dev_path {
Some(dev_path) => dev_path,
_ => "/dev/uinput".to_string(),
};
let path = CString::new(device).unwrap();
let fd = unsafe { open(path.as_ptr(), O_WRONLY | O_NONBLOCK) };
if fd < 0 {
eprintln!("error opening uinput");
return Err(io::Error::last_os_error());
}
// In your snippet you supplied value.into() to the wrappers. The wrappers may accept different types.
// We follow your earlier usage pattern:
unsafe {
let mut version_of_uinput = 0;
let pversion_of_uinput = std::ptr::from_mut(&mut version_of_uinput);
eprintln!("ioctl UI_GET_VERSION request");
ui_get_version(fd, pversion_of_uinput).unwrap_or_else(|e| {
eprintln!("ui_get_version failed: {:?}", e);
std::process::exit(1);
});
eprintln!("ioctl UI_GET_VERSION {}", version_of_uinput);
let _ = set_standard_keyboard_keys(fd).unwrap_or_else(|e| {
eprintln!("set_standard_keyboard_keys failed: {:?}", e);
std::process::exit(1);
});
}
// Prepare uinput_setup struct
let mut usetup: uinput_setup = unsafe { zeroed() };
// Fill id and name fields
// `id` has bustype, vendor, product fields (types may vary slightly by libc version)
// set bustype/vendor/product as in C example
// Note: make sure the fields exist as below in your libc version; adapt if names differ.
usetup.id.bustype = BUS_USB;
usetup.id.vendor = 0xbeef;
usetup.id.product = 0xdead;
// Copy device name into the C char array in the struct
let name = CString::new("Example device").unwrap();
// uinput_setup::name is usually [c_char; UINPUT_MAX_NAME_SIZE]
unsafe {
// Fill with zeros first (already zeroed by zeroed()) then copy bytes
let name_ptr = usetup.name.as_mut_ptr() as *mut c_char;
ptr::copy_nonoverlapping(name.as_ptr(), name_ptr, name.to_bytes_with_nul().len());
}
// Call IOCTLs to setup and create the device
// Assuming your wrappers accept (fd, ptr_to_usetup) etc.
// We'll pass pointer to usetup
let usetup_ptr = &mut usetup as *mut uinput_setup;
unsafe {
ui_dev_setup(fd, usetup_ptr).unwrap_or_else(|e| {
eprintln!("ui_dev_setup failed: {:?}", e);
close(fd);
std::process::exit(1);
});
ui_dev_create(fd).unwrap_or_else(|e| {
eprintln!("ui_dev_create failed: {:?}", e);
close(fd);
std::process::exit(1);
});
let mut resultbuf: [c_char; 64] = [0; 64];
ui_get_sysname(fd, resultbuf.as_mut_slice()).unwrap();
let sysname = format!(
"{}{}",
SYS_INPUT_DIR,
CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy()
);
println!("syspath: {}", sysname);
let devnode = fetch_device_node(&sysname)
.unwrap_or_else(|e| panic!("failed to fetch device node!: {e}"));
println!("devnode: {}", devnode);
eprintln!("sysname: {}", sysname);
// Sleep 12 second to allow userspace to also listen to the device in case we need to debug
// Comment this out: sleep(Duration::from_secs(12));
let event_device = OpenOptions::new()
.read(true)
.open(&devnode)
.unwrap_or_else(|err| {
panic!("Could not open event device {}, Error {}", &devnode, err)
});
// Emit (press + syn) + (release + syn)
let ev1 = emit_read_and_log(fd, &event_device, EV_KEY, KEY_SPACE, 1)?;
let ev2 = emit_read_and_log(fd, &event_device, EV_KEY, KEY_SPACE, 0)?;
let eventlog = TestLog {
events: vec![ev1, ev2],
};
let serialized = serde_json::to_string(&eventlog).unwrap();
println!("Event log: {}", serialized);
// Destroy device and close fd
ui_dev_destroy(fd).unwrap_or_else(|e| {
eprintln!("ui_dev_destroy failed: {:?}", e);
close(fd);
std::process::exit(1);
});
close(fd);
}
Ok(())
}

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),
*/
}
}

184
vuinputd-tests/src/bwrap.rs Normal file
View file

@ -0,0 +1,184 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
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 nix::errno::Errno;
use nix::sys::socket::{socketpair, AddressFamily, SockFlag, SockType};
use nix::unistd::close;
use crate::ipc::{SandboxChildIpc, SandboxIpc};
/// Check if bubblewrap is available.
pub fn bwrap_available() -> bool {
Command::new("bwrap")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Builder for bubblewrap invocations.
#[derive(Default)]
pub struct BwrapBuilder {
args: Vec<String>,
ipc_child_fd: Option<OwnedFd>,
}
impl BwrapBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn unshare_all(mut self) -> Self {
self.args.push("--unshare-all".into());
self
}
pub fn unshare_net(mut self) -> Self {
self.args.push("--unshare-net".into());
self
}
pub fn proc(mut self) -> Self {
self.args.push("--proc".into());
self.args.push("/proc".into());
self
}
pub fn dev(mut self) -> Self {
// for our tests, we cannot simply use the "--dev"-flag, because it creates a tmpfs with the nodev flag
// see SETUP_MOUNT_DEV and PRIV_SEP_OP_TMPFS_MOUNT
// in https://github.com/containers/bubblewrap/blob/v0.11.0/bubblewrap.c#L1370-L1376 .
// So, we mount a temporary directory that does not have this restrictions.
self.args.extend([
"--dev-bind".into(),
"/run/vuinputd/vuinput-test/dev".into(),
"/dev".into(),
"--dev-bind".into(),
"/run/vuinputd/vuinput-test/dev-input".into(),
"/dev/input".into(),
]);
self
}
pub fn tmpfs(mut self, path: &str) -> Self {
self.args.push("--tmpfs".into());
self.args.push(path.into());
self
}
// https://superuser.com/questions/1577262/bwrap-execvp-no-such-file-or-directory-when-ro-binding-non-root-path
pub fn ro_bind(mut self, src: &str, dst: &str) -> Self {
self.args
.extend(["--ro-bind".into(), src.into(), dst.into()]);
self
}
pub fn bind(mut self, src: &str, dst: &str) -> Self {
self.args.extend(["--bind".into(), src.into(), dst.into()]);
self
}
pub fn dev_bind(mut self, src: &str, dst: &str) -> Self {
self.args
.extend(["--dev-bind".into(), src.into(), dst.into()]);
self
}
/// Ensure the container dies if the parent dies.
///
/// This uses bwrap's `--die-with-parent` flag, which internally
/// uses a parent-death signal (PR_SET_PDEATHSIG).
pub fn die_with_parent(mut self) -> Self {
self.args.push("--die-with-parent".into());
self
}
/// Enable bidirectional IPC using a Unix seqpacket socketpair.
pub fn with_ipc(mut self) -> io::Result<(Self, SandboxIpc)> {
let (parent, child) = socketpair(
AddressFamily::Unix,
SockType::SeqPacket,
None,
SockFlag::empty(),
)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
// Parent side
let parent_sock = unsafe { UnixDatagram::from_raw_fd(parent.into_raw_fd()) };
// Child side must become FD 3 inside container
self.ipc_child_fd = Some(child);
Ok((self, SandboxIpc { sock: parent_sock }))
}
/// Final command executed inside the container.
pub fn command(mut self, cmd: &str, args: &[&str]) -> Self {
self.args.push("--".into());
self.args.push(cmd.into());
self.args.extend(args.iter().map(|s| s.to_string()));
self
}
pub fn run(mut self) -> io::Result<Output> {
println!("Arguments for bwrap: {:?}", &self.args);
let mut cmd = Command::new("bwrap");
if let Some(fd) = self.ipc_child_fd.take() {
// give up ownership of ipc_child_fd in host process.
let fd = fd.into_raw_fd();
// Move child FD to 3. Note that the FD 3 needs to be linked at the
// beginning of the child program.
unsafe {
cmd.pre_exec(move || {
let res = libc::dup2(fd, SandboxChildIpc::FD);
Errno::result(res)
.map(drop)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
close(fd).ok();
Ok(())
})
};
}
cmd.args(&self.args).output()
}
}
#[cfg(feature = "requires-bwrap")]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bwrap_works() {
if !bwrap_available() {
panic!("bwrap not available");
}
let out = BwrapBuilder::new()
.unshare_net()
//.proc()
.ro_bind("/", "/")
.tmpfs("/tmp")
.die_with_parent()
.command("/usr/bin/echo", &[])
.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());
}
}

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

11
vuinputd-tests/src/lib.rs Normal file
View file

@ -0,0 +1,11 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
pub mod bwrap;
pub mod devices;
pub mod ipc;
pub mod podman;
pub mod run_vuinputd;
pub mod scenarios;
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

@ -0,0 +1,85 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::{
os::unix::process::CommandExt,
process::{Child, Command},
sync::Mutex,
thread,
time::Duration,
};
use nix::sys::signal::{self, Signal};
use nix::unistd::Pid;
/// Global singleton
static VUINPUTD_LOCK: Mutex<()> = Mutex::new(());
pub fn ensure_vuinputd_running(args: &[&str]) -> VuinputdGuard {
VuinputdGuard::start(args)
}
pub struct VuinputdGuard {
child: Child,
}
impl VuinputdGuard {
fn start(args: &[&str]) -> Self {
println!("Acquiring lock to ensure only one vuinputd test instance is running");
let _mutex = VUINPUTD_LOCK.lock().unwrap();
println!("Executing vuinputd located via cargo run");
let mut concat_args = vec![
"run",
"-p",
"vuinputd",
"--",
"--major",
"120",
"--minor",
"414796",
"--devname",
"vuinput-test",
];
concat_args.extend(args);
let child = unsafe {
Command::new("cargo")
.args(concat_args)
.pre_exec(|| {
// Last resort, if the parent just is killed.
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL);
Ok(())
})
.spawn()
.expect("failed to start vuinputd")
};
// Optional: give it time to create /dev/vuinput
thread::sleep(Duration::from_millis(1000));
Self { child }
}
}
impl Drop for VuinputdGuard {
fn drop(&mut self) {
let pid = Pid::from_raw(self.child.id() as i32);
// First: SIGTERM
let _ = signal::kill(pid, Signal::SIGTERM);
// Wait a bit
for _ in 0..10 {
if let Ok(Some(_)) = self.child.try_wait() {
println!("vuinputd for tests shutdown gracefully");
return;
}
thread::sleep(Duration::from_millis(100));
}
// Still alive → SIGKILL
let _ = signal::kill(pid, Signal::SIGKILL);
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

@ -0,0 +1,27 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LoggedInputEvent {
pub tv_sec: i64,
pub tv_nsec: i64,
pub duration_usec: i64,
pub type_: u16,
pub code: u16,
pub value: i32,
pub send_and_receive_match: bool,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct TestLog {
pub events: Vec<LoggedInputEvent>,
}

View file

@ -0,0 +1,246 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::{process::Command, time::Duration};
use vuinputd_tests::bwrap;
use vuinputd_tests::run_vuinputd;
#[cfg(all(feature = "requires-privileges", feature = "requires-bwrap"))]
#[test]
fn test_bwrap_simple() {
let out = bwrap::BwrapBuilder::new()
.unshare_all()
.ro_bind("/", "/")
.tmpfs("/tmp")
.die_with_parent()
.command("/usr/bin/echo", &["test", "test", "test"])
.run()
.unwrap_or_else(|e| panic!("failed to run bwrap!: {e}"));
println!("Output");
println!("stdout: {}", str::from_utf8(&out.stdout).unwrap());
println!("stderr: {}", str::from_utf8(&out.stderr).unwrap());
}
#[cfg(all(feature = "requires-privileges", feature = "requires-bwrap"))]
#[test]
fn test_bwrap_ipc() {
let bwrap_ipc = env!("CARGO_BIN_EXE_test-ipc");
let (builder, ipc) = bwrap::BwrapBuilder::new()
.unshare_all()
.ro_bind("/", "/")
.tmpfs("/tmp")
.die_with_parent()
.with_ipc()
.expect("failed to create IPC");
// Note that builder.run() will block. Thus, the send needs to happen before the child process blocks
// the host process.
ipc.send("continue".as_bytes())
.unwrap_or_else(|e| panic!("failed to send data via ipc: {e}"));
let out = builder
.command(bwrap_ipc, &[])
.run()
.unwrap_or_else(|e| panic!("failed to run bwrap!: {e}"));
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-bwrap"))]
#[test]
fn test_list_sys_in_container() {
let out = bwrap::BwrapBuilder::new()
.unshare_all()
.ro_bind("/", "/")
.tmpfs("/tmp")
.die_with_parent()
.command(
"/usr/bin/ls",
&["-lh", "/sys/devices/virtual/input/input235"],
)
.run()
.unwrap_or_else(|e| panic!("failed to run bwrap!: {e}"));
println!("Output");
println!("stdout: {}", str::from_utf8(&out.stdout).unwrap());
println!("stderr: {}", str::from_utf8(&out.stderr).unwrap());
}
#[cfg(all(feature = "requires-privileges", feature = "requires-uinput"))]
#[test]
fn test_keyboard_on_host() {
let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard");
let status = Command::new(test_keyboard)
.status()
.expect("failed to launch keyboard-in-container");
assert!(status.success());
}
#[cfg(all(
feature = "requires-privileges",
feature = "requires-uinput",
feature = "requires-bwrap"
))]
#[test]
fn test_keyboard_in_container_with_uinput() {
let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard");
let out = bwrap::BwrapBuilder::new()
.unshare_net()
.ro_bind("/", "/")
.tmpfs("/tmp")
.dev_bind("/dev/uinput", "/dev/uinput")
.dev_bind("/dev/input", "/dev/input")
.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_keyboard_in_container_with_vuinput_placement_in_container() {
let _guard: run_vuinputd::VuinputdGuard = run_vuinputd::ensure_vuinputd_running(&[]);
let test_keyboard = env!("CARGO_BIN_EXE_test-keyboard");
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_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_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()
.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());
}

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]
name = "vuinputd"
version = "0.2.0"
version = "0.3.2"
edition = "2021"
authors = ["Johannes Leupolz <dev@leupolz.eu>"]
license = "MIT"
@ -26,3 +26,16 @@ async-channel = "2.5.0"
futures = "0.3.31"
async-io = "2.6.0"
anyhow = "1.0.100"
clap = { version = "4", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
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

@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[serde(tag = "action")]
pub enum Action {
#[serde(rename = "mknod-device")]
MknodDevice {
path: String,
major: u64,
minor: u64,
},
#[serde(rename = "write-udev-runtime-data")]
WriteUdevRuntimeData {
runtime_data: Option<String>,
major: u64,
minor: u64,
},
#[serde(rename = "emit-netlink-message")]
EmitNetlinkMessage {
netlink_message: HashMap<String, String>,
},
#[serde(rename = "remove-device")]
RemoveDevice {
path: String,
major: u64,
minor: u64,
},
}

View file

@ -0,0 +1,47 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use super::action::Action;
use crate::input_realizer::input_device;
use crate::input_realizer::netlink_message;
use crate::input_realizer::runtime_data;
pub fn handle_cli_action(json: String) -> i32 {
let action: Action = serde_json::from_str(&json).expect("invalid action JSON");
handle_action(action).unwrap_or_else(|err| {
panic!("Error handling action: {}", err);
});
0
}
fn handle_action(action: Action) -> anyhow::Result<()> {
match action {
Action::MknodDevice { path, major, minor } => {
input_device::ensure_input_device(path, major.into(), minor.into())?;
Ok(())
}
Action::WriteUdevRuntimeData {
runtime_data,
major,
minor,
} => {
runtime_data::ensure_udev_structure()?;
match runtime_data {
Some(data) => {
runtime_data::write_udev_data("/run", &data, major.into(), minor.into())?
}
None => runtime_data::delete_udev_data("/run", major.into(), minor.into())?,
}
Ok(())
}
Action::EmitNetlinkMessage { netlink_message } => {
netlink_message::send_udev_monitor_message_with_properties(netlink_message);
Ok(())
}
Action::RemoveDevice { path, major, minor } => {
input_device::remove_input_device(path, major.into(), minor.into())?;
Ok(())
}
}
}

View file

@ -0,0 +1,6 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
pub mod action;
pub mod handle_action;

View file

@ -1,34 +0,0 @@
use libc::{__s32, __u16, c_ulong, input_event};
#[repr(C)]
pub struct input_event_compat {
pub input_event_sec: u32,
pub input_event_usec: u32,
pub type_: __u16,
pub code: __u16,
pub value: __s32,
}
// this is static for the architecture
pub fn compat_uses_64bit_time() -> bool {
let uname = nix::sys::utsname::uname().unwrap();
let arch = uname.machine().to_str().unwrap();
match arch {
"x86_64" => false,
"ppc64" => false, // some setups still 32-bit time_t
_ => true, // arm64, riscv64, s390x all use 64-bit
}
}
pub fn map_to_64_bit(compat: &input_event_compat) -> input_event{
let mut mapped: input_event = unsafe { std::mem::zeroed() };
mapped.time.tv_sec=compat.input_event_sec.into();
mapped.time.tv_usec=compat.input_event_usec.into();
mapped.type_=compat.type_;
mapped.code=compat.code;
mapped.value=compat.value;
mapped
}

View file

@ -1,9 +0,0 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
pub mod inject_in_container_job;
pub mod remove_from_container_job;
pub mod mknod_input_device;
pub mod runtime_data;
pub mod netlink_message;

View file

@ -1,132 +0,0 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::{collections::HashMap, future::Future, pin::Pin, sync::{Arc, Condvar, Mutex}, time::Duration};
use async_io::Timer;
use log::debug;
use crate::{
container::{mknod_input_device::{ensure_input_device, remove_input_device}, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{self, delete_udev_data, ensure_udev_structure, read_udev_data, write_udev_data}}, jobs::job::{Job, JobTarget}, monitor_udev::EVENT_STORE, requesting_process::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace}
};
#[derive(Clone,Debug,Copy,PartialOrd,PartialEq)]
pub enum State {
Initialized,
Started,
Finished,
}
#[derive(Clone,Debug)]
pub struct RemoveFromContainerJob {
requesting_process: RequestingProcess,
target: JobTarget,
dev_path: String,
sys_path: String,
major: u64,
minor: u64,
sync_state: Arc<(Mutex<State>,Condvar)>,
}
impl RemoveFromContainerJob {
pub fn new(requesting_process: RequestingProcess,dev_path: String, sys_path: String, major: u64, minor: u64) -> Self {
Self {
requesting_process: requesting_process.clone(),
target: JobTarget::Container(requesting_process),
dev_path: dev_path,
sys_path: sys_path,
major: major ,
minor: minor,
sync_state: Arc::new((Mutex::new(State::Initialized), Condvar::new())),
}
}
fn set_state(&self, new_state: &State) -> () {
let (lock, cvar) = &*self.sync_state;
let mut current_state = lock.lock().unwrap();
*current_state = *new_state;
// We notify the condvar that the value has changed.
cvar.notify_all();
}
pub fn get_awaiter_for_state(&self) -> impl FnOnce(&State) -> () {
// pattern is described on https://doc.rust-lang.org/stable/std/sync/struct.Condvar.html
let sync_state = self.sync_state.clone();
let awaiter = move | state: &State| {
let (lock, cvar) = &*sync_state;
let mut current_state = lock.lock().unwrap();
while *current_state < *state {
current_state = cvar.wait(current_state).unwrap();
}
};
awaiter
}
}
impl Job for RemoveFromContainerJob {
fn desc(&self) -> &str {
"Remove input device from container"
}
fn execute_after_cancellation(&self) -> bool {
false
}
fn create_task(self: &RemoveFromContainerJob) -> Pin<Box<dyn Future<Output = ()>>> {
Box::pin(self.clone().remove_from_container())
}
fn job_target(&self) -> JobTarget {
self.target.clone()
}
}
impl RemoveFromContainerJob {
async fn remove_from_container(self) {
self.set_state(&State::Started);
let netlink_event = match EVENT_STORE.get().unwrap().lock().unwrap().take(&self.sys_path) {
Some(netlink_event) => netlink_event,
None => {
debug!("do nothing, because the device has never been announced via netlink");
self.set_state(&State::Finished);
return;
}
};
if netlink_event.tombstone {
debug!("do nothing, because the device has already been removed in the meantime");
self.set_state(&State::Finished);
return;
}
let netlink_data=netlink_event.add_data;
// define for capturing
let mut netlink_data = netlink_data.unwrap().clone();
let major = self.major;
let minor=self.minor;
let dev_path = self.dev_path.clone();
let _ = netlink_data.insert("ACTION".to_string(),"remove".to_string());
let child_pid = run_in_net_and_mnt_namespace(&self.requesting_process, Box::new(move || {
// TODO: we should keep the same order as event_execute_rules_on_remove in
// https://github.com/systemd/systemd/blob/main/src/udev/udev-event.c
send_udev_monitor_message_with_properties(netlink_data.clone());
if let Err(e) = delete_udev_data(major,minor) {
debug!("Error deleting udev data for {}:{}: {e}",major,minor);
}
if let Err(e) = remove_input_device(dev_path.clone(), self.major, self.minor) {
debug!("Error removing input device {}: {e}",dev_path.clone());
};
}))
.expect("subprocess should work");
let _exit_info = await_process(Pid::Pid(child_pid.as_raw())).await;
self.set_state(&State::Finished);
}
}

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

@ -0,0 +1,43 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
pub mod device_policy;
pub mod evdev_write_watcher;
pub mod state;
pub mod vuinput_ioctl;
pub mod vuinput_open;
pub mod vuinput_poll;
pub mod vuinput_read;
pub mod vuinput_release;
pub mod vuinput_write;
use std::io::ErrorKind;
use std::os::unix::fs::{FileTypeExt, MetadataExt};
use std::{fs, io};
use ::cuse_lowlevel::*;
use state::*;
pub const BUS_USB: u16 = 0x03;
// Instance of cuse_lowlevel_ops with all stubs assigned.
// Setting to None leads to e.g. "write error: Function not implemented".
// You can find the implementations of the uinput default (open, release ,read, write, poll,
// and ioctl) in uinput_fops of uinput.c.
// See: https://github.com/torvalds/linux/blob/master/drivers/input/misc/uinput.c,
pub fn vuinput_make_cuse_ops() -> cuse_lowlevel::cuse_lowlevel_ops {
cuse_lowlevel::cuse_lowlevel_ops {
init: None,
init_done: None,
destroy: None,
open: Some(vuinput_open::vuinput_open),
read: Some(vuinput_read::vuinput_read),
write: Some(vuinput_write::vuinput_write),
flush: None,
release: Some(vuinput_release::vuinput_release),
fsync: None,
ioctl: Some(vuinput_ioctl::vuinput_ioctl),
poll: Some(vuinput_poll::vuinput_poll),
}
}

View file

@ -0,0 +1,237 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::collections::HashMap;
use std::fs::File;
use std::ptr::NonNull;
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use ::cuse_lowlevel::*;
use smallvec::SmallVec;
use crate::process_tools::RequestingProcess;
pub type PendingPollHandles = SmallVec<[*mut fuse_lowlevel::fuse_pollhandle; 1]>;
#[derive(Debug)]
pub struct VuInputDevice {
pub major: u64,
pub minor: u64,
pub syspath: String,
pub devname: 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)]
pub struct VuInputState {
pub file: File,
pub requesting_process: RequestingProcess,
pub input_device: Option<VuInputDevice>,
pub keytracker: KeyTracker,
pub poll: PollState,
}
#[derive(Debug, Eq, Hash, PartialEq, Clone)]
pub enum VuFileHandle {
Fh(u64),
}
impl VuFileHandle {
pub fn from_fuse_file_info(fi: &fuse_lowlevel::fuse_file_info) -> VuFileHandle {
VuFileHandle::Fh(fi.fh)
}
}
impl std::fmt::Display for VuFileHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VuFileHandle::Fh(fh) => writeln!(f, "VuFileHandle({:?})", fh)?,
}
Ok(())
}
}
pub fn get_vuinput_state(fh: &VuFileHandle) -> Result<Arc<Mutex<VuInputState>>, String> {
let map = VUINPUT_STATE
.get()
.ok_or("global not initialized".to_string())?;
let guard = map.read().map_err(|e| e.to_string())?;
guard
.get(&fh)
.cloned()
.ok_or("handle not opened".to_string())
}
pub fn insert_vuinput_state(fh: &VuFileHandle, state: VuInputState) -> Result<(), String> {
let map = VUINPUT_STATE
.get()
.ok_or("global not initialized".to_string())?;
let mut guard = map.write().map_err(|e| e.to_string())?;
if guard.contains_key(&fh) {
return Err(format!(
"file handle {} already exists. file handles must not be reused!",
&fh
));
}
let _ = guard.insert(fh.clone(), Arc::new(Mutex::new(state)));
Ok(())
}
pub fn remove_vuinput_state(fh: &VuFileHandle) -> Result<Arc<Mutex<VuInputState>>, String> {
let map = VUINPUT_STATE
.get()
.ok_or("global not initialized".to_string())?;
let mut guard = map.write().map_err(|e| e.to_string())?;
let old_value = guard.remove(&fh).ok_or("fh unknown")?;
Ok(old_value)
}
pub fn initialize_vuinput_state() {
VUINPUT_STATE
.set(RwLock::new(HashMap::new()))
.expect("failed to initialize global state");
}
pub fn initialize_dedup_last_error() {
DEDUP_LAST_ERROR
.set(Mutex::new(None))
.expect("failed to initialize the log deduplication state");
}
#[derive(Debug)]
pub enum VuError {
WriteError,
}
pub static VUINPUT_STATE: OnceLock<RwLock<HashMap<VuFileHandle, Arc<Mutex<VuInputState>>>>> =
OnceLock::new();
// For log limiting. Idea: Move to log_limit crate
pub static DEDUP_LAST_ERROR: OnceLock<Mutex<Option<(u64, VuError)>>> = OnceLock::new();

View file

@ -0,0 +1,453 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use ::cuse_lowlevel::*;
use libc::{EBADRQC, input_absinfo, iovec, size_t};
use libc::{uinput_abs_setup, uinput_ff_erase, uinput_ff_upload, uinput_setup};
use log::debug;
use std::ffi::CStr;
use std::os::fd::AsRawFd;
use std::os::raw::{c_char, c_int, c_uint, c_void};
use uinput_ioctls::*;
use crate::cuse_device::{get_vuinput_state, VuFileHandle};
use crate::job_engine::JOB_DISPATCHER;
use crate::jobs::emit_udev_event_job::EmitUdevEventJob;
use crate::jobs::mknod_device_job::MknodDeviceJob;
use crate::jobs::remove_device_job::RemoveDeviceJob;
use crate::process_tools::SELF_NAMESPACES;
use crate::{cuse_device::*, jobs};
pub const SYS_INPUT_DIR: &str = "/sys/devices/virtual/input/";
pub unsafe extern "C" fn vuinput_ioctl(
_req: fuse_lowlevel::fuse_req_t,
_cmd: c_int,
_arg: *mut c_void, //note: this is a pointer in the application space and should not be dereferenced at all
_fi: *mut fuse_lowlevel::fuse_file_info,
_flags: c_uint,
_in_buf: *const c_void, // note: this was mapped by the kernel and can be read from
_in_bufsz: size_t,
_out_bufsz: size_t,
) {
// fuse_reply_ioctl_retry is only necessary for variable length commands;
// see comment "Now check variable-length commands" in uinput.c of the linux kernel.
// Those are UI_GET_SYSNAME and UI_ABS_SETUP as of v0.4.
// ioctl to map are listed on https://www.freedesktop.org/software/libevdev/doc/latest/ioctls.html
// https://docs.rs/linux-raw-sys/0.11.0/src/linux_raw_sys/x86_64/ioctl.rs.html#529
let cmd_u64 = (_cmd as c_uint).into();
// normalize the variable length ones
let cmd_without_size = cmd_u64 & !(nix::sys::ioctl::SIZEMASK << nix::sys::ioctl::SIZESHIFT);
let cmd_normalized = match cmd_without_size {
UI_GET_SYSNAME_WITHOUT_SIZE => UI_GET_SYSNAME_WITHOUT_SIZE,
//UI_ABS_SETUP => UI_ABS_SETUP_WITHOUT_SIZE,
_ => cmd_u64,
};
let vufh = VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap());
let vuinput_state_mutex = get_vuinput_state(&vufh).unwrap();
let fh = &(*_fi).fh;
let mut vuinput_state = vuinput_state_mutex.lock().unwrap();
// ensure for all ioctls that need mapped data, that we have the data correctly mapped
match (_in_bufsz, _out_bufsz, cmd_normalized) {
(0, _, UI_ABS_SETUP) => {
//todo: i guess this needs to be reworked as this is variable size. i guess it is not reachable at all
debug!("fh {}: submitting _in_bufsz for UI_ABS_SETUP", fh);
let iov = iovec {
iov_base: _arg,
iov_len: ::std::mem::size_of::<uinput_abs_setup>(),
};
fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0);
return;
}
(_, 0, UI_GET_SYSNAME_WITHOUT_SIZE) => {
let size = (cmd_u64 & nix::sys::ioctl::SIZEMASK) >> nix::sys::ioctl::SIZESHIFT;
debug!(
"fh {}: submitting _out_bufsz for UI_GET_SYSNAME({}) ",
fh, size
);
let iov = iovec {
iov_base: _arg,
iov_len: 64,
};
fuse_lowlevel::fuse_reply_ioctl_retry(_req, std::ptr::null(), 0, &iov, 1);
return;
}
(_, 0, UI_GET_VERSION) => {
let size = (cmd_u64 & nix::sys::ioctl::SIZEMASK) >> nix::sys::ioctl::SIZESHIFT;
debug!(
"fh {}: submitting _out_bufsz for UI_GET_VERSION({}) ",
fh, size
);
let iov = iovec {
iov_base: _arg,
iov_len: std::mem::size_of::<c_uint>(),
};
fuse_lowlevel::fuse_reply_ioctl_retry(_req, std::ptr::null(), 0, &iov, 1);
return;
}
(0, _, UI_DEV_SETUP) => {
debug!("fh {}: submitting _in_bufsz for UI_DEV_SETUP", fh);
let iov = iovec {
iov_base: _arg,
iov_len: ::std::mem::size_of::<uinput_setup>(),
};
fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0);
return;
}
(0, _, UI_SET_PHYS) => {
debug!("fh {}: submitting _in_bufsz for UI_SET_PHYS", fh);
let iov = iovec {
iov_base: _arg,
iov_len: ::std::mem::size_of::<c_char>() * 1024,
};
fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0);
return;
}
(0, _, UI_BEGIN_FF_UPLOAD) => {
debug!("fh {}: submitting _in_bufsz for UI_BEGIN_FF_UPLOAD", fh);
let iov = iovec {
iov_base: _arg,
iov_len: ::std::mem::size_of::<uinput_ff_upload>(),
};
fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, &iov, 1);
return;
}
(0, _, UI_END_FF_UPLOAD) => {
debug!("fh {}: submitting _in_bufsz for UI_END_FF_UPLOAD", fh);
let iov = iovec {
iov_base: _arg,
iov_len: ::std::mem::size_of::<uinput_ff_upload>(),
};
fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0);
return;
}
(0, _, UI_BEGIN_FF_ERASE) => {
debug!("fh {}: submitting _in_bufsz for UI_BEGIN_FF_ERASE", fh);
let iov = iovec {
iov_base: _arg,
iov_len: ::std::mem::size_of::<uinput_ff_erase>(),
};
fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, &iov, 1);
return;
}
(0, _, UI_END_FF_ERASE) => {
debug!("fh {}: submitting _in_bufsz for UI_END_FF_ERASE", fh);
let iov = iovec {
iov_base: _arg,
iov_len: ::std::mem::size_of::<uinput_ff_erase>(),
};
fuse_lowlevel::fuse_reply_ioctl_retry(_req, &iov, 1, std::ptr::null(), 0);
return;
}
_ => {
//nothing to map
}
}
let fd = vuinput_state.file.as_raw_fd();
// now we can assume that the data is mapped or it is not required
match cmd_normalized {
UI_DEV_CREATE => {
debug!("fh {}: ioctl UI_DEV_CREATE", fh);
ui_dev_create(fd).unwrap();
let mut resultbuf: [c_char; 64] = [0; 64];
ui_get_sysname(fd, resultbuf.as_mut_slice()).unwrap();
let sysname = format!(
"{}{}",
SYS_INPUT_DIR,
CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy()
);
debug!("fh {}: syspath: {}", fh, sysname);
let (devname, devnode) = fetch_device_node(&sysname).unwrap();
debug!("fh {}: devnode: {}", fh, devnode);
let (major, minor) = fetch_major_minor(&devnode).unwrap();
debug!("fh {}: major: {} minor: {} ", fh, major, minor);
vuinput_state.input_device = Some(VuInputDevice {
major: major,
minor: minor,
syspath: sysname.clone(),
devname: devname.clone(),
devnode: devnode.clone(),
});
// Create device in container, if the request was really from another namespace
if !SELF_NAMESPACES
.get()
.unwrap()
.equal_mnt_and_net(&vuinput_state.requesting_process.namespaces)
{
let mknod_job = MknodDeviceJob::new(
vuinput_state.requesting_process.clone(),
devname.clone(),
sysname.clone(),
major,
minor,
);
let awaiter = mknod_job.get_awaiter_for_state();
JOB_DISPATCHER
.get()
.unwrap()
.lock()
.unwrap()
.dispatch(Box::new(mknod_job));
awaiter(&jobs::mknod_device_job::State::Finished);
debug!("fh {}: mknod_device in container has been finished ", fh);
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
// we do not wait for the udev stuff
let emit_udev_event_job = EmitUdevEventJob::new(
vuinput_state.requesting_process.clone(),
devnode.clone(),
sysname.clone(),
major,
minor,
);
JOB_DISPATCHER
.get()
.unwrap()
.lock()
.unwrap()
.dispatch(Box::new(emit_udev_event_job));
} else {
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
}
UI_DEV_DESTROY => {
debug!("fh {}: ioctl UI_DEV_DESTROY", fh);
let input_device = vuinput_state.input_device.take();
// Remove device in container, if the request was really from another namespace
if input_device.is_some()
&& !SELF_NAMESPACES
.get()
.unwrap()
.equal_mnt_and_net(&vuinput_state.requesting_process.namespaces)
{
let input_device = input_device.unwrap();
let remove_job = RemoveDeviceJob::new(
vuinput_state.requesting_process.clone(),
input_device.devname.clone(),
input_device.syspath.clone(),
input_device.major,
input_device.minor,
);
let awaiter = remove_job.get_awaiter_for_state();
JOB_DISPATCHER
.get()
.unwrap()
.lock()
.unwrap()
.dispatch(Box::new(remove_job));
awaiter(&jobs::remove_device_job::State::Finished);
debug!(
"fh {}: removing dev-nodes from container has been finished ",
fh
);
}
ui_dev_destroy(fd).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_DEV_SETUP => {
debug!("fh {}: ioctl UI_DEV_SETUP", fh);
assert!(_in_bufsz != 0, "should have _in_bufsz");
let setup_ptr = _in_buf as *mut uinput_setup;
debug!(
"product: {:x} vendor: {:x}",
(*setup_ptr).id.product,
(*setup_ptr).id.vendor
);
// replace vendor and product id to the values from sunshine (see inputtino_common.h of sunshine)
// The pid is registered for vuinputd, see https://pid.codes/1209/5020/
(*setup_ptr).id.bustype = BUS_USB;
(*setup_ptr).id.product = 0x5020;
(*setup_ptr).id.vendor = 0x1209;
ui_dev_setup(fd, setup_ptr).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_ABS_SETUP => {
//todo: i guess this needs to be reworked as this is variable size. i guess it is not reachable at all
debug!("fh {}: ioctl UI_ABS_SETUP", fh);
assert!(_in_bufsz != 0, "should have _in_bufsz");
let abs_setup_ptr = _in_buf as *const uinput_abs_setup;
ui_abs_setup(fd, abs_setup_ptr).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_GET_SYSNAME_WITHOUT_SIZE => {
debug!("fh {}: ioctl UI_GET_SYSNAME {_out_bufsz}", fh);
assert!(
_out_bufsz == 64,
"should have _out_bufsz of length 64 (currently hardcoded)"
);
let mut resultbuf: [c_char; 64] = [0; 64];
ui_get_sysname(fd, resultbuf.as_mut_slice()).unwrap();
let sysname = CStr::from_ptr(resultbuf.as_ptr()).to_string_lossy();
debug!("fh {}: sysname: {}", fh, sysname);
fuse_lowlevel::fuse_reply_ioctl(
_req,
0,
resultbuf.as_mut_ptr() as *mut c_void,
_out_bufsz,
);
}
UI_GET_VERSION => {
let mut version_of_kernel = 0;
let pversion_of_kernel = std::ptr::from_mut(&mut version_of_kernel);
ui_get_version(fd, pversion_of_kernel).unwrap();
debug!("fh {}: ioctl UI_GET_VERSION {}", fh, version_of_kernel);
let reply_arg = 5;
let preply_arg = std::ptr::from_ref(&reply_arg);
fuse_lowlevel::fuse_reply_ioctl(
_req,
0,
preply_arg as *const c_void,
std::mem::size_of::<c_uint>(),
);
}
UI_SET_EVBIT => {
let value = _arg as c_uint;
debug!("fh {}: ioctl UI_SET_EVBIT {}", fh, value);
ui_set_evbit(fd, value.into()).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_SET_KEYBIT => {
let value = _arg as c_uint;
debug!("fh {}: ioctl UI_SET_KEYBIT {}", fh, value);
ui_set_keybit(fd, value.into()).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_SET_RELBIT => {
let value = _arg as c_uint;
debug!("fh {}: ioctl UI_SET_RELBIT {}", fh, value);
ui_set_relbit(fd, value.into()).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_SET_ABSBIT => {
let value = _arg as c_uint;
debug!("fh {}: ioctl UI_SET_ABSBIT {}", fh, value);
ui_set_absbit(fd, value.into()).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_SET_MSCBIT => {
let value = _arg as c_uint;
debug!("fh {}: ioctl UI_SET_MSCBIT {}", fh, value);
ui_set_mscbit(fd, value.into()).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_SET_LEDBIT => {
let value = _arg as c_uint;
debug!("fh {}: ioctl UI_SET_LEDBIT {}", fh, value);
ui_set_ledbit(fd, value.into()).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_SET_SNDBIT => {
let value = _arg as c_uint;
debug!("fh {}: ioctl UI_SET_SNDBIT {}", fh, value);
ui_set_sndbit(fd, value.into()).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_SET_FFBIT => {
let value = _arg as c_uint;
debug!("fh {}: ioctl UI_SET_FFBIT {}", fh, value);
ui_set_ffbit(fd, value.into()).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_SET_PHYS => {
assert!(_in_bufsz != 0, "should have _in_bufsz");
debug!("fh {}: ioctl UI_SET_PHYS", fh);
// inbuf is actually a *const c_char, but
// but the macro to generate ui_set_phys expects a ptr to the actual data structure.
let phys = _in_buf as *const *const c_char;
ui_set_phys(fd, phys).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_SET_SWBIT => {
let value = _arg as c_uint;
debug!("fh {}: ioctl UI_SET_SWBIT {}", fh, value);
ui_set_swbit(fd, value.into()).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_SET_PROPBIT => {
let value = _arg as c_uint;
debug!("fh {}: ioctl UI_SET_PROPBIT {}", fh, value);
ui_set_propbit(fd, value.into()).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_BEGIN_FF_UPLOAD => {
assert!(_in_bufsz != 0, "should have _in_bufsz");
debug!("fh {}: ioctl UI_BEGIN_FF_UPLOAD", fh);
let ff_upload_ptr = _in_buf as *mut uinput_ff_upload;
debug!("request_id: {:x}", (*ff_upload_ptr).request_id);
ui_begin_ff_upload(fd, ff_upload_ptr).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, ff_upload_ptr as *mut c_void, _out_bufsz);
}
UI_END_FF_UPLOAD => {
assert!(_in_bufsz != 0, "should have _in_bufsz");
debug!("fh {}: ioctl UI_END_FF_UPLOAD", fh);
let ff_upload_ptr = _in_buf as *const uinput_ff_upload;
debug!("request_id: {:x}", (*ff_upload_ptr).request_id);
ui_end_ff_upload(fd, ff_upload_ptr).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
UI_BEGIN_FF_ERASE => {
assert!(_in_bufsz != 0, "should have _in_bufsz");
debug!("fh {}: ioctl UI_BEGIN_FF_ERASE", fh);
let ff_erase_ptr = _in_buf as *mut uinput_ff_erase;
debug!("request_id: {:x}", (*ff_erase_ptr).request_id);
ui_begin_ff_erase(fd, ff_erase_ptr).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, ff_erase_ptr as *mut c_void, _out_bufsz);
}
UI_END_FF_ERASE => {
assert!(_in_bufsz != 0, "should have _in_bufsz");
debug!("fh {}: ioctl UI_END_FF_ERASE", fh);
let ff_erase_ptr = _in_buf as *const uinput_ff_erase;
debug!("request_id: {:x}", (*ff_erase_ptr).request_id);
ui_end_ff_erase(fd, ff_erase_ptr).unwrap();
fuse_lowlevel::fuse_reply_ioctl(_req, 0, std::ptr::null(), 0);
}
_ => {
debug!("fh {}: ioctl cmd {}", fh, _cmd);
fuse_lowlevel::fuse_reply_err(_req, EBADRQC);
}
}
}
pub fn fetch_device_node(path: &str) -> io::Result<(String, String)> {
for entry in fs::read_dir(path)? {
let entry = entry?; // propagate per-entry errors
if let Some(name) = entry.file_name().to_str() {
if name.starts_with("event") {
return Ok((name.to_string(), format!("/dev/input/{}", name)));
}
}
}
// If no device is found, return an error
Err(io::Error::new(ErrorKind::NotFound, "no device found"))
}
/// Returns (major, minor) numbers of a device node at `path`
pub fn fetch_major_minor(path: &str) -> io::Result<(u64, u64)> {
let metadata = fs::metadata(path)?;
// Ensure it's a character device
if !metadata.file_type().is_char_device() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Not a character device",
));
}
let rdev = metadata.rdev();
let major = ((rdev >> 8) & 0xfff) as u64;
let minor = ((rdev & 0xff) | ((rdev >> 12) & 0xfff00)) as u64;
Ok((major, minor))
}

View file

@ -0,0 +1,79 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use ::cuse_lowlevel::*;
use libc::ENOENT;
use libc::O_CLOEXEC;
use libc::O_NONBLOCK;
use log::{debug, error};
use std::fs::OpenOptions;
use std::os::fd::AsFd;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
use crate::cuse_device::evdev_write_watcher::EVDEV_WRITE_WATCHER;
use crate::cuse_device::*;
use crate::process_tools::{get_requesting_process, Pid};
pub static VUINPUT_COUNTER: OnceLock<AtomicU64> = OnceLock::new();
fn get_fresh_filehandle() -> u64 {
let ctr = VUINPUT_COUNTER.get().unwrap();
ctr.fetch_add(1, Ordering::SeqCst).into()
}
pub unsafe extern "C" fn vuinput_open(
_req: fuse_lowlevel::fuse_req_t,
_fi: *mut fuse_lowlevel::fuse_file_info,
) {
let fh = get_fresh_filehandle();
let ctx = fuse_lowlevel::fuse_req_ctx(_req);
debug!("fh {}: opened by process id {} (host view)", fh, (*ctx).pid);
let pid = Pid::Pid(
(*ctx)
.pid
.try_into()
.expect("pid must be a positive integer"),
);
let requesting_process = get_requesting_process(pid);
debug!("fh {}: namespaces {}", fh, requesting_process);
// namespaces net:4026531840, uts:4026531838, ipc:4026531839, pid:4026531836, pid_for_children:4026531836, user:4026531837, mnt:4026531841, cgroup:4026531835, time:4026531834, time_for_children:4026531834
(*_fi).fh = fh;
// Open the path, returns `io::Result<File>`
let open_vuinput_result = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(O_NONBLOCK | O_CLOEXEC)
.open(Path::new("/dev/uinput"));
match open_vuinput_result {
Ok(v) => {
let vu_fh: VuFileHandle = VuFileHandle::Fh(fh);
insert_vuinput_state(
&vu_fh,
VuInputState {
file: v,
requesting_process,
input_device: None,
keytracker: KeyTracker::new(),
poll: PollState::new(),
},
)
.unwrap();
EVDEV_WRITE_WATCHER
.get()
.unwrap()
.lock()
.unwrap()
.add_device(vu_fh)
.unwrap();
fuse_lowlevel::fuse_reply_open(_req, _fi);
}
Err(e) => {
error!("couldn't open /dev/uinput: {}", e);
fuse_lowlevel::fuse_reply_err(_req, ENOENT);
}
}
}

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

@ -0,0 +1,82 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use crate::cuse_device::evdev_write_watcher::EVDEV_WRITE_WATCHER;
use crate::job_engine::JOB_DISPATCHER;
use crate::jobs::remove_device_job::RemoveDeviceJob;
use crate::process_tools::SELF_NAMESPACES;
use crate::{cuse_device::*, jobs};
use ::cuse_lowlevel::*;
use log::debug;
use std::os::fd::AsFd;
use std::sync::Arc;
pub unsafe extern "C" fn vuinput_release(
_req: fuse_lowlevel::fuse_req_t,
_fi: *mut fuse_lowlevel::fuse_file_info,
) {
let fh = &(*_fi).fh;
let vu_fh = VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap());
let vuinput_state_mutex = remove_vuinput_state(&vu_fh).unwrap();
let mut vuinput_state = vuinput_state_mutex.lock().unwrap();
let input_device = vuinput_state.input_device.take();
// Remove device in container, if the request was really from another namespace
// Only do this in case it has not already been done by the ioctl UI_DEV_DESTROY
// this here is relevant if the process was killed and didn't have the chance to send the
// ioctl UI_DEV_DESTROY.
if input_device.is_some()
&& !SELF_NAMESPACES
.get()
.unwrap()
.equal_mnt_and_net(&vuinput_state.requesting_process.namespaces)
{
let input_device = input_device.unwrap();
let remove_job = RemoveDeviceJob::new(
vuinput_state.requesting_process.clone(),
input_device.devname.clone(),
input_device.syspath.clone(),
input_device.major,
input_device.minor,
);
let awaiter = remove_job.get_awaiter_for_state();
JOB_DISPATCHER
.get()
.unwrap()
.lock()
.unwrap()
.dispatch(Box::new(remove_job));
awaiter(&jobs::remove_device_job::State::Finished);
}
EVDEV_WRITE_WATCHER
.get()
.unwrap()
.lock()
.unwrap()
.remove_device(vuinput_state.file.as_fd())
.unwrap();
drop(vuinput_state);
debug!(
"fh {}: references left before releasing device {} (expected is 1)",
fh,
Arc::strong_count(&vuinput_state_mutex)
);
drop(vuinput_state_mutex); // this also closes the file when no other references are open
// TODO: maybe also ensure that nothing is left in the containers
// Note: For CUSE, the kernel always issues RELEASE via fuse_sync_release(),
// which forces a *synchronous* request (fuse_simple_request()).
//
// That means the kernel thread blocks until userspace sends a reply header.
// Calling fuse_reply_none() would send no header at all, causing the kernel
// to wait forever and the caller to deadlock.
//
// Therefore we must always send a real reply for RELEASE.
// `fuse_reply_err(req, 0)` is enough to wake the kernel and is safe here.
fuse_lowlevel::fuse_reply_err(_req, 0);
}

View file

@ -0,0 +1,168 @@
// 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};
use libc::{off_t, size_t, EIO};
use libc::{uinput_abs_setup, uinput_setup};
use log::{debug, trace};
use std::io::Write;
use std::os::fd::AsRawFd;
use std::os::raw::c_char;
use uinput_ioctls::*;
// TODO: compat-mode+ ensure sizeof(struct input_event)
pub unsafe extern "C" fn vuinput_write(
_req: fuse_lowlevel::fuse_req_t,
_buf: *const c_char,
_size: size_t,
_off: off_t,
_fi: *mut fuse_lowlevel::fuse_file_info,
) {
assert!(
_off == 0,
"vuinput_write: offset needs to be 0 but is {}",
_off
);
let fh = &(*_fi).fh;
let slice = std::slice::from_raw_parts(_buf as *const u8, _size);
let vuinput_state_mutex =
get_vuinput_state(&VuFileHandle::from_fuse_file_info(_fi.as_ref().unwrap())).unwrap();
let mut vuinput_state = vuinput_state_mutex.lock().unwrap();
if vuinput_state.input_device.is_none() {
debug!(
"{}: legacy device setup recognized! Ignore the data and use hardcoded values",
fh
);
assert!(_size == std::mem::size_of::<libc::uinput_user_dev>());
let legacy_uinput_user_dev = _buf as *const libc::uinput_user_dev;
let mut usetup: uinput_setup = unsafe { std::mem::zeroed() };
usetup.id.bustype = BUS_USB;
// The pid is registered for vuinputd, see https://pid.codes/1209/5020/
usetup.id.vendor = 0x1209;
usetup.id.product = 0x5020;
usetup.id.version = (*legacy_uinput_user_dev).id.version;
usetup.ff_effects_max = (*legacy_uinput_user_dev).ff_effects_max;
usetup.name = (*legacy_uinput_user_dev).name;
// Call IOCTLs to setup and create the device
// Assuming your wrappers accept (fd, ptr_to_usetup) etc.
// We'll pass pointer to usetup
let usetup_ptr = &mut usetup as *mut uinput_setup;
let fd = vuinput_state.file.as_raw_fd();
ui_dev_setup(fd, usetup_ptr).unwrap();
// setup abs
for code in 0..libc::ABS_CNT {
if (*legacy_uinput_user_dev).absmax[code] != 0
|| (*legacy_uinput_user_dev).absmin[code] != 0
{
let mut abs_setup: uinput_abs_setup = unsafe { std::mem::zeroed() };
abs_setup.code = code.try_into().unwrap();
abs_setup.absinfo.maximum = (*legacy_uinput_user_dev).absmax[code];
abs_setup.absinfo.minimum = (*legacy_uinput_user_dev).absmin[code];
abs_setup.absinfo.fuzz = (*legacy_uinput_user_dev).absfuzz[code];
abs_setup.absinfo.flat = (*legacy_uinput_user_dev).absflat[code];
let abs_setup_ptr = &mut abs_setup as *mut uinput_abs_setup;
ui_abs_setup(fd, abs_setup_ptr).unwrap();
}
}
fuse_lowlevel::fuse_reply_write(_req, _size);
return;
}
let mut bytes = 0;
let mut result = Result::Ok(0);
let compat_size = std::mem::size_of::<input_event_compat>();
let normal_size = std::mem::size_of::<libc::input_event>();
let is_compat = vuinput_state.requesting_process.is_compat;
// TODO: ARM: && !compat_uses_64bit_time()
let policy = get_device_policy();
if !is_compat {
while bytes + normal_size <= _size && result.is_ok() {
let position = _buf.byte_add(bytes);
let input_event = position as *const input_event;
if device_policy::is_allowed(&mut vuinput_state.keytracker, policy, &*input_event) {
result = vuinput_state.file.write(&slice[bytes..bytes + normal_size]);
}
bytes += normal_size;
}
} else {
while bytes + compat_size <= _size && result.is_ok() {
let position = _buf.byte_add(bytes);
let compat = position as *const input_event_compat;
let normal = map_to_64_bit(&*compat);
let normal_ptr = (&normal as *const libc::input_event) as *const u8;
let slice = std::slice::from_raw_parts(normal_ptr, normal_size);
if device_policy::is_allowed(&mut vuinput_state.keytracker, policy, &normal) {
result = vuinput_state.file.write(&slice);
}
bytes += compat_size;
}
};
match result {
Ok(_) => {
trace!("wrote {} of {} bytes (compat {})", bytes, _size, is_compat);
fuse_lowlevel::fuse_reply_write(_req, bytes);
}
Err(e) => {
let mut last_error = DEDUP_LAST_ERROR.get().unwrap().lock().unwrap();
match *last_error {
Some((last_fh, VuError::WriteError)) if *fh == last_fh => {}
_ => {
debug!("fh {}: error writing to uinput: {e:?}", fh);
}
}
*last_error = Some((*fh, VuError::WriteError));
fuse_lowlevel::fuse_reply_err(_req, EIO);
}
}
}
#[repr(C)]
pub struct input_event_compat {
pub input_event_sec: u32,
pub input_event_usec: u32,
pub type_: __u16,
pub code: __u16,
pub value: __s32,
}
// this is static for the architecture
pub fn compat_uses_64bit_time() -> bool {
let uname = nix::sys::utsname::uname().unwrap();
let arch = uname.machine().to_str().unwrap();
match arch {
"x86_64" => false,
"ppc64" => false, // some setups still 32-bit time_t
_ => true, // arm64, riscv64, s390x all use 64-bit
}
}
pub fn map_to_64_bit(compat: &input_event_compat) -> input_event {
let mut mapped: input_event = unsafe { std::mem::zeroed() };
mapped.time.tv_sec = compat.input_event_sec.into();
mapped.time.tv_usec = compat.input_event_usec.into();
mapped.type_ = compat.type_;
mapped.code = compat.code;
mapped.value = compat.value;
mapped
}

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

@ -2,22 +2,20 @@
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use anyhow::anyhow;
use nix::sys::stat::{makedev, mknod, stat, Mode, SFlag};
use nix::unistd::{chown, Gid, Uid};
use std::error::Error;
use std::fs;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
pub fn ensure_input_device(dev_path: String, major: u64, minor: u64) -> Result<(), Box<dyn Error>> {
pub fn ensure_input_device(dev_path: String, major: u64, minor: u64) -> anyhow::Result<()> {
let input_dir = Path::new("/dev/input");
// Create directory like `mkdir -p`
if !input_dir.exists() {
println!("Create /dev/input");
fs::create_dir_all(input_dir)?;
}
let path = Path::new(&dev_path);
let expected_dev = makedev(major, minor);
let expected_mode = 0o666;
@ -72,25 +70,27 @@ pub fn ensure_input_device(dev_path: String, major: u64, minor: u64) -> Result<(
Ok(())
}
pub fn remove_input_device(dev_path: String, major: u64, minor: u64) -> Result<(), Box<dyn Error>> {
pub fn remove_input_device(dev_path: String, major: u64, minor: u64) -> anyhow::Result<()> {
let path = Path::new(&dev_path);
let expected_dev = makedev(major, minor);
// --- Step 1: Ensure it is the correct device ---
if !path.exists() {
return Err("Device does not exist".into());
return Err(anyhow!("Device does not exist"));
}
match stat(path) {
Ok(st) => {
let is_char = (st.st_mode & libc::S_IFMT as u32) == libc::S_IFCHR as u32;
let dev_ok = st.st_rdev == expected_dev;
if !(is_char && dev_ok) {
return Err("Device that should be deleted has wrong major and minor".into())
return Err(anyhow!(
"Device that should be deleted has wrong major and minor"
));
}
}
Err(x) => return Err("Could not execute stat on device file".into())
Err(_x) => return Err(anyhow!("Could not execute stat on device file")),
}
let _ = fs::remove_file(path);
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,11 +6,11 @@ use std::collections::HashMap;
use std::mem;
use std::os::fd::{AsRawFd, OwnedFd};
use std::io::{IoSlice};
use std::io::IoSlice;
use log::debug;
use nix::sys::socket::{
bind, sendmsg, socket, AddressFamily, MsgFlags, NetlinkAddr, SockFlag, SockProtocol, SockType
bind, sendmsg, socket, AddressFamily, MsgFlags, NetlinkAddr, SockFlag, SockProtocol, SockType,
};
/// Netlink constants
@ -79,7 +79,7 @@ pub fn string_hash32(s: &str) -> u32 {
match s {
"input" => 3248653424,
"" => 0,
_ => panic!("uncovered use case")
_ => panic!("uncovered use case"),
}
}
@ -95,12 +95,10 @@ fn open_netlink(groups: u32) -> Result<OwnedFd, String> {
.map_err(|e| format!("Could not create netlink socket: {}", e))?;
// pid 0 => the kernel takes care of assigning it.
let sockaddr=NetlinkAddr::new(0, groups);
let raw_fd= fd.as_raw_fd();
let sockaddr = NetlinkAddr::new(0, groups);
let raw_fd = fd.as_raw_fd();
bind(raw_fd, &sockaddr).map_err(|e| {
format!("Could not bind netlink socket: {}", e)
})?;
bind(raw_fd, &sockaddr).map_err(|e| format!("Could not bind netlink socket: {}", e))?;
Ok(fd)
}
@ -128,16 +126,19 @@ pub fn send_udev_monitor_message(
let fd = open_netlink(groups)?;
// prepare iovecs
let iov = [
IoSlice::new(&header_bytes),
IoSlice::new(payload),
];
let iov = [IoSlice::new(&header_bytes), IoSlice::new(payload)];
// destination sockaddr (NULL nl_pid => kernel / multicast)
let sockaddr = NetlinkAddr::new(0, groups);
let _rc = sendmsg(fd.as_raw_fd(), &iov, &[], MsgFlags::empty(), Some(&sockaddr))
.map_err(|e| format!("Could not send message: {}", e));
let _rc = sendmsg(
fd.as_raw_fd(),
&iov,
&[],
MsgFlags::empty(),
Some(&sockaddr),
)
.map_err(|e| format!("Could not send message: {}", e));
debug!("udev message sent");
// ensure cleanup
@ -146,29 +147,27 @@ pub fn send_udev_monitor_message(
Ok(())
}
pub fn send_udev_monitor_message_with_properties(properties:HashMap<String, String>) {
pub fn send_udev_monitor_message_with_properties(properties: HashMap<String, String>) {
let device_name = match properties.get("DEVNAME") {
Some(name) => name,
None => "unknown device"
None => "unknown device",
};
debug!("Sending udev message over netlink for {}",device_name);
let mut payload:Vec<u8> = Vec::new();
for (key,value) in properties.iter() {
debug!("Sending udev message over netlink for {}", device_name);
let mut payload: Vec<u8> = Vec::new();
for (key, value) in properties.iter() {
payload.extend(key.as_bytes());
payload.extend("=".as_bytes());
payload.extend(value.as_bytes());
payload.push(0);
}
send_udev_monitor_message(&payload,Some("input"),None,UDEV_EVENT_MODE).unwrap();
send_udev_monitor_message(&payload, Some("input"), None, UDEV_EVENT_MODE).unwrap();
}
// println!("{:02X?}", payload);
// 746573743D76616C75650043555252454E545F544147533D3A736561745F7675696E7075743A00544147533D3A736561745F7675696E7075743A00444556504154483D2F646576696365732F7669727475616C2F696E7075742F696E7075743133382F6576656E74390049445F5655494E5055545F4D4F5553453D31004D494E4F523D37330049445F494E5055543D31002E494E5055545F434C4153533D6D6F757365005345514E554D3D3134393231002E484156455F485744425F50524F504552544945533D31004D414A4F523D313300414354494F4E3D6164640049445F53455249414C3D6E6F73657269616C004445564E414D453D2F6465762F696E7075742F6576656E743900555345435F494E495449414C495A45443D31373337373733353034373139320049445F5655494E5055543D310049445F534541543D736561745F7675696E7075740053554253595354454D3D696E70757400
// dGVzdD12YWx1ZQBDVVJSRU5UX1RBR1M9OnNlYXRfdnVpbnB1dDoAVEFHUz06c2VhdF92dWlucHV0OgBERVZQQVRIPS9kZXZpY2VzL3ZpcnR1YWwvaW5wdXQvaW5wdXQxMzgvZXZlbnQ5AElEX1ZVSU5QVVRfTU9VU0U9MQBNSU5PUj03MwBJRF9JTlBVVD0xAC5JTlBVVF9DTEFTUz1tb3VzZQBTRVFOVU09MTQ5MjEALkhBVkVfSFdEQl9QUk9QRVJUSUVTPTEATUFKT1I9MTMAQUNUSU9OPWFkZABJRF9TRVJJQUw9bm9zZXJpYWwAREVWTkFNRT0vZGV2L2lucHV0L2V2ZW50OQBVU0VDX0lOSVRJQUxJWkVEPTE3Mzc3NzM1MDQ3MTkyAElEX1ZVSU5QVVQ9MQBJRF9TRUFUPXNlYXRfdnVpbnB1dABTVUJTWVNURU09aW5wdXQA
/*
UDEV [16427452.069342] add /devices/virtual/input/input97 (input)
ACTION=add
@ -211,4 +210,4 @@ TAGS=:seat_vuinput:power-switch:
CURRENT_TAGS=:seat_vuinput:power-switch:
*/
*/

View file

@ -6,13 +6,16 @@ use std::fs::{self, File};
use std::io::{self, Write};
use std::path::Path;
use log::{info, warn};
/// Ensure required udev directories and files exist
pub fn ensure_udev_structure() -> io::Result<()> {
// TODO: this _must_ exist, before a service using libinput is run. The time of device creation might be too late
// Note that this structure _must_ exist, before a service using libinput is run. The time of device creation might be too late.
let data_dir = Path::new("/run/udev/data");
let control_file = Path::new("/run/udev/control");
let data_dir = format!("/run/udev/data");
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`
if !data_dir.exists() {
@ -21,6 +24,11 @@ pub fn ensure_udev_structure() -> io::Result<()> {
// Ensure /run/udev/control exists, create empty if not
if !control_file.exists() {
warn!(
"VUI-UDEV-001 — /run/udev/control/ not available. Keyboard or mouse might be unusable."
);
warn!("Visit https://github.com/joleuger/vuinputd/blob/main/docs/TROUBLESHOOTING.md for details");
info!("Creating file /run/udev/control anyway for subsequent runs.");
File::create(control_file)?;
}
@ -36,7 +44,7 @@ pub fn ensure_udev_structure() -> io::Result<()> {
/// - remove all lines containing `seat_` references (G:, Q: lines)
/// - replace ID_VUINPUT_* with ID_INPUT_*
/// - write updated content to `/run/udev/data/c<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();
for line in content.lines() {
@ -53,9 +61,8 @@ pub fn write_udev_data(content: &str, major: u64, minor: u64) -> io::Result<()>
cleaned.push_str(&line);
cleaned.push('\n');
}
let path = format!("/run/udev/data/c{}:{}", major, minor);
let path = format!("{}/udev/data/c{}:{}", path_prefix, major, minor);
let mut file = File::create(&path)?;
file.write_all(cleaned.as_bytes())?;
@ -64,9 +71,8 @@ pub fn write_udev_data(content: &str, major: u64, minor: u64) -> io::Result<()>
/// Delete udev data for a given major/minor number
/// - `major`, `minor` = device numbers
pub fn delete_udev_data(major: u64, minor: u64) -> io::Result<()> {
let path = format!("/run/udev/data/c{}:{}", major, minor);
pub fn delete_udev_data(path_prefix: &str, major: u64, minor: u64) -> io::Result<()> {
let path = format!("{}/udev/data/c{}:{}", path_prefix, major, minor);
fs::remove_file(&path)?;
Ok(())
}
@ -78,7 +84,6 @@ pub fn read_udev_data(major: u64, minor: u64) -> io::Result<String> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_replacement_and_filter() {

View file

@ -0,0 +1,94 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::future::Future;
use std::pin::Pin;
use super::job::{Job, JobTarget};
pub struct ClosureJob {
desc: String,
execute_after_cancellation: bool,
target: JobTarget,
task_creator: Box<dyn Fn(&ClosureJob) -> Pin<Box<dyn Future<Output = ()>>> + Send + 'static>,
}
impl ClosureJob {
pub fn new(
desc: impl Into<String>,
target: JobTarget,
execute_after_cancellation: bool,
f: Box<
dyn Fn(&ClosureJob) -> Pin<Box<dyn Future<Output = ()>>> // closure returns any future
+ Send // the closure itself can be sent across threads
+ 'static,
>,
) -> Self
where {
Self {
desc: desc.into(),
execute_after_cancellation,
target,
task_creator: f,
}
}
}
impl Job for ClosureJob {
fn desc(&self) -> &str {
&self.desc
}
fn execute_after_cancellation(&self) -> bool {
self.execute_after_cancellation
}
fn create_task(self: &ClosureJob) -> Pin<Box<dyn Future<Output = ()>>> {
let creator = &self.task_creator;
let task = creator(self);
task
}
fn job_target(&self) -> JobTarget {
self.target.clone()
}
}
#[cfg(test)]
mod tests {
use super::super::job::{Dispatcher, JobTarget};
use super::ClosureJob;
/// Example usage
#[test]
pub fn example() {
let mut dispatcher = Dispatcher::new();
// Send a Host job
dispatcher.dispatch(Box::new(ClosureJob::new(
"Host maintenance",
JobTarget::Host,
false,
Box::new(|job: &ClosureJob| {
let target = job.target.clone();
Box::pin(async move {
println!("Running host job on {:?}", target);
})
}),
)));
// Sending a Container job works the same
// dispatcher.dispatch(Job::new(JobTarget::Container(ns.clone()), "Container task", false, |target| async move {
// println!("Running container job for {:?}", target);
// }));
//
// JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(ClosureJob::new("Monitor udev events", JobTarget::BackgroundLoop,false,
// Box::new(move |_target| Box::pin(monitor_udev::udev_monitor_loop(cancel_token.clone()))))));
// Allow loops to run briefly before dropping all senders -> graceful shutdown
dispatcher.close();
dispatcher.wait_until_finished();
}
}

View file

@ -11,7 +11,7 @@ use std::sync::Mutex;
use std::thread::{self, JoinHandle};
use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc};
use crate::requesting_process::RequestingProcess;
use crate::process_tools::RequestingProcess;
// To discuss:
// what we handle here, could also be named Task. The decision for job was more or less

View file

@ -0,0 +1,44 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
//! # Design: Async Per-Container Job Executor (Tokio)
//!
//! ## Overview
//! A scalable, structured design for running async jobs per container.
//!
//! - Global dispatcher routes jobs to per-container async loops or to global queue.
//! - Each container has its own unbounded job queue (no backpressure).
//! - Loops are spawned lazily on first job and exit when their sender drops.
//! - Graceful shutdown happens automatically (channel close → loop exit).
//! - Periodic cleanup removes idle container queues.
//!
//! ## Async Jobs
//! - Each `Job` contains an async closure `task: Box<dyn FnOnce(JobTarget) -> Pin<Box<dyn Future<Output = ()>>> + Send>`
//! - This allows full async/await usage inside the job body.
//!
//!
//! +--------------------------------------+
//! | Global dispatcher |
//! +----------+---------------------------+
//! | |
//! v v
//! +----------+-----------+ +------------+
//! | Per-container queues | | Host queue |
//! +----+------+----+-----+ +------------+
//! | | |
//! +----v----+ +---v----+ +---v----+
//! | Cont A | | Cont B | | Cont C |
//! | loop() | | loop() | | loop() |
//! +---------+ +--------+ +--------+
use std::sync::{Mutex, OnceLock};
use crate::job_engine::job::Dispatcher;
pub mod closure_job;
pub mod job;
pub static JOB_DISPATCHER: OnceLock<Mutex<Dispatcher>> = OnceLock::new();
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,365 @@
use crate::job_engine::closure_job::ClosureJob;
use crate::job_engine::job::{Dispatcher, JobTarget};
use super::*;
use futures::executor::LocalPool;
use futures::task::LocalSpawnExt;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
/// Simple shared integer counter
fn shared_counter() -> Arc<Mutex<i32>> {
Arc::new(Mutex::new(0))
}
//
// 1. Ordering test
//
#[test]
fn test_job_ordering() {
let mut dispatcher = Dispatcher::new();
let c = shared_counter();
let c1 = c.clone();
dispatcher.dispatch(Box::new(ClosureJob::new(
"set to 5",
JobTarget::Host,
false,
Box::new(move |_job| {
let c1 = c1.clone();
Box::pin(async move {
*c1.lock().unwrap() = 5;
})
}),
)));
// job 2: increment to 6
let c2 = c.clone();
dispatcher.dispatch(Box::new(ClosureJob::new(
"increment to 6",
JobTarget::Host,
false,
Box::new(move |_job| {
let c2 = c2.clone();
Box::pin(async move {
*c2.lock().unwrap() += 1;
})
}),
)));
dispatcher.close();
dispatcher.wait_until_finished();
assert_eq!(*c.lock().unwrap(), 6);
}
/*
//
// 2. Cancellation test (default: do NOT run cancelled job)
//
#[test]
fn test_cancellation_stops_jobs() {
let (mut dispatcher, mut pool) = test_dispatcher();
let c = shared_counter();
// First job runs
let c1 = c.clone();
dispatcher.queue(Job::new(move |_ctx| {
*c1.borrow_mut() += 1;
Poll::Ready(())
}));
// Second job should NOT run (will be cancelled)
let c2 = c.clone();
dispatcher.queue(Job::with_flags(
move |_ctx| {
*c2.borrow_mut() += 1;
Poll::Ready(())
},
JobFlags::default(), // execute_after_cancellation = false
));
// Immediately close before job #2 runs
dispatcher.close();
run_pool_to_completion(&mut pool);
assert_eq!(*c.borrow(), 1);
}
//
// 3. Cancellation *with* execute_after_cancellation
//
#[test]
fn test_cancellation_runs_cleanup_jobs() {
let (mut dispatcher, mut pool) = test_dispatcher();
let c = shared_counter();
// First job runs
let c1 = c.clone();
dispatcher.queue(Job::new(move |_ctx| {
*c1.borrow_mut() += 1;
Poll::Ready(())
}));
// Cleanup job must run even after cancellation
let c2 = c.clone();
dispatcher.queue(Job::with_flags(
move |_ctx| {
*c2.borrow_mut() += 10;
Poll::Ready(())
},
JobFlags {
execute_after_cancellation: true,
},
));
dispatcher.close();
run_pool_to_completion(&mut pool);
assert_eq!(*c.borrow(), 11);
}
//
// 4. SIGTERM-like shutdown behaviour
//
#[test]
fn test_shutdown_wait_until_finished() {
let (mut dispatcher, mut pool) = test_dispatcher();
let c = shared_counter();
// Simulate some work
let c1 = c.clone();
dispatcher.queue(Job::new(move |_ctx| {
*c1.borrow_mut() += 1;
Poll::Ready(())
}));
dispatcher.close();
run_pool_to_completion(&mut pool);
dispatcher.wait_until_finished();
assert_eq!(*c.borrow(), 1);
}
//
// 5. Failure propagation
//
#[test]
fn test_job_failure_does_not_crash_dispatcher() {
let (mut dispatcher, mut pool) = test_dispatcher();
let c = shared_counter();
dispatcher.queue(Job::new(|_ctx| {
panic!("intentional test panic");
#[allow(unreachable_code)]
Poll::Ready(())
}));
// Following job should still run if the system recovers
let c2 = c.clone();
dispatcher.queue(Job::new(move |_ctx| {
*c2.borrow_mut() += 1;
Poll::Ready(())
}));
dispatcher.close();
// Some designs need catch_unwind here; yours might not.
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run_pool_to_completion(&mut pool);
}));
assert_eq!(*c.borrow(), 1);
}
//
// 6. Stress: many short jobs
//
#[test]
fn test_many_small_jobs() {
let (mut dispatcher, mut pool) = test_dispatcher();
let count = shared_counter();
for _ in 0..200 {
let c = count.clone();
dispatcher.queue(Job::new(move |_ctx| {
*c.borrow_mut() += 1;
Poll::Ready(())
}));
}
dispatcher.close();
run_pool_to_completion(&mut pool);
assert_eq!(*count.borrow(), 200);
}
//
// 7. Ensure no jobs run after final close
//
#[test]
fn test_no_jobs_after_close() {
let (mut dispatcher, mut pool) = test_dispatcher();
dispatcher.close();
let executed = shared_counter();
let e = executed.clone();
dispatcher.queue(Job::new(move |_ctx| {
*e.borrow_mut() += 1;
Poll::Ready(())
}));
// Dispatcher should ignore queued jobs after close()
run_pool_to_completion(&mut pool);
assert_eq!(*executed.borrow(), 0);
}
#[test]
fn test_stress_light_multi_target() {
use std::sync::{Arc, Mutex};
use crate::jobs::{Dispatcher, JobTarget};
use crate::jobs::closure_job::ClosureJob;
// Shared result buffer
let results = Arc::new(Mutex::new(Vec::new()));
let mut dispatcher = Dispatcher::new();
// Create a few job targets
let target_a = JobTarget::Container("A".into());
let target_b = JobTarget::Container("B".into());
let host = JobTarget::Host;
// Create 10 jobs per target
for i in 0..10 {
let results_a = results.clone();
dispatcher.dispatch(Box::new(ClosureJob::new(
format!("A-{i}"),
target_a.clone(),
false,
Box::new(move |_t| {
Box::pin(async move {
results_a.lock().unwrap().push(format!("A-{i}"));
})
}),
)));
let results_b = results.clone();
dispatcher.dispatch(Box::new(ClosureJob::new(
format!("B-{i}"),
target_b.clone(),
false,
Box::new(move |_t| {
Box::pin(async move {
results_b.lock().unwrap().push(format!("B-{i}"));
})
}),
)));
let results_h = results.clone();
dispatcher.dispatch(Box::new(ClosureJob::new(
format!("H-{i}"),
host.clone(),
false,
Box::new(move |_t| {
Box::pin(async move {
results_h.lock().unwrap().push(format!("H-{i}"));
})
}),
)));
}
dispatcher.close();
dispatcher.wait_until_finished();
let buf = results.lock().unwrap();
// Each target must preserve *its* order:
assert_eq!(
buf.iter().filter(|s| s.starts_with("A-")).cloned().collect::<Vec<_>>(),
(0..10).map(|i| format!("A-{i}")).collect::<Vec<_>>()
);
assert_eq!(
buf.iter().filter(|s| s.starts_with("B-")).cloned().collect::<Vec<_>>(),
(0..10).map(|i| format!("B-{i}")).collect::<Vec<_>>()
);
assert_eq!(
buf.iter().filter(|s| s.starts_with("H-")).cloned().collect::<Vec<_>>(),
(0..10).map(|i| format!("H-{i}")).collect::<Vec<_>>()
);
// But between targets, the order may be interleaved (expected)
// So we don't assert cross-order.
}
#[test]
fn test_cleanup_when_target_disappears() {
use std::sync::{Arc, Mutex};
use crate::jobs::{Dispatcher, JobTarget};
use crate::jobs::closure_job::ClosureJob;
let results = Arc::new(Mutex::new(Vec::new()));
let mut dispatcher = Dispatcher::new();
let target_dead = JobTarget::Container("dead".into());
let target_alive = JobTarget::Container("alive".into());
// Two jobs for dead target: one normal, one allowed after cancel
{
let r = results.clone();
dispatcher.dispatch(Box::new(ClosureJob::new(
"dead-normal",
target_dead.clone(),
false,
Box::new(move |_| Box::pin(async move {
r.lock().unwrap().push("dead-normal".into());
}))
)));
let r = results.clone();
dispatcher.dispatch(Box::new(ClosureJob::new(
"dead-cleanup",
target_dead.clone(),
true, // allowed after cancellation
Box::new(move |_| Box::pin(async move {
r.lock().unwrap().push("dead-cleanup".into());
}))
)));
}
// One job for a live target
{
let r = results.clone();
dispatcher.dispatch(Box::new(ClosureJob::new(
"alive-job",
target_alive.clone(),
false,
Box::new(move |_| Box::pin(async move {
r.lock().unwrap().push("alive-job".into());
}))
)));
}
// Simulate container removal
dispatcher.target_gone(&target_dead);
dispatcher.close();
dispatcher.wait_until_finished();
let buf = results.lock().unwrap();
// Should NOT run:
assert!(!buf.contains(&"dead-normal".into()));
// Should run because execute_after_cancellation = true
assert!(buf.contains(&"dead-cleanup".into()));
// Should run normally
assert!(buf.contains(&"alive-job".into()));
}
*/

View file

@ -1,89 +0,0 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::future::Future;
use std::pin::Pin;
use crate::jobs::job::{Dispatcher, Job, JobTarget};
pub struct ClosureJob {
desc: String,
execute_after_cancellation: bool,
target: JobTarget,
task_creator: Box<dyn Fn(&ClosureJob) -> Pin<Box<dyn Future<Output = ()>>> + Send + 'static>,
}
impl ClosureJob {
pub fn new(
desc: impl Into<String>,
target: JobTarget,
execute_after_cancellation: bool,
f: Box<
dyn Fn(&ClosureJob) -> Pin<Box<dyn Future<Output = ()>>> // closure returns any future
+ Send // the closure itself can be sent across threads
+ 'static,
>,
) -> Self
where {
Self {
desc: desc.into(),
execute_after_cancellation,
target,
task_creator: f,
}
}
}
impl Job for ClosureJob {
fn desc(&self) -> &str {
&self.desc
}
fn execute_after_cancellation(&self) -> bool {
self.execute_after_cancellation
}
fn create_task(self: &ClosureJob) -> Pin<Box<dyn Future<Output = ()>>> {
let creator = &self.task_creator;
let task = creator(self);
task
}
fn job_target(&self) -> JobTarget {
self.target.clone()
}
}
/// Example usage
#[allow(dead_code)]
pub fn example() {
let mut dispatcher = Dispatcher::new();
// Send a Host job
dispatcher.dispatch(Box::new(ClosureJob::new(
"Host maintenance",
JobTarget::Host,
false,
Box::new(|job: &ClosureJob| {
let target = job.target.clone();
Box::pin(async move {
println!("Running host job on {:?}", target);
})
}),
)));
// Sending a Container job works the same
// dispatcher.dispatch(Job::new(JobTarget::Container(ns.clone()), "Container task", false, |target| async move {
// println!("Running container job for {:?}", target);
// }));
//
// JOB_DISPATCHER.get().unwrap().lock().unwrap().dispatch(Box::new(ClosureJob::new("Monitor udev events", JobTarget::BackgroundLoop,false,
// Box::new(move |_target| Box::pin(monitor_udev::udev_monitor_loop(cancel_token.clone()))))));
// Allow loops to run briefly before dropping all senders -> graceful shutdown
dispatcher.close();
dispatcher.wait_until_finished();
}

View file

@ -2,41 +2,58 @@
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::{collections::HashMap, future::Future, pin::Pin, sync::{Arc, Condvar, Mutex}, time::Duration};
use std::{
collections::HashMap,
future::Future,
pin::Pin,
sync::{Arc, Condvar, Mutex},
time::Duration,
};
use async_io::Timer;
use log::debug;
use crate::{
container::{mknod_input_device::ensure_input_device, netlink_message::send_udev_monitor_message_with_properties, runtime_data::{self, ensure_udev_structure, read_udev_data, write_udev_data}}, jobs::job::{Job, JobTarget}, monitor_udev::EVENT_STORE, requesting_process::{Pid, RequestingProcess, await_process, run_in_net_and_mnt_namespace}
actions::action::Action,
global_config::get_container_runtime,
input_realizer::runtime_data,
job_engine::job::{Job, JobTarget},
jobs::monitor_udev_job::EVENT_STORE,
process_tools::{self, await_process, Pid, RequestingProcess},
};
#[derive(Clone,Debug,Copy,PartialOrd,PartialEq)]
#[derive(Clone, Debug, Copy, PartialOrd, PartialEq)]
pub enum State {
Initialized,
Started,
Finished,
}
#[derive(Clone,Debug)]
pub struct InjectInContainerJob {
#[derive(Clone, Debug)]
pub struct EmitUdevEventJob {
requesting_process: RequestingProcess,
target: JobTarget,
dev_path: String,
sys_path: String,
major: u64,
minor: u64,
sync_state: Arc<(Mutex<State>,Condvar)>,
sync_state: Arc<(Mutex<State>, Condvar)>,
}
impl InjectInContainerJob {
pub fn new(requesting_process: RequestingProcess,dev_path: String, sys_path: String, major: u64, minor: u64) -> Self {
impl EmitUdevEventJob {
pub fn new(
requesting_process: RequestingProcess,
dev_path: String,
sys_path: String,
major: u64,
minor: u64,
) -> Self {
Self {
requesting_process: requesting_process.clone(),
target: JobTarget::Container(requesting_process),
dev_path: dev_path,
sys_path: sys_path,
major: major ,
major: major,
minor: minor,
sync_state: Arc::new((Mutex::new(State::Initialized), Condvar::new())),
}
@ -53,10 +70,10 @@ impl InjectInContainerJob {
pub fn get_awaiter_for_state(&self) -> impl FnOnce(&State) -> () {
// pattern is described on https://doc.rust-lang.org/stable/std/sync/struct.Condvar.html
let sync_state = self.sync_state.clone();
let awaiter = move | state: &State| {
let awaiter = move |state: &State| {
let (lock, cvar) = &*sync_state;
let mut current_state = lock.lock().unwrap();
while *current_state < *state {
while *current_state < *state {
current_state = cvar.wait(current_state).unwrap();
}
};
@ -64,17 +81,17 @@ impl InjectInContainerJob {
}
}
impl Job for InjectInContainerJob {
impl Job for EmitUdevEventJob {
fn desc(&self) -> &str {
"Inject input device into container"
"emit udev event"
}
fn execute_after_cancellation(&self) -> bool {
false
}
fn create_task(self: &InjectInContainerJob) -> Pin<Box<dyn Future<Output = ()>>> {
Box::pin(self.clone().inject_in_container())
fn create_task(self: &EmitUdevEventJob) -> Pin<Box<dyn Future<Output = ()>>> {
Box::pin(self.clone().emit_udev_event())
}
fn job_target(&self) -> JobTarget {
@ -82,37 +99,40 @@ impl Job for InjectInContainerJob {
}
}
impl InjectInContainerJob {
async fn inject_in_container(self) {
impl EmitUdevEventJob {
async fn emit_udev_event(self) {
// temporary hack that needs to be replaced. We try 50 times
// Should be: Wait for the device to be created, the runtime data to be written and the
// netlink message to be sent
self.set_state(&State::Started);
let mut netlink_data: Option<HashMap<String,String>> = None;
let mut netlink_data: Option<HashMap<String, String>> = None;
let mut runtime_data: Option<String> = None;
let mut number_of_attempt = 1;
while number_of_attempt<=50 && !(netlink_data.is_some() && runtime_data.is_some()) {
while number_of_attempt <= 50 && !(netlink_data.is_some() && runtime_data.is_some()) {
if netlink_data.is_none() {
if let Some(netlink_event)=EVENT_STORE.get().unwrap().lock().unwrap().take(&self.sys_path) {
if let Some(netlink_event) = EVENT_STORE
.get()
.unwrap()
.lock()
.unwrap()
.take(&self.sys_path)
{
if netlink_event.tombstone || netlink_event.remove_data.is_some() {
debug!("do nothing, because the device has already been removed in the meantime");
return;
}
netlink_data=netlink_event.add_data;
netlink_data = netlink_event.add_data;
};
}
if runtime_data.is_none() {
runtime_data = read_udev_data(self.major,self.minor).ok();
runtime_data = runtime_data::read_udev_data(self.major, self.minor).ok();
}
number_of_attempt+=1;
number_of_attempt += 1;
// wait a maximum of 5 seconds == 50 attempts
Timer::after(Duration::from_millis(100)).await;
}
if (netlink_data.is_none() || runtime_data.is_none()) {
}
if netlink_data.is_none() || runtime_data.is_none() {
if netlink_data.is_none() {
debug!("Give up reading netlink data");
}
@ -123,29 +143,26 @@ impl InjectInContainerJob {
return;
}
// define for capturing
let major = self.major;
let minor=self.minor;
let runtime_data = runtime_data.unwrap();
let netlink_data = netlink_data.unwrap();
let dev_path = self.dev_path.clone();
let injector = get_container_runtime().injection_strategy();
let child_pid = run_in_net_and_mnt_namespace(&self.requesting_process, Box::new(move || {
injector
.write_udev_runtime_data(
&self.requesting_process,
&runtime_data,
self.major,
self.minor,
)
.await
.unwrap();
if let Err(e) = ensure_input_device(dev_path.clone(), self.major, self.minor) {
debug!("Error creating input device {}: {e}",dev_path.clone());
};
ensure_udev_structure().unwrap();
if let Err(e) = write_udev_data(runtime_data.as_str(), major, minor) {
debug!("Error writing udev data for device {}: {e}",dev_path.clone());
};
send_udev_monitor_message_with_properties(netlink_data.clone());
injector
.emit_netlink_message(&self.requesting_process, netlink_data)
.await
.unwrap();
}))
.expect("subprocess should work");
let _exit_info = await_process(Pid::Pid(child_pid.as_raw())).await.unwrap();
self.set_state(&State::Finished);
}
}

View file

@ -0,0 +1,112 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::{
future::Future,
pin::Pin,
sync::{Arc, Condvar, Mutex},
};
use crate::{
actions::action::Action,
global_config::{self, get_container_runtime, Placement},
input_realizer::input_device,
job_engine::job::{Job, JobTarget},
process_tools::{self, await_process, Pid, RequestingProcess},
};
#[derive(Clone, Debug, Copy, PartialOrd, PartialEq)]
pub enum State {
Initialized,
Started,
Finished,
}
#[derive(Clone, Debug)]
pub struct MknodDeviceJob {
requesting_process: RequestingProcess,
target: JobTarget,
devname: String,
sys_path: String,
major: u64,
minor: u64,
sync_state: Arc<(Mutex<State>, Condvar)>,
}
impl MknodDeviceJob {
pub fn new(
requesting_process: RequestingProcess,
devname: String,
sys_path: String,
major: u64,
minor: u64,
) -> Self {
Self {
requesting_process: requesting_process.clone(),
target: JobTarget::Container(requesting_process),
devname: devname,
sys_path: sys_path,
major: major,
minor: minor,
sync_state: Arc::new((Mutex::new(State::Initialized), Condvar::new())),
}
}
fn set_state(&self, new_state: &State) -> () {
let (lock, cvar) = &*self.sync_state;
let mut current_state = lock.lock().unwrap();
*current_state = *new_state;
// We notify the condvar that the value has changed.
cvar.notify_all();
}
pub fn get_awaiter_for_state(&self) -> impl FnOnce(&State) -> () {
// pattern is described on https://doc.rust-lang.org/stable/std/sync/struct.Condvar.html
let sync_state = self.sync_state.clone();
let awaiter = move |state: &State| {
let (lock, cvar) = &*sync_state;
let mut current_state = lock.lock().unwrap();
while *current_state < *state {
current_state = cvar.wait(current_state).unwrap();
}
};
awaiter
}
}
impl Job for MknodDeviceJob {
fn desc(&self) -> &str {
"mknod input device"
}
fn execute_after_cancellation(&self) -> bool {
false
}
fn create_task(self: &MknodDeviceJob) -> Pin<Box<dyn Future<Output = ()>>> {
Box::pin(self.clone().mknod_device())
}
fn job_target(&self) -> JobTarget {
self.target.clone()
}
}
impl MknodDeviceJob {
async fn mknod_device(self) {
let injector = get_container_runtime().injection_strategy();
injector
.mknod_device_node(
&self.requesting_process,
&self.devname,
self.major,
self.minor,
)
.await
.unwrap();
self.set_state(&State::Finished);
}
}

View file

@ -1,38 +1,8 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
//! # Design: Async Per-Container Job Executor (Tokio)
//!
//! ## Overview
//! A scalable, structured design for running async jobs per container.
//!
//! - Global dispatcher routes jobs to per-container async loops or to global queue.
//! - Each container has its own unbounded job queue (no backpressure).
//! - Loops are spawned lazily on first job and exit when their sender drops.
//! - Graceful shutdown happens automatically (channel close → loop exit).
//! - Periodic cleanup removes idle container queues.
//!
//! ## Async Jobs
//! - Each `Job` contains an async closure `task: Box<dyn FnOnce(JobTarget) -> Pin<Box<dyn Future<Output = ()>>> + Send>`
//! - This allows full async/await usage inside the job body.
//!
//!
//! +--------------------------------------+
//! | Global dispatcher |
//! +----------+---------------------------+
//! | |
//! v v
//! +----------+-----------+ +------------+
//! | Per-container queues | | Host queue |
//! +----+------+----+-----+ +------------+
//! | | |
//! +----v----+ +---v----+ +---v----+
//! | Cont A | | Cont B | | Cont C |
//! | loop() | | loop() | | loop() |
//! +---------+ +--------+ +--------+
pub mod closure_job;
pub mod job;
#[cfg(test)]
mod tests;
pub mod emit_udev_event_job;
pub mod mknod_device_job;
pub mod monitor_udev_job;
pub mod remove_device_job;

View file

@ -19,7 +19,7 @@ use libudev::Monitor;
use log::debug;
use regex::Regex;
use crate::jobs::job::{Job, JobTarget};
use crate::job_engine::job::{Job, JobTarget};
// === Basic types ===
@ -129,12 +129,10 @@ impl EventStore {
pub static EVENT_STORE: OnceLock<Arc<Mutex<EventStore>>> = OnceLock::new();
pub struct MonitorBackgroundLoop {
}
pub struct MonitorBackgroundLoop {}
impl MonitorBackgroundLoop {
pub fn new() -> Self {
Self {
}
Self {}
}
}
@ -208,16 +206,14 @@ pub async fn udev_monitor_loop(cancel_token: Arc<AtomicBool>) {
let key = match key.as_str() {
"ID_VUINPUT_KEYBOARD" => "ID_INPUT_KEYBOARD".to_string(),
"ID_VUINPUT_MOUSE" => "ID_INPUT_MOUSE".to_string(),
_ => key
_ => key,
};
let value: String = property.value().to_str().unwrap().to_string();
if key!="ID_SEAT" {
if key != "ID_SEAT" {
properties.insert(key, value);
}
}
let value_of_devpath = properties.get("DEVPATH").unwrap();

View file

@ -0,0 +1,152 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::{
future::Future,
pin::Pin,
sync::{Arc, Condvar, Mutex},
};
use log::debug;
use crate::{
actions::action::Action,
global_config::{self, get_container_runtime, Placement},
input_realizer::{input_device, runtime_data},
job_engine::job::{Job, JobTarget},
jobs::monitor_udev_job::EVENT_STORE,
process_tools::{self, await_process, Pid, RequestingProcess},
};
#[derive(Clone, Debug, Copy, PartialOrd, PartialEq)]
pub enum State {
Initialized,
Started,
Finished,
}
#[derive(Clone, Debug)]
pub struct RemoveDeviceJob {
requesting_process: RequestingProcess,
target: JobTarget,
dev_name: String,
sys_path: String,
major: u64,
minor: u64,
sync_state: Arc<(Mutex<State>, Condvar)>,
}
impl RemoveDeviceJob {
pub fn new(
requesting_process: RequestingProcess,
dev_name: String,
sys_path: String,
major: u64,
minor: u64,
) -> Self {
Self {
requesting_process: requesting_process.clone(),
target: JobTarget::Container(requesting_process),
dev_name: dev_name,
sys_path: sys_path,
major: major,
minor: minor,
sync_state: Arc::new((Mutex::new(State::Initialized), Condvar::new())),
}
}
fn set_state(&self, new_state: &State) -> () {
let (lock, cvar) = &*self.sync_state;
let mut current_state = lock.lock().unwrap();
*current_state = *new_state;
// We notify the condvar that the value has changed.
cvar.notify_all();
}
pub fn get_awaiter_for_state(&self) -> impl FnOnce(&State) -> () {
// pattern is described on https://doc.rust-lang.org/stable/std/sync/struct.Condvar.html
let sync_state = self.sync_state.clone();
let awaiter = move |state: &State| {
let (lock, cvar) = &*sync_state;
let mut current_state = lock.lock().unwrap();
while *current_state < *state {
current_state = cvar.wait(current_state).unwrap();
}
};
awaiter
}
}
impl Job for RemoveDeviceJob {
fn desc(&self) -> &str {
"Remove input device"
}
fn execute_after_cancellation(&self) -> bool {
false
}
fn create_task(self: &RemoveDeviceJob) -> Pin<Box<dyn Future<Output = ()>>> {
Box::pin(self.clone().remove_device())
}
fn job_target(&self) -> JobTarget {
self.target.clone()
}
}
impl RemoveDeviceJob {
async fn remove_device(self) {
self.set_state(&State::Started);
let netlink_event = match EVENT_STORE
.get()
.unwrap()
.lock()
.unwrap()
.take(&self.sys_path)
{
Some(netlink_event) => netlink_event,
None => {
debug!("do nothing, because the device has never been announced via netlink");
self.set_state(&State::Finished);
return;
}
};
if netlink_event.tombstone {
debug!("do nothing, because the device has already been removed in the meantime");
self.set_state(&State::Finished);
return;
}
let netlink_data = netlink_event.add_data;
let mut netlink_data = netlink_data.unwrap().clone();
let _ = netlink_data.insert("ACTION".to_string(), "remove".to_string());
let injector = get_container_runtime().injection_strategy();
injector
.remove_device_node(
&self.requesting_process,
&self.dev_name,
self.major,
self.minor,
)
.await
.unwrap();
injector
.remove_udev_runtime_data(&self.requesting_process, self.major, self.minor)
.await
.unwrap();
injector
.emit_netlink_message(&self.requesting_process, netlink_data)
.await
.unwrap();
self.set_state(&State::Finished);
}
}

View file

@ -1,55 +0,0 @@
use crate::jobs::closure_job::ClosureJob;
use crate::jobs::job::{Dispatcher, Job, JobTarget};
use super::*;
use futures::executor::LocalPool;
use futures::task::LocalSpawnExt;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
/// Simple shared integer counter
fn shared_counter() -> Arc<Mutex<i32>> {
Arc::new(Mutex::new(0))
}
//
// 1. Ordering test
//
#[test]
fn test_job_ordering() {
let mut dispatcher = Dispatcher::new();
let c = shared_counter();
let c1 = c.clone();
dispatcher.dispatch(Box::new(ClosureJob::new(
"set to 5",
JobTarget::Host,
false,
Box::new(move |_job| {
let c1 = c1.clone();
Box::pin(async move {
*c1.lock().unwrap() = 5;
})
}),
)));
// job 2: increment to 6
let c2 = c.clone();
dispatcher.dispatch(Box::new(ClosureJob::new(
"increment to 6",
JobTarget::Host,
false,
Box::new(move |_job| {
let c2 = c2.clone();
Box::pin(async move {
*c2.lock().unwrap() += 1;
})
}),
)));
dispatcher.wait_until_finished();
assert_eq!(*c.lock().unwrap(), 6);
}

File diff suppressed because it is too large Load diff

View file

@ -3,32 +3,52 @@
// Author: Johannes Leupolz <dev@leupolz.eu>
use async_io::Async;
use base64::prelude::BASE64_STANDARD;
use base64::Engine as _;
use log::debug;
use nix::{
sched::{setns, CloneFlags},
unistd::{fork, ForkResult},
};
use std::{
fs::{self, File}, io::Read, os::fd::{AsFd, FromRawFd, OwnedFd, RawFd}, path::{self, Path}, process, thread, time::Duration
fs::{self, File},
io::Read,
os::{
fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
unix::{fs::MetadataExt, process::CommandExt},
},
path::Path,
process::Command,
sync::OnceLock,
};
use std::io::{self, BufRead};
use std::path::PathBuf;
use anyhow::anyhow;
use std::io;
use crate::{
actions::action::Action,
global_config::{get_device_owner, DeviceOwner},
};
pub mod ns_fscreds;
pub static SELF_NAMESPACES: OnceLock<Namespaces> = OnceLock::new();
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum Pid {
SelfPid,
Pid(i32),
Pid(u32),
}
impl Pid {
pub fn path(&self) -> String {
match self {
Pid::SelfPid => "/proc/self".to_string(),
Pid::Pid(pid_no) => format!("/proc/{}",pid_no)
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)]
@ -43,12 +63,10 @@ pub struct Namespaces {
pub cgroup: Option<u64>,
pub time: Option<u64>,
pub time_for_children: Option<u64>,
}
/// Returns true if the process with `pid` is a 32-bit (compat) process. None, if unsure.
pub fn is_compat_process(pid: Pid) -> Option<bool> {
match pid {
Pid::Pid(pid) => {
const EI_CLASS: usize = 4;
@ -73,16 +91,13 @@ pub fn is_compat_process(pid: Pid) -> Option<bool> {
Err(_) => None,
}
}
Pid::SelfPid =>
unreachable!()
}
}
// TODO: Rename to capture all relevant process information
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct RequestingProcess {
pub nspath: String,
pub nsroot: String,
pub pid_requestor: Pid,
pub pid_requestor_root: Pid,
pub namespaces: Namespaces,
pub is_compat: bool,
}
@ -110,20 +125,37 @@ impl std::fmt::Display for RequestingProcess {
writeln!(f, " uts: {:?}", self.namespaces.uts)?;
writeln!(f, " ipc: {:?}", self.namespaces.ipc)?;
writeln!(f, " pid: {:?}", self.namespaces.pid)?;
writeln!(f, " pid_for_children: {:?}", self.namespaces.pid_for_children)?;
writeln!(
f,
" pid_for_children: {:?}",
self.namespaces.pid_for_children
)?;
writeln!(f, " user: {:?}", self.namespaces.user)?;
writeln!(f, " mnt: {:?}", self.namespaces.mnt)?;
writeln!(f, " cgroup: {:?}", self.namespaces.cgroup)?;
writeln!(f, " time: {:?}", self.namespaces.time)?;
writeln!(f, " time_for_children: {:?}", self.namespaces.time_for_children)?;
writeln!(
f,
" time_for_children: {:?}",
self.namespaces.time_for_children
)?;
Ok(())
}
}
pub fn get_self_namespace() -> Namespaces {
get_namespace_of_pid_or_self(PidOrSelf::SelfPid)
}
pub fn get_namespace(pid: Pid) -> Namespaces {
let pid: String = match pid {
Pid::Pid(pid) => pid.to_string(),
Pid::SelfPid => "self".to_string(),
let Pid::Pid(pid) = pid;
get_namespace_of_pid_or_self(PidOrSelf::Pid(pid))
}
fn get_namespace_of_pid_or_self(pid_or_self: PidOrSelf) -> Namespaces {
let pid: String = match pid_or_self {
PidOrSelf::Pid(pid) => pid.to_string(),
PidOrSelf::SelfPid => "self".to_string(),
};
let nspath = format!("/proc/{}/ns", pid);
@ -166,45 +198,41 @@ pub fn get_namespace(pid: Pid) -> Namespaces {
}
fn get_ppid(pid: Pid) -> Option<Pid> {
let content =
match pid {
Pid::SelfPid => fs::read_to_string(format!("/proc/self/status")).ok()?,
Pid::Pid(pid) => fs::read_to_string(format!("/proc/{}/status", pid)).ok()?
};
let ppid=content
let content = match pid {
Pid::Pid(pid) => fs::read_to_string(format!("/proc/{}/status", pid)).ok()?,
};
let ppid = content
.lines()
.find(|line| line.starts_with("PPid:"))
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|ppid| ppid.parse::<i32>().ok());
.and_then(|ppid| ppid.parse::<u32>().ok());
match ppid {
None => None,
Some(ppid)=> Some(Pid::Pid(ppid))
Some(ppid) => Some(Pid::Pid(ppid)),
}
}
pub fn get_requesting_process(pid: Pid) -> RequestingProcess {
match pid {
Pid::Pid(_) =>
{
Pid::Pid(_) => {
let is_compat = match is_compat_process(pid) {
Some(false) => {
debug!("identified process {} as 64 bit process",pid.path());
debug!("identified process {} as 64 bit process", pid.path());
false
},
}
Some(true) => {
debug!("identified process {} as 32 bit process",pid.path());
debug!("identified process {} as 32 bit process", pid.path());
true
},
}
None => {
debug!("could not identify bitness of process {}. Assume 64 bit process",pid.path());
debug!(
"could not identify bitness of process {}. Assume 64 bit process",
pid.path()
);
false
},
}
};
// go up the parent hierarchy until we find a parent with different namespaces
let mut ppid = pid;
let nsinodes = get_namespace(pid);
@ -212,77 +240,138 @@ pub fn get_requesting_process(pid: Pid) -> RequestingProcess {
let candidate_ppid = get_ppid(ppid);
match candidate_ppid {
None => break,
Some(candidate_ppid) =>
{
Some(candidate_ppid) => {
let ppid_nsinodes = get_namespace(candidate_ppid);
if nsinodes.equal_mnt_and_net(&ppid_nsinodes) {
ppid=candidate_ppid;
ppid = candidate_ppid;
} else {
break;
}
}
}
}
debug!("identified process {} as root of process id {}",ppid.path(),pid.path());
debug!(
"identified process {} as root of process id {}",
ppid.path(),
pid.path()
);
let nspath = format!("{}/ns", pid.path());
let nsroot = format!("{}/ns", ppid.path());
RequestingProcess {
nspath: nspath,
nsroot: nsroot,
pid_requestor: pid,
pid_requestor_root: ppid,
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.
/// Returns the child PID so the caller can `waitpid` on it.
pub fn run_in_net_and_mnt_namespace(ns: &RequestingProcess, func: Box<dyn Fn()>) -> nix::Result<nix::unistd::Pid> {
//Note: The child process is created with a single thread—the one that called fork().
pub fn start_action(
action: Action,
ns: &RequestingProcess,
enter_user_ns: bool,
) -> anyhow::Result<u32> {
let action_json = serde_json::to_string(&action).unwrap();
print_debug_string(&action_json, &ns);
match unsafe { fork()? } {
ForkResult::Parent { child } => {
// Parent: return the PID of the child
Ok(child)
}
ForkResult::Child => {
debug!("Start new process {}",process::id());
// enter namespace
let path: &Path = Path::new(ns.nsroot.as_str());
debug!("Entering namespaces of process {}. We assume this is the root process of the container.",ns.nsroot.clone());
if !fs::exists(path).unwrap() {
debug!("the root process of the container whose namespaces we want to enter does not exist anymore!");
std::process::exit(0);
}
let net = File::open(ns.nsroot.clone() + "/net").expect("net not found");
let mnt = File::open(ns.nsroot.clone() + "/mnt").expect("mnt not found");
setns(net.as_fd(), CloneFlags::CLONE_NEWNET).expect("couldn't enter net");
setns(mnt.as_fd(), CloneFlags::CLONE_NEWNS).expect("couldn't enter mnt");
// execute your function
func();
std::process::exit(0);
let device_owner = get_device_owner().to_string_rep();
let child = unsafe {
let mut cmd = Command::new("/proc/self/exe");
cmd.args([
"--action",
action_json.as_str(),
"--target-pid",
ns.pid_requestor_root.to_string_rep().as_str(),
"--device-owner",
device_owner.as_str(),
]);
if enter_user_ns {
cmd.arg("--enter-user-namespace");
}
cmd.pre_exec(|| {
// Last resort, if the parent just is killed.
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL);
Ok(())
})
.spawn()
.expect("failed to start vuinputd")
};
Result::Ok(child.id())
}
pub fn run_in_net_and_mnt_namespace(
target_pid: &str,
device_owner: &DeviceOwner,
enter_user_ns: bool,
) -> anyhow::Result<()> {
debug!(
"Entering namespaces of process {}. We assume this is the root process of the container.",
target_pid
);
let fs_uid_gid = if *device_owner == DeviceOwner::ContainerDevFolder {
let pid: u32 = target_pid.trim().parse()?;
let pid = Pid::Pid(pid);
let fs_uid = ns_fscreds::get_uid_in_container(pid, 0)?;
let fs_gid = ns_fscreds::get_gid_in_container(pid, 0)?;
Some((fs_uid, fs_gid))
} else {
None
};
let nspath = format!("/proc/{}/ns", target_pid);
let path: &Path = Path::new(&nspath);
if !fs::exists(path).unwrap() {
return Err(anyhow!("the root process of the container whose namespaces we want to enter does not exist anymore"));
}
let user = File::open(nspath.to_string() + "/user")?;
let net = File::open(nspath.to_string() + "/net")?;
let mnt = File::open(nspath.to_string() + "/mnt")?;
unsafe {
// enter namespaces
if enter_user_ns {
libc::setns(user.as_raw_fd(), libc::CLONE_NEWUSER);
libc::setresgid(0, 0, 0);
libc::setresuid(0, 0, 0);
}
libc::setns(net.as_raw_fd(), libc::CLONE_NEWNET);
libc::setns(mnt.as_raw_fd(), libc::CLONE_NEWNS);
};
if let Some((fs_uid, fs_gid)) = fs_uid_gid {
ns_fscreds::acquire_uid_and_gid(fs_uid, fs_gid)?;
}
anyhow::Ok(())
}
pub async fn await_process(pid: Pid) -> io::Result<i32> {
match pid {
Pid::Pid(pid) =>
{
unsafe {
Pid::Pid(pid) => {
unsafe {
// Use pidfd_open() (libc) to get a real FD
let pidfd = libc::syscall(libc::SYS_pidfd_open, pid, 0);
if pidfd == -1 {
return Err(io::Error::last_os_error())
return Err(io::Error::last_os_error());
}
let owned_fd = OwnedFd::from_raw_fd(pidfd as RawFd);
@ -292,22 +381,25 @@ pub async fn await_process(pid: Pid) -> io::Result<i32> {
// Retrieve the exit code using waitid()
let mut si: libc::siginfo_t = std::mem::zeroed();
let r = libc::waitid(
libc::P_PID,
pid as u32,
&mut si,
libc::WEXITED,
);
let r = libc::waitid(libc::P_PID, pid as u32, &mut si, libc::WEXITED);
if r != 0 {
return Err(io::Error::last_os_error());
}
Ok(si.si_status())
}
},
Pid::SelfPid =>
{
unreachable!();
},
}
}
}
pub fn check_permissions() -> Result<(), std::io::Error> {
let path = Path::new("/proc/self/status");
debug!("Capabilities of vuinputd process:");
fs::read_to_string(path).and_then(|status_file| {
status_file
.lines()
.filter(|line| line.starts_with("Cap"))
.for_each(move |x| debug!("{}", x));
Ok(())
})
}

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

Some files were not shown because too many files have changed in this diff Show more