7.2 KiB
Design Document
1. Introduction
This project provides a safe, general-purpose way to run Sunshine 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:
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:
sequenceDiagram
box transparent Host
participant uinput (kernel)
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)->>vuinputd: announce new device via udev
vuinputd->>libinput/game: announce new device via udev
libinput/game->>eventX: open /dev/input/eventX
3. Design Decisions
3.1 Where /dev/uinput lives
- Decision: Provide a fake
/dev/vuinputbacked by host proxy. This character device can be bind mounted inside containers to/dev/uinput. - Why: Prevents containers from creating devices visible system-wide.
3.2 Prevent host from using devices
- Decision: udev rules strip
ID_INPUT_KEYBOARDandID_INPUT_MOUSE, setID_SEAT=seat_vuinput. - Why: Ensures devices are invisible to host input subsystems while still available in containers.
3.3 udev events in containers
- Decision: Proxy forwards udev events into the container via netlink.
- Why: Without this, SDL2 and libinput might not recognize devices correctly; with it, containers behave as if devices were created locally.
3.4 Where to run the proxy
- Decision: Run proxy on host, one instance per container.
- Why: Only host can safely access
/dev/uinputand enforce mediation.
3.5 Security trade-off
- Decision: Accept that host always sees devices, but enforce rules to stop it consuming them.
- Why: Full input namespaces don’t exist in Linux today; mediation is the practical compromise.
3.6 Compatibility
- Runtimes supported: Works with systemd-nspawn, Docker, LXC, Podman, and other container engines.
- Applications supported: Any program that writes to
/dev/uinput, including Sunshine, custom input injectors, and game streaming servers.
4. Security Considerations
vuinputd must currently run with root privileges to:
- Access
/dev/uinputand 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/
unsafecode: 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_ADMINwhere needed). - Run under a dedicated system user (
vuinputd) with limited filesystem access. - Enforce container identity using cgroup, namespace, or pidfd checks.
- Use seccomp or
systemdsandboxing (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 toNbytes 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.