Skip to content

feat: redirect USB devices over URBDRC with nusb and RemoteFX USB backends - #2181

Merged
Marc-André Moreau (mamoreau-devolutions) merged 44 commits into
masterfrom
claude/rdpeusb-device-backend-873914
Oct 23, 2026
Merged

Marc-André Moreau (mamoreau-devolutions) merged 44 commits into
masterfrom
claude/rdpeusb-device-backend-873914

Conversation

@mamoreau-devolutions

@mamoreau-devolutions Marc-André Moreau (mamoreau-devolutions) commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Adds host-device backends for the MS-RDPEUSB (URBDRC) client and wires them into ironrdp-client and the viewer, so local USB devices can be redirected to the server. On Windows it redirects USB the way mstsc does, through the in-box RemoteFX USB drivers, when policy allows it, and falls back to nusb otherwise. It also fixes three interop bugs in ironrdp-rdpeusb found by redirecting a device to Windows Server 2025.

Device coverage

  • Windows, with RemoteFX USB redirection allowed: any device, including HID, mass storage, smart card readers and cameras, through TsUsbFlt and TsUsbGD, the drivers mstsc uses. Isochronous transfers and the cancellation of a single request are supported. The fUsbRedirectionEnableMode policy ("Allow RDP redirection of other supported RemoteFX USB devices from this computer") decides who may claim devices: nobody by default, elevated administrators, or every user.
  • Windows, otherwise, through nusb: only devices bound to WinUSB, or composite devices whose interfaces all use WinUSB. Firmware that requests WinUSB works out of the box; other devices need Zadig or similar.
  • Linux, through nusb: broad. Kernel drivers are detached while the device is redirected and reattached afterwards; the user needs udev access to /dev/bus/usb.
  • macOS, through nusb: only devices not claimed by a kernel or system extension.

nusb was chosen for the portable backend because it's pure Rust and supports hotplug on Windows, Linux and macOS. The full list of limits is in crates/ironrdp-rdpeusb-native/README.md.

