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>
306 lines
8.5 KiB
Go
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
|
|
}
|