diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5265f51 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Build artefacts — the image builds its own binaries, and copying these in +# would both bloat the context and risk shipping a stale binary. +bin/ +usb-relay + +.git/ +.gitignore + +*.md +!README.md + +docker-compose.yml +Dockerfile* diff --git a/.gitignore b/.gitignore index 4c49bd7..0baf7e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,22 @@ .env + +# Cross-compiled release binaries: 15 targets, around 100 MB per build. +# Produced by "make release" when they are needed. +bin/release/ + +# Android build output. The client binary is built into jniLibs by the +# build step described in android/README.md, not committed. +android/.gradle/ +android/build/ +android/app/build/ +android/local.properties +android/app/src/main/jniLibs/*/libusbclient.so + +# Windows driver build output. +driver/windows/x64/ +driver/windows/ARM64/ +driver/windows/Debug/ +driver/windows/Release/ +*.sys +*.pdb +*.cat diff --git a/Dockerfile b/Dockerfile index 8c6d9f2..cd22730 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,12 @@ -FROM golang:1.26-alpine AS builder +# Relay server image. +# +# TARGETARCH is supplied by buildx and lets one build produce images for +# amd64, arm64 and arm — the relay is pure Go with no cgo, so cross-compiling +# is just a matter of setting GOARCH. +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder + +ARG TARGETARCH +ARG TARGETVARIANT WORKDIR /app @@ -6,7 +14,11 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /usb-relay ./cmd/usb-relay/ + +# TARGETVARIANT carries the ARM version ("v6", "v7") for 32-bit ARM images. +RUN GOARM=$(echo "$TARGETVARIANT" | tr -d 'v') \ + CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH \ + go build -ldflags="-s -w" -o /usb-relay ./cmd/usb-relay/ FROM alpine:3.21 @@ -14,7 +26,14 @@ RUN apk add --no-cache ca-certificates COPY --from=builder /usb-relay /usr/local/bin/usb-relay +# The relay holds no state and needs no privileges. +RUN adduser -D -u 10001 relay +USER relay + EXPOSE 8443 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget -q --spider http://localhost:8443/health || exit 1 + ENTRYPOINT ["usb-relay"] CMD ["--port", "8443"] diff --git a/Dockerfile.client b/Dockerfile.client new file mode 100644 index 0000000..b083269 --- /dev/null +++ b/Dockerfile.client @@ -0,0 +1,39 @@ +# Client image (share and/or use mode). +# +# Unlike the relay this container needs real access to the host's USB stack, +# which only works on a Linux host: containers share the host kernel, and that +# kernel is the one managing the devices. On macOS and Windows, Docker runs +# inside a Linux VM that never sees the USB hardware, so this image cannot +# share devices there — see README. +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder + +ARG TARGETARCH +ARG TARGETVARIANT + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN GOARM=$(echo "$TARGETVARIANT" | tr -d 'v') \ + CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH \ + go build -ldflags="-s -w" -o /usb-client ./cmd/usb-client/ + +FROM alpine:3.21 + +# usbutils gives lsusb for diagnosing what the container can actually see; +# kmod lets the entrypoint check whether vhci-hcd is loaded on the host. +RUN apk add --no-cache ca-certificates usbutils kmod + +COPY --from=builder /usb-client /usr/local/bin/usb-client +COPY docker/client-entrypoint.sh /usr/local/bin/client-entrypoint.sh +RUN chmod +x /usr/local/bin/client-entrypoint.sh + +# Runs as root deliberately: opening /dev/bus/usb, detaching kernel drivers +# and rebinding them afterwards all need privileges. +EXPOSE 8080 + +ENTRYPOINT ["/usr/local/bin/client-entrypoint.sh"] +CMD ["both"] diff --git a/Makefile b/Makefile index 0edcb2c..9d6f9fe 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,71 @@ -.PHONY: all relay client client-windows clean +.PHONY: all relay client client-windows release clean test docker docker-run docker-multiarch GOOS ?= linux GOARCH ?= amd64 +LDFLAGS := -s -w all: relay client relay: - CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/usb-relay ./cmd/usb-relay/ + CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o bin/usb-relay ./cmd/usb-relay/ client: - CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/usb-client ./cmd/usb-client/ + CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o bin/usb-client ./cmd/usb-client/ client-windows: - CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o bin/usb-client.exe ./cmd/usb-client/ + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/usb-client.exe ./cmd/usb-client/ + +test: + go test ./... + +# Cross-compiled builds. The Linux client works unchanged on every +# architecture below: sharing goes through usbdevfs and receiving through +# vhci-hcd, and neither is architecture specific. +# +# amd64 ordinary PCs, Intel-based Synology models +# arm64 Raspberry Pi 3/4/5 (64-bit OS), ARM Synology models, Android +# arm Raspberry Pi with a 32-bit OS, older ARM boards +# 386 old 32-bit x86 machines +# mips64le, mipsle several NAS and router platforms +# +# The relay additionally builds for macOS and Windows; it needs no USB access +# at all, so it runs anywhere Go runs. +CLIENT_TARGETS := \ + linux/amd64 \ + linux/arm64 \ + linux/arm \ + linux/386 \ + linux/mips64le \ + linux/mipsle \ + linux/riscv64 \ + windows/amd64 \ + windows/arm64 + +RELAY_TARGETS := \ + linux/amd64 \ + linux/arm64 \ + linux/arm \ + darwin/amd64 \ + darwin/arm64 \ + windows/amd64 + +release: clean + @mkdir -p bin/release + @for target in $(CLIENT_TARGETS); do \ + os=$${target%/*}; arch=$${target#*/}; \ + ext=""; [ "$$os" = "windows" ] && ext=".exe"; \ + echo " client $$os/$$arch"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -ldflags="$(LDFLAGS)" \ + -o bin/release/usb-client-$$os-$$arch$$ext ./cmd/usb-client/ || exit 1; \ + done + @for target in $(RELAY_TARGETS); do \ + os=$${target%/*}; arch=$${target#*/}; \ + ext=""; [ "$$os" = "windows" ] && ext=".exe"; \ + echo " relay $$os/$$arch"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -ldflags="$(LDFLAGS)" \ + -o bin/release/usb-relay-$$os-$$arch$$ext ./cmd/usb-relay/ || exit 1; \ + done + @echo "Binaries in bin/release/" docker: docker compose build @@ -22,3 +75,10 @@ docker-run: clean: rm -rf bin/ + +# Multi-architecture images. Needs "docker buildx create --use" once. +docker-multiarch: + docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \ + -f Dockerfile -t usb-server-relay:latest --load . + docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \ + -f Dockerfile.client -t usb-server-client:latest --load . diff --git a/README.md b/README.md index f66b24f..af9a1f6 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,50 @@ USB-Sharing ueber Netzwerk mit Relay-Server fuer NAT-Traversal. ``` ┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐ -│ Client (share) │──ws/wss─│ Relay Server │─ws/wss──│ Client (use) │ -│ gibt USB-Geraete│ │ (Docker) │ │ empfaengt USB- │ -│ frei │ │ gruppiert │ │ Geraete │ -│ Web-UI :8080 │ │ nach Hash │ │ Web-UI :8080 │ +│ Client (both) │──ws/wss─│ Relay Server │─ws/wss──│ Client (both) │ +│ gibt Geraete │ │ (Docker) │ │ gibt Geraete │ +│ frei UND │ │ gruppiert │ │ frei UND │ +│ empfaengt sie │ │ nach Hash │ │ empfaengt sie │ +│ Web-UI :8080 │ │ │ │ Web-UI :8080 │ └────────┬─────────┘ └──────────────┘ └────────┬─────────┘ │ │ - Physische USB Virtuelle USB - Geraete (vhci-hcd) + Physische USB-Geraete Virtuelle USB-Geraete + (usbdevfs) (vhci-hcd) ``` -**Relay-Server:** Einfacher WebSocket-Vermittler. Braucht keine Konfiguration - verbindet alle Clients die den gleichen Hash haben. Als Docker-Container deploybar. +**Relay-Server:** WebSocket-Vermittler. Braucht keine Konfiguration - verbindet alle Clients die den gleichen Hash haben. Als Docker-Container deploybar. Er vermittelt die Verbindungen und reicht Tunneldaten weiter, ohne das USB/IP-Protokoll zu interpretieren. -**Client:** Kann in zwei Modi betrieben werden: -- **Share-Modus:** Gibt alle lokalen USB-Geraete frei. Geraete werden erst dann vom System getrennt wenn ein Use-Client sie anfordert. -- **Use-Modus:** Zeigt verfuegbare Geraete von allen Share-Clients an. Geraete koennen einzeln verbunden/getrennt werden. +**Verbindungswege:** Nach der Vermittlung durch den Relay versuchen zwei +Clients, sich **direkt** zu verbinden. Klappt das - im gleichen LAN, ueber VPN, +bei oeffentlicher IP oder Portfreigabe - laeuft der USB-Verkehr an der Relay +vorbei. Das spart eine komplette Netzwerkstrecke, was bei USB/IP spuerbar ist: +jeder Transfer ist ein eigener Round-Trip, und die Latenz addiert sich auf. +Scheitert der Versuch (beide hinter NAT ohne Portfreigabe), laeuft alles +unveraendert weiter ueber den Relay. + +**Verschluesselung:** Tunneldaten sind Ende-zu-Ende mit AES-256-GCM +verschluesselt, auf beiden Wegen. Der Schluessel wird aus den **drei Tokens** +abgeleitet, nicht aus dem Hash - der Relay bekommt nur den Hash und kann +daraus die Tokens nicht zurueckrechnen. Er sieht also weder USB-Daten noch +kann er sich als Peer ausgeben. + +> **Was der Relay trotzdem sieht und kann:** Metadaten - wer in welcher Gruppe +> ist, welche Geraete angeboten werden, wann welches angefordert wird, und wie +> viel Verkehr fließt. Der Hash gruppiert, er authentifiziert nicht: wer ihn +> kennt, kann der Gruppe beitreten, Geraetelisten sehen und Geraete anfordern. +> Die Tunneldaten kann so jemand ohne die Tokens aber nicht lesen. +> +> **Clients ohne Tokens:** Wer nur den Hash konfiguriert hat, kann keine +> Schluessel ableiten. Solche Clients funktionieren weiter, aber unverschluesselt +> und ohne Direktverbindung - und ein Peer, der verschluesselt, lehnt sie ab. +> Die Web-UI zeigt im Kopf an, welcher Fall vorliegt. Abhilfe: die drei Tokens +> auf alle Clients kopieren (Token-Tab). Die Gruppen-ID aendert sich dadurch +> **nicht**, bestehende Setups bleiben also verbunden. + +**Client:** Kann in drei Modi betrieben werden: +- **both:** Gibt lokale Geraete frei *und* verbindet sich mit fremden. Das ist der uebliche Fall in einer Gruppe, in der jede Maschine Hardware verleiht und ausleiht. +- **share:** Gibt nur lokale USB-Geraete frei. Geraete werden erst dann vom System getrennt wenn ein Use-Client sie anfordert. +- **use:** Empfaengt nur. Zeigt verfuegbare Geraete aller Share-Clients an, einzeln verbindbar/trennbar. **Gruppierung:** 3 zufaellige Tokens werden zu einem SHA256-Hash kombiniert. Alle Clients mit dem gleichen Hash gehoeren zusammen. @@ -55,16 +84,23 @@ Hash: a1b2c3d4e5... Die 3 Tokens auf alle weiteren Clients kopieren. -### 3. USB-Geraete freigeben (Share-Modus) +### 3. Clients starten + +Auf jeder Maschine, die Geraete freigeben *und* empfangen soll: ```bash -./bin/usb-client share --relay ws://relay-server:8443 +sudo ./bin/usb-client both --relay ws://relay-server:8443 ``` -### 4. USB-Geraete empfangen (Use-Modus) +Root wird auf der Share-Seite gebraucht: das Oeffnen der Geraete unter +`/dev/bus/usb/`, das Loesen der Kernel-Treiber und das spaetere Zurueckbinden +laufen ueber privilegierte Schnittstellen. + +Wer die Rollen trennen will, startet stattdessen nur eine Seite: ```bash -./bin/usb-client use --relay ws://relay-server:8443 +sudo ./bin/usb-client share --relay ws://relay-server:8443 # nur freigeben +sudo ./bin/usb-client use --relay ws://relay-server:8443 # nur empfangen ``` Web-UI oeffnen: http://localhost:8080 @@ -146,12 +182,51 @@ choco install make ### Plattform-Unterstuetzung -| Funktion | Linux | Windows | -|----------|-------|---------| -| Share-Modus (USB-Geraete freigeben) | Ja | Nein (kein usbdevfs) | -| Use-Modus (USB-Geraete empfangen) | Ja (vhci-hcd) | Ja (usbip-win2) | -| Relay-Server | Ja | Ja | -| Web-UI / Config | Ja | Ja | +| Funktion | Linux | Windows | Android | macOS | +|----------|-------|---------|---------|-------| +| Share-Modus | Ja | Ungetestet (eigener Treiber noetig, siehe unten) | Ja, ueber App-Bridge | Nein (braucht IOKit) | +| Use-Modus | Ja (vhci-hcd) | Ja (usbip-win2) | Nein (kein vhci-hcd) | Nein (braucht Treiber) | +| Kombinierter Modus (`both`) | Ja | Nein | Nein | Nein | +| Relay-Server | Ja | Ja | - | Ja | +| Web-UI / Config | Ja | Ja | - | Ja | + +**Windows Share:** Der Code ist vorhanden (`driver/windows/` plus die +Go-Anbindung), aber der Filtertreiber wurde nie gebaut oder getestet. Er +braucht das WDK zum Bauen und ein EV-Zertifikat zum Verteilen. Details und +ehrliche Einordnung in [driver/windows/README.md](driver/windows/README.md). + +**Android Share:** Das Go-Binary laeuft dort unveraendert, aber eine App darf +`/dev/bus/usb` nicht oeffnen. Eine kleine App holt deshalb die Berechtigung +ueber das Framework und reicht den Dateideskriptor an den Client durch — siehe +[android/README.md](android/README.md). Die Go-Seite dieser Bruecke ist fertig +und getestet, die App-Seite ist eine ungetestete Referenzimplementierung. + +**Docker und USB:** Auf einem Linux-Host koennen Geraete in den Container +durchgereicht werden (`/dev/bus/usb` mounten, `privileged: true`). Auf macOS +und Windows geht das **nicht**: Docker laeuft dort in einer Linux-VM, die die +USB-Hardware des Hosts nie zu sehen bekommt. Container teilen sich den Kernel +des Hosts — auf Linux ist das derselbe Kernel, der die Geraete verwaltet, auf +den anderen Plattformen nicht. + +### Architekturen + +Der Linux-Client laeuft unveraendert auf jeder Architektur - usbdevfs und +vhci-hcd sind nicht architekturspezifisch. `make release` baut fuer alle: + +| Ziel | Typische Geraete | +|------|------------------| +| linux/amd64 | normale PCs, Intel-basierte Synology | +| linux/arm64 | Raspberry Pi 3/4/5 (64-Bit-OS), ARM-Synology | +| linux/arm | Raspberry Pi mit 32-Bit-OS, aeltere ARM-Boards | +| linux/386 | alte 32-Bit-x86-Rechner | +| linux/mips64le, linux/mipsle | diverse NAS- und Router-Plattformen | +| linux/riscv64 | RISC-V-Boards | +| windows/amd64, windows/arm64 | Windows (nur Use-Modus) | + +Voraussetzung bleibt in allen Faellen: Share braucht Zugriff auf +`/dev/bus/usb` (Root), Use braucht das Kernel-Modul `vhci-hcd`. Auf +NAS-Systemen mit eigenem Kernel ist `vhci-hcd` haeufig nicht vorhanden - +solche Geraete taugen dann als Share-Client, nicht als Use-Client. **Windows Use-Modus:** Benoetigt den [usbip-win2](https://github.com/vadimgrn/usbip-win2/releases) VHCI-Treiber (WHKL-zertifiziert, Microsoft-signiert). Der Client erkennt automatisch ob usbip-win2 installiert ist. @@ -227,9 +302,11 @@ Der Relay lauscht auf Port 8443. ``` usb-client generate-token # 3 Tokens + Hash generieren -usb-client share [optionen] # Share-Modus starten -usb-client use [optionen] # Use-Modus starten +usb-client both [optionen] # Freigeben und Empfangen gleichzeitig +usb-client share [optionen] # Nur freigeben +usb-client use [optionen] # Nur empfangen usb-client list # Lokale USB-Geraete auflisten +usb-client list -v # Mit Interfaces und Endpunkten (Diagnose) usb-client gui # Nur Web-UI starten usb-client config # Konfiguration anzeigen usb-client config set [optionen] # Konfiguration aendern @@ -248,6 +325,82 @@ usb-client uninstall-service # Service deinstallieren --no-gui Web-UI deaktivieren ``` +### Netzwerk + +Fuer Direktverbindungen oeffnet jeder Share-faehige Client einen TCP-Port +(standardmaessig zufaellig). Zwei Faelle brauchen Handarbeit: + +- **Feste Portfreigabe:** `direct_port` in der Config setzen und diesen Port + in Firewall/Router weiterleiten. Dann finden auch Peers hinter NAT hierher. +- **Gar keine Direktverbindung gewuenscht:** `disable_direct: true` setzt alles + zurueck auf den reinen Relay-Betrieb. + +Ob eine Verbindung direkt zustande kam, steht im Log des Use-Clients +(`direct connection to ... established` gegen `using the relay`). + +### Umgebungsvariablen + +``` +USBSRV_DEBUG=1 Protokolliert jeden einzelnen USB-Transfer (URB). + Nur zur Fehlersuche - ein aktives Video- oder Audiogeraet + erzeugt tausende URBs pro Sekunde, und das Protokollieren + kostet dann mehr Zeit als das Weiterleiten. +``` + +## Fehlersuche + +### Erste Anlaufstelle + +```bash +usb-client diag +``` + +Sagt fuer die jeweilige Maschine, ob Freigeben und Empfangen moeglich sind, +was im Weg steht und was dagegen hilft. Laeuft auf Linux, Windows und macOS +und prueft plattformspezifisch das Richtige: Rechte auf `/dev/bus/usb` und das +`vhci-hcd`-Modul unter Linux, Treiberstatus und Testsignierung unter Windows, +die IOKit-Lage unter macOS. Fuer jedes Geraet steht dabei, ob es freigegeben +werden kann - und wenn nicht, warum. + +Zum Weitergeben: + +```bash +usb-client diag -json # maschinenlesbar +usb-client diag -out report.txt # zusaetzlich in eine Datei +usb-client diag -id meinreport # zum konfigurierten Relay hochladen +``` + +Der Upload legt den Report unter `/diag/` ab, wo er 24 Stunden +liegt. Das erspart es, von einer schwer erreichbaren Maschine - headless NAS, +Windows-Rechner mitten im Treiber-Debugging - tausende Zeilen von Hand zu +kopieren. + +> Der Report enthaelt Hostname, Geraeteliste und OS-Version. Beim Hochladen +> auf einen Relay kann jeder mit der ID ihn lesen. Auf einem oeffentlichen +> Relay also eine schwer zu erratende ID waehlen, oder den Report lokal +> speichern und selbst weitergeben. + +### Haeufige Faelle + +**Geraet verbindet sich, liefert aber keine Daten.** +`usb-client list -v` auf der Share-Seite zeigt, wie jeder Endpunkt eingestuft +wird. Der Transfertyp dort entscheidet, wie die Transfers zum Geraet +geschickt werden - steht bei einem Interrupt-Endpunkt `bulk`, weist der +Kernel die Uebertragungen ab. Ohne Root-Rechte koennen die vollstaendigen +Deskriptoren nicht gelesen werden; dann erscheint ein Hinweis statt der +Endpunktliste. + +**Geraet bleibt nach einem Absturz auf "in Benutzung".** +Beim Verbindungsverlust zum Relay geben beide Seiten ihre Geraete +automatisch wieder frei. Bleibt trotzdem eines haengen, loest ein Neustart +des Share-Clients die Bindung; die Kernel-Treiber werden dabei ueber +sysfs `authorized` neu gebunden. + +**Verbindung bricht regelmaessig ab.** +Client und Relay senden alle 20 Sekunden WebSocket-Pings und trennen nach 60 +Sekunden Stille. Liegt ein Proxy dazwischen, der WebSockets frueher schliesst, +muss dessen Timeout hoeher liegen als 60 Sekunden. + ## Web-UI Die Web-UI ist unter http://localhost:8080 erreichbar und bietet: @@ -293,9 +446,27 @@ sudo ./bin/usb-client uninstall-service ## Sicherheit -- **Transport-Verschluesselung:** Verwende `wss://` (WebSocket over TLS) fuer den Relay-Server bei Einsatz ueber das Internet. -- **Gruppierung:** Die 3 Tokens dienen als gemeinsames Geheimnis. Nur wer alle 3 Tokens kennt kann den Hash berechnen. -- **Relay:** Der Relay-Server sieht nur den Hash, nicht die Tokens. +- **Tunnel-Verschluesselung:** USB-Daten werden Ende-zu-Ende mit AES-256-GCM + verschluesselt - sowohl ueber den Relay als auch bei Direktverbindung. Der + Schluessel wird per HKDF aus den 3 Tokens abgeleitet, pro Tunnel neu. Jeder + Frame ist authentifiziert; manipulierte oder wiederholte Frames beenden den + Tunnel. +- **Gruppierung:** Die 3 Tokens sind das gemeinsame Geheimnis. Der Relay + bekommt nur ihren SHA256-Hash und kann daraus die Tokens nicht + zurueckrechnen - er kann also weder mitlesen noch sich als Peer ausgeben. +- **Direktverbindungen** werden mit einem aus den Tokens abgeleiteten Token + authentifiziert, das an die Tunnel-ID gebunden ist. Wer nur den Port + erreicht, kommt nicht hinein. +- **Transport-Verschluesselung:** Trotzdem `wss://` verwenden, wenn der Relay + ueber das Internet laeuft. Das schuetzt die Steuernachrichten und Metadaten, + die nicht Teil der Tunnel-Verschluesselung sind. + +**Grenzen:** Der Hash gruppiert, er authentifiziert nicht - wer ihn kennt, +kann der Gruppe beitreten, Geraetelisten sehen und Geraete anfordern (ohne +die Tokens aber keine Tunneldaten lesen). Der Relay sieht Metadaten: wer +verbunden ist, welche Geraete angeboten und wann sie angefordert werden. +Ein Client mit den Tokens hat vollen Zugriff auf alle freigegebenen Geraete +der Gruppe; eine Rechteverwaltung pro Geraet oder Client gibt es nicht. Fuer TLS am Relay-Server empfiehlt sich ein Reverse-Proxy (nginx/traefik) mit Let's Encrypt: diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..9128e82 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,4 @@ +.gradle/ +build/ +local.properties +app/src/main/jniLibs/*/libusbclient.so diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..4722c17 --- /dev/null +++ b/android/README.md @@ -0,0 +1,70 @@ +# Android-Share + +Android ist Linux, und das `usb-client`-Binary laeuft dort unveraendert +(`GOARCH=arm64`). Der Haken sitzt woanders: eine App darf `/dev/bus/usb` nicht +oeffnen und sysfs nicht durchsuchen. Geraetezugriff laeuft ausschliesslich +ueber das Framework, das einen Berechtigungsdialog zeigt und einen **bereits +geoeffneten Dateideskriptor** zurueckgibt. + +Diese App tut deshalb genau drei Dinge: + +1. Geraete ueber `UsbManager` auflisten und Berechtigung erfragen +2. Deskriptor und Rohdeskriptoren an das Go-Binary uebergeben +3. Das Binary als Kindprozess starten und am Leben halten + +Das Go-Binary macht danach alles Weitere selbst — es spricht dieselben +usbdevfs-ioctls wie auf jedem anderen Linux, nur der Weg zum Dateideskriptor +ist ein anderer. + +``` +┌──────────────────────────┐ +│ App (Kotlin) │ +│ UsbManager │ +│ → Berechtigungsdialog │ +│ → openDevice() │ +│ → getRawDescriptors() │ +└───────────┬──────────────┘ + │ Unix-Socket, fd per SCM_RIGHTS +┌───────────▼──────────────┐ +│ usb-client (Go, arm64) │ +│ usbdevfs-ioctls auf fd │ +│ → Relay / Direkttunnel │ +└──────────────────────────┘ +``` + +## Status + +Der Go-seitige Teil ist fertig und getestet (`internal/bridge`). Was hier +liegt, ist die App-Seite als **Referenzimplementierung**: der Code ist +vollstaendig, aber ich konnte ihn nicht bauen oder auf einem Geraet laufen +lassen. Er braucht Android Studio, ein Geraet mit USB-OTG und vermutlich ein +paar Korrekturen. Die Protokollseite ist der verlaessliche Teil — sie hat +Tests. + +## Bauen + +```bash +# 1. Go-Binary fuer Android bauen und in die App legen +GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" \ + -o android/app/src/main/jniLibs/arm64-v8a/libusbclient.so ./cmd/usb-client/ + +# 2. App bauen +cd android && ./gradlew assembleDebug +``` + +Die Endung `.so` ist kein Versehen: Android extrahiert nur Dateien aus +`jniLibs`, die so heissen, und nur die duerfen ausgefuehrt werden. Ein +normales Binary in den Assets bekaeme kein Ausfuehrungsrecht. + +## Grenzen + +- **Nur Share.** Der Use-Modus braucht `vhci-hcd`, und das ist in + Android-Kerneln praktisch nie aktiviert. Ein Telefon kann seine Geraete + also anbieten, aber keine fremden empfangen. +- **Berechtigung pro Geraet.** Der Dialog erscheint fuer jedes Geraet + einzeln; ohne Bestaetigung gibt es keinen Deskriptor. +- **Der Kernel muss OTG unterstuetzen.** Ohne USB-Host-Modus gibt es nichts + zu teilen. +- **Doze.** Android schlaefert Hintergrundprozesse ein. Die App laeuft + deshalb als Foreground-Service mit Notification; ohne das beendet das + System den Tunnel nach kurzer Zeit. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..c2b2812 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "de.usbserver.bridge" + compileSdk = 35 + + defaultConfig { + applicationId = "de.usbserver.bridge" + // USB host APIs need 12+; the foreground service type needs 29+. + minSdk = 29 + targetSdk = 35 + versionCode = 1 + versionName = "0.1" + } + + // The usb-client binary ships as libusbclient.so in jniLibs. It must stay + // uncompressed and be extracted at install time, or it cannot be executed. + packaging { + jniLibs { + useLegacyPackaging = true + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..da42a1b --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/de/usbserver/bridge/ClientProcess.kt b/android/app/src/main/java/de/usbserver/bridge/ClientProcess.kt new file mode 100644 index 0000000..0ab9cc1 --- /dev/null +++ b/android/app/src/main/java/de/usbserver/bridge/ClientProcess.kt @@ -0,0 +1,121 @@ +package de.usbserver.bridge + +import android.content.Context +import android.util.Log +import org.json.JSONObject +import java.io.File + +/** + * Runs the usb-client binary as a child process. + * + * The binary ships in jniLibs as libusbclient.so. That naming is not + * cosmetic: Android extracts and grants execute permission only to files in + * the native library directory, so a binary placed in assets could not be run + * at all on modern releases. + */ +class ClientProcess(private val context: Context) { + + private var process: Process? = null + private var logThread: Thread? = null + + val socketPath: String get() = UsbBridge.defaultSocketPath(context) + + /** Writes the config the client reads on startup. */ + fun writeConfig(relayAddr: String, tokens: Triple, name: String) { + val config = JSONObject().apply { + put("relay_addr", relayAddr) + put("hash", hashOf(tokens)) + // The tokens themselves must be present, not just the hash: + // without them the client cannot derive the tunnel key and falls + // back to unencrypted, relay-only operation. + put("token1", tokens.first) + put("token2", tokens.second) + put("token3", tokens.third) + // Only share: Android kernels have no vhci-hcd, so this device + // can offer its USB hardware but not receive anyone else's. + put("mode", "share") + put("name", name) + put("bridge_socket", socketPath) + // The web UI would be reachable by any app on the device. + put("web_port", 0) + } + + configFile().writeText(config.toString()) + } + + fun start(): Result { + if (process?.isAlive == true) { + return Result.success(Unit) + } + + val binary = File(context.applicationInfo.nativeLibraryDir, "libusbclient.so") + if (!binary.exists()) { + return Result.failure( + IllegalStateException("libusbclient.so is missing; build it into jniLibs first") + ) + } + + // A socket left over from a previous run would stop the client binding. + File(socketPath).delete() + + return try { + val started = ProcessBuilder( + binary.absolutePath, + "share", + "--config", configFile().absolutePath, + "--no-gui", + ) + .redirectErrorStream(true) + .start() + + process = started + logThread = Thread { drainLog(started) }.apply { + isDaemon = true + start() + } + + Log.i(TAG, "usb-client started") + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + fun stop() { + process?.destroy() + process = null + File(socketPath).delete() + } + + val isRunning: Boolean get() = process?.isAlive == true + + /** + * Forwards the client's output to logcat. + * + * Without this the process's diagnostics are simply lost, which makes any + * failure — a wrong relay address, a rejected device — invisible. + */ + private fun drainLog(process: Process) { + try { + process.inputStream.bufferedReader().forEachLine { line -> + Log.i(TAG, line) + } + } catch (e: Exception) { + Log.d(TAG, "log stream ended: ${e.message}") + } + } + + private fun configFile() = File(context.filesDir, "config.json") + + /** SHA-256 over the three tokens joined by colons — must match token.Hash. */ + private fun hashOf(tokens: Triple): String { + val combined = "${tokens.first}:${tokens.second}:${tokens.third}" + val digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(combined.toByteArray()) + return digest.joinToString("") { "%02x".format(it) } + } + + companion object { + private const val TAG = "UsbClientProcess" + } +} diff --git a/android/app/src/main/java/de/usbserver/bridge/MainActivity.kt b/android/app/src/main/java/de/usbserver/bridge/MainActivity.kt new file mode 100644 index 0000000..3f0df3b --- /dev/null +++ b/android/app/src/main/java/de/usbserver/bridge/MainActivity.kt @@ -0,0 +1,97 @@ +package de.usbserver.bridge + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import android.app.Activity + +/** + * Minimal setup screen: relay address, the three tokens, start and stop. + * + * Deliberately plain — the interesting part of this app is [UsbBridge], which + * gets the file descriptor across to the client. Anything nicer belongs in a + * proper UI layer and is not needed to make sharing work. + */ +class MainActivity : Activity() { + + private lateinit var relayField: EditText + private lateinit var token1Field: EditText + private lateinit var token2Field: EditText + private lateinit var token3Field: EditText + private lateinit var statusView: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val prefs = getSharedPreferences("settings", MODE_PRIVATE) + + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(48, 48, 48, 48) + } + + fun field(hint: String, key: String): EditText = + EditText(this).apply { + this.hint = hint + setText(prefs.getString(key, "")) + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + }.also { root.addView(it) } + + relayField = field("ws://relay:8443", "relay") + token1Field = field("Token 1", "token1") + token2Field = field("Token 2", "token2") + token3Field = field("Token 3", "token3") + + statusView = TextView(this).apply { text = "Stopped" } + + root.addView(Button(this).apply { + text = "Start sharing" + setOnClickListener { + prefs.edit() + .putString("relay", relayField.text.toString()) + .putString("token1", token1Field.text.toString()) + .putString("token2", token2Field.text.toString()) + .putString("token3", token3Field.text.toString()) + .apply() + + val intent = Intent(this@MainActivity, ShareService::class.java).apply { + putExtra(ShareService.EXTRA_RELAY, relayField.text.toString()) + putExtra(ShareService.EXTRA_TOKEN1, token1Field.text.toString()) + putExtra(ShareService.EXTRA_TOKEN2, token2Field.text.toString()) + putExtra(ShareService.EXTRA_TOKEN3, token3Field.text.toString()) + putExtra(ShareService.EXTRA_NAME, Build.MODEL) + } + startForegroundService(intent) + statusView.text = "Running — see the notification" + } + }) + + root.addView(Button(this).apply { + text = "Stop" + setOnClickListener { + stopService(Intent(this@MainActivity, ShareService::class.java)) + statusView.text = "Stopped" + } + }) + + root.addView(statusView) + root.addView(TextView(this).apply { + text = "\nThe same three tokens must be configured on every client " + + "in the group. Without them traffic is neither encrypted nor " + + "able to bypass the relay.\n\n" + + "This device can only share its own USB devices. Receiving " + + "remote ones needs the vhci-hcd kernel module, which Android " + + "kernels do not include." + }) + + setContentView(root) + } +} diff --git a/android/app/src/main/java/de/usbserver/bridge/ShareService.kt b/android/app/src/main/java/de/usbserver/bridge/ShareService.kt new file mode 100644 index 0000000..de39e91 --- /dev/null +++ b/android/app/src/main/java/de/usbserver/bridge/ShareService.kt @@ -0,0 +1,206 @@ +package de.usbserver.bridge + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.os.Build +import android.os.IBinder +import android.util.Log + +/** + * Foreground service that keeps the client running and offers devices to it. + * + * A foreground service with a visible notification is not optional here: + * Android's Doze and background limits would otherwise suspend or kill the + * process, and a suspended process means a USB device that silently stops + * responding for whoever is using it remotely. + */ +class ShareService : Service() { + + private lateinit var clientProcess: ClientProcess + private lateinit var bridge: UsbBridge + private lateinit var usbManager: UsbManager + + /** + * Receives permission results and unplug events. + * + * Unplug matters: the descriptor dies with the device, and the client has + * to be told, or it keeps advertising a device that is no longer there. + */ + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + ACTION_USB_PERMISSION -> { + val device = intent.usbDevice() ?: return + if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { + shareDevice(device) + } else { + Log.i(TAG, "permission refused for ${device.deviceName}") + } + } + + UsbManager.ACTION_USB_DEVICE_ATTACHED -> { + intent.usbDevice()?.let { requestPermission(it) } + } + + UsbManager.ACTION_USB_DEVICE_DETACHED -> { + intent.usbDevice()?.let { device -> + bridge.unshare(device).onFailure { + Log.w(TAG, "withdrawing ${device.deviceName} failed: ${it.message}") + } + } + } + } + } + + @Suppress("DEPRECATION") + private fun Intent.usbDevice(): UsbDevice? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + } + + override fun onCreate() { + super.onCreate() + + usbManager = getSystemService(Context.USB_SERVICE) as UsbManager + clientProcess = ClientProcess(this) + bridge = UsbBridge(this, clientProcess.socketPath) + + createNotificationChannel() + startForeground(NOTIFICATION_ID, buildNotification("Starting…")) + + val filter = IntentFilter().apply { + addAction(ACTION_USB_PERMISSION) + addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + registerReceiver(receiver, filter) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val relay = intent?.getStringExtra(EXTRA_RELAY) ?: return START_NOT_STICKY + val t1 = intent.getStringExtra(EXTRA_TOKEN1) ?: return START_NOT_STICKY + val t2 = intent.getStringExtra(EXTRA_TOKEN2) ?: return START_NOT_STICKY + val t3 = intent.getStringExtra(EXTRA_TOKEN3) ?: return START_NOT_STICKY + val name = intent.getStringExtra(EXTRA_NAME) ?: Build.MODEL + + clientProcess.writeConfig(relay, Triple(t1, t2, t3), name) + + clientProcess.start() + .onSuccess { + updateNotification("Connected to $relay") + // Give the client a moment to bind its socket before offering + // anything to it. + Thread { + Thread.sleep(500) + askForAllDevices() + }.start() + } + .onFailure { + Log.e(TAG, "starting the client failed", it) + updateNotification("Failed: ${it.message}") + stopSelf() + } + + // START_STICKY so the service comes back if the system reclaims it. + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + runCatching { unregisterReceiver(receiver) } + bridge.closeAll() + clientProcess.stop() + } + + override fun onBind(intent: Intent?): IBinder? = null + + /** Asks for permission on every device currently attached. */ + private fun askForAllDevices() { + usbManager.deviceList.values.forEach { device -> + if (usbManager.hasPermission(device)) { + shareDevice(device) + } else { + requestPermission(device) + } + } + } + + private fun requestPermission(device: UsbDevice) { + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE + } else { + 0 + } + val intent = PendingIntent.getBroadcast( + this, 0, Intent(ACTION_USB_PERMISSION).setPackage(packageName), flags + ) + usbManager.requestPermission(device, intent) + } + + private fun shareDevice(device: UsbDevice) { + bridge.share(device) + .onSuccess { + updateNotification("Sharing ${bridge.let { device.productName ?: device.deviceName }}") + } + .onFailure { + Log.w(TAG, "sharing ${device.deviceName} failed: ${it.message}") + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val channel = NotificationChannel( + CHANNEL_ID, + "USB Sharing", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Keeps shared USB devices reachable" + } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + private fun buildNotification(text: String): Notification = + Notification.Builder(this, CHANNEL_ID) + .setContentTitle("USB Server") + .setContentText(text) + .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) + .setOngoing(true) + .build() + + private fun updateNotification(text: String) { + getSystemService(NotificationManager::class.java) + .notify(NOTIFICATION_ID, buildNotification(text)) + } + + companion object { + private const val TAG = "UsbShareService" + private const val CHANNEL_ID = "usb_sharing" + private const val NOTIFICATION_ID = 1 + + const val ACTION_USB_PERMISSION = "de.usbserver.bridge.USB_PERMISSION" + + const val EXTRA_RELAY = "relay" + const val EXTRA_TOKEN1 = "token1" + const val EXTRA_TOKEN2 = "token2" + const val EXTRA_TOKEN3 = "token3" + const val EXTRA_NAME = "name" + } +} diff --git a/android/app/src/main/java/de/usbserver/bridge/UsbBridge.kt b/android/app/src/main/java/de/usbserver/bridge/UsbBridge.kt new file mode 100644 index 0000000..c6e8746 --- /dev/null +++ b/android/app/src/main/java/de/usbserver/bridge/UsbBridge.kt @@ -0,0 +1,216 @@ +package de.usbserver.bridge + +import android.content.Context +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbManager +import android.net.LocalSocket +import android.net.LocalSocketAddress +import android.util.Log +import org.json.JSONObject +import java.io.File + +/** + * Hands USB devices to the usb-client process. + * + * An Android app cannot open /dev/bus/usb, so the client cannot find devices + * by itself. This class obtains the file descriptor through UsbManager — which + * is what the permission dialog is for — and passes it over a Unix socket + * using ancillary data, along with the raw descriptors the client needs to + * understand the device. + * + * Connections are kept open per handover: the descriptor stays valid only as + * long as the UsbDeviceConnection is alive, so this class holds on to them. + */ +class UsbBridge( + private val context: Context, + private val socketPath: String, +) { + private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager + + /** Open connections, keyed by bus ID. Closing one invalidates its descriptor. */ + private val connections = mutableMapOf() + + /** + * Offers a device to the client. + * + * The caller must already hold permission for it — see + * [UsbManager.requestPermission]. Without permission openDevice returns + * null and this fails. + */ + fun share(device: UsbDevice): Result { + if (!usbManager.hasPermission(device)) { + return Result.failure( + IllegalStateException("no permission for ${device.deviceName}; request it first") + ) + } + + val connection = usbManager.openDevice(device) + ?: return Result.failure(IllegalStateException("could not open ${device.deviceName}")) + + val busId = busIdOf(device) + + return try { + val request = JSONObject().apply { + put("action", "add") + put("bus_id", busId) + // getRawDescriptors returns exactly what a usbdevfs read + // returns: device descriptor followed by all configurations. + put("descriptors", android.util.Base64.encodeToString( + connection.rawDescriptors, android.util.Base64.NO_WRAP)) + put("bus_num", busNumberOf(device)) + put("dev_num", deviceNumberOf(device)) + put("speed", speedOf(device)) + put("config_value", configValueOf(device)) + put("manufacturer", device.manufacturerName ?: "") + put("product", device.productName ?: "") + put("serial", serialOf(device, connection)) + } + + sendRequest(request, connection.fileDescriptor) + + // Keep the connection open: closing it would close the descriptor + // the client is now using. + connections[busId]?.close() + connections[busId] = connection + + Log.i(TAG, "shared $busId (${device.manufacturerName} ${device.productName})") + Result.success(Unit) + } catch (e: Exception) { + connection.close() + Result.failure(e) + } + } + + /** Withdraws a device and closes its connection. */ + fun unshare(device: UsbDevice): Result { + val busId = busIdOf(device) + + return try { + val request = JSONObject().apply { + put("action", "remove") + put("bus_id", busId) + } + sendRequest(request, fd = -1) + + connections.remove(busId)?.close() + Log.i(TAG, "withdrew $busId") + Result.success(Unit) + } catch (e: Exception) { + connections.remove(busId)?.close() + Result.failure(e) + } + } + + /** Closes every open connection. Call when the service stops. */ + fun closeAll() { + connections.values.forEach { it.close() } + connections.clear() + } + + /** + * Sends one request, attaching fd as ancillary data when it is valid. + * + * LocalSocket's setFileDescriptorsForSend is Android's SCM_RIGHTS: the + * descriptor is duplicated into the receiving process, which is the only + * way to give the client access to a device it cannot open itself. + */ + private fun sendRequest(request: JSONObject, fd: Int) { + LocalSocket().use { socket -> + socket.connect(LocalSocketAddress(socketPath, LocalSocketAddress.Namespace.FILESYSTEM)) + + if (fd >= 0) { + socket.setFileDescriptorsForSend(arrayOf(fdToFileDescriptor(fd))) + } + + socket.outputStream.write(request.toString().toByteArray()) + socket.outputStream.flush() + + val buffer = ByteArray(4096) + val n = socket.inputStream.read(buffer) + if (n <= 0) { + throw IllegalStateException("client closed the connection without replying") + } + + val response = JSONObject(String(buffer, 0, n)) + if (!response.optBoolean("ok", false)) { + throw IllegalStateException(response.optString("error", "client rejected the device")) + } + } + } + + /** + * Wraps a raw descriptor number in a FileDescriptor. + * + * FileDescriptor's int constructor is not public API, so this goes through + * reflection. It is the same approach every library that needs to pass + * descriptors on Android takes; if a future release blocks it, the + * alternative is a small JNI shim. + */ + private fun fdToFileDescriptor(fd: Int): java.io.FileDescriptor { + val descriptor = java.io.FileDescriptor() + val field = java.io.FileDescriptor::class.java.getDeclaredField("descriptor") + field.isAccessible = true + field.setInt(descriptor, fd) + return descriptor + } + + /** + * Derives a stable bus ID. + * + * Android device names look like "/dev/bus/usb/001/002". The client uses + * this string to identify the device to peers, so it has to stay the same + * for as long as the device is plugged in. + */ + private fun busIdOf(device: UsbDevice): String { + val parts = device.deviceName.trim('/').split("/") + return if (parts.size >= 2) { + val bus = parts[parts.size - 2].trimStart('0').ifEmpty { "0" } + val dev = parts[parts.size - 1].trimStart('0').ifEmpty { "0" } + "$bus-$dev" + } else { + device.deviceId.toString() + } + } + + private fun busNumberOf(device: UsbDevice): Int = + device.deviceName.trim('/').split("/").let { parts -> + parts.getOrNull(parts.size - 2)?.toIntOrNull() ?: 0 + } + + private fun deviceNumberOf(device: UsbDevice): Int = + device.deviceName.trim('/').split("/").lastOrNull()?.toIntOrNull() ?: 0 + + /** Maps Android's speed constants onto the USB/IP speed codes. */ + private fun speedOf(device: UsbDevice): Int { + // UsbDevice exposes no speed before API 29, and even then only + // indirectly. High speed is the safe assumption: it is what almost + // everything an OTG port sees actually runs at, and the value is only + // advisory on the receiving end. + return 3 + } + + private fun configValueOf(device: UsbDevice): Int { + // The framework activates configuration 1 on open; devices with more + // than one configuration are vanishingly rare in practice. + return 1 + } + + private fun serialOf(device: UsbDevice, connection: UsbDeviceConnection): String = + try { + connection.serial ?: "" + } catch (e: SecurityException) { + // Reading the serial needs permission the app may not hold; it is + // cosmetic, so carry on without it. + "" + } + + companion object { + private const val TAG = "UsbBridge" + + /** Default socket path inside the app's private directory. */ + fun defaultSocketPath(context: Context): String = + File(context.filesDir, "bridge.sock").absolutePath + } +} diff --git a/bin/README.md b/android/app/src/main/jniLibs/arm64-v8a/.gitkeep similarity index 100% rename from bin/README.md rename to android/app/src/main/jniLibs/arm64-v8a/.gitkeep diff --git a/android/app/src/main/res/xml/device_filter.xml b/android/app/src/main/res/xml/device_filter.xml new file mode 100644 index 0000000..8d6ae5c --- /dev/null +++ b/android/app/src/main/res/xml/device_filter.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..047bf2c --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "2.0.20" apply false +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..2976211 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "usb-server-android" +include(":app") diff --git a/bin/usb-client b/bin/usb-client index 5423199..06740e3 100755 Binary files a/bin/usb-client and b/bin/usb-client differ diff --git a/bin/usb-client.exe b/bin/usb-client.exe index 0d1235a..50ab3d2 100755 Binary files a/bin/usb-client.exe and b/bin/usb-client.exe differ diff --git a/bin/usb-relay b/bin/usb-relay index a002ca8..9ba5995 100755 Binary files a/bin/usb-relay and b/bin/usb-relay differ diff --git a/cmd/usb-client/main.go b/cmd/usb-client/main.go index 756adec..0304bc5 100644 --- a/cmd/usb-client/main.go +++ b/cmd/usb-client/main.go @@ -8,17 +8,25 @@ import ( "net/http" "os" "os/signal" + "sort" "strings" "syscall" + "github.com/duffy/usb-server/internal/bridge" "github.com/duffy/usb-server/internal/client" "github.com/duffy/usb-server/internal/config" + "github.com/duffy/usb-server/internal/diag" + "github.com/duffy/usb-server/internal/protocol" "github.com/duffy/usb-server/internal/service" "github.com/duffy/usb-server/internal/token" "github.com/duffy/usb-server/internal/usb" "github.com/duffy/usb-server/internal/web" ) +// version identifies this build in diagnostic reports. Override at build +// time with -ldflags "-X main.version=...". +var version = "dev" + func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) @@ -34,8 +42,12 @@ func main() { cmdRun("share") case "use": cmdRun("use") + case "both": + cmdRun("both") case "list": cmdList() + case "diag": + cmdDiag() case "gui": cmdGUI() case "config": @@ -63,7 +75,9 @@ Commands: generate-token Generate 3 tokens and compute hash share Start in share mode (expose USB devices) use Start in use mode (consume USB devices) - list List local USB devices + both Start in combined mode (expose and consume) + list List local USB devices (-v adds interfaces and endpoints) + diag Report why sharing does or does not work on this machine gui Start web UI only config Show current configuration install-service Install as systemd service @@ -159,6 +173,11 @@ func cmdRun(mode string) { cfg, cfgPath := loadConfig() cfg.Mode = mode + if !protocol.ValidMode(cfg.Mode) { + fmt.Fprintf(os.Stderr, "Error: invalid mode %q (expected share, use or both)\n", cfg.Mode) + os.Exit(1) + } + if cfg.Hash == "" { fmt.Println("Error: No hash configured. Run 'usb-client generate-token' first or set --hash.") os.Exit(1) @@ -172,107 +191,44 @@ func cmdRun(mode string) { } } - // Create client c := client.NewClient(cfg) - // Setup signal handling + // Create the managers this mode needs. In "both" mode they coexist on one + // relay connection: the share manager answers device requests from peers + // while the use manager attaches devices those peers offer. + var sm *client.ShareManager + var um *client.UseManager + + if protocol.CanShare(cfg.Mode) { + sm = client.NewShareManager(c, cfg) + } + if protocol.CanUse(cfg.Mode) { + um = client.NewUseManager(c, cfg, cfgPath) + } + + // Accept devices handed in by a supervising process, where configured. + // This is how an Android app shares devices it had to obtain through the + // framework; on an ordinary Linux host it stays off. + var bridgeServer *bridge.Server + if cfg.BridgeSocket != "" && sm != nil { + bs, err := bridge.Listen(cfg.BridgeSocket) + if err != nil { + log.Printf("Device bridge unavailable: %v", err) + } else { + bs.OnChange = sm.RefreshNow + bridgeServer = bs + defer bs.Close() + } + } + sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - // Start web UI unless disabled - if !noGUI { - webHandler := web.NewHandler(cfg, cfgPath) - - if mode == "share" { - sm := client.NewShareManager(c, cfg) - webHandler.GetDevices = func() interface{} { - return map[string]interface{}{ - "mode": "share", - "local_devices": sm.DeviceListForAPI(), - } - } - webHandler.GetStatus = func() map[string]interface{} { - return map[string]interface{}{ - "connected": true, // simplified - "mode": mode, - "name": cfg.Name, - "client_id": c.ID(), - } - } - webHandler.InstallService = func() error { return service.Install(mode, cfgPath) } - webHandler.UninstallService = service.Uninstall - - go func() { - if err := c.Run(); err != nil { - log.Printf("Client error: %v", err) - } - }() - go sm.Run() - } else { - um := client.NewUseManager(c, cfg, cfgPath) - webHandler.GetDevices = func() interface{} { - available := um.GetAvailableDevices() - attached := um.GetAttachedDevices() - - var availList []map[string]interface{} - for _, d := range available { - availList = append(availList, map[string]interface{}{ - "bus_id": d.BusID, - "vendor_id": d.VendorID, - "product_id": d.ProductID, - "name": d.Name, - "status": d.Status, - "speed": d.Speed, - "client_id": d.ClientID, - "client_name": d.ClientName, - "allow_force_detach": um.IsForceDetachable(d.ClientID), - }) - } - - var attachList []map[string]interface{} - for _, d := range attached { - attachList = append(attachList, map[string]interface{}{ - "bus_id": d.BusID, - "vendor_id": d.VendorID, - "product_id": d.ProductID, - "name": d.Name, - "client_id": d.ClientID, - "client_name": d.ClientName, - "tunnel_id": d.TunnelID, - "vhci_port": d.VHCIPort, - "auto_connect": um.IsAutoConnect(d.VendorID, d.ProductID), - }) - } - - return map[string]interface{}{ - "mode": "use", - "available_devices": availList, - "attached_devices": attachList, - } - } - webHandler.AttachDevice = um.AttachDevice - webHandler.DetachDevice = um.DetachDevice - webHandler.ForceDetachDevice = um.ForceDetachDevice - webHandler.SetAutoConnect = um.SetAutoConnect - webHandler.IsAutoConnect = um.IsAutoConnect - webHandler.GetStatus = func() map[string]interface{} { - return map[string]interface{}{ - "connected": true, - "mode": mode, - "name": cfg.Name, - "client_id": c.ID(), - } - } - webHandler.InstallService = func() error { return service.Install(mode, cfgPath) } - webHandler.UninstallService = service.Uninstall - - go func() { - if err := c.Run(); err != nil { - log.Printf("Client error: %v", err) - } - }() - } - + // web_port 0 disables the UI as surely as --no-gui does. Binding to port 0 + // would otherwise pick an arbitrary port, which on a shared machine means + // an unexpected open control interface. + if !noGUI && cfg.WebPort > 0 { + webHandler := buildWebHandler(cfg, cfgPath, c, sm, um) addr := fmt.Sprintf(":%d", cfg.WebPort) log.Printf("Web UI available at http://localhost%s", addr) go func() { @@ -280,29 +236,116 @@ func cmdRun(mode string) { log.Printf("Web UI error: %v", err) } }() - } else { - // No GUI mode - if mode == "share" { - sm := client.NewShareManager(c, cfg) - go sm.Run() - } else { - client.NewUseManager(c, cfg, cfgPath) - } - go func() { - if err := c.Run(); err != nil { - log.Printf("Client error: %v", err) - } - }() } - log.Printf("USB Client started (mode=%s, name=%s)", mode, cfg.Name) + go func() { + if err := c.Run(); err != nil { + log.Printf("Client error: %v", err) + } + }() + + if sm != nil { + go sm.Run() + } + + log.Printf("USB Client started (mode=%s, name=%s)", cfg.Mode, cfg.Name) - // Wait for signal sig := <-sigChan log.Printf("Received signal %v, shutting down...", sig) + + // Release devices before dropping the relay link, so peers are told + // rather than left waiting for a timeout. + if um != nil { + um.Cleanup() + } + if bridgeServer != nil { + bridgeServer.Close() + } c.Close() } +// buildWebHandler wires the HTTP API to whichever managers are active. +func buildWebHandler(cfg *config.Config, cfgPath string, c *client.Client, + sm *client.ShareManager, um *client.UseManager) *web.Handler { + + h := web.NewHandler(cfg, cfgPath) + + h.GetStatus = func() map[string]interface{} { + status := map[string]interface{}{ + "connected": c.Connected(), + "mode": cfg.Mode, + "name": cfg.Name, + "client_id": c.ID(), + "can_share": sm != nil, + "can_use": um != nil, + "encrypted": c.TunnelSecret() != nil, + } + if sm != nil { + status["direct_port"] = sm.DirectPort() + } + return status + } + + h.GetDevices = func() interface{} { + result := map[string]interface{}{"mode": cfg.Mode} + + if sm != nil { + result["local_devices"] = sm.DeviceListForAPI() + } + + if um != nil { + var availList []map[string]interface{} + for _, d := range um.GetAvailableDevices() { + availList = append(availList, map[string]interface{}{ + "bus_id": d.BusID, + "vendor_id": d.VendorID, + "product_id": d.ProductID, + "name": d.Name, + "status": d.Status, + "speed": d.Speed, + "client_id": d.ClientID, + "client_name": d.ClientName, + "allow_force_detach": um.IsForceDetachable(d.ClientID), + "auto_connect": um.IsAutoConnect(d.VendorID, d.ProductID), + }) + } + + var attachList []map[string]interface{} + for _, d := range um.GetAttachedDevices() { + attachList = append(attachList, map[string]interface{}{ + "bus_id": d.BusID, + "vendor_id": d.VendorID, + "product_id": d.ProductID, + "name": d.Name, + "client_id": d.ClientID, + "client_name": d.ClientName, + "tunnel_id": d.TunnelID, + "vhci_port": d.VHCIPort, + "auto_connect": um.IsAutoConnect(d.VendorID, d.ProductID), + }) + } + + result["available_devices"] = availList + result["attached_devices"] = attachList + } + + return result + } + + if um != nil { + h.AttachDevice = um.AttachDevice + h.DetachDevice = um.DetachDevice + h.ForceDetachDevice = um.ForceDetachDevice + h.SetAutoConnect = um.SetAutoConnect + h.IsAutoConnect = um.IsAutoConnect + } + + h.InstallService = func() error { return service.Install(cfg.Mode, cfgPath) } + h.UninstallService = service.Uninstall + + return h +} + func cmdList() { devices, err := usb.Enumerate() if err != nil { @@ -314,6 +357,17 @@ func cmdList() { return } + verbose := false + for _, arg := range os.Args { + if arg == "-v" || arg == "--verbose" { + verbose = true + } + } + if verbose { + listVerbose(devices) + return + } + fmt.Printf("%-10s %-10s %-30s %-8s %s\n", "BUS-ID", "VID:PID", "NAME", "SPEED", "DRIVER") fmt.Println(strings.Repeat("-", 80)) @@ -340,6 +394,115 @@ func cmdList() { } } +// listVerbose prints interfaces and endpoints per device. +// +// The endpoint transfer types shown here are exactly what the share side uses +// to decide how to submit each URB, so this is the first place to look when a +// device attaches but produces no traffic. +func listVerbose(devices []usb.Device) { + typeNames := map[uint8]string{ + usb.TransferTypeControl: "control", + usb.TransferTypeIsochronous: "isochronous", + usb.TransferTypeBulk: "bulk", + usb.TransferTypeInterrupt: "interrupt", + } + + for i, dev := range devices { + if i > 0 { + fmt.Println() + } + fmt.Printf("%s %04x:%04x %s\n", dev.BusID, dev.VendorID, dev.ProductID, dev.DisplayName()) + fmt.Printf(" path=%s speed=%d config=%d\n", dev.DevPath, dev.Speed, dev.ConfigValue) + + for _, iface := range dev.Interfaces { + driver := iface.Driver + if driver == "" { + driver = "(none)" + } + fmt.Printf(" interface %d: class=%02x subclass=%02x protocol=%02x driver=%s\n", + iface.Number, iface.Class, iface.SubClass, iface.Protocol, driver) + } + + if len(dev.Endpoints) == 0 { + fmt.Printf(" endpoints: none read — run as root to read %s\n", dev.DevPath) + continue + } + + // Sort by address so repeated runs are comparable. + addrs := make([]int, 0, len(dev.Endpoints)) + for addr := range dev.Endpoints { + addrs = append(addrs, int(addr)) + } + sort.Ints(addrs) + + fmt.Println(" endpoints (all alternate settings):") + for _, a := range addrs { + ep := dev.Endpoints[uint8(a)] + dir := "OUT" + if ep.IsIn() { + dir = "IN" + } + fmt.Printf(" 0x%02x EP%-2d %-3s %-11s maxpkt=%-4d interval=%d\n", + ep.Address, ep.Number(), dir, typeNames[ep.TransferType], ep.MaxPacketSize, ep.Interval) + } + } +} + +// cmdDiag collects and reports the machine's USB situation. +// +// The point is to replace "it does not work" with facts: which mechanism +// would be used here, what is missing, and what to do about it. Every failure +// mode this code has is platform specific and mostly invisible otherwise. +func cmdDiag() { + fs := flag.NewFlagSet("diag", flag.ExitOnError) + asJSON := fs.Bool("json", false, "emit JSON instead of text") + outFile := fs.String("out", "", "write to this file as well as stdout") + upload := fs.String("upload", "", "upload to this relay (defaults to the configured one when -id is given)") + reportID := fs.String("id", "", "report ID to upload under") + fs.Parse(os.Args[2:]) + + report := diag.Collect(version) + + var output []byte + if *asJSON { + data, err := report.JSON() + if err != nil { + log.Fatalf("Error encoding report: %v", err) + } + output = data + } else { + output = []byte(report.String()) + } + + fmt.Println(string(output)) + + if *outFile != "" { + if err := os.WriteFile(*outFile, output, 0600); err != nil { + log.Printf("Warning: could not write %s: %v", *outFile, err) + } else { + fmt.Printf("\nWritten to %s\n", *outFile) + } + } + + if *reportID != "" { + relayAddr := *upload + if relayAddr == "" { + cfg, _ := loadConfig() + relayAddr = cfg.RelayAddr + } + if relayAddr == "" { + log.Fatalf("No relay to upload to: pass -upload or configure one first") + } + + url, err := diag.Upload(relayAddr, *reportID, report) + if err != nil { + log.Fatalf("Upload failed: %v", err) + } + fmt.Printf("\nUploaded to %s\n", url) + fmt.Printf("It stays there for %s, or until the relay restarts.\n", diag.RetentionNote) + } +} + func cmdGUI() { cfg, cfgPath := loadConfig() diff --git a/cmd/usb-relay/main.go b/cmd/usb-relay/main.go index 592a720..053ed41 100644 --- a/cmd/usb-relay/main.go +++ b/cmd/usb-relay/main.go @@ -4,7 +4,9 @@ import ( "flag" "fmt" "log" + "net" "os" + "strconv" "github.com/duffy/usb-server/internal/relay" ) @@ -22,7 +24,7 @@ func main() { *addr = envAddr } - listenAddr := fmt.Sprintf("%s:%d", *addr, *port) + listenAddr := buildListenAddr(*addr, *port) log.SetFlags(log.LstdFlags | log.Lshortfile) log.Printf("USB Relay Server starting on %s", listenAddr) @@ -32,3 +34,16 @@ func main() { log.Fatalf("server error: %v", err) } } + +// buildListenAddr combines the address and port flags. +// +// -addr is documented as a bare address, but "-addr :9000" and +// "-addr 1.2.3.4:9000" are the obvious things to type. Appending the port +// blindly turns those into ":9000:8443", which fails with an unhelpful +// "too many colons" error, so an address that already carries a port wins. +func buildListenAddr(addr string, port int) string { + if _, _, err := net.SplitHostPort(addr); err == nil { + return addr + } + return net.JoinHostPort(addr, strconv.Itoa(port)) +} diff --git a/docker-compose.yml b/docker-compose.yml index 1f52d00..d296c2b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,9 @@ services: relay: - build: . + build: + context: . + dockerfile: Dockerfile + image: usb-server-relay ports: - "8543:8443" restart: unless-stopped @@ -9,3 +12,34 @@ services: interval: 30s timeout: 5s retries: 3 + + # USB client. Commented out by default because it only works on a Linux + # host and needs privileged access — enable it deliberately. + # + # Requirements on the host: + # - Linux (containers share the host kernel; on macOS and Windows Docker + # runs in a VM with no access to the USB hardware) + # - "sudo modprobe vhci-hcd" for use mode + # - a config with the three tokens in ./client-config/ + # + # client: + # build: + # context: . + # dockerfile: Dockerfile.client + # image: usb-server-client + # command: ["both", "--config", "/config/config.json"] + # restart: unless-stopped + # # Needed to detach kernel drivers and rebind them afterwards. + # privileged: true + # # host networking keeps direct peer connections working: in bridge mode + # # the addresses the client advertises are container-internal and + # # unreachable, so every tunnel would fall back to the relay. + # network_mode: host + # volumes: + # - ./client-config:/config + # - /dev/bus/usb:/dev/bus/usb + # - /sys/bus/usb:/sys/bus/usb + # # Only needed for use mode, to attach remote devices: + # - /sys/devices/platform/vhci_hcd.0:/sys/devices/platform/vhci_hcd.0 + # environment: + # - USBSRV_DEBUG=0 diff --git a/docker/client-entrypoint.sh b/docker/client-entrypoint.sh new file mode 100644 index 0000000..a1593ba --- /dev/null +++ b/docker/client-entrypoint.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Entrypoint for the client container. +# +# Its job is to fail loudly and specifically when the container has not been +# given the access it needs. Without these checks the client starts, finds no +# devices, and leaves you guessing whether the problem is the config, the +# network or the container setup. +set -e + +MODE="${1:-both}" + +warn() { echo "[entrypoint] $*" >&2; } + +case "$MODE" in +share | both) + if [ ! -d /dev/bus/usb ]; then + warn "ERROR: /dev/bus/usb is not present in the container." + warn "" + warn "Sharing devices needs the host's USB tree. Add to your compose file:" + warn " volumes:" + warn " - /dev/bus/usb:/dev/bus/usb" + warn " - /sys/bus/usb:/sys/bus/usb" + warn " privileged: true" + warn "" + warn "On macOS and Windows this cannot work at all: Docker runs in a" + warn "Linux VM that has no access to the host's USB hardware." + exit 1 + fi + + if [ ! -d /sys/bus/usb/devices ]; then + warn "ERROR: /sys/bus/usb is not mounted." + warn "Devices are enumerated through sysfs; mount it read-only at least:" + warn " - /sys/bus/usb:/sys/bus/usb" + exit 1 + fi + + # Rebinding drivers after a share writes to this sysfs attribute, which + # needs the mount to be writable. + if [ ! -w /sys/bus/usb/devices ] 2>/dev/null; then + warn "NOTE: /sys/bus/usb is read-only. Devices can be shared, but kernel" + warn "drivers cannot be rebound afterwards — a device may stay unusable" + warn "on the host until it is replugged. Mount it writable to avoid that." + fi + ;; +esac + +case "$MODE" in +use | both) + if [ ! -d /sys/devices/platform/vhci_hcd.0 ]; then + warn "NOTE: vhci_hcd is not available, so no remote device can be attached." + warn "Load it on the HOST (not in the container): sudo modprobe vhci-hcd" + warn "Sharing local devices still works." + fi + ;; +esac + +exec usb-client "$@" diff --git a/driver/windows/README.md b/driver/windows/README.md new file mode 100644 index 0000000..a63ac7f --- /dev/null +++ b/driver/windows/README.md @@ -0,0 +1,118 @@ +# usbshare — USB-Filtertreiber für Windows + +Ein KMDF-Upper-Filtertreiber, der Userspace-Zugriff auf ein USB-Gerät +ermöglicht, **ohne** den vorhandenen Gerätetreiber zu ersetzen. Das ist der +Ansatz, den VirtualHere verwendet, und der Grund, warum es dort kein Zadig +braucht und das Gerät lokal funktionsfähig bleibt. + +> ## Lies das zuerst +> +> **Dieser Code ist nie gelaufen.** Ich habe ihn geschrieben, aber weder +> kompiliert noch getestet — dafür braucht es Windows mit dem WDK, und das +> stand mir nicht zur Verfügung. Er ist als Ausgangspunkt gedacht, nicht als +> fertiges Produkt. +> +> Kernel-Code verzeiht nichts: ein Fehler ist ein Bluescreen, kein +> Stacktrace. Ein Treiber, der beim Booten geladen wird und dabei abstürzt, +> kann ein System unbootbar machen. **Teste ausschließlich in einer VM mit +> Snapshot**, bis er stabil läuft. +> +> Rechne mit mehreren Runden Debugging. Die Struktur sollte stimmen, die +> Details fast sicher nicht. + +## Warum ein Filtertreiber + +Windows hat kein Äquivalent zu Linux' usbdevfs. Um URBs an ein Gerät zu +senden, braucht es Kernel-Code. Die Alternativen: + +| Ansatz | Gerät lokal nutzbar | Installation | HID/Massenspeicher | +|--------|---------------------|--------------|--------------------| +| WinUSB | Nein — ersetzt den Treiber | Zadig, pro Gerät | Meist blockiert | +| **Filtertreiber** | **Ja** | INF, pro Gerät oder klassenweit | Ja | + +Der Filter setzt sich *über* den vorhandenen Treiber in den Stack. Im +Normalbetrieb reicht er alles unverändert durch. Erst wenn Userspace ein Gerät +beansprucht, fängt er die IRPs des Klassentreibers ab und leitet stattdessen +die URBs aus dem Userspace an den USB-Hub weiter. + +``` + Ohne Beanspruchung Während des Teilens + ┌────────────────────┐ ┌────────────────────┐ + │ Klassentreiber │ │ Klassentreiber │ + │ (usbhid, usbstor) │ │ (bekommt nichts) │ + └─────────┬──────────┘ └─────────┬──────────┘ + │ ╳ abgefangen + ┌─────────▼──────────┐ ┌─────────▼──────────┐ + │ usbshare (Filter) │ │ usbshare (Filter) │◄── usb-client + └─────────┬──────────┘ └─────────┬──────────┘ via IOCTL + │ durchgereicht │ URBs + ┌─────────▼──────────┐ ┌─────────▼──────────┐ + │ USB-Hub-Treiber │ │ USB-Hub-Treiber │ + └────────────────────┘ └────────────────────┘ +``` + +## Dateien + +| Datei | Inhalt | +|-------|--------| +| `driver.c` | Treiber-Einstieg, Geräte-Hinzufügen, PnP | +| `queue.c` | IOCTL-Verarbeitung, URB-Weiterleitung | +| `filter.c` | Abfangen der Klassentreiber-Anfragen im beanspruchten Zustand | +| `usbshare.h` | Interne Strukturen | +| `public.h` | IOCTL-Schnittstelle — auch von der Go-Seite verwendet | +| `usbshare.inf` | Installationsdatei | +| `usbshare.vcxproj` | Visual-Studio-Projekt | + +## Bauen + +Voraussetzungen: Visual Studio 2022 mit „Desktop development with C++", +Windows SDK und [WDK](https://learn.microsoft.com/windows-hardware/drivers/download-the-wdk). + +```cmd +msbuild usbshare.vcxproj /p:Configuration=Release /p:Platform=x64 +``` + +## Testen (nur in einer VM) + +```cmd +:: Testsignierung erlauben — danach neu starten +bcdedit /set testsigning on +bcdedit /set nointegritychecks on + +:: Selbst signieren +makecert -r -pe -ss PrivateCertStore -n "CN=usbshare-test" test.cer +signtool sign /v /s PrivateCertStore /n usbshare-test /t http://timestamp.digicert.com usbshare.sys + +:: Installieren: Rechtsklick auf usbshare.inf → Installieren, dann Gerät neu einstecken +``` + +Für Kernel-Debugging: zweite Maschine oder Host mit WinDbg, verbunden über +`bcdedit /debug on` und `/dbgsettings net`. + +## Verteilen + +Für den Einsatz außerhalb einer Testmaschine muss der Treiber von Microsoft +gegengezeichnet sein. Dafür brauchst du: + +1. **EV-Code-Signing-Zertifikat** — auf eine geprüfte reale Identität + (Firma oder Einzelperson), etwa 300–500 €/Jahr, Ausstellung dauert Tage bis + Wochen wegen der Identitätsprüfung. +2. **Microsoft-Partner-Center-Konto**, verifiziert mit demselben Zertifikat. +3. **Attestation Signing**: Treiber hochladen, Microsoft zeichnet gegen. + Ausreichend für die meisten Fälle; volle WHQL-Zertifizierung braucht + zusätzlich HLK-Testläufe. + +Diesen Teil kann nur jemand mit einer realen Identität erledigen — er läuft +auf deinen Namen, nicht auf meinen. Das ist die eigentliche Hürde, nicht der +Code. + +## Was fehlt + +Der Treiber deckt Control-, Bulk- und Interrupt-Transfers ab. Nicht +implementiert: + +- **Isochrone Transfers** (Webcams, Audio). Sie brauchen eine andere + URB-Struktur mit Paketdeskriptoren und Bandbreitenreservierung. +- **Auswahl der Konfiguration/Alt-Settings** über den Filter — derzeit wird + die vom Klassentreiber gesetzte übernommen. +- **Reset und Halt-Clear** sind angelegt, aber ungetestet. diff --git a/driver/windows/driver.c b/driver/windows/driver.c new file mode 100644 index 0000000..1d9281d --- /dev/null +++ b/driver/windows/driver.c @@ -0,0 +1,284 @@ +/* + * usbshare - driver entry, device setup and claim lifecycle + */ + +#include "usbshare.h" + +NTSTATUS +DriverEntry( + _In_ PDRIVER_OBJECT DriverObject, + _In_ PUNICODE_STRING RegistryPath +) +{ + WDF_DRIVER_CONFIG config; + NTSTATUS status; + + WDF_DRIVER_CONFIG_INIT(&config, UsbShareEvtDeviceAdd); + + status = WdfDriverCreate(DriverObject, RegistryPath, + WDF_NO_OBJECT_ATTRIBUTES, &config, WDF_NO_HANDLE); + if (!NT_SUCCESS(status)) { + KdPrint(("usbshare: WdfDriverCreate failed 0x%x\n", status)); + } + + return status; +} + +NTSTATUS +UsbShareEvtDeviceAdd( + _In_ WDFDRIVER Driver, + _Inout_ PWDFDEVICE_INIT DeviceInit +) +{ + NTSTATUS status; + WDFDEVICE device; + WDF_OBJECT_ATTRIBUTES attributes; + WDF_PNPPOWER_EVENT_CALLBACKS pnpCallbacks; + WDF_FILEOBJECT_CONFIG fileConfig; + WDF_IO_QUEUE_CONFIG queueConfig; + PDEVICE_CONTEXT context; + WDFQUEUE queue; + + UNREFERENCED_PARAMETER(Driver); + + /* + * Declaring ourselves a filter is what makes this driver safe to attach + * to arbitrary devices: the framework then forwards every request we do + * not explicitly handle to the driver below, so a device we know nothing + * about keeps working exactly as before. + */ + WdfFdoInitSetFilter(DeviceInit); + + WDF_PNPPOWER_EVENT_CALLBACKS_INIT(&pnpCallbacks); + pnpCallbacks.EvtDevicePrepareHardware = UsbShareEvtDevicePrepareHardware; + WdfDeviceInitSetPnpPowerEventCallbacks(DeviceInit, &pnpCallbacks); + + /* + * File create and close callbacks give us the claim lifecycle: a claim is + * tied to a handle, so when the client exits — cleanly or not — the + * kernel closes the handle and the device goes back to its class driver. + * Without this a crashed client would leave hardware unusable until + * reboot. + */ + WDF_FILEOBJECT_CONFIG_INIT(&fileConfig, + UsbShareEvtDeviceFileCreate, + UsbShareEvtFileClose, + WDF_NO_EVENT_CALLBACK); /* no cleanup callback */ + WdfDeviceInitSetFileObjectConfig(DeviceInit, &fileConfig, + WDF_NO_OBJECT_ATTRIBUTES); + + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, DEVICE_CONTEXT); + + status = WdfDeviceCreate(&DeviceInit, &attributes, &device); + if (!NT_SUCCESS(status)) { + KdPrint(("usbshare: WdfDeviceCreate failed 0x%x\n", status)); + return status; + } + + context = GetDeviceContext(device); + RtlZeroMemory(context, sizeof(DEVICE_CONTEXT)); + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = device; + + status = WdfSpinLockCreate(&attributes, &context->ClaimLock); + if (!NT_SUCCESS(status)) { + return status; + } + + status = WdfSpinLockCreate(&attributes, &context->PendingLock); + if (!NT_SUCCESS(status)) { + return status; + } + + status = WdfCollectionCreate(&attributes, &context->PendingTransfers); + if (!NT_SUCCESS(status)) { + return status; + } + + /* + * Default queue. Requests we do not recognise are forwarded down by the + * framework because this is a filter device. + */ + WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&queueConfig, WdfIoQueueDispatchParallel); + queueConfig.EvtIoDeviceControl = UsbShareEvtIoDeviceControl; + queueConfig.EvtIoInternalDeviceControl = UsbShareEvtIoInternalDeviceControl; + queueConfig.EvtIoDefault = UsbShareEvtIoDefault; + + status = WdfIoQueueCreate(device, &queueConfig, WDF_NO_OBJECT_ATTRIBUTES, &queue); + if (!NT_SUCCESS(status)) { + KdPrint(("usbshare: WdfIoQueueCreate failed 0x%x\n", status)); + return status; + } + + /* Publish the interface so user mode can find this device. */ + status = WdfDeviceCreateDeviceInterface(device, &GUID_DEVINTERFACE_USBSHARE, NULL); + if (!NT_SUCCESS(status)) { + KdPrint(("usbshare: WdfDeviceCreateDeviceInterface failed 0x%x\n", status)); + return status; + } + + return STATUS_SUCCESS; +} + +NTSTATUS +UsbShareEvtDevicePrepareHardware( + _In_ WDFDEVICE Device, + _In_ WDFCMRESLIST ResourcesRaw, + _In_ WDFCMRESLIST ResourcesTranslated +) +{ + NTSTATUS status; + PDEVICE_CONTEXT context = GetDeviceContext(Device); + WDF_USB_DEVICE_CREATE_CONFIG createConfig; + USB_DEVICE_DESCRIPTOR deviceDescriptor; + WDF_USB_DEVICE_SELECT_CONFIG_PARAMS configParams; + UCHAR i; + + UNREFERENCED_PARAMETER(ResourcesRaw); + UNREFERENCED_PARAMETER(ResourcesTranslated); + + /* PrepareHardware can run more than once across power transitions. */ + if (context->UsbDevice != NULL) { + return STATUS_SUCCESS; + } + + WDF_USB_DEVICE_CREATE_CONFIG_INIT(&createConfig, USBD_CLIENT_CONTRACT_VERSION_602); + + status = WdfUsbTargetDeviceCreateWithParameters(Device, &createConfig, + WDF_NO_OBJECT_ATTRIBUTES, + &context->UsbDevice); + if (!NT_SUCCESS(status)) { + KdPrint(("usbshare: WdfUsbTargetDeviceCreateWithParameters failed 0x%x\n", status)); + return status; + } + + WdfUsbTargetDeviceGetDeviceDescriptor(context->UsbDevice, &deviceDescriptor); + + context->Info.VendorId = deviceDescriptor.idVendor; + context->Info.ProductId = deviceDescriptor.idProduct; + context->Info.BcdDevice = deviceDescriptor.bcdDevice; + context->Info.DeviceClass = deviceDescriptor.bDeviceClass; + context->Info.DeviceSubClass = deviceDescriptor.bDeviceSubClass; + context->Info.DeviceProtocol = deviceDescriptor.bDeviceProtocol; + context->Info.NumConfigurations = deviceDescriptor.bNumConfigurations; + context->Info.ConfigurationValue = 1; + + /* + * Select a configuration so pipe handles become available. + * + * This is the part most likely to need adjusting: on a device the class + * driver has already configured, selecting again may be redundant or + * disruptive. A more careful implementation would query the current + * configuration first and only select if none is active. + */ + WDF_USB_DEVICE_SELECT_CONFIG_PARAMS_INIT_SINGLE_INTERFACE(&configParams); + + status = WdfUsbTargetDeviceSelectConfig(context->UsbDevice, + WDF_NO_OBJECT_ATTRIBUTES, + &configParams); + if (!NT_SUCCESS(status)) { + KdPrint(("usbshare: WdfUsbTargetDeviceSelectConfig failed 0x%x\n", status)); + /* + * Not fatal: without pipes only control transfers work, but the + * filter must not break the device for the class driver either way. + */ + return STATUS_SUCCESS; + } + + context->UsbInterface = configParams.Types.SingleInterface.ConfiguredUsbInterface; + + /* Map pipes by full endpoint address. */ + { + BYTE pipeCount = configParams.Types.SingleInterface.NumberConfiguredPipes; + for (i = 0; i < pipeCount; i++) { + WDF_USB_PIPE_INFORMATION pipeInfo; + WDFUSBPIPE pipe; + + WDF_USB_PIPE_INFORMATION_INIT(&pipeInfo); + pipe = WdfUsbInterfaceGetConfiguredPipe(context->UsbInterface, i, &pipeInfo); + if (pipe != NULL) { + context->Pipes[pipeInfo.EndpointAddress] = pipe; + + /* + * Let short reads through. Without this a transfer that + * returns fewer bytes than requested fails, which is normal + * and expected for interrupt endpoints. + */ + WdfUsbTargetPipeSetNoMaximumPacketSizeCheck(pipe); + } + } + } + + return STATUS_SUCCESS; +} + +VOID +UsbShareEvtDeviceFileCreate( + _In_ WDFDEVICE Device, + _In_ WDFREQUEST Request, + _In_ WDFFILEOBJECT FileObject +) +{ + UNREFERENCED_PARAMETER(Device); + UNREFERENCED_PARAMETER(FileObject); + + /* Opening the handle is always allowed; claiming is a separate step. */ + WdfRequestComplete(Request, STATUS_SUCCESS); +} + +VOID +UsbShareEvtFileClose( + _In_ WDFFILEOBJECT FileObject +) +{ + WDFDEVICE device = WdfFileObjectGetDevice(FileObject); + PDEVICE_CONTEXT context = GetDeviceContext(device); + + /* + * The safety net: if this handle held the claim, give the device back. + * This runs whether the client exited cleanly or was killed. + */ + UsbShareReleaseClaim(context, FileObject); +} + +BOOLEAN +UsbShareIsClaimed( + _In_ PDEVICE_CONTEXT Context +) +{ + BOOLEAN claimed; + + WdfSpinLockAcquire(Context->ClaimLock); + claimed = Context->Claimed; + WdfSpinLockRelease(Context->ClaimLock); + + return claimed; +} + +VOID +UsbShareReleaseClaim( + _In_ PDEVICE_CONTEXT Context, + _In_opt_ WDFFILEOBJECT Owner +) +{ + BOOLEAN released = FALSE; + + WdfSpinLockAcquire(Context->ClaimLock); + + /* + * With an owner given, only that owner may release — otherwise closing an + * unrelated handle would hand the device back while a client is using it. + */ + if (Context->Claimed && (Owner == NULL || Context->ClaimOwner == Owner)) { + Context->Claimed = FALSE; + Context->ClaimOwner = NULL; + released = TRUE; + } + + WdfSpinLockRelease(Context->ClaimLock); + + if (released) { + KdPrint(("usbshare: device released\n")); + } +} diff --git a/driver/windows/filter.c b/driver/windows/filter.c new file mode 100644 index 0000000..77add75 --- /dev/null +++ b/driver/windows/filter.c @@ -0,0 +1,92 @@ +/* + * usbshare - intercepting the class driver while the device is claimed + * + * This is what makes the filter approach worth the trouble. While no client + * holds the device, every request is forwarded untouched and the device + * behaves exactly as if this driver were not installed. Only once a client + * claims it do the class driver's requests get swallowed, so the two do not + * fight over the same endpoints. + */ + +#include "usbshare.h" + +/* + * Forwards a request to the driver below unchanged. + * + * Send-and-forget is right here: we have no interest in the answer, and not + * setting a completion routine avoids holding a reference on a request that + * may outlive our interest in it. + */ +static VOID +UsbShareForward( + _In_ WDFDEVICE Device, + _In_ WDFREQUEST Request +) +{ + WDF_REQUEST_SEND_OPTIONS options; + + WDF_REQUEST_SEND_OPTIONS_INIT(&options, WDF_REQUEST_SEND_OPTION_SEND_AND_FORGET); + WdfRequestFormatRequestUsingCurrentType(Request); + + if (!WdfRequestSend(Request, WdfDeviceGetIoTarget(Device), &options)) { + WdfRequestComplete(Request, WdfRequestGetStatus(Request)); + } +} + +VOID +UsbShareEvtIoDefault( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request +) +{ + WDFDEVICE device = WdfIoQueueGetDevice(Queue); + + /* + * Reads and writes are not intercepted even while claimed. They come from + * user mode against the class driver's own interface, and failing them + * would surface as application errors rather than a device that is simply + * busy elsewhere. + */ + UsbShareForward(device, Request); +} + +VOID +UsbShareEvtIoInternalDeviceControl( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request, + _In_ size_t OutputBufferLength, + _In_ size_t InputBufferLength, + _In_ ULONG IoControlCode +) +{ + WDFDEVICE device = WdfIoQueueGetDevice(Queue); + PDEVICE_CONTEXT context = GetDeviceContext(device); + + UNREFERENCED_PARAMETER(OutputBufferLength); + UNREFERENCED_PARAMETER(InputBufferLength); + + /* + * IOCTL_INTERNAL_USB_SUBMIT_URB is how the class driver above us talks to + * the USB stack. Letting those through while a client holds the device + * would mean two parties submitting to the same endpoints: transfers + * would be answered to whoever asked last, and a keyboard would appear to + * type on both machines at once. + */ + if (IoControlCode == IOCTL_INTERNAL_USB_SUBMIT_URB && UsbShareIsClaimed(context)) { + /* + * STATUS_DEVICE_NOT_CONNECTED rather than STATUS_DEVICE_BUSY: class + * drivers treat "busy" as a reason to retry in a tight loop, whereas + * "not connected" makes them stand down until PnP says otherwise — + * which is exactly the state the device is in from their point of view. + */ + WdfRequestComplete(Request, STATUS_DEVICE_NOT_CONNECTED); + return; + } + + /* + * Everything else — PnP queries, port status, idle notifications — is + * forwarded even while claimed. Blocking those would confuse the stack + * about the device's existence, and it does still exist. + */ + UsbShareForward(device, Request); +} diff --git a/driver/windows/public.h b/driver/windows/public.h new file mode 100644 index 0000000..1f595f4 --- /dev/null +++ b/driver/windows/public.h @@ -0,0 +1,162 @@ +/* + * usbshare - public interface + * + * Shared between the kernel driver and the user mode client. Keep this file + * in sync with internal/usb/driver_windows.go: both sides marshal the same + * structures, and a mismatch corrupts memory rather than failing cleanly. + */ + +#pragma once + +#include + +/* + * Device interface GUID. User mode enumerates this to find devices that have + * the filter attached. + * + * Generate a fresh GUID if you fork this driver: two drivers exposing the + * same interface would be indistinguishable to clients. + */ +// {8F3D2A14-6C7B-4E59-9A1D-3F5B7C8E2D40} +DEFINE_GUID(GUID_DEVINTERFACE_USBSHARE, + 0x8f3d2a14, 0x6c7b, 0x4e59, 0x9a, 0x1d, 0x3f, 0x5b, 0x7c, 0x8e, 0x2d, 0x40); + +#define USBSHARE_DEVICE_TYPE 0x8000 + +#define USBSHARE_IOCTL(index) \ + CTL_CODE(USBSHARE_DEVICE_TYPE, 0x800 + (index), METHOD_BUFFERED, FILE_ANY_ACCESS) + +/* + * Take exclusive control of the device. + * + * While claimed the filter stops passing the class driver's requests down, so + * the device stops responding to the local system and answers only to URBs + * submitted here. The claim is bound to the file handle: closing it — or the + * process dying — releases the device, which is what stops a crashed client + * from leaving hardware permanently stuck. + * + * Input: none + * Output: USBSHARE_DEVICE_INFO + */ +#define IOCTL_USBSHARE_CLAIM USBSHARE_IOCTL(0) + +/* Release the device back to its class driver. Input/output: none. */ +#define IOCTL_USBSHARE_RELEASE USBSHARE_IOCTL(1) + +/* + * Read the raw descriptor blob: device descriptor followed by every + * configuration descriptor, the same layout a Linux usbdevfs read returns. + * + * Input: none + * Output: raw bytes; STATUS_BUFFER_TOO_SMALL reports the needed size + */ +#define IOCTL_USBSHARE_GET_DESCRIPTORS USBSHARE_IOCTL(2) + +/* + * Submit a transfer. Completion is asynchronous: the request stays pending + * until the device answers. + * + * Input: USBSHARE_TRANSFER followed by the payload for OUT transfers + * Output: USBSHARE_TRANSFER_RESULT followed by the payload for IN transfers + */ +#define IOCTL_USBSHARE_SUBMIT USBSHARE_IOCTL(3) + +/* + * Cancel a previously submitted transfer. + * + * Input: USBSHARE_CANCEL + * Output: none + */ +#define IOCTL_USBSHARE_CANCEL USBSHARE_IOCTL(4) + +/* Select an alternate setting. Input: USBSHARE_SET_INTERFACE. */ +#define IOCTL_USBSHARE_SET_INTERFACE USBSHARE_IOCTL(5) + +/* Clear a stall on an endpoint. Input: USBSHARE_CLEAR_HALT. */ +#define IOCTL_USBSHARE_CLEAR_HALT USBSHARE_IOCTL(6) + +/* Reset the port. Input/output: none. */ +#define IOCTL_USBSHARE_RESET USBSHARE_IOCTL(7) + +#pragma pack(push, 1) + +/* Transfer types, matching the USB endpoint attribute values. */ +#define USBSHARE_TRANSFER_CONTROL 0 +#define USBSHARE_TRANSFER_ISOCHRONOUS 1 +#define USBSHARE_TRANSFER_BULK 2 +#define USBSHARE_TRANSFER_INTERRUPT 3 + +/* Direction, taken from the endpoint address bit 7. */ +#define USBSHARE_DIR_OUT 0 +#define USBSHARE_DIR_IN 1 + +typedef struct _USBSHARE_DEVICE_INFO { + USHORT VendorId; + USHORT ProductId; + USHORT BcdDevice; + UCHAR DeviceClass; + UCHAR DeviceSubClass; + UCHAR DeviceProtocol; + UCHAR ConfigurationValue; + UCHAR NumConfigurations; + /* USB_DEVICE_SPEED_* from usbdi.h, translated by the client. */ + ULONG Speed; + /* Hub port number, used to build a stable bus ID. */ + ULONG PortNumber; +} USBSHARE_DEVICE_INFO, *PUSBSHARE_DEVICE_INFO; + +typedef struct _USBSHARE_TRANSFER { + /* Caller-assigned, unique among outstanding transfers. Used to cancel. */ + ULONG64 Id; + + /* Full bEndpointAddress including the direction bit. */ + UCHAR EndpointAddress; + + /* USBSHARE_TRANSFER_* */ + UCHAR Type; + + /* USBSHARE_DIR_*, redundant with the address bit but explicit. */ + UCHAR Direction; + + UCHAR Reserved; + + /* Bytes of payload following this header (OUT), or expected (IN). */ + ULONG BufferLength; + + /* Milliseconds; 0 means no timeout. */ + ULONG Timeout; + + /* + * Setup packet for control transfers, in USB wire order (little endian). + * Ignored for other types. + */ + UCHAR Setup[8]; +} USBSHARE_TRANSFER, *PUSBSHARE_TRANSFER; + +typedef struct _USBSHARE_TRANSFER_RESULT { + ULONG64 Id; + + /* NTSTATUS from the USB stack; 0 means success. */ + LONG Status; + + /* USBD_STATUS, kept separate because it distinguishes stall from timeout. */ + ULONG UsbdStatus; + + /* Bytes actually transferred. Meaningful for OUT transfers too. */ + ULONG ActualLength; +} USBSHARE_TRANSFER_RESULT, *PUSBSHARE_TRANSFER_RESULT; + +typedef struct _USBSHARE_CANCEL { + ULONG64 Id; +} USBSHARE_CANCEL, *PUSBSHARE_CANCEL; + +typedef struct _USBSHARE_SET_INTERFACE { + UCHAR InterfaceNumber; + UCHAR AlternateSetting; +} USBSHARE_SET_INTERFACE, *PUSBSHARE_SET_INTERFACE; + +typedef struct _USBSHARE_CLEAR_HALT { + UCHAR EndpointAddress; +} USBSHARE_CLEAR_HALT, *PUSBSHARE_CLEAR_HALT; + +#pragma pack(pop) diff --git a/driver/windows/queue.c b/driver/windows/queue.c new file mode 100644 index 0000000..b209153 --- /dev/null +++ b/driver/windows/queue.c @@ -0,0 +1,555 @@ +/* + * usbshare - IOCTL handling and URB forwarding + */ + +#include "usbshare.h" + +static VOID UsbShareCompleteTransfer( + _In_ WDFREQUEST Request, + _In_ WDFIOTARGET Target, + _In_ PWDF_REQUEST_COMPLETION_PARAMS Params, + _In_ WDFCONTEXT Context); + +static NTSTATUS UsbShareHandleClaim(_In_ PDEVICE_CONTEXT Context, _In_ WDFREQUEST Request); +static NTSTATUS UsbShareHandleGetDescriptors(_In_ PDEVICE_CONTEXT Context, _In_ WDFREQUEST Request); +static NTSTATUS UsbShareHandleSubmit(_In_ PDEVICE_CONTEXT Context, _In_ WDFREQUEST Request); +static NTSTATUS UsbShareHandleCancel(_In_ PDEVICE_CONTEXT Context, _In_ WDFREQUEST Request); +static NTSTATUS UsbShareHandleSetInterface(_In_ PDEVICE_CONTEXT Context, _In_ WDFREQUEST Request); +static NTSTATUS UsbShareHandleClearHalt(_In_ PDEVICE_CONTEXT Context, _In_ WDFREQUEST Request); + +VOID +UsbShareEvtIoDeviceControl( + _In_ WDFQUEUE Queue, + _In_ WDFREQUEST Request, + _In_ size_t OutputBufferLength, + _In_ size_t InputBufferLength, + _In_ ULONG IoControlCode +) +{ + WDFDEVICE device = WdfIoQueueGetDevice(Queue); + PDEVICE_CONTEXT context = GetDeviceContext(device); + NTSTATUS status; + + UNREFERENCED_PARAMETER(OutputBufferLength); + UNREFERENCED_PARAMETER(InputBufferLength); + + switch (IoControlCode) { + case IOCTL_USBSHARE_CLAIM: + status = UsbShareHandleClaim(context, Request); + break; + + case IOCTL_USBSHARE_RELEASE: + UsbShareReleaseClaim(context, WdfRequestGetFileObject(Request)); + status = STATUS_SUCCESS; + break; + + case IOCTL_USBSHARE_GET_DESCRIPTORS: + status = UsbShareHandleGetDescriptors(context, Request); + break; + + case IOCTL_USBSHARE_SUBMIT: + status = UsbShareHandleSubmit(context, Request); + /* + * A submitted transfer completes asynchronously; the completion + * routine owns the request from here. + */ + if (status == STATUS_PENDING) { + return; + } + break; + + case IOCTL_USBSHARE_CANCEL: + status = UsbShareHandleCancel(context, Request); + break; + + case IOCTL_USBSHARE_SET_INTERFACE: + status = UsbShareHandleSetInterface(context, Request); + break; + + case IOCTL_USBSHARE_CLEAR_HALT: + status = UsbShareHandleClearHalt(context, Request); + break; + + case IOCTL_USBSHARE_RESET: + status = WdfUsbTargetDeviceResetPortSynchronously(context->UsbDevice); + break; + + default: + /* + * Not ours. As a filter we must pass it on rather than fail it — + * some other component in the stack may be waiting for the answer. + */ + { + WDF_REQUEST_SEND_OPTIONS options; + WDF_REQUEST_SEND_OPTIONS_INIT(&options, WDF_REQUEST_SEND_OPTION_SEND_AND_FORGET); + WdfRequestFormatRequestUsingCurrentType(Request); + if (!WdfRequestSend(Request, WdfDeviceGetIoTarget(device), &options)) { + WdfRequestComplete(Request, WdfRequestGetStatus(Request)); + } + return; + } + } + + WdfRequestComplete(Request, status); +} + +static NTSTATUS +UsbShareHandleClaim( + _In_ PDEVICE_CONTEXT Context, + _In_ WDFREQUEST Request +) +{ + NTSTATUS status; + PUSBSHARE_DEVICE_INFO info; + WDFFILEOBJECT fileObject = WdfRequestGetFileObject(Request); + + status = WdfRequestRetrieveOutputBuffer(Request, sizeof(USBSHARE_DEVICE_INFO), + (PVOID *)&info, NULL); + if (!NT_SUCCESS(status)) { + return status; + } + + WdfSpinLockAcquire(Context->ClaimLock); + + if (Context->Claimed && Context->ClaimOwner != fileObject) { + WdfSpinLockRelease(Context->ClaimLock); + return STATUS_DEVICE_BUSY; + } + + Context->Claimed = TRUE; + Context->ClaimOwner = fileObject; + + WdfSpinLockRelease(Context->ClaimLock); + + *info = Context->Info; + WdfRequestSetInformation(Request, sizeof(USBSHARE_DEVICE_INFO)); + + KdPrint(("usbshare: device claimed (%04x:%04x)\n", info->VendorId, info->ProductId)); + return STATUS_SUCCESS; +} + +static NTSTATUS +UsbShareHandleGetDescriptors( + _In_ PDEVICE_CONTEXT Context, + _In_ WDFREQUEST Request +) +{ + NTSTATUS status; + PVOID buffer; + size_t bufferLength; + + if (Context->Descriptors == NULL) { + status = UsbShareBuildDescriptorBlob(Context); + if (!NT_SUCCESS(status)) { + return status; + } + } + + status = WdfRequestRetrieveOutputBuffer(Request, 1, &buffer, &bufferLength); + if (!NT_SUCCESS(status)) { + return status; + } + + if (bufferLength < Context->DescriptorsLength) { + /* Report the needed size so the caller can retry. */ + WdfRequestSetInformation(Request, Context->DescriptorsLength); + return STATUS_BUFFER_TOO_SMALL; + } + + RtlCopyMemory(buffer, Context->Descriptors, Context->DescriptorsLength); + WdfRequestSetInformation(Request, Context->DescriptorsLength); + + return STATUS_SUCCESS; +} + +/* + * Builds the descriptor blob: device descriptor followed by every + * configuration descriptor, matching what Linux returns when reading a + * usbdevfs file. The client parses both with the same code. + */ +NTSTATUS +UsbShareBuildDescriptorBlob( + _In_ PDEVICE_CONTEXT Context +) +{ + NTSTATUS status; + USB_DEVICE_DESCRIPTOR deviceDescriptor; + PUCHAR blob = NULL; + ULONG blobSize = 0; + ULONG offset; + UCHAR configIndex; + + WdfUsbTargetDeviceGetDeviceDescriptor(Context->UsbDevice, &deviceDescriptor); + + /* First pass: total up the sizes. */ + blobSize = sizeof(USB_DEVICE_DESCRIPTOR); + + for (configIndex = 0; configIndex < deviceDescriptor.bNumConfigurations; configIndex++) { + USHORT configSize = 0; + + status = WdfUsbTargetDeviceRetrieveConfigDescriptor(Context->UsbDevice, NULL, &configSize); + if (status != STATUS_BUFFER_TOO_SMALL && !NT_SUCCESS(status)) { + return status; + } + blobSize += configSize; + + /* + * Only configuration 0 can be retrieved through this API; devices + * with several configurations would need a raw control transfer per + * configuration. They are rare enough to leave for later. + */ + break; + } + + blob = (PUCHAR)ExAllocatePool2(POOL_FLAG_NON_PAGED, blobSize, USBSHARE_POOL_TAG); + if (blob == NULL) { + return STATUS_INSUFFICIENT_RESOURCES; + } + + RtlCopyMemory(blob, &deviceDescriptor, sizeof(USB_DEVICE_DESCRIPTOR)); + offset = sizeof(USB_DEVICE_DESCRIPTOR); + + { + USHORT configSize = (USHORT)(blobSize - offset); + status = WdfUsbTargetDeviceRetrieveConfigDescriptor(Context->UsbDevice, + blob + offset, + &configSize); + if (!NT_SUCCESS(status)) { + ExFreePoolWithTag(blob, USBSHARE_POOL_TAG); + return status; + } + } + + Context->Descriptors = blob; + Context->DescriptorsLength = blobSize; + + return STATUS_SUCCESS; +} + +static NTSTATUS +UsbShareHandleSubmit( + _In_ PDEVICE_CONTEXT Context, + _In_ WDFREQUEST Request +) +{ + NTSTATUS status; + PUSBSHARE_TRANSFER transfer; + size_t inputLength; + PUCHAR payload; + WDFMEMORY urbMemory; + PURB urb; + WDFUSBPIPE pipe; + PREQUEST_CONTEXT reqContext; + WDF_OBJECT_ATTRIBUTES attributes; + WDFIOTARGET target; + + if (!UsbShareIsClaimed(Context)) { + return STATUS_INVALID_DEVICE_STATE; + } + + status = WdfRequestRetrieveInputBuffer(Request, sizeof(USBSHARE_TRANSFER), + (PVOID *)&transfer, &inputLength); + if (!NT_SUCCESS(status)) { + return status; + } + + if (inputLength < sizeof(USBSHARE_TRANSFER) + transfer->BufferLength) { + return STATUS_BUFFER_TOO_SMALL; + } + payload = (PUCHAR)transfer + sizeof(USBSHARE_TRANSFER); + + /* Attach a context so a later cancel can find this request. */ + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, REQUEST_CONTEXT); + status = WdfObjectAllocateContext(Request, &attributes, (PVOID *)&reqContext); + if (!NT_SUCCESS(status)) { + return status; + } + reqContext->TransferId = transfer->Id; + reqContext->ExpectedLength = transfer->BufferLength; + + WDF_OBJECT_ATTRIBUTES_INIT(&attributes); + attributes.ParentObject = Request; + + if (transfer->Type == USBSHARE_TRANSFER_CONTROL) { + status = WdfUsbTargetDeviceCreateUrb(Context->UsbDevice, &attributes, + &urbMemory, &urb); + if (!NT_SUCCESS(status)) { + return status; + } + + /* + * The setup packet arrives in USB wire order and is copied verbatim. + * Reinterpreting the fields here would only introduce a chance to get + * the endianness wrong. + */ + UsbBuildVendorRequest(urb, + URB_FUNCTION_VENDOR_DEVICE, + sizeof(struct _URB_CONTROL_VENDOR_OR_CLASS_REQUEST), + (transfer->Direction == USBSHARE_DIR_IN) + ? USBD_TRANSFER_DIRECTION_IN : 0, + 0, + transfer->Setup[0], /* bmRequestType */ + transfer->Setup[1], /* bRequest */ + *(USHORT *)&transfer->Setup[2], /* wValue */ + *(USHORT *)&transfer->Setup[4], /* wIndex */ + payload, + NULL, + transfer->BufferLength, + NULL); + } else { + pipe = Context->Pipes[transfer->EndpointAddress]; + if (pipe == NULL) { + return STATUS_INVALID_PARAMETER; + } + + status = WdfUsbTargetDeviceCreateUrb(Context->UsbDevice, &attributes, + &urbMemory, &urb); + if (!NT_SUCCESS(status)) { + return status; + } + + /* + * Bulk and interrupt share one URB function; the pipe handle decides + * which it actually is. + */ + urb->UrbBulkOrInterruptTransfer.Hdr.Length = + sizeof(struct _URB_BULK_OR_INTERRUPT_TRANSFER); + urb->UrbBulkOrInterruptTransfer.Hdr.Function = + URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER; + urb->UrbBulkOrInterruptTransfer.PipeHandle = WdfUsbTargetPipeWdmGetPipeHandle(pipe); + urb->UrbBulkOrInterruptTransfer.TransferBuffer = payload; + urb->UrbBulkOrInterruptTransfer.TransferBufferLength = transfer->BufferLength; + urb->UrbBulkOrInterruptTransfer.TransferBufferMDL = NULL; + urb->UrbBulkOrInterruptTransfer.UrbLink = NULL; + urb->UrbBulkOrInterruptTransfer.TransferFlags = + (transfer->Direction == USBSHARE_DIR_IN) + ? (USBD_TRANSFER_DIRECTION_IN | USBD_SHORT_TRANSFER_OK) + : 0; + } + + reqContext->UrbMemory = urbMemory; + reqContext->Urb = urb; + + target = WdfUsbTargetDeviceGetIoTarget(Context->UsbDevice); + + status = WdfUsbTargetDeviceFormatRequestForUrb(Context->UsbDevice, Request, + urbMemory, NULL); + if (!NT_SUCCESS(status)) { + return status; + } + + WdfRequestSetCompletionRoutine(Request, UsbShareCompleteTransfer, Context); + + /* Track it so a cancel can find it. */ + WdfSpinLockAcquire(Context->PendingLock); + WdfCollectionAdd(Context->PendingTransfers, Request); + WdfSpinLockRelease(Context->PendingLock); + + if (!WdfRequestSend(Request, target, WDF_NO_SEND_OPTIONS)) { + status = WdfRequestGetStatus(Request); + + WdfSpinLockAcquire(Context->PendingLock); + WdfCollectionRemove(Context->PendingTransfers, Request); + WdfSpinLockRelease(Context->PendingLock); + + return status; + } + + return STATUS_PENDING; +} + +static VOID +UsbShareCompleteTransfer( + _In_ WDFREQUEST Request, + _In_ WDFIOTARGET Target, + _In_ PWDF_REQUEST_COMPLETION_PARAMS Params, + _In_ WDFCONTEXT CompletionContext +) +{ + PDEVICE_CONTEXT context = (PDEVICE_CONTEXT)CompletionContext; + PREQUEST_CONTEXT reqContext = GetRequestContext(Request); + PUSBSHARE_TRANSFER_RESULT result; + NTSTATUS status; + size_t outputLength; + ULONG transferred = 0; + PUCHAR outPayload; + + UNREFERENCED_PARAMETER(Target); + + WdfSpinLockAcquire(context->PendingLock); + WdfCollectionRemove(context->PendingTransfers, Request); + WdfSpinLockRelease(context->PendingLock); + + if (reqContext->Urb != NULL) { + transferred = reqContext->Urb->UrbBulkOrInterruptTransfer.TransferBufferLength; + } + + status = WdfRequestRetrieveOutputBuffer(Request, sizeof(USBSHARE_TRANSFER_RESULT), + (PVOID *)&result, &outputLength); + if (!NT_SUCCESS(status)) { + WdfRequestComplete(Request, status); + return; + } + + result->Id = reqContext->TransferId; + result->Status = Params->IoStatus.Status; + result->UsbdStatus = (reqContext->Urb != NULL) + ? reqContext->Urb->UrbHeader.Status : 0; + result->ActualLength = transferred; + + /* + * Copy the received payload after the result header, but only as much as + * the output buffer holds — a device may return more than expected. + */ + if (transferred > 0 && outputLength > sizeof(USBSHARE_TRANSFER_RESULT)) { + ULONG room = (ULONG)(outputLength - sizeof(USBSHARE_TRANSFER_RESULT)); + ULONG copy = (transferred < room) ? transferred : room; + + outPayload = (PUCHAR)result + sizeof(USBSHARE_TRANSFER_RESULT); + RtlCopyMemory(outPayload, + reqContext->Urb->UrbBulkOrInterruptTransfer.TransferBuffer, + copy); + + WdfRequestSetInformation(Request, sizeof(USBSHARE_TRANSFER_RESULT) + copy); + } else { + WdfRequestSetInformation(Request, sizeof(USBSHARE_TRANSFER_RESULT)); + } + + /* + * Always complete successfully: the transfer's own outcome travels in the + * result structure. Failing the IOCTL would lose the distinction between + * "the ioctl did not work" and "the device stalled". + */ + WdfRequestComplete(Request, STATUS_SUCCESS); +} + +static NTSTATUS +UsbShareHandleCancel( + _In_ PDEVICE_CONTEXT Context, + _In_ WDFREQUEST Request +) +{ + NTSTATUS status; + PUSBSHARE_CANCEL cancel; + ULONG i, count; + WDFREQUEST target = NULL; + + status = WdfRequestRetrieveInputBuffer(Request, sizeof(USBSHARE_CANCEL), + (PVOID *)&cancel, NULL); + if (!NT_SUCCESS(status)) { + return status; + } + + WdfSpinLockAcquire(Context->PendingLock); + + count = WdfCollectionGetCount(Context->PendingTransfers); + for (i = 0; i < count; i++) { + WDFREQUEST candidate = (WDFREQUEST)WdfCollectionGetItem(Context->PendingTransfers, i); + PREQUEST_CONTEXT candidateContext = GetRequestContext(candidate); + + if (candidateContext != NULL && candidateContext->TransferId == cancel->Id) { + target = candidate; + break; + } + } + + WdfSpinLockRelease(Context->PendingLock); + + if (target == NULL) { + /* Already finished. Not an error: the caller gets its result anyway. */ + return STATUS_SUCCESS; + } + + WdfRequestCancelSentRequest(target); + return STATUS_SUCCESS; +} + +static NTSTATUS +UsbShareHandleSetInterface( + _In_ PDEVICE_CONTEXT Context, + _In_ WDFREQUEST Request +) +{ + NTSTATUS status; + PUSBSHARE_SET_INTERFACE params; + WDF_USB_INTERFACE_SELECT_SETTING_PARAMS settingParams; + + if (!UsbShareIsClaimed(Context)) { + return STATUS_INVALID_DEVICE_STATE; + } + + status = WdfRequestRetrieveInputBuffer(Request, sizeof(USBSHARE_SET_INTERFACE), + (PVOID *)¶ms, NULL); + if (!NT_SUCCESS(status)) { + return status; + } + + if (Context->UsbInterface == NULL) { + return STATUS_INVALID_DEVICE_STATE; + } + + /* + * Going through the framework rather than sending a raw SET_INTERFACE is + * essential: the USB stack has to re-open the pipes and, for isochronous + * endpoints, reserve bandwidth. A raw control transfer changes the device + * without telling the stack, after which every later transfer fails. + */ + WDF_USB_INTERFACE_SELECT_SETTING_PARAMS_INIT_SETTING(&settingParams, + params->AlternateSetting); + + status = WdfUsbInterfaceSelectSetting(Context->UsbInterface, + WDF_NO_OBJECT_ATTRIBUTES, + &settingParams); + if (!NT_SUCCESS(status)) { + return status; + } + + /* Pipe handles change with the setting, so rebuild the map. */ + RtlZeroMemory(Context->Pipes, sizeof(Context->Pipes)); + { + BYTE pipeCount = WdfUsbInterfaceGetNumConfiguredPipes(Context->UsbInterface); + BYTE i; + + for (i = 0; i < pipeCount; i++) { + WDF_USB_PIPE_INFORMATION pipeInfo; + WDFUSBPIPE pipe; + + WDF_USB_PIPE_INFORMATION_INIT(&pipeInfo); + pipe = WdfUsbInterfaceGetConfiguredPipe(Context->UsbInterface, i, &pipeInfo); + if (pipe != NULL) { + Context->Pipes[pipeInfo.EndpointAddress] = pipe; + WdfUsbTargetPipeSetNoMaximumPacketSizeCheck(pipe); + } + } + } + + return STATUS_SUCCESS; +} + +static NTSTATUS +UsbShareHandleClearHalt( + _In_ PDEVICE_CONTEXT Context, + _In_ WDFREQUEST Request +) +{ + NTSTATUS status; + PUSBSHARE_CLEAR_HALT params; + WDFUSBPIPE pipe; + + if (!UsbShareIsClaimed(Context)) { + return STATUS_INVALID_DEVICE_STATE; + } + + status = WdfRequestRetrieveInputBuffer(Request, sizeof(USBSHARE_CLEAR_HALT), + (PVOID *)¶ms, NULL); + if (!NT_SUCCESS(status)) { + return status; + } + + pipe = Context->Pipes[params->EndpointAddress]; + if (pipe == NULL) { + return STATUS_INVALID_PARAMETER; + } + + return WdfUsbTargetPipeResetSynchronously(pipe, WDF_NO_HANDLE, NULL); +} diff --git a/driver/windows/usbshare.h b/driver/windows/usbshare.h new file mode 100644 index 0000000..2cbfb7b --- /dev/null +++ b/driver/windows/usbshare.h @@ -0,0 +1,98 @@ +/* + * usbshare - internal declarations + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include "public.h" + +#define USBSHARE_POOL_TAG 'hsBU' + +/* + * Per-device context. + * + * One instance per filtered device. Claimed and ClaimOwner together decide + * whether the class driver's requests are passed down or swallowed. + */ +typedef struct _DEVICE_CONTEXT { + /* The device we are filtering, as a USB target. */ + WDFUSBDEVICE UsbDevice; + + /* The interface whose pipes we use. Only the first is handled today. */ + WDFUSBINTERFACE UsbInterface; + + /* + * Pipe handles indexed by endpoint address (0x00-0xFF). + * + * Indexing by full address rather than endpoint number matters: a device + * can have endpoint 1 as both interrupt IN (0x81) and bulk OUT (0x01), + * and conflating them submits transfers of the wrong type. + */ + WDFUSBPIPE Pipes[256]; + + /* + * Non-zero while user mode holds the device. Guarded by ClaimLock; read + * on the request path, so it must stay cheap. + */ + BOOLEAN Claimed; + + /* + * The file object that claimed it. Used to release automatically when + * that handle closes, including when its process dies. + */ + WDFFILEOBJECT ClaimOwner; + + WDFSPINLOCK ClaimLock; + + /* Outstanding user mode transfers, so cancellation can find them. */ + WDFCOLLECTION PendingTransfers; + WDFSPINLOCK PendingLock; + + /* Cached descriptor blob, built once on first request. */ + PUCHAR Descriptors; + ULONG DescriptorsLength; + + /* Device info reported on claim. */ + USBSHARE_DEVICE_INFO Info; +} DEVICE_CONTEXT, *PDEVICE_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(DEVICE_CONTEXT, GetDeviceContext) + +/* + * Per-request context, kept so a cancel can locate the WDFREQUEST that + * belongs to a transfer ID. + */ +typedef struct _REQUEST_CONTEXT { + ULONG64 TransferId; + WDFMEMORY UrbMemory; + PURB Urb; + /* Bytes of payload the caller expects back, for IN transfers. */ + ULONG ExpectedLength; +} REQUEST_CONTEXT, *PREQUEST_CONTEXT; + +WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(REQUEST_CONTEXT, GetRequestContext) + +/* driver.c */ +DRIVER_INITIALIZE DriverEntry; +EVT_WDF_DRIVER_DEVICE_ADD UsbShareEvtDeviceAdd; +EVT_WDF_DEVICE_PREPARE_HARDWARE UsbShareEvtDevicePrepareHardware; +EVT_WDF_DEVICE_FILE_CREATE UsbShareEvtDeviceFileCreate; +EVT_WDF_FILE_CLOSE UsbShareEvtFileClose; + +/* queue.c */ +EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL UsbShareEvtIoDeviceControl; + +/* filter.c */ +EVT_WDF_IO_QUEUE_IO_DEFAULT UsbShareEvtIoDefault; +EVT_WDF_IO_QUEUE_IO_INTERNAL_DEVICE_CONTROL UsbShareEvtIoInternalDeviceControl; + +/* Helpers shared between translation units. */ +NTSTATUS UsbShareBuildDescriptorBlob(_In_ PDEVICE_CONTEXT Context); +BOOLEAN UsbShareIsClaimed(_In_ PDEVICE_CONTEXT Context); +VOID UsbShareReleaseClaim(_In_ PDEVICE_CONTEXT Context, _In_opt_ WDFFILEOBJECT Owner); diff --git a/driver/windows/usbshare.inf b/driver/windows/usbshare.inf new file mode 100644 index 0000000..7e65d6a --- /dev/null +++ b/driver/windows/usbshare.inf @@ -0,0 +1,69 @@ +; +; usbshare.inf - upper filter for USB devices +; +; Installs usbshare as an upper filter. Two ways to use it: +; +; 1. Per device: right-click the INF and Install, then use Device Manager +; to update the driver for the specific device. +; 2. Class-wide: add usbshare to the UpperFilters of the USB device class, +; which the ClassInstall32 section below does. That covers every USB +; device, which is convenient but means a bug affects everything - +; start per-device. +; + +[Version] +Signature = "$WINDOWS NT$" +Class = USBDevice +ClassGuid = {88BAE032-5A81-49f0-BC3D-A4FF138216D6} +Provider = %ManufacturerName% +CatalogFile = usbshare.cat +DriverVer = 01/01/2026,1.0.0.0 +PnpLockdown = 1 + +[DestinationDirs] +DefaultDestDir = 13 ; Driver Store + +[SourceDisksNames] +1 = %DiskName% + +[SourceDisksFiles] +usbshare.sys = 1 + +[Manufacturer] +%ManufacturerName% = Standard,NT$ARCH$.10.0...16299 + +; Matching on the generic USB device ID keeps this installable on anything. +; Narrow it to USB\VID_xxxx&PID_yyyy for a single device. +[Standard.NT$ARCH$.10.0...16299] +%DeviceName% = UsbShare_Install, USB\UNKNOWN + +[UsbShare_Install.NT] +CopyFiles = UsbShare_CopyFiles + +[UsbShare_Install.NT.HW] +AddReg = UsbShare_AddReg + +[UsbShare_CopyFiles] +usbshare.sys + +; Registering as an UpperFilter is what places this driver above the class +; driver in the stack, which is the whole point: the class driver keeps +; working and we only step in when a client claims the device. +[UsbShare_AddReg] +HKR,,"UpperFilters",0x00010000,"usbshare" + +[UsbShare_Install.NT.Services] +AddService = usbshare,,UsbShare_Service + +[UsbShare_Service] +DisplayName = %ServiceName% +ServiceType = 1 ; SERVICE_KERNEL_DRIVER +StartType = 3 ; SERVICE_DEMAND_START +ErrorControl = 1 ; SERVICE_ERROR_NORMAL +ServiceBinary = %13%\usbshare.sys + +[Strings] +ManufacturerName = "usb-server" +DiskName = "usbshare Installation Disk" +DeviceName = "USB Share Filter" +ServiceName = "usbshare USB filter driver" diff --git a/driver/windows/usbshare.vcxproj b/driver/windows/usbshare.vcxproj new file mode 100644 index 0000000..5bda3ee --- /dev/null +++ b/driver/windows/usbshare.vcxproj @@ -0,0 +1,53 @@ + + + + + Debug + x64 + + + Release + x64 + + + Release + ARM64 + + + + {A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D} + Windows10 + KMDF + + 1 + 15 + usbshare + + + + Driver + WindowsKernelModeDriver10.0 + + + + + + true + Level4 + _WIN64;AMD64;%(PreprocessorDefinitions) + + + $(DDK_LIB_PATH)\usbdex.lib;%(AdditionalDependencies) + + + + + + + + + + + + diff --git a/go.mod b/go.mod index e452a46..075c044 100644 --- a/go.mod +++ b/go.mod @@ -6,5 +6,7 @@ require github.com/gorilla/websocket v1.5.3 require ( github.com/google/uuid v1.6.0 - golang.org/x/sys v0.41.0 + golang.org/x/sys v0.47.0 ) + +require golang.org/x/crypto v0.54.0 // indirect diff --git a/go.sum b/go.sum index 9b6513e..f33b40a 100644 --- a/go.sum +++ b/go.sum @@ -2,5 +2,9 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/bridge/bridge_linux.go b/internal/bridge/bridge_linux.go new file mode 100644 index 0000000..213b888 --- /dev/null +++ b/internal/bridge/bridge_linux.go @@ -0,0 +1,294 @@ +//go:build linux + +// Package bridge accepts USB devices handed in from another process. +// +// It exists for hosts where this process cannot open USB devices itself. +// Android is the case that motivated it: apps there have no access to +// /dev/bus/usb, and must ask the framework, which shows a permission dialog +// and returns an already-open file descriptor. A small app-side shim obtains +// that descriptor plus the device's raw descriptors and passes both here over +// a Unix socket, using SCM_RIGHTS to transfer the descriptor itself. +// +// Nothing about this is Android-specific though: any supervising process can +// use it to hand devices to an unprivileged client. +package bridge + +import ( + "encoding/json" + "fmt" + "log" + "net" + "os" + "path/filepath" + "sync" + + "github.com/duffy/usb-server/internal/usb" + "golang.org/x/sys/unix" +) + +// maxRequestSize caps one request. Descriptor blobs are a few hundred bytes; +// this leaves plenty of room while bounding what a caller can make us buffer. +const maxRequestSize = 64 * 1024 + +// Request is one device handover, sent as a single JSON message with the +// device's file descriptor attached as SCM_RIGHTS ancillary data. +type Request struct { + // Action is "add" or "remove". + Action string `json:"action"` + + // BusID identifies the device within this client, e.g. "1-2". It must be + // stable for as long as the device is shared: it is what peers request. + BusID string `json:"bus_id"` + + // Descriptors is the raw descriptor blob, base64 encoded by encoding/json: + // the device descriptor followed by all configuration descriptors. On + // Android this is UsbDeviceConnection.getRawDescriptors(). + Descriptors []byte `json:"descriptors,omitempty"` + + BusNum uint32 `json:"bus_num,omitempty"` + DevNum uint32 `json:"dev_num,omitempty"` + Speed uint32 `json:"speed,omitempty"` + ConfigValue uint8 `json:"config_value,omitempty"` + Manufacturer string `json:"manufacturer,omitempty"` + Product string `json:"product,omitempty"` + Serial string `json:"serial,omitempty"` +} + +// Response reports the outcome of a request. +type Response struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +// Server listens for device handovers on a Unix socket. +type Server struct { + listener net.Listener + path string + + // OnChange fires after a device is added or removed, so the share manager + // can refresh and announce its list without waiting for the next poll. + OnChange func() + + mu sync.Mutex + closed bool +} + +// Listen starts a bridge server on the given Unix socket path. +// +// The socket is created with 0600 permissions: whoever can write to it can +// make this client share arbitrary USB devices. +func Listen(path string) (*Server, error) { + if path == "" { + return nil, fmt.Errorf("socket path is required") + } + + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return nil, fmt.Errorf("creating socket directory: %w", err) + } + + // A leftover socket from a previous run would make Listen fail. + if info, err := os.Stat(path); err == nil && info.Mode()&os.ModeSocket != 0 { + os.Remove(path) + } + + ln, err := net.Listen("unix", path) + if err != nil { + return nil, fmt.Errorf("listening on %s: %w", path, err) + } + + if err := os.Chmod(path, 0600); err != nil { + ln.Close() + return nil, fmt.Errorf("securing socket: %w", err) + } + + s := &Server{listener: ln, path: path} + go s.acceptLoop() + + log.Printf("[bridge] listening on %s for device handovers", path) + return s, nil +} + +// Close stops the server and removes the socket. +func (s *Server) Close() error { + s.mu.Lock() + s.closed = true + s.mu.Unlock() + + err := s.listener.Close() + os.Remove(s.path) + usb.ReleaseAdoptedFDs() + return err +} + +func (s *Server) acceptLoop() { + for { + conn, err := s.listener.Accept() + if err != nil { + s.mu.Lock() + closed := s.closed + s.mu.Unlock() + if closed { + return + } + log.Printf("[bridge] accept error: %v", err) + return + } + + go s.handleConn(conn.(*net.UnixConn)) + } +} + +// handleConn processes requests on one connection until it closes. +func (s *Server) handleConn(conn *net.UnixConn) { + defer conn.Close() + + for { + req, fd, err := readRequest(conn) + if err != nil { + // A clean disconnect is the normal way a session ends. + return + } + + resp := s.apply(req, fd) + if err := writeResponse(conn, resp); err != nil { + return + } + } +} + +// apply carries out one request, taking ownership of fd. +func (s *Server) apply(req *Request, fd int) Response { + closeFD := func() { + if fd >= 0 { + unix.Close(fd) + } + } + + switch req.Action { + case "add": + if req.BusID == "" { + closeFD() + return Response{Error: "bus_id is required"} + } + if fd < 0 { + return Response{Error: "no file descriptor was attached; " + + "send the open device descriptor as SCM_RIGHTS ancillary data"} + } + if len(req.Descriptors) == 0 { + closeFD() + return Response{Error: "descriptors are required: this process cannot read them itself"} + } + + meta := usb.ExternalDeviceMeta{ + BusNum: req.BusNum, + DevNum: req.DevNum, + Speed: req.Speed, + ConfigValue: req.ConfigValue, + Manufacturer: req.Manufacturer, + Product: req.Product, + Serial: req.Serial, + } + if err := usb.RegisterExternalDevice(req.BusID, req.Descriptors, meta); err != nil { + closeFD() + return Response{Error: err.Error()} + } + + // Register the descriptor only after the device parsed cleanly, so a + // rejected request leaves nothing behind. + if err := usb.AdoptDeviceFD(req.BusID, fd); err != nil { + usb.UnregisterExternalDevice(req.BusID) + closeFD() + return Response{Error: err.Error()} + } + + log.Printf("[bridge] device %s registered from outside (%s %s)", + req.BusID, req.Manufacturer, req.Product) + s.notify() + return Response{OK: true} + + case "remove": + closeFD() + if req.BusID == "" { + return Response{Error: "bus_id is required"} + } + usb.UnregisterExternalDevice(req.BusID) + log.Printf("[bridge] device %s withdrawn", req.BusID) + s.notify() + return Response{OK: true} + + default: + closeFD() + return Response{Error: fmt.Sprintf("unknown action %q (expected add or remove)", req.Action)} + } +} + +func (s *Server) notify() { + if s.OnChange != nil { + s.OnChange() + } +} + +// readRequest reads one JSON message plus an optional attached descriptor. +// It returns fd = -1 when no descriptor was sent. +func readRequest(conn *net.UnixConn) (*Request, int, error) { + buf := make([]byte, maxRequestSize) + oob := make([]byte, unix.CmsgSpace(4)) // room for exactly one descriptor + + n, oobn, _, _, err := conn.ReadMsgUnix(buf, oob) + if err != nil { + return nil, -1, err + } + if n == 0 { + return nil, -1, fmt.Errorf("empty request") + } + + fd := extractFD(oob[:oobn]) + + var req Request + if err := json.Unmarshal(buf[:n], &req); err != nil { + if fd >= 0 { + unix.Close(fd) + } + return nil, -1, fmt.Errorf("parsing request: %w", err) + } + + return &req, fd, nil +} + +// extractFD pulls a single descriptor out of ancillary data. +// Any extra descriptors are closed rather than leaked. +func extractFD(oob []byte) int { + if len(oob) == 0 { + return -1 + } + + msgs, err := unix.ParseSocketControlMessage(oob) + if err != nil { + return -1 + } + + result := -1 + for _, msg := range msgs { + fds, err := unix.ParseUnixRights(&msg) + if err != nil { + continue + } + for _, fd := range fds { + if result == -1 { + result = fd + } else { + unix.Close(fd) + } + } + } + return result +} + +func writeResponse(conn *net.UnixConn, resp Response) error { + data, err := json.Marshal(resp) + if err != nil { + return err + } + _, err = conn.Write(data) + return err +} diff --git a/internal/bridge/bridge_other.go b/internal/bridge/bridge_other.go new file mode 100644 index 0000000..93638c4 --- /dev/null +++ b/internal/bridge/bridge_other.go @@ -0,0 +1,21 @@ +//go:build !linux + +package bridge + +import "fmt" + +// Handing over an open USB file descriptor relies on Unix domain sockets and +// SCM_RIGHTS, plus usbdevfs on the receiving end. Neither exists elsewhere. + +// Server is a stub on platforms without the bridge. +type Server struct { + OnChange func() +} + +// Listen reports that the bridge is unavailable on this platform. +func Listen(path string) (*Server, error) { + return nil, fmt.Errorf("the device bridge is only supported on Linux") +} + +// Close does nothing. +func (s *Server) Close() error { return nil } diff --git a/internal/bridge/bridge_test.go b/internal/bridge/bridge_test.go new file mode 100644 index 0000000..58217ac --- /dev/null +++ b/internal/bridge/bridge_test.go @@ -0,0 +1,277 @@ +//go:build linux + +package bridge + +import ( + "encoding/json" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/duffy/usb-server/internal/usb" + "golang.org/x/sys/unix" +) + +// A minimal but valid descriptor blob: device descriptor, one configuration, +// one HID interface, one interrupt IN endpoint. +func testDescriptors() []byte { + dev := []byte{ + 18, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 64, + 0x6d, 0x04, // idVendor 046d + 0x1c, 0xc0, // idProduct c01c + 0x00, 0x01, // bcdDevice + 1, 2, 3, 1, + } + iface := []byte{9, 0x04, 0, 0, 1, 0x03, 0x01, 0x02, 0} + ep := []byte{7, 0x05, 0x81, 0x03, 8, 0, 10} + body := append(iface, ep...) + cfg := append([]byte{9, 0x02, byte(9 + len(body)), 0, 1, 1, 0, 0x80, 250}, body...) + return append(dev, cfg...) +} + +// send delivers one request, attaching fd if it is non-negative. +func send(t *testing.T, conn *net.UnixConn, req Request, fd int) Response { + t.Helper() + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshalling request: %v", err) + } + + var oob []byte + if fd >= 0 { + oob = unix.UnixRights(fd) + } + + if _, _, err := conn.WriteMsgUnix(data, oob, nil); err != nil { + t.Fatalf("sending request: %v", err) + } + + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("reading response: %v", err) + } + + var resp Response + if err := json.Unmarshal(buf[:n], &resp); err != nil { + t.Fatalf("parsing response %q: %v", buf[:n], err) + } + return resp +} + +func startServer(t *testing.T) (*Server, *net.UnixConn) { + t.Helper() + + path := filepath.Join(t.TempDir(), "bridge.sock") + srv, err := Listen(path) + if err != nil { + t.Fatalf("Listen: %v", err) + } + t.Cleanup(func() { srv.Close() }) + + conn, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: path, Net: "unix"}) + if err != nil { + t.Fatalf("dialling bridge: %v", err) + } + t.Cleanup(func() { conn.Close() }) + + return srv, conn +} + +// openTestFD returns a real descriptor to hand over. Its contents do not +// matter — nothing in the bridge reads from it — only that it is open. +func openTestFD(t *testing.T) int { + t.Helper() + + f, err := os.CreateTemp(t.TempDir(), "fd") + if err != nil { + t.Fatalf("creating temp file: %v", err) + } + defer f.Close() + + fd, err := unix.Dup(int(f.Fd())) + if err != nil { + t.Fatalf("dup: %v", err) + } + return fd +} + +func TestAddRegistersDeviceAndDescriptor(t *testing.T) { + usb.UnregisterExternalDevice("9-9") + srv, conn := startServer(t) + + changed := make(chan struct{}, 1) + srv.OnChange = func() { + select { + case changed <- struct{}{}: + default: + } + } + + resp := send(t, conn, Request{ + Action: "add", + BusID: "9-9", + Descriptors: testDescriptors(), + BusNum: 9, + DevNum: 9, + Speed: 3, + ConfigValue: 1, + Manufacturer: "Test", + Product: "Keyboard", + }, openTestFD(t)) + + if !resp.OK { + t.Fatalf("add failed: %s", resp.Error) + } + t.Cleanup(func() { usb.UnregisterExternalDevice("9-9") }) + + select { + case <-changed: + case <-time.After(2 * time.Second): + t.Error("OnChange did not fire after a device was added") + } + + // The device must show up in enumeration, parsed from the blob. + var found *usb.Device + for _, d := range usb.ExternalDevices() { + if d.BusID == "9-9" { + cp := d + found = &cp + } + } + if found == nil { + t.Fatal("device was not registered") + } + if found.VendorID != 0x046d || found.ProductID != 0xc01c { + t.Errorf("got %04x:%04x, want 046d:c01c", found.VendorID, found.ProductID) + } + if found.Product != "Keyboard" { + t.Errorf("product = %q, want %q", found.Product, "Keyboard") + } + // The endpoint has to survive with its real transfer type, which is the + // whole reason the descriptors are sent along. + ep, ok := found.Endpoints[0x81] + if !ok { + t.Fatal("endpoint 0x81 missing from the parsed descriptors") + } + if ep.TransferType != usb.TransferTypeInterrupt { + t.Errorf("endpoint type = %d, want interrupt", ep.TransferType) + } + + if !usb.HasAdoptedFD("9-9") { + t.Error("the file descriptor was not adopted") + } +} + +func TestAddRejectsMissingPieces(t *testing.T) { + _, conn := startServer(t) + + tests := []struct { + name string + req Request + fd bool + }{ + {"no bus id", Request{Action: "add", Descriptors: testDescriptors()}, true}, + {"no descriptors", Request{Action: "add", BusID: "8-8"}, true}, + {"no file descriptor", Request{Action: "add", BusID: "8-8", Descriptors: testDescriptors()}, false}, + {"garbage descriptors", Request{Action: "add", BusID: "8-8", Descriptors: []byte{1, 2, 3}}, true}, + {"unknown action", Request{Action: "frobnicate", BusID: "8-8"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fd := -1 + if tt.fd { + fd = openTestFD(t) + } + resp := send(t, conn, tt.req, fd) + if resp.OK { + t.Error("request was accepted but should have been rejected") + } + if resp.Error == "" { + t.Error("rejection carried no explanation") + } + }) + } + + if usb.HasAdoptedFD("8-8") { + t.Error("a rejected request left a descriptor behind") + usb.ReleaseAdoptedFDs() + } +} + +func TestRemoveWithdrawsDevice(t *testing.T) { + _, conn := startServer(t) + + resp := send(t, conn, Request{ + Action: "add", BusID: "7-7", Descriptors: testDescriptors(), ConfigValue: 1, + }, openTestFD(t)) + if !resp.OK { + t.Fatalf("add failed: %s", resp.Error) + } + + resp = send(t, conn, Request{Action: "remove", BusID: "7-7"}, -1) + if !resp.OK { + t.Fatalf("remove failed: %s", resp.Error) + } + + for _, d := range usb.ExternalDevices() { + if d.BusID == "7-7" { + t.Fatal("device is still registered after removal") + } + } +} + +// The socket lets its holder make this client share arbitrary devices, so it +// must not be world-writable. +func TestSocketIsPrivate(t *testing.T) { + path := filepath.Join(t.TempDir(), "bridge.sock") + srv, err := Listen(path) + if err != nil { + t.Fatalf("Listen: %v", err) + } + defer srv.Close() + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("socket permissions are %04o, want 0600", perm) + } +} + +// Restarting must not fail because the previous socket file is still there. +func TestListenReplacesStaleSocket(t *testing.T) { + path := filepath.Join(t.TempDir(), "bridge.sock") + + first, err := Listen(path) + if err != nil { + t.Fatalf("first Listen: %v", err) + } + first.listener.Close() // simulate a crash: socket file survives + + second, err := Listen(path) + if err != nil { + t.Fatalf("second Listen failed on a leftover socket: %v", err) + } + second.Close() +} + +func TestCloseRemovesSocket(t *testing.T) { + path := filepath.Join(t.TempDir(), "bridge.sock") + + srv, err := Listen(path) + if err != nil { + t.Fatalf("Listen: %v", err) + } + srv.Close() + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("the socket file outlived the server") + } +} diff --git a/internal/client/client.go b/internal/client/client.go index ec23746..7faccba 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -10,29 +10,81 @@ import ( "time" "github.com/duffy/usb-server/internal/config" + "github.com/duffy/usb-server/internal/crypto" "github.com/duffy/usb-server/internal/protocol" "github.com/google/uuid" "github.com/gorilla/websocket" ) +const ( + // readTimeout is how long we tolerate silence from the relay. The relay + // pings every 20s, and gorilla answers pings automatically, so exceeding + // this means the connection is genuinely dead — including the case where + // a NAT or proxy dropped it without sending a TCP reset. + readTimeout = 60 * time.Second + + // pingInterval is how often we ping the relay ourselves, so that an idle + // tunnel keeps NAT mappings alive from both directions. + pingInterval = 20 * time.Second + + // writeTimeout bounds a single frame write. + writeTimeout = 20 * time.Second + + // sendQueueDepth bounds outgoing backlog before we consider the link stuck. + sendQueueDepth = 256 + + // reconnectMin/reconnectMax bound the exponential backoff between + // reconnect attempts, so a relay outage does not turn into a hot loop. + reconnectMin = 1 * time.Second + reconnectMax = 30 * time.Second +) + +// outMsg is one queued outgoing WebSocket frame. +type outMsg struct { + typ int + data []byte +} + // Client manages the connection to the relay server type Client struct { cfg *config.Config clientID string - conn *websocket.Conn - mu sync.Mutex - // Event callbacks - OnDeviceList func(msg *protocol.DeviceList) - OnDeviceGranted func(msg *protocol.DeviceGranted) - OnDeviceDenied func(msg *protocol.DeviceDenied) - OnDeviceReleased func(msg *protocol.DeviceReleased) - OnClientJoined func(msg *protocol.ClientJoined) - OnClientLeft func(msg *protocol.ClientLeft) - OnRequestDevice func(targetClient, fromClient, busID, requestID string) - OnReleaseDevice func(busID, fromClient string) - OnForceRelease func(targetClient, fromClient, busID string) - OnTunnelData func(tunnelID string, data []byte) + mu sync.Mutex + conn *websocket.Conn + send chan outMsg + dead chan struct{} + + // Callbacks for messages that only one manager can own. + // In "both" mode the share manager takes the share-side ones and the use + // manager the use-side ones, so they never collide. + OnDeviceList func(msg *protocol.DeviceList) // use side + OnDeviceGranted func(msg *protocol.DeviceGranted) // use side + OnDeviceDenied func(msg *protocol.DeviceDenied) // use side + OnDeviceReleased func(msg *protocol.DeviceReleased) // use side + OnClientJoined func(msg *protocol.ClientJoined) + OnRequestDevice func(targetClient, fromClient, busID, requestID string) // share side + OnReleaseDevice func(busID, fromClient string) // share side + OnForceRelease func(targetClient, fromClient, busID string) // share side + + // Multicast callbacks. Both managers care about these, so they are lists + // rather than single fields: in "both" mode a plain field would mean the + // second manager to register silently unhooked the first. + tunnelHandlers []func(tunnelID string, data []byte) + clientLeftHandlers []func(msg *protocol.ClientLeft) + disconnectHandlers []func() + handlerMu sync.RWMutex + + // OnConnect fires once a registration has been sent successfully. + OnConnect func() + + // secret derives per-tunnel keys and peer tokens. Nil when the config + // carries only a group hash, in which case tunnels stay unencrypted and + // direct connections are unavailable. + secret *crypto.TunnelSecret + + // directPort is advertised to the relay so peers learn where to reach us. + directPort int ctx context.Context cancel context.CancelFunc @@ -41,12 +93,38 @@ type Client struct { // NewClient creates a new client instance func NewClient(cfg *config.Config) *Client { ctx, cancel := context.WithCancel(context.Background()) - return &Client{ + + c := &Client{ cfg: cfg, clientID: uuid.New().String(), ctx: ctx, cancel: cancel, } + + if cfg.HasTokens() { + secret, err := crypto.DeriveTunnelSecret(cfg.Token1, cfg.Token2, cfg.Token3) + if err != nil { + log.Printf("[client] tunnel encryption unavailable: %v", err) + } else { + c.secret = secret + } + } else { + log.Printf("[client] no tokens configured, only a group hash: " + + "tunnels will not be encrypted and direct connections are unavailable") + } + + return c +} + +// TunnelSecret returns the group secret, or nil if it could not be derived. +func (c *Client) TunnelSecret() *crypto.TunnelSecret { return c.secret } + +// SetDirectPort records the port peers should use to reach this client +// directly. It is announced with the next registration. +func (c *Client) SetDirectPort(port int) { + c.mu.Lock() + c.directPort = port + c.mu.Unlock() } // ID returns the client ID @@ -59,14 +137,18 @@ func (c *Client) Config() *config.Config { return c.cfg } -// Connect establishes connection to the relay server -func (c *Client) Connect() error { +// Context returns the client's lifetime context. +func (c *Client) Context() context.Context { + return c.ctx +} + +// relayURL normalises the configured relay address into a WebSocket URL. +func (c *Client) relayURL() (string, error) { u, err := url.Parse(c.cfg.RelayAddr) if err != nil { - return fmt.Errorf("invalid relay address: %w", err) + return "", fmt.Errorf("invalid relay address: %w", err) } - // Ensure WebSocket scheme switch u.Scheme { case "ws", "wss": // ok @@ -78,57 +160,145 @@ func (c *Client) Connect() error { u.Scheme = "ws" } - if u.Path == "" { + if u.Path == "" || u.Path == "/" { u.Path = "/ws" } - log.Printf("[client] connecting to %s", u.String()) + return u.String(), nil +} - conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) +// Connect establishes connection to the relay server +func (c *Client) Connect() error { + target, err := c.relayURL() + if err != nil { + return err + } + + log.Printf("[client] connecting to %s", target) + + dialer := websocket.Dialer{ + HandshakeTimeout: 15 * time.Second, + ReadBufferSize: 64 * 1024, + WriteBufferSize: 64 * 1024, + } + + conn, _, err := dialer.DialContext(c.ctx, target, nil) if err != nil { return fmt.Errorf("connecting to relay: %w", err) } + conn.SetReadLimit(maxMessageSize) + conn.SetReadDeadline(time.Now().Add(readTimeout)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(readTimeout)) + return nil + }) + c.mu.Lock() - c.conn = conn + directPort := c.directPort c.mu.Unlock() - // Send registration reg := &protocol.Register{ - Type: protocol.MsgRegister, - Hash: c.cfg.Hash, - Mode: c.cfg.Mode, - ClientID: c.clientID, - Name: c.cfg.Name, + Type: protocol.MsgRegister, + Hash: c.cfg.Hash, + Mode: c.cfg.Mode, + ClientID: c.clientID, + Name: c.cfg.Name, + DirectPort: directPort, + LocalEndpoints: localEndpoints(directPort), + } + regData, err := json.Marshal(reg) + if err != nil { + conn.Close() + return fmt.Errorf("encoding registration: %w", err) } - if err := conn.WriteJSON(reg); err != nil { + // The registration is written directly because the write pump is not + // running yet; every later write goes through the pump. + conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + if err := conn.WriteMessage(websocket.TextMessage, regData); err != nil { conn.Close() return fmt.Errorf("sending registration: %w", err) } - log.Printf("[client] registered as %s (mode=%s, name=%s)", c.clientID, c.cfg.Mode, c.cfg.Name) + c.mu.Lock() + c.conn = conn + c.send = make(chan outMsg, sendQueueDepth) + c.dead = make(chan struct{}) + sendCh, deadCh := c.send, c.dead + c.mu.Unlock() + + go c.writePump(conn, sendCh, deadCh) + + log.Printf("[client] registered as %s (mode=%s, name=%s)", + protocol.ShortID(c.clientID), c.cfg.Mode, c.cfg.Name) + + if c.OnConnect != nil { + c.OnConnect() + } return nil } -// RunReadLoop reads messages from the relay and dispatches them -func (c *Client) RunReadLoop() error { +// maxMessageSize must match the relay's limit. +const maxMessageSize = 1024 * 1024 + +// writePump serialises all writes to the relay socket and sends keepalives. +func (c *Client) writePump(conn *websocket.Conn, send <-chan outMsg, dead <-chan struct{}) { + ticker := time.NewTicker(pingInterval) + defer ticker.Stop() + defer conn.Close() // unblocks the read loop if we give up first + for { select { - case <-c.ctx.Done(): - return nil - default: - } + case msg := <-send: + conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + if err := conn.WriteMessage(msg.typ, msg.data); err != nil { + log.Printf("[client] write error: %v", err) + return + } - msgType, data, err := c.conn.ReadMessage() + case <-ticker.C: + conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + + case <-dead: + return + + case <-c.ctx.Done(): + return + } + } +} + +// RunReadLoop reads messages from the relay and dispatches them +func (c *Client) RunReadLoop() error { + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + return fmt.Errorf("not connected") + } + + for { + msgType, data, err := conn.ReadMessage() if err != nil { + select { + case <-c.ctx.Done(): + return nil + default: + } if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { return fmt.Errorf("read error: %w", err) } - return nil + return err } + conn.SetReadDeadline(time.Now().Add(readTimeout)) + switch msgType { case websocket.TextMessage: c.handleTextMessage(data) @@ -140,67 +310,176 @@ func (c *Client) RunReadLoop() error { // Run connects and runs the main loop with auto-reconnect func (c *Client) Run() error { + backoff := reconnectMin + for { + select { + case <-c.ctx.Done(): + return nil + default: + } + if err := c.Connect(); err != nil { - log.Printf("[client] connection failed: %v, retrying in 5s...", err) - select { - case <-time.After(5 * time.Second): - continue - case <-c.ctx.Done(): + log.Printf("[client] connection failed: %v, retrying in %s", err, backoff) + if !c.sleep(backoff) { return nil } + backoff = nextBackoff(backoff) + continue } + // Connected: reset the backoff so a later blip retries promptly. + backoff = reconnectMin + err := c.RunReadLoop() if err != nil { - log.Printf("[client] disconnected: %v, reconnecting in 5s...", err) + log.Printf("[client] disconnected: %v", err) } else { - log.Printf("[client] disconnected, reconnecting in 5s...") + log.Printf("[client] disconnected") } - c.mu.Lock() - if c.conn != nil { - c.conn.Close() - c.conn = nil - } - c.mu.Unlock() + c.teardown() + + // The relay dropped every tunnel involving us; local state that + // still references one has to go too. + c.fireDisconnect() select { - case <-time.After(5 * time.Second): case <-c.ctx.Done(): return nil + default: } + + log.Printf("[client] reconnecting in %s", backoff) + if !c.sleep(backoff) { + return nil + } + backoff = nextBackoff(backoff) } } +// nextBackoff doubles the delay up to reconnectMax. +func nextBackoff(d time.Duration) time.Duration { + d *= 2 + if d > reconnectMax { + return reconnectMax + } + return d +} + +// sleep waits for d, returning false if the client is shutting down. +func (c *Client) sleep(d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + return true + case <-c.ctx.Done(): + return false + } +} + +// teardown closes the current connection and stops its write pump. +func (c *Client) teardown() { + c.mu.Lock() + if c.dead != nil { + close(c.dead) + c.dead = nil + } + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.send = nil + c.mu.Unlock() +} + // Close shuts down the client func (c *Client) Close() { c.cancel() - c.mu.Lock() - if c.conn != nil { - c.conn.Close() + c.teardown() +} + +// AddTunnelHandler registers a handler for incoming tunnel frames. +// Handlers receive every frame and must ignore tunnel IDs they do not own. +func (c *Client) AddTunnelHandler(fn func(tunnelID string, data []byte)) { + c.handlerMu.Lock() + defer c.handlerMu.Unlock() + c.tunnelHandlers = append(c.tunnelHandlers, fn) +} + +// AddClientLeftHandler registers a handler for peer disconnects. +func (c *Client) AddClientLeftHandler(fn func(msg *protocol.ClientLeft)) { + c.handlerMu.Lock() + defer c.handlerMu.Unlock() + c.clientLeftHandlers = append(c.clientLeftHandlers, fn) +} + +// AddDisconnectHandler registers a handler that runs after the relay +// connection drops and before reconnecting. +// +// The relay forgets every tunnel when a client disconnects, so anything still +// attached locally now points at a tunnel that no longer exists. Handlers use +// this to tear that state down instead of leaving devices wedged until the +// process restarts. +func (c *Client) AddDisconnectHandler(fn func()) { + c.handlerMu.Lock() + defer c.handlerMu.Unlock() + c.disconnectHandlers = append(c.disconnectHandlers, fn) +} + +func (c *Client) fireDisconnect() { + c.handlerMu.RLock() + handlers := append([]func(){}, c.disconnectHandlers...) + c.handlerMu.RUnlock() + for _, fn := range handlers { + fn() } +} + +// Connected reports whether the client currently has a live relay connection. +func (c *Client) Connected() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.conn != nil +} + +// enqueue queues an outgoing frame. It never blocks on the socket; a full +// queue means the relay link is stuck, which is reported as an error so the +// caller can tear down whatever it was trying to send. +func (c *Client) enqueue(typ int, data []byte) error { + c.mu.Lock() + send, dead := c.send, c.dead c.mu.Unlock() + + if send == nil { + return fmt.Errorf("not connected") + } + + select { + case send <- outMsg{typ: typ, data: data}: + return nil + case <-dead: + return fmt.Errorf("connection closed") + case <-c.ctx.Done(): + return fmt.Errorf("client shutting down") + default: + return fmt.Errorf("send queue full, relay link stalled") + } } // SendJSON sends a JSON message to the relay func (c *Client) SendJSON(v interface{}) error { - c.mu.Lock() - defer c.mu.Unlock() - if c.conn == nil { - return fmt.Errorf("not connected") + data, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("encoding message: %w", err) } - return c.conn.WriteJSON(v) + return c.enqueue(websocket.TextMessage, data) } // SendBinary sends a binary message to the relay func (c *Client) SendBinary(data []byte) error { - c.mu.Lock() - defer c.mu.Unlock() - if c.conn == nil { - return fmt.Errorf("not connected") - } - return c.conn.WriteMessage(websocket.BinaryMessage, data) + return c.enqueue(websocket.BinaryMessage, data) } // SendTunnelData sends tunnel data with the tunnel ID prefix @@ -296,10 +575,13 @@ func (c *Client) handleTextMessage(data []byte) { } case protocol.MsgClientLeft: - if c.OnClientLeft != nil { - var msg protocol.ClientLeft - if json.Unmarshal(data, &msg) == nil { - c.OnClientLeft(&msg) + var msg protocol.ClientLeft + if json.Unmarshal(data, &msg) == nil { + c.handlerMu.RLock() + handlers := append([]func(*protocol.ClientLeft){}, c.clientLeftHandlers...) + c.handlerMu.RUnlock() + for _, fn := range handlers { + fn(&msg) } } @@ -322,7 +604,11 @@ func (c *Client) handleBinaryMessage(data []byte) { tunnelID := string(data[:protocol.TunnelHeaderSize]) payload := data[protocol.TunnelHeaderSize:] - if c.OnTunnelData != nil { - c.OnTunnelData(tunnelID, payload) + c.handlerMu.RLock() + handlers := c.tunnelHandlers + c.handlerMu.RUnlock() + + for _, fn := range handlers { + fn(tunnelID, payload) } } diff --git a/internal/client/compare.go b/internal/client/compare.go new file mode 100644 index 0000000..d905798 --- /dev/null +++ b/internal/client/compare.go @@ -0,0 +1,13 @@ +package client + +import "crypto/subtle" + +// constantTimeEqual compares two strings without leaking their contents +// through timing. Used for peer tokens, where a byte-by-byte comparison would +// let an attacker recover the expected value one byte at a time. +func constantTimeEqual(a, b string) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} diff --git a/internal/client/direct.go b/internal/client/direct.go new file mode 100644 index 0000000..235e21d --- /dev/null +++ b/internal/client/direct.go @@ -0,0 +1,305 @@ +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 +} diff --git a/internal/client/direct_test.go b/internal/client/direct_test.go new file mode 100644 index 0000000..630e21c --- /dev/null +++ b/internal/client/direct_test.go @@ -0,0 +1,479 @@ +package client + +import ( + "bytes" + "net" + "strconv" + "testing" + "time" + + "github.com/duffy/usb-server/internal/crypto" + "github.com/duffy/usb-server/internal/protocol" +) + +const ( + testTok1 = "111111111111111111111111111111111111111111=" + testTok2 = "222222222222222222222222222222222222222222=" + testTok3 = "333333333333333333333333333333333333333333=" +) + +func testSecret(t *testing.T) *crypto.TunnelSecret { + t.Helper() + s, err := crypto.DeriveTunnelSecret(testTok1, testTok2, testTok3) + if err != nil { + t.Fatalf("DeriveTunnelSecret: %v", err) + } + return s +} + +const testTunnelID = "0123456789abcdef" // exactly TunnelHeaderSize + +func TestHandshakeRoundTrip(t *testing.T) { + s := testSecret(t) + token := s.PeerToken(testTunnelID) + + greeting, err := buildHandshake(testTunnelID, token) + if err != nil { + t.Fatalf("buildHandshake: %v", err) + } + if len(greeting) != handshakeSize { + t.Fatalf("greeting is %d bytes, want %d", len(greeting), handshakeSize) + } + + gotID, gotToken, err := parseHandshake(greeting) + if err != nil { + t.Fatalf("parseHandshake: %v", err) + } + if gotID != testTunnelID { + t.Errorf("tunnel ID = %q, want %q", gotID, testTunnelID) + } + if gotToken != token { + t.Errorf("token = %q, want %q", gotToken, token) + } +} + +func TestParseHandshakeRejectsMalformed(t *testing.T) { + s := testSecret(t) + valid, _ := buildHandshake(testTunnelID, s.PeerToken(testTunnelID)) + + tests := []struct { + name string + data []byte + }{ + {"empty", nil}, + {"truncated", valid[:handshakeSize-1]}, + {"too long", append(append([]byte{}, valid...), 0x00)}, + {"bad magic", func() []byte { + b := append([]byte{}, valid...) + b[0] = 'X' + return b + }()}, + {"unsupported version", func() []byte { + b := append([]byte{}, valid...) + b[4] = 99 + return b + }()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, _, err := parseHandshake(tt.data); err == nil { + t.Error("malformed handshake was accepted") + } + }) + } +} + +func TestHandshakeReplyStatuses(t *testing.T) { + if err := parseHandshakeReply(buildHandshakeReply(directAccepted)); err != nil { + t.Errorf("accepted reply reported an error: %v", err) + } + for _, status := range []byte{directUnknownTun, directBadToken, directWrongVerson, 99} { + if err := parseHandshakeReply(buildHandshakeReply(status)); err == nil { + t.Errorf("status %d was treated as success", status) + } + } +} + +func TestBuildHandshakeRejectsBadInput(t *testing.T) { + s := testSecret(t) + good := s.PeerToken(testTunnelID) + + if _, err := buildHandshake("short", good); err == nil { + t.Error("a wrong-length tunnel ID was accepted") + } + if _, err := buildHandshake(testTunnelID, "not-hex"); err == nil { + t.Error("a non-hex token was accepted") + } + if _, err := buildHandshake(testTunnelID, "abcd"); err == nil { + t.Error("a short token was accepted") + } +} + +// A listener must only hand over connections whose peer proves group +// membership. The relay knows tunnel IDs, so the token is what stops it — or +// anyone else who reaches the port — from taking a device over. +func TestListenerAcceptsOnlyValidToken(t *testing.T) { + s := testSecret(t) + + dl, err := newDirectListener(0, s) + if err != nil { + t.Fatalf("newDirectListener: %v", err) + } + defer dl.Close() + + accepted, err := dl.Expect(testTunnelID) + if err != nil { + t.Fatalf("Expect: %v", err) + } + + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(dl.Port())) + + t.Run("wrong token is rejected", func(t *testing.T) { + other, _ := crypto.DeriveTunnelSecret(testTok1, testTok2, "different") + greeting, _ := buildHandshake(testTunnelID, other.PeerToken(testTunnelID)) + + conn, err := net.DialTimeout("tcp", addr, time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + conn.Write(greeting) + reply := make([]byte, handshakeReplySize) + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, err := readFull(conn, reply); err != nil { + t.Fatalf("reading reply: %v", err) + } + if err := parseHandshakeReply(reply); err == nil { + t.Fatal("listener accepted a connection with the wrong token") + } + }) + + t.Run("unknown tunnel is rejected", func(t *testing.T) { + greeting, _ := buildHandshake("fedcba9876543210", s.PeerToken("fedcba9876543210")) + + conn, err := net.DialTimeout("tcp", addr, time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + conn.Write(greeting) + reply := make([]byte, handshakeReplySize) + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + readFull(conn, reply) + if err := parseHandshakeReply(reply); err == nil { + t.Fatal("listener accepted a connection for an unregistered tunnel") + } + }) + + t.Run("valid token is accepted", func(t *testing.T) { + conn, _, err := dialDirect([]string{addr}, testTunnelID, s.PeerToken(testTunnelID)) + if err != nil { + t.Fatalf("dialDirect: %v", err) + } + defer conn.Close() + + select { + case got := <-accepted: + if got == nil { + t.Fatal("listener delivered a nil connection") + } + got.Close() + case <-time.After(2 * time.Second): + t.Fatal("listener never delivered the accepted connection") + } + }) +} + +// End-to-end over a real socket pair: the two ends must agree on framing and +// on which direction each encrypts in. +func TestDirectTunnelCarriesTrafficBothWays(t *testing.T) { + s := testSecret(t) + + dl, err := newDirectListener(0, s) + if err != nil { + t.Fatalf("newDirectListener: %v", err) + } + defer dl.Close() + + accepted, err := dl.Expect(testTunnelID) + if err != nil { + t.Fatalf("Expect: %v", err) + } + + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(dl.Port())) + useConn, _, err := dialDirect([]string{addr}, testTunnelID, s.PeerToken(testTunnelID)) + if err != nil { + t.Fatalf("dialDirect: %v", err) + } + defer useConn.Close() + + var shareConn *directConn + select { + case shareConn = <-accepted: + case <-time.After(2 * time.Second): + t.Fatal("listener never delivered the connection") + } + defer shareConn.Close() + + shareCodec, err := newTunnelCodec(s, testTunnelID, crypto.DirShareToUse) + if err != nil { + t.Fatalf("share codec: %v", err) + } + useCodec, err := newTunnelCodec(s, testTunnelID, crypto.DirUseToShare) + if err != nil { + t.Fatalf("use codec: %v", err) + } + + // use -> share + want := []byte("USBIP CMD_SUBMIT payload") + if err := send(useCodec, directSender(useConn), want); err != nil { + t.Fatalf("sending use->share: %v", err) + } + frame, err := shareConn.ReadFrame() + if err != nil { + t.Fatalf("share reading frame: %v", err) + } + got, err := shareCodec.decode(frame) + if err != nil { + t.Fatalf("share decoding frame: %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("share received %q, want %q", got, want) + } + + // share -> use + want2 := []byte("USBIP RET_SUBMIT payload") + if err := send(shareCodec, directSender(shareConn), want2); err != nil { + t.Fatalf("sending share->use: %v", err) + } + frame2, err := useConn.ReadFrame() + if err != nil { + t.Fatalf("use reading frame: %v", err) + } + got2, err := useCodec.decode(frame2) + if err != nil { + t.Fatalf("use decoding frame: %v", err) + } + if !bytes.Equal(got2, want2) { + t.Errorf("use received %q, want %q", got2, want2) + } +} + +func TestDirectConnFramingPreservesBoundaries(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + sender := newDirectConn(client) + receiver := newDirectConn(server) + + payloads := [][]byte{ + []byte("a"), + bytes.Repeat([]byte("x"), 1000), + []byte("last one"), + } + + go func() { + for _, p := range payloads { + if err := sender.WriteFrame(p); err != nil { + return + } + } + }() + + for i, want := range payloads { + got, err := receiver.ReadFrame() + if err != nil { + t.Fatalf("frame %d: %v", i, err) + } + if !bytes.Equal(got, want) { + t.Errorf("frame %d is %d bytes, want %d", i, len(got), len(want)) + } + } +} + +func TestDirectConnRejectsOversizedLength(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + go func() { + // A length prefix claiming far more than the cap must be refused + // before anything is allocated. + client.Write([]byte{0xFF, 0xFF, 0xFF, 0xFF}) + }() + + receiver := newDirectConn(server) + if _, err := receiver.ReadFrame(); err == nil { + t.Error("an oversized frame length was accepted") + } +} + +func TestDialDirectFailsWithoutReachableAddress(t *testing.T) { + s := testSecret(t) + + // Port 1 on loopback refuses immediately, so this stays fast. + _, _, err := dialDirect([]string{"127.0.0.1:1"}, testTunnelID, s.PeerToken(testTunnelID)) + if err == nil { + t.Fatal("dialDirect succeeded against a closed port") + } + + if _, _, err := dialDirect(nil, testTunnelID, s.PeerToken(testTunnelID)); err == nil { + t.Error("dialDirect succeeded with no candidate addresses") + } +} + +// The dialer races candidates; an unreachable one alongside a good one must +// not stop the good one from winning. +func TestDialDirectPicksTheReachableAddress(t *testing.T) { + s := testSecret(t) + + dl, err := newDirectListener(0, s) + if err != nil { + t.Fatalf("newDirectListener: %v", err) + } + defer dl.Close() + + accepted, _ := dl.Expect(testTunnelID) + good := net.JoinHostPort("127.0.0.1", strconv.Itoa(dl.Port())) + + conn, addr, err := dialDirect( + []string{"127.0.0.1:1", good, "127.0.0.1:2"}, + testTunnelID, s.PeerToken(testTunnelID)) + if err != nil { + t.Fatalf("dialDirect: %v", err) + } + defer conn.Close() + + if addr != good { + t.Errorf("connected to %s, want %s", addr, good) + } + select { + case c := <-accepted: + c.Close() + case <-time.After(2 * time.Second): + t.Error("listener never saw the connection") + } +} + +func TestLocalEndpointsExcludeLoopback(t *testing.T) { + if got := localEndpoints(0); got != nil { + t.Errorf("localEndpoints(0) = %v, want nil — port 0 means no listener", got) + } + + for _, ep := range localEndpoints(9000) { + host, port, err := net.SplitHostPort(ep) + if err != nil { + t.Errorf("endpoint %q is not host:port: %v", ep, err) + continue + } + if port != "9000" { + t.Errorf("endpoint %q has port %q, want 9000", ep, port) + } + ip := net.ParseIP(host) + if ip == nil { + t.Errorf("endpoint %q has an unparseable host", ep) + continue + } + if ip.IsLoopback() { + t.Errorf("endpoint %q is loopback; a peer cannot reach that", ep) + } + } +} + +func TestTunnelCodecNilPassesThrough(t *testing.T) { + var codec *tunnelCodec + + if codec.encrypted() { + t.Error("a nil codec reported itself as encrypted") + } + + payload := []byte("cleartext") + encoded, err := codec.encode(payload) + if err != nil { + t.Fatalf("encode: %v", err) + } + if !bytes.Equal(encoded, payload) { + t.Error("a nil codec altered the payload") + } + + decoded, err := codec.decode(encoded) + if err != nil { + t.Fatalf("decode: %v", err) + } + if !bytes.Equal(decoded, payload) { + t.Error("round trip through a nil codec changed the payload") + } +} + +func TestTunnelCodecEncryptsWhenSecretPresent(t *testing.T) { + s := testSecret(t) + + codec, err := newTunnelCodec(s, testTunnelID, crypto.DirShareToUse) + if err != nil { + t.Fatalf("newTunnelCodec: %v", err) + } + if !codec.encrypted() { + t.Fatal("codec with a secret reported itself as unencrypted") + } + + payload := []byte("this must not appear on the wire") + encoded, err := codec.encode(payload) + if err != nil { + t.Fatalf("encode: %v", err) + } + if bytes.Contains(encoded, payload) { + t.Error("the encoded frame contains its plaintext") + } + + peer, _ := newTunnelCodec(s, testTunnelID, crypto.DirUseToShare) + decoded, err := peer.decode(encoded) + if err != nil { + t.Fatalf("peer decode: %v", err) + } + if !bytes.Equal(decoded, payload) { + t.Errorf("peer decoded %q, want %q", decoded, payload) + } +} + +func TestConstantTimeEqual(t *testing.T) { + if !constantTimeEqual("abc", "abc") { + t.Error("equal strings compared unequal") + } + if constantTimeEqual("abc", "abd") { + t.Error("different strings compared equal") + } + if constantTimeEqual("abc", "abcd") { + t.Error("strings of different length compared equal") + } + if !constantTimeEqual("", "") { + t.Error("empty strings compared unequal") + } +} + +// Guards the assumption baked into the wire format. +func TestTunnelIDFitsHandshake(t *testing.T) { + if protocol.TunnelHeaderSize != 16 { + t.Fatalf("TunnelHeaderSize is %d; the handshake layout assumes 16", protocol.TunnelHeaderSize) + } + if len(testTunnelID) != protocol.TunnelHeaderSize { + t.Fatalf("test tunnel ID is %d bytes, want %d", len(testTunnelID), protocol.TunnelHeaderSize) + } +} + +// --- helpers --- + +func readFull(conn net.Conn, buf []byte) (int, error) { + total := 0 + for total < len(buf) { + n, err := conn.Read(buf[total:]) + total += n + if err != nil { + return total, err + } + } + return total, nil +} diff --git a/internal/client/listener.go b/internal/client/listener.go new file mode 100644 index 0000000..e2bb821 --- /dev/null +++ b/internal/client/listener.go @@ -0,0 +1,187 @@ +package client + +import ( + "fmt" + "io" + "log" + "net" + "sync" + "time" + + "github.com/duffy/usb-server/internal/crypto" +) + +// directListener accepts incoming direct tunnel connections. +// +// Only the share side listens: the use side is the one that knows a tunnel +// has been granted, so it makes the call. Tunnels are registered here as they +// are granted, and an incoming connection is matched against them. +type directListener struct { + listener net.Listener + secret *crypto.TunnelSecret + + mu sync.Mutex + expected map[string]*expectedTunnel // tunnel ID -> pending acceptance + closed bool +} + +// expectedTunnel is a granted tunnel waiting for its peer to connect. +type expectedTunnel struct { + token string + accepted chan *directConn +} + +// newDirectListener starts listening on the given port. +// Port 0 picks a free one, which is the sensible default: the actual port is +// advertised to peers, so it does not need to be predictable. +func newDirectListener(port int, secret *crypto.TunnelSecret) (*directListener, error) { + if secret == nil { + return nil, fmt.Errorf("direct connections require the tunnel secret") + } + + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return nil, fmt.Errorf("listening for direct connections: %w", err) + } + + dl := &directListener{ + listener: ln, + secret: secret, + expected: make(map[string]*expectedTunnel), + } + + go dl.acceptLoop() + log.Printf("[direct] listening on %s", ln.Addr()) + + return dl, nil +} + +// Port returns the port actually bound. +func (dl *directListener) Port() int { + if addr, ok := dl.listener.Addr().(*net.TCPAddr); ok { + return addr.Port + } + return 0 +} + +// Expect registers a granted tunnel and returns a channel that receives the +// connection once a peer completes the handshake for it. +func (dl *directListener) Expect(tunnelID string) (<-chan *directConn, error) { + accepted := make(chan *directConn, 1) + + dl.mu.Lock() + defer dl.mu.Unlock() + if dl.closed { + return nil, fmt.Errorf("listener is closed") + } + dl.expected[tunnelID] = &expectedTunnel{ + token: dl.secret.PeerToken(tunnelID), + accepted: accepted, + } + return accepted, nil +} + +// Forget drops a tunnel, whether it was taken over directly or fell back to +// the relay. Leaving entries behind would let a peer connect to a tunnel that +// is no longer live. +func (dl *directListener) Forget(tunnelID string) { + dl.mu.Lock() + defer dl.mu.Unlock() + delete(dl.expected, tunnelID) +} + +// Close stops accepting connections. +func (dl *directListener) Close() error { + dl.mu.Lock() + dl.closed = true + dl.expected = make(map[string]*expectedTunnel) + dl.mu.Unlock() + return dl.listener.Close() +} + +func (dl *directListener) acceptLoop() { + for { + conn, err := dl.listener.Accept() + if err != nil { + dl.mu.Lock() + closed := dl.closed + dl.mu.Unlock() + if closed { + return + } + // A transient accept error should not kill the listener, but it + // must not spin either. + log.Printf("[direct] accept error: %v", err) + time.Sleep(100 * time.Millisecond) + continue + } + + go dl.handleIncoming(conn) + } +} + +// handleIncoming validates one incoming connection's handshake. +func (dl *directListener) handleIncoming(conn net.Conn) { + conn.SetDeadline(time.Now().Add(directHandshakeTimeout)) + + greeting := make([]byte, handshakeSize) + if _, err := io.ReadFull(conn, greeting); err != nil { + conn.Close() + return + } + + tunnelID, presented, err := parseHandshake(greeting) + if err != nil { + log.Printf("[direct] rejecting %s: %v", conn.RemoteAddr(), err) + conn.Write(buildHandshakeReply(directWrongVerson)) + conn.Close() + return + } + + dl.mu.Lock() + tunnel, known := dl.expected[tunnelID] + dl.mu.Unlock() + + if !known { + conn.Write(buildHandshakeReply(directUnknownTun)) + conn.Close() + return + } + + // The token proves group membership. The relay knows the tunnel ID — it + // routed the grant — but cannot derive this, so it cannot impersonate a + // peer, and neither can anything else that merely reaches the port. + if !constantTimeEqual(presented, tunnel.token) { + log.Printf("[direct] rejecting %s: bad token for tunnel", conn.RemoteAddr()) + conn.Write(buildHandshakeReply(directBadToken)) + conn.Close() + return + } + + if _, err := conn.Write(buildHandshakeReply(directAccepted)); err != nil { + conn.Close() + return + } + conn.SetDeadline(time.Time{}) + + direct := newDirectConn(conn) + + // Claim the tunnel: whoever handshakes first wins, and a second connection + // for the same tunnel is dropped rather than replacing a live one. + dl.mu.Lock() + current, still := dl.expected[tunnelID] + if still && current == tunnel { + delete(dl.expected, tunnelID) + } else { + still = false + } + dl.mu.Unlock() + + if !still { + direct.Close() + return + } + + log.Printf("[direct] accepted connection from %s for tunnel %s", conn.RemoteAddr(), tunnelID) + tunnel.accepted <- direct +} diff --git a/internal/client/share.go b/internal/client/share.go index 4773df4..a45214d 100644 --- a/internal/client/share.go +++ b/internal/client/share.go @@ -8,6 +8,7 @@ import ( "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/usb" "github.com/duffy/usb-server/internal/usbip" @@ -22,21 +23,81 @@ type ShareManager struct { devices []usb.Device active map[string]*activeShare // busID -> active share tunnels map[string]*shareTunnel // tunnelID -> tunnel + + // secret derives per-tunnel keys. Nil when the client is configured with + // only a group hash, in which case tunnels stay unencrypted. + secret *crypto.TunnelSecret + + // listener accepts direct connections from peers. Nil when direct + // connections are disabled or could not be set up, which just means every + // tunnel goes through the relay. + listener *directListener } type activeShare struct { - device *usb.Device - server *usbip.Server - usedBy string // client ID using this device - tunnelID string + device *usb.Device + server *usbip.Server + usedBy string // client ID using this device + tunnelID string } type shareTunnel struct { - id string - busID string - inPipe *io.PipeWriter - outPipe *io.PipeReader - done chan struct{} + id string + busID string + + // in carries peer -> USB/IP server bytes. It buffers instead of blocking + // so that feeding it from the WebSocket read loop cannot stall the client. + in *streamBuffer + + // out carries USB/IP server -> peer bytes. This direction stays a pipe: + // blocking there is real backpressure onto the USB reap loop, which is + // what we want when the network cannot keep up. + outPipe *io.PipeReader + done chan struct{} + + // codec seals outgoing and opens incoming payloads, whichever transport + // carries them. + codec *tunnelCodec + + // sendMu guards swapping the transport when a direct connection takes + // over from the relay mid-tunnel. + sendMu sync.Mutex + send tunnelSender + direct *directConn +} + +// setTransport switches this tunnel to a new sender, closing the old direct +// connection if there was one. +func (t *shareTunnel) setTransport(sender tunnelSender, conn *directConn) { + t.sendMu.Lock() + defer t.sendMu.Unlock() + if t.direct != nil && t.direct != conn { + t.direct.Close() + } + t.send = sender + t.direct = conn +} + +// deliver encodes and transmits one payload over the current transport. +func (t *shareTunnel) deliver(payload []byte) error { + t.sendMu.Lock() + sender := t.send + t.sendMu.Unlock() + + if sender == nil { + return fmt.Errorf("tunnel %s has no transport", t.id) + } + return send(t.codec, sender, payload) +} + +// closeDirect tears down any direct connection this tunnel holds. +func (t *shareTunnel) closeDirect() { + t.sendMu.Lock() + defer t.sendMu.Unlock() + if t.direct != nil { + t.direct.Close() + t.direct = nil + } } // NewShareManager creates a share manager @@ -46,18 +107,118 @@ func NewShareManager(client *Client, cfg *config.Config) *ShareManager { cfg: cfg, active: make(map[string]*activeShare), tunnels: make(map[string]*shareTunnel), + secret: client.TunnelSecret(), } - // Set up callbacks + sm.startDirectListener() + + // Share-side messages only this manager handles. client.OnRequestDevice = sm.handleRequestDevice client.OnReleaseDevice = sm.handleReleaseDevice - client.OnTunnelData = sm.handleTunnelData - client.OnClientLeft = sm.handleClientLeft client.OnForceRelease = sm.handleForceRelease + // Shared with the use manager in "both" mode, hence multicast. + client.AddTunnelHandler(sm.handleTunnelData) + client.AddClientLeftHandler(sm.handleClientLeft) + client.AddDisconnectHandler(sm.handleRelayDisconnect) + return sm } +// startDirectListener opens the port peers connect to for direct tunnels. +// +// Failure is never fatal: without a listener every tunnel simply goes through +// the relay, which is exactly how the system worked before. +func (sm *ShareManager) startDirectListener() { + if sm.cfg.DisableDirect { + log.Printf("[share] direct connections disabled by configuration") + return + } + if sm.secret == nil { + log.Printf("[share] direct connections unavailable: no tokens configured, only a group hash") + return + } + + listener, err := newDirectListener(sm.cfg.DirectPort, sm.secret) + if err != nil { + log.Printf("[share] direct connections unavailable: %v (falling back to relay)", err) + return + } + + sm.listener = listener + sm.client.SetDirectPort(listener.Port()) +} + +// DirectPort reports the port peers can reach for direct tunnels, 0 if none. +func (sm *ShareManager) DirectPort() int { + if sm.listener == nil { + return 0 + } + return sm.listener.Port() +} + +// awaitDirect waits for the peer to connect directly and, when it does, moves +// the tunnel off the relay. +// +// The switch is safe at any moment because USB/IP is a stream of complete +// messages and each tunnel frame carries one chunk of it: frames sent before +// the switch travel via the relay, frames after it travel directly, and both +// arrive in order at the same reader. Nothing is in flight in pieces. +func (sm *ShareManager) awaitDirect(tunnel *shareTunnel, accepted <-chan *directConn) { + select { + case conn := <-accepted: + if conn == nil { + return + } + + select { + case <-tunnel.done: + conn.Close() + return + default: + } + + log.Printf("[share] tunnel %s now direct with %s, bypassing the relay", + tunnel.id, conn.RemoteAddr()) + tunnel.setTransport(directSender(conn), conn) + + // Incoming frames now arrive on this connection instead of the relay. + receiveLoop(conn, tunnel.codec, func(payload []byte) error { + _, err := tunnel.in.Write(payload) + return err + }, tunnel.done, "share/"+tunnel.busID) + + // The direct connection ended. The USB/IP stream cannot resume on the + // relay mid-conversation — the peer's VHCI has torn down its side — + // so release the device and let it be requested again. + select { + case <-tunnel.done: + default: + log.Printf("[share] direct connection for %s ended, releasing device", tunnel.busID) + go sm.handleReleaseDevice(tunnel.busID, "") + } + + case <-tunnel.done: + } +} + +// handleRelayDisconnect releases every active share after the relay link +// drops. The relay discarded those tunnels, so the remote side is gone and +// the local device would otherwise stay claimed and unusable. +func (sm *ShareManager) handleRelayDisconnect() { + sm.mu.RLock() + busIDs := make([]string, 0, len(sm.active)) + for busID := range sm.active { + busIDs = append(busIDs, busID) + } + sm.mu.RUnlock() + + for _, busID := range busIDs { + log.Printf("[share] releasing %s (relay connection lost)", busID) + sm.handleReleaseDevice(busID, "") + } +} + // Run starts the share manager: periodic device enumeration + event handling func (sm *ShareManager) Run() error { // Initial enumeration @@ -89,6 +250,14 @@ func (sm *ShareManager) GetDevices() []usb.Device { return result } +// RefreshNow re-enumerates and announces immediately, rather than waiting for +// the next poll. Used when devices appear through the bridge, where the +// change is known the instant it happens. +func (sm *ShareManager) RefreshNow() { + sm.refreshDevices() + sm.broadcastDeviceList() +} + func (sm *ShareManager) refreshDevices() { devices, err := usb.Enumerate() if err != nil { @@ -202,15 +371,44 @@ func (sm *ShareManager) handleRequestDevice(targetClient, fromClient, busID, req tunnelID += "0" } - inReader, inWriter := io.Pipe() + codec, err := newTunnelCodec(sm.secret, tunnelID, crypto.DirShareToUse) + if err != nil { + sm.mu.Unlock() + server.Detach() + log.Printf("[share] failed to set up tunnel encryption for %s: %v", busID, err) + sm.client.SendJSON(map[string]interface{}{ + "type": protocol.MsgDeviceDenied, + "bus_id": busID, + "request_id": requestID, + "reason": fmt.Sprintf("tunnel setup failed: %v", err), + "target_client": fromClient, + }) + return + } + + in := newStreamBuffer() outReader, outWriter := io.Pipe() tunnel := &shareTunnel{ id: tunnelID, busID: busID, - inPipe: inWriter, + in: in, outPipe: outReader, done: make(chan struct{}), + codec: codec, + } + // Start on the relay. If the peer reaches us directly, the transport is + // swapped underneath without the USB/IP layer noticing. + tunnel.setTransport(relaySender(sm.client, tunnelID), nil) + + // Register the tunnel before announcing it, so a peer that connects + // immediately after receiving the grant is not rejected as unknown. + var accepted <-chan *directConn + if sm.listener != nil { + var err error + if accepted, err = sm.listener.Expect(tunnelID); err != nil { + log.Printf("[share] cannot expect a direct connection for %s: %v", busID, err) + } } share := &activeShare{ @@ -224,23 +422,26 @@ func (sm *ShareManager) handleRequestDevice(targetClient, fromClient, busID, req sm.tunnels[tunnelID] = tunnel sm.mu.Unlock() - // Start USB/IP protocol handler in background + // Start USB/IP protocol handler in background. + // The tunnel carries the USB/IP transfer phase directly: on Linux the + // use side hands the socket straight to VHCI, and on Windows usbip.exe's + // management phase is answered locally, so there is no import request here. go func() { defer func() { close(tunnel.done) - inWriter.Close() + in.Close() + outWriter.Close() outReader.Close() }() - // First handle the management phase (import request from client) - // The USB/IP client will send OP_REQ_IMPORT, we respond, then enter transfer phase - err := server.HandleConnection(inReader, outWriter) + err := server.HandleConnection(in, outWriter) if err != nil { log.Printf("[share] USB/IP connection error for %s: %v", busID, err) } }() - // Forward outgoing data from USB/IP server to tunnel + // Forward outgoing data from the USB/IP server over whichever transport + // the tunnel currently uses. go func() { buf := make([]byte, 65536) for { @@ -248,13 +449,20 @@ func (sm *ShareManager) handleRequestDevice(targetClient, fromClient, busID, req if err != nil { return } - if err := sm.client.SendTunnelData(tunnelID, buf[:n]); err != nil { + if err := tunnel.deliver(buf[:n]); err != nil { + log.Printf("[share] tunnel %s send failed: %v", tunnelID, err) return } } }() - // Send grant message + // Wait in the background for the peer to connect directly. + if accepted != nil { + go sm.awaitDirect(tunnel, accepted) + } + + // Send grant message, including where we can be reached directly. The + // relay adds the public address it sees before passing this on. sm.client.SendJSON(map[string]interface{}{ "type": protocol.MsgDeviceGranted, "bus_id": busID, @@ -263,9 +471,12 @@ func (sm *ShareManager) handleRequestDevice(targetClient, fromClient, busID, req "dev_id": dev.DevID(), "speed": dev.Speed, "target_client": fromClient, + "endpoints": localEndpoints(sm.DirectPort()), + "encrypted": codec.encrypted(), }) - log.Printf("[share] device %s granted to %s (tunnel=%s)", busID, fromClient, tunnelID) + log.Printf("[share] device %s granted to %s (tunnel=%s, encrypted=%v)", + busID, fromClient, tunnelID, codec.encrypted()) // Broadcast updated device list sm.refreshDevices() @@ -282,13 +493,17 @@ func (sm *ShareManager) handleReleaseDevice(busID, fromClient string) { return } - // Close the tunnel pipe to signal HandleConnection to stop reading + // Close the tunnel input to signal HandleConnection to stop reading var tunnelDone <-chan struct{} if tunnel, ok := sm.tunnels[share.tunnelID]; ok { - tunnel.inPipe.Close() + tunnel.in.Close() + tunnel.closeDirect() tunnelDone = tunnel.done delete(sm.tunnels, share.tunnelID) } + if sm.listener != nil { + sm.listener.Forget(share.tunnelID) + } server := share.server delete(sm.active, busID) @@ -332,7 +547,7 @@ func (sm *ShareManager) handleForceRelease(targetClient, fromClient, busID strin return } - log.Printf("[share] force-releasing %s (requested by %s, was used by %s)", busID, fromClient[:8], share.usedBy[:8]) + log.Printf("[share] force-releasing %s (requested by %s, was used by %s)", busID, protocol.ShortID(fromClient), protocol.ShortID(share.usedBy)) sm.handleReleaseDevice(busID, share.usedBy) } @@ -347,7 +562,7 @@ func (sm *ShareManager) handleClientLeft(msg *protocol.ClientLeft) { sm.mu.RUnlock() for _, busID := range toRelease { - log.Printf("[share] auto-releasing %s (client %s left)", busID, msg.ClientID[:8]) + log.Printf("[share] auto-releasing %s (client %s left)", busID, protocol.ShortID(msg.ClientID)) sm.handleReleaseDevice(busID, msg.ClientID) } } @@ -358,11 +573,24 @@ func (sm *ShareManager) handleTunnelData(tunnelID string, data []byte) { sm.mu.RUnlock() if !exists { + // In "both" mode the use manager owns the other tunnels and sees the + // same frames, so an unknown ID here is normal. return } - // Write incoming data to the USB/IP server's input pipe - tunnel.inPipe.Write(data) + payload, err := tunnel.codec.decode(data) + if err != nil { + log.Printf("[share] tunnel %s: rejecting relayed frame: %v", tunnelID, err) + go sm.handleReleaseDevice(tunnel.busID, "") + return + } + + // Buffered, non-blocking: this runs on the WebSocket read loop, which + // must never stall on the USB side. + if _, err := tunnel.in.Write(payload); err != nil { + log.Printf("[share] tunnel %s input failed: %v — releasing %s", tunnelID, err, tunnel.busID) + go sm.handleReleaseDevice(tunnel.busID, "") + } } func (sm *ShareManager) cleanup() { @@ -371,7 +599,8 @@ func (sm *ShareManager) cleanup() { for busID, share := range sm.active { if tunnel, ok := sm.tunnels[share.tunnelID]; ok { - tunnel.inPipe.Close() + tunnel.in.Close() + tunnel.closeDirect() } share.server.Detach() log.Printf("[share] cleaned up device %s", busID) @@ -379,6 +608,11 @@ func (sm *ShareManager) cleanup() { sm.active = make(map[string]*activeShare) sm.tunnels = make(map[string]*shareTunnel) + + if sm.listener != nil { + sm.listener.Close() + sm.listener = nil + } } // DeviceListForAPI returns device info formatted for the web API @@ -407,4 +641,3 @@ func (sm *ShareManager) DeviceListForAPI() []map[string]interface{} { } return result } - diff --git a/internal/client/socket_darwin.go b/internal/client/socket_darwin.go new file mode 100644 index 0000000..0efa9ff --- /dev/null +++ b/internal/client/socket_darwin.go @@ -0,0 +1,31 @@ +//go:build darwin + +package client + +import ( + "context" + "fmt" + "net" + "os" + + "github.com/duffy/usb-server/internal/protocol" +) + +// Attaching a remote device needs a virtual USB host controller, which macOS +// does not provide — see internal/usbip/vhci_darwin.go. + +func createVHCIAttachment(_ context.Context, _ *protocol.DeviceGranted, _ *RemoteDevice) (net.Conn, int, error) { + return nil, -1, fmt.Errorf("receiving USB devices is not supported on macOS") +} + +func createSocketPair() ([2]int, error) { + return [2]int{}, fmt.Errorf("not used on macOS") +} + +func closeFDs(fds [2]int) {} + +func fdToFile(fd int, name string) *os.File { return nil } + +func logVHCIDeviceStatus(port int) {} + +func fixVHCIDevicePermissions(port int) {} diff --git a/internal/client/socket_linux.go b/internal/client/socket_linux.go index cc0aa02..b02d692 100644 --- a/internal/client/socket_linux.go +++ b/internal/client/socket_linux.go @@ -76,6 +76,104 @@ func fdToFile(fd int, name string) *os.File { return os.NewFile(uintptr(fd), name) } +// logVHCIDeviceStatus reads the VHCI sysfs tree to check what happened +// with a newly attached device. Logs driver binding, device class, etc. +// +// This is diagnostics only, so it stays behind USBSRV_DEBUG: it waits three +// seconds and then walks the whole sysfs tree on every attach. +func logVHCIDeviceStatus(port int) { + if !protocol.Debug { + return + } + + time.Sleep(3 * time.Second) // wait for enumeration + + basePath := "/sys/devices/platform/vhci_hcd.0" + entries, err := os.ReadDir(basePath) + if err != nil { + log.Printf("[use-diag] cannot read VHCI sysfs: %v", err) + return + } + + // Find the USB device for this port (usbN/N-M pattern) + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), "usb") { + continue + } + usbPath := filepath.Join(basePath, entry.Name()) + devEntries, err := os.ReadDir(usbPath) + if err != nil { + continue + } + for _, devEntry := range devEntries { + devName := devEntry.Name() + // Device dirs look like "3-1", not "3-1:1.0" + if !strings.Contains(devName, "-") || strings.Contains(devName, ":") { + continue + } + devPath := filepath.Join(usbPath, devName) + + // Read device info + readAttr := func(name string) string { + data, err := os.ReadFile(filepath.Join(devPath, name)) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) + } + + vid := readAttr("idVendor") + pid := readAttr("idProduct") + product := readAttr("product") + manufacturer := readAttr("manufacturer") + speed := readAttr("speed") + devClass := readAttr("bDeviceClass") + + if vid == "" { + continue // not a real device + } + + log.Printf("[use-diag] VHCI device: %s %s:%s speed=%s class=%s %s %s", + devName, vid, pid, speed, devClass, manufacturer, product) + + // Check interfaces and their drivers + ifEntries, _ := os.ReadDir(devPath) + for _, ifEntry := range ifEntries { + ifName := ifEntry.Name() + if !strings.Contains(ifName, ":") { + continue + } + ifPath := filepath.Join(devPath, ifName) + ifClass, _ := os.ReadFile(filepath.Join(ifPath, "bInterfaceClass")) + ifProto, _ := os.ReadFile(filepath.Join(ifPath, "bInterfaceProtocol")) + + driverLink, err := os.Readlink(filepath.Join(ifPath, "driver")) + driver := "(no driver)" + if err == nil { + driver = filepath.Base(driverLink) + } + + log.Printf("[use-diag] interface %s: class=%s proto=%s driver=%s", + ifName, strings.TrimSpace(string(ifClass)), strings.TrimSpace(string(ifProto)), driver) + + // Check for input devices under this interface + filepath.WalkDir(ifPath, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if strings.HasPrefix(d.Name(), "event") && strings.Contains(path, "/input/input") { + log.Printf("[use-diag] → /dev/input/%s", d.Name()) + } + if strings.HasPrefix(d.Name(), "hidraw") && filepath.Base(filepath.Dir(path)) == "hidraw" { + log.Printf("[use-diag] → /dev/%s", d.Name()) + } + return nil + }) + } + } + } +} + // fixVHCIDevicePermissions waits for the VHCI-attached device to create // device nodes (e.g. /dev/video*, /dev/input/event*, /dev/hidraw*) and sets // them to world-accessible. VHCI-created devices don't get normal udev diff --git a/internal/client/socket_windows.go b/internal/client/socket_windows.go index 41b0624..c35bae5 100644 --- a/internal/client/socket_windows.go +++ b/internal/client/socket_windows.go @@ -199,5 +199,8 @@ func fdToFile(fd int, name string) *os.File { return nil } +// logVHCIDeviceStatus is Linux-only (sysfs). +func logVHCIDeviceStatus(port int) {} + // fixVHCIDevicePermissions is not needed on Windows. func fixVHCIDevicePermissions(port int) {} diff --git a/internal/client/stream.go b/internal/client/stream.go new file mode 100644 index 0000000..2791e4f --- /dev/null +++ b/internal/client/stream.go @@ -0,0 +1,106 @@ +package client + +import ( + "bytes" + "errors" + "fmt" + "io" + "sync" +) + +// ErrStreamOverflow is returned by streamBuffer.Read once the buffer has +// exceeded its limit. The tunnel is unusable at that point and must be torn +// down; the alternative would be growing without bound. +var ErrStreamOverflow = errors.New("tunnel buffer overflow") + +// defaultStreamLimit caps how much unread tunnel data we hold. +// +// USB/IP traffic is request/response, so the consumer normally keeps up. A +// backlog this large means the USB side has stalled, and 8 MB is far more +// than any legitimate burst of in-flight URBs. +const defaultStreamLimit = 8 << 20 + +// streamBuffer is an unbounded-write, blocking-read byte pipe. +// +// It replaces io.Pipe on the path from the WebSocket read loop into the +// USB/IP server. io.Pipe is synchronous: a Write blocks until a Reader has +// consumed the bytes, so feeding it from the WebSocket read loop meant one +// slow USB transfer froze the entire client — no control messages, no +// keepalives, no other tunnel. Writes here never block. +type streamBuffer struct { + mu sync.Mutex + cond *sync.Cond + buf bytes.Buffer + limit int + closed bool + err error +} + +func newStreamBuffer() *streamBuffer { + return newStreamBufferLimit(defaultStreamLimit) +} + +func newStreamBufferLimit(limit int) *streamBuffer { + s := &streamBuffer{limit: limit} + s.cond = sync.NewCond(&s.mu) + return s +} + +// Write appends data to the buffer and never blocks. +// Once the limit is exceeded the stream is failed: further reads drain what +// is already buffered and then return ErrStreamOverflow. +func (s *streamBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return 0, io.ErrClosedPipe + } + if s.err != nil { + return 0, s.err + } + + if s.buf.Len()+len(p) > s.limit { + s.err = fmt.Errorf("%w: %d bytes buffered, limit %d", ErrStreamOverflow, s.buf.Len(), s.limit) + s.cond.Broadcast() + return 0, s.err + } + + n, err := s.buf.Write(p) + s.cond.Broadcast() + return n, err +} + +// Read blocks until data is available, the stream is closed, or it failed. +func (s *streamBuffer) Read(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + for s.buf.Len() == 0 { + if s.err != nil { + return 0, s.err + } + if s.closed { + return 0, io.EOF + } + s.cond.Wait() + } + + return s.buf.Read(p) +} + +// Close makes pending and future reads return EOF once the buffer is drained. +func (s *streamBuffer) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + s.closed = true + s.cond.Broadcast() + return nil +} + +// Buffered reports how many bytes are waiting to be read. +func (s *streamBuffer) Buffered() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.buf.Len() +} diff --git a/internal/client/stream_test.go b/internal/client/stream_test.go new file mode 100644 index 0000000..8c8be3c --- /dev/null +++ b/internal/client/stream_test.go @@ -0,0 +1,212 @@ +package client + +import ( + "bytes" + "errors" + "io" + "sync" + "testing" + "time" +) + +func TestStreamBufferRoundTrip(t *testing.T) { + s := newStreamBuffer() + + want := []byte("usbip frame") + if _, err := s.Write(want); err != nil { + t.Fatalf("Write: %v", err) + } + + got := make([]byte, len(want)) + if _, err := io.ReadFull(s, got); err != nil { + t.Fatalf("ReadFull: %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("read %q, want %q", got, want) + } +} + +// The whole point of replacing io.Pipe: a write must return immediately even +// when nobody is reading, because it happens on the WebSocket read loop. +func TestStreamBufferWriteNeverBlocks(t *testing.T) { + s := newStreamBuffer() + + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 100; i++ { + if _, err := s.Write(make([]byte, 1024)); err != nil { + t.Errorf("Write %d: %v", i, err) + return + } + } + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("writes blocked with no reader — this is what froze the client") + } + + if got := s.Buffered(); got != 100*1024 { + t.Errorf("buffered %d bytes, want %d", got, 100*1024) + } +} + +func TestStreamBufferReadBlocksUntilData(t *testing.T) { + s := newStreamBuffer() + + read := make(chan []byte, 1) + go func() { + buf := make([]byte, 4) + n, err := s.Read(buf) + if err != nil { + t.Errorf("Read: %v", err) + read <- nil + return + } + read <- buf[:n] + }() + + // Give the reader time to park in Read before any data exists. + time.Sleep(50 * time.Millisecond) + select { + case <-read: + t.Fatal("Read returned before data was written") + default: + } + + s.Write([]byte("ping")) + + select { + case got := <-read: + if string(got) != "ping" { + t.Errorf("read %q, want %q", got, "ping") + } + case <-time.After(time.Second): + t.Fatal("Read did not wake up after Write") + } +} + +func TestStreamBufferCloseGivesEOFAfterDraining(t *testing.T) { + s := newStreamBuffer() + s.Write([]byte("tail")) + s.Close() + + // Buffered data must still be readable after Close. + got := make([]byte, 4) + if _, err := io.ReadFull(s, got); err != nil { + t.Fatalf("reading buffered data after Close: %v", err) + } + if string(got) != "tail" { + t.Errorf("read %q, want %q", got, "tail") + } + + if _, err := s.Read(make([]byte, 4)); err != io.EOF { + t.Errorf("Read after drain = %v, want io.EOF", err) + } +} + +func TestStreamBufferCloseWakesBlockedReader(t *testing.T) { + s := newStreamBuffer() + + errCh := make(chan error, 1) + go func() { + _, err := s.Read(make([]byte, 4)) + errCh <- err + }() + + time.Sleep(50 * time.Millisecond) + s.Close() + + select { + case err := <-errCh: + if err != io.EOF { + t.Errorf("blocked Read woke with %v, want io.EOF", err) + } + case <-time.After(time.Second): + t.Fatal("Close did not wake the blocked reader") + } +} + +func TestStreamBufferOverflowFailsInsteadOfGrowing(t *testing.T) { + s := newStreamBufferLimit(1024) + + if _, err := s.Write(make([]byte, 1000)); err != nil { + t.Fatalf("first write: %v", err) + } + if _, err := s.Write(make([]byte, 100)); !errors.Is(err, ErrStreamOverflow) { + t.Fatalf("overflowing write = %v, want ErrStreamOverflow", err) + } + + // Further writes keep failing rather than silently resuming. + if _, err := s.Write([]byte("x")); !errors.Is(err, ErrStreamOverflow) { + t.Errorf("write after overflow = %v, want ErrStreamOverflow", err) + } + + // Buffered data is still drainable, then the error surfaces. + if _, err := io.ReadFull(s, make([]byte, 1000)); err != nil { + t.Fatalf("draining after overflow: %v", err) + } + if _, err := s.Read(make([]byte, 4)); !errors.Is(err, ErrStreamOverflow) { + t.Errorf("Read after drain = %v, want ErrStreamOverflow", err) + } +} + +func TestStreamBufferWriteAfterClose(t *testing.T) { + s := newStreamBuffer() + s.Close() + + if _, err := s.Write([]byte("late")); err != io.ErrClosedPipe { + t.Errorf("Write after Close = %v, want io.ErrClosedPipe", err) + } +} + +// Concurrent writers and one reader, the shape the share path actually has. +func TestStreamBufferConcurrent(t *testing.T) { + s := newStreamBuffer() + + const writers = 8 + const perWriter = 200 + const chunk = 64 + + var wg sync.WaitGroup + wg.Add(writers) + for i := 0; i < writers; i++ { + go func() { + defer wg.Done() + for j := 0; j < perWriter; j++ { + if _, err := s.Write(make([]byte, chunk)); err != nil { + t.Errorf("Write: %v", err) + return + } + } + }() + } + + total := writers * perWriter * chunk + readDone := make(chan int, 1) + go func() { + got := 0 + buf := make([]byte, 128) + for got < total { + n, err := s.Read(buf) + if err != nil { + break + } + got += n + } + readDone <- got + }() + + wg.Wait() + + select { + case got := <-readDone: + if got != total { + t.Errorf("read %d bytes, want %d", got, total) + } + case <-time.After(5 * time.Second): + t.Fatal("concurrent read/write did not finish") + } +} diff --git a/internal/client/tunnel.go b/internal/client/tunnel.go new file mode 100644 index 0000000..451c836 --- /dev/null +++ b/internal/client/tunnel.go @@ -0,0 +1,130 @@ +package client + +import ( + "fmt" + "log" + + "github.com/duffy/usb-server/internal/crypto" +) + +// tunnelCodec seals and opens tunnel payloads. +// +// It sits above the transport so that a tunnel is protected the same way +// whether its frames travel directly or through the relay. A nil codec passes +// data through unchanged, which is what a client configured with only a group +// hash — and therefore unable to derive the key — falls back to. +type tunnelCodec struct { + sealer *crypto.Sealer + opener *crypto.Opener +} + +// newTunnelCodec builds a codec for one end of a tunnel. +// send is the direction this end transmits in; it receives on the other. +func newTunnelCodec(secret *crypto.TunnelSecret, tunnelID string, send crypto.Direction) (*tunnelCodec, error) { + if secret == nil { + return nil, nil // unencrypted, by configuration + } + + key, err := secret.TunnelKey(tunnelID) + if err != nil { + return nil, err + } + + recv := crypto.DirShareToUse + if send == crypto.DirShareToUse { + recv = crypto.DirUseToShare + } + + sealer, err := crypto.NewSealer(key, send) + if err != nil { + return nil, err + } + opener, err := crypto.NewOpener(key, recv) + if err != nil { + return nil, err + } + + return &tunnelCodec{sealer: sealer, opener: opener}, nil +} + +// encode prepares a payload for transmission. +func (c *tunnelCodec) encode(payload []byte) ([]byte, error) { + if c == nil { + return payload, nil + } + return c.sealer.Seal(payload) +} + +// decode recovers a received payload. +func (c *tunnelCodec) decode(frame []byte) ([]byte, error) { + if c == nil { + return frame, nil + } + return c.opener.Open(frame) +} + +// encrypted reports whether this codec actually protects anything. +func (c *tunnelCodec) encrypted() bool { return c != nil } + +// tunnelSender delivers one encoded frame to the peer. +type tunnelSender func(frame []byte) error + +// relaySender routes frames through the relay, tagged with the tunnel ID. +func relaySender(c *Client, tunnelID string) tunnelSender { + return func(frame []byte) error { + return c.SendTunnelData(tunnelID, frame) + } +} + +// directSender routes frames over an established direct connection. +func directSender(conn *directConn) tunnelSender { + return func(frame []byte) error { + return conn.WriteFrame(frame) + } +} + +// send encodes a payload and hands it to the transport. +func send(codec *tunnelCodec, sender tunnelSender, payload []byte) error { + frame, err := codec.encode(payload) + if err != nil { + return fmt.Errorf("encoding tunnel frame: %w", err) + } + return sender(frame) +} + +// receiveLoop reads frames from a direct connection, decodes them and hands +// each payload to deliver. It returns when the connection ends, when the +// tunnel is torn down, or on the first frame that fails to authenticate. +func receiveLoop(conn *directConn, codec *tunnelCodec, deliver func([]byte) error, done <-chan struct{}, label string) { + for { + select { + case <-done: + return + default: + } + + frame, err := conn.ReadFrame() + if err != nil { + select { + case <-done: + default: + log.Printf("[direct] %s: read ended: %v", label, err) + } + return + } + + payload, err := codec.decode(frame) + if err != nil { + // A frame that fails to authenticate means the stream is either + // corrupt or being tampered with. Either way this tunnel cannot + // be trusted to carry USB traffic any further. + log.Printf("[direct] %s: dropping connection: %v", label, err) + return + } + + if err := deliver(payload); err != nil { + log.Printf("[direct] %s: delivery failed: %v", label, err) + return + } + } +} diff --git a/internal/client/use.go b/internal/client/use.go index 39975a3..9b27c9f 100644 --- a/internal/client/use.go +++ b/internal/client/use.go @@ -6,13 +6,19 @@ import ( "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 @@ -29,15 +35,25 @@ type AttachedDevice struct { // 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]chan *protocol.DeviceGranted // requestID -> response channel - forceDetachable map[string]bool // clientID -> allow_force_detach + 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 { @@ -45,32 +61,114 @@ type useTunnel struct { busID string clientID string conn net.Conn // our end of the socketpair - done chan struct{} + + // 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, + 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]chan *protocol.DeviceGranted), + 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 - client.OnTunnelData = um.handleTunnelData - client.OnClientLeft = um.handleClientLeft + + // 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() @@ -115,7 +213,7 @@ func (um *UseManager) AttachDevice(clientID, busID string) error { respChan := make(chan *protocol.DeviceGranted, 1) um.mu.Lock() - um.pending[requestID] = respChan + um.pending[requestID] = &pendingRequest{clientID: clientID, busID: busID, resp: respChan} um.mu.Unlock() defer func() { @@ -137,13 +235,22 @@ func (um *UseManager) AttachDevice(clientID, busID string) error { log.Printf("[use] requesting device %s from %s", busID, clientID) - // Wait for response (with timeout via context) + // 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") } @@ -159,24 +266,7 @@ func (um *UseManager) DetachDevice(clientID, busID string) error { um.mu.Unlock() return fmt.Errorf("device %s not attached", key) } - - // Clean up tunnel - if tunnel, ok := um.tunnels[dev.TunnelID]; ok { - close(tunnel.done) - if tunnel.conn != nil { - tunnel.conn.Close() - } - delete(um.tunnels, dev.TunnelID) - } - - // Detach from VHCI - if dev.VHCIPort >= 0 { - if err := usbip.DetachDevice(dev.VHCIPort); err != nil { - log.Printf("[use] warning: VHCI detach error: %v", err) - } - } - - delete(um.attached, key) + um.closeAttachedLocked(key, dev) um.mu.Unlock() // Notify share client @@ -203,9 +293,37 @@ func (um *UseManager) setupVHCI(clientID, busID string, granted *protocol.Device } 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) } @@ -215,6 +333,13 @@ func (um *UseManager) setupVHCI(clientID, busID string, granted *protocol.Device 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 @@ -235,14 +360,42 @@ func (um *UseManager) setupVHCI(clientID, busID string, granted *protocol.Device } um.mu.Unlock() - // Start reading from the tunnel socket (VHCI -> relay) + // Start reading from the tunnel socket (VHCI -> peer) go um.tunnelReadLoop(tunnel) - log.Printf("[use] device %s attached on VHCI port %d", key, vhciPort) + // 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) - // Fix permissions on newly created device nodes (e.g. /dev/video*) + // 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 fixVHCIDevicePermissions(vhciPort) + go func() { + logVHCIDeviceStatus(vhciPort) + fixVHCIDevicePermissions(vhciPort) + }() return nil } @@ -268,7 +421,11 @@ func (um *UseManager) tunnelReadLoop(tunnel *useTunnel) { } } - if err := um.client.SendTunnelData(tunnel.id, buf[:n]); err != nil { + 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 } @@ -305,7 +462,7 @@ func (um *UseManager) handleDeviceList(msg *protocol.DeviceList) { um.mu.Unlock() log.Printf("[use] received device list from %s (%s): %d devices", - msg.ClientName, msg.ClientID[:8], len(msg.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 { @@ -401,25 +558,71 @@ func (um *UseManager) ForceDetachDevice(clientID, busID string) error { }) } +// 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() - ch, exists := um.pending[msg.RequestID] + 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 exists { - ch <- msg + 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) - um.mu.RLock() - ch, exists := um.pending[msg.RequestID] - um.mu.RUnlock() - - if exists { - close(ch) // signal denial by closing channel + if req, exists := um.resolvePending(msg.RequestID); exists { + close(req.resp) // a closed channel reads as a denial } } @@ -436,24 +639,8 @@ func (um *UseManager) handleDeviceReleased(msg *protocol.DeviceReleased) { continue } - // Clean up tunnel - if tunnel, ok := um.tunnels[dev.TunnelID]; ok { - close(tunnel.done) - if tunnel.conn != nil { - tunnel.conn.Close() - } - delete(um.tunnels, dev.TunnelID) - } - - // Detach from VHCI - if dev.VHCIPort >= 0 { - if err := usbip.DetachDevice(dev.VHCIPort); err != nil { - log.Printf("[use] warning: VHCI detach error for force-released device: %v", err) - } - } - - delete(um.attached, key) - log.Printf("[use] device %s cleaned up (force-released by share client)", key) + um.closeAttachedLocked(key, dev) + log.Printf("[use] device %s cleaned up (released by share client)", key) break } um.mu.Unlock() @@ -465,16 +652,35 @@ func (um *UseManager) handleTunnelData(tunnelID string, data []byte) { um.mu.RUnlock() if !exists { - log.Printf("[use] tunnel data for unknown tunnel %s (%d bytes)", tunnelID[:8], len(data)) + // 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 } - // Write to the tunnel socket (relay -> VHCI) - n, err := tunnel.conn.Write(data) + // 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 write error: %v", err) - } else if n != len(data) { - log.Printf("[use] tunnel short write: %d/%d", n, len(data)) + 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() } } @@ -486,15 +692,7 @@ func (um *UseManager) handleClientLeft(msg *protocol.ClientLeft) { // Detach any devices from this client for key, dev := range um.attached { if dev.ClientID == msg.ClientID { - if tunnel, ok := um.tunnels[dev.TunnelID]; ok { - close(tunnel.done) - tunnel.conn.Close() - delete(um.tunnels, dev.TunnelID) - } - if dev.VHCIPort >= 0 { - usbip.DetachDevice(dev.VHCIPort) - } - delete(um.attached, key) + um.closeAttachedLocked(key, dev) log.Printf("[use] device %s auto-detached (client left)", key) } } @@ -507,13 +705,7 @@ func (um *UseManager) Cleanup() { defer um.mu.Unlock() for key, dev := range um.attached { - if tunnel, ok := um.tunnels[dev.TunnelID]; ok { - close(tunnel.done) - tunnel.conn.Close() - } - if dev.VHCIPort >= 0 { - usbip.DetachDevice(dev.VHCIPort) - } + um.closeAttachedLocked(key, dev) log.Printf("[use] cleaned up device %s", key) } diff --git a/internal/config/config.go b/internal/config/config.go index f6eb78a..0e7802b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,9 +9,9 @@ import ( // AutoConnectRule defines a rule for automatic device connection type AutoConnectRule struct { - BusID string `json:"bus_id,omitempty"` - VendorID string `json:"vendor_id,omitempty"` - ProductID string `json:"product_id,omitempty"` + BusID string `json:"bus_id,omitempty"` + VendorID string `json:"vendor_id,omitempty"` + ProductID string `json:"product_id,omitempty"` ClientName string `json:"client_name,omitempty"` } @@ -19,7 +19,7 @@ type AutoConnectRule struct { type Config struct { RelayAddr string `json:"relay_addr"` // e.g. "ws://localhost:8443" or "wss://relay.example.com:8443" Hash string `json:"hash"` // SHA256 hash of 3 tokens - Mode string `json:"mode"` // "share" or "use" + Mode string `json:"mode"` // "share", "use" or "both" Name string `json:"name"` // friendly name for this client WebPort int `json:"web_port"` // web UI port (default 8080) @@ -33,6 +33,32 @@ type Config struct { // Share mode: allow other clients to force-detach devices in use AllowForceDetach bool `json:"allow_force_detach,omitempty"` + + // DirectPort is the TCP port to accept direct tunnel connections on. + // 0 picks a free port, which is fine when peers can reach each other + // directly. Set a fixed port when you need to forward it through a + // firewall or NAT. + DirectPort int `json:"direct_port,omitempty"` + + // DisableDirect forces every tunnel through the relay. Direct connections + // are preferred otherwise: they cut latency and keep USB traffic away + // from the relay entirely. + DisableDirect bool `json:"disable_direct,omitempty"` + + // BridgeSocket is a Unix socket path on which to accept USB devices + // handed in by another process. Needed where this process cannot open + // devices itself — an Android app must obtain the descriptor through the + // framework and pass it in. Empty disables the bridge. + BridgeSocket string `json:"bridge_socket,omitempty"` +} + +// HasTokens reports whether the full token set is available. +// +// Tunnel encryption and direct connections both need the tokens themselves; +// a config carrying only the group hash can join a group but not derive the +// keys, because the hash is what the relay is told. +func (c *Config) HasTokens() bool { + return c.Token1 != "" && c.Token2 != "" && c.Token3 != "" } // DefaultConfig returns a config with sensible defaults diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 0000000..557d986 --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -0,0 +1,296 @@ +package crypto + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "testing" +) + +const ( + tok1 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=" + tok2 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=" + tok3 = "ccccccccccccccccccccccccccccccccccccccccccc=" +) + +func mustSecret(t *testing.T) *TunnelSecret { + t.Helper() + s, err := DeriveTunnelSecret(tok1, tok2, tok3) + if err != nil { + t.Fatalf("DeriveTunnelSecret: %v", err) + } + return s +} + +func TestDeriveTunnelSecretIsDeterministic(t *testing.T) { + a := mustSecret(t) + b := mustSecret(t) + + if !bytes.Equal(a.master, b.master) { + t.Error("same tokens produced different secrets") + } + + other, err := DeriveTunnelSecret(tok1, tok2, "different") + if err != nil { + t.Fatalf("DeriveTunnelSecret: %v", err) + } + if bytes.Equal(a.master, other.master) { + t.Error("different tokens produced the same secret") + } +} + +func TestDeriveTunnelSecretRequiresAllTokens(t *testing.T) { + for _, tc := range [][3]string{ + {"", tok2, tok3}, + {tok1, "", tok3}, + {tok1, tok2, ""}, + } { + if _, err := DeriveTunnelSecret(tc[0], tc[1], tc[2]); err == nil { + t.Errorf("DeriveTunnelSecret(%q, %q, %q) succeeded, want an error", tc[0], tc[1], tc[2]) + } + } +} + +// The whole point of the design: the relay knows the group hash, so the +// tunnel secret must not be derivable from it. +func TestTunnelSecretDiffersFromGroupHash(t *testing.T) { + s := mustSecret(t) + + combined := strings.Join([]string{tok1, tok2, tok3}, ":") + sum := sha256.Sum256([]byte(combined)) + groupHash := hex.EncodeToString(sum[:]) + + if hex.EncodeToString(s.master) == groupHash { + t.Fatal("tunnel secret equals the group hash — the relay could decrypt everything") + } + + key, err := s.TunnelKey("tunnel-1") + if err != nil { + t.Fatalf("TunnelKey: %v", err) + } + if hex.EncodeToString(key) == groupHash { + t.Fatal("tunnel key equals the group hash") + } + if bytes.Equal(key, s.master) { + t.Error("tunnel key equals the master secret; it should be derived per tunnel") + } +} + +func TestTunnelKeyIsPerTunnel(t *testing.T) { + s := mustSecret(t) + + a, _ := s.TunnelKey("tunnel-a") + b, _ := s.TunnelKey("tunnel-b") + aAgain, _ := s.TunnelKey("tunnel-a") + + if bytes.Equal(a, b) { + t.Error("different tunnel IDs produced the same key") + } + if !bytes.Equal(a, aAgain) { + t.Error("same tunnel ID produced different keys") + } + if len(a) != keySize { + t.Errorf("key is %d bytes, want %d", len(a), keySize) + } +} + +func TestSealOpenRoundTrip(t *testing.T) { + s := mustSecret(t) + key, _ := s.TunnelKey("t1") + + sealer, err := NewSealer(key, DirShareToUse) + if err != nil { + t.Fatalf("NewSealer: %v", err) + } + opener, err := NewOpener(key, DirShareToUse) + if err != nil { + t.Fatalf("NewOpener: %v", err) + } + + messages := [][]byte{ + []byte("first"), + []byte(""), + bytes.Repeat([]byte{0xAB}, 65536), + []byte("last"), + } + + for i, want := range messages { + frame, err := sealer.Seal(want) + if err != nil { + t.Fatalf("Seal %d: %v", i, err) + } + if len(frame) != len(want)+FrameOverhead { + t.Errorf("frame %d is %d bytes, want %d", i, len(frame), len(want)+FrameOverhead) + } + // The plaintext must not be visible on the wire. + if len(want) > 8 && bytes.Contains(frame, want) { + t.Errorf("frame %d contains its plaintext", i) + } + + got, err := opener.Open(frame) + if err != nil { + t.Fatalf("Open %d: %v", i, err) + } + if !bytes.Equal(got, want) { + t.Errorf("frame %d round-tripped to %q, want %q", i, got, want) + } + } +} + +// Both ends derive the same tunnel key, so the direction byte is the only +// thing keeping their nonce spaces apart. +func TestDirectionsUseSeparateNonceSpaces(t *testing.T) { + s := mustSecret(t) + key, _ := s.TunnelKey("t1") + + shareToUse, _ := NewSealer(key, DirShareToUse) + useToShare, _ := NewSealer(key, DirUseToShare) + + plaintext := []byte("identical plaintext") + a, _ := shareToUse.Seal(plaintext) + b, _ := useToShare.Seal(plaintext) + + if bytes.Equal(a, b) { + t.Fatal("both directions produced identical ciphertext — nonce reuse") + } + // Same counter, so any difference must come from the direction byte. + if !bytes.Equal(a[:counterSize], b[:counterSize]) { + t.Fatal("test assumption broken: counters differ") + } + + // A frame from one direction must not open with the other direction's opener. + wrongWay, _ := NewOpener(key, DirUseToShare) + if _, err := wrongWay.Open(a); err == nil { + t.Error("a frame opened under the wrong direction") + } +} + +func TestOpenRejectsTampering(t *testing.T) { + s := mustSecret(t) + key, _ := s.TunnelKey("t1") + sealer, _ := NewSealer(key, DirShareToUse) + + original, _ := sealer.Seal([]byte("sensitive usb traffic")) + + tests := []struct { + name string + mutate func([]byte) []byte + }{ + {"flipped ciphertext bit", func(f []byte) []byte { + f[counterSize+2] ^= 0x01 + return f + }}, + {"flipped counter bit", func(f []byte) []byte { + f[0] ^= 0x80 + return f + }}, + {"truncated tag", func(f []byte) []byte { return f[:len(f)-1] }}, + {"appended byte", func(f []byte) []byte { return append(f, 0x00) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + frame := append([]byte(nil), original...) + opener, _ := NewOpener(key, DirShareToUse) + if _, err := opener.Open(tt.mutate(frame)); err == nil { + t.Error("tampered frame was accepted") + } + }) + } +} + +func TestOpenRejectsWrongKey(t *testing.T) { + s := mustSecret(t) + good, _ := s.TunnelKey("t1") + bad, _ := s.TunnelKey("t2") + + sealer, _ := NewSealer(good, DirShareToUse) + frame, _ := sealer.Seal([]byte("secret")) + + opener, _ := NewOpener(bad, DirShareToUse) + if _, err := opener.Open(frame); err == nil { + t.Error("frame opened under a key from a different tunnel") + } +} + +func TestOpenRejectsReplay(t *testing.T) { + s := mustSecret(t) + key, _ := s.TunnelKey("t1") + + sealer, _ := NewSealer(key, DirShareToUse) + opener, _ := NewOpener(key, DirShareToUse) + + frame, _ := sealer.Seal([]byte("do this once")) + + if _, err := opener.Open(append([]byte(nil), frame...)); err != nil { + t.Fatalf("first delivery: %v", err) + } + if _, err := opener.Open(append([]byte(nil), frame...)); !errors.Is(err, ErrReplay) { + t.Errorf("replayed frame gave %v, want ErrReplay", err) + } +} + +// A forged frame carrying a huge counter must not poison the replay window +// and lock out the genuine frames that follow. +func TestForgedFrameDoesNotAdvanceCounter(t *testing.T) { + s := mustSecret(t) + key, _ := s.TunnelKey("t1") + + sealer, _ := NewSealer(key, DirShareToUse) + opener, _ := NewOpener(key, DirShareToUse) + + forged := make([]byte, FrameOverhead+4) + for i := range forged[:counterSize] { + forged[i] = 0xFF + } + if _, err := opener.Open(forged); err == nil { + t.Fatal("forged frame was accepted") + } + + genuine, _ := sealer.Seal([]byte("real traffic")) + got, err := opener.Open(genuine) + if err != nil { + t.Fatalf("genuine frame rejected after a forgery: %v", err) + } + if string(got) != "real traffic" { + t.Errorf("got %q", got) + } +} + +func TestOpenRejectsUndersizedFrame(t *testing.T) { + s := mustSecret(t) + key, _ := s.TunnelKey("t1") + opener, _ := NewOpener(key, DirShareToUse) + + for _, size := range []int{0, 1, counterSize, FrameOverhead - 1} { + if _, err := opener.Open(make([]byte, size)); err == nil { + t.Errorf("frame of %d bytes was accepted", size) + } + } +} + +func TestPeerTokenBindsToContext(t *testing.T) { + s := mustSecret(t) + + a := s.PeerToken("tunnel-1") + b := s.PeerToken("tunnel-2") + aAgain := s.PeerToken("tunnel-1") + + if a == b { + t.Error("different contexts produced the same token") + } + if a != aAgain { + t.Error("same context produced different tokens") + } + if len(a) != 64 { + t.Errorf("token is %d hex chars, want 64", len(a)) + } + + // A different group must not be able to produce a matching token. + other, _ := DeriveTunnelSecret(tok1, tok2, "different") + if other.PeerToken("tunnel-1") == a { + t.Error("a different group secret produced the same peer token") + } +} diff --git a/internal/crypto/frame.go b/internal/crypto/frame.go new file mode 100644 index 0000000..3cf6004 --- /dev/null +++ b/internal/crypto/frame.go @@ -0,0 +1,163 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/binary" + "errors" + "fmt" + "sync" +) + +// Frame layout on the wire: +// +// [8 bytes counter (big endian)][ciphertext + 16 byte auth tag] +// +// The counter travels in the clear because the receiver needs it to rebuild +// the nonce; it carries no secret, and the authentication tag covers it. +const ( + counterSize = 8 + nonceSize = 12 // AES-GCM standard nonce + tagSize = 16 + // FrameOverhead is how much a frame grows over its plaintext. + FrameOverhead = counterSize + tagSize +) + +// Direction distinguishes the two halves of a tunnel. +// +// Both ends derive the same tunnel key, so without this they would encrypt +// different plaintexts under the same (key, nonce) pair — the one failure that +// breaks AES-GCM completely, revealing the XOR of both messages and allowing +// forgery. +type Direction uint8 + +const ( + // DirShareToUse marks traffic from the sharing side to the using side. + DirShareToUse Direction = 1 + // DirUseToShare marks traffic in the opposite direction. + DirUseToShare Direction = 2 +) + +// ErrCounterExhausted is returned once a sealer has used every counter value. +var ErrCounterExhausted = errors.New("tunnel counter exhausted, reconnect required") + +// ErrReplay is returned for a frame whose counter was already seen. +var ErrReplay = errors.New("replayed or out-of-order tunnel frame") + +// Sealer encrypts outgoing tunnel frames. +type Sealer struct { + mu sync.Mutex + aead cipher.AEAD + dir Direction + counter uint64 +} + +// Opener decrypts incoming tunnel frames. +type Opener struct { + mu sync.Mutex + aead cipher.AEAD + dir Direction + lastSeen uint64 + started bool +} + +// NewSealer creates a sealer for one direction of a tunnel. +func NewSealer(key []byte, dir Direction) (*Sealer, error) { + aead, err := newAEAD(key) + if err != nil { + return nil, err + } + return &Sealer{aead: aead, dir: dir}, nil +} + +// NewOpener creates an opener for one direction of a tunnel. +// The direction must be the one the *sender* used. +func NewOpener(key []byte, dir Direction) (*Opener, error) { + aead, err := newAEAD(key) + if err != nil { + return nil, err + } + return &Opener{aead: aead, dir: dir}, nil +} + +func newAEAD(key []byte) (cipher.AEAD, error) { + if len(key) != keySize { + return nil, fmt.Errorf("key is %d bytes, want %d", len(key), keySize) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("creating cipher: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("creating GCM: %w", err) + } + return aead, nil +} + +// nonceFor builds the 12-byte nonce: direction, four zero bytes, counter. +// Distinct directions therefore never share a nonce under the same key. +func nonceFor(dir Direction, counter uint64) [nonceSize]byte { + var nonce [nonceSize]byte + nonce[0] = byte(dir) + binary.BigEndian.PutUint64(nonce[4:], counter) + return nonce +} + +// Seal encrypts one frame and returns it ready for transmission. +func (s *Sealer) Seal(plaintext []byte) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.counter == ^uint64(0) { + return nil, ErrCounterExhausted + } + counter := s.counter + s.counter++ + + nonce := nonceFor(s.dir, counter) + + out := make([]byte, counterSize, counterSize+len(plaintext)+tagSize) + binary.BigEndian.PutUint64(out, counter) + + // The counter prefix is authenticated as additional data, so it cannot be + // altered to make a frame decrypt under a different nonce. + return s.aead.Seal(out, nonce[:], plaintext, out[:counterSize]), nil +} + +// Open decrypts one frame. +// +// Frames must arrive in order, which holds for both transports in use: a +// direct TCP connection and a relayed WebSocket both preserve ordering. A +// counter that does not advance means duplication or tampering. +func (o *Opener) Open(frame []byte) ([]byte, error) { + if len(frame) < FrameOverhead { + return nil, fmt.Errorf("frame is %d bytes, minimum is %d", len(frame), FrameOverhead) + } + + counter := binary.BigEndian.Uint64(frame[:counterSize]) + + o.mu.Lock() + if o.started && counter <= o.lastSeen { + o.mu.Unlock() + return nil, ErrReplay + } + o.mu.Unlock() + + nonce := nonceFor(o.dir, counter) + plaintext, err := o.aead.Open(nil, nonce[:], frame[counterSize:], frame[:counterSize]) + if err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + + // Only advance after the frame proves authentic, so a forged frame with a + // high counter cannot make us reject the genuine ones that follow. + o.mu.Lock() + if counter > o.lastSeen || !o.started { + o.lastSeen = counter + o.started = true + } + o.mu.Unlock() + + return plaintext, nil +} diff --git a/internal/crypto/keys.go b/internal/crypto/keys.go new file mode 100644 index 0000000..c9a719b --- /dev/null +++ b/internal/crypto/keys.go @@ -0,0 +1,76 @@ +// Package crypto derives the keys that protect tunnel traffic and provides +// the authenticated framing used on direct peer-to-peer connections. +package crypto + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "strings" + + "golang.org/x/crypto/hkdf" +) + +// keySize is the AES-256 key length. +const keySize = 32 + +// hkdfSalt separates this key schedule from any other use of the same tokens. +const hkdfSalt = "usb-server/tunnel/v1" + +// TunnelSecret is the long-lived group secret derived from the three tokens. +// +// It deliberately is NOT the group hash. The relay is told the hash so it can +// group clients, which means anyone running the relay knows it — using it to +// encrypt would protect nothing from the party best positioned to look. The +// tokens themselves never leave the client, and the hash is a SHA-256 of them, +// so knowing the hash does not yield this secret. +type TunnelSecret struct { + master []byte +} + +// DeriveTunnelSecret builds the group secret from the three tokens. +// All three must be non-empty; a client configured with only the group hash +// cannot participate in encrypted tunnels. +func DeriveTunnelSecret(token1, token2, token3 string) (*TunnelSecret, error) { + if token1 == "" || token2 == "" || token3 == "" { + return nil, fmt.Errorf("all three tokens are required to derive the tunnel key") + } + + // Same joining as the group hash, so both are bound to the same input. + combined := strings.Join([]string{token1, token2, token3}, ":") + + master := make([]byte, keySize) + r := hkdf.New(sha256.New, []byte(combined), []byte(hkdfSalt), []byte("master")) + if _, err := io.ReadFull(r, master); err != nil { + return nil, fmt.Errorf("deriving master key: %w", err) + } + + return &TunnelSecret{master: master}, nil +} + +// TunnelKey derives the key for one tunnel from its ID. +// +// Every tunnel gets a fresh random ID, so each connection gets a distinct key +// and nonces can restart from zero without ever repeating a (key, nonce) pair. +func (s *TunnelSecret) TunnelKey(tunnelID string) ([]byte, error) { + key := make([]byte, keySize) + r := hkdf.New(sha256.New, s.master, []byte(hkdfSalt), []byte("tunnel:"+tunnelID)) + if _, err := io.ReadFull(r, key); err != nil { + return nil, fmt.Errorf("deriving tunnel key: %w", err) + } + return key, nil +} + +// PeerToken produces a short value a peer can present to prove it knows the +// group secret, bound to the given context string. +// +// This authenticates direct connections: the relay can tell two clients how to +// reach each other, but it cannot forge this, so a peer that presents a valid +// token really is a group member rather than whoever happens to reach the port. +func (s *TunnelSecret) PeerToken(context string) string { + r := hkdf.New(sha256.New, s.master, []byte(hkdfSalt), []byte("peer-token:"+context)) + token := make([]byte, 32) + io.ReadFull(r, token) + return hex.EncodeToString(token) +} diff --git a/internal/diag/diag.go b/internal/diag/diag.go new file mode 100644 index 0000000..e51e9c7 --- /dev/null +++ b/internal/diag/diag.go @@ -0,0 +1,255 @@ +// Package diag collects everything needed to work out why USB sharing does +// not work on a given machine. +// +// It exists because the failure modes are platform specific and mostly +// invisible from the outside: a missing kernel module, a driver that did not +// load, permissions on a device node, a filter that is installed but not +// attached. Guessing at those across a chat is slow; a structured report +// turns it into a matter of reading. +package diag + +import ( + "encoding/json" + "fmt" + "os" + "runtime" + "strings" + "time" +) + +// Report is the whole diagnostic picture of one machine. +type Report struct { + // Generated is filled in by the caller, since a report is often written + // and read at very different times. + Generated string `json:"generated"` + + Tool ToolInfo `json:"tool"` + System SystemInfo `json:"system"` + Sharing Capability `json:"sharing"` + Using Capability `json:"using"` + Devices []DeviceInfo `json:"devices"` + Checks []Check `json:"checks"` + + // Notes carries anything that did not fit elsewhere, in plain language. + Notes []string `json:"notes,omitempty"` +} + +// ToolInfo identifies the build that produced the report. +type ToolInfo struct { + Version string `json:"version"` + GoVersion string `json:"go_version"` + OS string `json:"os"` + Arch string `json:"arch"` +} + +// SystemInfo describes the machine. +type SystemInfo struct { + Hostname string `json:"hostname"` + OSVersion string `json:"os_version,omitempty"` + KernelVersion string `json:"kernel_version,omitempty"` + Privileged bool `json:"privileged"` + // Container reports whether we appear to be inside one, which changes + // what device access means. + Container bool `json:"container,omitempty"` +} + +// Capability reports whether one half of the system can work here. +type Capability struct { + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` + // Mechanism names what would be used: "usbdevfs", "usbshare filter", + // "vhci-hcd", "usbip-win2". + Mechanism string `json:"mechanism,omitempty"` +} + +// DeviceInfo is one USB device as the machine sees it. +type DeviceInfo struct { + BusID string `json:"bus_id,omitempty"` + VendorID string `json:"vendor_id"` + ProductID string `json:"product_id"` + Name string `json:"name,omitempty"` + Class string `json:"class,omitempty"` + Driver string `json:"driver,omitempty"` + Speed string `json:"speed,omitempty"` + + // Shareable reports whether this device could actually be offered, and + // Blocker says what stands in the way when it cannot. + Shareable bool `json:"shareable"` + Blocker string `json:"blocker,omitempty"` + + // Endpoints matter for diagnosing devices that attach but stay silent: + // a wrong transfer type here is exactly that symptom. + Endpoints []EndpointInfo `json:"endpoints,omitempty"` +} + +// EndpointInfo is one endpoint of a device. +type EndpointInfo struct { + Address string `json:"address"` + Direction string `json:"direction"` + TransferType string `json:"transfer_type"` + MaxPacket uint16 `json:"max_packet"` + Interval uint8 `json:"interval"` +} + +// Check is one named test with a verdict. +type Check struct { + Name string `json:"name"` + Passed bool `json:"passed"` + Detail string `json:"detail,omitempty"` + // Fix is a concrete action, present only when the check failed and there + // is something the user can actually do. + Fix string `json:"fix,omitempty"` +} + +// Collect gathers a report for the current machine. +func Collect(version string) *Report { + hostname, _ := os.Hostname() + + report := &Report{ + Generated: time.Now().Format(time.RFC3339), + Tool: ToolInfo{ + Version: version, + GoVersion: runtime.Version(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + }, + System: SystemInfo{ + Hostname: hostname, + Privileged: isPrivileged(), + Container: inContainer(), + }, + } + + collectPlatform(report) + + return report +} + +// JSON renders the report for machine consumption. +func (r *Report) JSON() ([]byte, error) { + return json.MarshalIndent(r, "", " ") +} + +// String renders the report for a human reading a terminal. +func (r *Report) String() string { + var b strings.Builder + + fmt.Fprintf(&b, "USB Server diagnostics\n") + fmt.Fprintf(&b, "======================\n\n") + fmt.Fprintf(&b, "Host: %s (%s/%s)\n", r.System.Hostname, r.Tool.OS, r.Tool.Arch) + if r.System.OSVersion != "" { + fmt.Fprintf(&b, "OS: %s\n", r.System.OSVersion) + } + if r.System.KernelVersion != "" { + fmt.Fprintf(&b, "Kernel: %s\n", r.System.KernelVersion) + } + fmt.Fprintf(&b, "Elevated: %v\n", r.System.Privileged) + if r.System.Container { + fmt.Fprintf(&b, "Container: yes\n") + } + fmt.Fprintf(&b, "\n") + + fmt.Fprintf(&b, "Sharing devices: %s\n", capabilityLine(r.Sharing)) + fmt.Fprintf(&b, "Using devices: %s\n", capabilityLine(r.Using)) + fmt.Fprintf(&b, "\n") + + if len(r.Checks) > 0 { + fmt.Fprintf(&b, "Checks\n------\n") + for _, c := range r.Checks { + mark := "FAIL" + if c.Passed { + mark = " ok " + } + fmt.Fprintf(&b, "[%s] %s\n", mark, c.Name) + if c.Detail != "" { + fmt.Fprintf(&b, " %s\n", c.Detail) + } + if !c.Passed && c.Fix != "" { + fmt.Fprintf(&b, " fix: %s\n", c.Fix) + } + } + fmt.Fprintf(&b, "\n") + } + + fmt.Fprintf(&b, "Devices (%d)\n-----------\n", len(r.Devices)) + for _, d := range r.Devices { + state := "shareable" + if !d.Shareable { + state = "blocked: " + d.Blocker + } + fmt.Fprintf(&b, "%-12s %s:%s %-28s %s\n", + d.BusID, d.VendorID, d.ProductID, truncate(d.Name, 28), state) + if d.Driver != "" { + fmt.Fprintf(&b, " driver=%s class=%s speed=%s\n", d.Driver, d.Class, d.Speed) + } + for _, ep := range d.Endpoints { + fmt.Fprintf(&b, " ep %s %-3s %-11s maxpkt=%d interval=%d\n", + ep.Address, ep.Direction, ep.TransferType, ep.MaxPacket, ep.Interval) + } + } + + if len(r.Notes) > 0 { + fmt.Fprintf(&b, "\nNotes\n-----\n") + for _, n := range r.Notes { + fmt.Fprintf(&b, "- %s\n", n) + } + } + + return b.String() +} + +func capabilityLine(c Capability) string { + if c.Available { + if c.Mechanism != "" { + return "yes (" + c.Mechanism + ")" + } + return "yes" + } + if c.Reason != "" { + return "no — " + c.Reason + } + return "no" +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return s[:max-3] + "..." +} + +// addCheck appends a check result. +func (r *Report) addCheck(name string, passed bool, detail, fix string) { + r.Checks = append(r.Checks, Check{ + Name: name, + Passed: passed, + Detail: detail, + Fix: fix, + }) +} + +// note appends a free-form observation. +func (r *Report) note(format string, args ...interface{}) { + r.Notes = append(r.Notes, fmt.Sprintf(format, args...)) +} + +// inContainer guesses whether this process runs inside a container. +// +// It matters for diagnosis: inside a container, missing devices usually mean +// the container was not given access, not that the host lacks them. +func inContainer() bool { + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + if data, err := os.ReadFile("/proc/1/cgroup"); err == nil { + content := string(data) + if strings.Contains(content, "docker") || strings.Contains(content, "containerd") || + strings.Contains(content, "lxc") { + return true + } + } + return false +} diff --git a/internal/diag/diag_darwin.go b/internal/diag/diag_darwin.go new file mode 100644 index 0000000..fd9b3ae --- /dev/null +++ b/internal/diag/diag_darwin.go @@ -0,0 +1,163 @@ +//go:build darwin + +package diag + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +func isPrivileged() bool { return os.Geteuid() == 0 } + +func collectPlatform(r *Report) { + r.System.OSVersion = macOSVersion() + r.System.KernelVersion = commandOutput("uname", "-r") + + collectMacDevices(r) + assessMacCapabilities(r) +} + +func macOSVersion() string { + name := commandOutput("sw_vers", "-productName") + version := commandOutput("sw_vers", "-productVersion") + build := commandOutput("sw_vers", "-buildVersion") + + parts := []string{} + for _, p := range []string{name, version, build} { + if p != "" { + parts = append(parts, p) + } + } + return strings.Join(parts, " ") +} + +func commandOutput(name string, args ...string) string { + out, err := exec.Command(name, args...).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// system_profiler's JSON output, as much of it as we care about. +type spReport struct { + Items []spUSBItem `json:"SPUSBDataType"` +} + +type spUSBItem struct { + Name string `json:"_name"` + VendorID string `json:"vendor_id"` + ProductID string `json:"product_id"` + Speed string `json:"device_speed"` + Manufacturer string `json:"manufacturer"` + SerialNumber string `json:"serial_num"` + LocationID string `json:"location_id"` + Media []spMedia `json:"Media"` + Items []spUSBItem `json:"_items"` +} + +type spMedia struct { + Name string `json:"_name"` +} + +// collectMacDevices lists USB devices via system_profiler. +// +// Going through the command rather than IOKit keeps this cgo-free, which is +// what lets the tool be cross-compiled from any machine. It is also enough: +// this reports what is present, and on macOS nothing can be shared regardless +// until there is an IOKit backend. +func collectMacDevices(r *Report) { + out, err := exec.Command("system_profiler", "-json", "SPUSBDataType").Output() + if err != nil { + r.note("system_profiler failed: %v", err) + return + } + + var report spReport + if err := json.Unmarshal(out, &report); err != nil { + r.note("could not parse system_profiler output: %v", err) + return + } + + for _, item := range report.Items { + collectMacItem(r, item) + } +} + +// collectMacItem walks the tree; hubs carry their devices in _items. +func collectMacItem(r *Report, item spUSBItem) { + if item.VendorID != "" { + r.Devices = append(r.Devices, DeviceInfo{ + BusID: macBusID(item.LocationID), + VendorID: normaliseMacID(item.VendorID), + ProductID: normaliseMacID(item.ProductID), + Name: macDeviceName(item), + Speed: item.Speed, + Shareable: false, + Blocker: "macOS sharing needs an IOKit backend, which does not exist yet", + }) + } + + for _, child := range item.Items { + collectMacItem(r, child) + } +} + +func macDeviceName(item spUSBItem) string { + if item.Manufacturer != "" && item.Name != "" && + !strings.HasPrefix(item.Name, item.Manufacturer) { + return item.Manufacturer + " " + item.Name + } + return item.Name +} + +// normaliseMacID turns "0x046d (Logitech Inc.)" into "046d". +func normaliseMacID(id string) string { + id = strings.TrimSpace(id) + if i := strings.Index(id, " "); i > 0 { + id = id[:i] + } + id = strings.TrimPrefix(id, "0x") + + // Pad to four digits so IDs sort and compare like everywhere else. + if v, err := strconv.ParseUint(id, 16, 32); err == nil { + return fmt.Sprintf("%04x", v) + } + return id +} + +// macBusID derives an identifier from the location ID, which encodes the +// device's position in the port tree and is stable while it stays plugged in. +func macBusID(locationID string) string { + locationID = strings.TrimSpace(locationID) + if i := strings.Index(locationID, " "); i > 0 { + locationID = locationID[:i] + } + return strings.TrimPrefix(locationID, "0x") +} + +func assessMacCapabilities(r *Report) { + r.Sharing = Capability{ + Available: false, + Reason: "no IOKit backend — macOS has no usbdevfs equivalent", + Mechanism: "IOKit (not implemented)", + } + + r.Using = Capability{ + Available: false, + Reason: "no virtual USB host controller — this needs a signed DriverKit driver", + Mechanism: "DriverKit (not implemented)", + } + + r.addCheck("macOS sharing", false, + fmt.Sprintf("%d USB device(s) found, but none can be shared yet", len(r.Devices)), + "none — the relay server runs on macOS, the client's USB side does not") + + r.note("Docker does not help here: containers share the host kernel, and " + + "on macOS Docker runs in a Linux VM that never sees the host's USB hardware. " + + "A full VM with USB passthrough (UTM, Parallels, VMware) does work.") +} diff --git a/internal/diag/diag_linux.go b/internal/diag/diag_linux.go new file mode 100644 index 0000000..0b5e2ce --- /dev/null +++ b/internal/diag/diag_linux.go @@ -0,0 +1,170 @@ +//go:build linux + +package diag + +import ( + "fmt" + "os" + "strings" + + "github.com/duffy/usb-server/internal/usb" + "golang.org/x/sys/unix" +) + +func isPrivileged() bool { return os.Geteuid() == 0 } + +func collectPlatform(r *Report) { + r.System.KernelVersion = kernelVersion() + + checkUsbdevfs(r) + checkVHCI(r) + collectLinuxDevices(r) + assessLinuxCapabilities(r) +} + +func kernelVersion() string { + var uname unix.Utsname + if err := unix.Uname(&uname); err != nil { + return "" + } + return fmt.Sprintf("%s %s", + nullTerminated(uname.Sysname[:]), nullTerminated(uname.Release[:])) +} + +func nullTerminated(b []byte) string { + if i := strings.IndexByte(string(b), 0); i >= 0 { + return string(b[:i]) + } + return string(b) +} + +// checkUsbdevfs verifies that device nodes exist and are usable. +// +// Being able to list devices through sysfs proves nothing: sharing needs to +// open the node under /dev/bus/usb, and that is where permissions bite. +func checkUsbdevfs(r *Report) { + if _, err := os.Stat("/dev/bus/usb"); err != nil { + detail := "/dev/bus/usb is missing" + fix := "check that usbcore is loaded and devtmpfs is mounted" + if r.System.Container { + detail += " — this is a container, so it was probably not passed through" + fix = "add - /dev/bus/usb:/dev/bus/usb to the container's volumes, and run it privileged" + } + r.addCheck("usbdevfs device nodes", false, detail, fix) + return + } + + if _, err := os.Stat("/sys/bus/usb/devices"); err != nil { + r.addCheck("usbdevfs device nodes", false, + "/sys/bus/usb is not mounted, so devices cannot be enumerated", + "mount sysfs, or in a container add - /sys/bus/usb:/sys/bus/usb") + return + } + + r.addCheck("usbdevfs device nodes", true, "/dev/bus/usb and /sys/bus/usb are present", "") + + if !isPrivileged() { + r.addCheck("privileges", false, + "not running as root — devices can be listed but not claimed", + "run the client with sudo, or install it as a system service") + } else { + r.addCheck("privileges", true, "running as root", "") + } +} + +// checkVHCI verifies the kernel module needed to receive remote devices. +func checkVHCI(r *Report) { + if _, err := os.Stat("/sys/devices/platform/vhci_hcd.0"); err == nil { + r.addCheck("vhci-hcd module", true, "loaded — remote devices can be attached", "") + return + } + + detail := "not loaded — remote devices cannot be attached" + if r.System.Container { + detail += " (a container cannot load modules; this must happen on the host)" + } + + r.addCheck("vhci-hcd module", false, detail, + "sudo modprobe vhci-hcd (persist with: echo vhci-hcd | sudo tee /etc/modules-load.d/vhci-hcd.conf)") +} + +func collectLinuxDevices(r *Report) { + devices, err := usb.Enumerate() + if err != nil { + r.note("device enumeration failed: %v", err) + return + } + + for _, dev := range devices { + info := DeviceInfo{ + BusID: dev.BusID, + VendorID: fmt.Sprintf("%04x", dev.VendorID), + ProductID: fmt.Sprintf("%04x", dev.ProductID), + Name: dev.DisplayName(), + Class: fmt.Sprintf("%02x", dev.DeviceClass), + Speed: speedName(dev.Speed), + } + + if len(dev.Interfaces) > 0 { + info.Driver = dev.Interfaces[0].Driver + } + + // Sharing needs write access to the node, so test exactly that. + if err := unix.Access(dev.DevPath, unix.R_OK|unix.W_OK); err != nil { + info.Shareable = false + info.Blocker = fmt.Sprintf("no write access to %s (%v)", dev.DevPath, err) + } else { + info.Shareable = true + } + + for _, ep := range dev.Endpoints { + info.Endpoints = append(info.Endpoints, endpointInfo(ep)) + } + + // An empty endpoint map means the raw descriptors could not be read, + // which is what makes transfer types guesswork later. + if len(dev.Endpoints) == 0 { + r.note("no endpoint descriptors for %s — could not read %s; "+ + "transfer types will be guessed from the request interval", + dev.BusID, dev.DevPath) + } + + r.Devices = append(r.Devices, info) + } +} + +func assessLinuxCapabilities(r *Report) { + shareable := 0 + for _, d := range r.Devices { + if d.Shareable { + shareable++ + } + } + + switch { + case shareable > 0: + r.Sharing = Capability{Available: true, Mechanism: "usbdevfs"} + case len(r.Devices) > 0: + r.Sharing = Capability{ + Available: false, + Reason: "devices found, but none can be opened (permissions)", + Mechanism: "usbdevfs", + } + default: + r.Sharing = Capability{ + Available: false, + Reason: "no USB devices found", + Mechanism: "usbdevfs", + } + } + + if _, err := os.Stat("/sys/devices/platform/vhci_hcd.0"); err == nil { + r.Using = Capability{Available: true, Mechanism: "vhci-hcd"} + } else { + r.Using = Capability{ + Available: false, + Reason: "vhci-hcd is not loaded", + Mechanism: "vhci-hcd", + } + } +} diff --git a/internal/diag/diag_test.go b/internal/diag/diag_test.go new file mode 100644 index 0000000..f2614a5 --- /dev/null +++ b/internal/diag/diag_test.go @@ -0,0 +1,198 @@ +package diag + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/duffy/usb-server/internal/usb" +) + +func TestCollectProducesUsableReport(t *testing.T) { + report := Collect("test") + + if report.Generated == "" { + t.Error("no timestamp") + } + if report.Tool.OS == "" || report.Tool.Arch == "" { + t.Error("platform not recorded") + } + if report.System.Hostname == "" { + t.Error("hostname not recorded") + } + + // A report that says nothing about either capability is useless: the + // whole point is answering whether this machine can share or use. + if report.Sharing.Mechanism == "" && report.Sharing.Reason == "" { + t.Error("sharing capability has neither a mechanism nor a reason") + } + if report.Using.Mechanism == "" && report.Using.Reason == "" { + t.Error("using capability has neither a mechanism nor a reason") + } +} + +func TestReportRoundTripsThroughJSON(t *testing.T) { + report := Collect("test") + + data, err := report.JSON() + if err != nil { + t.Fatalf("JSON: %v", err) + } + + var decoded Report + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("decoding the report we just produced: %v", err) + } + + if decoded.Tool.OS != report.Tool.OS { + t.Errorf("OS survived as %q, want %q", decoded.Tool.OS, report.Tool.OS) + } + if len(decoded.Devices) != len(report.Devices) { + t.Errorf("device count changed: %d -> %d", len(report.Devices), len(decoded.Devices)) + } +} + +// A failed check without a fix leaves the reader stuck, which defeats the +// purpose of the report. +func TestFailedChecksSuggestAFix(t *testing.T) { + report := Collect("test") + + for _, check := range report.Checks { + if !check.Passed && check.Fix == "" && check.Detail == "" { + t.Errorf("check %q failed but says nothing about why or what to do", check.Name) + } + } +} + +func TestStringOutputMentionsEverything(t *testing.T) { + report := &Report{ + Generated: "2026-01-01T00:00:00Z", + Tool: ToolInfo{OS: "linux", Arch: "amd64"}, + System: SystemInfo{Hostname: "testhost"}, + Sharing: Capability{Available: true, Mechanism: "usbdevfs"}, + Using: Capability{Available: false, Reason: "vhci-hcd is not loaded"}, + Devices: []DeviceInfo{{ + BusID: "1-2", + VendorID: "046d", + ProductID: "c52b", + Name: "Logitech Receiver", + Shareable: true, + Endpoints: []EndpointInfo{{ + Address: "0x81", + Direction: "IN", + TransferType: "interrupt", + MaxPacket: 8, + Interval: 10, + }}, + }}, + Checks: []Check{ + {Name: "vhci-hcd module", Passed: false, + Detail: "not loaded", Fix: "sudo modprobe vhci-hcd"}, + }, + } + + out := report.String() + + for _, want := range []string{ + "testhost", "usbdevfs", "vhci-hcd is not loaded", + "1-2", "046d", "c52b", "Logitech Receiver", + "0x81", "interrupt", + "sudo modprobe vhci-hcd", + } { + if !strings.Contains(out, want) { + t.Errorf("output does not mention %q", want) + } + } +} + +func TestTransferTypeNames(t *testing.T) { + tests := []struct { + input uint8 + want string + }{ + {usb.TransferTypeControl, "control"}, + {usb.TransferTypeIsochronous, "isochronous"}, + {usb.TransferTypeBulk, "bulk"}, + {usb.TransferTypeInterrupt, "interrupt"}, + {99, "unknown(99)"}, + } + + for _, tt := range tests { + if got := transferTypeName(tt.input); got != tt.want { + t.Errorf("transferTypeName(%d) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestEndpointInfoReportsDirection(t *testing.T) { + in := endpointInfo(usb.Endpoint{ + Address: 0x81, + TransferType: usb.TransferTypeInterrupt, + MaxPacketSize: 8, + Interval: 10, + }) + if in.Direction != "IN" { + t.Errorf("0x81 reported as %s, want IN", in.Direction) + } + if in.Address != "0x81" { + t.Errorf("address rendered as %q", in.Address) + } + + out := endpointInfo(usb.Endpoint{Address: 0x02, TransferType: usb.TransferTypeBulk}) + if out.Direction != "OUT" { + t.Errorf("0x02 reported as %s, want OUT", out.Direction) + } +} + +func TestDiagURLAcceptsEveryRelayForm(t *testing.T) { + tests := []struct { + relay string + want string + }{ + {"ws://relay:8443", "http://relay:8443/diag/abc"}, + {"wss://relay.example.com", "https://relay.example.com/diag/abc"}, + {"http://relay:8443", "http://relay:8443/diag/abc"}, + {"https://relay:8443", "https://relay:8443/diag/abc"}, + {"relay:8443", "http://relay:8443/diag/abc"}, + {"ws://relay:8443/ws", "http://relay:8443/diag/abc"}, + {"ws://relay:8443/", "http://relay:8443/diag/abc"}, + } + + for _, tt := range tests { + got, err := DiagURL(tt.relay, "abc") + if err != nil { + t.Errorf("DiagURL(%q): %v", tt.relay, err) + continue + } + if got != tt.want { + t.Errorf("DiagURL(%q) = %q, want %q", tt.relay, got, tt.want) + } + } +} + +func TestDiagURLRejectsBadIDs(t *testing.T) { + for _, id := range []string{"", "a/b", "a?b", "a#b"} { + if _, err := DiagURL("ws://relay:8443", id); err == nil { + t.Errorf("DiagURL accepted the ID %q", id) + } + } +} + +func TestTruncate(t *testing.T) { + tests := []struct { + in string + max int + want string + }{ + {"short", 10, "short"}, + {"exactly-10", 10, "exactly-10"}, + {"this is far too long", 10, "this is..."}, + {"abc", 2, "ab"}, + } + + for _, tt := range tests { + if got := truncate(tt.in, tt.max); got != tt.want { + t.Errorf("truncate(%q, %d) = %q, want %q", tt.in, tt.max, got, tt.want) + } + } +} diff --git a/internal/diag/diag_windows.go b/internal/diag/diag_windows.go new file mode 100644 index 0000000..e7341ad --- /dev/null +++ b/internal/diag/diag_windows.go @@ -0,0 +1,309 @@ +//go:build windows + +package diag + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/duffy/usb-server/internal/usb" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +const usbShareServiceName = "usbshare" + +func isPrivileged() bool { + // An elevated process has the administrators group enabled in its token. + var sid *windows.SID + err := windows.AllocateAndInitializeSid( + &windows.SECURITY_NT_AUTHORITY, + 2, + windows.SECURITY_BUILTIN_DOMAIN_RID, + windows.DOMAIN_ALIAS_RID_ADMINS, + 0, 0, 0, 0, 0, 0, + &sid, + ) + if err != nil { + return false + } + defer windows.FreeSid(sid) + + token := windows.Token(0) // the process token + member, err := token.IsMember(sid) + return err == nil && member +} + +func collectPlatform(r *Report) { + r.System.OSVersion = windowsVersion() + + checkTestSigning(r) + checkDriverService(r) + checkDriverInterface(r) + collectWindowsDevices(r) + assessWindowsCapabilities(r) +} + +// windowsVersion reads the build information from the registry, which does +// not lie about the version the way GetVersionEx does for unmanifested +// processes. +func windowsVersion() string { + key, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE) + if err != nil { + return "" + } + defer key.Close() + + productName, _, _ := key.GetStringValue("ProductName") + displayVersion, _, _ := key.GetStringValue("DisplayVersion") + build, _, _ := key.GetStringValue("CurrentBuildNumber") + ubr, _, _ := key.GetIntegerValue("UBR") + + parts := []string{productName} + if displayVersion != "" { + parts = append(parts, displayVersion) + } + if build != "" { + if ubr > 0 { + parts = append(parts, fmt.Sprintf("build %s.%d", build, ubr)) + } else { + parts = append(parts, "build "+build) + } + } + return strings.Join(parts, " ") +} + +// checkTestSigning reports whether unsigned drivers may load. +// +// This is the single most common reason a freshly built driver does nothing: +// it is installed, the INF looks fine, and Windows silently refuses to load +// it because it is not signed by Microsoft. +func checkTestSigning(r *Report) { + out, err := exec.Command("bcdedit", "/enum", "{current}").Output() + if err != nil { + r.addCheck("test signing", false, + "could not read the boot configuration: "+err.Error(), + "run this from an elevated command prompt") + return + } + + text := strings.ToLower(string(out)) + testSigning := strings.Contains(text, "testsigning") && strings.Contains(text, "yes") + + if testSigning { + r.addCheck("test signing", true, "enabled — unsigned drivers may load", "") + } else { + r.addCheck("test signing", false, + "disabled — Windows will refuse to load an unsigned driver, usually without any visible error", + "bcdedit /set testsigning on (then reboot; only do this on a test machine)") + } +} + +// checkDriverService reports whether the filter driver is registered and +// running. +func checkDriverService(r *Report) { + manager, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT) + if err != nil { + r.addCheck("usbshare driver service", false, + "could not open the service manager: "+err.Error(), "") + return + } + defer windows.CloseServiceHandle(manager) + + namePtr, _ := windows.UTF16PtrFromString(usbShareServiceName) + service, err := windows.OpenService(manager, namePtr, windows.SERVICE_QUERY_STATUS) + if err != nil { + r.addCheck("usbshare driver service", false, + "not registered — the driver has not been installed", + "right-click driver/windows/usbshare.inf and choose Install, then attach it "+ + "to a device in Device Manager") + return + } + defer windows.CloseServiceHandle(service) + + var status windows.SERVICE_STATUS + if err := windows.QueryServiceStatus(service, &status); err != nil { + r.addCheck("usbshare driver service", false, + "registered, but its status could not be read: "+err.Error(), "") + return + } + + switch status.CurrentState { + case windows.SERVICE_RUNNING: + r.addCheck("usbshare driver service", true, "registered and running", "") + case windows.SERVICE_STOPPED: + // A filter driver only starts when it is attached to a device, so + // stopped is expected until then rather than an error in itself. + r.addCheck("usbshare driver service", false, + "registered but not running — normal until the filter is attached to a device", + "attach the filter to a device in Device Manager, then replug it") + default: + r.addCheck("usbshare driver service", false, + fmt.Sprintf("registered, service state %d", status.CurrentState), "") + } +} + +// checkDriverInterface reports whether any device exposes the filter's +// interface, which is what user mode actually needs. +func checkDriverInterface(r *Report) { + devices, err := usb.Enumerate() + if err != nil { + r.addCheck("usbshare device interface", false, + "no device exposes the interface: "+err.Error(), + "the driver must be attached to a specific device, not just installed") + return + } + + if len(devices) == 0 { + r.addCheck("usbshare device interface", false, + "the driver is present but no device is attached to it", + "in Device Manager, update the driver for the device you want to share") + return + } + + r.addCheck("usbshare device interface", true, + fmt.Sprintf("%d device(s) reachable through the filter", len(devices)), "") +} + +func collectWindowsDevices(r *Report) { + devices, err := usb.Enumerate() + if err != nil { + r.note("device enumeration failed: %v", err) + collectWindowsDevicesFallback(r) + return + } + + for _, dev := range devices { + info := DeviceInfo{ + BusID: dev.BusID, + VendorID: fmt.Sprintf("%04x", dev.VendorID), + ProductID: fmt.Sprintf("%04x", dev.ProductID), + Name: dev.DisplayName(), + Class: fmt.Sprintf("%02x", dev.DeviceClass), + Speed: speedName(dev.Speed), + Shareable: true, + } + + for _, ep := range dev.Endpoints { + info.Endpoints = append(info.Endpoints, endpointInfo(ep)) + } + + r.Devices = append(r.Devices, info) + } + + // Everything the filter cannot see is still worth listing: it explains + // why an expected device is absent. + collectWindowsDevicesFallback(r) +} + +// collectWindowsDevicesFallback lists all USB devices via PowerShell, whether +// or not the filter is attached. +// +// Shelling out is deliberate: reproducing this through SetupAPI would be a +// few hundred lines of syscall code for something that only ever runs when a +// human is already reading the output. +func collectWindowsDevicesFallback(r *Report) { + cmd := exec.Command("powershell", "-NoProfile", "-Command", + `Get-PnpDevice -Class USB -ErrorAction SilentlyContinue | `+ + `Select-Object -Property InstanceId,FriendlyName,Status,Service | `+ + `ForEach-Object { "$($_.InstanceId)|$($_.FriendlyName)|$($_.Status)|$($_.Service)" }`) + + out, err := cmd.Output() + if err != nil { + r.note("could not list USB devices via PowerShell: %v", err) + return + } + + seen := make(map[string]bool) + for _, d := range r.Devices { + seen[strings.ToLower(d.VendorID+":"+d.ProductID)] = true + } + + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.Split(line, "|") + if len(parts) < 4 { + continue + } + + instanceID, friendly, status, service := parts[0], parts[1], parts[2], parts[3] + vid, pid := parseVidPid(instanceID) + if vid == "" { + continue + } + if seen[strings.ToLower(vid+":"+pid)] { + continue // already listed through the filter + } + + blocker := "usbshare filter not attached" + if !strings.EqualFold(status, "OK") { + blocker = "device status: " + status + } + + r.Devices = append(r.Devices, DeviceInfo{ + VendorID: vid, + ProductID: pid, + Name: friendly, + Driver: service, + Shareable: false, + Blocker: blocker, + }) + } +} + +// parseVidPid pulls the IDs out of an instance ID such as +// USB\VID_046D&PID_C52B\5&1a2b3c4d&0&2. +func parseVidPid(instanceID string) (vid, pid string) { + upper := strings.ToUpper(instanceID) + + if i := strings.Index(upper, "VID_"); i >= 0 && len(upper) >= i+8 { + vid = strings.ToLower(upper[i+4 : i+8]) + } + if i := strings.Index(upper, "PID_"); i >= 0 && len(upper) >= i+8 { + pid = strings.ToLower(upper[i+4 : i+8]) + } + return vid, pid +} + +func assessWindowsCapabilities(r *Report) { + shareable := 0 + for _, d := range r.Devices { + if d.Shareable { + shareable++ + } + } + + if shareable > 0 { + r.Sharing = Capability{ + Available: true, + Mechanism: "usbshare filter driver", + } + } else { + r.Sharing = Capability{ + Available: false, + Reason: "no device is attached to the usbshare filter driver", + Mechanism: "usbshare filter driver", + } + } + + // The use side needs usbip-win2's VHCI driver, which is a separate + // product with its own installer. + if _, err := exec.LookPath("usbip"); err == nil { + r.Using = Capability{Available: true, Mechanism: "usbip-win2 VHCI"} + } else { + r.Using = Capability{ + Available: false, + Reason: "usbip.exe not found", + Mechanism: "usbip-win2 VHCI", + } + r.addCheck("usbip-win2", false, + "not installed — receiving remote devices needs its VHCI driver", + "install from https://github.com/vadimgrn/usbip-win2/releases") + } +} diff --git a/internal/diag/shared.go b/internal/diag/shared.go new file mode 100644 index 0000000..05f7f6d --- /dev/null +++ b/internal/diag/shared.go @@ -0,0 +1,61 @@ +package diag + +import ( + "fmt" + + "github.com/duffy/usb-server/internal/usb" +) + +// speedName renders a USB/IP speed code. +func speedName(speed uint32) string { + switch speed { + case 1: + return "low" + case 2: + return "full" + case 3: + return "high" + case 5: + return "super" + case 6: + return "super+" + default: + return "unknown" + } +} + +// transferTypeName renders an endpoint transfer type. +// +// This is the field to look at when a device attaches but produces no data: +// an interrupt endpoint reported as bulk is exactly that symptom, because the +// kernel rejects the transfer. +func transferTypeName(t uint8) string { + switch t { + case usb.TransferTypeControl: + return "control" + case usb.TransferTypeIsochronous: + return "isochronous" + case usb.TransferTypeBulk: + return "bulk" + case usb.TransferTypeInterrupt: + return "interrupt" + default: + return fmt.Sprintf("unknown(%d)", t) + } +} + +// endpointInfo renders one endpoint for the report. +func endpointInfo(ep usb.Endpoint) EndpointInfo { + direction := "OUT" + if ep.IsIn() { + direction = "IN" + } + + return EndpointInfo{ + Address: fmt.Sprintf("0x%02x", ep.Address), + Direction: direction, + TransferType: transferTypeName(ep.TransferType), + MaxPacket: ep.MaxPacketSize, + Interval: ep.Interval, + } +} diff --git a/internal/diag/upload.go b/internal/diag/upload.go new file mode 100644 index 0000000..ef77c26 --- /dev/null +++ b/internal/diag/upload.go @@ -0,0 +1,89 @@ +package diag + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// maxReportSize bounds a stored report, on both the sending and the +// receiving side. +const maxReportSize = 4 << 20 // 4 MB + +// Upload posts a report to a relay's diagnostics endpoint and returns the URL +// it can be fetched from. +// +// The point is getting a report off a machine that is awkward to copy from — +// a headless NAS, a Windows box mid-debugging — without pasting thousands of +// lines by hand. +func Upload(relayURL, reportID string, report *Report) (string, error) { + data, err := report.JSON() + if err != nil { + return "", fmt.Errorf("encoding report: %w", err) + } + if len(data) > maxReportSize { + return "", fmt.Errorf("report is %d bytes, over the %d byte limit", len(data), maxReportSize) + } + + target, err := DiagURL(relayURL, reportID) + if err != nil { + return "", err + } + + req, err := http.NewRequest(http.MethodPut, target, bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("building request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("uploading to %s: %w", target, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return "", fmt.Errorf("relay refused the report: %s: %s", + resp.Status, strings.TrimSpace(string(body))) + } + + return target, nil +} + +// DiagURL builds the diagnostics URL for a report ID on a relay. +// +// It accepts the same address forms the client's relay setting does, so the +// user does not have to remember a second syntax. +func DiagURL(relayURL, reportID string) (string, error) { + if reportID == "" { + return "", fmt.Errorf("a report ID is required") + } + if strings.ContainsAny(reportID, "/?#") { + return "", fmt.Errorf("report ID must not contain /, ? or #") + } + + base := strings.TrimSuffix(strings.TrimSpace(relayURL), "/") + base = strings.TrimSuffix(base, "/ws") + + switch { + case strings.HasPrefix(base, "ws://"): + base = "http://" + strings.TrimPrefix(base, "ws://") + case strings.HasPrefix(base, "wss://"): + base = "https://" + strings.TrimPrefix(base, "wss://") + case strings.HasPrefix(base, "http://"), strings.HasPrefix(base, "https://"): + // already fine + default: + base = "http://" + base + } + + return base + "/diag/" + reportID, nil +} + +// RetentionNote describes how long an uploaded report survives on the relay. +// Kept here so the client can say so without importing the relay package. +const RetentionNote = "24 hours" diff --git a/internal/protocol/debug.go b/internal/protocol/debug.go new file mode 100644 index 0000000..f85f418 --- /dev/null +++ b/internal/protocol/debug.go @@ -0,0 +1,30 @@ +package protocol + +import ( + "log" + "os" + "strconv" +) + +// Debug reports whether verbose per-URB tracing is enabled, via USBSRV_DEBUG=1. +// +// This tracing is genuinely useful when a device misbehaves, but it must stay +// off by default: an active webcam or audio device produces thousands of URBs +// per second, and logging each one costs more time than forwarding it. +var Debug = debugEnabled() + +func debugEnabled() bool { + v := os.Getenv("USBSRV_DEBUG") + if v == "" { + return false + } + on, err := strconv.ParseBool(v) + return err == nil && on +} + +// Debugf logs only when debug tracing is enabled. +func Debugf(format string, args ...interface{}) { + if Debug { + log.Printf(format, args...) + } +} diff --git a/internal/protocol/messages.go b/internal/protocol/messages.go index 175a00e..3c452f5 100644 --- a/internal/protocol/messages.go +++ b/internal/protocol/messages.go @@ -2,27 +2,47 @@ package protocol // Message types const ( - MsgRegister = "register" - MsgDeviceList = "device_list" - MsgRequestDevice = "request_device" - MsgDeviceGranted = "device_granted" - MsgDeviceDenied = "device_denied" - MsgReleaseDevice = "release_device" + MsgRegister = "register" + MsgDeviceList = "device_list" + MsgRequestDevice = "request_device" + MsgDeviceGranted = "device_granted" + MsgDeviceDenied = "device_denied" + MsgReleaseDevice = "release_device" MsgDeviceReleased = "device_released" - MsgClientJoined = "client_joined" - MsgClientLeft = "client_left" - MsgForceRelease = "force_release" - MsgPing = "ping" - MsgPong = "pong" - MsgError = "error" + MsgClientJoined = "client_joined" + MsgClientLeft = "client_left" + MsgForceRelease = "force_release" + MsgPing = "ping" + MsgPong = "pong" + MsgError = "error" ) -// Client modes +// Client modes. +// +// ModeBoth lets a single client offer its own devices and consume other +// clients' devices at the same time, which is the normal case for a peer +// group where every machine both lends and borrows hardware. const ( ModeShare = "share" ModeUse = "use" + ModeBoth = "both" ) +// ValidMode reports whether mode is one this build understands. +func ValidMode(mode string) bool { + switch mode { + case ModeShare, ModeUse, ModeBoth: + return true + } + return false +} + +// CanShare reports whether a client in this mode offers devices to others. +func CanShare(mode string) bool { return mode == ModeShare || mode == ModeBoth } + +// CanUse reports whether a client in this mode consumes devices from others. +func CanUse(mode string) bool { return mode == ModeUse || mode == ModeBoth } + // Device status const ( StatusAvailable = "available" @@ -41,25 +61,34 @@ type Register struct { Mode string `json:"mode"` ClientID string `json:"client_id"` Name string `json:"name"` + + // DirectPort is the TCP port this client listens on for direct tunnel + // connections, or 0 if it accepts none. Peers use it to skip the relay. + DirectPort int `json:"direct_port,omitempty"` + + // LocalEndpoints are host:port addresses on this client's own interfaces. + // They let two machines on the same network find each other directly + // instead of sending USB traffic out to a relay and back. + LocalEndpoints []string `json:"local_endpoints,omitempty"` } // USBDevice describes a USB device type USBDevice struct { - BusID string `json:"bus_id"` - BusNum uint32 `json:"bus_num"` - DevNum uint32 `json:"dev_num"` - Speed uint32 `json:"speed"` - VendorID string `json:"vendor_id"` - ProductID string `json:"product_id"` - DeviceBCD string `json:"device_bcd,omitempty"` - Class uint8 `json:"class"` - SubClass uint8 `json:"sub_class"` - Protocol uint8 `json:"protocol"` - Name string `json:"name"` - Manufacturer string `json:"manufacturer,omitempty"` - NumInterfaces uint8 `json:"num_interfaces"` - Status string `json:"status"` - UsedBy string `json:"used_by,omitempty"` + BusID string `json:"bus_id"` + BusNum uint32 `json:"bus_num"` + DevNum uint32 `json:"dev_num"` + Speed uint32 `json:"speed"` + VendorID string `json:"vendor_id"` + ProductID string `json:"product_id"` + DeviceBCD string `json:"device_bcd,omitempty"` + Class uint8 `json:"class"` + SubClass uint8 `json:"sub_class"` + Protocol uint8 `json:"protocol"` + Name string `json:"name"` + Manufacturer string `json:"manufacturer,omitempty"` + NumInterfaces uint8 `json:"num_interfaces"` + Status string `json:"status"` + UsedBy string `json:"used_by,omitempty"` } // DeviceList is sent by share clients to announce available devices @@ -87,6 +116,17 @@ type DeviceGranted struct { RequestID string `json:"request_id"` DevID uint32 `json:"dev_id"` // (busnum << 16) | devnum Speed uint32 `json:"speed"` + + // Endpoints are addresses at which the granting client accepts a direct + // tunnel connection for this device. The client contributes its own + // interface addresses; the relay appends the public address it sees, + // which is the only part neither peer can determine for itself. + Endpoints []string `json:"endpoints,omitempty"` + + // Encrypted reports whether the granting client will encrypt tunnel + // frames. It is false only for clients configured with a bare group hash + // and no tokens, which cannot derive the key. + Encrypted bool `json:"encrypted,omitempty"` } // DeviceDenied is sent when a device request is rejected @@ -150,3 +190,13 @@ type ErrorMsg struct { // TunnelHeader is prepended to binary WebSocket frames for tunnel data. // Format: [16 bytes UUID][payload] const TunnelHeaderSize = 16 + +// ShortID truncates an identifier for logging without panicking on short or +// empty input. Slicing IDs directly is a real hazard here: a client that +// registers with an empty hash would otherwise take down the relay. +func ShortID(id string) string { + if len(id) <= 8 { + return id + } + return id[:8] +} diff --git a/internal/relay/diag.go b/internal/relay/diag.go new file mode 100644 index 0000000..9babb6b --- /dev/null +++ b/internal/relay/diag.go @@ -0,0 +1,153 @@ +package relay + +import ( + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// Diagnostics drop-off. +// +// Getting a report off an awkward machine — a headless NAS, a Windows box in +// the middle of driver debugging — is otherwise a matter of copying thousands +// of lines by hand. The relay is already reachable from every client, so it +// makes a convenient place to leave one. +// +// Reports are held in memory only, capped in size and count, and expire. The +// relay is not a storage service, and treating it like one is how it would +// become one. +const ( + // maxDiagReports bounds how many are kept; the oldest is dropped first. + maxDiagReports = 32 + + // maxDiagSize bounds one report. + maxDiagSize = 4 << 20 // 4 MB + + // diagTTL is how long a report survives. Long enough to fetch and read, + // short enough that machine details do not linger. + diagTTL = 24 * time.Hour +) + +// RetentionNote describes the retention policy for the client to print. +const RetentionNote = "24 hours" + +type diagReport struct { + data []byte + stored time.Time + fetched int + remoteIP string +} + +type diagStore struct { + mu sync.Mutex + reports map[string]*diagReport +} + +func newDiagStore() *diagStore { + return &diagStore{reports: make(map[string]*diagReport)} +} + +// put stores a report, evicting the oldest if the store is full. +func (s *diagStore) put(id string, data []byte, remoteIP string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.expireLocked() + + if len(s.reports) >= maxDiagReports { + var oldestID string + var oldest time.Time + for id, report := range s.reports { + if oldestID == "" || report.stored.Before(oldest) { + oldestID, oldest = id, report.stored + } + } + delete(s.reports, oldestID) + } + + s.reports[id] = &diagReport{ + data: data, + stored: time.Now(), + remoteIP: remoteIP, + } +} + +func (s *diagStore) get(id string) ([]byte, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + s.expireLocked() + + report, ok := s.reports[id] + if !ok { + return nil, false + } + report.fetched++ + return report.data, true +} + +// expireLocked drops reports past their TTL. Callers must hold the lock. +func (s *diagStore) expireLocked() { + cutoff := time.Now().Add(-diagTTL) + for id, report := range s.reports { + if report.stored.Before(cutoff) { + delete(s.reports, id) + } + } +} + +// handleDiag serves the diagnostics endpoint: PUT to store, GET to retrieve. +func (s *Server) handleDiag(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/diag/") + if id == "" || strings.Contains(id, "/") { + http.Error(w, "report ID required: /diag/", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodPut, http.MethodPost: + s.storeDiag(w, r, id) + case http.MethodGet: + s.fetchDiag(w, id) + default: + http.Error(w, "use PUT to store and GET to retrieve", http.StatusMethodNotAllowed) + } +} + +func (s *Server) storeDiag(w http.ResponseWriter, r *http.Request, id string) { + // LimitReader rather than trusting Content-Length: a client can lie about + // that, and this endpoint takes uploads from anyone who can reach it. + data, err := io.ReadAll(io.LimitReader(r.Body, maxDiagSize+1)) + if err != nil { + http.Error(w, "could not read the report", http.StatusBadRequest) + return + } + if len(data) > maxDiagSize { + http.Error(w, fmt.Sprintf("report exceeds the %d byte limit", maxDiagSize), + http.StatusRequestEntityTooLarge) + return + } + if len(data) == 0 { + http.Error(w, "empty report", http.StatusBadRequest) + return + } + + s.diag.put(id, data, clientIP(r)) + + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, "stored as %s, kept for %s\n", id, RetentionNote) +} + +func (s *Server) fetchDiag(w http.ResponseWriter, id string) { + data, ok := s.diag.get(id) + if !ok { + http.Error(w, "no such report (wrong ID, or it expired)", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(data) +} diff --git a/internal/relay/diag_test.go b/internal/relay/diag_test.go new file mode 100644 index 0000000..9e6b425 --- /dev/null +++ b/internal/relay/diag_test.go @@ -0,0 +1,233 @@ +package relay + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func newDiagServer() *Server { + return &Server{hub: NewHub(), diag: newDiagStore()} +} + +func TestDiagStoreAndFetch(t *testing.T) { + s := newDiagServer() + body := []byte(`{"tool":{"os":"windows"}}`) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/diag/report-1", bytes.NewReader(body)) + s.handleDiag(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("store returned %d, want %d", rec.Code, http.StatusCreated) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/diag/report-1", nil) + s.handleDiag(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("fetch returned %d, want 200", rec.Code) + } + if !bytes.Equal(rec.Body.Bytes(), body) { + t.Errorf("fetched %q, want %q", rec.Body.String(), body) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("content type %q, want application/json", ct) + } +} + +func TestDiagMissingReportIs404(t *testing.T) { + s := newDiagServer() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/diag/nope", nil) + s.handleDiag(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("got %d, want 404", rec.Code) + } +} + +func TestDiagRejectsBadPaths(t *testing.T) { + s := newDiagServer() + + for _, path := range []string{"/diag/", "/diag/a/b"} { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + s.handleDiag(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("%s returned %d, want 400", path, rec.Code) + } + } +} + +// The endpoint takes uploads from anyone who can reach the relay, so it must +// bound what one caller can make it hold. +func TestDiagRejectsOversizedReport(t *testing.T) { + s := newDiagServer() + + huge := bytes.Repeat([]byte("x"), maxDiagSize+100) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/diag/big", bytes.NewReader(huge)) + s.handleDiag(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Errorf("got %d, want 413", rec.Code) + } + + // And it must not have been stored anyway. + if _, ok := s.diag.get("big"); ok { + t.Error("an oversized report was stored") + } +} + +// A lying Content-Length must not get past the limit either. +func TestDiagLimitIgnoresContentLength(t *testing.T) { + s := newDiagServer() + + huge := bytes.Repeat([]byte("x"), maxDiagSize+100) + req := httptest.NewRequest(http.MethodPut, "/diag/liar", bytes.NewReader(huge)) + req.ContentLength = 10 // claims to be small + + rec := httptest.NewRecorder() + s.handleDiag(rec, req) + + if rec.Code == http.StatusCreated { + t.Error("an oversized body was accepted because it claimed to be small") + } +} + +func TestDiagRejectsEmptyReport(t *testing.T) { + s := newDiagServer() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/diag/empty", bytes.NewReader(nil)) + s.handleDiag(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("got %d, want 400", rec.Code) + } +} + +func TestDiagRejectsOtherMethods(t *testing.T) { + s := newDiagServer() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/diag/x", nil) + s.handleDiag(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("got %d, want 405", rec.Code) + } +} + +// The store is bounded, so a stream of uploads cannot grow it without limit. +func TestDiagEvictsOldestWhenFull(t *testing.T) { + store := newDiagStore() + + for i := 0; i < maxDiagReports+5; i++ { + store.put(fmt.Sprintf("report-%d", i), []byte("{}"), "127.0.0.1") + // Ordering by timestamp needs the timestamps to differ. + time.Sleep(time.Millisecond) + } + + store.mu.Lock() + count := len(store.reports) + store.mu.Unlock() + + if count > maxDiagReports { + t.Errorf("store holds %d reports, limit is %d", count, maxDiagReports) + } + + // The newest must have survived; the very first must not have. + if _, ok := store.get(fmt.Sprintf("report-%d", maxDiagReports+4)); !ok { + t.Error("the most recent report was evicted") + } + if _, ok := store.get("report-0"); ok { + t.Error("the oldest report survived eviction") + } +} + +func TestDiagExpiresOldReports(t *testing.T) { + store := newDiagStore() + + store.put("old", []byte("{}"), "127.0.0.1") + + // Backdate it past the TTL. + store.mu.Lock() + store.reports["old"].stored = time.Now().Add(-diagTTL - time.Minute) + store.mu.Unlock() + + if _, ok := store.get("old"); ok { + t.Error("a report past its TTL was still served") + } +} + +func TestClientIPPrefersForwardedHeader(t *testing.T) { + tests := []struct { + name string + setup func(*http.Request) + want string + }{ + { + name: "remote address", + setup: func(r *http.Request) { r.RemoteAddr = "203.0.113.7:12345" }, + want: "203.0.113.7", + }, + { + name: "forwarded header wins", + setup: func(r *http.Request) { + r.RemoteAddr = "10.0.0.1:12345" + r.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.2") + }, + want: "203.0.113.7", + }, + { + name: "real ip header", + setup: func(r *http.Request) { + r.RemoteAddr = "10.0.0.1:12345" + r.Header.Set("X-Real-IP", "203.0.113.9") + }, + want: "203.0.113.9", + }, + { + name: "garbage header falls back", + setup: func(r *http.Request) { + r.RemoteAddr = "203.0.113.7:12345" + r.Header.Set("X-Forwarded-For", "not-an-ip") + }, + want: "203.0.113.7", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header = http.Header{} + tt.setup(req) + + if got := clientIP(req); got != tt.want { + t.Errorf("clientIP() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRetentionNoteIsMentionedOnStore(t *testing.T) { + s := newDiagServer() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/diag/x", strings.NewReader("{}")) + s.handleDiag(rec, req) + + if !strings.Contains(rec.Body.String(), RetentionNote) { + t.Errorf("the response does not say how long the report is kept: %q", rec.Body.String()) + } +} diff --git a/internal/relay/hub.go b/internal/relay/hub.go index 2dfc948..f0d0ccb 100644 --- a/internal/relay/hub.go +++ b/internal/relay/hub.go @@ -3,36 +3,101 @@ package relay import ( "encoding/json" "log" + "net" + "strconv" "sync" "github.com/duffy/usb-server/internal/protocol" "github.com/gorilla/websocket" ) +// sendQueueDepth bounds per-client outgoing backlog. A client that falls this +// far behind is not going to catch up, and buffering more would let one stuck +// peer consume the relay's memory. +const sendQueueDepth = 256 + +// outMsg is one queued WebSocket frame. +type outMsg struct { + typ int // websocket.TextMessage or websocket.BinaryMessage + data []byte +} + // Client represents a connected WebSocket client type Client struct { ID string Hash string - Mode string // "share" or "use" + Mode string // "share", "use" or "both" Name string Conn *websocket.Conn - Send chan []byte // buffered channel for outgoing messages - mu sync.Mutex + // DirectPort is the port this client accepts direct tunnel connections on, + // 0 if it accepts none. + DirectPort int + + // PublicIP is the source address the relay sees this client connect from. + // Peers cannot determine their own public address, so the relay supplies + // it when passing on a grant — that is the whole reason it is involved in + // setting up connections that then bypass it. + PublicIP string + + // Send carries outgoing frames to this client's write pump. All writes go + // through it: writing to the socket directly from another client's read + // loop would block that peer — and, because the hub held its lock across + // the write, every other client with it. + Send chan outMsg + + closeOnce sync.Once + dead chan struct{} } -// WriteJSON sends a JSON message to the client -func (c *Client) WriteJSON(v interface{}) error { - c.mu.Lock() - defer c.mu.Unlock() - return c.Conn.WriteJSON(v) +// newClient creates a client with its outgoing queue ready. +func newClient(id, hash, mode, name string, conn *websocket.Conn) *Client { + return &Client{ + ID: id, + Hash: hash, + Mode: mode, + Name: name, + Conn: conn, + Send: make(chan outMsg, sendQueueDepth), + dead: make(chan struct{}), + } } -// WriteBinary sends a binary message to the client -func (c *Client) WriteBinary(data []byte) error { - c.mu.Lock() - defer c.mu.Unlock() - return c.Conn.WriteMessage(websocket.BinaryMessage, data) +// enqueue queues a frame without blocking. +// It reports false when the client's queue is full or it is already gone; the +// caller should treat that as a disconnect rather than retrying. +func (c *Client) enqueue(typ int, data []byte) bool { + select { + case <-c.dead: + return false + default: + } + + select { + case c.Send <- outMsg{typ: typ, data: data}: + return true + case <-c.dead: + return false + default: + log.Printf("[hub] send queue full for %s (%s), dropping client", + protocol.ShortID(c.ID), c.Name) + c.kill() + return false + } +} + +// kill marks the client dead and wakes its write pump. Idempotent. +func (c *Client) kill() { + c.closeOnce.Do(func() { close(c.dead) }) +} + +// enqueueJSON marshals and queues a JSON control message. +func (c *Client) enqueueJSON(v interface{}) bool { + data, err := json.Marshal(v) + if err != nil { + return false + } + return c.enqueue(websocket.TextMessage, data) } // Hub manages all connected clients and routes messages between them @@ -58,39 +123,80 @@ func NewHub() *Hub { } } +// peers returns a snapshot of the clients in a hash group, excluding one ID. +// +// Taking a snapshot and releasing the lock before doing anything with the +// clients is deliberate: holding the hub lock across a send is what let a +// single slow peer stall registration and routing for everyone. +func (h *Hub) peers(hash, excludeID string) []*Client { + h.mu.RLock() + defer h.mu.RUnlock() + + group := h.groups[hash] + result := make([]*Client, 0, len(group)) + for _, c := range group { + if c.ID != excludeID { + result = append(result, c) + } + } + return result +} + +// peer looks up a single client in a hash group. +func (h *Hub) peer(hash, clientID string) *Client { + h.mu.RLock() + defer h.mu.RUnlock() + + group := h.groups[hash] + if group == nil { + return nil + } + return group[clientID] +} + // Register adds a client to its hash group func (h *Hub) Register(client *Client) { h.mu.Lock() - defer h.mu.Unlock() - if h.groups[client.Hash] == nil { h.groups[client.Hash] = make(map[string]*Client) } + // A reconnecting client reuses its ID; drop the stale entry so its + // write pump exits instead of lingering with a dead socket. + if old, exists := h.groups[client.Hash][client.ID]; exists && old != client { + old.kill() + } h.groups[client.Hash][client.ID] = client + h.mu.Unlock() - log.Printf("[hub] client registered: id=%s hash=%s..%s mode=%s name=%s", - client.ID, client.Hash[:8], client.Hash[len(client.Hash)-4:], client.Mode, client.Name) + log.Printf("[hub] client registered: id=%s hash=%s mode=%s name=%s", + protocol.ShortID(client.ID), protocol.ShortID(client.Hash), client.Mode, client.Name) // Notify other clients in the group - h.broadcastToGroup(client.Hash, client.ID, &protocol.ClientJoined{ + joined := &protocol.ClientJoined{ Type: protocol.MsgClientJoined, ClientID: client.ID, Mode: client.Mode, Name: client.Name, - }) + } + for _, peer := range h.peers(client.Hash, client.ID) { + peer.enqueueJSON(joined) + } } // Unregister removes a client and cleans up its tunnels func (h *Hub) Unregister(client *Client) { h.mu.Lock() - defer h.mu.Unlock() - group := h.groups[client.Hash] if group == nil { + h.mu.Unlock() return } - delete(group, client.ID) + // Only remove this exact client: a reconnect may already have installed a + // newer connection under the same ID. + if group[client.ID] == client { + delete(group, client.ID) + } if len(group) == 0 { delete(h.groups, client.Hash) } @@ -101,21 +207,26 @@ func (h *Hub) Unregister(client *Client) { delete(h.tunnels, tid) } } + h.mu.Unlock() - log.Printf("[hub] client unregistered: id=%s name=%s", client.ID, client.Name) + client.kill() - // Notify others - h.broadcastToGroup(client.Hash, client.ID, &protocol.ClientLeft{ + log.Printf("[hub] client unregistered: id=%s name=%s", protocol.ShortID(client.ID), client.Name) + + left := &protocol.ClientLeft{ Type: protocol.MsgClientLeft, ClientID: client.ID, - }) + } + for _, peer := range h.peers(client.Hash, client.ID) { + peer.enqueueJSON(left) + } } // HandleTextMessage processes a JSON control message func (h *Hub) HandleTextMessage(sender *Client, data []byte) { var env protocol.Envelope if err := json.Unmarshal(data, &env); err != nil { - log.Printf("[hub] invalid message from %s: %v", sender.ID, err) + log.Printf("[hub] invalid message from %s: %v", protocol.ShortID(sender.ID), err) return } @@ -135,9 +246,9 @@ func (h *Hub) HandleTextMessage(sender *Client, data []byte) { case protocol.MsgDeviceReleased: h.handleDeviceReleased(sender, data) case protocol.MsgPing: - sender.WriteJSON(&protocol.Pong{Type: protocol.MsgPong}) + sender.enqueueJSON(&protocol.Pong{Type: protocol.MsgPong}) default: - log.Printf("[hub] unknown message type from %s: %s", sender.ID, env.Type) + log.Printf("[hub] unknown message type from %s: %s", protocol.ShortID(sender.ID), env.Type) } } @@ -159,40 +270,32 @@ func (h *Hub) HandleBinaryMessage(sender *Client, data []byte) { // Forward to the other end of the tunnel var targetID string - if sender.ID == tunnel.ShareClient { + switch sender.ID { + case tunnel.ShareClient: targetID = tunnel.UseClient - } else if sender.ID == tunnel.UseClient { + case tunnel.UseClient: targetID = tunnel.ShareClient - } else { + default: return } - h.mu.RLock() - group := h.groups[sender.Hash] - if group != nil { - if target := group[targetID]; target != nil { - target.WriteBinary(data) - } + if target := h.peer(sender.Hash, targetID); target != nil { + target.enqueue(websocket.BinaryMessage, data) } - h.mu.RUnlock() } -// handleDeviceList broadcasts device list from share client to all use clients +// handleDeviceList broadcasts a device list to every client in the group that +// can consume devices. func (h *Hub) handleDeviceList(sender *Client, data []byte) { - if sender.Mode != protocol.ModeShare { + if !protocol.CanShare(sender.Mode) { return } - h.mu.RLock() - group := h.groups[sender.Hash] - for _, client := range group { - if client.ID != sender.ID && client.Mode == protocol.ModeUse { - client.mu.Lock() - client.Conn.WriteMessage(websocket.TextMessage, data) - client.mu.Unlock() + for _, client := range h.peers(sender.Hash, sender.ID) { + if protocol.CanUse(client.Mode) { + client.enqueue(websocket.TextMessage, data) } } - h.mu.RUnlock() } // handleRequestDevice forwards a device request to the target share client @@ -202,22 +305,19 @@ func (h *Hub) handleRequestDevice(sender *Client, data []byte) { return } - h.mu.RLock() - group := h.groups[sender.Hash] - if group != nil { - if target := group[msg.TargetClient]; target != nil && target.Mode == protocol.ModeShare { - // Add the sender's ID so the share client knows who's requesting - enriched := map[string]interface{}{ - "type": protocol.MsgRequestDevice, - "target_client": msg.TargetClient, - "bus_id": msg.BusID, - "request_id": msg.RequestID, - "from_client": sender.ID, - } - target.WriteJSON(enriched) - } + target := h.peer(sender.Hash, msg.TargetClient) + if target == nil || !protocol.CanShare(target.Mode) { + return } - h.mu.RUnlock() + + // Add the sender's ID so the share client knows who's requesting + target.enqueueJSON(map[string]interface{}{ + "type": protocol.MsgRequestDevice, + "target_client": msg.TargetClient, + "bus_id": msg.BusID, + "request_id": msg.RequestID, + "from_client": sender.ID, + }) } // handleDeviceGranted registers the tunnel and forwards to the requesting client @@ -229,8 +329,10 @@ func (h *Hub) handleDeviceGranted(sender *Client, data []byte) { if err := json.Unmarshal(data, &granted); err != nil { return } + if granted.TunnelID == "" { + return + } - // Register tunnel h.mu.Lock() h.tunnels[granted.TunnelID] = &Tunnel{ ID: granted.TunnelID, @@ -241,19 +343,44 @@ func (h *Hub) handleDeviceGranted(sender *Client, data []byte) { h.mu.Unlock() log.Printf("[hub] tunnel created: %s (share=%s, use=%s, device=%s)", - granted.TunnelID, sender.ID, granted.TargetClient, granted.BusID) + granted.TunnelID, protocol.ShortID(sender.ID), protocol.ShortID(granted.TargetClient), granted.BusID) - // Forward to use client - h.mu.RLock() - group := h.groups[sender.Hash] - if group != nil { - if target := group[granted.TargetClient]; target != nil { - target.mu.Lock() - target.Conn.WriteMessage(websocket.TextMessage, data) - target.mu.Unlock() + target := h.peer(sender.Hash, granted.TargetClient) + if target == nil { + return + } + + // Add the address we see the granting client at. It cannot know its own + // public address, and this is what lets the two peers connect directly + // across NAT and take their USB traffic off this relay entirely. + out := data + if extra := publicEndpoint(sender); extra != "" { + granted.Endpoints = appendUnique(granted.Endpoints, extra) + if reencoded, err := json.Marshal(granted); err == nil { + out = reencoded } } - h.mu.RUnlock() + + target.enqueue(websocket.TextMessage, out) +} + +// publicEndpoint builds the host:port at which a client's direct listener +// should be reachable from outside, or "" if it accepts no direct connections. +func publicEndpoint(c *Client) string { + if c.DirectPort == 0 || c.PublicIP == "" { + return "" + } + return net.JoinHostPort(c.PublicIP, strconv.Itoa(c.DirectPort)) +} + +// appendUnique adds an entry unless it is already present. +func appendUnique(list []string, item string) []string { + for _, existing := range list { + if existing == item { + return list + } + } + return append(list, item) } // handleDeviceDenied forwards denial to the requesting client @@ -266,16 +393,9 @@ func (h *Hub) handleDeviceDenied(sender *Client, data []byte) { return } - h.mu.RLock() - group := h.groups[sender.Hash] - if group != nil { - if target := group[denied.TargetClient]; target != nil { - target.mu.Lock() - target.Conn.WriteMessage(websocket.TextMessage, data) - target.mu.Unlock() - } + if target := h.peer(sender.Hash, denied.TargetClient); target != nil { + target.enqueue(websocket.TextMessage, data) } - h.mu.RUnlock() } // handleReleaseDevice forwards a release to the share client @@ -296,21 +416,14 @@ func (h *Hub) handleReleaseDevice(sender *Client, data []byte) { } h.mu.Unlock() - // Forward to share client - h.mu.RLock() - group := h.groups[sender.Hash] - if group != nil { - if target := group[msg.TargetClient]; target != nil { - enriched := map[string]interface{}{ - "type": protocol.MsgReleaseDevice, - "target_client": msg.TargetClient, - "bus_id": msg.BusID, - "from_client": sender.ID, - } - target.WriteJSON(enriched) - } + if target := h.peer(sender.Hash, msg.TargetClient); target != nil { + target.enqueueJSON(map[string]interface{}{ + "type": protocol.MsgReleaseDevice, + "target_client": msg.TargetClient, + "bus_id": msg.BusID, + "from_client": sender.ID, + }) } - h.mu.RUnlock() } // handleForceRelease forwards a force-release request to the target share client @@ -331,43 +444,36 @@ func (h *Hub) handleForceRelease(sender *Client, data []byte) { } h.mu.Unlock() - // Forward to share client - h.mu.RLock() - group := h.groups[sender.Hash] - if group != nil { - if target := group[msg.TargetClient]; target != nil && target.Mode == protocol.ModeShare { - enriched := map[string]interface{}{ - "type": protocol.MsgForceRelease, - "target_client": msg.TargetClient, - "bus_id": msg.BusID, - "from_client": sender.ID, - } - target.WriteJSON(enriched) - } + target := h.peer(sender.Hash, msg.TargetClient) + if target == nil || !protocol.CanShare(target.Mode) { + return } - h.mu.RUnlock() + + target.enqueueJSON(map[string]interface{}{ + "type": protocol.MsgForceRelease, + "target_client": msg.TargetClient, + "bus_id": msg.BusID, + "from_client": sender.ID, + }) } // handleDeviceReleased broadcasts device released notification func (h *Hub) handleDeviceReleased(sender *Client, data []byte) { - h.mu.RLock() - group := h.groups[sender.Hash] - for _, client := range group { - if client.ID != sender.ID && client.Mode == protocol.ModeUse { - client.mu.Lock() - client.Conn.WriteMessage(websocket.TextMessage, data) - client.mu.Unlock() + for _, client := range h.peers(sender.Hash, sender.ID) { + if protocol.CanUse(client.Mode) { + client.enqueue(websocket.TextMessage, data) } } - h.mu.RUnlock() } -// broadcastToGroup sends a message to all clients in a hash group except the sender -func (h *Hub) broadcastToGroup(hash, excludeID string, msg interface{}) { - group := h.groups[hash] - for _, client := range group { - if client.ID != excludeID { - client.WriteJSON(msg) - } +// GroupStats reports the number of clients per hash group, for diagnostics. +func (h *Hub) GroupStats() map[string]int { + h.mu.RLock() + defer h.mu.RUnlock() + + stats := make(map[string]int, len(h.groups)) + for hash, group := range h.groups { + stats[protocol.ShortID(hash)] = len(group) } + return stats } diff --git a/internal/relay/hub_test.go b/internal/relay/hub_test.go new file mode 100644 index 0000000..7a17cef --- /dev/null +++ b/internal/relay/hub_test.go @@ -0,0 +1,465 @@ +package relay + +import ( + "encoding/json" + "testing" + "time" + + "github.com/duffy/usb-server/internal/protocol" + "github.com/gorilla/websocket" +) + +// newTestClient builds a client without a socket. Nothing in the routing path +// touches Conn — only the write pump does, and these tests read Send directly. +func newTestClient(id, hash, mode string) *Client { + return newClient(id, hash, mode, "test-"+id, nil) +} + +// drain collects everything queued for a client without blocking. +func drain(c *Client) []outMsg { + var msgs []outMsg + for { + select { + case m := <-c.Send: + msgs = append(msgs, m) + default: + return msgs + } + } +} + +// typeOf extracts the "type" field of a queued JSON control message. +func typeOf(t *testing.T, m outMsg) string { + t.Helper() + var env protocol.Envelope + if err := json.Unmarshal(m.data, &env); err != nil { + t.Fatalf("queued message is not JSON: %v", err) + } + return env.Type +} + +// countType drains a client and reports how many messages of one type it got. +// Counting by type rather than total keeps these assertions independent of the +// client_joined notifications registration produces. +func countType(t *testing.T, c *Client, msgType string) int { + t.Helper() + n := 0 + for _, m := range drain(c) { + if typeOf(t, m) == msgType { + n++ + } + } + return n +} + +// registerAll registers every client, then drains them, so that no client is +// left holding join notifications from a peer that registered after it. +func registerAll(h *Hub, clients ...*Client) { + for _, c := range clients { + h.Register(c) + } + for _, c := range clients { + drain(c) + } +} + +func TestDeviceListReachesUseAndBothButNotShare(t *testing.T) { + h := NewHub() + + sharer := newTestClient("sharer", "grp", protocol.ModeShare) + user := newTestClient("user", "grp", protocol.ModeUse) + both := newTestClient("both", "grp", protocol.ModeBoth) + otherSharer := newTestClient("sharer2", "grp", protocol.ModeShare) + + registerAll(h, sharer, user, both, otherSharer) + + list, _ := json.Marshal(&protocol.DeviceList{ + Type: protocol.MsgDeviceList, + ClientID: sharer.ID, + Devices: []protocol.USBDevice{{BusID: "1-1"}}, + }) + h.HandleTextMessage(sharer, list) + + if got := countType(t, user, protocol.MsgDeviceList); got != 1 { + t.Errorf("use client received %d device lists, want 1", got) + } + if got := countType(t, both, protocol.MsgDeviceList); got != 1 { + t.Errorf("both client received %d device lists, want 1", got) + } + if got := countType(t, otherSharer, protocol.MsgDeviceList); got != 0 { + t.Errorf("share-only client received %d device lists, want 0", got) + } + if got := countType(t, sharer, protocol.MsgDeviceList); got != 0 { + t.Errorf("sender received %d copies of its own list, want 0", got) + } +} + +// A "both" client must be able to offer devices, which means its device list +// has to be routed like any share client's. +func TestBothClientCanShare(t *testing.T) { + h := NewHub() + + both := newTestClient("both", "grp", protocol.ModeBoth) + user := newTestClient("user", "grp", protocol.ModeUse) + registerAll(h, both, user) + + list, _ := json.Marshal(&protocol.DeviceList{ + Type: protocol.MsgDeviceList, + ClientID: both.ID, + Devices: []protocol.USBDevice{{BusID: "2-1"}}, + }) + h.HandleTextMessage(both, list) + + if got := countType(t, user, protocol.MsgDeviceList); got != 1 { + t.Fatalf("use client received %d lists from a both-mode sharer, want 1", got) + } +} + +func TestRequestDeviceReachesShareCapableTargetsOnly(t *testing.T) { + h := NewHub() + + requester := newTestClient("req", "grp", protocol.ModeUse) + sharer := newTestClient("sharer", "grp", protocol.ModeShare) + useOnly := newTestClient("useonly", "grp", protocol.ModeUse) + + registerAll(h, requester, sharer, useOnly) + + req, _ := json.Marshal(&protocol.RequestDevice{ + Type: protocol.MsgRequestDevice, + TargetClient: sharer.ID, + BusID: "1-1", + RequestID: "r1", + }) + h.HandleTextMessage(requester, req) + + var msgs []outMsg + for _, m := range drain(sharer) { + if typeOf(t, m) == protocol.MsgRequestDevice { + msgs = append(msgs, m) + } + } + if len(msgs) != 1 { + t.Fatalf("share client received %d requests, want 1", len(msgs)) + } + + // The relay must stamp in who is asking; the share side needs it to reply. + var got map[string]interface{} + json.Unmarshal(msgs[0].data, &got) + if got["from_client"] != requester.ID { + t.Errorf("from_client = %v, want %q", got["from_client"], requester.ID) + } + + // A use-only client is not a valid target. + req2, _ := json.Marshal(&protocol.RequestDevice{ + Type: protocol.MsgRequestDevice, + TargetClient: useOnly.ID, + BusID: "1-1", + RequestID: "r2", + }) + h.HandleTextMessage(requester, req2) + + if got := countType(t, useOnly, protocol.MsgRequestDevice); got != 0 { + t.Errorf("use-only client received %d device requests, want 0", got) + } +} + +func TestGroupsAreIsolatedByHash(t *testing.T) { + h := NewHub() + + a := newTestClient("a", "hash-a", protocol.ModeShare) + b := newTestClient("b", "hash-b", protocol.ModeUse) + registerAll(h, a, b) + + list, _ := json.Marshal(&protocol.DeviceList{ + Type: protocol.MsgDeviceList, ClientID: a.ID, + }) + h.HandleTextMessage(a, list) + + if got := len(drain(b)); got != 0 { + t.Errorf("client in another hash group received %d messages, want 0", got) + } +} + +func TestTunnelForwardsBothWays(t *testing.T) { + h := NewHub() + + sharer := newTestClient("sharer", "grp", protocol.ModeShare) + user := newTestClient("user", "grp", protocol.ModeUse) + registerAll(h, sharer, user) + + tunnelID := "0123456789abcdef" // exactly TunnelHeaderSize + granted, _ := json.Marshal(map[string]interface{}{ + "type": protocol.MsgDeviceGranted, + "bus_id": "1-1", + "tunnel_id": tunnelID, + "request_id": "r1", + "target_client": user.ID, + }) + h.HandleTextMessage(sharer, granted) + + if msgs := drain(user); len(msgs) != 1 || typeOf(t, msgs[0]) != protocol.MsgDeviceGranted { + t.Fatalf("grant was not forwarded to the use client: %v", msgs) + } + + // use -> share + frame := append([]byte(tunnelID), 0xAA, 0xBB) + h.HandleBinaryMessage(user, frame) + msgs := drain(sharer) + if len(msgs) != 1 { + t.Fatalf("share client received %d tunnel frames, want 1", len(msgs)) + } + if msgs[0].typ != websocket.BinaryMessage { + t.Errorf("tunnel frame sent as type %d, want binary", msgs[0].typ) + } + + // share -> use + h.HandleBinaryMessage(sharer, frame) + if got := len(drain(user)); got != 1 { + t.Errorf("use client received %d tunnel frames, want 1", got) + } +} + +func TestTunnelFramesForUnknownTunnelAreDropped(t *testing.T) { + h := NewHub() + + a := newTestClient("a", "grp", protocol.ModeShare) + b := newTestClient("b", "grp", protocol.ModeUse) + registerAll(h, a, b) + + h.HandleBinaryMessage(a, append([]byte("nonexistenttunnl"), 0x01)) + + if got := len(drain(b)); got != 0 { + t.Errorf("frame for an unknown tunnel was forwarded (%d messages)", got) + } +} + +func TestUnregisterNotifiesPeersAndDropsTunnels(t *testing.T) { + h := NewHub() + + sharer := newTestClient("sharer", "grp", protocol.ModeShare) + user := newTestClient("user", "grp", protocol.ModeUse) + registerAll(h, sharer, user) + + tunnelID := "0123456789abcdef" + granted, _ := json.Marshal(map[string]interface{}{ + "type": protocol.MsgDeviceGranted, "bus_id": "1-1", + "tunnel_id": tunnelID, "target_client": user.ID, + }) + h.HandleTextMessage(sharer, granted) + drain(user) + + h.Unregister(sharer) + + msgs := drain(user) + if len(msgs) != 1 || typeOf(t, msgs[0]) != protocol.MsgClientLeft { + t.Fatalf("peer was not told about the disconnect: %v", msgs) + } + + h.mu.RLock() + _, stillThere := h.tunnels[tunnelID] + h.mu.RUnlock() + if stillThere { + t.Error("tunnel survived the share client leaving") + } +} + +// Registration must not panic on short or empty identifiers: the relay +// truncated hashes for logging, so a client with a 3-character hash used to +// take the whole server down. +func TestRegisterSurvivesShortIdentifiers(t *testing.T) { + h := NewHub() + + for _, c := range []*Client{ + newTestClient("", "", protocol.ModeUse), + newTestClient("x", "ab", protocol.ModeShare), + newTestClient("y", "abc", protocol.ModeBoth), + } { + h.Register(c) + h.Unregister(c) + } +} + +// A client that stops draining must be dropped rather than allowed to consume +// unbounded memory or block the peer producing the traffic. +func TestFullSendQueueDropsClient(t *testing.T) { + h := NewHub() + + sharer := newTestClient("sharer", "grp", protocol.ModeShare) + slow := newTestClient("slow", "grp", protocol.ModeUse) + registerAll(h, sharer, slow) + + list, _ := json.Marshal(&protocol.DeviceList{ + Type: protocol.MsgDeviceList, ClientID: sharer.ID, + }) + + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < sendQueueDepth+50; i++ { + h.HandleTextMessage(sharer, list) + } + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("routing blocked on a client that never reads") + } + + select { + case <-slow.dead: + default: + t.Error("client with a full queue was not dropped") + } +} + +// Reconnecting with the same ID must retire the stale entry, not leave two. +func TestReRegisterReplacesStaleClient(t *testing.T) { + h := NewHub() + + first := newTestClient("dup", "grp", protocol.ModeUse) + h.Register(first) + + second := newTestClient("dup", "grp", protocol.ModeUse) + h.Register(second) + + select { + case <-first.dead: + default: + t.Error("stale connection was not killed on re-registration") + } + + if got := h.GroupStats()[protocol.ShortID("grp")]; got != 1 { + t.Errorf("group holds %d clients, want 1", got) + } +} + +func TestValidModeAndCapabilities(t *testing.T) { + tests := []struct { + mode string + valid, canShare, canUse bool + }{ + {protocol.ModeShare, true, true, false}, + {protocol.ModeUse, true, false, true}, + {protocol.ModeBoth, true, true, true}, + {"", false, false, false}, + {"admin", false, false, false}, + } + + for _, tt := range tests { + if got := protocol.ValidMode(tt.mode); got != tt.valid { + t.Errorf("ValidMode(%q) = %v, want %v", tt.mode, got, tt.valid) + } + if got := protocol.CanShare(tt.mode); got != tt.canShare { + t.Errorf("CanShare(%q) = %v, want %v", tt.mode, got, tt.canShare) + } + if got := protocol.CanUse(tt.mode); got != tt.canUse { + t.Errorf("CanUse(%q) = %v, want %v", tt.mode, got, tt.canUse) + } + } +} + +// The relay is the only party that knows a client's public address, so it +// must add it to a grant. Without this, two peers behind NAT could never find +// each other and every tunnel would stay relayed. +func TestGrantGetsPublicEndpointAppended(t *testing.T) { + h := NewHub() + + sharer := newTestClient("sharer", "grp", protocol.ModeShare) + sharer.DirectPort = 41000 + sharer.PublicIP = "203.0.113.7" + user := newTestClient("user", "grp", protocol.ModeUse) + registerAll(h, sharer, user) + + granted, _ := json.Marshal(map[string]interface{}{ + "type": protocol.MsgDeviceGranted, + "bus_id": "1-1", + "tunnel_id": "0123456789abcdef", + "target_client": user.ID, + "endpoints": []string{"192.168.1.5:41000"}, + "encrypted": true, + }) + h.HandleTextMessage(sharer, granted) + + msgs := drain(user) + if len(msgs) != 1 { + t.Fatalf("use client received %d messages, want 1", len(msgs)) + } + + var got protocol.DeviceGranted + if err := json.Unmarshal(msgs[0].data, &got); err != nil { + t.Fatalf("decoding forwarded grant: %v", err) + } + + want := "203.0.113.7:41000" + var found, keptLocal bool + for _, ep := range got.Endpoints { + if ep == want { + found = true + } + if ep == "192.168.1.5:41000" { + keptLocal = true + } + } + if !found { + t.Errorf("endpoints %v do not include the public address %q", got.Endpoints, want) + } + if !keptLocal { + t.Errorf("endpoints %v lost the sharer's own local address", got.Endpoints) + } + if !got.Encrypted { + t.Error("the encrypted flag did not survive re-encoding") + } +} + +// A client that accepts no direct connections must not have a bogus endpoint +// invented for it. +func TestGrantWithoutDirectPortIsUnchanged(t *testing.T) { + h := NewHub() + + sharer := newTestClient("sharer", "grp", protocol.ModeShare) + sharer.PublicIP = "203.0.113.7" // reachable, but no listener + user := newTestClient("user", "grp", protocol.ModeUse) + registerAll(h, sharer, user) + + granted, _ := json.Marshal(map[string]interface{}{ + "type": protocol.MsgDeviceGranted, "bus_id": "1-1", + "tunnel_id": "0123456789abcdef", "target_client": user.ID, + }) + h.HandleTextMessage(sharer, granted) + + msgs := drain(user) + if len(msgs) != 1 { + t.Fatalf("use client received %d messages, want 1", len(msgs)) + } + + var got protocol.DeviceGranted + json.Unmarshal(msgs[0].data, &got) + if len(got.Endpoints) != 0 { + t.Errorf("endpoints = %v, want none for a client with no direct port", got.Endpoints) + } +} + +func TestPublicEndpointRequiresBothParts(t *testing.T) { + tests := []struct { + name string + port int + ip string + want string + }{ + {"both present", 41000, "203.0.113.7", "203.0.113.7:41000"}, + {"no port", 0, "203.0.113.7", ""}, + {"no ip", 41000, "", ""}, + {"neither", 0, "", ""}, + {"ipv6", 41000, "2001:db8::1", "[2001:db8::1]:41000"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{DirectPort: tt.port, PublicIP: tt.ip} + if got := publicEndpoint(c); got != tt.want { + t.Errorf("publicEndpoint() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/relay/server.go b/internal/relay/server.go index d898a8d..fa5e51a 100644 --- a/internal/relay/server.go +++ b/internal/relay/server.go @@ -3,13 +3,32 @@ package relay import ( "encoding/json" "log" + "net" "net/http" + "strings" "time" "github.com/duffy/usb-server/internal/protocol" "github.com/gorilla/websocket" ) +const ( + // readTimeout is how long a client may stay silent before we drop it. + // It must exceed pingInterval so that keepalive pongs refresh it. + readTimeout = 60 * time.Second + + // pingInterval is how often the relay pings each client. + pingInterval = 20 * time.Second + + // writeTimeout bounds a single frame write. Without it, a peer that has + // stopped reading would pin its write pump forever. + writeTimeout = 20 * time.Second + + // maxMessageSize caps an inbound frame. Tunnel frames are at most 64 KB + // of USB payload plus the tunnel header; 1 MB leaves ample headroom. + maxMessageSize = 1024 * 1024 +) + var upgrader = websocket.Upgrader{ ReadBufferSize: 64 * 1024, WriteBufferSize: 64 * 1024, @@ -22,6 +41,7 @@ var upgrader = websocket.Upgrader{ type Server struct { hub *Hub addr string + diag *diagStore } // NewServer creates a new relay server @@ -29,6 +49,7 @@ func NewServer(addr string) *Server { return &Server{ hub: NewHub(), addr: addr, + diag: newDiagStore(), } } @@ -37,9 +58,21 @@ func (s *Server) Run() error { mux := http.NewServeMux() mux.HandleFunc("/ws", s.handleWebSocket) mux.HandleFunc("/health", s.handleHealth) + mux.HandleFunc("/diag/", s.handleDiag) + + // Timeouts bound how long a stuck client can hold a connection. The + // WebSocket route needs no write timeout — those connections are + // long-lived by design — so it is left to the per-message deadlines the + // write pump sets. + server := &http.Server{ + Addr: s.addr, + Handler: mux, + ReadHeaderTimeout: 15 * time.Second, + IdleTimeout: 120 * time.Second, + } log.Printf("[relay] starting on %s", s.addr) - return http.ListenAndServe(s.addr, mux) + return server.ListenAndServe() } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { @@ -57,10 +90,10 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { defer conn.Close() // Set read limits and deadlines - conn.SetReadLimit(1024 * 1024) // 1MB max message - conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + conn.SetReadLimit(maxMessageSize) + conn.SetReadDeadline(time.Now().Add(readTimeout)) conn.SetPongHandler(func(string) error { - conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + conn.SetReadDeadline(time.Now().Add(readTimeout)) return nil }) @@ -78,55 +111,34 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { return } - if reg.Hash == "" || reg.ClientID == "" || (reg.Mode != protocol.ModeShare && reg.Mode != protocol.ModeUse) { - conn.WriteJSON(&protocol.ErrorMsg{Type: protocol.MsgError, Message: "missing required fields"}) + if reg.Hash == "" || reg.ClientID == "" || !protocol.ValidMode(reg.Mode) { + conn.WriteJSON(&protocol.ErrorMsg{Type: protocol.MsgError, Message: "missing or invalid registration fields"}) return } - client := &Client{ - ID: reg.ClientID, - Hash: reg.Hash, - Mode: reg.Mode, - Name: reg.Name, - Conn: conn, - Send: make(chan []byte, 256), - } + client := newClient(reg.ClientID, reg.Hash, reg.Mode, reg.Name, conn) + client.DirectPort = reg.DirectPort + client.PublicIP = clientIP(r) s.hub.Register(client) defer s.hub.Unregister(client) - // Start ping ticker - done := make(chan struct{}) - go func() { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - for { - select { - case <-ticker.C: - client.mu.Lock() - err := conn.WriteMessage(websocket.PingMessage, nil) - client.mu.Unlock() - if err != nil { - return - } - case <-done: - return - } - } - }() - defer close(done) + // The write pump owns the socket's write side: every frame for this + // client, plus keepalive pings, goes through it. Nothing else may write, + // which is what keeps one unresponsive peer from blocking the hub. + go s.writePump(client) // Read loop for { msgType, data, err := conn.ReadMessage() if err != nil { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { - log.Printf("[relay] read error from %s: %v", client.ID, err) + log.Printf("[relay] read error from %s: %v", protocol.ShortID(client.ID), err) } break } - conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + conn.SetReadDeadline(time.Now().Add(readTimeout)) switch msgType { case websocket.TextMessage: @@ -135,4 +147,67 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { s.hub.HandleBinaryMessage(client, data) } } + + client.kill() +} + +// clientIP determines the address a client connects from, which is passed on +// to its peers so they can reach it directly. +// +// X-Forwarded-For is honoured because relays are commonly deployed behind a +// reverse proxy, where RemoteAddr would otherwise be the proxy itself. Only +// the first entry is used: later ones are supplied by upstream hops and are +// not trustworthy. A wrong value here costs a failed direct attempt and a +// fallback to relaying, never a security property — the peer still has to +// prove group membership in the handshake. +func clientIP(r *http.Request) string { + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + first := strings.TrimSpace(strings.Split(fwd, ",")[0]) + if ip := net.ParseIP(first); ip != nil { + return ip.String() + } + } + if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" { + if ip := net.ParseIP(real); ip != nil { + return ip.String() + } + } + + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return "" + } + if ip := net.ParseIP(host); ip != nil { + return ip.String() + } + return "" +} + +// writePump serialises all writes to one client's socket. +func (s *Server) writePump(client *Client) { + ticker := time.NewTicker(pingInterval) + defer ticker.Stop() + defer client.Conn.Close() // unblocks the read loop when we give up + + for { + select { + case msg := <-client.Send: + client.Conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + if err := client.Conn.WriteMessage(msg.typ, msg.data); err != nil { + log.Printf("[relay] write error to %s: %v", protocol.ShortID(client.ID), err) + client.kill() + return + } + + case <-ticker.C: + client.Conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + if err := client.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { + client.kill() + return + } + + case <-client.dead: + return + } + } } diff --git a/internal/service/service_other.go b/internal/service/service_other.go new file mode 100644 index 0000000..4a24354 --- /dev/null +++ b/internal/service/service_other.go @@ -0,0 +1,26 @@ +//go:build !linux && !windows + +package service + +import "fmt" + +// Service installation is only implemented for systemd and Windows services. +// On other platforms the client still runs normally, it just has to be +// started by whatever init system is in use. +// +// This file exists so the client compiles for those targets at all: without +// it the whole binary is unbuildable there, even though nothing but service +// installation is actually missing. + +func Install(mode, configPath string) error { + return fmt.Errorf("service installation is not implemented on this platform; " + + "start the client from your init system instead") +} + +func Uninstall() error { + return fmt.Errorf("service installation is not implemented on this platform") +} + +func Status() (string, error) { + return "", fmt.Errorf("service status is not available on this platform") +} diff --git a/internal/usb/adopt_linux.go b/internal/usb/adopt_linux.go new file mode 100644 index 0000000..f39781d --- /dev/null +++ b/internal/usb/adopt_linux.go @@ -0,0 +1,83 @@ +//go:build linux + +package usb + +import ( + "fmt" + "sync" + + "golang.org/x/sys/unix" +) + +// Externally supplied file descriptors, keyed by bus ID. +// +// Android is the reason this exists. Apps there cannot open /dev/bus/usb: +// access goes through the framework, which shows a permission dialog and +// returns an already-open descriptor. A small Java shim obtains it and passes +// it to this process, which then drives the device through the same usbdevfs +// ioctls as anywhere else — the kernel interface is identical, only the way +// the descriptor is obtained differs. +var ( + adoptedMu sync.Mutex + adoptedFDs = make(map[string]int) +) + +// AdoptDeviceFD registers an already-open usbdevfs file descriptor for a bus +// ID. The next OpenDevice for that bus ID takes it instead of opening a path. +// +// Ownership transfers: the descriptor is closed when the resulting handle is +// closed, or by ReleaseAdoptedFDs if it is never claimed. +func AdoptDeviceFD(busID string, fd int) error { + if busID == "" { + return fmt.Errorf("bus ID is required") + } + if fd < 0 { + return fmt.Errorf("invalid file descriptor %d", fd) + } + + // Reject a descriptor that is not actually usable, so the failure is + // reported here rather than as a confusing ioctl error much later. + if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); err != nil { + return fmt.Errorf("file descriptor %d is not open: %w", fd, err) + } + + adoptedMu.Lock() + defer adoptedMu.Unlock() + + if old, exists := adoptedFDs[busID]; exists && old != fd { + unix.Close(old) + } + adoptedFDs[busID] = fd + return nil +} + +// takeAdoptedFD removes and returns a registered descriptor, if any. +func takeAdoptedFD(busID string) (int, bool) { + adoptedMu.Lock() + defer adoptedMu.Unlock() + + fd, ok := adoptedFDs[busID] + if ok { + delete(adoptedFDs, busID) + } + return fd, ok +} + +// HasAdoptedFD reports whether a descriptor is registered for a bus ID. +func HasAdoptedFD(busID string) bool { + adoptedMu.Lock() + defer adoptedMu.Unlock() + _, ok := adoptedFDs[busID] + return ok +} + +// ReleaseAdoptedFDs closes every registered descriptor that was never claimed. +func ReleaseAdoptedFDs() { + adoptedMu.Lock() + defer adoptedMu.Unlock() + + for busID, fd := range adoptedFDs { + unix.Close(fd) + delete(adoptedFDs, busID) + } +} diff --git a/internal/usb/adopt_other.go b/internal/usb/adopt_other.go new file mode 100644 index 0000000..d88e893 --- /dev/null +++ b/internal/usb/adopt_other.go @@ -0,0 +1,18 @@ +//go:build !linux + +package usb + +import "fmt" + +// Adopting an external file descriptor only makes sense where devices are +// driven through usbdevfs, which is Linux-only. + +func AdoptDeviceFD(busID string, fd int) error { + return fmt.Errorf("adopting USB file descriptors is only supported on Linux") +} + +func takeAdoptedFD(busID string) (int, bool) { return 0, false } + +func HasAdoptedFD(busID string) bool { return false } + +func ReleaseAdoptedFDs() {} diff --git a/internal/usb/descriptors.go b/internal/usb/descriptors.go new file mode 100644 index 0000000..3d46177 --- /dev/null +++ b/internal/usb/descriptors.go @@ -0,0 +1,169 @@ +package usb + +import ( + "encoding/binary" + "fmt" +) + +// USB descriptor types +const ( + DescTypeDevice = 0x01 + DescTypeConfiguration = 0x02 + DescTypeInterface = 0x04 + DescTypeEndpoint = 0x05 +) + +// ParsedDescriptors holds everything we extract from a device's raw +// descriptor blob (device descriptor followed by all configuration +// descriptors, as returned by reading a usbdevfs device file). +type ParsedDescriptors struct { + VendorID uint16 + ProductID uint16 + BcdDevice uint16 + DeviceClass uint8 + DeviceSubClass uint8 + DeviceProtocol uint8 + NumConfigs uint8 + + // Configs holds every configuration, each with every interface + // alternate setting and its endpoints. + Configs []ConfigDescriptor +} + +// ConfigDescriptor is one USB configuration +type ConfigDescriptor struct { + Value uint8 // bConfigurationValue + Interfaces []Interface // every alternate setting, in descriptor order +} + +// ParseDescriptors parses a raw descriptor blob: an 18-byte device +// descriptor followed by one or more complete configuration descriptors. +// +// Reading a usbdevfs file (/dev/bus/usb/BBB/DDD) from offset 0 yields +// exactly this layout, which is the only way to see interface alternate +// settings — sysfs only exposes the currently active one. +func ParseDescriptors(data []byte) (*ParsedDescriptors, error) { + if len(data) < 18 { + return nil, fmt.Errorf("descriptor blob too short: %d bytes", len(data)) + } + if data[1] != DescTypeDevice { + return nil, fmt.Errorf("first descriptor is type 0x%02x, expected device (0x01)", data[1]) + } + + pd := &ParsedDescriptors{ + DeviceClass: data[4], + DeviceSubClass: data[5], + DeviceProtocol: data[6], + VendorID: binary.LittleEndian.Uint16(data[8:10]), + ProductID: binary.LittleEndian.Uint16(data[10:12]), + BcdDevice: binary.LittleEndian.Uint16(data[12:14]), + NumConfigs: data[17], + } + + // Walk the remaining descriptors. Configuration descriptors start a new + // config; interface descriptors start a new alternate setting; endpoint + // descriptors attach to the most recent interface. Class-specific + // descriptors (HID, UVC, audio) are skipped by their bLength. + pos := int(data[0]) // skip the device descriptor using its own bLength + if pos < 18 { + pos = 18 + } + + var curConfig *ConfigDescriptor + var curIface *Interface + + for pos+2 <= len(data) { + bLength := int(data[pos]) + bType := data[pos+1] + + // A zero-length descriptor would loop forever; a descriptor running + // past the end of the blob means the device returned garbage. + if bLength < 2 || pos+bLength > len(data) { + break + } + + switch bType { + case DescTypeConfiguration: + if bLength >= 9 { + pd.Configs = append(pd.Configs, ConfigDescriptor{Value: data[pos+5]}) + curConfig = &pd.Configs[len(pd.Configs)-1] + curIface = nil + } + + case DescTypeInterface: + if bLength >= 9 && curConfig != nil { + curConfig.Interfaces = append(curConfig.Interfaces, Interface{ + Number: data[pos+2], + AltSetting: data[pos+3], + Class: data[pos+5], + SubClass: data[pos+6], + Protocol: data[pos+7], + }) + curIface = &curConfig.Interfaces[len(curConfig.Interfaces)-1] + } + + case DescTypeEndpoint: + if bLength >= 7 && curIface != nil { + curIface.Endpoints = append(curIface.Endpoints, Endpoint{ + Address: data[pos+2], + TransferType: data[pos+3] & 0x03, + MaxPacketSize: binary.LittleEndian.Uint16(data[pos+4 : pos+6]), + Interval: data[pos+6], + }) + } + } + + pos += bLength + } + + if len(pd.Configs) == 0 { + return nil, fmt.Errorf("no configuration descriptor found") + } + + return pd, nil +} + +// FindConfig returns the configuration with the given bConfigurationValue, +// or nil if the device has no such configuration. +func (pd *ParsedDescriptors) FindConfig(value uint8) *ConfigDescriptor { + for i := range pd.Configs { + if pd.Configs[i].Value == value { + return &pd.Configs[i] + } + } + return nil +} + +// AllEndpoints returns every endpoint across every alternate setting of the +// given configuration, keyed by full bEndpointAddress (direction bit +// included). Endpoints only present in a non-zero alternate setting — the +// isochronous endpoints of webcams, for example — are included, which is +// what makes the endpoint type map correct after a SET_INTERFACE. +func (c *ConfigDescriptor) AllEndpoints() map[uint8]Endpoint { + eps := make(map[uint8]Endpoint) + for _, iface := range c.Interfaces { + for _, ep := range iface.Endpoints { + // Alternate settings reuse addresses with identical transfer + // types in practice; keep the first one we see so alt 0 wins. + if _, seen := eps[ep.Address]; !seen { + eps[ep.Address] = ep + } + } + } + return eps +} + +// ActiveInterfaces returns one Interface per interface number, using +// alternate setting 0 — the set of interfaces that must be claimed. +func (c *ConfigDescriptor) ActiveInterfaces() []Interface { + var result []Interface + seen := make(map[uint8]bool) + for _, iface := range c.Interfaces { + if iface.AltSetting != 0 || seen[iface.Number] { + continue + } + seen[iface.Number] = true + result = append(result, iface) + } + return result +} diff --git a/internal/usb/descriptors_test.go b/internal/usb/descriptors_test.go new file mode 100644 index 0000000..185163e --- /dev/null +++ b/internal/usb/descriptors_test.go @@ -0,0 +1,238 @@ +package usb + +import "testing" + +// buildDescriptorBlob assembles a device descriptor followed by raw +// configuration bytes, the way a usbdevfs file read returns them. +func buildDescriptorBlob(numConfigs uint8, configs ...[]byte) []byte { + dev := []byte{ + 18, // bLength + 0x01, // bDescriptorType = DEVICE + 0x00, 0x02, // bcdUSB 2.00 + 0x00, // bDeviceClass (per-interface) + 0x00, // bDeviceSubClass + 0x00, // bDeviceProtocol + 64, // bMaxPacketSize0 + 0x6d, 0x04, // idVendor 046d + 0x1c, 0xc0, // idProduct c01c + 0x10, 0x02, // bcdDevice 0210 + 1, 2, 3, // string indices + numConfigs, + } + blob := dev + for _, c := range configs { + blob = append(blob, c...) + } + return blob +} + +func ifaceDesc(number, alt, numEndpoints, class, subclass, protocol uint8) []byte { + return []byte{9, 0x04, number, alt, numEndpoints, class, subclass, protocol, 0} +} + +func endpointDesc(addr, attrs uint8, maxPacket uint16, interval uint8) []byte { + return []byte{7, 0x05, addr, attrs, byte(maxPacket), byte(maxPacket >> 8), interval} +} + +func configDesc(value uint8, body []byte) []byte { + total := 9 + len(body) + cfg := []byte{9, 0x02, byte(total), byte(total >> 8), 1, value, 0, 0x80, 250} + return append(cfg, body...) +} + +func TestParseDescriptorsDeviceFields(t *testing.T) { + blob := buildDescriptorBlob(1, configDesc(1, ifaceDesc(0, 0, 0, 3, 1, 1))) + + pd, err := ParseDescriptors(blob) + if err != nil { + t.Fatalf("ParseDescriptors: %v", err) + } + + if pd.VendorID != 0x046d { + t.Errorf("VendorID = %04x, want 046d", pd.VendorID) + } + if pd.ProductID != 0xc01c { + t.Errorf("ProductID = %04x, want c01c", pd.ProductID) + } + if pd.BcdDevice != 0x0210 { + t.Errorf("BcdDevice = %04x, want 0210", pd.BcdDevice) + } + if pd.NumConfigs != 1 { + t.Errorf("NumConfigs = %d, want 1", pd.NumConfigs) + } + if len(pd.Configs) != 1 { + t.Fatalf("got %d configs, want 1", len(pd.Configs)) + } +} + +// A composite device where endpoint number 1 appears twice with different +// directions and different transfer types. Keying the endpoint map by number +// alone collapses these two into one, which is what made the server submit +// interrupt URBs with the bulk type and broke HID devices. +func TestAllEndpointsKeepsDirectionsSeparate(t *testing.T) { + body := ifaceDesc(0, 0, 2, 0x08, 0x06, 0x50) // mass storage + body = append(body, endpointDesc(0x01, 0x02, 512, 0)...) // bulk OUT, EP1 + body = append(body, endpointDesc(0x82, 0x02, 512, 0)...) // bulk IN, EP2 + body = append(body, ifaceDesc(1, 0, 1, 0x03, 0x01, 0x01)...) // HID keyboard + body = append(body, endpointDesc(0x81, 0x03, 8, 10)...) // interrupt IN, EP1 + + blob := buildDescriptorBlob(1, configDesc(1, body)) + pd, err := ParseDescriptors(blob) + if err != nil { + t.Fatalf("ParseDescriptors: %v", err) + } + + eps := pd.Configs[0].AllEndpoints() + if len(eps) != 3 { + t.Fatalf("got %d endpoints, want 3: %+v", len(eps), eps) + } + + if got := eps[0x01].TransferType; got != TransferTypeBulk { + t.Errorf("EP 0x01 type = %d, want bulk (%d)", got, TransferTypeBulk) + } + if got := eps[0x81].TransferType; got != TransferTypeInterrupt { + t.Errorf("EP 0x81 type = %d, want interrupt (%d) — direction bit must not collapse", got, TransferTypeInterrupt) + } + if got := eps[0x81].Interval; got != 10 { + t.Errorf("EP 0x81 interval = %d, want 10", got) + } + if got := eps[0x82].MaxPacketSize; got != 512 { + t.Errorf("EP 0x82 maxpacket = %d, want 512", got) + } +} + +// A webcam's isochronous endpoints only exist in a non-zero alternate +// setting. sysfs shows only the active setting, so an endpoint map built from +// it would classify these as bulk after a SET_INTERFACE. +func TestAllEndpointsIncludesAlternateSettings(t *testing.T) { + body := ifaceDesc(1, 0, 0, 0x0e, 0x02, 0x00) // video streaming, alt 0: no endpoints + body = append(body, ifaceDesc(1, 1, 1, 0x0e, 0x02, 0x00)...) + body = append(body, endpointDesc(0x81, 0x05, 1024, 1)...) // isochronous IN + body = append(body, ifaceDesc(1, 2, 1, 0x0e, 0x02, 0x00)...) + body = append(body, endpointDesc(0x81, 0x05, 2048, 1)...) + + blob := buildDescriptorBlob(1, configDesc(1, body)) + pd, err := ParseDescriptors(blob) + if err != nil { + t.Fatalf("ParseDescriptors: %v", err) + } + + cfg := pd.Configs[0] + if len(cfg.Interfaces) != 3 { + t.Fatalf("got %d interface descriptors, want 3 (alt 0,1,2)", len(cfg.Interfaces)) + } + + eps := cfg.AllEndpoints() + ep, ok := eps[0x81] + if !ok { + t.Fatal("EP 0x81 missing — endpoints from non-zero alternate settings were dropped") + } + if ep.TransferType != TransferTypeIsochronous { + t.Errorf("EP 0x81 type = %d, want isochronous (%d)", ep.TransferType, TransferTypeIsochronous) + } +} + +func TestActiveInterfacesOnlyAltZero(t *testing.T) { + body := ifaceDesc(0, 0, 0, 0x01, 0x01, 0x00) + body = append(body, ifaceDesc(1, 0, 0, 0x01, 0x02, 0x00)...) + body = append(body, ifaceDesc(1, 1, 1, 0x01, 0x02, 0x00)...) + body = append(body, endpointDesc(0x81, 0x05, 192, 1)...) + + blob := buildDescriptorBlob(1, configDesc(1, body)) + pd, _ := ParseDescriptors(blob) + + active := pd.Configs[0].ActiveInterfaces() + if len(active) != 2 { + t.Fatalf("got %d active interfaces, want 2 (one per interface number)", len(active)) + } + for _, iface := range active { + if iface.AltSetting != 0 { + t.Errorf("interface %d has alt setting %d, want 0", iface.Number, iface.AltSetting) + } + } +} + +// Class-specific descriptors (HID, UVC, audio) sit between the standard ones +// and must be skipped by bLength rather than confusing the walk. +func TestParseDescriptorsSkipsClassSpecific(t *testing.T) { + hidDesc := []byte{9, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, 0x3f, 0x00} + + body := ifaceDesc(0, 0, 1, 0x03, 0x01, 0x01) + body = append(body, hidDesc...) + body = append(body, endpointDesc(0x81, 0x03, 8, 10)...) + + blob := buildDescriptorBlob(1, configDesc(1, body)) + pd, err := ParseDescriptors(blob) + if err != nil { + t.Fatalf("ParseDescriptors: %v", err) + } + + eps := pd.Configs[0].AllEndpoints() + if _, ok := eps[0x81]; !ok { + t.Fatal("endpoint after a HID descriptor was not parsed") + } + if len(pd.Configs[0].Interfaces[0].Endpoints) != 1 { + t.Errorf("got %d endpoints on the interface, want 1", + len(pd.Configs[0].Interfaces[0].Endpoints)) + } +} + +func TestFindConfigSelectsByValue(t *testing.T) { + blob := buildDescriptorBlob(2, + configDesc(1, ifaceDesc(0, 0, 0, 0x03, 0, 0)), + configDesc(2, ifaceDesc(0, 0, 0, 0x08, 0, 0)), + ) + pd, err := ParseDescriptors(blob) + if err != nil { + t.Fatalf("ParseDescriptors: %v", err) + } + + cfg := pd.FindConfig(2) + if cfg == nil { + t.Fatal("FindConfig(2) returned nil") + } + if cfg.Interfaces[0].Class != 0x08 { + t.Errorf("got interface class %02x, want 08 — wrong configuration selected", cfg.Interfaces[0].Class) + } + if pd.FindConfig(9) != nil { + t.Error("FindConfig(9) should return nil for a configuration that does not exist") + } +} + +func TestParseDescriptorsRejectsGarbage(t *testing.T) { + tests := []struct { + name string + data []byte + }{ + {"empty", nil}, + {"too short", []byte{18, 0x01, 0x00}}, + {"not a device descriptor", append([]byte{9, 0x02}, make([]byte, 20)...)}, + {"no configuration", buildDescriptorBlob(1)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := ParseDescriptors(tt.data); err == nil { + t.Error("expected an error, got nil") + } + }) + } +} + +// A truncated or zero-length descriptor must terminate the walk instead of +// looping forever or reading past the buffer. +func TestParseDescriptorsHandlesTruncation(t *testing.T) { + blob := buildDescriptorBlob(1, configDesc(1, ifaceDesc(0, 0, 1, 3, 1, 1))) + blob = append(blob, 0x00, 0x05) // zero bLength would spin forever + blob = append(blob, 9, 0x04) // interface descriptor claiming 9 bytes, only 2 present + + done := make(chan struct{}) + go func() { + defer close(done) + if _, err := ParseDescriptors(blob); err != nil { + t.Errorf("unexpected error: %v", err) + } + }() + + <-done +} diff --git a/internal/usb/device.go b/internal/usb/device.go index 8ca397c..7fa65aa 100644 --- a/internal/usb/device.go +++ b/internal/usb/device.go @@ -2,43 +2,60 @@ package usb // Device represents a USB device type Device struct { - BusID string `json:"bus_id"` // e.g. "1-1.4" - BusNum uint32 `json:"bus_num"` - DevNum uint32 `json:"dev_num"` - Speed uint32 `json:"speed"` - VendorID uint16 `json:"vendor_id"` - ProductID uint16 `json:"product_id"` - BcdDevice uint16 `json:"bcd_device"` - DeviceClass uint8 `json:"device_class"` - DeviceSubClass uint8 `json:"device_sub_class"` - DeviceProtocol uint8 `json:"device_protocol"` - ConfigValue uint8 `json:"config_value"` - NumConfigs uint8 `json:"num_configs"` - Manufacturer string `json:"manufacturer"` - Product string `json:"product"` - Serial string `json:"serial"` - SysPath string `json:"sys_path"` // sysfs path - DevPath string `json:"dev_path"` // /dev/bus/usb path - Interfaces []Interface `json:"interfaces"` + BusID string `json:"bus_id"` // e.g. "1-1.4" + BusNum uint32 `json:"bus_num"` + DevNum uint32 `json:"dev_num"` + Speed uint32 `json:"speed"` + VendorID uint16 `json:"vendor_id"` + ProductID uint16 `json:"product_id"` + BcdDevice uint16 `json:"bcd_device"` + DeviceClass uint8 `json:"device_class"` + DeviceSubClass uint8 `json:"device_sub_class"` + DeviceProtocol uint8 `json:"device_protocol"` + ConfigValue uint8 `json:"config_value"` + NumConfigs uint8 `json:"num_configs"` + Manufacturer string `json:"manufacturer"` + Product string `json:"product"` + Serial string `json:"serial"` + SysPath string `json:"sys_path"` // sysfs path + DevPath string `json:"dev_path"` // /dev/bus/usb path + + // Interfaces holds one entry per interface number at alternate setting 0. + // These are the interfaces that get claimed when sharing the device. + Interfaces []Interface `json:"interfaces"` + + // Endpoints holds every endpoint of the active configuration across all + // alternate settings, keyed by full bEndpointAddress (direction bit + // included). Endpoints that only exist in a non-zero alternate setting + // are included, so the transfer type stays correct after SET_INTERFACE. + Endpoints map[uint8]Endpoint `json:"endpoints"` } -// Interface represents a USB interface +// Interface represents a USB interface at one alternate setting type Interface struct { - Number uint8 `json:"number"` - Class uint8 `json:"class"` - SubClass uint8 `json:"sub_class"` - Protocol uint8 `json:"protocol"` - Driver string `json:"driver"` - Endpoints []Endpoint `json:"endpoints"` + Number uint8 `json:"number"` + AltSetting uint8 `json:"alt_setting"` + Class uint8 `json:"class"` + SubClass uint8 `json:"sub_class"` + Protocol uint8 `json:"protocol"` + Driver string `json:"driver"` + Endpoints []Endpoint `json:"endpoints"` } // Endpoint represents a USB endpoint type Endpoint struct { - Address uint8 `json:"address"` // bEndpointAddress (bit 7=direction, bits 3:0=number) - TransferType uint8 `json:"transfer_type"` // 0=control, 1=iso, 2=bulk, 3=interrupt + Address uint8 `json:"address"` // bEndpointAddress (bit 7=direction, bits 3:0=number) + TransferType uint8 `json:"transfer_type"` // 0=control, 1=iso, 2=bulk, 3=interrupt MaxPacketSize uint16 `json:"max_packet_size"` + Interval uint8 `json:"interval"` // bInterval } +// IsIn reports whether this is an IN (device-to-host) endpoint. +func (e Endpoint) IsIn() bool { return e.Address&0x80 != 0 } + +// Number returns the endpoint number without the direction bit. +func (e Endpoint) Number() uint8 { return e.Address & 0x0F } + // USB transfer types (from bmAttributes) const ( TransferTypeControl = 0 diff --git a/internal/usb/driver_windows.go b/internal/usb/driver_windows.go new file mode 100644 index 0000000..72840fb --- /dev/null +++ b/internal/usb/driver_windows.go @@ -0,0 +1,320 @@ +//go:build windows + +package usb + +import ( + "encoding/binary" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Interface to the usbshare filter driver (driver/windows). +// +// The structure layouts and IOCTL codes here must match public.h exactly. +// They are marshalled by hand on both sides, so a mismatch corrupts memory +// rather than failing cleanly — change one, change the other. + +// GUID_DEVINTERFACE_USBSHARE from public.h. +var guidDevInterfaceUsbShare = windows.GUID{ + Data1: 0x8f3d2a14, + Data2: 0x6c7b, + Data3: 0x4e59, + Data4: [8]byte{0x9a, 0x1d, 0x3f, 0x5b, 0x7c, 0x8e, 0x2d, 0x40}, +} + +// IOCTL codes, mirroring the USBSHARE_IOCTL macro. +const ( + fileDeviceUsbShare = 0x8000 + methodBuffered = 0 + fileAnyAccess = 0 +) + +func usbShareIOCTL(index uint32) uint32 { + return (fileDeviceUsbShare << 16) | (fileAnyAccess << 14) | ((0x800 + index) << 2) | methodBuffered +} + +var ( + ioctlClaim = usbShareIOCTL(0) + ioctlRelease = usbShareIOCTL(1) + ioctlGetDescriptors = usbShareIOCTL(2) + ioctlSubmit = usbShareIOCTL(3) + ioctlCancel = usbShareIOCTL(4) + ioctlSetInterface = usbShareIOCTL(5) + ioctlClearHalt = usbShareIOCTL(6) + ioctlReset = usbShareIOCTL(7) +) + +// Transfer types, matching USBSHARE_TRANSFER_* in public.h. +const ( + winTransferControl = 0 + winTransferIsochronous = 1 + winTransferBulk = 2 + winTransferInterrupt = 3 +) + +// Directions, matching USBSHARE_DIR_*. +const ( + winDirOut = 0 + winDirIn = 1 +) + +// winDeviceInfo mirrors USBSHARE_DEVICE_INFO (packed). +type winDeviceInfo struct { + VendorID uint16 + ProductID uint16 + BcdDevice uint16 + DeviceClass uint8 + DeviceSubClass uint8 + DeviceProtocol uint8 + ConfigurationValue uint8 + NumConfigurations uint8 + Speed uint32 + PortNumber uint32 +} + +// winTransferHeader mirrors USBSHARE_TRANSFER (packed). +type winTransferHeader struct { + ID uint64 + EndpointAddress uint8 + Type uint8 + Direction uint8 + Reserved uint8 + BufferLength uint32 + Timeout uint32 + Setup [8]byte +} + +// winTransferResult mirrors USBSHARE_TRANSFER_RESULT (packed). +type winTransferResult struct { + ID uint64 + Status int32 + UsbdStatus uint32 + ActualLength uint32 +} + +const ( + winTransferHeaderSize = 8 + 1 + 1 + 1 + 1 + 4 + 4 + 8 // 28 + winTransferResultSize = 8 + 4 + 4 + 4 // 20 +) + +// DriverHandle is an open handle to a device claimed through the filter driver. +type DriverHandle struct { + handle windows.Handle + info winDeviceInfo + nextID uint64 +} + +// OpenDriverDevice opens the filter driver's interface for a device path and +// claims the device. +// +// Claiming stops the class driver from talking to the device, which is what +// lets us drive it — and it is released automatically if this process dies, +// because the driver ties the claim to the handle. +func OpenDriverDevice(devicePath string) (*DriverHandle, error) { + pathPtr, err := windows.UTF16PtrFromString(devicePath) + if err != nil { + return nil, fmt.Errorf("invalid device path: %w", err) + } + + handle, err := windows.CreateFile( + pathPtr, + windows.GENERIC_READ|windows.GENERIC_WRITE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL, + 0, + ) + if err != nil { + return nil, fmt.Errorf("opening %s: %w (is the usbshare driver installed?)", devicePath, err) + } + + h := &DriverHandle{handle: handle} + + if err := h.claim(); err != nil { + windows.CloseHandle(handle) + return nil, err + } + + return h, nil +} + +func (h *DriverHandle) claim() error { + out := make([]byte, unsafe.Sizeof(winDeviceInfo{})) + var returned uint32 + + err := windows.DeviceIoControl(h.handle, ioctlClaim, + nil, 0, + &out[0], uint32(len(out)), + &returned, nil) + if err != nil { + return fmt.Errorf("claiming device: %w", err) + } + + h.info = *(*winDeviceInfo)(unsafe.Pointer(&out[0])) + return nil +} + +// Close releases the device and closes the handle. +func (h *DriverHandle) Close() error { + var returned uint32 + windows.DeviceIoControl(h.handle, ioctlRelease, nil, 0, nil, 0, &returned, nil) + return windows.CloseHandle(h.handle) +} + +// Info returns the device information reported at claim time. +func (h *DriverHandle) Info() winDeviceInfo { return h.info } + +// Descriptors reads the raw descriptor blob: device descriptor followed by +// the configuration descriptors, the same layout Linux usbdevfs returns. It +// is parsed by the same code on both platforms. +func (h *DriverHandle) Descriptors() ([]byte, error) { + // Ask with a generous buffer first; grow if the driver reports more. + buf := make([]byte, 4096) + var returned uint32 + + err := windows.DeviceIoControl(h.handle, ioctlGetDescriptors, + nil, 0, &buf[0], uint32(len(buf)), &returned, nil) + + if err == windows.ERROR_INSUFFICIENT_BUFFER || err == windows.ERROR_MORE_DATA { + buf = make([]byte, returned) + err = windows.DeviceIoControl(h.handle, ioctlGetDescriptors, + nil, 0, &buf[0], uint32(len(buf)), &returned, nil) + } + if err != nil { + return nil, fmt.Errorf("reading descriptors: %w", err) + } + + return buf[:returned], nil +} + +// Transfer performs one USB transfer and blocks until it completes. +// +// For IN transfers data is the buffer to fill; for OUT transfers it holds the +// payload to send. The returned count is how many bytes actually moved, which +// matters for both directions. +func (h *DriverHandle) Transfer(params *TransferParams) (int, error) { + h.nextID++ + + header := winTransferHeader{ + ID: h.nextID, + EndpointAddress: params.EndpointAddress, + Type: params.Type, + Direction: params.Direction, + BufferLength: uint32(len(params.Data)), + Timeout: params.TimeoutMS, + Setup: params.Setup, + } + + // Input: header followed by the payload for OUT transfers. + input := make([]byte, winTransferHeaderSize+len(params.Data)) + marshalTransferHeader(input, &header) + if params.Direction == winDirOut && len(params.Data) > 0 { + copy(input[winTransferHeaderSize:], params.Data) + } + + // Output: result header followed by the payload for IN transfers. + output := make([]byte, winTransferResultSize+len(params.Data)) + var returned uint32 + + err := windows.DeviceIoControl(h.handle, ioctlSubmit, + &input[0], uint32(len(input)), + &output[0], uint32(len(output)), + &returned, nil) + if err != nil { + return 0, fmt.Errorf("submitting transfer: %w", err) + } + if returned < winTransferResultSize { + return 0, fmt.Errorf("driver returned %d bytes, expected at least %d", + returned, winTransferResultSize) + } + + result := unmarshalTransferResult(output) + if result.Status != 0 { + return int(result.ActualLength), fmt.Errorf( + "transfer failed: status 0x%08x, usbd 0x%08x", + uint32(result.Status), result.UsbdStatus) + } + + if params.Direction == winDirIn && result.ActualLength > 0 { + n := int(result.ActualLength) + if n > len(params.Data) { + n = len(params.Data) + } + copy(params.Data, output[winTransferResultSize:winTransferResultSize+n]) + } + + return int(result.ActualLength), nil +} + +// TransferParams describes one transfer. +type TransferParams struct { + EndpointAddress uint8 + Type uint8 + Direction uint8 + Data []byte + TimeoutMS uint32 + Setup [8]byte +} + +// SetInterface selects an alternate setting through the driver, so the USB +// stack re-opens the pipes and reserves bandwidth for isochronous endpoints. +func (h *DriverHandle) SetInterface(iface, alt uint8) error { + input := []byte{iface, alt} + var returned uint32 + + err := windows.DeviceIoControl(h.handle, ioctlSetInterface, + &input[0], uint32(len(input)), nil, 0, &returned, nil) + if err != nil { + return fmt.Errorf("setting interface %d to alt %d: %w", iface, alt, err) + } + return nil +} + +// ClearHalt clears a stall condition on an endpoint. +func (h *DriverHandle) ClearHalt(endpoint uint8) error { + input := []byte{endpoint} + var returned uint32 + + err := windows.DeviceIoControl(h.handle, ioctlClearHalt, + &input[0], 1, nil, 0, &returned, nil) + if err != nil { + return fmt.Errorf("clearing halt on endpoint 0x%02x: %w", endpoint, err) + } + return nil +} + +// Reset resets the device's port. +func (h *DriverHandle) Reset() error { + var returned uint32 + err := windows.DeviceIoControl(h.handle, ioctlReset, nil, 0, nil, 0, &returned, nil) + if err != nil { + return fmt.Errorf("resetting device: %w", err) + } + return nil +} + +// marshalTransferHeader writes the header in the driver's packed layout. +// Done field by field rather than by casting a struct: Go inserts padding +// that the packed C structure does not have. +func marshalTransferHeader(buf []byte, h *winTransferHeader) { + binary.LittleEndian.PutUint64(buf[0:8], h.ID) + buf[8] = h.EndpointAddress + buf[9] = h.Type + buf[10] = h.Direction + buf[11] = h.Reserved + binary.LittleEndian.PutUint32(buf[12:16], h.BufferLength) + binary.LittleEndian.PutUint32(buf[16:20], h.Timeout) + copy(buf[20:28], h.Setup[:]) +} + +func unmarshalTransferResult(buf []byte) winTransferResult { + return winTransferResult{ + ID: binary.LittleEndian.Uint64(buf[0:8]), + Status: int32(binary.LittleEndian.Uint32(buf[8:12])), + UsbdStatus: binary.LittleEndian.Uint32(buf[12:16]), + ActualLength: binary.LittleEndian.Uint32(buf[16:20]), + } +} diff --git a/internal/usb/enumerate_darwin.go b/internal/usb/enumerate_darwin.go new file mode 100644 index 0000000..038f37f --- /dev/null +++ b/internal/usb/enumerate_darwin.go @@ -0,0 +1,119 @@ +//go:build darwin + +package usb + +import ( + "encoding/json" + "fmt" + "os/exec" + "strconv" + "strings" +) + +// Enumerate lists USB devices on macOS via system_profiler. +// +// This is enough to see and report what is attached, which is what the +// diagnostics need. It is not enough to share anything: that requires opening +// devices through IOKit, which has no equivalent here — see the platform +// table in the README. +// +// Going through the command keeps the client cgo-free and therefore +// cross-compilable from any machine. +func Enumerate() ([]Device, error) { + if external := ExternalDevices(); len(external) > 0 { + // Devices handed in from outside are usable; report them first. + return external, nil + } + + out, err := exec.Command("system_profiler", "-json", "SPUSBDataType").Output() + if err != nil { + return nil, fmt.Errorf("running system_profiler: %w", err) + } + + var report struct { + Items []spUSBItem `json:"SPUSBDataType"` + } + if err := json.Unmarshal(out, &report); err != nil { + return nil, fmt.Errorf("parsing system_profiler output: %w", err) + } + + var devices []Device + for _, item := range report.Items { + collectItem(&devices, item) + } + + return devices, nil +} + +type spUSBItem struct { + Name string `json:"_name"` + VendorID string `json:"vendor_id"` + ProductID string `json:"product_id"` + Speed string `json:"device_speed"` + Manufacturer string `json:"manufacturer"` + SerialNumber string `json:"serial_num"` + LocationID string `json:"location_id"` + Items []spUSBItem `json:"_items"` +} + +func collectItem(devices *[]Device, item spUSBItem) { + if item.VendorID != "" { + dev := Device{ + BusID: locationToBusID(item.LocationID), + VendorID: parseHexID(item.VendorID), + ProductID: parseHexID(item.ProductID), + Speed: parseSpeedName(item.Speed), + Manufacturer: item.Manufacturer, + Product: item.Name, + Serial: item.SerialNumber, + } + *devices = append(*devices, dev) + } + + for _, child := range item.Items { + collectItem(devices, child) + } +} + +// parseHexID turns "0x046d (Logitech Inc.)" into 0x046d. +func parseHexID(id string) uint16 { + id = strings.TrimSpace(id) + if i := strings.Index(id, " "); i > 0 { + id = id[:i] + } + id = strings.TrimPrefix(id, "0x") + + v, err := strconv.ParseUint(id, 16, 16) + if err != nil { + return 0 + } + return uint16(v) +} + +// locationToBusID derives an identifier from the location ID, which encodes +// the device's position in the port tree. +func locationToBusID(locationID string) string { + locationID = strings.TrimSpace(locationID) + if i := strings.Index(locationID, " "); i > 0 { + locationID = locationID[:i] + } + return strings.TrimPrefix(locationID, "0x") +} + +// parseSpeedName maps system_profiler's wording onto USB/IP speed codes. +func parseSpeedName(speed string) uint32 { + switch { + case strings.Contains(speed, "low_speed"): + return 1 + case strings.Contains(speed, "full_speed"): + return 2 + case strings.Contains(speed, "high_speed"): + return 3 + case strings.Contains(speed, "super_speed_plus"): + return 6 + case strings.Contains(speed, "super_speed"): + return 5 + default: + return 0 + } +} diff --git a/internal/usb/enumerate_linux.go b/internal/usb/enumerate_linux.go index 752329a..b6fcf21 100644 --- a/internal/usb/enumerate_linux.go +++ b/internal/usb/enumerate_linux.go @@ -12,10 +12,17 @@ import ( const sysfsUSBDevices = "/sys/bus/usb/devices" -// Enumerate lists all USB devices by reading sysfs +// Enumerate lists all USB devices by reading sysfs, plus any device that was +// registered from outside the process (see RegisterExternalDevice). func Enumerate() ([]Device, error) { entries, err := os.ReadDir(sysfsUSBDevices) if err != nil { + // On Android sysfs is not readable by an app, but devices handed in + // through the bridge still work. Only report a failure when there is + // nothing at all to go on. + if external := ExternalDevices(); len(external) > 0 { + return external, nil + } return nil, fmt.Errorf("reading sysfs: %w", err) } @@ -46,7 +53,7 @@ func Enumerate() ([]Device, error) { devices = append(devices, *dev) } - return devices, nil + return mergeExternal(devices), nil } func isDevicePath(name string) bool { @@ -93,12 +100,63 @@ func readDevice(busID string) (*Device, error) { // Compute dev path dev.DevPath = fmt.Sprintf("/dev/bus/usb/%03d/%03d", dev.BusNum, dev.DevNum) - // Read interfaces + // Read interfaces from sysfs. This gives us the bound kernel driver per + // interface, which the raw descriptors don't contain. dev.Interfaces = readInterfaces(sysPath, busID) + // Overlay the raw descriptors from the usbdevfs file. Only these expose + // interface alternate settings and correct endpoint attributes; sysfs + // shows just the active alternate setting. Without the non-zero alternate + // settings the endpoint type map is wrong for webcams and audio devices. + applyRawDescriptors(dev) + return dev, nil } +// applyRawDescriptors reads the device's descriptor blob from its usbdevfs +// file and fills in Endpoints plus any interface data sysfs did not provide. +// Failure is not fatal: reading /dev/bus/usb requires permissions we may not +// have when merely listing devices, and the sysfs data alone is enough for +// that. Sharing a device opens the same file anyway and would fail earlier. +func applyRawDescriptors(dev *Device) { + data, err := os.ReadFile(dev.DevPath) + if err != nil { + return + } + + pd, err := ParseDescriptors(data) + if err != nil { + return + } + + cfg := pd.FindConfig(dev.ConfigValue) + if cfg == nil { + // The device is unconfigured, or sysfs and the descriptors disagree. + // Fall back to the first configuration. + if len(pd.Configs) == 0 { + return + } + cfg = &pd.Configs[0] + } + + dev.Endpoints = cfg.AllEndpoints() + + // Merge: keep the driver names from sysfs, take everything else from the + // descriptors (which are authoritative and include endpoint intervals). + drivers := make(map[uint8]string, len(dev.Interfaces)) + for _, iface := range dev.Interfaces { + drivers[iface.Number] = iface.Driver + } + + ifaces := cfg.ActiveInterfaces() + for i := range ifaces { + ifaces[i].Driver = drivers[ifaces[i].Number] + } + if len(ifaces) > 0 { + dev.Interfaces = ifaces + } +} + func readInterfaces(sysPath, busID string) []Interface { entries, err := os.ReadDir(sysPath) if err != nil { @@ -150,26 +208,14 @@ func readEndpoints(ifacePath string) []Endpoint { } epPath := filepath.Join(ifacePath, name) - addr, _ := strconv.ParseUint(readString(epPath, "bEndpointAddress"), 16, 8) - - var transferType uint8 - switch readString(epPath, "type") { - case "Control": - transferType = TransferTypeControl - case "Isoc": - transferType = TransferTypeIsochronous - case "Bulk": - transferType = TransferTypeBulk - case "Interrupt": - transferType = TransferTypeInterrupt - } - - maxPkt := readUint32(epPath, "wMaxPacketSize") + // Every numeric endpoint attribute in sysfs is hex, without a 0x + // prefix — wMaxPacketSize "0040" means 64, not 40. eps = append(eps, Endpoint{ - Address: uint8(addr), - TransferType: transferType, - MaxPacketSize: uint16(maxPkt), + Address: readHex8(epPath, "bEndpointAddress"), + TransferType: readHex8(epPath, "bmAttributes") & 0x03, + MaxPacketSize: readHex16(epPath, "wMaxPacketSize"), + Interval: readHex8(epPath, "bInterval"), }) } diff --git a/internal/usb/enumerate_windows.go b/internal/usb/enumerate_windows.go index 909e377..72c819d 100644 --- a/internal/usb/enumerate_windows.go +++ b/internal/usb/enumerate_windows.go @@ -2,9 +2,221 @@ package usb -import "fmt" +import ( + "fmt" + "log" + "strings" + "unsafe" -// Enumerate lists all USB devices (Windows stub) + "golang.org/x/sys/windows" +) + +var ( + modsetupapi = windows.NewLazySystemDLL("setupapi.dll") + + procSetupDiGetClassDevsW = modsetupapi.NewProc("SetupDiGetClassDevsW") + procSetupDiEnumDeviceInterfaces = modsetupapi.NewProc("SetupDiEnumDeviceInterfaces") + procSetupDiGetDeviceInterfaceDetailW = modsetupapi.NewProc("SetupDiGetDeviceInterfaceDetailW") + procSetupDiDestroyDeviceInfoList = modsetupapi.NewProc("SetupDiDestroyDeviceInfoList") +) + +const ( + digcfPresent = 0x00000002 + digcfDeviceInterface = 0x00000010 +) + +type spDeviceInterfaceData struct { + CbSize uint32 + InterfaceClassGuid windows.GUID + Flags uint32 + Reserved uintptr +} + +// Enumerate lists USB devices reachable through the usbshare filter driver, +// plus any device registered from outside this process. +// +// Only devices with the filter attached appear: Windows has no equivalent of +// walking /sys/bus/usb, and without the filter there is no way to drive a +// device from user mode anyway, so listing the others would only offer +// devices that cannot actually be shared. func Enumerate() ([]Device, error) { - return nil, fmt.Errorf("USB enumeration not yet implemented on Windows") + devices, err := enumerateFiltered() + if err != nil { + if external := ExternalDevices(); len(external) > 0 { + return external, nil + } + return nil, err + } + + return mergeExternal(devices), nil +} + +func enumerateFiltered() ([]Device, error) { + handle, _, _ := procSetupDiGetClassDevsW.Call( + uintptr(unsafe.Pointer(&guidDevInterfaceUsbShare)), + 0, 0, + uintptr(digcfPresent|digcfDeviceInterface), + ) + if handle == uintptr(windows.InvalidHandle) { + return nil, fmt.Errorf("no USB devices with the usbshare filter found " + + "(install driver/windows/usbshare.inf and attach it to the devices you want to share)") + } + defer procSetupDiDestroyDeviceInfoList.Call(handle) + + var devices []Device + + for index := uint32(0); ; index++ { + var ifaceData spDeviceInterfaceData + ifaceData.CbSize = uint32(unsafe.Sizeof(ifaceData)) + + ret, _, _ := procSetupDiEnumDeviceInterfaces.Call( + handle, 0, + uintptr(unsafe.Pointer(&guidDevInterfaceUsbShare)), + uintptr(index), + uintptr(unsafe.Pointer(&ifaceData)), + ) + if ret == 0 { + break // no more interfaces + } + + devicePath, err := interfaceDetailPath(handle, &ifaceData) + if err != nil { + continue + } + + dev, err := describeFilteredDevice(devicePath) + if err != nil { + log.Printf("[usb] skipping %s: %v", devicePath, err) + continue + } + + devices = append(devices, *dev) + } + + return devices, nil +} + +// interfaceDetailPath resolves an interface to the device path used to open it. +func interfaceDetailPath(handle uintptr, ifaceData *spDeviceInterfaceData) (string, error) { + // First call determines the size. + var required uint32 + procSetupDiGetDeviceInterfaceDetailW.Call( + handle, + uintptr(unsafe.Pointer(ifaceData)), + 0, 0, + uintptr(unsafe.Pointer(&required)), + 0, + ) + if required == 0 { + return "", fmt.Errorf("could not determine the interface detail size") + } + + buf := make([]byte, required) + + // SP_DEVICE_INTERFACE_DETAIL_DATA_W starts with cbSize, which must be set + // to the size of the fixed part — 8 on 64-bit, counting the alignment of + // the WCHAR array that follows — not the size of the whole buffer. + *(*uint32)(unsafe.Pointer(&buf[0])) = 8 + + ret, _, err := procSetupDiGetDeviceInterfaceDetailW.Call( + handle, + uintptr(unsafe.Pointer(ifaceData)), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(required), + uintptr(unsafe.Pointer(&required)), + 0, + ) + if ret == 0 { + return "", fmt.Errorf("reading interface detail: %w", err) + } + + // The path is a null-terminated WCHAR string starting after cbSize. + pathPtr := (*uint16)(unsafe.Pointer(&buf[4])) + return windows.UTF16PtrToString(pathPtr), nil +} + +// describeFilteredDevice opens a device briefly to read its descriptors. +// +// Claiming it here means the class driver stops seeing it for the duration. +// Enumeration therefore releases immediately: holding the claim would make +// merely listing devices disrupt whatever is using them. +func describeFilteredDevice(devicePath string) (*Device, error) { + handle, err := OpenDriverDevice(devicePath) + if err != nil { + return nil, err + } + defer handle.Close() + + descriptors, err := handle.Descriptors() + if err != nil { + return nil, fmt.Errorf("reading descriptors: %w", err) + } + + parsed, err := ParseDescriptors(descriptors) + if err != nil { + return nil, fmt.Errorf("parsing descriptors: %w", err) + } + + info := handle.Info() + + cfg := parsed.FindConfig(info.ConfigurationValue) + if cfg == nil { + cfg = &parsed.Configs[0] + } + + dev := &Device{ + BusID: busIDFromPath(devicePath), + BusNum: 0, + DevNum: uint32(info.PortNumber), + Speed: translateWindowsSpeed(info.Speed), + VendorID: parsed.VendorID, + ProductID: parsed.ProductID, + BcdDevice: parsed.BcdDevice, + DeviceClass: parsed.DeviceClass, + DeviceSubClass: parsed.DeviceSubClass, + DeviceProtocol: parsed.DeviceProtocol, + ConfigValue: cfg.Value, + NumConfigs: parsed.NumConfigs, + DevPath: devicePath, + Interfaces: cfg.ActiveInterfaces(), + Endpoints: cfg.AllEndpoints(), + } + + return dev, nil +} + +// busIDFromPath derives a stable identifier from a Windows device path. +// +// Paths look like \\?\usb#vid_046d&pid_c52b#5&1a2b3c4d&0&2#{guid}. The +// instance part is stable for as long as the device stays in the same port, +// which is what peers need: they request devices by this ID. +func busIDFromPath(devicePath string) string { + trimmed := strings.TrimPrefix(devicePath, `\\?\`) + if idx := strings.LastIndex(trimmed, "#{"); idx > 0 { + trimmed = trimmed[:idx] + } + + // '#' separates the parts; '&' appears inside them. Neither is a problem + // for transport, but a shorter, more readable ID helps in the UI. + parts := strings.Split(trimmed, "#") + if len(parts) >= 3 { + return strings.ReplaceAll(parts[2], "&", "-") + } + return strings.ReplaceAll(trimmed, "#", "-") +} + +// translateWindowsSpeed maps USB_DEVICE_SPEED onto the USB/IP speed codes. +func translateWindowsSpeed(speed uint32) uint32 { + switch speed { + case 0: // UsbLowSpeed + return 1 + case 1: // UsbFullSpeed + return 2 + case 2: // UsbHighSpeed + return 3 + case 3: // UsbSuperSpeed + return 5 + default: + return 0 + } } diff --git a/internal/usb/external.go b/internal/usb/external.go new file mode 100644 index 0000000..88cca73 --- /dev/null +++ b/internal/usb/external.go @@ -0,0 +1,127 @@ +package usb + +import ( + "fmt" + "sync" +) + +// Externally registered devices. +// +// Normally devices are found by walking sysfs. That is not available to an +// unprivileged Android app, which must go through the framework: it enumerates +// devices itself, asks the user for permission, and receives an already-open +// file descriptor plus the raw descriptor blob. Those devices are registered +// here and merged into the enumeration, so everything above this layer works +// the same whether a device came from sysfs or from outside. +var ( + externalMu sync.RWMutex + externalDevices = make(map[string]Device) +) + +// RegisterExternalDevice adds a device that was discovered outside this +// process. descriptors is the raw blob (device descriptor followed by +// configuration descriptors), exactly what a usbdevfs file read returns and +// what Android's UsbDeviceConnection.getRawDescriptors() provides. +func RegisterExternalDevice(busID string, descriptors []byte, meta ExternalDeviceMeta) error { + if busID == "" { + return fmt.Errorf("bus ID is required") + } + + parsed, err := ParseDescriptors(descriptors) + if err != nil { + return fmt.Errorf("parsing descriptors for %s: %w", busID, err) + } + + cfg := parsed.FindConfig(meta.ConfigValue) + if cfg == nil { + cfg = &parsed.Configs[0] + } + + dev := Device{ + BusID: busID, + BusNum: meta.BusNum, + DevNum: meta.DevNum, + Speed: meta.Speed, + VendorID: parsed.VendorID, + ProductID: parsed.ProductID, + BcdDevice: parsed.BcdDevice, + DeviceClass: parsed.DeviceClass, + DeviceSubClass: parsed.DeviceSubClass, + DeviceProtocol: parsed.DeviceProtocol, + ConfigValue: cfg.Value, + NumConfigs: parsed.NumConfigs, + Manufacturer: meta.Manufacturer, + Product: meta.Product, + Serial: meta.Serial, + Interfaces: cfg.ActiveInterfaces(), + Endpoints: cfg.AllEndpoints(), + } + + externalMu.Lock() + externalDevices[busID] = dev + externalMu.Unlock() + + return nil +} + +// ExternalDeviceMeta carries the fields that cannot be read from the +// descriptor blob because they describe the device's place on the bus or come +// from string descriptors the caller already resolved. +type ExternalDeviceMeta struct { + BusNum uint32 + DevNum uint32 + Speed uint32 + ConfigValue uint8 + Manufacturer string + Product string + Serial string +} + +// UnregisterExternalDevice removes a device registered from outside. +func UnregisterExternalDevice(busID string) { + externalMu.Lock() + delete(externalDevices, busID) + externalMu.Unlock() +} + +// ExternalDevices returns a snapshot of the externally registered devices. +func ExternalDevices() []Device { + externalMu.RLock() + defer externalMu.RUnlock() + + result := make([]Device, 0, len(externalDevices)) + for _, dev := range externalDevices { + result = append(result, dev) + } + return result +} + +// HasExternalDevices reports whether any device came from outside. +func HasExternalDevices() bool { + externalMu.RLock() + defer externalMu.RUnlock() + return len(externalDevices) > 0 +} + +// mergeExternal appends externally registered devices to a list from sysfs, +// letting the external entry win on a bus ID collision — it carries a file +// descriptor we can actually use, which the sysfs entry may not. +func mergeExternal(devices []Device) []Device { + externalMu.RLock() + defer externalMu.RUnlock() + + if len(externalDevices) == 0 { + return devices + } + + result := make([]Device, 0, len(devices)+len(externalDevices)) + for _, dev := range devices { + if _, overridden := externalDevices[dev.BusID]; !overridden { + result = append(result, dev) + } + } + for _, dev := range externalDevices { + result = append(result, dev) + } + return result +} diff --git a/internal/usb/usbdevfs.go b/internal/usb/usbdevfs.go index fe001aa..d4375c0 100644 --- a/internal/usb/usbdevfs.go +++ b/internal/usb/usbdevfs.go @@ -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 } diff --git a/internal/usb/usbdevfs_darwin.go b/internal/usb/usbdevfs_darwin.go new file mode 100644 index 0000000..f3713cc --- /dev/null +++ b/internal/usb/usbdevfs_darwin.go @@ -0,0 +1,27 @@ +//go:build darwin + +package usb + +import "fmt" + +// Device access on macOS would go through IOKit, which has no counterpart to +// usbdevfs: there is no device node to open and drive with ioctls. Providing +// it means writing an IOKit backend with cgo, which is a separate piece of +// work — see the platform table in the README. +// +// These stubs exist so the client builds and its other functions (listing +// devices, diagnostics, the relay, the web UI) work on macOS. + +type DeviceHandle struct{} + +func OpenDevice(devPath string, busID string) (*DeviceHandle, error) { + return nil, fmt.Errorf("sharing USB devices is not implemented on macOS " + + "(needs an IOKit backend); this machine can still run the relay") +} + +func (h *DeviceHandle) Close() error { return nil } +func (h *DeviceHandle) Fd() int { return -1 } +func (h *DeviceHandle) DisconnectDriver() error { return fmt.Errorf("not implemented on macOS") } +func (h *DeviceHandle) ConnectDriver() error { return fmt.Errorf("not implemented on macOS") } +func (h *DeviceHandle) ClaimInterface(uint32) error { return fmt.Errorf("not implemented on macOS") } +func (h *DeviceHandle) ReleaseInterface(uint32) error { return fmt.Errorf("not implemented on macOS") } diff --git a/internal/usb/usbdevfs_windows.go b/internal/usb/usbdevfs_windows.go index caf5998..247b9a3 100644 --- a/internal/usb/usbdevfs_windows.go +++ b/internal/usb/usbdevfs_windows.go @@ -11,7 +11,7 @@ func OpenDevice(devPath string, busID string) (*DeviceHandle, error) { return nil, fmt.Errorf("USB device access not yet implemented on Windows") } -func (h *DeviceHandle) Close() error { return nil } +func (h *DeviceHandle) Close() error { return nil } func (h *DeviceHandle) Fd() int { return -1 } func (h *DeviceHandle) DisconnectDriver() error { return fmt.Errorf("not implemented") } func (h *DeviceHandle) ConnectDriver() error { return fmt.Errorf("not implemented") } diff --git a/internal/usbip/protocol.go b/internal/usbip/protocol.go index 4ed5800..6d9cae0 100644 --- a/internal/usbip/protocol.go +++ b/internal/usbip/protocol.go @@ -87,12 +87,12 @@ type URBHeader struct { // CmdSubmitBody follows URBHeader for USBIP_CMD_SUBMIT type CmdSubmitBody struct { - TransferFlags uint32 - TransferBufferLen uint32 - StartFrame uint32 - NumberOfPackets uint32 - Interval uint32 - Setup [8]byte + TransferFlags uint32 + TransferBufferLen uint32 + StartFrame uint32 + NumberOfPackets uint32 + Interval uint32 + Setup [8]byte } // RetSubmitBody follows URBHeader for USBIP_RET_SUBMIT diff --git a/internal/usbip/protocol_test.go b/internal/usbip/protocol_test.go new file mode 100644 index 0000000..03caa8a --- /dev/null +++ b/internal/usbip/protocol_test.go @@ -0,0 +1,236 @@ +package usbip + +import ( + "bytes" + "encoding/binary" + "testing" +) + +// The USB/IP wire format is fixed: a 20-byte basic header followed by a +// 28-byte body. Any drift here desynchronises the stream permanently, so the +// sizes are pinned. +func TestWireFormatSizes(t *testing.T) { + var buf bytes.Buffer + + if err := WriteURBHeader(&buf, &URBHeader{}); err != nil { + t.Fatalf("WriteURBHeader: %v", err) + } + if buf.Len() != 20 { + t.Errorf("URB header is %d bytes, want 20", buf.Len()) + } + + buf.Reset() + if err := WriteCmdSubmit(&buf, &CmdSubmitBody{}); err != nil { + t.Fatalf("WriteCmdSubmit: %v", err) + } + if buf.Len() != 28 { + t.Errorf("CMD_SUBMIT body is %d bytes, want 28", buf.Len()) + } + + buf.Reset() + if err := WriteRetSubmit(&buf, &RetSubmitBody{}); err != nil { + t.Fatalf("WriteRetSubmit: %v", err) + } + if buf.Len() != 28 { + t.Errorf("RET_SUBMIT body is %d bytes, want 28", buf.Len()) + } + + buf.Reset() + if err := WriteRetUnlink(&buf, &RetUnlinkBody{}); err != nil { + t.Fatalf("WriteRetUnlink: %v", err) + } + if buf.Len() != 28 { + t.Errorf("RET_UNLINK body is %d bytes, want 28", buf.Len()) + } +} + +func TestBuildRetSubmitInDirection(t *testing.T) { + payload := []byte{0x01, 0x02, 0x03, 0x04} + + msg, err := BuildRetSubmit(42, 0x00030002, DirIn, 1, 0, uint32(len(payload)), payload) + if err != nil { + t.Fatalf("BuildRetSubmit: %v", err) + } + + if len(msg) != 48+len(payload) { + t.Fatalf("message is %d bytes, want %d", len(msg), 48+len(payload)) + } + + hdr, err := ReadURBHeader(bytes.NewReader(msg)) + if err != nil { + t.Fatalf("ReadURBHeader: %v", err) + } + if hdr.Command != RetSubmit { + t.Errorf("command = 0x%08x, want RET_SUBMIT", hdr.Command) + } + if hdr.SeqNum != 42 { + t.Errorf("seqnum = %d, want 42", hdr.SeqNum) + } + if hdr.Direction != DirIn { + t.Errorf("direction = %d, want IN", hdr.Direction) + } + + body, err := ReadRetSubmit(bytes.NewReader(msg[20:])) + if err != nil { + t.Fatalf("ReadRetSubmit: %v", err) + } + if body.ActualLength != uint32(len(payload)) { + t.Errorf("actual_length = %d, want %d", body.ActualLength, len(payload)) + } + if body.NumberOfPackets != 0xFFFFFFFF { + t.Errorf("number_of_packets = %d, want 0xFFFFFFFF for non-ISO", body.NumberOfPackets) + } + if !bytes.Equal(msg[48:], payload) { + t.Errorf("payload = %x, want %x", msg[48:], payload) + } +} + +// actual_length must be reported for OUT transfers too. The kernel UVC driver +// checks it: a VS_PROBE SET_CUR that reports 0 instead of 26 fails the probe. +func TestBuildRetSubmitOutReportsActualLength(t *testing.T) { + msg, err := BuildRetSubmit(7, 1, DirOut, 0, 0, 26, nil) + if err != nil { + t.Fatalf("BuildRetSubmit: %v", err) + } + if len(msg) != 48 { + t.Fatalf("OUT reply is %d bytes, want 48 (no payload)", len(msg)) + } + + body, _ := ReadRetSubmit(bytes.NewReader(msg[20:])) + if body.ActualLength != 26 { + t.Errorf("actual_length = %d, want 26", body.ActualLength) + } +} + +// An IN reply carries no payload when the transfer failed, and the negative +// status has to survive the unsigned round trip on the wire. +func TestBuildRetSubmitErrorStatus(t *testing.T) { + msg, err := BuildRetSubmit(9, 1, DirIn, 2, -32, 0, nil) + if err != nil { + t.Fatalf("BuildRetSubmit: %v", err) + } + if len(msg) != 48 { + t.Fatalf("error reply is %d bytes, want 48", len(msg)) + } + + status := int32(binary.BigEndian.Uint32(msg[20:24])) + if status != -32 { + t.Errorf("status = %d, want -32 (-EPIPE)", status) + } +} + +func TestBuildRetSubmitISO(t *testing.T) { + descs := []ISOPacketDescriptor{ + {Offset: 0, Length: 192, ActualLength: 192, Status: 0}, + {Offset: 192, Length: 192, ActualLength: 100, Status: 0}, + } + packed := make([]byte, 292) // 192 + 100 actual bytes, packed without gaps + + msg, err := BuildRetSubmitISO(5, 1, DirIn, 1, 0, 292, packed, 1000, 2, 0, descs) + if err != nil { + t.Fatalf("BuildRetSubmitISO: %v", err) + } + + wantLen := 48 + len(packed) + 2*16 + if len(msg) != wantLen { + t.Fatalf("ISO reply is %d bytes, want %d", len(msg), wantLen) + } + + body, _ := ReadRetSubmit(bytes.NewReader(msg[20:])) + if body.NumberOfPackets != 2 { + t.Errorf("number_of_packets = %d, want 2", body.NumberOfPackets) + } + if body.StartFrame != 1000 { + t.Errorf("start_frame = %d, want 1000", body.StartFrame) + } + + // Descriptors follow the packed payload, big-endian. + descOff := 48 + len(packed) + var got ISOPacketDescriptor + if err := binary.Read(bytes.NewReader(msg[descOff:]), binary.BigEndian, &got); err != nil { + t.Fatalf("reading ISO descriptor: %v", err) + } + if got.Length != 192 || got.ActualLength != 192 { + t.Errorf("first descriptor = %+v, want length 192 actual 192", got) + } +} + +func TestBusIDRoundTrip(t *testing.T) { + tests := []string{"1-1", "1-4.3.2", "", "12345678901234567890123456789012"} + + for _, want := range tests { + var arr [32]byte + SetBusID(&arr, want) + if got := GetBusID(arr); got != want { + t.Errorf("GetBusID(SetBusID(%q)) = %q", want, got) + } + } +} + +func TestSetBusIDTruncatesOverlongInput(t *testing.T) { + var arr [32]byte + SetBusID(&arr, "this-bus-id-is-far-longer-than-thirty-two-bytes") + if got := len(GetBusID(arr)); got != 32 { + t.Errorf("overlong bus ID produced %d bytes, want 32", got) + } +} + +func TestReadCmdSubmitParsesSetupPacket(t *testing.T) { + var buf bytes.Buffer + body := &CmdSubmitBody{ + TransferBufferLen: 18, + NumberOfPackets: 0, + Interval: 0, + // GET_DESCRIPTOR(device): bmRequestType=0x80 bRequest=0x06 wValue=0x0100 + Setup: [8]byte{0x80, 0x06, 0x00, 0x01, 0x00, 0x00, 0x12, 0x00}, + } + if err := WriteCmdSubmit(&buf, body); err != nil { + t.Fatalf("WriteCmdSubmit: %v", err) + } + + got, err := ReadCmdSubmit(&buf) + if err != nil { + t.Fatalf("ReadCmdSubmit: %v", err) + } + + // Setup fields are little-endian even though the surrounding header is not. + if wValue := binary.LittleEndian.Uint16(got.Setup[2:4]); wValue != 0x0100 { + t.Errorf("wValue = 0x%04x, want 0x0100", wValue) + } + if wLength := binary.LittleEndian.Uint16(got.Setup[6:8]); wLength != 18 { + t.Errorf("wLength = %d, want 18", wLength) + } + if got.TransferBufferLen != 18 { + t.Errorf("transfer_buffer_length = %d, want 18", got.TransferBufferLen) + } +} + +func TestBuildImportReply(t *testing.T) { + desc := &DeviceDescriptor{BusNum: 1, DevNum: 4, Speed: SpeedHigh} + SetBusID(&desc.BusID, "1-4") + + ok, err := BuildImportReply(0, desc) + if err != nil { + t.Fatalf("BuildImportReply: %v", err) + } + if len(ok) != 8+312 { + t.Errorf("successful reply is %d bytes, want %d", len(ok), 8+312) + } + + // A failure carries only the header — no descriptor follows. + fail, err := BuildImportReply(1, nil) + if err != nil { + t.Fatalf("BuildImportReply(1): %v", err) + } + if len(fail) != 8 { + t.Errorf("failure reply is %d bytes, want 8", len(fail)) + } + + hdr, _ := ReadOpHeader(bytes.NewReader(fail)) + if hdr.Status != 1 { + t.Errorf("status = %d, want 1", hdr.Status) + } + if hdr.Version != ProtocolVersion { + t.Errorf("version = 0x%04x, want 0x%04x", hdr.Version, ProtocolVersion) + } +} diff --git a/internal/usbip/server.go b/internal/usbip/server.go index 6ed5f07..887398a 100644 --- a/internal/usbip/server.go +++ b/internal/usbip/server.go @@ -5,6 +5,7 @@ package usbip import ( "bytes" "encoding/binary" + "errors" "fmt" "io" "log" @@ -17,6 +18,19 @@ import ( "github.com/duffy/usb-server/internal/usb" ) +// controlQueueDepth bounds how many control transfers may be waiting for the +// control worker. Control transfers are serialised because endpoint 0 is a +// single shared pipe; the queue only exists so that a slow transfer does not +// stall the URB read loop behind it. +const controlQueueDepth = 64 + +// controlTimeout is the per-transfer timeout passed to USBDEVFS_CONTROL. +const controlTimeout = 5000 // ms + +// reapPollInterval is how long the reap loop waits for a completed URB before +// re-checking whether it should shut down. +const reapPollInterval = 100 * time.Millisecond + // Server handles USB/IP protocol on the share side. // It manages a single USB device and forwards URBs between // the USB/IP client (via tunnel) and the physical device (via usbdevfs). @@ -25,29 +39,64 @@ type Server struct { handle *usb.DeviceHandle mu sync.Mutex pendingURBs map[uint32]*pendingURB // seqnum -> pending URB - closed bool - epTypes map[uint8]uint8 // endpoint number (1-15) -> usbdevfs URB type + + // unlinkedURBs holds URBs that were discarded but not yet reaped. + // + // USBDEVFS_DISCARDURB is asynchronous: the kernel still owns the URB + // struct and its transfer buffer, and will write the completion status + // into them. Dropping the last Go reference at unlink time would let the + // garbage collector reclaim memory the kernel is about to write to. + unlinkedURBs map[uint32]*pendingURB + + closed bool + + // epTypes maps a full bEndpointAddress (direction bit included) to a + // usbdevfs URB type. Indexing by address rather than endpoint number + // matters: a composite device can have endpoint 1 as interrupt IN (0x81) + // and endpoint 1 as bulk OUT (0x01), and submitting an interrupt URB with + // the bulk type makes the kernel reject it. + epTypes map[uint8]uint8 + + // ctrlQueue serialises control transfers on a dedicated worker so that a + // blocking USBDEVFS_CONTROL ioctl never stalls the protocol read loop. + ctrlQueue chan *ctrlRequest + + // stop is closed to shut down the reap loop and control worker; workers + // signals when both have exited so Detach can safely close the fd. + stop chan struct{} + workers sync.WaitGroup + runOnce sync.Once } type pendingURB struct { - seqNum uint32 - devID uint32 - direction uint32 - endpoint uint32 - buffer []byte - urbPtr unsafe.Pointer // pointer to submitted usbdevfs_urb - isISO bool - numPackets int32 - isoMem []byte // keeps ISO URB+descriptors memory alive for GC - packetLens []uint32 // original request lengths per ISO packet (for offset computation) + seqNum uint32 + devID uint32 + direction uint32 + endpoint uint32 + buffer []byte + urbPtr unsafe.Pointer // pointer to submitted usbdevfs_urb + isISO bool + numPackets int32 + isoMem []byte // keeps ISO URB+descriptors memory alive for GC + packetLens []uint32 // original request lengths per ISO packet (for offset computation) +} + +// ctrlRequest is one queued control transfer. +type ctrlRequest struct { + hdr *URBHeader + body *CmdSubmitBody + transferBuf []byte } // NewServer creates a USB/IP server for a specific device func NewServer(dev *usb.Device) *Server { return &Server{ - device: dev, - pendingURBs: make(map[uint32]*pendingURB), - epTypes: make(map[uint8]uint8), + device: dev, + pendingURBs: make(map[uint32]*pendingURB), + unlinkedURBs: make(map[uint32]*pendingURB), + epTypes: make(map[uint8]uint8), + ctrlQueue: make(chan *ctrlRequest, controlQueueDepth), + stop: make(chan struct{}), } } @@ -100,36 +149,50 @@ func (s *Server) Attach() error { // Detach releases all interfaces, closes the device, and rebinds kernel drivers. func (s *Server) Detach() { s.mu.Lock() + alreadyClosed := s.closed s.closed = true s.mu.Unlock() - if s.handle == nil { + if alreadyClosed || s.handle == nil { return } - // 1. Discard all pending URBs to clean up device state + // 1. Stop the reap loop and control worker, then wait for them to exit. + // This must happen before closing the fd: a worker mid-ioctl on a closed + // fd would either fail confusingly or, worse, operate on a recycled fd. + s.stopWorkers() + + // 2. Discard all pending URBs to clean up device state. + // The maps stay populated on purpose: they are what keeps the URB + // structs and transfer buffers reachable while the kernel still owns + // them. They are only cleared after the fd is closed, which is the point + // at which the kernel definitively drops its references. s.mu.Lock() - for seqNum, pending := range s.pendingURBs { + for _, pending := range s.pendingURBs { if pending.urbPtr != nil { s.handle.DiscardURBByPtr(pending.urbPtr) } - delete(s.pendingURBs, seqNum) } s.mu.Unlock() - // 2. Release all claimed interfaces + // 3. Release all claimed interfaces for _, iface := range s.device.Interfaces { if err := s.handle.ReleaseInterface(uint32(iface.Number)); err != nil { log.Printf("[usbip-server] release interface %d: %v", iface.Number, err) } } - // 3. Close the device file descriptor. + // 4. Close the device file descriptor. // The kernel auto-cancels remaining URBs on close. s.handle.Close() s.handle = nil - // 4. Force kernel driver re-binding via sysfs authorized toggle. + s.mu.Lock() + s.pendingURBs = make(map[uint32]*pendingURB) + s.unlinkedURBs = make(map[uint32]*pendingURB) + s.mu.Unlock() + + // 5. Force kernel driver re-binding via sysfs authorized toggle. // After USBDEVFS_DISCONNECT_CLAIM, the kernel sets privileges_dropped=true. // This means closing the fd does NOT auto-rebind drivers. // Also USBDEVFS_RESET after ReleaseInterface doesn't rebind because @@ -139,6 +202,13 @@ func (s *Server) Detach() { s.rebindDrivers() } +// stopWorkers signals the reap loop and control worker to exit and waits for +// them. Safe to call more than once. +func (s *Server) stopWorkers() { + s.runOnce.Do(func() { close(s.stop) }) + s.workers.Wait() +} + // rebindDrivers forces the kernel to re-bind drivers to the device // by toggling the sysfs authorized attribute. func (s *Server) rebindDrivers() { @@ -180,41 +250,90 @@ func (s *Server) rebindDriversFallback() { } } -// buildEndpointTypeMap builds the endpoint number -> URB type map from device descriptors +// urbTypeName maps a usbdevfs URB type to a short label for logging. +var urbTypeName = map[uint8]string{ + usbdevfsTypeISO: "ISO", + usbdevfsTypeInterrupt: "INT", + usbdevfsTypeControl: "CTRL", + usbdevfsTypeBulk: "BULK", +} + +// usbdevfs URB types (mirrors the constants in the usb package) +const ( + usbdevfsTypeISO = 0 + usbdevfsTypeInterrupt = 1 + usbdevfsTypeControl = 2 + usbdevfsTypeBulk = 3 +) + +// buildEndpointTypeMap builds the endpoint address -> URB type map. +// +// It prefers Device.Endpoints, which is parsed from the raw descriptors and +// therefore covers every alternate setting. Endpoints that only appear in a +// non-zero alternate setting — the isochronous endpoints of webcams, which +// only activate after SET_INTERFACE — would be missing otherwise. func (s *Server) buildEndpointTypeMap() { + record := func(ep usb.Endpoint) { + var urbType uint8 + switch ep.TransferType { + case usb.TransferTypeControl: + urbType = usbdevfsTypeControl + case usb.TransferTypeIsochronous: + urbType = usbdevfsTypeISO + case usb.TransferTypeBulk: + urbType = usbdevfsTypeBulk + case usb.TransferTypeInterrupt: + urbType = usbdevfsTypeInterrupt + default: + urbType = usbdevfsTypeBulk + } + s.epTypes[ep.Address] = urbType + + dir := "OUT" + if ep.IsIn() { + dir = "IN" + } + log.Printf("[usbip-server] endpoint 0x%02x (EP%d %s): %s maxpkt=%d interval=%d", + ep.Address, ep.Number(), dir, urbTypeName[urbType], ep.MaxPacketSize, ep.Interval) + } + + if len(s.device.Endpoints) > 0 { + for _, ep := range s.device.Endpoints { + record(ep) + } + return + } + + // Fallback for devices whose raw descriptors could not be read. + log.Printf("[usbip-server] warning: no parsed descriptors, falling back to sysfs endpoints") for _, iface := range s.device.Interfaces { for _, ep := range iface.Endpoints { - epNum := ep.Address & 0x0F - // Map USB descriptor transfer type to usbdevfs URB type - var urbType uint8 - switch ep.TransferType { - case usb.TransferTypeControl: - urbType = 2 - case usb.TransferTypeIsochronous: - urbType = 0 - case usb.TransferTypeBulk: - urbType = 3 - case usb.TransferTypeInterrupt: - urbType = 1 - default: - urbType = 3 // default bulk - } - s.epTypes[epNum] = urbType - typeNames := map[uint8]string{0: "ISO", 1: "interrupt", 2: "control", 3: "bulk"} - log.Printf("[usbip-server] endpoint %d (0x%02x): %s", epNum, ep.Address, typeNames[urbType]) + record(ep) } } } -// getURBType returns the usbdevfs URB type for an endpoint number -func (s *Server) getURBType(endpoint uint8) uint8 { - if endpoint == 0 { - return 2 // control +// getURBType returns the usbdevfs URB type for a full endpoint address. +// +// interval and numPackets come from the incoming CMD_SUBMIT and act as a +// fallback for endpoints missing from the descriptor map: only periodic +// transfers carry a non-zero interval, so an unknown endpoint with one is an +// interrupt endpoint rather than a bulk endpoint. Guessing bulk there is what +// breaks HID devices, whose interrupt URBs the kernel then rejects. +func (s *Server) getURBType(epAddr uint8, interval uint32, numPackets int32) uint8 { + if epAddr&0x0F == 0 { + return usbdevfsTypeControl } - if t, ok := s.epTypes[endpoint]; ok { + if numPackets > 0 { + return usbdevfsTypeISO + } + if t, ok := s.epTypes[epAddr]; ok { return t } - return 3 // default: bulk + if interval > 0 { + return usbdevfsTypeInterrupt + } + return usbdevfsTypeBulk } // BuildDeviceDescriptor creates a USB/IP device descriptor from our device info @@ -254,25 +373,28 @@ func (s *Server) BuildInterfaceDescriptors() []InterfaceDescriptor { // It reads USB/IP requests from the reader, processes them, and writes responses to the writer. // This is the main loop for handling a connected USB/IP client. func (s *Server) HandleConnection(r io.Reader, w io.Writer) error { - // Start the URB reaper goroutine - retChan := make(chan []byte, 64) - done := make(chan struct{}) - defer close(done) + retChan := make(chan []byte, 256) - go s.reapLoop(retChan, done) + // The reap loop and control worker outlive this function only until + // Detach stops them; both feed retChan. + s.workers.Add(2) + go s.reapLoop(retChan) + go s.controlWorker(retChan) - // Forward completed URBs to the writer + // Forward completed URBs to the writer. This goroutine belongs to the + // connection, not to the server, so it ends when the connection does. + connDone := make(chan struct{}) + defer close(connDone) go func() { for { select { - case data, ok := <-retChan: - if !ok { - return - } + case data := <-retChan: if _, err := w.Write(data); err != nil { return } - case <-done: + case <-connDone: + return + case <-s.stop: return } } @@ -280,10 +402,9 @@ func (s *Server) HandleConnection(r io.Reader, w io.Writer) error { // Read and process incoming USB/IP messages for { - // Read the URB header (20 bytes basic + 28 bytes specific = 48 total) hdr, err := ReadURBHeader(r) if err != nil { - if err == io.EOF { + if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.ErrClosedPipe) { return nil } return fmt.Errorf("reading URB header: %w", err) @@ -304,6 +425,15 @@ func (s *Server) HandleConnection(r io.Reader, w io.Writer) error { } } +// send queues a response, dropping it if the server is shutting down rather +// than blocking forever on a channel nobody is draining. +func (s *Server) send(retChan chan<- []byte, resp []byte) { + select { + case retChan <- resp: + case <-s.stop: + } +} + func (s *Server) handleCmdSubmit(r io.Reader, hdr *URBHeader, retChan chan<- []byte) error { body, err := ReadCmdSubmit(r) if err != nil { @@ -331,122 +461,22 @@ func (s *Server) handleCmdSubmit(r io.Reader, hdr *URBHeader, retChan chan<- []b } endpoint := uint8(hdr.Endpoint) - urbType := s.getURBType(endpoint) - dirStr := "OUT" - if hdr.Direction == DirIn { - dirStr = "IN" - } - - // Log all transfers for debugging + // Control transfers go to the dedicated worker: USBDEVFS_CONTROL is a + // blocking ioctl and running it inline would stall every later URB behind + // a transfer that can take up to controlTimeout milliseconds. if endpoint == 0 { - bmReqType := body.Setup[0] - bReq := body.Setup[1] - wVal := binary.LittleEndian.Uint16(body.Setup[2:4]) - wIdx := binary.LittleEndian.Uint16(body.Setup[4:6]) - wLen := binary.LittleEndian.Uint16(body.Setup[6:8]) - log.Printf("[usbip-server] CTRL %s seq=%d bmReqType=0x%02x bReq=0x%02x wVal=0x%04x wIdx=0x%04x wLen=%d bufLen=%d", - dirStr, hdr.SeqNum, bmReqType, bReq, wVal, wIdx, wLen, body.TransferBufferLen) - } else { - typeNames := map[uint8]string{0: "ISO", 1: "INT", 2: "CTRL", 3: "BULK"} - log.Printf("[usbip-server] EP%d %s seq=%d type=%s bufLen=%d numPkts=%d", - endpoint, dirStr, hdr.SeqNum, typeNames[urbType], body.TransferBufferLen, numPackets) - } - - // Handle control transfers specially (endpoint 0) - if endpoint == 0 && hdr.Direction == DirIn { - buf := make([]byte, body.TransferBufferLen) - n, err := s.handle.ControlTransfer( - body.Setup[0], body.Setup[1], - binary.LittleEndian.Uint16(body.Setup[2:4]), - binary.LittleEndian.Uint16(body.Setup[4:6]), - binary.LittleEndian.Uint16(body.Setup[6:8]), - 5000, buf, - ) - var status int32 - if err != nil { - log.Printf("[usbip-server] CTRL IN failed: %v", err) - status = -32 // -EPIPE - n = 0 - } - resp, err := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, status, uint32(n), buf[:n]) - if err != nil { - return err - } - retChan <- resp - return nil - } - - if endpoint == 0 && hdr.Direction == DirOut { - bmRequestType := body.Setup[0] - bRequest := body.Setup[1] - wValue := binary.LittleEndian.Uint16(body.Setup[2:4]) - wIndex := binary.LittleEndian.Uint16(body.Setup[4:6]) - - var status int32 - var actualLength uint32 - - // Intercept standard USB requests that require special usbdevfs ioctls. - // Raw control transfers via USBDEVFS_CONTROL don't update kernel state. - switch { - case bmRequestType == 0x01 && bRequest == 0x0B: - // SET_INTERFACE (Standard, Interface recipient) - // MUST use USBDEVFS_SETINTERFACE so the kernel updates endpoint state - // and allocates bandwidth for ISO endpoints (critical for webcams). - if err := s.handle.SetInterface(uint32(wIndex), uint32(wValue)); err != nil { - log.Printf("[usbip-server] SET_INTERFACE(iface=%d, alt=%d) failed: %v", wIndex, wValue, err) - status = -32 // -EPIPE - } else { - log.Printf("[usbip-server] SET_INTERFACE(iface=%d, alt=%d) OK", wIndex, wValue) - } - - case bmRequestType == 0x02 && bRequest == 0x01 && wValue == 0x0000: - // CLEAR_FEATURE(ENDPOINT_HALT) (Standard, Endpoint recipient) - if err := s.handle.ClearHalt(uint32(wIndex)); err != nil { - log.Printf("[usbip-server] CLEAR_HALT(ep=0x%02x) failed: %v", wIndex, err) - status = -32 - } - - case bmRequestType == 0x00 && bRequest == 0x09: - // SET_CONFIGURATION — do NOT forward to the physical device. - // The device is already configured (we claimed interfaces during Attach). - // Sending SET_CONFIGURATION via raw USBDEVFS_CONTROL would reset the - // device's endpoint state without updating the kernel's internal USB - // subsystem, breaking all subsequent SETINTERFACE and SUBMITURB calls - // (ESRCH / EHOSTUNREACH). - // Do NOT reset host-side data toggles either: after DisconnectClaimInterface - // the host and device toggles are already in sync. Resetting host-side - // toggles to DATA0 would create a mismatch (device still at its current - // toggle), causing the first interrupt packet to be silently discarded. - log.Printf("[usbip-server] SET_CONFIGURATION(%d) intercepted (device already configured)", wValue) - + req := &ctrlRequest{hdr: hdr, body: body, transferBuf: transferBuf} + select { + case s.ctrlQueue <- req: + case <-s.stop: default: - // Generic OUT control transfer - buf := transferBuf - if buf == nil { - buf = make([]byte, 0) - } - n, err := s.handle.ControlTransfer( - bmRequestType, bRequest, wValue, wIndex, - binary.LittleEndian.Uint16(body.Setup[6:8]), - 5000, buf, - ) - if err != nil { - log.Printf("[usbip-server] CTRL OUT seq=%d failed: %v", hdr.SeqNum, err) - status = -32 // -EPIPE - } else { - actualLength = uint32(n) - log.Printf("[usbip-server] CTRL OUT seq=%d OK actualLength=%d (bmReqType=0x%02x bReq=0x%02x wVal=0x%04x)", - hdr.SeqNum, n, bmRequestType, bRequest, wValue) + log.Printf("[usbip-server] control queue full, stalling on seq=%d", hdr.SeqNum) + select { + case s.ctrlQueue <- req: + case <-s.stop: } } - - log.Printf("[usbip-server] CTRL OUT seq=%d → response status=%d actualLength=%d", hdr.SeqNum, status, actualLength) - resp, err := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, status, actualLength, nil) - if err != nil { - return err - } - retChan <- resp return nil } @@ -455,10 +485,11 @@ func (s *Server) handleCmdSubmit(r io.Reader, hdr *URBHeader, retChan chan<- []b ep |= 0x80 } + urbType := s.getURBType(ep, body.Interval, numPackets) + // Handle isochronous transfers. // Trust the USB/IP NumberOfPackets field rather than our endpoint type map, - // because the map is built at enumeration time (alternate setting 0) and - // webcams only activate ISO endpoints after SET_INTERFACE to alt > 0. + // because a webcam only activates its ISO endpoints after SET_INTERFACE. if numPackets > 0 { return s.handleISOSubmit(hdr, body, transferBuf, isoDescs, numPackets, ep, retChan) } @@ -476,12 +507,13 @@ func (s *Server) handleCmdSubmit(r io.Reader, hdr *URBHeader, retChan chan<- []b Endpoint: ep, Flags: 0, Buffer: buf, - UserContext: uintptr(hdr.SeqNum), + UserContext: uintptr(hdr.SeqNum), }) if err != nil { - log.Printf("[usbip-server] SubmitURB(ep=0x%02x, type=%d, len=%d) FAILED: %v", ep, urbType, len(buf), err) + log.Printf("[usbip-server] SubmitURB(ep=0x%02x, type=%s, len=%d, interval=%d) FAILED: %v", + ep, urbTypeName[urbType], len(buf), body.Interval, err) resp, _ := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, -32, 0, nil) - retChan <- resp + s.send(retChan, resp) return nil } @@ -499,6 +531,123 @@ func (s *Server) handleCmdSubmit(r io.Reader, hdr *URBHeader, retChan chan<- []b return nil } +// controlWorker executes queued control transfers one at a time. +// Endpoint 0 is a single shared pipe, so serialising is both correct and +// what the device expects; the queue exists purely to decouple these +// blocking ioctls from the protocol read loop. +func (s *Server) controlWorker(retChan chan<- []byte) { + defer s.workers.Done() + + for { + select { + case <-s.stop: + return + case req := <-s.ctrlQueue: + resp := s.doControlTransfer(req) + if resp != nil { + s.send(retChan, resp) + } + } + } +} + +// doControlTransfer performs one control transfer and builds its RET_SUBMIT. +func (s *Server) doControlTransfer(req *ctrlRequest) []byte { + hdr, body := req.hdr, req.body + + bmRequestType := body.Setup[0] + bRequest := body.Setup[1] + wValue := binary.LittleEndian.Uint16(body.Setup[2:4]) + wIndex := binary.LittleEndian.Uint16(body.Setup[4:6]) + wLength := binary.LittleEndian.Uint16(body.Setup[6:8]) + + s.mu.Lock() + handle := s.handle + closed := s.closed + s.mu.Unlock() + if closed || handle == nil { + return nil + } + + if hdr.Direction == DirIn { + buf := make([]byte, body.TransferBufferLen) + n, err := handle.ControlTransfer(bmRequestType, bRequest, wValue, wIndex, wLength, controlTimeout, buf) + var status int32 + if err != nil { + log.Printf("[usbip-server] CTRL IN seq=%d bmReqType=0x%02x bReq=0x%02x wVal=0x%04x failed: %v", + hdr.SeqNum, bmRequestType, bRequest, wValue, err) + status = -32 // -EPIPE + n = 0 + } + resp, err := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, status, uint32(n), buf[:n]) + if err != nil { + return nil + } + return resp + } + + var status int32 + var actualLength uint32 + + // Intercept standard USB requests that require special usbdevfs ioctls. + // Raw control transfers via USBDEVFS_CONTROL don't update kernel state. + switch { + case bmRequestType == 0x01 && bRequest == 0x0B: + // SET_INTERFACE (Standard, Interface recipient) + // MUST use USBDEVFS_SETINTERFACE so the kernel updates endpoint state + // and allocates bandwidth for ISO endpoints (critical for webcams). + if err := handle.SetInterface(uint32(wIndex), uint32(wValue)); err != nil { + log.Printf("[usbip-server] SET_INTERFACE(iface=%d, alt=%d) failed: %v", wIndex, wValue, err) + status = -32 // -EPIPE + } else { + log.Printf("[usbip-server] SET_INTERFACE(iface=%d, alt=%d) OK", wIndex, wValue) + } + + case bmRequestType == 0x02 && bRequest == 0x01 && wValue == 0x0000: + // CLEAR_FEATURE(ENDPOINT_HALT) (Standard, Endpoint recipient) + if err := handle.ClearHalt(uint32(wIndex)); err != nil { + log.Printf("[usbip-server] CLEAR_HALT(ep=0x%02x) failed: %v", wIndex, err) + status = -32 + } + + case bmRequestType == 0x00 && bRequest == 0x09: + // SET_CONFIGURATION — do NOT forward to the physical device. + // The device is already configured (we claimed interfaces during Attach). + // Sending SET_CONFIGURATION via raw USBDEVFS_CONTROL would reset the + // device's endpoint state without updating the kernel's internal USB + // subsystem, breaking all subsequent SETINTERFACE and SUBMITURB calls + // (ESRCH / EHOSTUNREACH). + // Do NOT reset host-side data toggles either: after DisconnectClaimInterface + // the host and device toggles are already in sync. Resetting host-side + // toggles to DATA0 would create a mismatch (device still at its current + // toggle), causing the first interrupt packet to be silently discarded. + log.Printf("[usbip-server] SET_CONFIGURATION(%d) intercepted (device already configured)", wValue) + + default: + // Generic OUT control transfer + buf := req.transferBuf + if buf == nil { + buf = make([]byte, 0) + } + n, err := handle.ControlTransfer(bmRequestType, bRequest, wValue, wIndex, wLength, controlTimeout, buf) + if err != nil { + log.Printf("[usbip-server] CTRL OUT seq=%d bmReqType=0x%02x bReq=0x%02x wVal=0x%04x failed: %v", + hdr.SeqNum, bmRequestType, bRequest, wValue, err) + status = -32 // -EPIPE + } else { + // actualLength must be reported for OUT transfers too: the kernel + // UVC driver checks it (a VS_PROBE SET_CUR expects 26). + actualLength = uint32(n) + } + } + + resp, err := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, status, actualLength, nil) + if err != nil { + return nil + } + return resp +} + // handleISOSubmit handles isochronous URB submission func (s *Server) handleISOSubmit(hdr *URBHeader, body *CmdSubmitBody, transferBuf []byte, isoDescs []ISOPacketDescriptor, numPackets int32, ep uint8, retChan chan<- []byte) error { @@ -532,23 +681,19 @@ func (s *Server) handleISOSubmit(hdr *URBHeader, body *CmdSubmitBody, transferBu } } - // Submit ISO URB - log.Printf("[usbip-server] ISO submit: ep=0x%02x dir=%d pkts=%d totalBuf=%d", - ep, hdr.Direction, numPackets, totalBufLen) - urb, isoMem, err := s.handle.SubmitISOURB(&usb.SubmitISOURBParams{ Endpoint: ep, Flags: 0x02, // URB_ISO_ASAP Buffer: buf, - NumberOfPackets: numPackets, + NumberOfPackets: numPackets, PacketLengths: packetLens, - UserContext: uintptr(hdr.SeqNum), + UserContext: uintptr(hdr.SeqNum), }) if err != nil { - log.Printf("[usbip-server] ISO submit FAILED: %v", err) - // Submit failed - send error response + log.Printf("[usbip-server] ISO submit FAILED (ep=0x%02x pkts=%d buf=%d): %v", + ep, numPackets, totalBufLen, err) resp, _ := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, -32, 0, nil) - retChan <- resp + s.send(retChan, resp) return nil } @@ -576,18 +721,22 @@ func (s *Server) handleCmdUnlink(r io.Reader, hdr *URBHeader, retChan chan<- []b return err } - log.Printf("[usbip-server] UNLINK seq=%d target_seq=%d", hdr.SeqNum, body.UnlinkSeqNum) - s.mu.Lock() pending, exists := s.pendingURBs[body.UnlinkSeqNum] if exists { delete(s.pendingURBs, body.UnlinkSeqNum) + // Keep the URB and its buffer reachable until the kernel hands it + // back through the reap loop; discarding is asynchronous. + s.unlinkedURBs[body.UnlinkSeqNum] = pending } + handle := s.handle s.mu.Unlock() + // -ECONNRESET tells the client the URB was actually cancelled; 0 means it + // had already completed, which is also a valid outcome. var status int32 - if exists && pending.urbPtr != nil { - if err := s.handle.DiscardURBByPtr(pending.urbPtr); err == nil { + if exists && pending.urbPtr != nil && handle != nil { + if err := handle.DiscardURBByPtr(pending.urbPtr); err == nil { status = -104 // -ECONNRESET } } @@ -596,37 +745,61 @@ func (s *Server) handleCmdUnlink(r io.Reader, hdr *URBHeader, retChan chan<- []b if err != nil { return err } - retChan <- resp + s.send(retChan, resp) return nil } -// reapLoop continuously reaps completed URBs and sends responses -func (s *Server) reapLoop(retChan chan<- []byte, done <-chan struct{}) { +// reapLoop collects completed URBs and turns them into RET_SUBMIT responses. +func (s *Server) reapLoop(retChan chan<- []byte) { + defer s.workers.Done() + for { select { - case <-done: + case <-s.stop: return default: } s.mu.Lock() - if s.closed || s.handle == nil { - s.mu.Unlock() - return - } - // Save handle reference under lock to prevent nil deref race + closed := s.closed handle := s.handle s.mu.Unlock() + if closed || handle == nil { + return + } - urbInfo, err := handle.ReapURBInfo() + // Wait for a completion rather than spinning on a non-blocking reap. + ready, err := handle.WaitForURB(reapPollInterval) if err != nil { - select { - case <-done: + if errors.Is(err, usb.ErrDeviceGone) { + log.Printf("[usbip-server] device gone, stopping reap loop") return - default: + } + log.Printf("[usbip-server] reap poll error: %v", err) + // Back off so a persistent poll error cannot become a busy loop. + select { + case <-time.After(reapPollInterval): + case <-s.stop: + return + } + continue + } + if !ready { + continue // timeout, re-check shutdown + } + + urbInfo, err := handle.ReapURBInfoNonBlock() + if err != nil { + if errors.Is(err, usb.ErrNoURBReady) { continue } + if errors.Is(err, usb.ErrDeviceGone) { + log.Printf("[usbip-server] device gone, stopping reap loop") + return + } + log.Printf("[usbip-server] reap error: %v", err) + continue } seqNum := uint32(urbInfo.UserContext) @@ -635,34 +808,24 @@ func (s *Server) reapLoop(retChan chan<- []byte, done <-chan struct{}) { pending, exists := s.pendingURBs[seqNum] if exists { delete(s.pendingURBs, seqNum) + } else if _, wasUnlinked := s.unlinkedURBs[seqNum]; wasUnlinked { + // The kernel is done with it; the memory may now be reclaimed. + delete(s.unlinkedURBs, seqNum) } s.mu.Unlock() if !exists { + // Already unlinked; the client is not expecting a RET_SUBMIT. continue } - dirStr := "OUT" - if pending.direction == DirIn { - dirStr = "IN" - } - urbType := s.getURBType(uint8(pending.endpoint)) - typeNames := map[uint8]string{0: "ISO", 1: "INT", 2: "CTRL", 3: "BULK"} - if urbInfo.Status != 0 { - log.Printf("[usbip-server] URB completed: seq=%d EP%d %s type=%s status=%d actual=%d", - pending.seqNum, pending.endpoint, dirStr, typeNames[urbType], urbInfo.Status, urbInfo.ActualLength) - } else if urbType == 1 { // interrupt — always log for HID debugging - hexStr := "" - if pending.direction == DirIn && urbInfo.ActualLength > 0 { - n := int(urbInfo.ActualLength) - if n > 16 { - n = 16 - } - hexStr = fmt.Sprintf(" data=%x", pending.buffer[:n]) + dirStr := "OUT" + if pending.direction == DirIn { + dirStr = "IN" } - log.Printf("[usbip-server] INT completed: seq=%d EP%d %s actual=%d%s", - pending.seqNum, pending.endpoint, dirStr, urbInfo.ActualLength, hexStr) + log.Printf("[usbip-server] URB error: seq=%d EP%d %s status=%d actual=%d", + pending.seqNum, pending.endpoint, dirStr, urbInfo.Status, urbInfo.ActualLength) } var resp []byte @@ -671,7 +834,11 @@ func (s *Server) reapLoop(retChan chan<- []byte, done <-chan struct{}) { } else { var data []byte if pending.direction == DirIn && urbInfo.ActualLength > 0 { - data = pending.buffer[:urbInfo.ActualLength] + n := int(urbInfo.ActualLength) + if n > len(pending.buffer) { + n = len(pending.buffer) + } + data = pending.buffer[:n] } resp, err = BuildRetSubmit( pending.seqNum, @@ -689,7 +856,7 @@ func (s *Server) reapLoop(retChan chan<- []byte, done <-chan struct{}) { select { case retChan <- resp: - case <-done: + case <-s.stop: return } } @@ -712,7 +879,7 @@ func (s *Server) buildISOResponse(urbInfo *usb.ReapedURBInfo, pending *pendingUR usbipDescs = append(usbipDescs, ISOPacketDescriptor{ Offset: bufOffset, - Length: pktLen, + Length: pktLen, ActualLength: actualLen, Status: status, }) @@ -723,7 +890,9 @@ func (s *Server) buildISOResponse(urbInfo *usb.ReapedURBInfo, pending *pendingUR if end > uint32(len(pending.buffer)) { end = uint32(len(pending.buffer)) } - packedData = append(packedData, pending.buffer[bufOffset:end]...) + if bufOffset < end { + packedData = append(packedData, pending.buffer[bufOffset:end]...) + } } bufOffset += pktLen diff --git a/internal/usbip/server_darwin.go b/internal/usbip/server_darwin.go new file mode 100644 index 0000000..cb2ded4 --- /dev/null +++ b/internal/usbip/server_darwin.go @@ -0,0 +1,44 @@ +//go:build darwin + +package usbip + +import ( + "fmt" + "io" + + "github.com/duffy/usb-server/internal/usb" +) + +// Sharing needs a way to submit URBs to a physical device, which macOS only +// offers through IOKit. Until that backend exists the server is a stub, so +// that the rest of the client still builds and runs here. + +type Server struct{} + +func NewServer(dev *usb.Device) *Server { return &Server{} } + +func (s *Server) Attach() error { + return fmt.Errorf("sharing USB devices is not implemented on macOS (needs an IOKit backend)") +} + +func (s *Server) Detach() {} + +func (s *Server) BuildDeviceDescriptor() DeviceDescriptor { return DeviceDescriptor{} } + +func (s *Server) BuildInterfaceDescriptors() []InterfaceDescriptor { return nil } + +func (s *Server) HandleConnection(r io.Reader, w io.Writer) error { + return fmt.Errorf("sharing USB devices is not implemented on macOS") +} + +func (s *Server) HandleDevlistRequest() ([]byte, error) { + return nil, fmt.Errorf("sharing USB devices is not implemented on macOS") +} + +func (s *Server) HandleImportRequest(requestedBusID string) ([]byte, error) { + return nil, fmt.Errorf("sharing USB devices is not implemented on macOS") +} + +func (s *Server) ReadManagementRequest(r io.Reader) ([]byte, bool, error) { + return nil, false, fmt.Errorf("sharing USB devices is not implemented on macOS") +} diff --git a/internal/usbip/server_test.go b/internal/usbip/server_test.go new file mode 100644 index 0000000..70bb864 --- /dev/null +++ b/internal/usbip/server_test.go @@ -0,0 +1,100 @@ +//go:build linux + +package usbip + +import ( + "testing" + + "github.com/duffy/usb-server/internal/usb" +) + +func newTestServer(eps map[uint8]usb.Endpoint) *Server { + dev := &usb.Device{ + BusID: "1-1", + Endpoints: eps, + } + s := NewServer(dev) + s.buildEndpointTypeMap() + return s +} + +// The composite case that broke HID: endpoint number 1 exists as bulk OUT +// (0x01) and interrupt IN (0x81). Both must keep their own transfer type. +func TestGetURBTypeSeparatesDirections(t *testing.T) { + s := newTestServer(map[uint8]usb.Endpoint{ + 0x01: {Address: 0x01, TransferType: usb.TransferTypeBulk}, + 0x81: {Address: 0x81, TransferType: usb.TransferTypeInterrupt, Interval: 10}, + 0x82: {Address: 0x82, TransferType: usb.TransferTypeIsochronous, Interval: 1}, + }) + + tests := []struct { + name string + epAddr uint8 + interval uint32 + packets int32 + want uint8 + }{ + {"bulk OUT endpoint 1", 0x01, 0, 0, usbdevfsTypeBulk}, + {"interrupt IN endpoint 1", 0x81, 10, 0, usbdevfsTypeInterrupt}, + {"isochronous IN endpoint 2", 0x82, 1, 8, usbdevfsTypeISO}, + {"control endpoint 0", 0x00, 0, 0, usbdevfsTypeControl}, + {"control endpoint 0 IN", 0x80, 0, 0, usbdevfsTypeControl}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := s.getURBType(tt.epAddr, tt.interval, tt.packets) + if got != tt.want { + t.Errorf("getURBType(0x%02x, interval=%d, packets=%d) = %s, want %s", + tt.epAddr, tt.interval, tt.packets, urbTypeName[got], urbTypeName[tt.want]) + } + }) + } +} + +// An endpoint missing from the descriptor map must not default to bulk when +// the request carries an interval: only periodic transfers have one, and +// submitting an interrupt endpoint's URB as bulk is what the kernel rejects. +func TestGetURBTypeFallsBackOnInterval(t *testing.T) { + s := newTestServer(nil) + + if got := s.getURBType(0x83, 8, 0); got != usbdevfsTypeInterrupt { + t.Errorf("unknown endpoint with interval=8: got %s, want INT", urbTypeName[got]) + } + if got := s.getURBType(0x02, 0, 0); got != usbdevfsTypeBulk { + t.Errorf("unknown endpoint with interval=0: got %s, want BULK", urbTypeName[got]) + } +} + +// NumberOfPackets is authoritative for isochronous transfers: a webcam only +// activates its ISO endpoints after SET_INTERFACE, so the descriptor map may +// still describe the alternate-setting-0 view when the request arrives. +func TestGetURBTypeISOWinsOverMap(t *testing.T) { + s := newTestServer(map[uint8]usb.Endpoint{ + 0x81: {Address: 0x81, TransferType: usb.TransferTypeBulk}, + }) + + if got := s.getURBType(0x81, 1, 16); got != usbdevfsTypeISO { + t.Errorf("packets=16 should force ISO, got %s", urbTypeName[got]) + } +} + +func TestBuildEndpointTypeMapFallsBackToInterfaces(t *testing.T) { + dev := &usb.Device{ + BusID: "1-1", + Interfaces: []usb.Interface{{ + Number: 0, + Class: 0x03, + Endpoints: []usb.Endpoint{ + {Address: 0x81, TransferType: usb.TransferTypeInterrupt, Interval: 10}, + }, + }}, + } + + s := NewServer(dev) + s.buildEndpointTypeMap() + + if got := s.getURBType(0x81, 10, 0); got != usbdevfsTypeInterrupt { + t.Errorf("sysfs fallback lost the interrupt type: got %s", urbTypeName[got]) + } +} diff --git a/internal/usbip/server_windows.go b/internal/usbip/server_windows.go index 40e87c3..20f78a7 100644 --- a/internal/usbip/server_windows.go +++ b/internal/usbip/server_windows.go @@ -3,45 +3,467 @@ package usbip import ( + "encoding/binary" + "errors" "fmt" "io" + "log" + "sync" "github.com/duffy/usb-server/internal/usb" ) -// Server is a stub on Windows - USB/IP server requires Linux usbdevfs. -type Server struct{} +// USB/IP server for Windows, driving devices through the usbshare filter +// driver (driver/windows). +// +// The shape differs from the Linux server because the interfaces differ: +// usbdevfs submits asynchronously and hands completions back through a reap +// loop, whereas the filter driver's IOCTL blocks until the transfer finishes. +// Concurrency therefore comes from a pool of workers rather than from one +// reaper. +// +// UNTESTED: this depends on the filter driver, which has never been built or +// run. Treat it as a starting point, not as working code. +// transferWorkers bounds how many transfers are in flight at once. USB/IP +// clients keep several outstanding, and serialising them would stall the +// device on every round trip. +const transferWorkers = 8 + +// Server handles USB/IP protocol on the share side. +type Server struct { + device *usb.Device + handle *usb.DriverHandle + + mu sync.Mutex + closed bool + pending map[uint32]uint64 // USB/IP seqnum -> driver transfer ID + + epTypes map[uint8]uint8 + + work chan *transferJob + ctrlWork chan *transferJob + stop chan struct{} + workers sync.WaitGroup + stopOnce sync.Once +} + +// transferJob is one queued USB/IP request. +type transferJob struct { + hdr *URBHeader + body *CmdSubmitBody + transferBuf []byte + retChan chan<- []byte +} + +// NewServer creates a USB/IP server for a specific device. func NewServer(dev *usb.Device) *Server { - return &Server{} + return &Server{ + device: dev, + pending: make(map[uint32]uint64), + epTypes: make(map[uint8]uint8), + work: make(chan *transferJob, 64), + ctrlWork: make(chan *transferJob, 64), + stop: make(chan struct{}), + } } +// Attach opens the device through the filter driver and claims it. func (s *Server) Attach() error { - return fmt.Errorf("USB/IP server not supported on Windows") -} + if s.device.DevPath == "" { + return fmt.Errorf("device %s has no driver path; is the usbshare filter attached?", s.device.BusID) + } -func (s *Server) Detach() {} + handle, err := usb.OpenDriverDevice(s.device.DevPath) + if err != nil { + return fmt.Errorf("claiming %s: %w", s.device.BusID, err) + } + s.handle = handle -func (s *Server) BuildDeviceDescriptor() DeviceDescriptor { - return DeviceDescriptor{} -} - -func (s *Server) BuildInterfaceDescriptors() []InterfaceDescriptor { + s.buildEndpointTypeMap() return nil } +// Detach releases the device back to its class driver. +func (s *Server) Detach() { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.closed = true + s.mu.Unlock() + + s.stopOnce.Do(func() { close(s.stop) }) + s.workers.Wait() + + if s.handle != nil { + s.handle.Close() + s.handle = nil + } +} + +// buildEndpointTypeMap indexes endpoints by full address, including the +// direction bit — a composite device can use the same endpoint number for an +// interrupt IN and a bulk OUT, and conflating them breaks HID devices. +func (s *Server) buildEndpointTypeMap() { + for _, ep := range s.device.Endpoints { + s.epTypes[ep.Address] = ep.TransferType + } + if len(s.epTypes) > 0 { + return + } + for _, iface := range s.device.Interfaces { + for _, ep := range iface.Endpoints { + s.epTypes[ep.Address] = ep.TransferType + } + } +} + +// getTransferType maps an endpoint to a driver transfer type, falling back to +// the request's interval: only periodic transfers carry one, so an unknown +// endpoint with a non-zero interval is interrupt rather than bulk. +func (s *Server) getTransferType(epAddr uint8, interval uint32) uint8 { + if epAddr&0x0F == 0 { + return usb.TransferTypeControl + } + if t, ok := s.epTypes[epAddr]; ok { + return t + } + if interval > 0 { + return usb.TransferTypeInterrupt + } + return usb.TransferTypeBulk +} + +// BuildDeviceDescriptor creates a USB/IP device descriptor. +func (s *Server) BuildDeviceDescriptor() DeviceDescriptor { + var desc DeviceDescriptor + SetPath(&desc.Path, s.device.DevPath) + SetBusID(&desc.BusID, s.device.BusID) + desc.BusNum = s.device.BusNum + desc.DevNum = s.device.DevNum + desc.Speed = s.device.Speed + desc.IDVendor = s.device.VendorID + desc.IDProduct = s.device.ProductID + desc.BcdDevice = s.device.BcdDevice + desc.BDeviceClass = s.device.DeviceClass + desc.BDeviceSubClass = s.device.DeviceSubClass + desc.BDeviceProtocol = s.device.DeviceProtocol + desc.BConfigurationValue = s.device.ConfigValue + desc.BNumConfigurations = s.device.NumConfigs + desc.BNumInterfaces = uint8(len(s.device.Interfaces)) + return desc +} + +// BuildInterfaceDescriptors creates USB/IP interface descriptors. +func (s *Server) BuildInterfaceDescriptors() []InterfaceDescriptor { + var descs []InterfaceDescriptor + for _, iface := range s.device.Interfaces { + descs = append(descs, InterfaceDescriptor{ + BInterfaceClass: iface.Class, + BInterfaceSubClass: iface.SubClass, + BInterfaceProtocol: iface.Protocol, + }) + } + return descs +} + +// HandleConnection processes USB/IP protocol on a bidirectional stream. func (s *Server) HandleConnection(r io.Reader, w io.Writer) error { - return fmt.Errorf("USB/IP server not supported on Windows") + retChan := make(chan []byte, 256) + + // Control transfers get their own serial worker because endpoint 0 is a + // single shared pipe; everything else runs on a pool. + s.workers.Add(1) + go s.controlWorker(retChan) + + for i := 0; i < transferWorkers; i++ { + s.workers.Add(1) + go s.transferWorker(retChan) + } + + connDone := make(chan struct{}) + defer close(connDone) + + go func() { + for { + select { + case data := <-retChan: + if _, err := w.Write(data); err != nil { + return + } + case <-connDone: + return + case <-s.stop: + return + } + } + }() + + for { + hdr, err := ReadURBHeader(r) + if err != nil { + if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.ErrClosedPipe) { + return nil + } + return fmt.Errorf("reading URB header: %w", err) + } + + switch hdr.Command { + case CmdSubmit: + if err := s.handleCmdSubmit(r, hdr, retChan); err != nil { + return fmt.Errorf("handling CMD_SUBMIT: %w", err) + } + case CmdUnlink: + if err := s.handleCmdUnlink(r, hdr, retChan); err != nil { + return fmt.Errorf("handling CMD_UNLINK: %w", err) + } + default: + return fmt.Errorf("unknown URB command: 0x%08x", hdr.Command) + } + } } +func (s *Server) handleCmdSubmit(r io.Reader, hdr *URBHeader, retChan chan<- []byte) error { + body, err := ReadCmdSubmit(r) + if err != nil { + return err + } + + var transferBuf []byte + if hdr.Direction == DirOut && body.TransferBufferLen > 0 { + transferBuf = make([]byte, body.TransferBufferLen) + if _, err := io.ReadFull(r, transferBuf); err != nil { + return fmt.Errorf("reading transfer buffer: %w", err) + } + } + + // Isochronous transfers are not supported by the driver yet; the packet + // descriptors still have to be consumed or the stream desynchronises. + if body.NumberOfPackets != 0xFFFFFFFF && body.NumberOfPackets > 0 { + descs := make([]ISOPacketDescriptor, body.NumberOfPackets) + binary.Read(r, binary.BigEndian, &descs) + + log.Printf("[usbip-win] isochronous transfer on EP%d rejected (not implemented)", hdr.Endpoint) + resp, _ := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, -32, 0, nil) + s.send(retChan, resp) + return nil + } + + job := &transferJob{hdr: hdr, body: body, transferBuf: transferBuf, retChan: retChan} + + queue := s.work + if hdr.Endpoint == 0 { + queue = s.ctrlWork + } + + select { + case queue <- job: + case <-s.stop: + } + return nil +} + +func (s *Server) controlWorker(retChan chan<- []byte) { + defer s.workers.Done() + + for { + select { + case <-s.stop: + return + case job := <-s.ctrlWork: + s.runTransfer(job, retChan) + } + } +} + +func (s *Server) transferWorker(retChan chan<- []byte) { + defer s.workers.Done() + + for { + select { + case <-s.stop: + return + case job := <-s.work: + s.runTransfer(job, retChan) + } + } +} + +// runTransfer performs one transfer and emits its RET_SUBMIT. +func (s *Server) runTransfer(job *transferJob, retChan chan<- []byte) { + hdr, body := job.hdr, job.body + + s.mu.Lock() + handle := s.handle + closed := s.closed + s.mu.Unlock() + + if closed || handle == nil { + return + } + + epAddr := uint8(hdr.Endpoint) + if hdr.Direction == DirIn { + epAddr |= 0x80 + } + + direction := uint8(0) // USBSHARE_DIR_OUT + if hdr.Direction == DirIn { + direction = 1 + } + + // Intercept the standard requests that need dedicated driver calls: + // sending them as raw control transfers changes the device without + // telling the USB stack, after which later transfers fail. + if hdr.Endpoint == 0 && hdr.Direction == DirOut { + bmRequestType := body.Setup[0] + bRequest := body.Setup[1] + wValue := binary.LittleEndian.Uint16(body.Setup[2:4]) + wIndex := binary.LittleEndian.Uint16(body.Setup[4:6]) + + switch { + case bmRequestType == 0x01 && bRequest == 0x0B: // SET_INTERFACE + var status int32 + if err := handle.SetInterface(uint8(wIndex), uint8(wValue)); err != nil { + log.Printf("[usbip-win] SET_INTERFACE(%d, %d) failed: %v", wIndex, wValue, err) + status = -32 + } + resp, _ := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, status, 0, nil) + s.send(retChan, resp) + return + + case bmRequestType == 0x02 && bRequest == 0x01 && wValue == 0x0000: // CLEAR_FEATURE(HALT) + var status int32 + if err := handle.ClearHalt(uint8(wIndex)); err != nil { + log.Printf("[usbip-win] CLEAR_HALT(0x%02x) failed: %v", wIndex, err) + status = -32 + } + resp, _ := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, status, 0, nil) + s.send(retChan, resp) + return + + case bmRequestType == 0x00 && bRequest == 0x09: // SET_CONFIGURATION + // The device is already configured; forwarding this would reset + // its endpoint state behind the stack's back. + log.Printf("[usbip-win] SET_CONFIGURATION(%d) intercepted", wValue) + resp, _ := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, 0, 0, nil) + s.send(retChan, resp) + return + } + } + + var buf []byte + if hdr.Direction == DirIn { + buf = make([]byte, body.TransferBufferLen) + } else { + buf = job.transferBuf + if buf == nil { + buf = make([]byte, 0) + } + } + + params := &usb.TransferParams{ + EndpointAddress: epAddr, + Type: s.getTransferType(epAddr, body.Interval), + Direction: direction, + Data: buf, + TimeoutMS: 5000, + Setup: body.Setup, + } + + n, err := handle.Transfer(params) + + var status int32 + if err != nil { + log.Printf("[usbip-win] transfer on EP%d failed: %v", hdr.Endpoint, err) + status = -32 // -EPIPE + } + + // actualLength must be reported for both directions: the kernel UVC + // driver checks it on OUT control transfers. + var data []byte + if hdr.Direction == DirIn && n > 0 { + if n > len(buf) { + n = len(buf) + } + data = buf[:n] + } + + resp, buildErr := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, + status, uint32(n), data) + if buildErr != nil { + return + } + s.send(retChan, resp) +} + +func (s *Server) handleCmdUnlink(r io.Reader, hdr *URBHeader, retChan chan<- []byte) error { + body, err := ReadCmdUnlink(r) + if err != nil { + return err + } + + // Cancellation is best effort here: the driver tracks transfers by its + // own ID, and a transfer already completing cannot be recalled. + resp, err := BuildRetUnlink(hdr.SeqNum, hdr.DevID, 0) + if err != nil { + return err + } + s.send(retChan, resp) + + log.Printf("[usbip-win] UNLINK for seq=%d acknowledged", body.UnlinkSeqNum) + return nil +} + +func (s *Server) send(retChan chan<- []byte, resp []byte) { + select { + case retChan <- resp: + case <-s.stop: + } +} + +// HandleDevlistRequest handles an OP_REQ_DEVLIST for this device. func (s *Server) HandleDevlistRequest() ([]byte, error) { - return nil, fmt.Errorf("USB/IP server not supported on Windows") + desc := s.BuildDeviceDescriptor() + ifaceDescs := s.BuildInterfaceDescriptors() + return BuildDevlistReply([]DeviceDescriptor{desc}, [][]InterfaceDescriptor{ifaceDescs}) } +// HandleImportRequest handles an OP_REQ_IMPORT for this device. func (s *Server) HandleImportRequest(requestedBusID string) ([]byte, error) { - return nil, fmt.Errorf("USB/IP server not supported on Windows") + if requestedBusID != s.device.BusID { + return BuildImportReply(1, nil) + } + desc := s.BuildDeviceDescriptor() + return BuildImportReply(0, &desc) } +// ReadManagementRequest reads and dispatches a management phase message. func (s *Server) ReadManagementRequest(r io.Reader) (response []byte, startTransfer bool, err error) { - return nil, false, fmt.Errorf("USB/IP server not supported on Windows") + hdr, err := ReadOpHeader(r) + if err != nil { + return nil, false, err + } + + switch hdr.Command { + case OpReqDevlist: + resp, err := s.HandleDevlistRequest() + return resp, false, err + + case OpReqImport: + var busID [32]byte + if _, err := io.ReadFull(r, busID[:]); err != nil { + return nil, false, err + } + resp, err := s.HandleImportRequest(GetBusID(busID)) + if err != nil { + return nil, false, err + } + return resp, len(resp) > 8, nil + + default: + return nil, false, fmt.Errorf("unknown management command: 0x%04x", hdr.Command) + } } diff --git a/internal/usbip/trace.go b/internal/usbip/trace.go new file mode 100644 index 0000000..a12cf76 --- /dev/null +++ b/internal/usbip/trace.go @@ -0,0 +1,87 @@ +package usbip + +import ( + "encoding/binary" + "fmt" + "log" +) + +// urbHeaderSize is the fixed 20-byte USB/IP basic header. +const urbHeaderSize = 20 + +// urbMessageSize is the basic header plus the 28-byte command/return body. +const urbMessageSize = 48 + +// TraceRequest logs a CMD_SUBMIT or CMD_UNLINK frame travelling from the use +// side towards the share side. Callers must gate this on protocol.Debug. +func TraceRequest(tag string, data []byte) { + if len(data) < urbHeaderSize { + return + } + + cmd := binary.BigEndian.Uint32(data[0:4]) + seqNum := binary.BigEndian.Uint32(data[4:8]) + dir := binary.BigEndian.Uint32(data[12:16]) + ep := binary.BigEndian.Uint32(data[16:20]) + + switch cmd { + case CmdSubmit: + var extra string + if ep == 0 && len(data) >= urbMessageSize { + // The 8-byte setup packet sits at the end of the command body. + setup := data[urbMessageSize-8 : urbMessageSize] + extra = fmt.Sprintf(" setup=0x%02x/0x%02x wVal=0x%04x wIdx=0x%04x wLen=%d", + setup[0], setup[1], + binary.LittleEndian.Uint16(setup[2:4]), + binary.LittleEndian.Uint16(setup[4:6]), + binary.LittleEndian.Uint16(setup[6:8])) + } + log.Printf("[%s] -> CMD_SUBMIT seq=%d EP%d %s%s (%d bytes)", + tag, seqNum, ep, dirName(dir), extra, len(data)) + + case CmdUnlink: + log.Printf("[%s] -> CMD_UNLINK seq=%d (%d bytes)", tag, seqNum, len(data)) + } +} + +// TraceResponse logs a RET_SUBMIT or RET_UNLINK frame travelling from the +// share side back to the use side. Callers must gate this on protocol.Debug. +func TraceResponse(tag string, data []byte) { + if len(data) < urbMessageSize { + return + } + + cmd := binary.BigEndian.Uint32(data[0:4]) + seqNum := binary.BigEndian.Uint32(data[4:8]) + dir := binary.BigEndian.Uint32(data[12:16]) + ep := binary.BigEndian.Uint32(data[16:20]) + status := int32(binary.BigEndian.Uint32(data[20:24])) + + switch cmd { + case RetSubmit: + actualLen := binary.BigEndian.Uint32(data[24:28]) + var payload string + if dir == DirIn && actualLen > 0 && len(data) > urbMessageSize { + end := urbMessageSize + int(actualLen) + if end > len(data) { + end = len(data) + } + if end-urbMessageSize > 16 { + end = urbMessageSize + 16 + } + payload = fmt.Sprintf(" data=%x", data[urbMessageSize:end]) + } + log.Printf("[%s] <- RET_SUBMIT seq=%d EP%d %s status=%d actual=%d%s", + tag, seqNum, ep, dirName(dir), status, actualLen, payload) + + case RetUnlink: + log.Printf("[%s] <- RET_UNLINK seq=%d status=%d", tag, seqNum, status) + } +} + +func dirName(dir uint32) string { + if dir == DirIn { + return "IN" + } + return "OUT" +} diff --git a/internal/usbip/vhci.go b/internal/usbip/vhci.go index 5f801e4..c336cd8 100644 --- a/internal/usbip/vhci.go +++ b/internal/usbip/vhci.go @@ -14,12 +14,12 @@ const vhciBasePath = "/sys/devices/platform/vhci_hcd.0" // VHCIPort represents a virtual USB port on the VHCI controller type VHCIPort struct { - Hub string // "hs" or "ss" - Port int - Status int - Speed int - DevID uint32 - SocketFD int + Hub string // "hs" or "ss" + Port int + Status int + Speed int + DevID uint32 + SocketFD int LocalBusID string } diff --git a/internal/usbip/vhci_darwin.go b/internal/usbip/vhci_darwin.go new file mode 100644 index 0000000..2ae5e4d --- /dev/null +++ b/internal/usbip/vhci_darwin.go @@ -0,0 +1,28 @@ +//go:build darwin + +package usbip + +import "fmt" + +// Receiving remote devices needs a virtual USB host controller. On macOS that +// means a DriverKit driver, which needs an Apple developer identity and +// notarisation — the same class of hurdle as signing a Windows kernel driver. + +func IsVHCIAvailable() bool { return false } + +func VHCIUnavailableError() error { + return fmt.Errorf("receiving USB devices is not supported on macOS: " + + "it needs a virtual USB host controller, for which no signed driver exists here") +} + +func DetachDevice(port int) error { + return VHCIUnavailableError() +} + +func FindFreePort(speed uint32) (int, error) { + return -1, VHCIUnavailableError() +} + +func AttachDevice(port int, sockfd int, devID uint32, speed uint32) error { + return VHCIUnavailableError() +} diff --git a/internal/web/handler.go b/internal/web/handler.go index e17cb61..00bcc3a 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -8,6 +8,7 @@ import ( "net/http" "github.com/duffy/usb-server/internal/config" + "github.com/duffy/usb-server/internal/protocol" "github.com/duffy/usb-server/internal/token" ) @@ -16,16 +17,16 @@ var staticFiles embed.FS // Handler provides the web UI and API type Handler struct { - cfg *config.Config - cfgPath string - mux *http.ServeMux + cfg *config.Config + cfgPath string + mux *http.ServeMux // Callbacks for device operations - GetDevices func() interface{} - AttachDevice func(clientID, busID string) error - DetachDevice func(clientID, busID string) error - SetAutoConnect func(vendorID, productID string, enabled bool) error - IsAutoConnect func(vendorID, productID string) bool + GetDevices func() interface{} + AttachDevice func(clientID, busID string) error + DetachDevice func(clientID, busID string) error + SetAutoConnect func(vendorID, productID string, enabled bool) error + IsAutoConnect func(vendorID, productID string) bool ForceDetachDevice func(clientID, busID string) error InstallService func() error UninstallService func() error @@ -171,6 +172,8 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { Name string `json:"name"` WebPort int `json:"web_port"` AllowForceDetach *bool `json:"allow_force_detach,omitempty"` + DirectPort *int `json:"direct_port,omitempty"` + DisableDirect *bool `json:"disable_direct,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { writeJSON(w, map[string]interface{}{"ok": false, "error": "invalid request"}) @@ -180,7 +183,7 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { if updates.RelayAddr != "" { h.cfg.RelayAddr = updates.RelayAddr } - if updates.Mode == "share" || updates.Mode == "use" { + if protocol.ValidMode(updates.Mode) { h.cfg.Mode = updates.Mode } if updates.Name != "" { @@ -192,6 +195,12 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { if updates.AllowForceDetach != nil { h.cfg.AllowForceDetach = *updates.AllowForceDetach } + if updates.DirectPort != nil && *updates.DirectPort >= 0 && *updates.DirectPort <= 65535 { + h.cfg.DirectPort = *updates.DirectPort + } + if updates.DisableDirect != nil { + h.cfg.DisableDirect = *updates.DisableDirect + } if err := h.cfg.Save(h.cfgPath); err != nil { writeJSON(w, map[string]interface{}{"ok": false, "error": err.Error()}) diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 469eb69..2a5c76e 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -27,10 +27,17 @@ async function updateStatus() { el.className = 'status disconnected'; } + const modeNames = { + share: 'Freigeben', + use: 'Empfangen', + both: 'Freigeben und Empfangen', + }; document.getElementById('mode-info').innerHTML = - `Modus: ${data.mode === 'share' ? 'Freigeben' : 'Empfangen'} | ` + - `Name: ${data.name} | ` + - `Client ID: ${data.client_id ? data.client_id.substring(0, 8) + '...' : '-'}`; + `Modus: ${escapeHtml(modeNames[data.mode] || data.mode)} | ` + + `Name: ${escapeHtml(data.name)} | ` + + `Client ID: ${data.client_id ? escapeHtml(data.client_id.substring(0, 8)) + '...' : '-'}` + + (data.encrypted ? ' | Tunnel: verschluesselt' + : ' | Tunnel: unverschluesselt (keine Tokens)'); } catch (e) { const el = document.getElementById('status'); el.textContent = 'Fehler'; @@ -53,26 +60,34 @@ async function updateDevices() { function renderDevices(data) { const container = document.getElementById('device-list'); - if (data.mode === 'share') { - renderShareDevices(container, data.local_devices || []); - } else { - renderUseDevices(container, data.available_devices || [], data.attached_devices || []); + // In "both" mode show remote devices first — those are the ones you act + // on — then the local devices this machine offers. + let html = ''; + if (data.available_devices || data.attached_devices) { + html += renderUseSection(data.available_devices || [], data.attached_devices || []); } + if (data.local_devices) { + if (html) { + html += '
Eigene Geraete (freigegeben)
'; + } + html += renderShareSection(data.local_devices); + } + + container.innerHTML = html || '

Keine Geraete

'; } -function renderShareDevices(container, devices) { +function renderShareSection(devices) { if (!devices || devices.length === 0) { - container.innerHTML = '

Keine USB-Geraete gefunden

'; - return; + return '

Keine USB-Geraete gefunden

'; } - container.innerHTML = devices.map(dev => ` + return devices.map(dev => `
${escapeHtml(dev.name)}
- Bus: ${dev.bus_id} - VID:PID: ${dev.vendor_id}:${dev.product_id} + Bus: ${escapeHtml(dev.bus_id)} + VID:PID: ${escapeHtml(dev.vendor_id)}:${escapeHtml(dev.product_id)} Speed: ${speedName(dev.speed)}
@@ -85,7 +100,7 @@ function renderShareDevices(container, devices) { `).join(''); } -function renderUseDevices(container, available, attached) { +function renderUseSection(available, attached) { let html = ''; // Attached devices first @@ -97,18 +112,18 @@ function renderUseDevices(container, available, attached) {
${escapeHtml(dev.name || dev.bus_id)}
Von: ${escapeHtml(dev.client_name || dev.client_id)} - ${dev.vendor_id ? `VID:PID: ${dev.vendor_id}:${dev.product_id}` : ''} - VHCI Port: ${dev.vhci_port} + ${dev.vendor_id ? `VID:PID: ${escapeHtml(dev.vendor_id)}:${escapeHtml(dev.product_id)}` : ''} + VHCI Port: ${escapeHtml(dev.vhci_port)}
Verbunden - +
`).join(''); @@ -125,12 +140,11 @@ function renderUseDevices(container, available, attached) { }); if (Object.keys(byClient).length === 0 && (!attached || attached.length === 0)) { - container.innerHTML = '

Keine Geraete verfuegbar. Warte auf Share-Clients...

'; - return; + return '

Keine fremden Geraete verfuegbar. Warte auf Share-Clients...

'; } for (const [clientId, info] of Object.entries(byClient)) { - html += `
${escapeHtml(info.name)} (${clientId.substring(0, 8)}...)
`; + html += `
${escapeHtml(info.name)} (${escapeHtml(clientId.substring(0, 8))}...)
`; html += info.devices.map(dev => { const isAttached = (attached || []).some(a => a.bus_id === dev.bus_id && a.client_id === clientId @@ -140,19 +154,19 @@ function renderUseDevices(container, available, attached) {
${escapeHtml(dev.name)}
- Bus: ${dev.bus_id} - VID:PID: ${dev.vendor_id}:${dev.product_id} + Bus: ${escapeHtml(dev.bus_id)} + VID:PID: ${escapeHtml(dev.vendor_id)}:${escapeHtml(dev.product_id)} Speed: ${speedName(dev.speed)}
${dev.status === 'in_use' ? `In Benutzung - ${dev.allow_force_detach ? `` : ''}` + ${dev.allow_force_detach ? `` : ''}` : isAttached ? 'Verbunden' : `Verfuegbar - ` + ` }
@@ -160,7 +174,7 @@ function renderUseDevices(container, available, attached) { }).join(''); } - container.innerHTML = html || '

Keine Geraete verfuegbar

'; + return html; } // Attach/Detach @@ -246,6 +260,8 @@ async function loadSettings() { document.getElementById('client-name').value = cfg.name || ''; document.getElementById('web-port').value = cfg.web_port || 8080; document.getElementById('allow-force-detach').checked = cfg.allow_force_detach || false; + document.getElementById('direct-port').value = cfg.direct_port || 0; + document.getElementById('disable-direct').checked = cfg.disable_direct || false; document.getElementById('token1').value = cfg.token1 || ''; document.getElementById('token2').value = cfg.token2 || ''; document.getElementById('token3').value = cfg.token3 || ''; @@ -269,6 +285,8 @@ document.getElementById('settings-form').addEventListener('submit', async (e) => name: document.getElementById('client-name').value, web_port: parseInt(document.getElementById('web-port').value), allow_force_detach: document.getElementById('allow-force-detach').checked, + direct_port: parseInt(document.getElementById('direct-port').value) || 0, + disable_direct: document.getElementById('disable-direct').checked, }) }); const data = await resp.json(); @@ -354,8 +372,28 @@ function speedName(speed) { } function escapeHtml(str) { - if (!str) return ''; - return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// jsArg escapes a value for use inside a single-quoted JavaScript string that +// itself sits in an HTML attribute. Bus IDs, client IDs and vendor strings all +// arrive from remote peers, so interpolating them raw would let another client +// in the group inject script into this UI. +function jsArg(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/"/g, '"') + .replace(//g, '\\x3e') + .replace(/&/g, '\\x26'); } // Init diff --git a/internal/web/static/index.html b/internal/web/static/index.html index a899415..1e21a5d 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -43,9 +43,11 @@
+ Nach dem Speichern neu starten, damit der Modus wirkt
@@ -62,6 +64,20 @@ Erlaubt Use-Clients, Geraete die von anderen benutzt werden zu trennen
+
+ + + 0 = zufaelliger Port. Fest setzen, wenn der Port durch eine Firewall + oder NAT weitergeleitet werden muss. +
+
+ + Erzwingt, dass aller USB-Verkehr ueber den Relay laeuft. Normalerweise + verbinden sich Clients direkt, was Latenz spart und den Relay entlastet. +