11 KiB
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.
Status: NixOS is one of the primary target platforms for
vuinputd. Native packaging is planned; for now, the configuration below buildsvuinputddirectly from source as part of the NixOS system.
Configurations in the Community
The following community members have shared their NixOS configurations including vuinputd:
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
vuinputdfrom source usingrustPlatform.buildRustPackage - Installs the required udev rules and hwdb entries
- Runs
vuinputdas a systemd service with a tmpfs for the container-scoped/dev/inputtree - Applies the correct permissions to
/dev/vuinputafter the daemon starts
Add the module to your configuration.nix imports and run nixos-rebuild switch.
{ 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 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.
--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:
ExecStart = "${lib.getExe vuinputd} --major 120 --minor 414795 --placement on-host --device-policy sanitized";
See 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:
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:
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.
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):
# 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 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:
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
section in the main usage guide.
Troubleshooting
If vuinputd does not behave as expected, refer to
DEBUG.md for general
debugging guidance.
Common NixOS-specific issues:
-
CUSE module not loaded: NixOS should load
cuseautomatically via udev, but if/dev/vuinputdoes not appear after the service starts, runmodprobe cuseand restart the service. -
/dev/vuinputis not accessible: Thevuinputd-chmodservice applies permissions 2 seconds after startup. If it fails, checksystemctl status vuinputd-chmodand applychmod 666 /dev/vuinputmanually for debugging. -
Input devices not visible inside the container: Verify that
/run/vuinputd/vuinput/dev-inputis mounted as a tmpfs (mount | grep vuinputd) and that thebwrap--dev-bindflags point to the correct paths. -
Read-only filesystem error from
vuinputd: If the daemon logs an error likeReadOnlyFilesystemwhen creating a device node, the directory where it tries to write (typically/dev/inputor/run) is not writable inside the container. Ensure the--dev-bindand--tmpfsflags in yourbwrapcommand cover all pathsvuinputdwrites to, or switch to--placement on-hostso 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
postPatchblock in the derivation is present; it is required for recent versions offuse3.