Changes

  • New crate ironrdp-rdpeusb-native, publishable like ironrdp-rdpeusb. It implements DeviceManagerBackend and UrbdrcDeviceBackend. Both backends share device selection, announcement and unplug handling through a small RedirectableDevice trait.
    • nusb backend (all platforms):

      • lists and filters devices, opens them and claims every interface before announcing them to the server;
      • translates control, bulk and interrupt URBs, plus feature, status, descriptor, vendor/class and Microsoft OS descriptor requests;
      • handles cancel, abort pipe and clear stall, configuration and interface selection, port IOCTLs and device text;
      • follows plug and unplug during the session, including devices unplugged before their channel opens;
      • rejects isochronous transfers packet by packet;
      • bounds the data each device holds in flight (64 MiB) and each IN transfer (16 MiB).

      The translation code has no I/O and is unit-tested. Each device gets its own worker thread with a single-threaded Tokio runtime and one ordered queue per endpoint. nusb can only cancel a whole endpoint, so transfers cancelled only because another request was cancelled resume where they stopped.

    • RemoteFX USB backend (Windows): claims the selected devices through the hub filter's control object and finds them, re-enumerated under the generic redirection driver, as interfaces of the per-connection claim GUID.

      • TS_URBs are re-encoded and submitted as overlapped I/O on a completion port; the driver writes each TS_URB_RESULT over its request.
      • IO controls are forwarded, and a single request can be cancelled.
      • A device is given back to its own driver when its channel closes, when the server retracts it, or when the connection ends.

      The driver interface is undocumented and was reverse-engineered from the Windows 11 binaries.

  • Device selection: FreeRDP /usb rules (id:VID:PID#..., addr:BUS:ADDR#..., auto, filter:CLASS#... / filter:none) and the .rdp usbdevicestoredirect property (*, USB\VID_xxxx&PID_yyyy..., device instance IDs, - exclusions). auto and * keep audio, HID, mass storage, hubs and smart cards local by default, as FreeRDP does. Both backends select devices with the same rules.
  • ironrdp-client: new usb feature, included in all, that registers the URBDRC listener.
    • Backend events go through the unbounded ordered queue the clipboard already uses (RdpInputSender::send_urbdrc, new RdpInputEvent::Urbdrc variant). The bounded input queue drops events when full, and a lost completion would leave the server driver waiting forever.
    • Events are scoped to their connection and device channel, so a reconnection that reuses channel IDs never receives stale ones.
    • USB redirection is suppressed when the RD Gateway's device-redirection policy disables Plug and Play or all devices, over both the HTTP and RPCH transports.
    • New ConfigBuilder::with_usb_redirection. usbdevicestoredirect is mapped in with_property_set through a new ironrdp-cfg accessor.
    • The backend crate is re-exported as ironrdp_client::usb, and ironrdp gains a client-usb feature.
  • ironrdp-rdpeusb (client side):
    • CANCEL_REQUEST keeps the request pending until the backend completes it, so the server-side IRP finishes; FreeRDP does the same.
    • DeviceInfo::isochronous: ADD_DEVICE advertises a nonzero no-ack isochronous jitter buffer only when the backend performs isochronous transfers.
    • The listener takes the device before allocating its interface ID, so a refused channel doesn't use one up.
    • TsUrbInPacket and TsUrbOutPacket gain encode_ts_urb and decode_ts_urb_result, for backends that hand TS_URBs to the operating system as they are.
    • Interop fixes found against Windows Server 2025:
      • REGISTER_REQUEST_CALLBACK accepted only request completion interface IDs of 4 or more, but Windows picks one below 4. The device channel failed to decode and the whole session ended right after a device was announced.
      • Windows sends control requests without a data stage, including OUT ones such as HID SET_IDLE, in TRANSFER_IN_REQUEST messages whose flags keep the OUT direction. They were rejected when decoded and when encoded. Bulk, interrupt and isochronous transfers are still checked.
  • ironrdp-dvc: a client-initiated DYNVC_CLOSE now tolerates data that races it, with bounded bookkeeping that keeps the close order and lets a channel ID be reused. close_channel_batch returns the Soft-Sync tunnel the channel used, so the close travels on it.
  • Viewer: --usb RULES, repeatable, overrides the .rdp value. --usb list prints the local devices with the values the rules match against and, on Windows, each device's driver.
  • ActiveX: RedirectDevices is left as E_NOTIMPL. It switches Plug and Play device redirection (MS-RDPEPNP), not URBDRC.

Testing

  • cargo xtask check fmt -v, cargo xtask check lints -v and cargo xtask check locks -v pass.

  • Unit and integration tests pass:

    • ironrdp-rdpeusb-native (57);
    • ironrdp-client with all features (68);
    • the rdpeusb/dvc suites in ironrdp-testsuite-core (144);
    • the client config suite in ironrdp-testsuite-extra (70).

    They cover URB translation, cancellation and resumed transfers, select-configuration results, completion status and HRESULT, IOCTLs, filter and .rdp parsing, the config and gateway policy mapping, the RemoteFX USB request buffers and status mapping, and regression tests for each interop fix.

  • On Windows, --usb list enumerates the local devices.

  • Live, against Windows Server 2025, with the RemoteFX USB backend and the policy set to every user, repeated on this PR's head:

    • A YubiKey 5 was claimed, redirected, and enumerated on the server with its composite device, OTP keyboard, FIDO and CCID interfaces all OK.
    • A CTAPHID INIT sent to its FIDO interface in the session got its nonce echoed back, which exercises interrupt OUT and IN transfers end to end.
    • The session stayed up for about an hour, and the YubiKey went back to its own drivers when the session ended.
    • Unplugging the YubiKey during the session closed its device channel; plugging it back in claimed it again on a new channel, and the CTAPHID ping passed on the new device.
    • The server needed the RD Session Host role and fDisablePNPRedir = 0 to offer URBDRC; it isn't offered in remote administration mode.
    • With the nusb backend, URBDRC negotiates; no WinUSB device was available to redirect.
  • Not verified:

    • a WinUSB device redirected through the nusb backend;
    • smart card access in the session: the reader enumerates, but the server's smart card service wasn't reachable;
    • OTP keystrokes reach the server's console rather than the session, as with mstsc.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Compilation, device-selection safety, allocation bounds, and USB lifecycle correctness issues remain unresolved.

6 open issues
What changed in this PR

Adds an nusb-backed USB redirection backend and integrates it into the client and viewer, keeping host I/O outside the protocol crate.

Changes:

  • Implements device discovery, filtering, hotplug handling, and USB request processing.
  • Adds client feature flags, configuration APIs, and ordered backend events.
  • Exposes viewer USB selection and device listing, with platform-limit documentation.
File Description
crates/​ironrdp/​Cargo.toml Exposes client-usb.
crates/​ironrdp-viewer/​src/​cli.rs Adds USB selection and listing.
crates/​ironrdp-rdpeusb/​README.md Links the native backend.
crates/​ironrdp-rdpeusb-native/​src/​urb.rs Translates requests and builds completions.
crates/​ironrdp-rdpeusb-native/​src/​manager.rs Manages discovery and hotplug events.
crates/​ironrdp-rdpeusb-native/​src/​lib.rs Exposes listener and backend events.
crates/​ironrdp-rdpeusb-native/​src/​filter.rs Parses and matches device-selection rules.
crates/​ironrdp-rdpeusb-native/​src/​device.rs Implements host-device workers and transfers.
crates/​ironrdp-rdpeusb-native/​README.md Documents usage and platform limitations.
crates/​ironrdp-rdpeusb-native/​LICENSE-MIT Links the MIT license.
crates/​ironrdp-rdpeusb-native/​LICENSE-APACHE Links the Apache license.
crates/​ironrdp-rdpeusb-native/​Cargo.toml Defines backend dependencies.
crates/​ironrdp-client/​src/​urbdrc.rs Routes backend events through device channels.
crates/​ironrdp-client/​src/​rdp.rs Registers USB support and processes events.
crates/​ironrdp-client/​src/​lib.rs Re-exports the backend.
crates/​ironrdp-client/​src/​config.rs Adds USB configuration and mapping tests.
crates/​ironrdp-client/​README.md Documents optional USB support.
crates/​ironrdp-client/​Cargo.toml Adds the USB feature and dependencies.
crates/​ironrdp-cfg/​src/​lib.rs Adds USB property accessors.
Cargo.lock Records backend dependencies.

Review effort: Balanced

Comment thread crates/ironrdp-rdpeusb-native/src/device.rs Outdated
Comment thread crates/ironrdp-rdpeusb-native/src/device.rs Outdated
Comment thread crates/ironrdp-rdpeusb-native/src/filter.rs Outdated
Comment thread crates/ironrdp-rdpeusb-native/src/manager.rs Outdated
Comment thread crates/ironrdp-rdpeusb-native/src/device.rs Outdated
Comment thread crates/ironrdp-rdpeusb-native/src/manager.rs Outdated
@github-actions github-actions Bot added automation-failed Exact-head automated classification or review failed or was unavailable risk/unknown Risk could not be determined automatically; needs maintainer-level scrutiny scope/core Touches the core architectural tier size/XXL Size: 1300 or more counted lines or 50 or more files labels Oct 21, 2026
@github-actions github-actions Bot added breaking-change Includes a breaking change, and requires special scrutiny at the boundaries kind/protocol Affects RDP or related protocol behavior risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny scope/cross-cutting Spans multiple architectural boundaries and removed risk/unknown Risk could not be determined automatically; needs maintainer-level scrutiny automation-failed Exact-head automated classification or review failed or was unavailable labels Oct 21, 2026
@github-actions github-actions Bot added automation-failed Exact-head automated classification or review failed or was unavailable risk/unknown Risk could not be determined automatically; needs maintainer-level scrutiny and removed risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny labels Oct 21, 2026
@github-actions github-actions Bot added risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny and removed risk/unknown Risk could not be determined automatically; needs maintainer-level scrutiny automation-failed Exact-head automated classification or review failed or was unavailable labels Oct 21, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

USB redirection over URBDRC with an nusb backend: protocol review found two spec deviations (isochronous capability advertised but unsupported; IN overrun not terminating the channel); structural review confirmed the published client now depends on publish=false crates in its public API, blocking device opens on the session loop, broken LICENSE blobs, a process exit in config construction, and a colliding Windows bus-id fold; plus six behavior-preserving compression opportunities. The unmanaged-MSRV claim was rejected: dozens of existing workspace crates already declare rust-version = "1.94".

  1. [skeptical] `--usb list` terminates the process from inside ViewerConfig::from_args — low 🟡 — crates/ironrdp-viewer/src/cli.rs
    `--usb list` makes print_usb_devices run and then calls std::process::exit(0) inside ViewerConfig::from_args, a config-construction function that now carries a process-terminating side effect: destructors of already-built values are skipped, and callers cannot compose or test from_args without risking an exit. Listing mode is CLI dispatch and belongs at the argument-parsing layer alongside the other early-exit surfaces.

Push a commit after addressing these findings. If no code change is needed, you may resolve inline threads and comment @github-actions review-ready to request human review.

Comment thread crates/ironrdp-rdpeusb-native/src/device.rs
Comment thread crates/ironrdp-rdpeusb-native/src/device.rs Outdated
Comment thread crates/ironrdp-client/src/lib.rs
Comment thread crates/ironrdp-rdpeusb-native/src/manager.rs Outdated
Comment thread crates/ironrdp-rdpeusb-native/LICENSE-APACHE
Comment thread crates/ironrdp-rdpeusb-native/src/device.rs
Comment thread crates/ironrdp-rdpeusb-native/src/device.rs Outdated
Comment thread crates/ironrdp-dvc/src/client.rs
Comment thread crates/ironrdp-dvc/src/client.rs Outdated
Comment thread crates/ironrdp-rdpeusb-native/src/device.rs Outdated
@github-actions github-actions Bot added ai-reviewed/1 One automated review completed needs-author-action The pull request author is the current next actor labels Oct 21, 2026
@github-actions github-actions Bot removed the needs-author-action The pull request author is the current next actor label Oct 21, 2026
@github-actions github-actions Bot removed the scope/cross-cutting Spans multiple architectural boundaries label Oct 21, 2026
Add UsbRedirectionConfig::all for devices present at connection,
alongside auto for hotplug, instead of storing UsbDeviceMatch::Any in
the device rules and scanning for it. Any remains for exclusions (-*),
and as a device rule it now selects every device like any explicit
rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR 2180 makes ironrdp-rdpeusb publishable with the same edit minus the
readme key, which Cargo infers from README.md anyway. Keeping the two
identical lets them merge in either order without a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The drive redirection change added ViewerConfig::named_drives, which the
early return for --usb list did not set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nsistent

- A bulk or interrupt transfer that nusb cancels only because the server
  cancelled another request on the endpoint now resumes where it stopped,
  instead of completing as a successful short transfer. Transfers that the
  server cancelled or whose pipe was aborted complete as cancelled, even
  when some data moved.
- Spawned control requests complete through the device worker, after
  leaving the running requests, so that a request reusing the ID can
  still be cancelled.
- A device unplugged before its device channel exists keeps its place in
  the announcement queue, so the channel created for it is refused rather
  than handed the next device. The listener takes the device before
  allocating an interface ID, and the client skips announcing a device
  that is already unplugged.
- Hotplugged devices are opened right away, with retries in their own
  task, so unplugs are still followed while a driver loads; devices are
  identified once instead of twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Opening a plugged device stops when it is unplugged, and a replug
  replaces the attempts in progress instead of racing them for its
  interfaces.
- A device that is already redirected, for example by the enumeration at
  connection time, is not opened again, which only failed and warned.
- IN transfers reuse nusb's buffer unless they resumed after a
  cancellation, and a transfer cancelled before moving any data runs
  again with the same buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st configuration

Master replaced ViewerConfig's redirect_printers flag with optional
printer options and added the audio device listing flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
REGISTER_REQUEST_CALLBACK rejected request completion interface IDs
below 4, as if they clashed with the control channel's default
interfaces. The server allocates this ID in its own space and only uses
it to receive completions, and Windows Server 2025 picks one below 4:
the device channel failed to decode, which ended the whole session
right after a device was announced.

Found by redirecting a YubiKey to Windows Server 2025.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…_REQUEST

Windows submits control requests without a data stage, including OUT
ones such as a HID SET_IDLE, as TRANSFER_IN_REQUESTs whose transfer
flags keep the OUT direction. Decoding rejected them, which ended the
session as soon as a HID device was redirected to Windows Server 2025.
Control, control-ex and vendor or class requests are no longer checked
for the IN direction when decoding a TRANSFER_IN_REQUEST; bulk,
interrupt and isochronous transfers still are.

The nusb backend now sends a vendor or class request whose transfer
flags say OUT as an OUT request without data, instead of an IN one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…EST carries

Decoding now accepts OUT control requests without data in a
TRANSFER_IN_REQUEST, as Windows sends them. Encoding still rejected
them, so a backend that hands the TS_URB to the operating system, such
as the Windows RemoteFX USB redirection driver, could not pass them on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Evict late-data history by close order instead of channel ID.
Clear it only after a restored channel is successfully attached.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Scope unplug events and registry cleanup to the device channel.
Keep cancelled controls charged until native completion.
Use task identity to reject results for reused request IDs.
Report partial transfer progress on failures and cancellations.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The client library disables its default unit-test target.
Run USB property mapping checks in the extra integration suite.
Enable the USB feature explicitly rather than relying on unification.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Master added the RPCH gateway transport, whose redirection flags are
mapped onto the HTTP tunnel ones. Its pnp_disabled flag was not mapped,
so USB redirection stayed on through an RPCH gateway that disables Plug
and Play device redirection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Backends that hand TS_URBs to the operating system as they are, such as
the Windows RemoteFX USB redirection driver, need the wire form of a
transfer request back and must decode the TS_URB_RESULT it produces.
TsUrbInPacket and TsUrbOutPacket gain encode_ts_urb and
decode_ts_urb_result for that, reusing the server's knowledge of which
result each request expects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…teFX USB

Windows ships the drivers mstsc redirects USB devices with: a filter on
every USB hub (TsUsbFlt), through which a process claims devices, and a
generic redirection driver (TsUsbGD) that the claimed devices are
re-enumerated with. When the fUsbRedirectionEnableMode policy lets the
current process claim devices, the backend now uses them instead of
nusb, which reaches class devices (HID, storage, smart cards, cameras)
and not only WinUSB ones, and supports isochronous transfers and the
cancellation of a single request.

- Devices are selected by the redirection rules and described from
  their descriptors before they are claimed, then claimed by their
  proprietary ID; claimed devices are found as device interfaces of the
  per-handle claim GUID and opened under it.
- TS_URBs are re-encoded and submitted as overlapped I/O on a completion
  port; the driver writes the TS_URB_RESULT over the request.
- A device is released when its channel closes, when the server
  retracts it, or when the connection ends.
- The device manager now serves any RedirectableDevice, so both
  backends share announcement and unplug handling.

The driver interface is undocumented and was reverse-engineered from
the Windows 11 binaries. A YubiKey 5 redirected to Windows Server 2025
enumerated with all of its interfaces, and answered a CTAPHID ping in
the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	crates/ironrdp-client/Cargo.toml
A failed request completes with a header-only TS_URB_RESULT, whatever result its success carries: Windows does so, and so do both IronRDP backends. Decoding rejected it for select-configuration, select-interface, current-frame and isochronous requests, which failed the server-side device channel and made the RemoteFX USB backend report REQUEST_FAILED instead of the real status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A channel the client closed while the server was choosing tunnels is still listed in the Soft-Sync request, and the server may already send its data on the tunnel. That data failed the connection; the closed channel is now remembered on the tunnel too, and its data dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n order

nusb cancels every transfer of an endpoint at once, and transfers cancelled along with another one run again later. A transfer after them that finished meanwhile was reported first. Each transfer now gets its position on the endpoint, and is reported once every earlier one finished, which also orders zero-length IN requests completed without the device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…those not ready

Unplugging a device closed its channel, whose release then kept the device from being claimed again during the connection. An unplugged device is now forgotten without that, and only devices given back while still plugged in stay unclaimed.

A device just plugged in may not be known to the hub filter yet, and a claim may fail; such devices were never tried again. They are now retried at the next scans, up to five times, under the rules that selected them, and a failed claim is rolled back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sUsbGD

- The HRESULT of a transfer follows its USBD status instead of always being S_OK, so server drivers see failures and recover.

- An isochronous packet whose bytes are not in the buffer is reported as failed, so the packed data matches the successful packets' lengths.

- Writes without acknowledgement are not tracked for cancellation, and a request only removes its own tracking entry, so a reused request ID stays cancellable.

- Removing the last request in flight wakes the shutdown wait on every path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was previously deployed

1 inactive deployment
llm-providers — 73a0daa4 Deployed Oct 23, 2026 by mamoreau-devolutions via Classify pull request #7702
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-reviewed/3 Final automated review completed breaking-change Includes a breaking change, and requires special scrutiny at the boundaries kind/protocol Affects RDP or related protocol behavior risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny scope/core Touches the core architectural tier scope/cross-cutting Spans multiple architectural boundaries size/XXL Size: 1300 or more counted lines or 50 or more files

Development

Successfully merging this pull request may close these issues.

2 participants