Files
vuinputd/docs/USAGE-NIXOS.md
2026-05-09 08:04:06 +00:00

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

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.

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