Fix HID transfers, harden the tunnel, add E2E crypto and direct peers

The HID failure came down to the endpoint type map being indexed by
endpoint number without the direction bit. A composite device can have
endpoint 1 as both interrupt IN (0x81) and bulk OUT (0x01); the last one
read won, so interrupt URBs were submitted as bulk and the kernel rejected
them. The device attached and stayed silent.

Endpoint data now comes from the raw descriptors read from /dev/bus/usb
rather than sysfs, which only ever exposes the active alternate setting —
a webcam's isochronous endpoints are invisible there because they only
exist after SET_INTERFACE. Two sysfs parsing bugs fell out of that too:
the numeric endpoint attributes are hex without a prefix (wMaxPacketSize
"0040" was read as 40, not 64), and bInterval was never read at all.

Reliability: three places could freeze the whole process. The share path
fed io.Pipe from the WebSocket read loop, so one slow USB transfer stalled
every tunnel and the keepalives with them. The relay wrote to client
sockets while holding the hub lock, so one peer that stopped reading
blocked routing and registration for everyone. Control transfers ran
inline in the protocol loop behind a 5s timeout. Also fixed: a use-after-
free where a discarded URB's memory could be collected while the kernel
still owned it, a reap loop that spun at 100% CPU on ioctl errors, a
missing attach timeout, a double close(done) panic, and Hash[:8] in the
relay's log line, which let a client with a short hash take the server
down.

Adds mode "both", so one client can offer and consume devices at once.
The tunnel and client-left callbacks became multicast for it: as plain
fields the second manager to register silently unhooked the first.

Tunnel traffic is now AES-256-GCM end to end, on the relay path as well
as directly. The key is derived from the three tokens, not from the group
hash — the relay is told the hash, so a key derived from it would protect
nothing from the one party in the middle. Group IDs are unchanged, so
existing setups keep working; only clients configured without the tokens
drop to unencrypted, relay-only operation.

Peers now try to connect directly, with the relay supplying the public
address neither side can determine for itself. Candidates are raced
because an unreachable address hangs until timeout rather than refusing.
Falling back to the relay is not an error.

Platform reach: cross-compiled targets for ARM, MIPS and RISC-V (the
Linux client needed no code changes — usbdevfs is not architecture
specific), multi-arch Docker images, an Android bridge that accepts
devices over SCM_RIGHTS because apps cannot open /dev/bus/usb, and macOS
builds via system_profiler enumeration.

Adds a Windows KMDF filter driver under driver/windows with its Go side.
UNTESTED: it has never been compiled or run, needs the WDK to build and
an EV certificate to distribute. Treat it as a starting point.

Adds "usb-client diag": says per machine whether sharing and using are
possible, what stands in the way, and what fixes it. Reports can be
uploaded to a relay to get them off machines that are awkward to copy
from.

