Files
usb-server/internal/client/use.go
T
duffyduckandClaude Opus 5 9ed473a965 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>
2026-08-11 22:02:04 +02:00

715 lines
20 KiB
Go

package client
import (
"fmt"
"log"
"net"
"strings"
"sync"
"time"
"github.com/duffy/usb-server/internal/config"
"github.com/duffy/usb-server/internal/crypto"
"github.com/duffy/usb-server/internal/protocol"
"github.com/duffy/usb-server/internal/usbip"
"github.com/google/uuid"
)
// attachRequestTimeout bounds how long we wait for a share client to answer a
// device request before giving up.
const attachRequestTimeout = 30 * time.Second
// RemoteDevice represents a USB device available from a share client
type RemoteDevice struct {
protocol.USBDevice
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
}
// AttachedDevice represents a device currently attached via VHCI
type AttachedDevice struct {
RemoteDevice
TunnelID string `json:"tunnel_id"`
VHCIPort int `json:"vhci_port"`
}
// UseManager handles receiving/using remote USB devices
type UseManager struct {
client *Client
cfg *config.Config
cfgPath string
mu sync.RWMutex
available map[string][]RemoteDevice // clientID -> devices
attached map[string]*AttachedDevice // busID@clientID -> attached info
tunnels map[string]*useTunnel // tunnelID -> tunnel
pending map[string]*pendingRequest // requestID -> in-flight attach
forceDetachable map[string]bool // clientID -> allow_force_detach
}
// pendingRequest tracks an attach request waiting for the share client's reply.
// It carries the target so that a reply arriving after the caller gave up can
// still be undone — otherwise the share side would hold the device open for a
// user who is no longer waiting for it.
type pendingRequest struct {
clientID string
busID string
resp chan *protocol.DeviceGranted
}
type useTunnel struct {
id string
busID string
clientID string
conn net.Conn // our end of the socketpair
// codec seals outgoing and opens incoming payloads, whichever transport
// carries them.
codec *tunnelCodec
// send delivers an encoded frame; direct is non-nil when this tunnel
// bypasses the relay.
send tunnelSender
direct *directConn
// done is closed exactly once. Several paths can tear down the same
// tunnel — an explicit detach, a release from the share side, the peer
// leaving, a relay disconnect — and closing it twice would panic.
done chan struct{}
closeOnce sync.Once
}
// tryDirect attempts a direct connection to the granting peer, returning nil
// if none can be established.
//
// Every failure here is benign: the tunnel simply runs through the relay, the
// way it always did. Only the latency improves when this succeeds.
func (um *UseManager) tryDirect(granted *protocol.DeviceGranted, secret *crypto.TunnelSecret) *directConn {
if um.cfg.DisableDirect || secret == nil || len(granted.Endpoints) == 0 {
return nil
}
conn, addr, err := dialDirect(granted.Endpoints, granted.TunnelID, secret.PeerToken(granted.TunnelID))
if err != nil {
log.Printf("[use] no direct route to %s (%v), using the relay", granted.BusID, err)
return nil
}
log.Printf("[use] direct connection to %s established for %s", addr, granted.BusID)
return conn
}
// NewUseManager creates a use manager
func NewUseManager(client *Client, cfg *config.Config, cfgPath string) *UseManager {
um := &UseManager{
client: client,
cfg: cfg,
cfgPath: cfgPath,
available: make(map[string][]RemoteDevice),
attached: make(map[string]*AttachedDevice),
tunnels: make(map[string]*useTunnel),
pending: make(map[string]*pendingRequest),
forceDetachable: make(map[string]bool),
}
// Use-side messages only this manager handles.
client.OnDeviceList = um.handleDeviceList
client.OnDeviceGranted = um.handleDeviceGranted
client.OnDeviceDenied = um.handleDeviceDenied
client.OnDeviceReleased = um.handleDeviceReleased
// Shared with the share manager in "both" mode, hence multicast.
client.AddTunnelHandler(um.handleTunnelData)
client.AddClientLeftHandler(um.handleClientLeft)
client.AddDisconnectHandler(um.handleRelayDisconnect)
return um
}
// handleRelayDisconnect detaches everything after the relay link drops.
// The relay discarded those tunnels, so the devices are dead: without this
// they would stay listed as attached while no traffic could reach them.
func (um *UseManager) handleRelayDisconnect() {
um.mu.Lock()
defer um.mu.Unlock()
if n := len(um.attached); n > 0 {
log.Printf("[use] detaching %d device(s) (relay connection lost)", n)
for key, dev := range um.attached {
um.closeAttachedLocked(key, dev)
}
}
// The peers that advertised these are unreachable, and their device lists
// are re-sent on reconnect. Keeping stale entries would show devices the
// UI cannot actually attach.
um.available = make(map[string][]RemoteDevice)
um.forceDetachable = make(map[string]bool)
}
// closeAttachedLocked tears down one attached device: its tunnel, its VHCI
// port and its bookkeeping. Callers must hold um.mu.
func (um *UseManager) closeAttachedLocked(key string, dev *AttachedDevice) {
if tunnel, ok := um.tunnels[dev.TunnelID]; ok {
tunnel.closeOnce.Do(func() { close(tunnel.done) })
if tunnel.conn != nil {
tunnel.conn.Close()
}
if tunnel.direct != nil {
tunnel.direct.Close()
}
delete(um.tunnels, dev.TunnelID)
}
if dev.VHCIPort >= 0 {
if err := usbip.DetachDevice(dev.VHCIPort); err != nil {
log.Printf("[use] warning: VHCI detach error for %s: %v", key, err)
}
}
delete(um.attached, key)
}
// GetAvailableDevices returns all available remote devices
func (um *UseManager) GetAvailableDevices() []RemoteDevice {
um.mu.RLock()
defer um.mu.RUnlock()
var all []RemoteDevice
for _, devs := range um.available {
all = append(all, devs...)
}
return all
}
// GetAttachedDevices returns currently attached devices
func (um *UseManager) GetAttachedDevices() []*AttachedDevice {
um.mu.RLock()
defer um.mu.RUnlock()
var result []*AttachedDevice
for _, dev := range um.attached {
result = append(result, dev)
}
return result
}
// AttachDevice requests and attaches a remote USB device
func (um *UseManager) AttachDevice(clientID, busID string) error {
// Check if VHCI is available
if err := usbip.VHCIUnavailableError(); err != nil {
return err
}
key := busID + "@" + clientID
um.mu.RLock()
if _, already := um.attached[key]; already {
um.mu.RUnlock()
return fmt.Errorf("device %s already attached", key)
}
um.mu.RUnlock()
// Create request
requestID := uuid.New().String()
respChan := make(chan *protocol.DeviceGranted, 1)
um.mu.Lock()
um.pending[requestID] = &pendingRequest{clientID: clientID, busID: busID, resp: respChan}
um.mu.Unlock()
defer func() {
um.mu.Lock()
delete(um.pending, requestID)
um.mu.Unlock()
}()
// Send request to relay
err := um.client.SendJSON(&protocol.RequestDevice{
Type: protocol.MsgRequestDevice,
TargetClient: clientID,
BusID: busID,
RequestID: requestID,
})
if err != nil {
return fmt.Errorf("sending request: %w", err)
}
log.Printf("[use] requesting device %s from %s", busID, clientID)
// Wait for a grant or denial. Without the timeout a share client that
// never answers — because it crashed, or the relay dropped the message —
// would leave this call blocked forever, and with it the HTTP request or
// auto-connect goroutine that made it.
timer := time.NewTimer(attachRequestTimeout)
defer timer.Stop()
select {
case granted, ok := <-respChan:
if !ok || granted == nil {
return fmt.Errorf("device request denied")
}
return um.setupVHCI(clientID, busID, granted)
case <-timer.C:
return fmt.Errorf("no response from %s for device %s after %s",
protocol.ShortID(clientID), busID, attachRequestTimeout)
case <-um.client.ctx.Done():
return fmt.Errorf("client shutting down")
}
}
// DetachDevice releases an attached device
func (um *UseManager) DetachDevice(clientID, busID string) error {
key := busID + "@" + clientID
um.mu.Lock()
dev, exists := um.attached[key]
if !exists {
um.mu.Unlock()
return fmt.Errorf("device %s not attached", key)
}
um.closeAttachedLocked(key, dev)
um.mu.Unlock()
// Notify share client
um.client.SendJSON(&protocol.ReleaseDevice{
Type: protocol.MsgReleaseDevice,
TargetClient: clientID,
BusID: busID,
})
log.Printf("[use] device %s detached", key)
return nil
}
func (um *UseManager) setupVHCI(clientID, busID string, granted *protocol.DeviceGranted) error {
// Look up device info from available list (needed for Windows management phase)
var devInfo *RemoteDevice
um.mu.RLock()
for _, d := range um.available[clientID] {
if d.BusID == busID {
cp := d
devInfo = &cp
break
}
}
um.mu.RUnlock()
// The granting side tells us whether it encrypts. Both ends must agree:
// a mismatch would turn ciphertext into garbage USB traffic.
secret := um.client.TunnelSecret()
if granted.Encrypted && secret == nil {
return fmt.Errorf("%s encrypts its tunnels but this client has no tokens configured, "+
"only a group hash — copy the three tokens over to connect", protocol.ShortID(clientID))
}
if !granted.Encrypted {
if secret != nil {
log.Printf("[use] warning: %s does not encrypt tunnel traffic for %s",
protocol.ShortID(clientID), busID)
}
secret = nil
}
codec, err := newTunnelCodec(secret, granted.TunnelID, crypto.DirUseToShare)
if err != nil {
return fmt.Errorf("setting up tunnel encryption: %w", err)
}
// Try to reach the peer directly before falling back to the relay. This
// is where the latency win comes from: two machines on the same network
// otherwise send every USB transfer out to the relay and back.
direct := um.tryDirect(granted, secret)
// Platform-specific VHCI attachment (Linux: socketpair+sysfs, Windows: TCP proxy+usbip.exe)
tunnelConn, vhciPort, err := createVHCIAttachment(um.client.ctx, granted, devInfo)
if err != nil {
if direct != nil {
direct.Close()
}
return fmt.Errorf("VHCI attachment: %w", err)
}
tunnel := &useTunnel{
id: granted.TunnelID,
busID: busID,
clientID: clientID,
conn: tunnelConn,
done: make(chan struct{}),
codec: codec,
direct: direct,
}
if direct != nil {
tunnel.send = directSender(direct)
} else {
tunnel.send = relaySender(um.client, granted.TunnelID)
}
key := busID + "@" + clientID
remDev := RemoteDevice{
USBDevice: protocol.USBDevice{BusID: busID},
ClientID: clientID,
}
if devInfo != nil {
remDev = *devInfo
}
um.mu.Lock()
um.tunnels[granted.TunnelID] = tunnel
um.attached[key] = &AttachedDevice{
RemoteDevice: remDev,
TunnelID: granted.TunnelID,
VHCIPort: vhciPort,
}
um.mu.Unlock()
// Start reading from the tunnel socket (VHCI -> peer)
go um.tunnelReadLoop(tunnel)
// On a direct connection, incoming frames arrive here instead of through
// the relay's tunnel-data callback.
if direct != nil {
go func() {
receiveLoop(direct, tunnel.codec, func(payload []byte) error {
_, err := tunnel.conn.Write(payload)
return err
}, tunnel.done, "use/"+busID)
// Losing the direct connection ends the tunnel: the USB/IP stream
// cannot be resumed on the relay mid-conversation.
select {
case <-tunnel.done:
default:
log.Printf("[use] direct connection for %s ended, detaching", key)
um.DetachDevice(clientID, busID)
}
}()
}
transport := "relay"
if direct != nil {
transport = "direct " + direct.RemoteAddr()
}
log.Printf("[use] device %s attached on VHCI port %d (devID=0x%08x speed=%d, %s, encrypted=%v)",
key, vhciPort, granted.DevID, granted.Speed, transport, codec.encrypted())
// Check device status and fix permissions on newly created device nodes
// VHCI-created devices don't get normal udev permissions
go func() {
logVHCIDeviceStatus(vhciPort)
fixVHCIDevicePermissions(vhciPort)
}()
return nil
}
// tunnelReadLoop reads from the VHCI socket and sends to relay
func (um *UseManager) tunnelReadLoop(tunnel *useTunnel) {
buf := make([]byte, 65536)
for {
select {
case <-tunnel.done:
return
default:
}
n, err := tunnel.conn.Read(buf)
if err != nil {
select {
case <-tunnel.done:
return
default:
log.Printf("[use] tunnel read error: %v", err)
return
}
}
if protocol.Debug {
usbip.TraceRequest("use-tunnel", buf[:n])
}
if err := send(tunnel.codec, tunnel.send, buf[:n]); err != nil {
log.Printf("[use] tunnel send error: %v", err)
return
}
}
}
func (um *UseManager) handleDeviceList(msg *protocol.DeviceList) {
um.mu.Lock()
var remoteDevs []RemoteDevice
for _, dev := range msg.Devices {
remoteDevs = append(remoteDevs, RemoteDevice{
USBDevice: dev,
ClientID: msg.ClientID,
ClientName: msg.ClientName,
})
}
um.available[msg.ClientID] = remoteDevs
um.forceDetachable[msg.ClientID] = msg.AllowForceDetach
// Collect devices to auto-connect (while holding the lock to check attached map)
var toAutoConnect []RemoteDevice
for _, dev := range remoteDevs {
if dev.Status != protocol.StatusAvailable {
continue
}
key := dev.BusID + "@" + msg.ClientID
if _, attached := um.attached[key]; attached {
continue
}
if um.matchesAutoConnect(dev) {
toAutoConnect = append(toAutoConnect, dev)
}
}
um.mu.Unlock()
log.Printf("[use] received device list from %s (%s): %d devices",
msg.ClientName, protocol.ShortID(msg.ClientID), len(msg.Devices))
// Auto-connect matching devices (outside lock, each in its own goroutine)
for _, dev := range toAutoConnect {
log.Printf("[use] auto-connecting %s (%s:%s) from %s",
dev.Name, dev.VendorID, dev.ProductID, msg.ClientName)
go um.AttachDevice(msg.ClientID, dev.BusID)
}
}
// matchesAutoConnect checks if a device matches any auto-connect rule.
// Must be called with um.mu held (at least RLock).
func (um *UseManager) matchesAutoConnect(dev RemoteDevice) bool {
for _, rule := range um.cfg.AutoConnect {
if rule.VendorID != "" && !strings.EqualFold(rule.VendorID, dev.VendorID) {
continue
}
if rule.ProductID != "" && !strings.EqualFold(rule.ProductID, dev.ProductID) {
continue
}
if rule.BusID != "" && rule.BusID != dev.BusID {
continue
}
if rule.ClientName != "" && rule.ClientName != dev.ClientName {
continue
}
return true // all specified fields match
}
return false
}
// SetAutoConnect adds or removes an auto-connect rule for a VendorID:ProductID pair.
func (um *UseManager) SetAutoConnect(vendorID, productID string, enabled bool) error {
um.mu.Lock()
defer um.mu.Unlock()
if enabled {
// Check if rule already exists
for _, rule := range um.cfg.AutoConnect {
if strings.EqualFold(rule.VendorID, vendorID) && strings.EqualFold(rule.ProductID, productID) {
return nil // already exists
}
}
um.cfg.AutoConnect = append(um.cfg.AutoConnect, config.AutoConnectRule{
VendorID: vendorID,
ProductID: productID,
})
} else {
// Remove matching rule
filtered := um.cfg.AutoConnect[:0]
for _, rule := range um.cfg.AutoConnect {
if strings.EqualFold(rule.VendorID, vendorID) && strings.EqualFold(rule.ProductID, productID) {
continue
}
filtered = append(filtered, rule)
}
um.cfg.AutoConnect = filtered
}
if err := um.cfg.Save(um.cfgPath); err != nil {
return fmt.Errorf("saving config: %w", err)
}
log.Printf("[use] auto-connect %s:%s = %v", vendorID, productID, enabled)
return nil
}
// IsAutoConnect checks if there is an auto-connect rule for this VendorID:ProductID.
func (um *UseManager) IsAutoConnect(vendorID, productID string) bool {
um.mu.RLock()
defer um.mu.RUnlock()
for _, rule := range um.cfg.AutoConnect {
if strings.EqualFold(rule.VendorID, vendorID) && strings.EqualFold(rule.ProductID, productID) {
return true
}
}
return false
}
// IsForceDetachable checks if a share client allows force-detach
func (um *UseManager) IsForceDetachable(clientID string) bool {
um.mu.RLock()
defer um.mu.RUnlock()
return um.forceDetachable[clientID]
}
// ForceDetachDevice sends a force-release request to the share client
func (um *UseManager) ForceDetachDevice(clientID, busID string) error {
return um.client.SendJSON(&protocol.ForceRelease{
Type: protocol.MsgForceRelease,
TargetClient: clientID,
BusID: busID,
})
}
// resolvePending hands a response to the waiting AttachDevice call and removes
// the request, so that a duplicate or late reply cannot reach the channel
// twice — a grant arriving after a denial closed it would panic.
func (um *UseManager) resolvePending(requestID string) (*pendingRequest, bool) {
um.mu.Lock()
defer um.mu.Unlock()
req, exists := um.pending[requestID]
if exists {
delete(um.pending, requestID)
}
return req, exists
}
func (um *UseManager) handleDeviceGranted(msg *protocol.DeviceGranted) {
req, exists := um.resolvePending(msg.RequestID)
if !exists {
// Nobody is waiting any more — the request timed out, or the caller
// gave up. The share client has already claimed the device for us, so
// hand it back instead of leaving it stuck in "in use".
log.Printf("[use] late grant for %s, releasing it again", msg.BusID)
um.releaseOrphanedGrant(msg)
return
}
// The channel is buffered with capacity 1 and we are the only sender for
// this request ID, so this never blocks.
req.resp <- msg
}
// releaseOrphanedGrant tells the share client to take back a device that was
// granted to a request nobody is waiting for.
func (um *UseManager) releaseOrphanedGrant(msg *protocol.DeviceGranted) {
// Find who owns this bus ID; the grant message does not name the sender.
um.mu.RLock()
var owner string
for clientID, devs := range um.available {
for _, d := range devs {
if d.BusID == msg.BusID {
owner = clientID
break
}
}
if owner != "" {
break
}
}
um.mu.RUnlock()
if owner == "" {
return
}
um.client.SendJSON(&protocol.ReleaseDevice{
Type: protocol.MsgReleaseDevice,
TargetClient: owner,
BusID: msg.BusID,
})
}
func (um *UseManager) handleDeviceDenied(msg *protocol.DeviceDenied) {
log.Printf("[use] device request denied: %s - %s", msg.BusID, msg.Reason)
if req, exists := um.resolvePending(msg.RequestID); exists {
close(req.resp) // a closed channel reads as a denial
}
}
func (um *UseManager) handleDeviceReleased(msg *protocol.DeviceReleased) {
log.Printf("[use] device released by share client: %s", msg.BusID)
um.mu.Lock()
// Find and clean up any attached device matching this BusID (and ClientID if provided)
for key, dev := range um.attached {
if dev.BusID != msg.BusID {
continue
}
if msg.ClientID != "" && dev.ClientID != msg.ClientID {
continue
}
um.closeAttachedLocked(key, dev)
log.Printf("[use] device %s cleaned up (released by share client)", key)
break
}
um.mu.Unlock()
}
func (um *UseManager) handleTunnelData(tunnelID string, data []byte) {
um.mu.RLock()
tunnel, exists := um.tunnels[tunnelID]
um.mu.RUnlock()
if !exists {
// In "both" mode the share manager sees the same frames and owns the
// other tunnels, so an unknown ID here is normal, not an error.
return
}
// A tunnel running directly gets its frames from that connection; anything
// arriving via the relay for it is stale or spoofed.
if tunnel.direct != nil {
return
}
payload, err := tunnel.codec.decode(data)
if err != nil {
log.Printf("[use] tunnel %s: rejecting relayed frame: %v", protocol.ShortID(tunnelID), err)
tunnel.closeOnce.Do(func() { close(tunnel.done) })
tunnel.conn.Close()
return
}
if protocol.Debug {
usbip.TraceResponse("use-tunnel", payload)
}
// Write to the tunnel socket (peer -> VHCI). A failed write would desync
// the USB/IP stream permanently, so treat it as fatal for this tunnel.
if _, err := tunnel.conn.Write(payload); err != nil {
log.Printf("[use] tunnel %s write error: %v", protocol.ShortID(tunnelID), err)
tunnel.closeOnce.Do(func() { close(tunnel.done) })
tunnel.conn.Close()
}
}
func (um *UseManager) handleClientLeft(msg *protocol.ClientLeft) {
um.mu.Lock()
delete(um.available, msg.ClientID)
delete(um.forceDetachable, msg.ClientID)
// Detach any devices from this client
for key, dev := range um.attached {
if dev.ClientID == msg.ClientID {
um.closeAttachedLocked(key, dev)
log.Printf("[use] device %s auto-detached (client left)", key)
}
}
um.mu.Unlock()
}
// Cleanup releases all attached devices
func (um *UseManager) Cleanup() {
um.mu.Lock()
defer um.mu.Unlock()
for key, dev := range um.attached {
um.closeAttachedLocked(key, dev)
log.Printf("[use] cleaned up device %s", key)
}
um.attached = make(map[string]*AttachedDevice)
um.tunnels = make(map[string]*useTunnel)
}