Files
usb-server/internal/client/direct.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

306 lines
8.5 KiB
Go

package client
import (
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"net"
"sync"
"time"
"github.com/duffy/usb-server/internal/protocol"
)
// Direct tunnel wire format.
//
// Handshake, sent by the connecting (use) side:
//
// [4] magic "USBD" [1] version [16] tunnel ID [32] peer token
//
// Reply, sent by the listening (share) side:
//
// [4] magic "USBD" [1] version [1] status (0 = accepted)
//
// Everything after that is length-prefixed encrypted frames:
//
// [4] length (big endian) [length bytes] sealed frame
const (
directMagic = "USBD"
directVersion = 1
handshakeSize = 4 + 1 + protocol.TunnelHeaderSize + 32
handshakeReplySize = 4 + 1 + 1
// directHandshakeTimeout bounds the handshake. A peer that reaches the
// port but does not speak this protocol must not hold the slot.
directHandshakeTimeout = 5 * time.Second
// directDialTimeout bounds one connection attempt. Candidate addresses
// are tried in parallel, so this is also how long the whole attempt takes
// before falling back to the relay.
directDialTimeout = 3 * time.Second
// maxDirectFrame caps a single frame, so a hostile or corrupt length
// prefix cannot make us allocate arbitrarily.
maxDirectFrame = 2 << 20
)
// Handshake status codes.
const (
directAccepted = 0
directUnknownTun = 1
directBadToken = 2
directWrongVerson = 3
)
// directConn carries length-prefixed frames over a plain TCP connection.
//
// It deliberately does no encryption of its own: tunnel frames are sealed one
// level up, by the tunnel's codec, so that relayed and direct tunnels get the
// same protection. Putting it here instead would leave the relay path in
// cleartext — the one path where a third party is actually in the middle.
type directConn struct {
conn net.Conn
writeMu sync.Mutex
}
func newDirectConn(conn net.Conn) *directConn {
return &directConn{conn: conn}
}
// WriteFrame sends one length-prefixed frame.
func (d *directConn) WriteFrame(payload []byte) error {
if len(payload) > maxDirectFrame {
return fmt.Errorf("frame of %d bytes exceeds the %d byte limit", len(payload), maxDirectFrame)
}
buf := make([]byte, 4+len(payload))
binary.BigEndian.PutUint32(buf, uint32(len(payload)))
copy(buf[4:], payload)
// TCP writes from several goroutines would interleave and corrupt the
// framing, so sends are serialised.
d.writeMu.Lock()
defer d.writeMu.Unlock()
if _, err := d.conn.Write(buf); err != nil {
return fmt.Errorf("writing frame: %w", err)
}
return nil
}
// ReadFrame reads one length-prefixed frame.
func (d *directConn) ReadFrame() ([]byte, error) {
var lenBuf [4]byte
if _, err := io.ReadFull(d.conn, lenBuf[:]); err != nil {
return nil, err
}
length := binary.BigEndian.Uint32(lenBuf[:])
if length == 0 || length > maxDirectFrame {
return nil, fmt.Errorf("frame length %d is out of range", length)
}
payload := make([]byte, length)
if _, err := io.ReadFull(d.conn, payload); err != nil {
return nil, err
}
return payload, nil
}
// RemoteAddr reports the peer address, for logging.
func (d *directConn) RemoteAddr() string { return d.conn.RemoteAddr().String() }
// Close closes the underlying connection.
func (d *directConn) Close() error { return d.conn.Close() }
// buildHandshake assembles the greeting the connecting side sends.
func buildHandshake(tunnelID, peerToken string) ([]byte, error) {
tokenBytes, err := hex.DecodeString(peerToken)
if err != nil || len(tokenBytes) != 32 {
return nil, fmt.Errorf("invalid peer token")
}
if len(tunnelID) != protocol.TunnelHeaderSize {
return nil, fmt.Errorf("tunnel ID is %d bytes, want %d", len(tunnelID), protocol.TunnelHeaderSize)
}
buf := make([]byte, 0, handshakeSize)
buf = append(buf, directMagic...)
buf = append(buf, directVersion)
buf = append(buf, tunnelID...)
buf = append(buf, tokenBytes...)
return buf, nil
}
// parseHandshake validates the greeting and returns the requested tunnel ID
// and the presented token in hex form.
func parseHandshake(data []byte) (tunnelID, peerToken string, err error) {
if len(data) != handshakeSize {
return "", "", fmt.Errorf("handshake is %d bytes, want %d", len(data), handshakeSize)
}
if string(data[:4]) != directMagic {
return "", "", fmt.Errorf("bad magic")
}
if data[4] != directVersion {
return "", "", fmt.Errorf("unsupported version %d", data[4])
}
tunnelID = string(data[5 : 5+protocol.TunnelHeaderSize])
peerToken = hex.EncodeToString(data[5+protocol.TunnelHeaderSize:])
return tunnelID, peerToken, nil
}
func buildHandshakeReply(status byte) []byte {
buf := make([]byte, 0, handshakeReplySize)
buf = append(buf, directMagic...)
buf = append(buf, directVersion)
buf = append(buf, status)
return buf
}
func parseHandshakeReply(data []byte) error {
if len(data) != handshakeReplySize {
return fmt.Errorf("reply is %d bytes, want %d", len(data), handshakeReplySize)
}
if string(data[:4]) != directMagic {
return fmt.Errorf("bad magic in reply")
}
if data[4] != directVersion {
return fmt.Errorf("peer speaks version %d, we speak %d", data[4], directVersion)
}
switch data[5] {
case directAccepted:
return nil
case directUnknownTun:
return fmt.Errorf("peer does not know this tunnel")
case directBadToken:
return fmt.Errorf("peer rejected our token")
case directWrongVerson:
return fmt.Errorf("peer rejected our version")
default:
return fmt.Errorf("peer rejected the connection (status %d)", data[5])
}
}
// localEndpoints lists host:port addresses on this machine's own interfaces.
//
// Loopback is skipped — a peer on another machine cannot use it — but every
// other usable unicast address is offered, because which one is reachable
// depends on the network and only the attempt can tell.
func localEndpoints(port int) []string {
if port == 0 {
return nil
}
addrs, err := net.InterfaceAddrs()
if err != nil {
return nil
}
var endpoints []string
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip := ipNet.IP
if ip.IsLoopback() || ip.IsUnspecified() || !ip.IsGlobalUnicast() {
continue
}
// Link-local IPv6 needs a zone to be dialable and rarely helps here.
if ip.To4() == nil && ip.IsLinkLocalUnicast() {
continue
}
endpoints = append(endpoints, net.JoinHostPort(ip.String(), fmt.Sprint(port)))
}
return endpoints
}
// dialDirect races the candidate addresses and returns the first connection
// that completes the handshake.
//
// Racing rather than trying in sequence matters: an unreachable address on a
// different subnet typically does not refuse the connection, it hangs until
// the timeout, and trying those one after another would take longer than the
// relay fallback it is meant to avoid.
func dialDirect(endpoints []string, tunnelID, peerToken string) (*directConn, string, error) {
if len(endpoints) == 0 {
return nil, "", fmt.Errorf("no candidate addresses")
}
greeting, err := buildHandshake(tunnelID, peerToken)
if err != nil {
return nil, "", err
}
type result struct {
conn *directConn
addr string
err error
}
results := make(chan result, len(endpoints))
for _, endpoint := range endpoints {
go func(addr string) {
conn, err := attemptDirect(addr, greeting)
results <- result{conn: conn, addr: addr, err: err}
}(endpoint)
}
var lastErr error
var winner *directConn
var winnerAddr string
// Collect every result so that a connection completing after we already
// have a winner still gets closed instead of leaking.
for range endpoints {
r := <-results
switch {
case r.err != nil:
lastErr = r.err
case winner == nil:
winner, winnerAddr = r.conn, r.addr
default:
r.conn.Close()
}
}
if winner == nil {
return nil, "", fmt.Errorf("no address reachable: %w", lastErr)
}
return winner, winnerAddr, nil
}
// attemptDirect performs one dial plus handshake.
func attemptDirect(addr string, greeting []byte) (*directConn, error) {
conn, err := net.DialTimeout("tcp", addr, directDialTimeout)
if err != nil {
return nil, err
}
conn.SetDeadline(time.Now().Add(directHandshakeTimeout))
if _, err := conn.Write(greeting); err != nil {
conn.Close()
return nil, fmt.Errorf("sending handshake to %s: %w", addr, err)
}
reply := make([]byte, handshakeReplySize)
if _, err := io.ReadFull(conn, reply); err != nil {
conn.Close()
return nil, fmt.Errorf("reading handshake reply from %s: %w", addr, err)
}
if err := parseHandshakeReply(reply); err != nil {
conn.Close()
return nil, fmt.Errorf("handshake with %s: %w", addr, err)
}
// Clear the handshake deadline; tunnel traffic has no fixed timing.
conn.SetDeadline(time.Time{})
return newDirectConn(conn), nil
}