GSoC 2026 final report
Overview
My project this summer is to implement USB redirection for qemu-rdp. qemu-rdp
is an RDP server for the org.qemu.Display1 D-Bus interface that QEMU
exposes with -display dbus. It runs as its own process, outside of QEMU, and
turns a virtual machine into a remote desktop that speaks a protocol every
platform already has a client for. It could already carry display, input, audio
and clipboard, but not a USB device.
And my work aims to close that gap. USB redirection feels like plugging a local USB device into a remote QEMU machine over the network.
RDP carries everything beyond the core desktop over dynamic virtual channels,
multiplexed by a static DRDYNVC channel (MS-RDPEDYC). MS-RDPEUSB defines
one named URBDRC for USB. What it carries is deliberately low level: URBs,
raw USB Request Blocks, so a device works without any code written for its
class.
QEMU, meanwhile, already speaks usbredir, the protocol SPICE has used for USB
pass-through for years. -device usb-redir gives the guest an emulated port
and exposes the other end as a chardev, which QEMU offers over the same
org.qemu.Display1 D-Bus interface. That chardev is qemu-rdp's way in.
So the project is a bridge. qemu-rdp plays the usbredir host, except it owns no
device: each request from QEMU becomes a URB sent over URBDRC to the RDP
client, and completes when the answer comes back.
[physical USB] → RDP client → URBDRC over DRDYNVC → qemu-rdp → usbredir → QEMU → [guest]My Work
All my work can be found in these repos:
my info: @uchouT i@uchout.moe
- qemu-rdp - the RDP server that exports a QEMU virtual machine. I wrote the
USB redirection backend here, bridging
URBDRCto usbredir. - IronRDP - the Rust RDP stack qemu-rdp is built on. The MS-RDPEUSB implementation lives here, along with the dynamic virtual channel work it needed.
- FreeRDP - the widely used C
implementation of RDP, and the client I tested against. A few
urbdrcbug fixes. - QEMU - just a documentation fix for the D-Bus chardev.
I'll pick some highlights from my GSoC project below.
Refactor ironrdp-dvc
URBDRC asks more of the DVC layer than any channel before it. Channels have to
be created and closed at runtime, and every instance shares one name — a
control channel plus one channel per redirected device, told apart only by the
ID DRDYNVC assigns them. ironrdp-dvc could do neither: channels were
registered once at build time, and the registry was keyed by name, so a second
URBDRC would have overwritten the first. It took several PRs to get there.
- IronRDP#1142 -
DvcChannelListener, so one name can produce many processors - IronRDP#1302 - closing a channel from either side
- IronRDP#1368, IronRDP#1377 - typed accessors, so a caller recovers a processor together with its channel ID and the wrapper types stay private
- IronRDP#1416 - reserve the ID before building the processor, so the processor can use it while initializing
The first of those came out of a design argument I am glad I made. IronRDP#1135 arrived with a plan already sketched, and the plan was to let the DVC layer do the work: a protocol processor would queue up the channels it wanted, and the layer beneath would notice and open them on its next pass. It would have worked for USB redirection. I argued against it anyway.
Two things were wrong with it. That layer only wakes up when the client sends something, so channels could only ever be opened in reaction to the client — while RDPEDYC is clear that the server is the side that opens them. And it asked a layer whose whole job is to speak RDPEDYC to also understand why a channel was wanted, a question only the protocol above it can answer. Convenient for USB redirection, useless to anything else, and paid for out of the layering.
What I proposed instead was that the DVC layer simply offer a way to create a channel and know nothing about who asked or why, leaving that decision to the layer that actually has the context.
ironrdp-rdpeusb PDU codec
IronRDP#1165 had left a PDU skeleton in place, and I finished the codec on top of it. The idea running through these PRs is to let the type system carry that context, so an invalid state is not merely rejected at runtime but impossible to build.
- IronRDP#1294 - lift
SHARED_MSG_HEADERout of every PDU struct, so callers cannot assemble a mismatched header - IronRDP#1321 - split the
PDU and
TS_URBenum wrappers - IronRDP#1403 - separate
raw and validated
InternalIoControl - IronRDP#1456 - type the URB completion payloads
URBDRC state machine
Sans-I/O protocol processors for both the server and the client side. This is one of the most interesting and challenging part of my GSoC journey :) The traits are the seam between the protocol and whatever backend sits behind it, so designing them meant working out the whole data flow first.
Setting up a new device channel is the trickiest part of that flow. When a
device is plugged in on the client, the client asks the server to open a
channel for it, and the server does, but what comes back carries nothing except
a channel ID. To the DVC layer it is just another URBDRC channel,
indistinguishable from the control channel or from any other device. The client
still has to announce the device on it with ADD_DEVICE, and ADD_DEVICE
needs that device's information.
Nothing in the protocol correlates the two, and the processor cannot do it
either — it has no way of knowing which device is its own. Only the backend
knows, because it is the side that asked for the channel in the first place. So
that became part of the trait: when a new channel appears, the backend is asked
to hand over the device it has been waiting to redirect, and the device
information is fetched from it again later, at the moment ADD_DEVICE is
actually assembled.
- IronRDP#1365 - the client processors
- IronRDP#1394 - the server
processors, plus the backend-facing
iomodel shared by both sides
USB model
Driving a redirected device through the processors alone meant assembling a
TS_URB, choosing a URB function code, etc by hand. This work can be shared
not just qemu-rdp. So I split the model into four layers:
ironrdp-usb— protocol-independent,no_std, sans-I/O, no dependencies. It describes USB operations without executing them, and parses only byte layouts USB itself defines.ironrdp-rdpeusb::usb— translation. Anironrdp-usbrequest in, a complete RDPEUSB packet out, so theTS_URBpayload, URB function, transfer envelope, flags and buffer shape cannot disagree.ironrdp-server— the facade and the request lifetime:UsbDeviceHandle, request IDs, pending requests, cancellation, completion routing. A caller never names aTS_URB.- qemu-rdp — everything shaped by usbredir: packet types and IDs, ordering rules, in-flight scheduling, response reconstruction.
The full rationale could be found in IronRDP#1516.
- IronRDP#1682 - the new
ironrdp-usbcrate - IronRDP#1683 - the RDPEUSB translation module
- IronRDP#1417 - the server integration and the device facade
Decoupling qemu-display's usbredir manager
qemu-display could already redirect a USB device to QEMU, but only a local
physical one. Its usbredir module did not merely use usbredirhost — it
was the usbredirhost session: a DeviceHandler implementation with its own
Drop and raw file-descriptor polling, keyed by USB bus and device address.
qemu_display::Error carried Rusb and Usbredir variants, so every consumer
of the crate depended on libusb, and Display::usbredir() handed back a
manager with nothing to substitute.
qemu-rdp has no local device to give it. Its device lives behind the RDP connection, and is driven by the client rather than by usbredirhost. But the part it does need was already written: finding a free chardev, creating the socket pair, registering one end over D-Bus, and tracking the live sessions and the free-channel count.
So I separated them. A UsbRedirBackend trait and a generic UsbRedir<B> keep
the chardev orchestration in qemu-display; the usbredirhost session moved out
to qemu-rdw, where it belongs; the rusb error variants left qemu_display:: Error; and Display::usbredir() became usbredir_chardevs() plus explicit
backend injection.
- qemu-display!8 - decouple the usbredir channel manager from the host backend
bridge URBDRC to usbredir in qemu-rdp
The bridge splits into a transport half and a protocol half. session_task
frames the QEMU chardev and drives usbredir-proto's sans-I/O parser, holding
no protocol state of its own; bridge holds all of it — negotiation, device
state, packet translation, request correlation.