Files
kernalix7--winpodx/docs/design/USB-PASSTHROUGH-INVESTIGATION.md
wehub-resource-sync 3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

10 KiB

USB passthrough investigation (#286)

Why a YubiKey passes through to the Windows guest but an external USB3 drive does not, and what it would take to fix it.

Branch: explore/usb-container-hotplug. Builds on the security_opt: label=disable fix already on main (PR #411).

TL;DR

Live USB hot-plug via QEMU's own usb-host is not achievable here: dockur runs QEMU inside a rootless Podman container, and QEMU's libusb device list is frozen at container/QEMU start — the container's separate network namespace (plus no udevd / /run/udev) means libusb's udev netlink hotplug monitor never receives kernel uevents. So device_add usb-host can only attach a device present and at the same bus address as at QEMU start. VirtualBox / virt-manager succeed because they are host processes (host netns, live libusb hotplug) — the containerization is the difference.

But live IS achievable by bypassing the container: redirect USB from a host process (usbredirect) into a QEMU usb-redir socket channel — the host process has live libusb hotplug, so attach/detach is truly live. Write access needs no persistent udev rule — run just usbredirect under pkexec/sudo at attach time (transient root, nothing persistent touched). See "Live fix that bypasses the container" below. Feasible at every layer tested; end-to-end (device-in-Windows) not yet smoke-verified.

The layered walls (each independently necessary)

# Wall Status
1 SELinuxcontainer_t can't open usb_device_t nodes Fixed (#411, security_opt: label=disable, scoped to this container)
2 Node write permission — QEMU usb-host needs O_RDWR; udev tags only some classes uaccess Needs a per-device udev rule (sudo, once)
3 Frozen libusb list — QEMU's device list never updates after start The real blocker for "live"; requires recreate to refresh

Wall 2 — write permission

udev grants the active-seat user an uaccess ACL only to certain device classes (security keys, input, sound, cameras). USB mass storage, Bluetooth, etc. get no uaccess tag, so their /dev/bus/usb/BBB/DDD node stays root:root rw-rw-r--. The rootless container's user (mapped from container-root via the userns) is neither owner nor group, so it gets only other::r-- = read-only. QEMU usb-host needs to open the node O_RDWR to claim interfaces, so it can't.

  • YubiKey works because udev tags it TAGS=...uaccess...user:<you>:rw.
  • Fix: a per-device udev rule, installed once with sudo, persistent across replug/reboot:
    SUBSYSTEM=="usb", ATTR{idVendor}=="2109", ATTR{idProduct}=="0715", MODE="0660", TAG+="uaccess"
    
    (setfacl -m u:<you>:rw <node> works too but is wiped on every replug — and USB3 devices re-enumerate their address frequently.)

Wall 3 — the frozen libusb device list (the deep one)

QEMU's usb-host backend uses libusb. libusb on Linux keeps its device list live via a udev/netlink hotplug monitor thread (udev_monitor_new_from_netlink). Inside the rootless container that monitor receives no events — the container is in a separate network namespace (kernel uevents are netns-scoped) and there is no udevd / /run/udev. So libusb_get_device_list returns the boot-time snapshot, forever.

Evidence (independent of any timing/replug coordination):

HMP probe against the running guest, device_add usb-host,hostbus=2,hostaddr=N:

Device Host state QEMU response
WD 1058:2626 @ addr 20 (stable since QEMU boot) present failed to **open** 2:20found, perm-only
SSD 2109:0715 @ addr 30 (re-enumerated after boot) present (sysfs + fresh lsusb both confirm) failed to **find** 2:30not in QEMU's list

Corroboration: 6+ long-lived libusb monitors (a ctypes poller of libusb_get_device_list) — in the default netns, in --network=host, and in --network=host with /run/udev bind-mounted — every one stayed frozen within its lifetime, while the SSD's address churned 30→33→34→36→39 across runs (proving replugs happened, just never visible to a single long-lived context). A fresh context (each new lsusb / monitor) always sees the current state. So it is specifically the long-lived (QEMU) context that goes stale.

Notes:

  • USB3 is not special per se — qemu-xhci has p3=7 SuperSpeed ports, and the (stable) USB3 WD is in QEMU's list. The killer is post-boot address churn + the frozen list. USB3 just churns more (link re-training).
  • --network=host + /run/udev mount did not restore hotplug in testing. (It may be theoretically possible with a running udevd in the right netns, but it would break WinPodX's 127.0.0.1:port mapping model and was not made to work.)

Consequence

device_add usb-host for a device not in QEMU's frozen list produces an empty 1.5 Mb/s "USB Host Device" stub — it never opens the real device. The YubiKey "works live" only because it was present + stable at QEMU start.

So the current usb_live live-attach design (monitor device_add at runtime) cannot work for the general case.

Proposed fix — boot-time passthrough (recreate-to-attach)

Treat USB like PCI: pass assigned USB devices on QEMU's start-up args so the fresh boot enumeration grabs them.

  • compose._qemu_arguments_for_host already has the builder (devices.qemu_device_args emits -device usb-host,vendorid=0x..,productid=0x..) but only calls it for PCI. Extend it to USB.
  • Match by vendorid/productid (not hostbus/hostaddr) so an absent device yields a pending device rather than aborting boot.
  • Target dockur's controller: bus=xhci.0verify ordering: our ARGUMENTS must be appended after dockur emits -device qemu-xhci,id=xhci, or bus=xhci.0 won't resolve.
  • Requires the node writable at boot → the Wall-2 udev rule.
  • UX change: USB attach becomes recreate-on-attach (like PCI), not live. Update cli/device.py + the GUI Devices tab messaging; revisit the usb_live flag semantics.

Open questions to settle on real hardware (before merge)

  1. Does -device usb-host,vendorid=,productid= abort QEMU boot when the device is absent, or create a pending device? (Determines whether we can always emit it or must gate on presence.)
  2. Does bus=xhci.0 resolve from ARGUMENTS, or do we need our own controller?
  3. Does a boot-added device actually appear in Windows end-to-end (not just a stub)?
  4. Address-churning devices (some USB3 bridges/docks re-enumerate repeatedly) may still miss the boot enumeration window — acceptable?

Live fix that bypasses the container — usbredir + transient root

The frozen-libusb wall only applies to QEMU inside the container. A host process has live libusb hotplug (that's why VirtualBox / virt-manager work). So redirect USB from the host:

[host] usbredirect --device VID:PID  <--socket-->  [qemu] usb-redir chardev  -->  Windows (native driver)
       ^ host process => live libusb hotplug => true live attach/detach
  • QEMU side: -chardev socket,id=urN,... -device usb-redir,chardev=urN. Confirmed: dockur's QEMU has the usb-redir device; the channel can even be added to a running guest via HMP chardev-add + device_add usb-redir (no recreate). USB-over-socket is the same mechanism SPICE uses.
  • Host side: usbredirect (openSUSE usbredir package) grabs the device with the host's libusb and pipes it to QEMU. Live: spawn = attach, kill = detach.
  • Write permission, without a persistent system change (the user does not want to touch udev rules): run just usbredirect under pkexec/sudo at attach time. Root opens the root-owned device node directly — no udev rule, no group, nothing persistent. The privileged process is one small short-lived USB forwarder, not WinPodX. (Contrast: VirtualBox ships a broad install-time udev rule + vboxusers group; we can avoid that entirely with transient root.)
  • Windows guest sees the real device with its native driver (URB-level redirection), not a translated/class share.

Transport snag (the one open item)

The usbredir socket must be reachable host<->container. On this host the podman bridge firewall blocks both directions for arbitrary ports (host->10.89.0.2:port refused; container->10.89.0.1:port refused). So the socket has to ride WinPodX's existing compose ports: mapping (127.0.0.1:<port>:<port>, same as RDP/VNC/agent) — which means a one-time recreate to add the usb-redir channel + port-map. After that, attach/detach is fully live via usbredirect. (The HMP live-add of the channel works but is moot until the socket is reachable.)

Status

Feasible at every layer tested (QEMU usb-redir ✓, HMP channel-add ✓, host usbredirect ✓, pkexec ✓, transient-root model ✓). End-to-end not yet verified — device-appears-in-Windows needs the port-mapped socket wired (one recreate) + a real smoke. No blind compose change before that smoke.

Proposed WinPodX integration

  1. compose: add a usb-redir chardev (socket server) + 127.0.0.1:<port>:<port> map per concurrent USB slot (e.g. 4 slots). One-time recreate when the feature is enabled.
  2. device attach <usb>: spawn pkexec usbredirect --device <vid:pid> --to tcp:127.0.0.1:<port> (transient root, no persistent change). detach: kill that process. Replaces the broken qemu-libusb live_attach.
  3. GUI Devices tab drives the same; surface the pkexec prompt / failures.
  4. Security keys etc. (uaccess) can still use the existing path without root.

What works today (shipped on main)

  • uaccess USB2 devices (security keys) attach live.
  • The security_opt: label=disable SELinux lift (#411).
  • For files on an external drive, FreeRDP drive redirection (\\tsclient\media, already wired) is the right tool and works for any filesystem — raw passthrough of a LUKS/ext4 disk is useless to Windows anyway.