mirror of
https://github.com/joleuger/vuinputd.git
synced 2026-07-18 00:45:07 +00:00
458 lines
No EOL
19 KiB
Markdown
458 lines
No EOL
19 KiB
Markdown
# Design Document
|
||
|
||
## 1. Introduction
|
||
|
||
This project provides a safe, general-purpose way to run [Sunshine](https://github.com/LizardByte/Sunshine), Steam Input, and other applications that use `/dev/uinput` **inside containers** — including `systemd-nspawn`, Docker, LXC, Podman, and similar runtimes.
|
||
|
||
Applications like Sunshine require creating virtual input devices (`/dev/uinput`) for keyboards, mice, and controllers.
|
||
Naively bind-mounting `/dev/uinput` from the host into a container breaks isolation: a container could create devices visible to other containers or even the host, leading to unwanted input injection and security risks.
|
||
|
||
`vuinputd` introduces a **mediated `/dev/uinput` proxy** that preserves isolation without kernel changes.
|
||
|
||
---
|
||
|
||
## 2. Architecture
|
||
|
||
Normally, applications open `/dev/uinput` directly to create virtual event devices such as `/dev/input/event9`:
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
uinput apps->>uinput (kernel): open /dev/uinput and setup
|
||
create participant eventx
|
||
uinput (kernel)->>eventx: create /dev/input/eventx
|
||
uinput (kernel)->>libinput/game: announce new device via udev
|
||
libinput/game->>eventx: open /dev/input/eventx
|
||
```
|
||
|
||
vuinputd provides a virtual /dev/vuinput implemented via CUSE (Character Device in Userspace).
|
||
This device can be bind-mounted into a container as /dev/uinput, so applications operate normally:
|
||
```mermaid
|
||
|
||
sequenceDiagram
|
||
box transparent Host
|
||
participant uinput (kernel)
|
||
participant udevd
|
||
participant vuinputd
|
||
participant vuinput (host)
|
||
end
|
||
|
||
box transparent Container
|
||
participant uinput (container)
|
||
participant uinput apps
|
||
participant eventX
|
||
participant libinput/game
|
||
end
|
||
|
||
vuinputd->>vuinput (host): create /dev/vuinput with cuse
|
||
uinput apps->>uinput (container): open /dev/uinput and setup
|
||
uinput (container)-->vuinput (host): is equal (bind mount)
|
||
vuinput (host)->>vuinputd: forward data
|
||
vuinputd->>uinput (kernel): forward data
|
||
uinput (kernel)->>eventX: create /dev/input/eventX
|
||
uinput (kernel)->>udevd: announce new device via netlink
|
||
udevd->>vuinputd: announce new device via netlink
|
||
vuinputd->>libinput/game: announce new device via netlink
|
||
libinput/game->>eventX: open /dev/input/eventX
|
||
```
|
||
|
||
`vuinputd` forwards udev events into the container via netlink, because otherwise the game in the container would not recognize when its net namespace is different from the one of udevd.
|
||
|
||
---
|
||
|
||
## 3. Design Decisions
|
||
|
||
This section describes *why* the architecture works, which invariants it relies on, and why the implementation is correct given the constraints of `/dev/uinput`, udev, containers, CUSE, and Rust’s async model.
|
||
|
||
The key idea: **vuinputd linearizes the requests to create or destroy devices from a container**, and replays the linearized device generation actions in the container in a strict order per container.
|
||
|
||
---
|
||
|
||
## **3.1 Source of Truth: The CUSE Device (`/dev/vuinput`)**
|
||
|
||
### **Decision**
|
||
|
||
The only authoritative source of truth about devices is the set of **active clients connected to the CUSE implementation** (`/dev/vuinput`).
|
||
|
||
### **Rationale**
|
||
|
||
Clients intentionally open `/dev/uinput` to create virtual devices.
|
||
Those clients → via the container → bind mount → map to `/dev/vuinput` host-side.
|
||
|
||
Therefore:
|
||
|
||
* **Every relevant device originates from an explicit open() request**
|
||
* Clients *must* communicate the device’s lifetime to vuinputd (via file handle lifetime)
|
||
* The daemon can unambiguously map:
|
||
|
||
* which container the client belongs to (from CUSE file handle metadata)
|
||
* which input devices belong to which client
|
||
* when a client terminates (file descriptor closes)
|
||
|
||
---
|
||
|
||
## **3.2 Event Dispatcher and Job Engine**
|
||
|
||
The system uses a **single-threaded job engine** that processes events sequentially. Job
|
||
This engine runs:
|
||
|
||
* udev events
|
||
* internal jobs (hotplug propagation, cleanup, reconciliation)
|
||
|
||
CUSE events (open, ioctl, write, close) happen in a separate thread and may trigger jobs in the job engine.
|
||
|
||
### **Decision**
|
||
|
||
Use a “logically single-threaded” dispatcher with async/await for I/O.
|
||
|
||
### **Why this is correct**
|
||
|
||
Even though Rust’s async runtime may move tasks between OS threads, the dispatcher ensures:
|
||
|
||
* only **one mutation** happens at a time
|
||
* jobs should be relatively short or have async waiting points (awaits)
|
||
* async I/O does not break ordering, because state mutations always occur inside the central job loop
|
||
|
||
### **Invariants ensured**
|
||
|
||
1. **No two jobs mutate global state concurrently**
|
||
2. **All jobs are serialized**
|
||
3. **Await points do not allow interleaving jobs affecting the same container**
|
||
5. **Awaiting in one container allows progress in another container**
|
||
|
||
This is the core of correctness.
|
||
|
||
### ** Implementation Details on the job engine **
|
||
|
||
This subsection documents the concrete behaviour, invariants and policies implemented by the Dispatcher (the job engine). It is intentionally precise and maps to the code: `src/jobs/*` and `src/jobs/job.rs`.
|
||
|
||
**What the Dispatcher is**
|
||
|
||
* A logically single-threaded job executor that serializes *all* state-mutating work.
|
||
* Implementation lives in `src/jobs/job.rs` (type `Dispatcher`) and `src/jobs/*`.
|
||
|
||
**Core job targets / types**
|
||
|
||
* `JobTarget::Host` — host-global jobs (maintenance, one of jobs).
|
||
* `JobTarget::BackgroundLoop` — long-lived background tasks (udev monitor loop).
|
||
* `JobTarget::Container(RequestingProcess)` — per-container jobs, strictly ordered per container.
|
||
|
||
**Common concrete jobs (examples)**
|
||
|
||
* `InjectInContainerJob` — create device node in container, write udev runtime data, send udev add.
|
||
* `RemoveFromContainerJob` — remove device node, delete runtime data, send udev remove.
|
||
* `MonitorBackgroundLoop` — reads host udev events and populates `EVENT_STORE`.
|
||
|
||
**Ordering guarantees**
|
||
|
||
* Jobs are FIFO per `JobTarget`.
|
||
* BackgroundLoop jobs are spawned independent of per-target queues.
|
||
* The dispatcher may spawn new per-target loops lazily on first job.
|
||
* No two jobs for the same `JobTarget` run concurrently.
|
||
|
||
**Why this matters**
|
||
|
||
* Strict serialization per target prevents device lifecycle races (create/remove) for a given container.
|
||
* Global or host-level jobs are separate so they don't block per-container sequencing.
|
||
|
||
**Code pointers**
|
||
|
||
* Dispatcher implementation: `src/jobs/job.rs` (`Dispatcher`, `get_or_spawn_target_loop`, `job_target_loop`)
|
||
* Per-target job queues: `async_channel::unbounded()`
|
||
* Background loop registration: `JobTarget::BackgroundLoop` special-case spawning
|
||
* Event storage: `src/monitor_udev.rs` (`EVENT_STORE`) — jobs read and consume entries from it
|
||
* Example jobs: `src/container/inject_in_container_job.rs`, `src/container/remove_from_container_job.rs`, `src/monitor_udev.rs` (background)
|
||
|
||
---
|
||
|
||
## **3.3 Combined Queue: Creation, Updates, Cleanup**
|
||
|
||
### **Decision**
|
||
|
||
A *single* queue per container holds all jobs affecting this container.
|
||
|
||
### **Why a single queue is correct**
|
||
|
||
Because events must follow strict causality:
|
||
|
||
* A CUSE open → must be followed by corresponding uinput device creation
|
||
* A cleanup (fd closed) → must be applied before the device is reused
|
||
* forwarding events → must happen after device creation is fully committed
|
||
|
||
Executing the jobs directly would create opportunities for:
|
||
|
||
* unintended interleavings ors reorderings
|
||
* partially applied lifecycle transitions
|
||
* delivering udev signals before device registration
|
||
* cleanup before creation
|
||
* or worse — cleanup of a new device based on stale IDs
|
||
|
||
A single queue avoids all of this.
|
||
|
||
### **Why ordering issues cannot happen**
|
||
|
||
Because:
|
||
|
||
* the dispatcher processes events strictly FIFO
|
||
* each mutation step is atomic
|
||
* every step sees consistent internal state
|
||
* cleanup is just another state transition, not a special phase
|
||
|
||
---
|
||
|
||
## **3.4 Device State Model and Convergence**
|
||
|
||
Every device is represented by a state machine:
|
||
|
||
```
|
||
Nonexistent → Creating → Live → PendingCleanup → Removed
|
||
```
|
||
|
||
The key correctness property:
|
||
|
||
### **Decision**
|
||
|
||
State transitions are monotonic and idempotent.
|
||
|
||
### **Meaning**
|
||
|
||
If the dispatcher sees any event (udev add/remove, client close, reconciliation trigger), it will:
|
||
|
||
1. re-evaluate the device's intended state
|
||
2. re-evaluate the observed state
|
||
3. compute the delta
|
||
4. run the next monotonic transition
|
||
|
||
This ensures:
|
||
|
||
* No transition can "undo" a future one
|
||
* Unexpected udev events (late, missing, reordered) cannot create inconsistencies
|
||
* A device will *eventually* reach correct state regardless of event order
|
||
|
||
### **Why this guarantees correctness**
|
||
|
||
As long as:
|
||
|
||
* intended state is derived solely from CUSE clients
|
||
* observed state is derived from udev
|
||
* transitions are deterministic and monotonic
|
||
|
||
the system **always converges** to the correct overall state.
|
||
This is CRDT-like behaviour (convergent replicated state machine), but simpler.
|
||
|
||
---
|
||
|
||
## **3.5 Integration with Container Runtimes**
|
||
|
||
### **Decision**
|
||
|
||
Every host-created `/dev/input/eventX` is tagged:
|
||
|
||
* `ID_SEAT=seat_vuinput`
|
||
* stripped of `ID_INPUT_KEYBOARD=1`, `ID_INPUT_MOUSE=1`
|
||
* placed into the correct container’s device namespace via bind-mount, cgroup association, or namespace logic
|
||
|
||
### **Rationale**
|
||
|
||
The host must:
|
||
|
||
* see device nodes (kernel requires this)
|
||
* but must *not* consume them
|
||
* while containers must believe they were created locally
|
||
|
||
### **Why tagging is correct**
|
||
|
||
On the host:
|
||
|
||
* libinput ignores devices with no `ID_INPUT_*`
|
||
* tag-based routing ensures correct multi-container isolation
|
||
* per-container udev forwarding ensures applications like SDL/libinput behave normally
|
||
|
||
Inside containers:
|
||
|
||
* the device node exists
|
||
* udev hotplug events are synthesized and delivered
|
||
* container-side libraries operate normally
|
||
|
||
---
|
||
|
||
## **3.6 Cleanups and Race-Free Destruction**
|
||
|
||
### **Decision**
|
||
|
||
Cleanup is triggered by:
|
||
|
||
* CUSE fd closure
|
||
* udev “remove” events
|
||
* container teardown
|
||
* daemon shutdown
|
||
* late reconciliation jobs
|
||
|
||
Cleanup **does not need to be a dedicated final phase** — it is part of normal operation.
|
||
|
||
### **Why no race conditions**
|
||
|
||
Because:
|
||
|
||
1. cleanup is just another serialized job in the dispatcher
|
||
2. cleanup transitions are monotonic (“Live → PendingCleanup → Removed”)
|
||
3. device destruction happens only after:
|
||
|
||
* CUSE client is gone
|
||
* forwarding is complete
|
||
* all references are released
|
||
4. udev “remove” events are treated as hints, not authoritative commands
|
||
|
||
### **Result**
|
||
|
||
No “use after free”, no double cleanup, no ghost devices.
|
||
|
||
---
|
||
|
||
## **3.7 Why This Implementation Is Correct**
|
||
|
||
The design is correct because it satisfies:
|
||
|
||
### **Correctness Criteria**
|
||
|
||
1. **Isolation**
|
||
Containers cannot interfere with each other or with host input.
|
||
|
||
2. **Safety**
|
||
Device lifecycle is serialized, deterministic, and race-free.
|
||
|
||
3. **Eventual Convergence**
|
||
Regardless of event order, the system converges to the correct set of live devices.
|
||
|
||
4. **Compatibility**
|
||
Applications requiring `/dev/uinput` behave identically to native host execution.
|
||
|
||
### **Proof Sketch**
|
||
|
||
Because:
|
||
|
||
* the dispatcher is single-threaded at the logical mutation level
|
||
* intended state is derived from a single authoritative source (CUSE)
|
||
* observed state from udev never overrides intended state
|
||
* transitions are monotonic and idempotent
|
||
* cleanup is serialized and non-destructive to future transitions
|
||
|
||
→ The system behaves like a deterministic state machine and cannot diverge.
|
||
|
||
---
|
||
|
||
## 3.8 Implementation notes on the CUSE front-end (open/write/ioctl/release)`
|
||
|
||
**Summary**
|
||
The CUSE front-end implements `/dev/vuinput` and maps guest operations to host actions. It is *not* a passive pipe; it holds per-handle state and must obey the dispatcher rules: short-running operations in CUSE callbacks (data-plane), heavy or state-mutating operations scheduled as dispatcher jobs (control-plane). The module therefore has two responsibilities:
|
||
|
||
* **Per-handle data plane**: parse/wrap ioctls, translate writes (input_event → uinput), handle legacy vs compat event formats, produce SYN events.
|
||
* **Control-plane dispatching**: schedule `InjectInContainerJob`, `RemoveFromContainerJob` (and other lifecycle jobs) and wait on their completion.
|
||
|
||
**Blocking / IO in callbacks**
|
||
|
||
CUSE callbacks must not perform long-blocking work on the FUSE thread. Long operations (mknod, writing `/run/udev/data`, sending netlink, waiting for container namespace exec) must be executed in jobs dispatched to the Dispatcher. If a callback must wait for job completion, it must use a small wait primitive (condvar) to block only the caller thread for as little as necessary and avoid locking dispatcher mutexes while waiting.
|
||
|
||
*Why:* reduce risk of deadlock and avoid starving other FUSE callbacks.
|
||
|
||
**IOCTL handling policy**
|
||
|
||
Variable-length ioctls are handled by using `fuse_reply_ioctl_retry` to request correct buffer sizing. The callback must validate sizes and respond with `fuse_reply_ioctl_retry` only when necessary, otherwise reply directly. Special-case ioctls (UI_DEV_CREATE, UI_DEV_DESTROY, UI_DEV_SETUP, UI_GET_SYSNAME, UI_GET_VERSION) must be handled on the data plane and schedule control jobs for side effects.
|
||
|
||
**Compat/Alignment & memory-safety**
|
||
|
||
All pointer-to-userdata handling must be done with `read_unaligned` or copying into properly aligned stack locals before creating slices or reinterpreting them. Do not retain pointers into ephemeral stack memory across writes; create owned buffers for any data that must survive until the next syscall.
|
||
|
||
**Error reporting & log dedup**
|
||
|
||
CUSE front-end deduplicates repeating errors (write failures) to reduce log spam but must still emit at least one full diagnostic with device identifiers (filehandle, devnode, major:minor, container). Deduplication should not hide critical first-occurrence context.
|
||
|
||
*Why:* helps debugging without overwhelming logs.
|
||
|
||
**Response semantics**
|
||
|
||
Use the correct FUSE reply: `fuse_reply_open`, `fuse_reply_write`, `fuse_reply_ioctl` for success; `fuse_reply_err` for error codes; `fuse_reply_none` for `release` where appropriate. Do not reply with error code 0 using `fuse_reply_err` — prefer `fuse_reply_none` or the matching success reply.
|
||
|
||
*Why:* avoid confusing FUSE/kernel semantics and accidental error returns.
|
||
|
||
**Resource lifecycle & refcounts**
|
||
|
||
Per-handle state is stored as `Arc<Mutex<VuInputState>>`. CUSE callbacks must hold only short-lived locks. When a handle is closed, the release callback must remove the state via a dispatcher job if needed and then release its Arc; any long-running cleanup must be scheduled.
|
||
|
||
*Why:* prevents deadlocks and reference cycles.
|
||
|
||
**Blocking while awaiting job completion**
|
||
|
||
If a callback synchronously waits for job completion (the current implementation uses a Condvar awaiter), it must not hold any global locks (Dispatcher lock, global state lock) while waiting. The wait must be limited and should log timeouts when exceeded.
|
||
|
||
*Why:* prevents deadlocks (dispatcher needs that same mutex to execute jobs).
|
||
|
||
**Compatibility & architecture notes**
|
||
|
||
When mapping 32-bit compat input_event formats into 64-bit representation, copy data into properly aligned locals and then write; do not create slices pointing at temporaries. Provide clear tests for compat conversion for each architecture supported.
|
||
|
||
*Why:* correctness across bitness.
|
||
|
||
**Single-threaded CUSE in foreground mode**
|
||
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.
|
||
---
|
||
|
||
## 4. Security Considerations
|
||
|
||
`vuinputd` must currently run with **root privileges** to:
|
||
|
||
* Access `/dev/uinput` and create CUSE devices.
|
||
* Send and receive **udev/netlink** messages.
|
||
* Manage per-container device nodes under `/dev/input`.
|
||
|
||
While this design is necessary for mediation, it introduces potential attack surfaces:
|
||
|
||
### ⚠️ Risks
|
||
|
||
* **Privilege escalation:** a compromised container could exploit bugs in the proxy.
|
||
* **Input injection:** if isolation fails, input devices may leak between containers.
|
||
* **Unsafe FUSE/`unsafe` code:** any memory or pointer error could lead to denial-of-service or privilege abuse.
|
||
|
||
### 🛡️ Mitigations (planned / recommended)
|
||
|
||
* [ ] Drop capabilities after startup (e.g. keep only `CAP_SYS_ADMIN` where needed).
|
||
* [ ] Run under a dedicated **system user** (`vuinputd`) with limited filesystem access.
|
||
* [ ] Enforce **container identity** using cgroup, namespace, or pidfd checks.
|
||
* [ ] Use **seccomp** or `systemd` sandboxing (`ProtectSystem`, `ProtectKernelTunables`, `RestrictNamespaces`, etc.).
|
||
* [ ] Eventually migrate to **Rust-native FUSE/Netlink** bindings to remove unsafe dependencies.
|
||
|
||
|
||
|
||
## 5. Alternative Approaches
|
||
|
||
### 5.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.
|
||
|
||
#### 1) Trace hook: `tracepoint/syscalls/sys_enter_ioctl`
|
||
|
||
Use the *syscall tracepoint* `syscalls:sys_enter_ioctl`. Tracepoints are stable, exported kernel probe points and the syscall tracepoint provides the syscall arguments (fd, cmd, arg) in a stable layout. This avoids fragile kprobe offsets on architecture-specific syscall wrappers. See the kernel tracepoint docs.
|
||
|
||
#### 2) BPF map: ring buffer (kernel → userspace)
|
||
|
||
Use the BPF ring buffer (`BPF_MAP_TYPE_RINGBUF`) to cheaply publish fixed-size events to userspace. The ring buffer provides `bpf_ringbuf_reserve()` / `bpf_ringbuf_submit()` semantics from the kernel side and is the recommended modern replacement for perf-buf for high-rate kernel→user events. See the kernel documentation for the ring buffer API.
|
||
|
||
#### 3) Useful eBPF helpers
|
||
|
||
Inside the trace program you will typically use:
|
||
|
||
* `bpf_get_current_pid_tgid()` to record tgid/pid,
|
||
* `bpf_get_current_cgroup_id()` to filter to the container cgroup you care about,
|
||
* `bpf_copy_from_user()` to safely copy up to `N` bytes from the user pointer (`arg`) into the event buffer.
|
||
|
||
#### 4) Use of `pidfd_getfd`
|
||
The **`pidfd_getfd()`** syscall (introduced in Linux 5.6, see `man pidfd_getfd(2)`) allows one process to **duplicate a file descriptor from another process** into its own FD table. It takes a *pidfd* (obtained via `pidfd_open()` or from `CLONE_PIDFD`), the target FD number in the remote process, and optional flags. The resulting descriptor refers to the **same open file description**—sharing offset, status flags, and driver state—exactly as if the target process had called `dup()`. Permission checks apply: the caller must either share credentials (same UID) or hold `CAP_SYS_PTRACE` or an equivalent capability over the target. This makes `pidfd_getfd()` the canonical and race-free way to inspect or reuse another process’s device handles (for example, to run `UI_GET_SYSNAME` on a client apps' fd on `/dev/uinput` ) without invasive ptrace tricks.
|
||
|
||
|
||
### 5.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 |