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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 22:02:04 +02:00
co-authored by Claude Opus 5
parent 54178dce75
commit 9ed473a965
95 changed files with 12181 additions and 892 deletions
+13
View File
@@ -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*
+21
View File
@@ -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
+21 -2
View File
@@ -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"]
+39
View File
@@ -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"]
+64 -4
View File
@@ -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 .
+196 -25
View File
@@ -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 `<relay>/diag/<id>` 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:
+4
View File
@@ -0,0 +1,4 @@
.gradle/
build/
local.properties
app/src/main/jniLibs/*/libusbclient.so
+70
View File
@@ -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.
+38
View File
@@ -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")
}
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- USB host mode. required="true" keeps the app off devices without an
OTG port, where it would have nothing to share. -->
<uses-feature android:name="android.hardware.usb.host" android:required="true" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="USB Server"
android:icon="@android:drawable/stat_sys_data_bluetooth"
android:allowBackup="false"
android:supportsRtl="true">
<!-- extractNativeLibs is what makes the bundled usb-client binary
executable: Android only grants execute permission to files
unpacked into the native library directory. -->
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Offer to start when a device is plugged in. -->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</activity>
<service
android:name=".ShareService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application>
</manifest>
@@ -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<String, String, String>, 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<Unit> {
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, String, String>): 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"
}
}
@@ -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)
}
}
@@ -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"
}
}
@@ -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<String, UsbDeviceConnection>()
/**
* 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<Unit> {
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<Unit> {
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
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Matches any USB device. Narrow this to specific vendor/product IDs if the
app should only offer to start for particular hardware. -->
<resources>
<usb-device />
</resources>
+4
View File
@@ -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
}
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "usb-server-android"
include(":app")
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+275 -112
View File
@@ -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()
+16 -1
View File
@@ -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))
}
+35 -1
View File
@@ -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
+57
View File
@@ -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 "$@"
+118
View File
@@ -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 300500 €/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.
+284
View File
@@ -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"));
}
}
+92
View File
@@ -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);
}
+162
View File
@@ -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 <initguid.h>
/*
* 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)
+555
View File
@@ -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 *)&params, 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 *)&params, 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);
}
+98
View File
@@ -0,0 +1,98 @@
/*
* usbshare - internal declarations
*/
#pragma once
#include <ntddk.h>
#include <wdf.h>
#include <usb.h>
#include <usbdlib.h>
#include <wdfusb.h>
#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);
+69
View File
@@ -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"
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|ARM64">
<Configuration>Release</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<ProjectGuid>{A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D}</ProjectGuid>
<TargetVersion>Windows10</TargetVersion>
<DriverType>KMDF</DriverType>
<!-- KMDF 1.15 keeps this loadable back to Windows 10 1507. -->
<KMDF_VERSION_MAJOR>1</KMDF_VERSION_MAJOR>
<KMDF_VERSION_MINOR>15</KMDF_VERSION_MINOR>
<RootNamespace>usbshare</RootNamespace>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Label="Configuration">
<ConfigurationType>Driver</ConfigurationType>
<PlatformToolset>WindowsKernelModeDriver10.0</PlatformToolset>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ItemDefinitionGroup>
<ClCompile>
<!-- Warnings as errors: in kernel code a warning is usually a crash
waiting for the right input. -->
<TreatWarningAsError>true</TreatWarningAsError>
<WarningLevel>Level4</WarningLevel>
<PreprocessorDefinitions>_WIN64;AMD64;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ClCompile>
<Link>
<AdditionalDependencies>$(DDK_LIB_PATH)\usbdex.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="driver.c" />
<ClCompile Include="queue.c" />
<ClCompile Include="filter.c" />
<ClInclude Include="usbshare.h" />
<ClInclude Include="public.h" />
<Inf Include="usbshare.inf" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
</Project>
+3 -1
View File
@@ -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
+4
View File
@@ -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=
+294
View File
@@ -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
}
+21
View File
@@ -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 }
+277
View File
@@ -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")
}
}
+358 -72
View File
@@ -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)
}
}
+13
View File
@@ -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
}
+305
View File
@@ -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
}
+479
View File
@@ -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
}
+187
View File
@@ -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
}
+264 -31
View File
@@ -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
}
+31
View File
@@ -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) {}
+98
View File
@@ -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
+3
View File
@@ -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) {}
+106
View File
@@ -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()
}
+212
View File
@@ -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")
}
}
+130
View File
@@ -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
}
}
}
+283 -91
View File
@@ -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)
}
+30 -4
View File
@@ -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
+296
View File
@@ -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")
}
}
+163
View File
@@ -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
}
+76
View File
@@ -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)
}
+255
View File
@@ -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
}
+163
View File
@@ -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.")
}
+170
View File
@@ -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",
}
}
}
+198
View File
@@ -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)
}
}
}
+309
View File
@@ -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")
}
}
+61
View File
@@ -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,
}
}
+89
View File
@@ -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"
+30
View File
@@ -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...)
}
}
+78 -28
View File
@@ -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]
}
+153
View File
@@ -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/<id>", 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)
}
+233
View File
@@ -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())
}
}
+233 -127
View File
@@ -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
}
+465
View File
@@ -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)
}
})
}
}
+111 -36
View File
@@ -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
}
}
}
+26
View File
@@ -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")
}
+83
View File
@@ -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)
}
}
+18
View File
@@ -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() {}
+169
View File
@@ -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
}
+238
View File
@@ -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
}
+44 -27
View File
@@ -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
+320
View File
@@ -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]),
}
}
+119
View File
@@ -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
}
}
+67 -21
View File
@@ -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"),
})
}
+215 -3
View File
@@ -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
}
}
+127
View File
@@ -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
}
+116 -14
View File
@@ -3,8 +3,10 @@
package usb
import (
"errors"
"fmt"
"os"
"time"
"unsafe"
"golang.org/x/sys/unix"
@@ -81,7 +83,7 @@ type usbdevfsBulkTransfer struct {
}
type usbdevfsSetIntf struct {
Interface uint32
Interface uint32
AltSetting uint32
}
@@ -117,7 +119,7 @@ type usbdevfsURB struct {
NumberOfPackets int32 // or StreamID
ErrorCount int32
Signr uint32
UserContext uintptr
UserContext uintptr
// ISO packet descriptors follow in memory if Type == urbTypeISO
}
@@ -126,10 +128,25 @@ type DeviceHandle struct {
fd int
busID string
devPath string
// adopted marks a descriptor handed to us from outside rather than
// opened here. It is closed on Close like any other, but the distinction
// matters for diagnostics: an adopted descriptor means the host process
// could not have opened the device itself.
adopted bool
}
// OpenDevice opens a USB device file for direct access
// OpenDevice opens a USB device file for direct access.
//
// If an external file descriptor has been registered for this device (see
// AdoptDeviceFD) it is used instead of opening the path. That is how Android
// works: apps cannot open /dev/bus/usb themselves, so a small Java shim asks
// the system for permission and hands the resulting descriptor down.
func OpenDevice(devPath string, busID string) (*DeviceHandle, error) {
if fd, ok := takeAdoptedFD(busID); ok {
return &DeviceHandle{fd: fd, busID: busID, devPath: devPath, adopted: true}, nil
}
fd, err := unix.Open(devPath, unix.O_RDWR, 0)
if err != nil {
return nil, fmt.Errorf("opening %s: %w", devPath, err)
@@ -293,7 +310,7 @@ type SubmitURBParams struct {
Endpoint uint8
Flags uint32
Buffer []byte
UserContext uintptr
UserContext uintptr
}
// SubmitURB submits an asynchronous URB
@@ -310,7 +327,7 @@ func (h *DeviceHandle) SubmitURB(params *SubmitURBParams) (*usbdevfsURB, error)
Buffer: bufPtr,
BufferLength: int32(len(params.Buffer)),
NumberOfPackets: -1, // 0xFFFFFFFF for non-ISO
UserContext: params.UserContext,
UserContext: params.UserContext,
}
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsSubmitURB, uintptr(unsafe.Pointer(urb)))
@@ -320,6 +337,20 @@ func (h *DeviceHandle) SubmitURB(params *SubmitURBParams) (*usbdevfsURB, error)
return urb, nil
}
// urbFromKernelPtr converts the uintptr USBDEVFS_REAPURB writes back into a
// *usbdevfsURB.
//
// go vet flags this as "possible misuse of unsafe.Pointer", correctly in
// general: the garbage collector cannot see a pointer stored in a uintptr, so
// the object could be collected before the conversion. It is safe here because
// the kernel only ever returns a pointer we submitted ourselves, and the
// caller keeps that URB reachable — in pendingURBs or unlinkedURBs on the
// server — from submission until after it has been reaped.
func urbFromKernelPtr(p uintptr) *usbdevfsURB {
//nolint:govet // see the comment above
return (*usbdevfsURB)(unsafe.Pointer(p))
}
// ReapURB blocks until a URB completes, then returns it
func (h *DeviceHandle) ReapURB() (*usbdevfsURB, error) {
var urbPtr uintptr
@@ -327,7 +358,7 @@ func (h *DeviceHandle) ReapURB() (*usbdevfsURB, error) {
if errno != 0 {
return nil, fmt.Errorf("USBDEVFS_REAPURB: %w", errno)
}
return (*usbdevfsURB)(unsafe.Pointer(urbPtr)), nil
return urbFromKernelPtr(urbPtr), nil
}
// ReapURBNonBlock tries to reap a URB without blocking
@@ -337,7 +368,7 @@ func (h *DeviceHandle) ReapURBNonBlock() (*usbdevfsURB, error) {
if errno != 0 {
return nil, fmt.Errorf("USBDEVFS_REAPURBNDELAY: %w", errno)
}
return (*usbdevfsURB)(unsafe.Pointer(urbPtr)), nil
return urbFromKernelPtr(urbPtr), nil
}
// DiscardURB cancels a submitted URB
@@ -450,10 +481,81 @@ func ReadISOResults(mem []byte, numPackets int32) []ISOPacketResult {
// ReapedURBInfo holds exported fields from a reaped URB needed for response building
type ReapedURBInfo struct {
UserContext uintptr
Status int32
Status int32
ActualLength int32
StartFrame int32
ErrorCount int32
StartFrame int32
ErrorCount int32
}
// ErrNoURBReady is returned by ReapURBInfoNonBlock when no URB has completed.
var ErrNoURBReady = errors.New("no completed URB available")
// ErrDeviceGone is returned when the device has been unplugged or the file
// descriptor is no longer usable.
var ErrDeviceGone = errors.New("device gone")
// WaitForURB waits up to timeout for at least one URB to complete.
// It returns true if a URB is ready to be reaped, false on timeout.
//
// usbdevfs signals completed URBs via POLLOUT, so polling lets the reap loop
// stay responsive to shutdown without either spinning on a non-blocking ioctl
// or blocking indefinitely in USBDEVFS_REAPURB. The latter matters: a blocking
// reap can only be broken by closing the fd, which races with the fd being
// reused by another goroutine.
func (h *DeviceHandle) WaitForURB(timeout time.Duration) (bool, error) {
fds := []unix.PollFd{{Fd: int32(h.fd), Events: unix.POLLOUT}}
ms := int(timeout.Milliseconds())
if ms < 0 {
ms = 0
}
for {
n, err := unix.Poll(fds, ms)
if err == unix.EINTR {
continue // interrupted by a signal, not an error
}
if err != nil {
return false, fmt.Errorf("poll: %w", err)
}
if n == 0 {
return false, nil // timeout
}
// POLLERR/POLLHUP/POLLNVAL mean the device is gone or the fd was closed.
if fds[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 {
return false, ErrDeviceGone
}
return fds[0].Revents&unix.POLLOUT != 0, nil
}
}
// ReapURBInfoNonBlock reaps one completed URB without blocking.
// Returns ErrNoURBReady if none has completed, ErrDeviceGone if the device
// has been disconnected.
func (h *DeviceHandle) ReapURBInfoNonBlock() (*ReapedURBInfo, error) {
var urbPtr uintptr
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsReapURBNDelay, uintptr(unsafe.Pointer(&urbPtr)))
if errno != 0 {
switch errno {
case unix.EAGAIN:
return nil, ErrNoURBReady
case unix.ENODEV, unix.ESHUTDOWN, unix.EBADF, unix.ENOENT:
return nil, ErrDeviceGone
default:
return nil, fmt.Errorf("USBDEVFS_REAPURBNDELAY: %w", errno)
}
}
if urbPtr == 0 {
return nil, ErrNoURBReady
}
urb := urbFromKernelPtr(urbPtr)
return &ReapedURBInfo{
UserContext: urb.UserContext,
Status: urb.Status,
ActualLength: urb.ActualLength,
StartFrame: urb.StartFrame,
ErrorCount: urb.ErrorCount,
}, nil
}
// ReapURBInfo blocks until a URB completes and returns exported info
@@ -463,13 +565,13 @@ func (h *DeviceHandle) ReapURBInfo() (*ReapedURBInfo, error) {
if errno != 0 {
return nil, fmt.Errorf("USBDEVFS_REAPURB: %w", errno)
}
urb := (*usbdevfsURB)(unsafe.Pointer(urbPtr))
urb := urbFromKernelPtr(urbPtr)
return &ReapedURBInfo{
UserContext: urb.UserContext,
Status: urb.Status,
Status: urb.Status,
ActualLength: urb.ActualLength,
StartFrame: urb.StartFrame,
ErrorCount: urb.ErrorCount,
StartFrame: urb.StartFrame,
ErrorCount: urb.ErrorCount,
}, nil
}
+27
View File
@@ -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") }
+1 -1
View File
@@ -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") }
+6 -6
View File
@@ -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
+236
View File
@@ -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)
}
}
+394 -225
View File
@@ -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
+44
View File
@@ -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")
}
+100
View File
@@ -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])
}
}
+437 -15
View File
@@ -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)
}
}
+87
View File
@@ -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"
}
+6 -6
View File
@@ -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
}
+28
View File
@@ -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()
}
+18 -9
View File
@@ -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()})
+66 -28
View File
@@ -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 =
`<strong>Modus:</strong> ${data.mode === 'share' ? 'Freigeben' : 'Empfangen'} | ` +
`<strong>Name:</strong> ${data.name} | ` +
`<strong>Client ID:</strong> ${data.client_id ? data.client_id.substring(0, 8) + '...' : '-'}`;
`<strong>Modus:</strong> ${escapeHtml(modeNames[data.mode] || data.mode)} | ` +
`<strong>Name:</strong> ${escapeHtml(data.name)} | ` +
`<strong>Client ID:</strong> ${data.client_id ? escapeHtml(data.client_id.substring(0, 8)) + '...' : '-'}` +
(data.encrypted ? ' | <strong>Tunnel:</strong> verschluesselt'
: ' | <strong>Tunnel:</strong> 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 += '<div class="client-header">Eigene Geraete (freigegeben)</div>';
}
html += renderShareSection(data.local_devices);
}
container.innerHTML = html || '<p class="no-devices">Keine Geraete</p>';
}
function renderShareDevices(container, devices) {
function renderShareSection(devices) {
if (!devices || devices.length === 0) {
container.innerHTML = '<p class="no-devices">Keine USB-Geraete gefunden</p>';
return;
return '<p class="no-devices">Keine USB-Geraete gefunden</p>';
}
container.innerHTML = devices.map(dev => `
return devices.map(dev => `
<div class="device-card">
<div class="device-info">
<div class="device-name">${escapeHtml(dev.name)}</div>
<div class="device-details">
<span>Bus: ${dev.bus_id}</span>
<span>VID:PID: ${dev.vendor_id}:${dev.product_id}</span>
<span>Bus: ${escapeHtml(dev.bus_id)}</span>
<span>VID:PID: ${escapeHtml(dev.vendor_id)}:${escapeHtml(dev.product_id)}</span>
<span>Speed: ${speedName(dev.speed)}</span>
</div>
</div>
@@ -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) {
<div class="device-name">${escapeHtml(dev.name || dev.bus_id)}</div>
<div class="device-details">
<span>Von: ${escapeHtml(dev.client_name || dev.client_id)}</span>
${dev.vendor_id ? `<span>VID:PID: ${dev.vendor_id}:${dev.product_id}</span>` : ''}
<span>VHCI Port: ${dev.vhci_port}</span>
${dev.vendor_id ? `<span>VID:PID: ${escapeHtml(dev.vendor_id)}:${escapeHtml(dev.product_id)}</span>` : ''}
<span>VHCI Port: ${escapeHtml(dev.vhci_port)}</span>
</div>
</div>
<div class="device-status">
<label class="auto-connect-label" title="Beim Start automatisch verbinden">
<input type="checkbox" ${dev.auto_connect ? 'checked' : ''}
onchange="toggleAutoConnect('${dev.vendor_id}', '${dev.product_id}', this.checked)">
onchange="toggleAutoConnect('${jsArg(dev.vendor_id)}', '${jsArg(dev.product_id)}', this.checked)">
Autostart
</label>
<span class="badge attached">Verbunden</span>
<button class="btn small danger" onclick="detachDevice('${dev.client_id}', '${dev.bus_id}')">Trennen</button>
<button class="btn small danger" onclick="detachDevice('${jsArg(dev.client_id)}', '${jsArg(dev.bus_id)}')">Trennen</button>
</div>
</div>
`).join('');
@@ -125,12 +140,11 @@ function renderUseDevices(container, available, attached) {
});
if (Object.keys(byClient).length === 0 && (!attached || attached.length === 0)) {
container.innerHTML = '<p class="no-devices">Keine Geraete verfuegbar. Warte auf Share-Clients...</p>';
return;
return '<p class="no-devices">Keine fremden Geraete verfuegbar. Warte auf Share-Clients...</p>';
}
for (const [clientId, info] of Object.entries(byClient)) {
html += `<div class="client-header">${escapeHtml(info.name)} (${clientId.substring(0, 8)}...)</div>`;
html += `<div class="client-header">${escapeHtml(info.name)} (${escapeHtml(clientId.substring(0, 8))}...)</div>`;
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) {
<div class="device-info">
<div class="device-name">${escapeHtml(dev.name)}</div>
<div class="device-details">
<span>Bus: ${dev.bus_id}</span>
<span>VID:PID: ${dev.vendor_id}:${dev.product_id}</span>
<span>Bus: ${escapeHtml(dev.bus_id)}</span>
<span>VID:PID: ${escapeHtml(dev.vendor_id)}:${escapeHtml(dev.product_id)}</span>
<span>Speed: ${speedName(dev.speed)}</span>
</div>
</div>
<div class="device-status">
${dev.status === 'in_use'
? `<span class="badge in-use">In Benutzung</span>
${dev.allow_force_detach ? `<button class="btn small danger" onclick="forceDetach('${clientId}', '${dev.bus_id}')">Trennen</button>` : ''}`
${dev.allow_force_detach ? `<button class="btn small danger" onclick="forceDetach('${jsArg(clientId)}', '${jsArg(dev.bus_id)}')">Trennen</button>` : ''}`
: isAttached
? '<span class="badge attached">Verbunden</span>'
: `<span class="badge available">Verfuegbar</span>
<button class="btn small primary" onclick="attachDevice('${clientId}', '${dev.bus_id}')">Verbinden</button>`
<button class="btn small primary" onclick="attachDevice('${jsArg(clientId)}', '${jsArg(dev.bus_id)}')">Verbinden</button>`
}
</div>
</div>
@@ -160,7 +174,7 @@ function renderUseDevices(container, available, attached) {
}).join('');
}
container.innerHTML = html || '<p class="no-devices">Keine Geraete verfuegbar</p>';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 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, '&quot;')
.replace(/</g, '\\x3c')
.replace(/>/g, '\\x3e')
.replace(/&/g, '\\x26');
}
// Init
+18 -2
View File
@@ -43,9 +43,11 @@
<div class="form-group">
<label for="mode">Modus</label>
<select id="mode">
<option value="share">Freigeben (Share)</option>
<option value="use">Empfangen (Use)</option>
<option value="both">Beides (Freigeben und Empfangen)</option>
<option value="share">Nur Freigeben (Share)</option>
<option value="use">Nur Empfangen (Use)</option>
</select>
<small>Nach dem Speichern neu starten, damit der Modus wirkt</small>
</div>
<div class="form-group">
<label for="client-name">Client Name</label>
@@ -62,6 +64,20 @@
</label>
<small>Erlaubt Use-Clients, Geraete die von anderen benutzt werden zu trennen</small>
</div>
<div class="form-group">
<label for="direct-port">Port fuer Direktverbindungen</label>
<input type="number" id="direct-port" min="0" max="65535" value="0">
<small>0 = zufaelliger Port. Fest setzen, wenn der Port durch eine Firewall
oder NAT weitergeleitet werden muss.</small>
</div>
<div class="form-group">
<label class="auto-connect-label">
<input type="checkbox" id="disable-direct">
Direktverbindungen deaktivieren
</label>
<small>Erzwingt, dass aller USB-Verkehr ueber den Relay laeuft. Normalerweise
verbinden sich Clients direkt, was Latenz spart und den Relay entlastet.</small>
</div>
<button type="submit" class="btn primary">Speichern</button>
</form>
</section>