96 tests, all green under -race. Builds for linux, windows and darwin on
amd64 and arm64.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 22:02:04 +02:00
co-authored by Claude Opus 5
parent 54178dce75
commit 9ed473a965
95 changed files with 12181 additions and 892 deletions
+116 -14
View File
@@ -3,8 +3,10 @@
package usb
import (
"errors"
"fmt"
"os"
"time"
"unsafe"
"golang.org/x/sys/unix"
@@ -81,7 +83,7 @@ type usbdevfsBulkTransfer struct {
}
type usbdevfsSetIntf struct {
Interface uint32
Interface uint32
AltSetting uint32
}
@@ -117,7 +119,7 @@ type usbdevfsURB struct {
NumberOfPackets int32 // or StreamID
ErrorCount int32
Signr uint32
UserContext uintptr
UserContext uintptr
// ISO packet descriptors follow in memory if Type == urbTypeISO
}
@@ -126,10 +128,25 @@ type DeviceHandle struct {
fd int
busID string
devPath string
// adopted marks a descriptor handed to us from outside rather than
// opened here. It is closed on Close like any other, but the distinction
// matters for diagnostics: an adopted descriptor means the host process
// could not have opened the device itself.
adopted bool
}
// OpenDevice opens a USB device file for direct access
// OpenDevice opens a USB device file for direct access.
//
// If an external file descriptor has been registered for this device (see
// AdoptDeviceFD) it is used instead of opening the path. That is how Android
// works: apps cannot open /dev/bus/usb themselves, so a small Java shim asks
// the system for permission and hands the resulting descriptor down.
func OpenDevice(devPath string, busID string) (*DeviceHandle, error) {
if fd, ok := takeAdoptedFD(busID); ok {
return &DeviceHandle{fd: fd, busID: busID, devPath: devPath, adopted: true}, nil
}
fd, err := unix.Open(devPath, unix.O_RDWR, 0)
if err != nil {
return nil, fmt.Errorf("opening %s: %w", devPath, err)
@@ -293,7 +310,7 @@ type SubmitURBParams struct {
Endpoint uint8
Flags uint32
Buffer []byte
UserContext uintptr
UserContext uintptr
}
// SubmitURB submits an asynchronous URB
@@ -310,7 +327,7 @@ func (h *DeviceHandle) SubmitURB(params *SubmitURBParams) (*usbdevfsURB, error)
Buffer: bufPtr,
BufferLength: int32(len(params.Buffer)),
NumberOfPackets: -1, // 0xFFFFFFFF for non-ISO
UserContext: params.UserContext,
UserContext: params.UserContext,
}
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsSubmitURB, uintptr(unsafe.Pointer(urb)))
@@ -320,6 +337,20 @@ func (h *DeviceHandle) SubmitURB(params *SubmitURBParams) (*usbdevfsURB, error)
return urb, nil
}
// urbFromKernelPtr converts the uintptr USBDEVFS_REAPURB writes back into a
// *usbdevfsURB.
//
// go vet flags this as "possible misuse of unsafe.Pointer", correctly in
// general: the garbage collector cannot see a pointer stored in a uintptr, so
// the object could be collected before the conversion. It is safe here because
// the kernel only ever returns a pointer we submitted ourselves, and the
// caller keeps that URB reachable — in pendingURBs or unlinkedURBs on the
// server — from submission until after it has been reaped.
func urbFromKernelPtr(p uintptr) *usbdevfsURB {
//nolint:govet // see the comment above
return (*usbdevfsURB)(unsafe.Pointer(p))
}
// ReapURB blocks until a URB completes, then returns it
func (h *DeviceHandle) ReapURB() (*usbdevfsURB, error) {
var urbPtr uintptr
@@ -327,7 +358,7 @@ func (h *DeviceHandle) ReapURB() (*usbdevfsURB, error) {
if errno != 0 {
return nil, fmt.Errorf("USBDEVFS_REAPURB: %w", errno)
}
return (*usbdevfsURB)(unsafe.Pointer(urbPtr)), nil
return urbFromKernelPtr(urbPtr), nil
}
// ReapURBNonBlock tries to reap a URB without blocking
@@ -337,7 +368,7 @@ func (h *DeviceHandle) ReapURBNonBlock() (*usbdevfsURB, error) {
if errno != 0 {
return nil, fmt.Errorf("USBDEVFS_REAPURBNDELAY: %w", errno)
}
return (*usbdevfsURB)(unsafe.Pointer(urbPtr)), nil
return urbFromKernelPtr(urbPtr), nil
}
// DiscardURB cancels a submitted URB
@@ -450,10 +481,81 @@ func ReadISOResults(mem []byte, numPackets int32) []ISOPacketResult {
// ReapedURBInfo holds exported fields from a reaped URB needed for response building
type ReapedURBInfo struct {
UserContext uintptr
Status int32
Status int32
ActualLength int32
StartFrame int32
ErrorCount int32
StartFrame int32
ErrorCount int32
}
// ErrNoURBReady is returned by ReapURBInfoNonBlock when no URB has completed.
var ErrNoURBReady = errors.New("no completed URB available")
// ErrDeviceGone is returned when the device has been unplugged or the file
// descriptor is no longer usable.
var ErrDeviceGone = errors.New("device gone")
// WaitForURB waits up to timeout for at least one URB to complete.
// It returns true if a URB is ready to be reaped, false on timeout.
//
// usbdevfs signals completed URBs via POLLOUT, so polling lets the reap loop
// stay responsive to shutdown without either spinning on a non-blocking ioctl
// or blocking indefinitely in USBDEVFS_REAPURB. The latter matters: a blocking
// reap can only be broken by closing the fd, which races with the fd being
// reused by another goroutine.
func (h *DeviceHandle) WaitForURB(timeout time.Duration) (bool, error) {
fds := []unix.PollFd{{Fd: int32(h.fd), Events: unix.POLLOUT}}
ms := int(timeout.Milliseconds())
if ms < 0 {
ms = 0
}
for {
n, err := unix.Poll(fds, ms)
if err == unix.EINTR {
continue // interrupted by a signal, not an error
}
if err != nil {
return false, fmt.Errorf("poll: %w", err)
}
if n == 0 {
return false, nil // timeout
}
// POLLERR/POLLHUP/POLLNVAL mean the device is gone or the fd was closed.
if fds[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 {
return false, ErrDeviceGone
}
return fds[0].Revents&unix.POLLOUT != 0, nil
}
}
// ReapURBInfoNonBlock reaps one completed URB without blocking.
// Returns ErrNoURBReady if none has completed, ErrDeviceGone if the device
// has been disconnected.
func (h *DeviceHandle) ReapURBInfoNonBlock() (*ReapedURBInfo, error) {
var urbPtr uintptr
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsReapURBNDelay, uintptr(unsafe.Pointer(&urbPtr)))
if errno != 0 {
switch errno {
case unix.EAGAIN:
return nil, ErrNoURBReady
case unix.ENODEV, unix.ESHUTDOWN, unix.EBADF, unix.ENOENT:
return nil, ErrDeviceGone
default:
return nil, fmt.Errorf("USBDEVFS_REAPURBNDELAY: %w", errno)
}
}
if urbPtr == 0 {
return nil, ErrNoURBReady
}
urb := urbFromKernelPtr(urbPtr)
return &ReapedURBInfo{
UserContext: urb.UserContext,
Status: urb.Status,
ActualLength: urb.ActualLength,
StartFrame: urb.StartFrame,
ErrorCount: urb.ErrorCount,
}, nil
}
// ReapURBInfo blocks until a URB completes and returns exported info
@@ -463,13 +565,13 @@ func (h *DeviceHandle) ReapURBInfo() (*ReapedURBInfo, error) {
if errno != 0 {
return nil, fmt.Errorf("USBDEVFS_REAPURB: %w", errno)
}
urb := (*usbdevfsURB)(unsafe.Pointer(urbPtr))
urb := urbFromKernelPtr(urbPtr)
return &ReapedURBInfo{
UserContext: urb.UserContext,
Status: urb.Status,
Status: urb.Status,
ActualLength: urb.ActualLength,
StartFrame: urb.StartFrame,
ErrorCount: urb.ErrorCount,
StartFrame: urb.StartFrame,
ErrorCount: urb.ErrorCount,
}, nil
}