Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ed473a965 |
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 .
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
app/src/main/jniLibs/*/libusbclient.so
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "usb-server-android"
|
||||
include(":app")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+246
-83
@@ -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,50 +191,111 @@ 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(),
|
||||
// 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() {
|
||||
if err := http.ListenAndServe(addr, webHandler); err != nil {
|
||||
log.Printf("Web UI error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
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()
|
||||
|
||||
if sm != nil {
|
||||
go sm.Run()
|
||||
}
|
||||
|
||||
log.Printf("USB Client started (mode=%s, name=%s)", cfg.Mode, cfg.Name)
|
||||
|
||||
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 available {
|
||||
for _, d := range um.GetAvailableDevices() {
|
||||
availList = append(availList, map[string]interface{}{
|
||||
"bus_id": d.BusID,
|
||||
"vendor_id": d.VendorID,
|
||||
@@ -226,11 +306,12 @@ func cmdRun(mode string) {
|
||||
"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 attached {
|
||||
for _, d := range um.GetAttachedDevices() {
|
||||
attachList = append(attachList, map[string]interface{}{
|
||||
"bus_id": d.BusID,
|
||||
"vendor_id": d.VendorID,
|
||||
@@ -244,63 +325,25 @@ func cmdRun(mode string) {
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}()
|
||||
result["available_devices"] = availList
|
||||
result["attached_devices"] = attachList
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.WebPort)
|
||||
log.Printf("Web UI available at http://localhost%s", addr)
|
||||
go func() {
|
||||
if err := http.ListenAndServe(addr, webHandler); err != nil {
|
||||
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)
|
||||
}
|
||||
}()
|
||||
return result
|
||||
}
|
||||
|
||||
log.Printf("USB Client started (mode=%s, name=%s)", mode, cfg.Name)
|
||||
if um != nil {
|
||||
h.AttachDevice = um.AttachDevice
|
||||
h.DetachDevice = um.DetachDevice
|
||||
h.ForceDetachDevice = um.ForceDetachDevice
|
||||
h.SetAutoConnect = um.SetAutoConnect
|
||||
h.IsAutoConnect = um.IsAutoConnect
|
||||
}
|
||||
|
||||
// Wait for signal
|
||||
sig := <-sigChan
|
||||
log.Printf("Received signal %v, shutting down...", sig)
|
||||
c.Close()
|
||||
h.InstallService = func() error { return service.Install(cfg.Mode, cfgPath) }
|
||||
h.UninstallService = service.Uninstall
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func cmdList() {
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -0,0 +1,118 @@
|
||||
# usbshare — USB-Filtertreiber für Windows
|
||||
|
||||
Ein KMDF-Upper-Filtertreiber, der Userspace-Zugriff auf ein USB-Gerät
|
||||
ermöglicht, **ohne** den vorhandenen Gerätetreiber zu ersetzen. Das ist der
|
||||
Ansatz, den VirtualHere verwendet, und der Grund, warum es dort kein Zadig
|
||||
braucht und das Gerät lokal funktionsfähig bleibt.
|
||||
|
||||
> ## Lies das zuerst
|
||||
>
|
||||
> **Dieser Code ist nie gelaufen.** Ich habe ihn geschrieben, aber weder
|
||||
> kompiliert noch getestet — dafür braucht es Windows mit dem WDK, und das
|
||||
> stand mir nicht zur Verfügung. Er ist als Ausgangspunkt gedacht, nicht als
|
||||
> fertiges Produkt.
|
||||
>
|
||||
> Kernel-Code verzeiht nichts: ein Fehler ist ein Bluescreen, kein
|
||||
> Stacktrace. Ein Treiber, der beim Booten geladen wird und dabei abstürzt,
|
||||
> kann ein System unbootbar machen. **Teste ausschließlich in einer VM mit
|
||||
> Snapshot**, bis er stabil läuft.
|
||||
>
|
||||
> Rechne mit mehreren Runden Debugging. Die Struktur sollte stimmen, die
|
||||
> Details fast sicher nicht.
|
||||
|
||||
## Warum ein Filtertreiber
|
||||
|
||||
Windows hat kein Äquivalent zu Linux' usbdevfs. Um URBs an ein Gerät zu
|
||||
senden, braucht es Kernel-Code. Die Alternativen:
|
||||
|
||||
| Ansatz | Gerät lokal nutzbar | Installation | HID/Massenspeicher |
|
||||
|--------|---------------------|--------------|--------------------|
|
||||
| WinUSB | Nein — ersetzt den Treiber | Zadig, pro Gerät | Meist blockiert |
|
||||
| **Filtertreiber** | **Ja** | INF, pro Gerät oder klassenweit | Ja |
|
||||
|
||||
Der Filter setzt sich *über* den vorhandenen Treiber in den Stack. Im
|
||||
Normalbetrieb reicht er alles unverändert durch. Erst wenn Userspace ein Gerät
|
||||
beansprucht, fängt er die IRPs des Klassentreibers ab und leitet stattdessen
|
||||
die URBs aus dem Userspace an den USB-Hub weiter.
|
||||
|
||||
```
|
||||
Ohne Beanspruchung Während des Teilens
|
||||
┌────────────────────┐ ┌────────────────────┐
|
||||
│ Klassentreiber │ │ Klassentreiber │
|
||||
│ (usbhid, usbstor) │ │ (bekommt nichts) │
|
||||
└─────────┬──────────┘ └─────────┬──────────┘
|
||||
│ ╳ abgefangen
|
||||
┌─────────▼──────────┐ ┌─────────▼──────────┐
|
||||
│ usbshare (Filter) │ │ usbshare (Filter) │◄── usb-client
|
||||
└─────────┬──────────┘ └─────────┬──────────┘ via IOCTL
|
||||
│ durchgereicht │ URBs
|
||||
┌─────────▼──────────┐ ┌─────────▼──────────┐
|
||||
│ USB-Hub-Treiber │ │ USB-Hub-Treiber │
|
||||
└────────────────────┘ └────────────────────┘
|
||||
```
|
||||
|
||||
## Dateien
|
||||
|
||||
| Datei | Inhalt |
|
||||
|-------|--------|
|
||||
| `driver.c` | Treiber-Einstieg, Geräte-Hinzufügen, PnP |
|
||||
| `queue.c` | IOCTL-Verarbeitung, URB-Weiterleitung |
|
||||
| `filter.c` | Abfangen der Klassentreiber-Anfragen im beanspruchten Zustand |
|
||||
| `usbshare.h` | Interne Strukturen |
|
||||
| `public.h` | IOCTL-Schnittstelle — auch von der Go-Seite verwendet |
|
||||
| `usbshare.inf` | Installationsdatei |
|
||||
| `usbshare.vcxproj` | Visual-Studio-Projekt |
|
||||
|
||||
## Bauen
|
||||
|
||||
Voraussetzungen: Visual Studio 2022 mit „Desktop development with C++",
|
||||
Windows SDK und [WDK](https://learn.microsoft.com/windows-hardware/drivers/download-the-wdk).
|
||||
|
||||
```cmd
|
||||
msbuild usbshare.vcxproj /p:Configuration=Release /p:Platform=x64
|
||||
```
|
||||
|
||||
## Testen (nur in einer VM)
|
||||
|
||||
```cmd
|
||||
:: Testsignierung erlauben — danach neu starten
|
||||
bcdedit /set testsigning on
|
||||
bcdedit /set nointegritychecks on
|
||||
|
||||
:: Selbst signieren
|
||||
makecert -r -pe -ss PrivateCertStore -n "CN=usbshare-test" test.cer
|
||||
signtool sign /v /s PrivateCertStore /n usbshare-test /t http://timestamp.digicert.com usbshare.sys
|
||||
|
||||
:: Installieren: Rechtsklick auf usbshare.inf → Installieren, dann Gerät neu einstecken
|
||||
```
|
||||
|
||||
Für Kernel-Debugging: zweite Maschine oder Host mit WinDbg, verbunden über
|
||||
`bcdedit /debug on` und `/dbgsettings net`.
|
||||
|
||||
## Verteilen
|
||||
|
||||
Für den Einsatz außerhalb einer Testmaschine muss der Treiber von Microsoft
|
||||
gegengezeichnet sein. Dafür brauchst du:
|
||||
|
||||
1. **EV-Code-Signing-Zertifikat** — auf eine geprüfte reale Identität
|
||||
(Firma oder Einzelperson), etwa 300–500 €/Jahr, Ausstellung dauert Tage bis
|
||||
Wochen wegen der Identitätsprüfung.
|
||||
2. **Microsoft-Partner-Center-Konto**, verifiziert mit demselben Zertifikat.
|
||||
3. **Attestation Signing**: Treiber hochladen, Microsoft zeichnet gegen.
|
||||
Ausreichend für die meisten Fälle; volle WHQL-Zertifizierung braucht
|
||||
zusätzlich HLK-Testläufe.
|
||||
|
||||
Diesen Teil kann nur jemand mit einer realen Identität erledigen — er läuft
|
||||
auf deinen Namen, nicht auf meinen. Das ist die eigentliche Hürde, nicht der
|
||||
Code.
|
||||
|
||||
## Was fehlt
|
||||
|
||||
Der Treiber deckt Control-, Bulk- und Interrupt-Transfers ab. Nicht
|
||||
implementiert:
|
||||
|
||||
- **Isochrone Transfers** (Webcams, Audio). Sie brauchen eine andere
|
||||
URB-Struktur mit Paketdeskriptoren und Bandbreitenreservierung.
|
||||
- **Auswahl der Konfiguration/Alt-Settings** über den Filter — derzeit wird
|
||||
die vom Klassentreiber gesetzte übernommen.
|
||||
- **Reset und Halt-Clear** sind angelegt, aber ungetestet.
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,555 @@
|
||||
/*
|
||||
* usbshare - IOCTL handling and URB forwarding
|
||||
*/
|
||||
|
||||
#include "usbshare.h"
|
||||
|
||||
static VOID UsbShareCompleteTransfer(
|
||||
_In_ WDFREQUEST Request,
|
||||
_In_ WDFIOTARGET Target,
|
||||
_In_ PWDF_REQUEST_COMPLETION_PARAMS Params,
|
||||
_In_ WDFCONTEXT Context);
|
||||
|
||||
static NTSTATUS UsbShareHandleClaim(_In_ PDEVICE_CONTEXT Context, _In_ WDFREQUEST Request);
|
||||
static NTSTATUS UsbShareHandleGetDescriptors(_In_ PDEVICE_CONTEXT Context, _In_ WDFREQUEST Request);
|
||||
static NTSTATUS UsbShareHandleSubmit(_In_ PDEVICE_CONTEXT Context, _In_ WDFREQUEST Request);
|
||||
static NTSTATUS UsbShareHandleCancel(_In_ PDEVICE_CONTEXT Context, _In_ WDFREQUEST Request);
|
||||
static NTSTATUS UsbShareHandleSetInterface(_In_ PDEVICE_CONTEXT Context, _In_ WDFREQUEST Request);
|
||||
static NTSTATUS UsbShareHandleClearHalt(_In_ PDEVICE_CONTEXT Context, _In_ WDFREQUEST Request);
|
||||
|
||||
VOID
|
||||
UsbShareEvtIoDeviceControl(
|
||||
_In_ WDFQUEUE Queue,
|
||||
_In_ WDFREQUEST Request,
|
||||
_In_ size_t OutputBufferLength,
|
||||
_In_ size_t InputBufferLength,
|
||||
_In_ ULONG IoControlCode
|
||||
)
|
||||
{
|
||||
WDFDEVICE device = WdfIoQueueGetDevice(Queue);
|
||||
PDEVICE_CONTEXT context = GetDeviceContext(device);
|
||||
NTSTATUS status;
|
||||
|
||||
UNREFERENCED_PARAMETER(OutputBufferLength);
|
||||
UNREFERENCED_PARAMETER(InputBufferLength);
|
||||
|
||||
switch (IoControlCode) {
|
||||
case IOCTL_USBSHARE_CLAIM:
|
||||
status = UsbShareHandleClaim(context, Request);
|
||||
break;
|
||||
|
||||
case IOCTL_USBSHARE_RELEASE:
|
||||
UsbShareReleaseClaim(context, WdfRequestGetFileObject(Request));
|
||||
status = STATUS_SUCCESS;
|
||||
break;
|
||||
|
||||
case IOCTL_USBSHARE_GET_DESCRIPTORS:
|
||||
status = UsbShareHandleGetDescriptors(context, Request);
|
||||
break;
|
||||
|
||||
case IOCTL_USBSHARE_SUBMIT:
|
||||
status = UsbShareHandleSubmit(context, Request);
|
||||
/*
|
||||
* A submitted transfer completes asynchronously; the completion
|
||||
* routine owns the request from here.
|
||||
*/
|
||||
if (status == STATUS_PENDING) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
|
||||
case IOCTL_USBSHARE_CANCEL:
|
||||
status = UsbShareHandleCancel(context, Request);
|
||||
break;
|
||||
|
||||
case IOCTL_USBSHARE_SET_INTERFACE:
|
||||
status = UsbShareHandleSetInterface(context, Request);
|
||||
break;
|
||||
|
||||
case IOCTL_USBSHARE_CLEAR_HALT:
|
||||
status = UsbShareHandleClearHalt(context, Request);
|
||||
break;
|
||||
|
||||
case IOCTL_USBSHARE_RESET:
|
||||
status = WdfUsbTargetDeviceResetPortSynchronously(context->UsbDevice);
|
||||
break;
|
||||
|
||||
default:
|
||||
/*
|
||||
* Not ours. As a filter we must pass it on rather than fail it —
|
||||
* some other component in the stack may be waiting for the answer.
|
||||
*/
|
||||
{
|
||||
WDF_REQUEST_SEND_OPTIONS options;
|
||||
WDF_REQUEST_SEND_OPTIONS_INIT(&options, WDF_REQUEST_SEND_OPTION_SEND_AND_FORGET);
|
||||
WdfRequestFormatRequestUsingCurrentType(Request);
|
||||
if (!WdfRequestSend(Request, WdfDeviceGetIoTarget(device), &options)) {
|
||||
WdfRequestComplete(Request, WdfRequestGetStatus(Request));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
WdfRequestComplete(Request, status);
|
||||
}
|
||||
|
||||
static NTSTATUS
|
||||
UsbShareHandleClaim(
|
||||
_In_ PDEVICE_CONTEXT Context,
|
||||
_In_ WDFREQUEST Request
|
||||
)
|
||||
{
|
||||
NTSTATUS status;
|
||||
PUSBSHARE_DEVICE_INFO info;
|
||||
WDFFILEOBJECT fileObject = WdfRequestGetFileObject(Request);
|
||||
|
||||
status = WdfRequestRetrieveOutputBuffer(Request, sizeof(USBSHARE_DEVICE_INFO),
|
||||
(PVOID *)&info, NULL);
|
||||
if (!NT_SUCCESS(status)) {
|
||||
return status;
|
||||
}
|
||||
|
||||
WdfSpinLockAcquire(Context->ClaimLock);
|
||||
|
||||
if (Context->Claimed && Context->ClaimOwner != fileObject) {
|
||||
WdfSpinLockRelease(Context->ClaimLock);
|
||||
return STATUS_DEVICE_BUSY;
|
||||
}
|
||||
|
||||
Context->Claimed = TRUE;
|
||||
Context->ClaimOwner = fileObject;
|
||||
|
||||
WdfSpinLockRelease(Context->ClaimLock);
|
||||
|
||||
*info = Context->Info;
|
||||
WdfRequestSetInformation(Request, sizeof(USBSHARE_DEVICE_INFO));
|
||||
|
||||
KdPrint(("usbshare: device claimed (%04x:%04x)\n", info->VendorId, info->ProductId));
|
||||
return STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
static NTSTATUS
|
||||
UsbShareHandleGetDescriptors(
|
||||
_In_ PDEVICE_CONTEXT Context,
|
||||
_In_ WDFREQUEST Request
|
||||
)
|
||||
{
|
||||
NTSTATUS status;
|
||||
PVOID buffer;
|
||||
size_t bufferLength;
|
||||
|
||||
if (Context->Descriptors == NULL) {
|
||||
status = UsbShareBuildDescriptorBlob(Context);
|
||||
if (!NT_SUCCESS(status)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
status = WdfRequestRetrieveOutputBuffer(Request, 1, &buffer, &bufferLength);
|
||||
if (!NT_SUCCESS(status)) {
|
||||
return status;
|
||||
}
|
||||
|
||||
if (bufferLength < Context->DescriptorsLength) {
|
||||
/* Report the needed size so the caller can retry. */
|
||||
WdfRequestSetInformation(Request, Context->DescriptorsLength);
|
||||
return STATUS_BUFFER_TOO_SMALL;
|
||||
}
|
||||
|
||||
RtlCopyMemory(buffer, Context->Descriptors, Context->DescriptorsLength);
|
||||
WdfRequestSetInformation(Request, Context->DescriptorsLength);
|
||||
|
||||
return STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
/*
|
||||
* Builds the descriptor blob: device descriptor followed by every
|
||||
* configuration descriptor, matching what Linux returns when reading a
|
||||
* usbdevfs file. The client parses both with the same code.
|
||||
*/
|
||||
NTSTATUS
|
||||
UsbShareBuildDescriptorBlob(
|
||||
_In_ PDEVICE_CONTEXT Context
|
||||
)
|
||||
{
|
||||
NTSTATUS status;
|
||||
USB_DEVICE_DESCRIPTOR deviceDescriptor;
|
||||
PUCHAR blob = NULL;
|
||||
ULONG blobSize = 0;
|
||||
ULONG offset;
|
||||
UCHAR configIndex;
|
||||
|
||||
WdfUsbTargetDeviceGetDeviceDescriptor(Context->UsbDevice, &deviceDescriptor);
|
||||
|
||||
/* First pass: total up the sizes. */
|
||||
blobSize = sizeof(USB_DEVICE_DESCRIPTOR);
|
||||
|
||||
for (configIndex = 0; configIndex < deviceDescriptor.bNumConfigurations; configIndex++) {
|
||||
USHORT configSize = 0;
|
||||
|
||||
status = WdfUsbTargetDeviceRetrieveConfigDescriptor(Context->UsbDevice, NULL, &configSize);
|
||||
if (status != STATUS_BUFFER_TOO_SMALL && !NT_SUCCESS(status)) {
|
||||
return status;
|
||||
}
|
||||
blobSize += configSize;
|
||||
|
||||
/*
|
||||
* Only configuration 0 can be retrieved through this API; devices
|
||||
* with several configurations would need a raw control transfer per
|
||||
* configuration. They are rare enough to leave for later.
|
||||
*/
|
||||
break;
|
||||
}
|
||||
|
||||
blob = (PUCHAR)ExAllocatePool2(POOL_FLAG_NON_PAGED, blobSize, USBSHARE_POOL_TAG);
|
||||
if (blob == NULL) {
|
||||
return STATUS_INSUFFICIENT_RESOURCES;
|
||||
}
|
||||
|
||||
RtlCopyMemory(blob, &deviceDescriptor, sizeof(USB_DEVICE_DESCRIPTOR));
|
||||
offset = sizeof(USB_DEVICE_DESCRIPTOR);
|
||||
|
||||
{
|
||||
USHORT configSize = (USHORT)(blobSize - offset);
|
||||
status = WdfUsbTargetDeviceRetrieveConfigDescriptor(Context->UsbDevice,
|
||||
blob + offset,
|
||||
&configSize);
|
||||
if (!NT_SUCCESS(status)) {
|
||||
ExFreePoolWithTag(blob, USBSHARE_POOL_TAG);
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
Context->Descriptors = blob;
|
||||
Context->DescriptorsLength = blobSize;
|
||||
|
||||
return STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
static NTSTATUS
|
||||
UsbShareHandleSubmit(
|
||||
_In_ PDEVICE_CONTEXT Context,
|
||||
_In_ WDFREQUEST Request
|
||||
)
|
||||
{
|
||||
NTSTATUS status;
|
||||
PUSBSHARE_TRANSFER transfer;
|
||||
size_t inputLength;
|
||||
PUCHAR payload;
|
||||
WDFMEMORY urbMemory;
|
||||
PURB urb;
|
||||
WDFUSBPIPE pipe;
|
||||
PREQUEST_CONTEXT reqContext;
|
||||
WDF_OBJECT_ATTRIBUTES attributes;
|
||||
WDFIOTARGET target;
|
||||
|
||||
if (!UsbShareIsClaimed(Context)) {
|
||||
return STATUS_INVALID_DEVICE_STATE;
|
||||
}
|
||||
|
||||
status = WdfRequestRetrieveInputBuffer(Request, sizeof(USBSHARE_TRANSFER),
|
||||
(PVOID *)&transfer, &inputLength);
|
||||
if (!NT_SUCCESS(status)) {
|
||||
return status;
|
||||
}
|
||||
|
||||
if (inputLength < sizeof(USBSHARE_TRANSFER) + transfer->BufferLength) {
|
||||
return STATUS_BUFFER_TOO_SMALL;
|
||||
}
|
||||
payload = (PUCHAR)transfer + sizeof(USBSHARE_TRANSFER);
|
||||
|
||||
/* Attach a context so a later cancel can find this request. */
|
||||
WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&attributes, REQUEST_CONTEXT);
|
||||
status = WdfObjectAllocateContext(Request, &attributes, (PVOID *)&reqContext);
|
||||
if (!NT_SUCCESS(status)) {
|
||||
return status;
|
||||
}
|
||||
reqContext->TransferId = transfer->Id;
|
||||
reqContext->ExpectedLength = transfer->BufferLength;
|
||||
|
||||
WDF_OBJECT_ATTRIBUTES_INIT(&attributes);
|
||||
attributes.ParentObject = Request;
|
||||
|
||||
if (transfer->Type == USBSHARE_TRANSFER_CONTROL) {
|
||||
status = WdfUsbTargetDeviceCreateUrb(Context->UsbDevice, &attributes,
|
||||
&urbMemory, &urb);
|
||||
if (!NT_SUCCESS(status)) {
|
||||
return status;
|
||||
}
|
||||
|
||||
/*
|
||||
* The setup packet arrives in USB wire order and is copied verbatim.
|
||||
* Reinterpreting the fields here would only introduce a chance to get
|
||||
* the endianness wrong.
|
||||
*/
|
||||
UsbBuildVendorRequest(urb,
|
||||
URB_FUNCTION_VENDOR_DEVICE,
|
||||
sizeof(struct _URB_CONTROL_VENDOR_OR_CLASS_REQUEST),
|
||||
(transfer->Direction == USBSHARE_DIR_IN)
|
||||
? USBD_TRANSFER_DIRECTION_IN : 0,
|
||||
0,
|
||||
transfer->Setup[0], /* bmRequestType */
|
||||
transfer->Setup[1], /* bRequest */
|
||||
*(USHORT *)&transfer->Setup[2], /* wValue */
|
||||
*(USHORT *)&transfer->Setup[4], /* wIndex */
|
||||
payload,
|
||||
NULL,
|
||||
transfer->BufferLength,
|
||||
NULL);
|
||||
} else {
|
||||
pipe = Context->Pipes[transfer->EndpointAddress];
|
||||
if (pipe == NULL) {
|
||||
return STATUS_INVALID_PARAMETER;
|
||||
}
|
||||
|
||||
status = WdfUsbTargetDeviceCreateUrb(Context->UsbDevice, &attributes,
|
||||
&urbMemory, &urb);
|
||||
if (!NT_SUCCESS(status)) {
|
||||
return status;
|
||||
}
|
||||
|
||||
/*
|
||||
* Bulk and interrupt share one URB function; the pipe handle decides
|
||||
* which it actually is.
|
||||
*/
|
||||
urb->UrbBulkOrInterruptTransfer.Hdr.Length =
|
||||
sizeof(struct _URB_BULK_OR_INTERRUPT_TRANSFER);
|
||||
urb->UrbBulkOrInterruptTransfer.Hdr.Function =
|
||||
URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER;
|
||||
urb->UrbBulkOrInterruptTransfer.PipeHandle = WdfUsbTargetPipeWdmGetPipeHandle(pipe);
|
||||
urb->UrbBulkOrInterruptTransfer.TransferBuffer = payload;
|
||||
urb->UrbBulkOrInterruptTransfer.TransferBufferLength = transfer->BufferLength;
|
||||
urb->UrbBulkOrInterruptTransfer.TransferBufferMDL = NULL;
|
||||
urb->UrbBulkOrInterruptTransfer.UrbLink = NULL;
|
||||
urb->UrbBulkOrInterruptTransfer.TransferFlags =
|
||||
(transfer->Direction == USBSHARE_DIR_IN)
|
||||
? (USBD_TRANSFER_DIRECTION_IN | USBD_SHORT_TRANSFER_OK)
|
||||
: 0;
|
||||
}
|
||||
|
||||
reqContext->UrbMemory = urbMemory;
|
||||
reqContext->Urb = urb;
|
||||
|
||||
target = WdfUsbTargetDeviceGetIoTarget(Context->UsbDevice);
|
||||
|
||||
status = WdfUsbTargetDeviceFormatRequestForUrb(Context->UsbDevice, Request,
|
||||
urbMemory, NULL);
|
||||
if (!NT_SUCCESS(status)) {
|
||||
return status;
|
||||
}
|
||||
|
||||
WdfRequestSetCompletionRoutine(Request, UsbShareCompleteTransfer, Context);
|
||||
|
||||
/* Track it so a cancel can find it. */
|
||||
WdfSpinLockAcquire(Context->PendingLock);
|
||||
WdfCollectionAdd(Context->PendingTransfers, Request);
|
||||
WdfSpinLockRelease(Context->PendingLock);
|
||||
|
||||
if (!WdfRequestSend(Request, target, WDF_NO_SEND_OPTIONS)) {
|
||||
status = WdfRequestGetStatus(Request);
|
||||
|
||||
WdfSpinLockAcquire(Context->PendingLock);
|
||||
WdfCollectionRemove(Context->PendingTransfers, Request);
|
||||
WdfSpinLockRelease(Context->PendingLock);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
return STATUS_PENDING;
|
||||
}
|
||||
|
||||
static VOID
|
||||
UsbShareCompleteTransfer(
|
||||
_In_ WDFREQUEST Request,
|
||||
_In_ WDFIOTARGET Target,
|
||||
_In_ PWDF_REQUEST_COMPLETION_PARAMS Params,
|
||||
_In_ WDFCONTEXT CompletionContext
|
||||
)
|
||||
{
|
||||
PDEVICE_CONTEXT context = (PDEVICE_CONTEXT)CompletionContext;
|
||||
PREQUEST_CONTEXT reqContext = GetRequestContext(Request);
|
||||
PUSBSHARE_TRANSFER_RESULT result;
|
||||
NTSTATUS status;
|
||||
size_t outputLength;
|
||||
ULONG transferred = 0;
|
||||
PUCHAR outPayload;
|
||||
|
||||
UNREFERENCED_PARAMETER(Target);
|
||||
|
||||
WdfSpinLockAcquire(context->PendingLock);
|
||||
WdfCollectionRemove(context->PendingTransfers, Request);
|
||||
WdfSpinLockRelease(context->PendingLock);
|
||||
|
||||
if (reqContext->Urb != NULL) {
|
||||
transferred = reqContext->Urb->UrbBulkOrInterruptTransfer.TransferBufferLength;
|
||||
}
|
||||
|
||||
status = WdfRequestRetrieveOutputBuffer(Request, sizeof(USBSHARE_TRANSFER_RESULT),
|
||||
(PVOID *)&result, &outputLength);
|
||||
if (!NT_SUCCESS(status)) {
|
||||
WdfRequestComplete(Request, status);
|
||||
return;
|
||||
}
|
||||
|
||||
result->Id = reqContext->TransferId;
|
||||
result->Status = Params->IoStatus.Status;
|
||||
result->UsbdStatus = (reqContext->Urb != NULL)
|
||||
? reqContext->Urb->UrbHeader.Status : 0;
|
||||
result->ActualLength = transferred;
|
||||
|
||||
/*
|
||||
* Copy the received payload after the result header, but only as much as
|
||||
* the output buffer holds — a device may return more than expected.
|
||||
*/
|
||||
if (transferred > 0 && outputLength > sizeof(USBSHARE_TRANSFER_RESULT)) {
|
||||
ULONG room = (ULONG)(outputLength - sizeof(USBSHARE_TRANSFER_RESULT));
|
||||
ULONG copy = (transferred < room) ? transferred : room;
|
||||
|
||||
outPayload = (PUCHAR)result + sizeof(USBSHARE_TRANSFER_RESULT);
|
||||
RtlCopyMemory(outPayload,
|
||||
reqContext->Urb->UrbBulkOrInterruptTransfer.TransferBuffer,
|
||||
copy);
|
||||
|
||||
WdfRequestSetInformation(Request, sizeof(USBSHARE_TRANSFER_RESULT) + copy);
|
||||
} else {
|
||||
WdfRequestSetInformation(Request, sizeof(USBSHARE_TRANSFER_RESULT));
|
||||
}
|
||||
|
||||
/*
|
||||
* Always complete successfully: the transfer's own outcome travels in the
|
||||
* result structure. Failing the IOCTL would lose the distinction between
|
||||
* "the ioctl did not work" and "the device stalled".
|
||||
*/
|
||||
WdfRequestComplete(Request, STATUS_SUCCESS);
|
||||
}
|
||||
|
||||
static NTSTATUS
|
||||
UsbShareHandleCancel(
|
||||
_In_ PDEVICE_CONTEXT Context,
|
||||
_In_ WDFREQUEST Request
|
||||
)
|
||||
{
|
||||
NTSTATUS status;
|
||||
PUSBSHARE_CANCEL cancel;
|
||||
ULONG i, count;
|
||||
WDFREQUEST target = NULL;
|
||||
|
||||
status = WdfRequestRetrieveInputBuffer(Request, sizeof(USBSHARE_CANCEL),
|
||||
(PVOID *)&cancel, NULL);
|
||||
if (!NT_SUCCESS(status)) {
|
||||
return status;
|
||||
}
|
||||
|
||||
WdfSpinLockAcquire(Context->PendingLock);
|
||||
|
||||
count = WdfCollectionGetCount(Context->PendingTransfers);
|
||||
for (i = 0; i < count; i++) {
|
||||
WDFREQUEST candidate = (WDFREQUEST)WdfCollectionGetItem(Context->PendingTransfers, i);
|
||||
PREQUEST_CONTEXT candidateContext = GetRequestContext(candidate);
|
||||
|
||||
if (candidateContext != NULL && candidateContext->TransferId == cancel->Id) {
|
||||
target = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
WdfSpinLockRelease(Context->PendingLock);
|
||||
|
||||
if (target == NULL) {
|
||||
/* Already finished. Not an error: the caller gets its result anyway. */
|
||||
return STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
WdfRequestCancelSentRequest(target);
|
||||
return STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
static NTSTATUS
|
||||
UsbShareHandleSetInterface(
|
||||
_In_ PDEVICE_CONTEXT Context,
|
||||
_In_ WDFREQUEST Request
|
||||
)
|
||||
{
|
||||
NTSTATUS status;
|
||||
PUSBSHARE_SET_INTERFACE params;
|
||||
WDF_USB_INTERFACE_SELECT_SETTING_PARAMS settingParams;
|
||||
|
||||
if (!UsbShareIsClaimed(Context)) {
|
||||
return STATUS_INVALID_DEVICE_STATE;
|
||||
}
|
||||
|
||||
status = WdfRequestRetrieveInputBuffer(Request, sizeof(USBSHARE_SET_INTERFACE),
|
||||
(PVOID *)¶ms, NULL);
|
||||
if (!NT_SUCCESS(status)) {
|
||||
return status;
|
||||
}
|
||||
|
||||
if (Context->UsbInterface == NULL) {
|
||||
return STATUS_INVALID_DEVICE_STATE;
|
||||
}
|
||||
|
||||
/*
|
||||
* Going through the framework rather than sending a raw SET_INTERFACE is
|
||||
* essential: the USB stack has to re-open the pipes and, for isochronous
|
||||
* endpoints, reserve bandwidth. A raw control transfer changes the device
|
||||
* without telling the stack, after which every later transfer fails.
|
||||
*/
|
||||
WDF_USB_INTERFACE_SELECT_SETTING_PARAMS_INIT_SETTING(&settingParams,
|
||||
params->AlternateSetting);
|
||||
|
||||
status = WdfUsbInterfaceSelectSetting(Context->UsbInterface,
|
||||
WDF_NO_OBJECT_ATTRIBUTES,
|
||||
&settingParams);
|
||||
if (!NT_SUCCESS(status)) {
|
||||
return status;
|
||||
}
|
||||
|
||||
/* Pipe handles change with the setting, so rebuild the map. */
|
||||
RtlZeroMemory(Context->Pipes, sizeof(Context->Pipes));
|
||||
{
|
||||
BYTE pipeCount = WdfUsbInterfaceGetNumConfiguredPipes(Context->UsbInterface);
|
||||
BYTE i;
|
||||
|
||||
for (i = 0; i < pipeCount; i++) {
|
||||
WDF_USB_PIPE_INFORMATION pipeInfo;
|
||||
WDFUSBPIPE pipe;
|
||||
|
||||
WDF_USB_PIPE_INFORMATION_INIT(&pipeInfo);
|
||||
pipe = WdfUsbInterfaceGetConfiguredPipe(Context->UsbInterface, i, &pipeInfo);
|
||||
if (pipe != NULL) {
|
||||
Context->Pipes[pipeInfo.EndpointAddress] = pipe;
|
||||
WdfUsbTargetPipeSetNoMaximumPacketSizeCheck(pipe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
static NTSTATUS
|
||||
UsbShareHandleClearHalt(
|
||||
_In_ PDEVICE_CONTEXT Context,
|
||||
_In_ WDFREQUEST Request
|
||||
)
|
||||
{
|
||||
NTSTATUS status;
|
||||
PUSBSHARE_CLEAR_HALT params;
|
||||
WDFUSBPIPE pipe;
|
||||
|
||||
if (!UsbShareIsClaimed(Context)) {
|
||||
return STATUS_INVALID_DEVICE_STATE;
|
||||
}
|
||||
|
||||
status = WdfRequestRetrieveInputBuffer(Request, sizeof(USBSHARE_CLEAR_HALT),
|
||||
(PVOID *)¶ms, NULL);
|
||||
if (!NT_SUCCESS(status)) {
|
||||
return status;
|
||||
}
|
||||
|
||||
pipe = Context->Pipes[params->EndpointAddress];
|
||||
if (pipe == NULL) {
|
||||
return STATUS_INVALID_PARAMETER;
|
||||
}
|
||||
|
||||
return WdfUsbTargetPipeResetSynchronously(pipe, WDF_NO_HANDLE, NULL);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+345
-59
@@ -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)
|
||||
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)
|
||||
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)
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
// 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 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
|
||||
}
|
||||
|
||||
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:
|
||||
}
|
||||
|
||||
msgType, data, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
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 {
|
||||
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():
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
if err := c.Connect(); err != nil {
|
||||
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.teardown()
|
||||
|
||||
// The relay dropped every tunnel involving us; local state that
|
||||
// still references one has to go too.
|
||||
c.fireDisconnect()
|
||||
|
||||
select {
|
||||
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()
|
||||
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-c.ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+256
-23
@@ -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,6 +23,15 @@ 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 {
|
||||
@@ -34,9 +44,60 @@ type activeShare struct {
|
||||
type shareTunnel struct {
|
||||
id string
|
||||
busID string
|
||||
inPipe *io.PipeWriter
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+271
-79
@@ -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
|
||||
@@ -36,16 +42,60 @@ type UseManager struct {
|
||||
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
|
||||
pending map[string]*pendingRequest // requestID -> in-flight attach
|
||||
forceDetachable map[string]bool // clientID -> allow_force_detach
|
||||
}
|
||||
|
||||
// pendingRequest tracks an attach request waiting for the share client's reply.
|
||||
// It carries the target so that a reply arriving after the caller gave up can
|
||||
// still be undone — otherwise the share side would hold the device open for a
|
||||
// user who is no longer waiting for it.
|
||||
type pendingRequest struct {
|
||||
clientID string
|
||||
busID string
|
||||
resp chan *protocol.DeviceGranted
|
||||
}
|
||||
|
||||
type useTunnel struct {
|
||||
id string
|
||||
busID string
|
||||
clientID string
|
||||
conn net.Conn // our end of the socketpair
|
||||
|
||||
// codec seals outgoing and opens incoming payloads, whichever transport
|
||||
// carries them.
|
||||
codec *tunnelCodec
|
||||
|
||||
// send delivers an encoded frame; direct is non-nil when this tunnel
|
||||
// bypasses the relay.
|
||||
send tunnelSender
|
||||
direct *directConn
|
||||
|
||||
// done is closed exactly once. Several paths can tear down the same
|
||||
// tunnel — an explicit detach, a release from the share side, the peer
|
||||
// leaving, a relay disconnect — and closing it twice would panic.
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// tryDirect attempts a direct connection to the granting peer, returning nil
|
||||
// if none can be established.
|
||||
//
|
||||
// Every failure here is benign: the tunnel simply runs through the relay, the
|
||||
// way it always did. Only the latency improves when this succeeds.
|
||||
func (um *UseManager) tryDirect(granted *protocol.DeviceGranted, secret *crypto.TunnelSecret) *directConn {
|
||||
if um.cfg.DisableDirect || secret == nil || len(granted.Endpoints) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
conn, addr, err := dialDirect(granted.Endpoints, granted.TunnelID, secret.PeerToken(granted.TunnelID))
|
||||
if err != nil {
|
||||
log.Printf("[use] no direct route to %s (%v), using the relay", granted.BusID, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("[use] direct connection to %s established for %s", addr, granted.BusID)
|
||||
return conn
|
||||
}
|
||||
|
||||
// NewUseManager creates a use manager
|
||||
@@ -57,20 +107,68 @@ func NewUseManager(client *Client, cfg *config.Config, cfgPath string) *UseManag
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,32 @@ const (
|
||||
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,6 +61,15 @@ 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
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+220
-114
@@ -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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
target := h.peer(sender.Hash, msg.TargetClient)
|
||||
if target == nil || !protocol.CanShare(target.Mode) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add the sender's ID so the share client knows who's requesting
|
||||
enriched := map[string]interface{}{
|
||||
target.enqueueJSON(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)
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
})
|
||||
}
|
||||
|
||||
// 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{}{
|
||||
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,
|
||||
})
|
||||
}
|
||||
target.WriteJSON(enriched)
|
||||
}
|
||||
}
|
||||
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{}{
|
||||
target := h.peer(sender.Hash, msg.TargetClient)
|
||||
if target == nil || !protocol.CanShare(target.Mode) {
|
||||
return
|
||||
}
|
||||
|
||||
target.enqueueJSON(map[string]interface{}{
|
||||
"type": protocol.MsgForceRelease,
|
||||
"target_client": msg.TargetClient,
|
||||
"bus_id": msg.BusID,
|
||||
"from_client": sender.ID,
|
||||
}
|
||||
target.WriteJSON(enriched)
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+18
-1
@@ -19,12 +19,22 @@ type Device struct {
|
||||
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"`
|
||||
AltSetting uint8 `json:"alt_setting"`
|
||||
Class uint8 `json:"class"`
|
||||
SubClass uint8 `json:"sub_class"`
|
||||
Protocol uint8 `json:"protocol"`
|
||||
@@ -37,8 +47,15 @@ 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
|
||||
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
|
||||
|
||||
@@ -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]),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+106
-4
@@ -3,8 +3,10 @@
|
||||
package usb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -456,6 +487,77 @@ type ReapedURBInfo struct {
|
||||
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
|
||||
func (h *DeviceHandle) ReapURBInfo() (*ReapedURBInfo, error) {
|
||||
var urbPtr uintptr
|
||||
@@ -463,7 +565,7 @@ 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,
|
||||
|
||||
@@ -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") }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+369
-200
@@ -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,8 +39,33 @@ type Server struct {
|
||||
handle *usb.DeviceHandle
|
||||
mu sync.Mutex
|
||||
pendingURBs map[uint32]*pendingURB // seqnum -> pending URB
|
||||
|
||||
// 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 map[uint8]uint8 // endpoint number (1-15) -> usbdevfs URB type
|
||||
|
||||
// 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 {
|
||||
@@ -42,12 +81,22 @@ type pendingURB struct {
|
||||
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),
|
||||
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() {
|
||||
for _, iface := range s.device.Interfaces {
|
||||
for _, ep := range iface.Endpoints {
|
||||
epNum := ep.Address & 0x0F
|
||||
// Map USB descriptor transfer type to usbdevfs URB type
|
||||
record := func(ep usb.Endpoint) {
|
||||
var urbType uint8
|
||||
switch ep.TransferType {
|
||||
case usb.TransferTypeControl:
|
||||
urbType = 2
|
||||
urbType = usbdevfsTypeControl
|
||||
case usb.TransferTypeIsochronous:
|
||||
urbType = 0
|
||||
urbType = usbdevfsTypeISO
|
||||
case usb.TransferTypeBulk:
|
||||
urbType = 3
|
||||
urbType = usbdevfsTypeBulk
|
||||
case usb.TransferTypeInterrupt:
|
||||
urbType = 1
|
||||
urbType = usbdevfsTypeInterrupt
|
||||
default:
|
||||
urbType = 3 // default bulk
|
||||
urbType = usbdevfsTypeBulk
|
||||
}
|
||||
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])
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
@@ -479,9 +510,10 @@ func (s *Server) handleCmdSubmit(r io.Reader, hdr *URBHeader, retChan chan<- []b
|
||||
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,10 +681,6 @@ 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
|
||||
@@ -545,10 +690,10 @@ func (s *Server) handleISOSubmit(hdr *URBHeader, body *CmdSubmitBody, transferBu
|
||||
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()
|
||||
|
||||
urbInfo, err := handle.ReapURBInfo()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-done:
|
||||
if closed || handle == nil {
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Wait for a completion rather than spinning on a non-blocking reap.
|
||||
ready, err := handle.WaitForURB(reapPollInterval)
|
||||
if err != nil {
|
||||
if errors.Is(err, usb.ErrDeviceGone) {
|
||||
log.Printf("[usbip-server] device gone, stopping reap loop")
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
if urbInfo.Status != 0 {
|
||||
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])
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -723,8 +890,10 @@ func (s *Server) buildISOResponse(urbInfo *usb.ReapedURBInfo, pending *pendingUR
|
||||
if end > uint32(len(pending.buffer)) {
|
||||
end = uint32(len(pending.buffer))
|
||||
}
|
||||
if bufOffset < end {
|
||||
packedData = append(packedData, pending.buffer[bufOffset:end]...)
|
||||
}
|
||||
}
|
||||
|
||||
bufOffset += pktLen
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+10
-1
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// jsArg escapes a value for use inside a single-quoted JavaScript string that
|
||||
// itself sits in an HTML attribute. Bus IDs, client IDs and vendor strings all
|
||||
// arrive from remote peers, so interpolating them raw would let another client
|
||||
// in the group inject script into this UI.
|
||||
function jsArg(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '\\x3c')
|
||||
.replace(/>/g, '\\x3e')
|
||||
.replace(/&/g, '\\x26');
|
||||
}
|
||||
|
||||
// Init
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user