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
|
.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
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -6,7 +14,11 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
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
|
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
|
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
|
EXPOSE 8443
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
|
CMD wget -q --spider http://localhost:8443/health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["usb-relay"]
|
ENTRYPOINT ["usb-relay"]
|
||||||
CMD ["--port", "8443"]
|
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
|
GOOS ?= linux
|
||||||
GOARCH ?= amd64
|
GOARCH ?= amd64
|
||||||
|
LDFLAGS := -s -w
|
||||||
|
|
||||||
all: relay client
|
all: relay client
|
||||||
|
|
||||||
relay:
|
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:
|
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:
|
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:
|
||||||
docker compose build
|
docker compose build
|
||||||
@@ -22,3 +75,10 @@ docker-run:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin/
|
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) │
|
│ Client (both) │──ws/wss─│ Relay Server │─ws/wss──│ Client (both) │
|
||||||
│ gibt USB-Geraete│ │ (Docker) │ │ empfaengt USB- │
|
│ gibt Geraete │ │ (Docker) │ │ gibt Geraete │
|
||||||
│ frei │ │ gruppiert │ │ Geraete │
|
│ frei UND │ │ gruppiert │ │ frei UND │
|
||||||
│ Web-UI :8080 │ │ nach Hash │ │ Web-UI :8080 │
|
│ empfaengt sie │ │ nach Hash │ │ empfaengt sie │
|
||||||
|
│ Web-UI :8080 │ │ │ │ Web-UI :8080 │
|
||||||
└────────┬─────────┘ └──────────────┘ └────────┬─────────┘
|
└────────┬─────────┘ └──────────────┘ └────────┬─────────┘
|
||||||
│ │
|
│ │
|
||||||
Physische USB Virtuelle USB
|
Physische USB-Geraete Virtuelle USB-Geraete
|
||||||
Geraete (vhci-hcd)
|
(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:
|
**Verbindungswege:** Nach der Vermittlung durch den Relay versuchen zwei
|
||||||
- **Share-Modus:** Gibt alle lokalen USB-Geraete frei. Geraete werden erst dann vom System getrennt wenn ein Use-Client sie anfordert.
|
Clients, sich **direkt** zu verbinden. Klappt das - im gleichen LAN, ueber VPN,
|
||||||
- **Use-Modus:** Zeigt verfuegbare Geraete von allen Share-Clients an. Geraete koennen einzeln verbunden/getrennt werden.
|
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.
|
**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.
|
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
|
```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
|
```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
|
Web-UI oeffnen: http://localhost:8080
|
||||||
@@ -146,12 +182,51 @@ choco install make
|
|||||||
|
|
||||||
### Plattform-Unterstuetzung
|
### Plattform-Unterstuetzung
|
||||||
|
|
||||||
| Funktion | Linux | Windows |
|
| Funktion | Linux | Windows | Android | macOS |
|
||||||
|----------|-------|---------|
|
|----------|-------|---------|---------|-------|
|
||||||
| Share-Modus (USB-Geraete freigeben) | Ja | Nein (kein usbdevfs) |
|
| Share-Modus | Ja | Ungetestet (eigener Treiber noetig, siehe unten) | Ja, ueber App-Bridge | Nein (braucht IOKit) |
|
||||||
| Use-Modus (USB-Geraete empfangen) | Ja (vhci-hcd) | Ja (usbip-win2) |
|
| Use-Modus | Ja (vhci-hcd) | Ja (usbip-win2) | Nein (kein vhci-hcd) | Nein (braucht Treiber) |
|
||||||
| Relay-Server | Ja | Ja |
|
| Kombinierter Modus (`both`) | Ja | Nein | Nein | Nein |
|
||||||
| Web-UI / Config | Ja | Ja |
|
| 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.
|
**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 generate-token # 3 Tokens + Hash generieren
|
||||||
usb-client share [optionen] # Share-Modus starten
|
usb-client both [optionen] # Freigeben und Empfangen gleichzeitig
|
||||||
usb-client use [optionen] # Use-Modus starten
|
usb-client share [optionen] # Nur freigeben
|
||||||
|
usb-client use [optionen] # Nur empfangen
|
||||||
usb-client list # Lokale USB-Geraete auflisten
|
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 gui # Nur Web-UI starten
|
||||||
usb-client config # Konfiguration anzeigen
|
usb-client config # Konfiguration anzeigen
|
||||||
usb-client config set [optionen] # Konfiguration aendern
|
usb-client config set [optionen] # Konfiguration aendern
|
||||||
@@ -248,6 +325,82 @@ usb-client uninstall-service # Service deinstallieren
|
|||||||
--no-gui Web-UI deaktivieren
|
--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
|
## Web-UI
|
||||||
|
|
||||||
Die Web-UI ist unter http://localhost:8080 erreichbar und bietet:
|
Die Web-UI ist unter http://localhost:8080 erreichbar und bietet:
|
||||||
@@ -293,9 +446,27 @@ sudo ./bin/usb-client uninstall-service
|
|||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
- **Transport-Verschluesselung:** Verwende `wss://` (WebSocket over TLS) fuer den Relay-Server bei Einsatz ueber das Internet.
|
- **Tunnel-Verschluesselung:** USB-Daten werden Ende-zu-Ende mit AES-256-GCM
|
||||||
- **Gruppierung:** Die 3 Tokens dienen als gemeinsames Geheimnis. Nur wer alle 3 Tokens kennt kann den Hash berechnen.
|
verschluesselt - sowohl ueber den Relay als auch bei Direktverbindung. Der
|
||||||
- **Relay:** Der Relay-Server sieht nur den Hash, nicht die Tokens.
|
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:
|
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"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/duffy/usb-server/internal/bridge"
|
||||||
"github.com/duffy/usb-server/internal/client"
|
"github.com/duffy/usb-server/internal/client"
|
||||||
"github.com/duffy/usb-server/internal/config"
|
"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/service"
|
||||||
"github.com/duffy/usb-server/internal/token"
|
"github.com/duffy/usb-server/internal/token"
|
||||||
"github.com/duffy/usb-server/internal/usb"
|
"github.com/duffy/usb-server/internal/usb"
|
||||||
"github.com/duffy/usb-server/internal/web"
|
"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() {
|
func main() {
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
|
|
||||||
@@ -34,8 +42,12 @@ func main() {
|
|||||||
cmdRun("share")
|
cmdRun("share")
|
||||||
case "use":
|
case "use":
|
||||||
cmdRun("use")
|
cmdRun("use")
|
||||||
|
case "both":
|
||||||
|
cmdRun("both")
|
||||||
case "list":
|
case "list":
|
||||||
cmdList()
|
cmdList()
|
||||||
|
case "diag":
|
||||||
|
cmdDiag()
|
||||||
case "gui":
|
case "gui":
|
||||||
cmdGUI()
|
cmdGUI()
|
||||||
case "config":
|
case "config":
|
||||||
@@ -63,7 +75,9 @@ Commands:
|
|||||||
generate-token Generate 3 tokens and compute hash
|
generate-token Generate 3 tokens and compute hash
|
||||||
share Start in share mode (expose USB devices)
|
share Start in share mode (expose USB devices)
|
||||||
use Start in use mode (consume 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
|
gui Start web UI only
|
||||||
config Show current configuration
|
config Show current configuration
|
||||||
install-service Install as systemd service
|
install-service Install as systemd service
|
||||||
@@ -159,6 +173,11 @@ func cmdRun(mode string) {
|
|||||||
cfg, cfgPath := loadConfig()
|
cfg, cfgPath := loadConfig()
|
||||||
cfg.Mode = mode
|
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 == "" {
|
if cfg.Hash == "" {
|
||||||
fmt.Println("Error: No hash configured. Run 'usb-client generate-token' first or set --hash.")
|
fmt.Println("Error: No hash configured. Run 'usb-client generate-token' first or set --hash.")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -172,50 +191,111 @@ func cmdRun(mode string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create client
|
|
||||||
c := client.NewClient(cfg)
|
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)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
// Start web UI unless disabled
|
// web_port 0 disables the UI as surely as --no-gui does. Binding to port 0
|
||||||
if !noGUI {
|
// would otherwise pick an arbitrary port, which on a shared machine means
|
||||||
webHandler := web.NewHandler(cfg, cfgPath)
|
// an unexpected open control interface.
|
||||||
|
if !noGUI && cfg.WebPort > 0 {
|
||||||
if mode == "share" {
|
webHandler := buildWebHandler(cfg, cfgPath, c, sm, um)
|
||||||
sm := client.NewShareManager(c, cfg)
|
addr := fmt.Sprintf(":%d", cfg.WebPort)
|
||||||
webHandler.GetDevices = func() interface{} {
|
log.Printf("Web UI available at http://localhost%s", addr)
|
||||||
return map[string]interface{}{
|
go func() {
|
||||||
"mode": "share",
|
if err := http.ListenAndServe(addr, webHandler); err != nil {
|
||||||
"local_devices": sm.DeviceListForAPI(),
|
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() {
|
go func() {
|
||||||
if err := c.Run(); err != nil {
|
if err := c.Run(); err != nil {
|
||||||
log.Printf("Client error: %v", err)
|
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{}
|
var availList []map[string]interface{}
|
||||||
for _, d := range available {
|
for _, d := range um.GetAvailableDevices() {
|
||||||
availList = append(availList, map[string]interface{}{
|
availList = append(availList, map[string]interface{}{
|
||||||
"bus_id": d.BusID,
|
"bus_id": d.BusID,
|
||||||
"vendor_id": d.VendorID,
|
"vendor_id": d.VendorID,
|
||||||
@@ -226,11 +306,12 @@ func cmdRun(mode string) {
|
|||||||
"client_id": d.ClientID,
|
"client_id": d.ClientID,
|
||||||
"client_name": d.ClientName,
|
"client_name": d.ClientName,
|
||||||
"allow_force_detach": um.IsForceDetachable(d.ClientID),
|
"allow_force_detach": um.IsForceDetachable(d.ClientID),
|
||||||
|
"auto_connect": um.IsAutoConnect(d.VendorID, d.ProductID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var attachList []map[string]interface{}
|
var attachList []map[string]interface{}
|
||||||
for _, d := range attached {
|
for _, d := range um.GetAttachedDevices() {
|
||||||
attachList = append(attachList, map[string]interface{}{
|
attachList = append(attachList, map[string]interface{}{
|
||||||
"bus_id": d.BusID,
|
"bus_id": d.BusID,
|
||||||
"vendor_id": d.VendorID,
|
"vendor_id": d.VendorID,
|
||||||
@@ -244,63 +325,25 @@ func cmdRun(mode string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
result["available_devices"] = availList
|
||||||
"mode": "use",
|
result["attached_devices"] = attachList
|
||||||
"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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", cfg.WebPort)
|
return result
|
||||||
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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
h.InstallService = func() error { return service.Install(cfg.Mode, cfgPath) }
|
||||||
sig := <-sigChan
|
h.UninstallService = service.Uninstall
|
||||||
log.Printf("Received signal %v, shutting down...", sig)
|
|
||||||
c.Close()
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmdList() {
|
func cmdList() {
|
||||||
@@ -314,6 +357,17 @@ func cmdList() {
|
|||||||
return
|
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.Printf("%-10s %-10s %-30s %-8s %s\n", "BUS-ID", "VID:PID", "NAME", "SPEED", "DRIVER")
|
||||||
fmt.Println(strings.Repeat("-", 80))
|
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() {
|
func cmdGUI() {
|
||||||
cfg, cfgPath := loadConfig()
|
cfg, cfgPath := loadConfig()
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -4,7 +4,9 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/duffy/usb-server/internal/relay"
|
"github.com/duffy/usb-server/internal/relay"
|
||||||
)
|
)
|
||||||
@@ -22,7 +24,7 @@ func main() {
|
|||||||
*addr = envAddr
|
*addr = envAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
listenAddr := fmt.Sprintf("%s:%d", *addr, *port)
|
listenAddr := buildListenAddr(*addr, *port)
|
||||||
|
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
log.Printf("USB Relay Server starting on %s", listenAddr)
|
log.Printf("USB Relay Server starting on %s", listenAddr)
|
||||||
@@ -32,3 +34,16 @@ func main() {
|
|||||||
log.Fatalf("server error: %v", err)
|
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:
|
services:
|
||||||
relay:
|
relay:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: usb-server-relay
|
||||||
ports:
|
ports:
|
||||||
- "8543:8443"
|
- "8543:8443"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -9,3 +12,34 @@ services:
|
|||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
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 (
|
require (
|
||||||
github.com/google/uuid v1.6.0
|
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/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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"
|
"time"
|
||||||
|
|
||||||
"github.com/duffy/usb-server/internal/config"
|
"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/protocol"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/gorilla/websocket"
|
"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
|
// Client manages the connection to the relay server
|
||||||
type Client struct {
|
type Client struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
clientID string
|
clientID string
|
||||||
conn *websocket.Conn
|
|
||||||
mu sync.Mutex
|
|
||||||
|
|
||||||
// Event callbacks
|
mu sync.Mutex
|
||||||
OnDeviceList func(msg *protocol.DeviceList)
|
conn *websocket.Conn
|
||||||
OnDeviceGranted func(msg *protocol.DeviceGranted)
|
send chan outMsg
|
||||||
OnDeviceDenied func(msg *protocol.DeviceDenied)
|
dead chan struct{}
|
||||||
OnDeviceReleased func(msg *protocol.DeviceReleased)
|
|
||||||
|
// 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)
|
OnClientJoined func(msg *protocol.ClientJoined)
|
||||||
OnClientLeft func(msg *protocol.ClientLeft)
|
OnRequestDevice func(targetClient, fromClient, busID, requestID string) // share side
|
||||||
OnRequestDevice func(targetClient, fromClient, busID, requestID string)
|
OnReleaseDevice func(busID, fromClient string) // share side
|
||||||
OnReleaseDevice func(busID, fromClient string)
|
OnForceRelease func(targetClient, fromClient, busID string) // share side
|
||||||
OnForceRelease func(targetClient, fromClient, busID string)
|
|
||||||
OnTunnelData func(tunnelID string, data []byte)
|
// 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
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -41,12 +93,38 @@ type Client struct {
|
|||||||
// NewClient creates a new client instance
|
// NewClient creates a new client instance
|
||||||
func NewClient(cfg *config.Config) *Client {
|
func NewClient(cfg *config.Config) *Client {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return &Client{
|
|
||||||
|
c := &Client{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
clientID: uuid.New().String(),
|
clientID: uuid.New().String(),
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
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
|
// ID returns the client ID
|
||||||
@@ -59,14 +137,18 @@ func (c *Client) Config() *config.Config {
|
|||||||
return c.cfg
|
return c.cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect establishes connection to the relay server
|
// Context returns the client's lifetime context.
|
||||||
func (c *Client) Connect() error {
|
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)
|
u, err := url.Parse(c.cfg.RelayAddr)
|
||||||
if err != nil {
|
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 {
|
switch u.Scheme {
|
||||||
case "ws", "wss":
|
case "ws", "wss":
|
||||||
// ok
|
// ok
|
||||||
@@ -78,57 +160,145 @@ func (c *Client) Connect() error {
|
|||||||
u.Scheme = "ws"
|
u.Scheme = "ws"
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.Path == "" {
|
if u.Path == "" || u.Path == "/" {
|
||||||
u.Path = "/ws"
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("connecting to relay: %w", err)
|
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.mu.Lock()
|
||||||
c.conn = conn
|
directPort := c.directPort
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
// Send registration
|
|
||||||
reg := &protocol.Register{
|
reg := &protocol.Register{
|
||||||
Type: protocol.MsgRegister,
|
Type: protocol.MsgRegister,
|
||||||
Hash: c.cfg.Hash,
|
Hash: c.cfg.Hash,
|
||||||
Mode: c.cfg.Mode,
|
Mode: c.cfg.Mode,
|
||||||
ClientID: c.clientID,
|
ClientID: c.clientID,
|
||||||
Name: c.cfg.Name,
|
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()
|
conn.Close()
|
||||||
return fmt.Errorf("sending registration: %w", err)
|
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
|
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
|
// RunReadLoop reads messages from the relay and dispatches them
|
||||||
func (c *Client) RunReadLoop() error {
|
func (c *Client) RunReadLoop() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
msgType, data, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
select {
|
select {
|
||||||
case <-c.ctx.Done():
|
case <-c.ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
msgType, data, err := c.conn.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||||
return fmt.Errorf("read error: %w", err)
|
return fmt.Errorf("read error: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||||
|
|
||||||
switch msgType {
|
switch msgType {
|
||||||
case websocket.TextMessage:
|
case websocket.TextMessage:
|
||||||
c.handleTextMessage(data)
|
c.handleTextMessage(data)
|
||||||
@@ -140,67 +310,176 @@ func (c *Client) RunReadLoop() error {
|
|||||||
|
|
||||||
// Run connects and runs the main loop with auto-reconnect
|
// Run connects and runs the main loop with auto-reconnect
|
||||||
func (c *Client) Run() error {
|
func (c *Client) Run() error {
|
||||||
|
backoff := reconnectMin
|
||||||
|
|
||||||
for {
|
for {
|
||||||
if err := c.Connect(); err != nil {
|
|
||||||
log.Printf("[client] connection failed: %v, retrying in 5s...", err)
|
|
||||||
select {
|
select {
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
continue
|
|
||||||
case <-c.ctx.Done():
|
case <-c.ctx.Done():
|
||||||
return nil
|
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()
|
err := c.RunReadLoop()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[client] disconnected: %v, reconnecting in 5s...", err)
|
log.Printf("[client] disconnected: %v", err)
|
||||||
} else {
|
} 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()
|
c.mu.Lock()
|
||||||
|
if c.dead != nil {
|
||||||
|
close(c.dead)
|
||||||
|
c.dead = nil
|
||||||
|
}
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
c.conn.Close()
|
c.conn.Close()
|
||||||
c.conn = nil
|
c.conn = nil
|
||||||
}
|
}
|
||||||
|
c.send = nil
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
case <-c.ctx.Done():
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close shuts down the client
|
// Close shuts down the client
|
||||||
func (c *Client) Close() {
|
func (c *Client) Close() {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
c.mu.Lock()
|
c.teardown()
|
||||||
if c.conn != nil {
|
|
||||||
c.conn.Close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
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
|
// SendJSON sends a JSON message to the relay
|
||||||
func (c *Client) SendJSON(v interface{}) error {
|
func (c *Client) SendJSON(v interface{}) error {
|
||||||
c.mu.Lock()
|
data, err := json.Marshal(v)
|
||||||
defer c.mu.Unlock()
|
if err != nil {
|
||||||
if c.conn == nil {
|
return fmt.Errorf("encoding message: %w", err)
|
||||||
return fmt.Errorf("not connected")
|
|
||||||
}
|
}
|
||||||
return c.conn.WriteJSON(v)
|
return c.enqueue(websocket.TextMessage, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendBinary sends a binary message to the relay
|
// SendBinary sends a binary message to the relay
|
||||||
func (c *Client) SendBinary(data []byte) error {
|
func (c *Client) SendBinary(data []byte) error {
|
||||||
c.mu.Lock()
|
return c.enqueue(websocket.BinaryMessage, data)
|
||||||
defer c.mu.Unlock()
|
|
||||||
if c.conn == nil {
|
|
||||||
return fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
return c.conn.WriteMessage(websocket.BinaryMessage, data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendTunnelData sends tunnel data with the tunnel ID prefix
|
// SendTunnelData sends tunnel data with the tunnel ID prefix
|
||||||
@@ -296,10 +575,13 @@ func (c *Client) handleTextMessage(data []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case protocol.MsgClientLeft:
|
case protocol.MsgClientLeft:
|
||||||
if c.OnClientLeft != nil {
|
|
||||||
var msg protocol.ClientLeft
|
var msg protocol.ClientLeft
|
||||||
if json.Unmarshal(data, &msg) == nil {
|
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])
|
tunnelID := string(data[:protocol.TunnelHeaderSize])
|
||||||
payload := data[protocol.TunnelHeaderSize:]
|
payload := data[protocol.TunnelHeaderSize:]
|
||||||
|
|
||||||
if c.OnTunnelData != nil {
|
c.handlerMu.RLock()
|
||||||
c.OnTunnelData(tunnelID, payload)
|
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"
|
"time"
|
||||||
|
|
||||||
"github.com/duffy/usb-server/internal/config"
|
"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/protocol"
|
||||||
"github.com/duffy/usb-server/internal/usb"
|
"github.com/duffy/usb-server/internal/usb"
|
||||||
"github.com/duffy/usb-server/internal/usbip"
|
"github.com/duffy/usb-server/internal/usbip"
|
||||||
@@ -22,6 +23,15 @@ type ShareManager struct {
|
|||||||
devices []usb.Device
|
devices []usb.Device
|
||||||
active map[string]*activeShare // busID -> active share
|
active map[string]*activeShare // busID -> active share
|
||||||
tunnels map[string]*shareTunnel // tunnelID -> tunnel
|
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 {
|
type activeShare struct {
|
||||||
@@ -34,9 +44,60 @@ type activeShare struct {
|
|||||||
type shareTunnel struct {
|
type shareTunnel struct {
|
||||||
id string
|
id string
|
||||||
busID 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
|
outPipe *io.PipeReader
|
||||||
done chan struct{}
|
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
|
// NewShareManager creates a share manager
|
||||||
@@ -46,18 +107,118 @@ func NewShareManager(client *Client, cfg *config.Config) *ShareManager {
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
active: make(map[string]*activeShare),
|
active: make(map[string]*activeShare),
|
||||||
tunnels: make(map[string]*shareTunnel),
|
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.OnRequestDevice = sm.handleRequestDevice
|
||||||
client.OnReleaseDevice = sm.handleReleaseDevice
|
client.OnReleaseDevice = sm.handleReleaseDevice
|
||||||
client.OnTunnelData = sm.handleTunnelData
|
|
||||||
client.OnClientLeft = sm.handleClientLeft
|
|
||||||
client.OnForceRelease = sm.handleForceRelease
|
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
|
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
|
// Run starts the share manager: periodic device enumeration + event handling
|
||||||
func (sm *ShareManager) Run() error {
|
func (sm *ShareManager) Run() error {
|
||||||
// Initial enumeration
|
// Initial enumeration
|
||||||
@@ -89,6 +250,14 @@ func (sm *ShareManager) GetDevices() []usb.Device {
|
|||||||
return result
|
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() {
|
func (sm *ShareManager) refreshDevices() {
|
||||||
devices, err := usb.Enumerate()
|
devices, err := usb.Enumerate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -202,15 +371,44 @@ func (sm *ShareManager) handleRequestDevice(targetClient, fromClient, busID, req
|
|||||||
tunnelID += "0"
|
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()
|
outReader, outWriter := io.Pipe()
|
||||||
|
|
||||||
tunnel := &shareTunnel{
|
tunnel := &shareTunnel{
|
||||||
id: tunnelID,
|
id: tunnelID,
|
||||||
busID: busID,
|
busID: busID,
|
||||||
inPipe: inWriter,
|
in: in,
|
||||||
outPipe: outReader,
|
outPipe: outReader,
|
||||||
done: make(chan struct{}),
|
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{
|
share := &activeShare{
|
||||||
@@ -224,23 +422,26 @@ func (sm *ShareManager) handleRequestDevice(targetClient, fromClient, busID, req
|
|||||||
sm.tunnels[tunnelID] = tunnel
|
sm.tunnels[tunnelID] = tunnel
|
||||||
sm.mu.Unlock()
|
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() {
|
go func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
close(tunnel.done)
|
close(tunnel.done)
|
||||||
inWriter.Close()
|
in.Close()
|
||||||
|
outWriter.Close()
|
||||||
outReader.Close()
|
outReader.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// First handle the management phase (import request from client)
|
err := server.HandleConnection(in, outWriter)
|
||||||
// The USB/IP client will send OP_REQ_IMPORT, we respond, then enter transfer phase
|
|
||||||
err := server.HandleConnection(inReader, outWriter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[share] USB/IP connection error for %s: %v", busID, err)
|
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() {
|
go func() {
|
||||||
buf := make([]byte, 65536)
|
buf := make([]byte, 65536)
|
||||||
for {
|
for {
|
||||||
@@ -248,13 +449,20 @@ func (sm *ShareManager) handleRequestDevice(targetClient, fromClient, busID, req
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
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
|
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{}{
|
sm.client.SendJSON(map[string]interface{}{
|
||||||
"type": protocol.MsgDeviceGranted,
|
"type": protocol.MsgDeviceGranted,
|
||||||
"bus_id": busID,
|
"bus_id": busID,
|
||||||
@@ -263,9 +471,12 @@ func (sm *ShareManager) handleRequestDevice(targetClient, fromClient, busID, req
|
|||||||
"dev_id": dev.DevID(),
|
"dev_id": dev.DevID(),
|
||||||
"speed": dev.Speed,
|
"speed": dev.Speed,
|
||||||
"target_client": fromClient,
|
"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
|
// Broadcast updated device list
|
||||||
sm.refreshDevices()
|
sm.refreshDevices()
|
||||||
@@ -282,13 +493,17 @@ func (sm *ShareManager) handleReleaseDevice(busID, fromClient string) {
|
|||||||
return
|
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{}
|
var tunnelDone <-chan struct{}
|
||||||
if tunnel, ok := sm.tunnels[share.tunnelID]; ok {
|
if tunnel, ok := sm.tunnels[share.tunnelID]; ok {
|
||||||
tunnel.inPipe.Close()
|
tunnel.in.Close()
|
||||||
|
tunnel.closeDirect()
|
||||||
tunnelDone = tunnel.done
|
tunnelDone = tunnel.done
|
||||||
delete(sm.tunnels, share.tunnelID)
|
delete(sm.tunnels, share.tunnelID)
|
||||||
}
|
}
|
||||||
|
if sm.listener != nil {
|
||||||
|
sm.listener.Forget(share.tunnelID)
|
||||||
|
}
|
||||||
|
|
||||||
server := share.server
|
server := share.server
|
||||||
delete(sm.active, busID)
|
delete(sm.active, busID)
|
||||||
@@ -332,7 +547,7 @@ func (sm *ShareManager) handleForceRelease(targetClient, fromClient, busID strin
|
|||||||
return
|
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)
|
sm.handleReleaseDevice(busID, share.usedBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +562,7 @@ func (sm *ShareManager) handleClientLeft(msg *protocol.ClientLeft) {
|
|||||||
sm.mu.RUnlock()
|
sm.mu.RUnlock()
|
||||||
|
|
||||||
for _, busID := range toRelease {
|
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)
|
sm.handleReleaseDevice(busID, msg.ClientID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,11 +573,24 @@ func (sm *ShareManager) handleTunnelData(tunnelID string, data []byte) {
|
|||||||
sm.mu.RUnlock()
|
sm.mu.RUnlock()
|
||||||
|
|
||||||
if !exists {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write incoming data to the USB/IP server's input pipe
|
payload, err := tunnel.codec.decode(data)
|
||||||
tunnel.inPipe.Write(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() {
|
func (sm *ShareManager) cleanup() {
|
||||||
@@ -371,7 +599,8 @@ func (sm *ShareManager) cleanup() {
|
|||||||
|
|
||||||
for busID, share := range sm.active {
|
for busID, share := range sm.active {
|
||||||
if tunnel, ok := sm.tunnels[share.tunnelID]; ok {
|
if tunnel, ok := sm.tunnels[share.tunnelID]; ok {
|
||||||
tunnel.inPipe.Close()
|
tunnel.in.Close()
|
||||||
|
tunnel.closeDirect()
|
||||||
}
|
}
|
||||||
share.server.Detach()
|
share.server.Detach()
|
||||||
log.Printf("[share] cleaned up device %s", busID)
|
log.Printf("[share] cleaned up device %s", busID)
|
||||||
@@ -379,6 +608,11 @@ func (sm *ShareManager) cleanup() {
|
|||||||
|
|
||||||
sm.active = make(map[string]*activeShare)
|
sm.active = make(map[string]*activeShare)
|
||||||
sm.tunnels = make(map[string]*shareTunnel)
|
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
|
// DeviceListForAPI returns device info formatted for the web API
|
||||||
@@ -407,4 +641,3 @@ func (sm *ShareManager) DeviceListForAPI() []map[string]interface{} {
|
|||||||
}
|
}
|
||||||
return result
|
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)
|
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
|
// fixVHCIDevicePermissions waits for the VHCI-attached device to create
|
||||||
// device nodes (e.g. /dev/video*, /dev/input/event*, /dev/hidraw*) and sets
|
// 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
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// logVHCIDeviceStatus is Linux-only (sysfs).
|
||||||
|
func logVHCIDeviceStatus(port int) {}
|
||||||
|
|
||||||
// fixVHCIDevicePermissions is not needed on Windows.
|
// fixVHCIDevicePermissions is not needed on Windows.
|
||||||
func fixVHCIDevicePermissions(port int) {}
|
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"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/duffy/usb-server/internal/config"
|
"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/protocol"
|
||||||
"github.com/duffy/usb-server/internal/usbip"
|
"github.com/duffy/usb-server/internal/usbip"
|
||||||
"github.com/google/uuid"
|
"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
|
// RemoteDevice represents a USB device available from a share client
|
||||||
type RemoteDevice struct {
|
type RemoteDevice struct {
|
||||||
protocol.USBDevice
|
protocol.USBDevice
|
||||||
@@ -36,16 +42,60 @@ type UseManager struct {
|
|||||||
available map[string][]RemoteDevice // clientID -> devices
|
available map[string][]RemoteDevice // clientID -> devices
|
||||||
attached map[string]*AttachedDevice // busID@clientID -> attached info
|
attached map[string]*AttachedDevice // busID@clientID -> attached info
|
||||||
tunnels map[string]*useTunnel // tunnelID -> tunnel
|
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
|
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 {
|
type useTunnel struct {
|
||||||
id string
|
id string
|
||||||
busID string
|
busID string
|
||||||
clientID string
|
clientID string
|
||||||
conn net.Conn // our end of the socketpair
|
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{}
|
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
|
// NewUseManager creates a use manager
|
||||||
@@ -57,20 +107,68 @@ func NewUseManager(client *Client, cfg *config.Config, cfgPath string) *UseManag
|
|||||||
available: make(map[string][]RemoteDevice),
|
available: make(map[string][]RemoteDevice),
|
||||||
attached: make(map[string]*AttachedDevice),
|
attached: make(map[string]*AttachedDevice),
|
||||||
tunnels: make(map[string]*useTunnel),
|
tunnels: make(map[string]*useTunnel),
|
||||||
pending: make(map[string]chan *protocol.DeviceGranted),
|
pending: make(map[string]*pendingRequest),
|
||||||
forceDetachable: make(map[string]bool),
|
forceDetachable: make(map[string]bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use-side messages only this manager handles.
|
||||||
client.OnDeviceList = um.handleDeviceList
|
client.OnDeviceList = um.handleDeviceList
|
||||||
client.OnDeviceGranted = um.handleDeviceGranted
|
client.OnDeviceGranted = um.handleDeviceGranted
|
||||||
client.OnDeviceDenied = um.handleDeviceDenied
|
client.OnDeviceDenied = um.handleDeviceDenied
|
||||||
client.OnDeviceReleased = um.handleDeviceReleased
|
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
|
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
|
// GetAvailableDevices returns all available remote devices
|
||||||
func (um *UseManager) GetAvailableDevices() []RemoteDevice {
|
func (um *UseManager) GetAvailableDevices() []RemoteDevice {
|
||||||
um.mu.RLock()
|
um.mu.RLock()
|
||||||
@@ -115,7 +213,7 @@ func (um *UseManager) AttachDevice(clientID, busID string) error {
|
|||||||
respChan := make(chan *protocol.DeviceGranted, 1)
|
respChan := make(chan *protocol.DeviceGranted, 1)
|
||||||
|
|
||||||
um.mu.Lock()
|
um.mu.Lock()
|
||||||
um.pending[requestID] = respChan
|
um.pending[requestID] = &pendingRequest{clientID: clientID, busID: busID, resp: respChan}
|
||||||
um.mu.Unlock()
|
um.mu.Unlock()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -137,13 +235,22 @@ func (um *UseManager) AttachDevice(clientID, busID string) error {
|
|||||||
|
|
||||||
log.Printf("[use] requesting device %s from %s", busID, clientID)
|
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 {
|
select {
|
||||||
case granted, ok := <-respChan:
|
case granted, ok := <-respChan:
|
||||||
if !ok || granted == nil {
|
if !ok || granted == nil {
|
||||||
return fmt.Errorf("device request denied")
|
return fmt.Errorf("device request denied")
|
||||||
}
|
}
|
||||||
return um.setupVHCI(clientID, busID, granted)
|
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():
|
case <-um.client.ctx.Done():
|
||||||
return fmt.Errorf("client shutting down")
|
return fmt.Errorf("client shutting down")
|
||||||
}
|
}
|
||||||
@@ -159,24 +266,7 @@ func (um *UseManager) DetachDevice(clientID, busID string) error {
|
|||||||
um.mu.Unlock()
|
um.mu.Unlock()
|
||||||
return fmt.Errorf("device %s not attached", key)
|
return fmt.Errorf("device %s not attached", key)
|
||||||
}
|
}
|
||||||
|
um.closeAttachedLocked(key, dev)
|
||||||
// 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.mu.Unlock()
|
um.mu.Unlock()
|
||||||
|
|
||||||
// Notify share client
|
// Notify share client
|
||||||
@@ -203,9 +293,37 @@ func (um *UseManager) setupVHCI(clientID, busID string, granted *protocol.Device
|
|||||||
}
|
}
|
||||||
um.mu.RUnlock()
|
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)
|
// Platform-specific VHCI attachment (Linux: socketpair+sysfs, Windows: TCP proxy+usbip.exe)
|
||||||
tunnelConn, vhciPort, err := createVHCIAttachment(um.client.ctx, granted, devInfo)
|
tunnelConn, vhciPort, err := createVHCIAttachment(um.client.ctx, granted, devInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if direct != nil {
|
||||||
|
direct.Close()
|
||||||
|
}
|
||||||
return fmt.Errorf("VHCI attachment: %w", err)
|
return fmt.Errorf("VHCI attachment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +333,13 @@ func (um *UseManager) setupVHCI(clientID, busID string, granted *protocol.Device
|
|||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
conn: tunnelConn,
|
conn: tunnelConn,
|
||||||
done: make(chan struct{}),
|
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
|
key := busID + "@" + clientID
|
||||||
@@ -235,14 +360,42 @@ func (um *UseManager) setupVHCI(clientID, busID string, granted *protocol.Device
|
|||||||
}
|
}
|
||||||
um.mu.Unlock()
|
um.mu.Unlock()
|
||||||
|
|
||||||
// Start reading from the tunnel socket (VHCI -> relay)
|
// Start reading from the tunnel socket (VHCI -> peer)
|
||||||
go um.tunnelReadLoop(tunnel)
|
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
|
// VHCI-created devices don't get normal udev permissions
|
||||||
go fixVHCIDevicePermissions(vhciPort)
|
go func() {
|
||||||
|
logVHCIDeviceStatus(vhciPort)
|
||||||
|
fixVHCIDevicePermissions(vhciPort)
|
||||||
|
}()
|
||||||
|
|
||||||
return nil
|
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)
|
log.Printf("[use] tunnel send error: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -305,7 +462,7 @@ func (um *UseManager) handleDeviceList(msg *protocol.DeviceList) {
|
|||||||
um.mu.Unlock()
|
um.mu.Unlock()
|
||||||
|
|
||||||
log.Printf("[use] received device list from %s (%s): %d devices",
|
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)
|
// Auto-connect matching devices (outside lock, each in its own goroutine)
|
||||||
for _, dev := range toAutoConnect {
|
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) {
|
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()
|
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()
|
um.mu.RUnlock()
|
||||||
|
|
||||||
if exists {
|
if owner == "" {
|
||||||
ch <- msg
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
um.client.SendJSON(&protocol.ReleaseDevice{
|
||||||
|
Type: protocol.MsgReleaseDevice,
|
||||||
|
TargetClient: owner,
|
||||||
|
BusID: msg.BusID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (um *UseManager) handleDeviceDenied(msg *protocol.DeviceDenied) {
|
func (um *UseManager) handleDeviceDenied(msg *protocol.DeviceDenied) {
|
||||||
log.Printf("[use] device request denied: %s - %s", msg.BusID, msg.Reason)
|
log.Printf("[use] device request denied: %s - %s", msg.BusID, msg.Reason)
|
||||||
|
|
||||||
um.mu.RLock()
|
if req, exists := um.resolvePending(msg.RequestID); exists {
|
||||||
ch, exists := um.pending[msg.RequestID]
|
close(req.resp) // a closed channel reads as a denial
|
||||||
um.mu.RUnlock()
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
close(ch) // signal denial by closing channel
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,24 +639,8 @@ func (um *UseManager) handleDeviceReleased(msg *protocol.DeviceReleased) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up tunnel
|
um.closeAttachedLocked(key, dev)
|
||||||
if tunnel, ok := um.tunnels[dev.TunnelID]; ok {
|
log.Printf("[use] device %s cleaned up (released by share client)", key)
|
||||||
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)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
um.mu.Unlock()
|
um.mu.Unlock()
|
||||||
@@ -465,16 +652,35 @@ func (um *UseManager) handleTunnelData(tunnelID string, data []byte) {
|
|||||||
um.mu.RUnlock()
|
um.mu.RUnlock()
|
||||||
|
|
||||||
if !exists {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to the tunnel socket (relay -> VHCI)
|
// A tunnel running directly gets its frames from that connection; anything
|
||||||
n, err := tunnel.conn.Write(data)
|
// arriving via the relay for it is stale or spoofed.
|
||||||
|
if tunnel.direct != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := tunnel.codec.decode(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[use] tunnel write error: %v", err)
|
log.Printf("[use] tunnel %s: rejecting relayed frame: %v", protocol.ShortID(tunnelID), err)
|
||||||
} else if n != len(data) {
|
tunnel.closeOnce.Do(func() { close(tunnel.done) })
|
||||||
log.Printf("[use] tunnel short write: %d/%d", n, len(data))
|
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
|
// Detach any devices from this client
|
||||||
for key, dev := range um.attached {
|
for key, dev := range um.attached {
|
||||||
if dev.ClientID == msg.ClientID {
|
if dev.ClientID == msg.ClientID {
|
||||||
if tunnel, ok := um.tunnels[dev.TunnelID]; ok {
|
um.closeAttachedLocked(key, dev)
|
||||||
close(tunnel.done)
|
|
||||||
tunnel.conn.Close()
|
|
||||||
delete(um.tunnels, dev.TunnelID)
|
|
||||||
}
|
|
||||||
if dev.VHCIPort >= 0 {
|
|
||||||
usbip.DetachDevice(dev.VHCIPort)
|
|
||||||
}
|
|
||||||
delete(um.attached, key)
|
|
||||||
log.Printf("[use] device %s auto-detached (client left)", key)
|
log.Printf("[use] device %s auto-detached (client left)", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -507,13 +705,7 @@ func (um *UseManager) Cleanup() {
|
|||||||
defer um.mu.Unlock()
|
defer um.mu.Unlock()
|
||||||
|
|
||||||
for key, dev := range um.attached {
|
for key, dev := range um.attached {
|
||||||
if tunnel, ok := um.tunnels[dev.TunnelID]; ok {
|
um.closeAttachedLocked(key, dev)
|
||||||
close(tunnel.done)
|
|
||||||
tunnel.conn.Close()
|
|
||||||
}
|
|
||||||
if dev.VHCIPort >= 0 {
|
|
||||||
usbip.DetachDevice(dev.VHCIPort)
|
|
||||||
}
|
|
||||||
log.Printf("[use] cleaned up device %s", key)
|
log.Printf("[use] cleaned up device %s", key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type AutoConnectRule struct {
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
RelayAddr string `json:"relay_addr"` // e.g. "ws://localhost:8443" or "wss://relay.example.com:8443"
|
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
|
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
|
Name string `json:"name"` // friendly name for this client
|
||||||
WebPort int `json:"web_port"` // web UI port (default 8080)
|
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
|
// Share mode: allow other clients to force-detach devices in use
|
||||||
AllowForceDetach bool `json:"allow_force_detach,omitempty"`
|
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
|
// 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"
|
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 (
|
const (
|
||||||
ModeShare = "share"
|
ModeShare = "share"
|
||||||
ModeUse = "use"
|
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
|
// Device status
|
||||||
const (
|
const (
|
||||||
StatusAvailable = "available"
|
StatusAvailable = "available"
|
||||||
@@ -41,6 +61,15 @@ type Register struct {
|
|||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
Name string `json:"name"`
|
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
|
// USBDevice describes a USB device
|
||||||
@@ -87,6 +116,17 @@ type DeviceGranted struct {
|
|||||||
RequestID string `json:"request_id"`
|
RequestID string `json:"request_id"`
|
||||||
DevID uint32 `json:"dev_id"` // (busnum << 16) | devnum
|
DevID uint32 `json:"dev_id"` // (busnum << 16) | devnum
|
||||||
Speed uint32 `json:"speed"`
|
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
|
// 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.
|
// TunnelHeader is prepended to binary WebSocket frames for tunnel data.
|
||||||
// Format: [16 bytes UUID][payload]
|
// Format: [16 bytes UUID][payload]
|
||||||
const TunnelHeaderSize = 16
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/duffy/usb-server/internal/protocol"
|
"github.com/duffy/usb-server/internal/protocol"
|
||||||
"github.com/gorilla/websocket"
|
"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
|
// Client represents a connected WebSocket client
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ID string
|
ID string
|
||||||
Hash string
|
Hash string
|
||||||
Mode string // "share" or "use"
|
Mode string // "share", "use" or "both"
|
||||||
Name string
|
Name string
|
||||||
Conn *websocket.Conn
|
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
|
// newClient creates a client with its outgoing queue ready.
|
||||||
func (c *Client) WriteJSON(v interface{}) error {
|
func newClient(id, hash, mode, name string, conn *websocket.Conn) *Client {
|
||||||
c.mu.Lock()
|
return &Client{
|
||||||
defer c.mu.Unlock()
|
ID: id,
|
||||||
return c.Conn.WriteJSON(v)
|
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
|
// enqueue queues a frame without blocking.
|
||||||
func (c *Client) WriteBinary(data []byte) error {
|
// It reports false when the client's queue is full or it is already gone; the
|
||||||
c.mu.Lock()
|
// caller should treat that as a disconnect rather than retrying.
|
||||||
defer c.mu.Unlock()
|
func (c *Client) enqueue(typ int, data []byte) bool {
|
||||||
return c.Conn.WriteMessage(websocket.BinaryMessage, data)
|
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
|
// 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
|
// Register adds a client to its hash group
|
||||||
func (h *Hub) Register(client *Client) {
|
func (h *Hub) Register(client *Client) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
if h.groups[client.Hash] == nil {
|
if h.groups[client.Hash] == nil {
|
||||||
h.groups[client.Hash] = make(map[string]*Client)
|
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.groups[client.Hash][client.ID] = client
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
log.Printf("[hub] client registered: id=%s hash=%s..%s mode=%s name=%s",
|
log.Printf("[hub] client registered: id=%s hash=%s mode=%s name=%s",
|
||||||
client.ID, client.Hash[:8], client.Hash[len(client.Hash)-4:], client.Mode, client.Name)
|
protocol.ShortID(client.ID), protocol.ShortID(client.Hash), client.Mode, client.Name)
|
||||||
|
|
||||||
// Notify other clients in the group
|
// Notify other clients in the group
|
||||||
h.broadcastToGroup(client.Hash, client.ID, &protocol.ClientJoined{
|
joined := &protocol.ClientJoined{
|
||||||
Type: protocol.MsgClientJoined,
|
Type: protocol.MsgClientJoined,
|
||||||
ClientID: client.ID,
|
ClientID: client.ID,
|
||||||
Mode: client.Mode,
|
Mode: client.Mode,
|
||||||
Name: client.Name,
|
Name: client.Name,
|
||||||
})
|
}
|
||||||
|
for _, peer := range h.peers(client.Hash, client.ID) {
|
||||||
|
peer.enqueueJSON(joined)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unregister removes a client and cleans up its tunnels
|
// Unregister removes a client and cleans up its tunnels
|
||||||
func (h *Hub) Unregister(client *Client) {
|
func (h *Hub) Unregister(client *Client) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
group := h.groups[client.Hash]
|
group := h.groups[client.Hash]
|
||||||
if group == nil {
|
if group == nil {
|
||||||
|
h.mu.Unlock()
|
||||||
return
|
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)
|
delete(group, client.ID)
|
||||||
|
}
|
||||||
if len(group) == 0 {
|
if len(group) == 0 {
|
||||||
delete(h.groups, client.Hash)
|
delete(h.groups, client.Hash)
|
||||||
}
|
}
|
||||||
@@ -101,21 +207,26 @@ func (h *Hub) Unregister(client *Client) {
|
|||||||
delete(h.tunnels, tid)
|
delete(h.tunnels, tid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
log.Printf("[hub] client unregistered: id=%s name=%s", client.ID, client.Name)
|
client.kill()
|
||||||
|
|
||||||
// Notify others
|
log.Printf("[hub] client unregistered: id=%s name=%s", protocol.ShortID(client.ID), client.Name)
|
||||||
h.broadcastToGroup(client.Hash, client.ID, &protocol.ClientLeft{
|
|
||||||
|
left := &protocol.ClientLeft{
|
||||||
Type: protocol.MsgClientLeft,
|
Type: protocol.MsgClientLeft,
|
||||||
ClientID: client.ID,
|
ClientID: client.ID,
|
||||||
})
|
}
|
||||||
|
for _, peer := range h.peers(client.Hash, client.ID) {
|
||||||
|
peer.enqueueJSON(left)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleTextMessage processes a JSON control message
|
// HandleTextMessage processes a JSON control message
|
||||||
func (h *Hub) HandleTextMessage(sender *Client, data []byte) {
|
func (h *Hub) HandleTextMessage(sender *Client, data []byte) {
|
||||||
var env protocol.Envelope
|
var env protocol.Envelope
|
||||||
if err := json.Unmarshal(data, &env); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +246,9 @@ func (h *Hub) HandleTextMessage(sender *Client, data []byte) {
|
|||||||
case protocol.MsgDeviceReleased:
|
case protocol.MsgDeviceReleased:
|
||||||
h.handleDeviceReleased(sender, data)
|
h.handleDeviceReleased(sender, data)
|
||||||
case protocol.MsgPing:
|
case protocol.MsgPing:
|
||||||
sender.WriteJSON(&protocol.Pong{Type: protocol.MsgPong})
|
sender.enqueueJSON(&protocol.Pong{Type: protocol.MsgPong})
|
||||||
default:
|
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
|
// Forward to the other end of the tunnel
|
||||||
var targetID string
|
var targetID string
|
||||||
if sender.ID == tunnel.ShareClient {
|
switch sender.ID {
|
||||||
|
case tunnel.ShareClient:
|
||||||
targetID = tunnel.UseClient
|
targetID = tunnel.UseClient
|
||||||
} else if sender.ID == tunnel.UseClient {
|
case tunnel.UseClient:
|
||||||
targetID = tunnel.ShareClient
|
targetID = tunnel.ShareClient
|
||||||
} else {
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.mu.RLock()
|
if target := h.peer(sender.Hash, targetID); target != nil {
|
||||||
group := h.groups[sender.Hash]
|
target.enqueue(websocket.BinaryMessage, data)
|
||||||
if group != nil {
|
|
||||||
if target := group[targetID]; target != nil {
|
|
||||||
target.WriteBinary(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) {
|
func (h *Hub) handleDeviceList(sender *Client, data []byte) {
|
||||||
if sender.Mode != protocol.ModeShare {
|
if !protocol.CanShare(sender.Mode) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.mu.RLock()
|
for _, client := range h.peers(sender.Hash, sender.ID) {
|
||||||
group := h.groups[sender.Hash]
|
if protocol.CanUse(client.Mode) {
|
||||||
for _, client := range group {
|
client.enqueue(websocket.TextMessage, data)
|
||||||
if client.ID != sender.ID && client.Mode == protocol.ModeUse {
|
|
||||||
client.mu.Lock()
|
|
||||||
client.Conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
client.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.mu.RUnlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRequestDevice forwards a device request to the target share client
|
// handleRequestDevice forwards a device request to the target share client
|
||||||
@@ -202,22 +305,19 @@ func (h *Hub) handleRequestDevice(sender *Client, data []byte) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.mu.RLock()
|
target := h.peer(sender.Hash, msg.TargetClient)
|
||||||
group := h.groups[sender.Hash]
|
if target == nil || !protocol.CanShare(target.Mode) {
|
||||||
if group != nil {
|
return
|
||||||
if target := group[msg.TargetClient]; target != nil && target.Mode == protocol.ModeShare {
|
}
|
||||||
|
|
||||||
// Add the sender's ID so the share client knows who's requesting
|
// 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,
|
"type": protocol.MsgRequestDevice,
|
||||||
"target_client": msg.TargetClient,
|
"target_client": msg.TargetClient,
|
||||||
"bus_id": msg.BusID,
|
"bus_id": msg.BusID,
|
||||||
"request_id": msg.RequestID,
|
"request_id": msg.RequestID,
|
||||||
"from_client": sender.ID,
|
"from_client": sender.ID,
|
||||||
}
|
})
|
||||||
target.WriteJSON(enriched)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.mu.RUnlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDeviceGranted registers the tunnel and forwards to the requesting client
|
// 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 {
|
if err := json.Unmarshal(data, &granted); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if granted.TunnelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Register tunnel
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.tunnels[granted.TunnelID] = &Tunnel{
|
h.tunnels[granted.TunnelID] = &Tunnel{
|
||||||
ID: granted.TunnelID,
|
ID: granted.TunnelID,
|
||||||
@@ -241,19 +343,44 @@ func (h *Hub) handleDeviceGranted(sender *Client, data []byte) {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
log.Printf("[hub] tunnel created: %s (share=%s, use=%s, device=%s)",
|
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
|
target := h.peer(sender.Hash, granted.TargetClient)
|
||||||
h.mu.RLock()
|
if target == nil {
|
||||||
group := h.groups[sender.Hash]
|
return
|
||||||
if group != nil {
|
}
|
||||||
if target := group[granted.TargetClient]; target != nil {
|
|
||||||
target.mu.Lock()
|
// Add the address we see the granting client at. It cannot know its own
|
||||||
target.Conn.WriteMessage(websocket.TextMessage, data)
|
// public address, and this is what lets the two peers connect directly
|
||||||
target.mu.Unlock()
|
// 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
|
// handleDeviceDenied forwards denial to the requesting client
|
||||||
@@ -266,17 +393,10 @@ func (h *Hub) handleDeviceDenied(sender *Client, data []byte) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.mu.RLock()
|
if target := h.peer(sender.Hash, denied.TargetClient); target != nil {
|
||||||
group := h.groups[sender.Hash]
|
target.enqueue(websocket.TextMessage, data)
|
||||||
if group != nil {
|
|
||||||
if target := group[denied.TargetClient]; target != nil {
|
|
||||||
target.mu.Lock()
|
|
||||||
target.Conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
target.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.mu.RUnlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleReleaseDevice forwards a release to the share client
|
// handleReleaseDevice forwards a release to the share client
|
||||||
func (h *Hub) handleReleaseDevice(sender *Client, data []byte) {
|
func (h *Hub) handleReleaseDevice(sender *Client, data []byte) {
|
||||||
@@ -296,21 +416,14 @@ func (h *Hub) handleReleaseDevice(sender *Client, data []byte) {
|
|||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
// Forward to share client
|
if target := h.peer(sender.Hash, msg.TargetClient); target != nil {
|
||||||
h.mu.RLock()
|
target.enqueueJSON(map[string]interface{}{
|
||||||
group := h.groups[sender.Hash]
|
|
||||||
if group != nil {
|
|
||||||
if target := group[msg.TargetClient]; target != nil {
|
|
||||||
enriched := map[string]interface{}{
|
|
||||||
"type": protocol.MsgReleaseDevice,
|
"type": protocol.MsgReleaseDevice,
|
||||||
"target_client": msg.TargetClient,
|
"target_client": msg.TargetClient,
|
||||||
"bus_id": msg.BusID,
|
"bus_id": msg.BusID,
|
||||||
"from_client": sender.ID,
|
"from_client": sender.ID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
target.WriteJSON(enriched)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.mu.RUnlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleForceRelease forwards a force-release request to the target share client
|
// 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()
|
h.mu.Unlock()
|
||||||
|
|
||||||
// Forward to share client
|
target := h.peer(sender.Hash, msg.TargetClient)
|
||||||
h.mu.RLock()
|
if target == nil || !protocol.CanShare(target.Mode) {
|
||||||
group := h.groups[sender.Hash]
|
return
|
||||||
if group != nil {
|
}
|
||||||
if target := group[msg.TargetClient]; target != nil && target.Mode == protocol.ModeShare {
|
|
||||||
enriched := map[string]interface{}{
|
target.enqueueJSON(map[string]interface{}{
|
||||||
"type": protocol.MsgForceRelease,
|
"type": protocol.MsgForceRelease,
|
||||||
"target_client": msg.TargetClient,
|
"target_client": msg.TargetClient,
|
||||||
"bus_id": msg.BusID,
|
"bus_id": msg.BusID,
|
||||||
"from_client": sender.ID,
|
"from_client": sender.ID,
|
||||||
}
|
})
|
||||||
target.WriteJSON(enriched)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.mu.RUnlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDeviceReleased broadcasts device released notification
|
// handleDeviceReleased broadcasts device released notification
|
||||||
func (h *Hub) handleDeviceReleased(sender *Client, data []byte) {
|
func (h *Hub) handleDeviceReleased(sender *Client, data []byte) {
|
||||||
h.mu.RLock()
|
for _, client := range h.peers(sender.Hash, sender.ID) {
|
||||||
group := h.groups[sender.Hash]
|
if protocol.CanUse(client.Mode) {
|
||||||
for _, client := range group {
|
client.enqueue(websocket.TextMessage, data)
|
||||||
if client.ID != sender.ID && client.Mode == protocol.ModeUse {
|
|
||||||
client.mu.Lock()
|
|
||||||
client.Conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
client.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.mu.RUnlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcastToGroup sends a message to all clients in a hash group except the sender
|
// GroupStats reports the number of clients per hash group, for diagnostics.
|
||||||
func (h *Hub) broadcastToGroup(hash, excludeID string, msg interface{}) {
|
func (h *Hub) GroupStats() map[string]int {
|
||||||
group := h.groups[hash]
|
h.mu.RLock()
|
||||||
for _, client := range group {
|
defer h.mu.RUnlock()
|
||||||
if client.ID != excludeID {
|
|
||||||
client.WriteJSON(msg)
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/duffy/usb-server/internal/protocol"
|
"github.com/duffy/usb-server/internal/protocol"
|
||||||
"github.com/gorilla/websocket"
|
"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{
|
var upgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 64 * 1024,
|
ReadBufferSize: 64 * 1024,
|
||||||
WriteBufferSize: 64 * 1024,
|
WriteBufferSize: 64 * 1024,
|
||||||
@@ -22,6 +41,7 @@ var upgrader = websocket.Upgrader{
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
hub *Hub
|
hub *Hub
|
||||||
addr string
|
addr string
|
||||||
|
diag *diagStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new relay server
|
// NewServer creates a new relay server
|
||||||
@@ -29,6 +49,7 @@ func NewServer(addr string) *Server {
|
|||||||
return &Server{
|
return &Server{
|
||||||
hub: NewHub(),
|
hub: NewHub(),
|
||||||
addr: addr,
|
addr: addr,
|
||||||
|
diag: newDiagStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,9 +58,21 @@ func (s *Server) Run() error {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/ws", s.handleWebSocket)
|
mux.HandleFunc("/ws", s.handleWebSocket)
|
||||||
mux.HandleFunc("/health", s.handleHealth)
|
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)
|
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) {
|
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()
|
defer conn.Close()
|
||||||
|
|
||||||
// Set read limits and deadlines
|
// Set read limits and deadlines
|
||||||
conn.SetReadLimit(1024 * 1024) // 1MB max message
|
conn.SetReadLimit(maxMessageSize)
|
||||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
conn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||||
conn.SetPongHandler(func(string) error {
|
conn.SetPongHandler(func(string) error {
|
||||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
conn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -78,55 +111,34 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if reg.Hash == "" || reg.ClientID == "" || (reg.Mode != protocol.ModeShare && reg.Mode != protocol.ModeUse) {
|
if reg.Hash == "" || reg.ClientID == "" || !protocol.ValidMode(reg.Mode) {
|
||||||
conn.WriteJSON(&protocol.ErrorMsg{Type: protocol.MsgError, Message: "missing required fields"})
|
conn.WriteJSON(&protocol.ErrorMsg{Type: protocol.MsgError, Message: "missing or invalid registration fields"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &Client{
|
client := newClient(reg.ClientID, reg.Hash, reg.Mode, reg.Name, conn)
|
||||||
ID: reg.ClientID,
|
client.DirectPort = reg.DirectPort
|
||||||
Hash: reg.Hash,
|
client.PublicIP = clientIP(r)
|
||||||
Mode: reg.Mode,
|
|
||||||
Name: reg.Name,
|
|
||||||
Conn: conn,
|
|
||||||
Send: make(chan []byte, 256),
|
|
||||||
}
|
|
||||||
|
|
||||||
s.hub.Register(client)
|
s.hub.Register(client)
|
||||||
defer s.hub.Unregister(client)
|
defer s.hub.Unregister(client)
|
||||||
|
|
||||||
// Start ping ticker
|
// The write pump owns the socket's write side: every frame for this
|
||||||
done := make(chan struct{})
|
// client, plus keepalive pings, goes through it. Nothing else may write,
|
||||||
go func() {
|
// which is what keeps one unresponsive peer from blocking the hub.
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
go s.writePump(client)
|
||||||
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)
|
|
||||||
|
|
||||||
// Read loop
|
// Read loop
|
||||||
for {
|
for {
|
||||||
msgType, data, err := conn.ReadMessage()
|
msgType, data, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
conn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||||
|
|
||||||
switch msgType {
|
switch msgType {
|
||||||
case websocket.TextMessage:
|
case websocket.TextMessage:
|
||||||
@@ -135,4 +147,67 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.hub.HandleBinaryMessage(client, data)
|
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"`
|
Serial string `json:"serial"`
|
||||||
SysPath string `json:"sys_path"` // sysfs path
|
SysPath string `json:"sys_path"` // sysfs path
|
||||||
DevPath string `json:"dev_path"` // /dev/bus/usb 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"`
|
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 {
|
type Interface struct {
|
||||||
Number uint8 `json:"number"`
|
Number uint8 `json:"number"`
|
||||||
|
AltSetting uint8 `json:"alt_setting"`
|
||||||
Class uint8 `json:"class"`
|
Class uint8 `json:"class"`
|
||||||
SubClass uint8 `json:"sub_class"`
|
SubClass uint8 `json:"sub_class"`
|
||||||
Protocol uint8 `json:"protocol"`
|
Protocol uint8 `json:"protocol"`
|
||||||
@@ -37,8 +47,15 @@ type Endpoint struct {
|
|||||||
Address uint8 `json:"address"` // bEndpointAddress (bit 7=direction, bits 3:0=number)
|
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
|
TransferType uint8 `json:"transfer_type"` // 0=control, 1=iso, 2=bulk, 3=interrupt
|
||||||
MaxPacketSize uint16 `json:"max_packet_size"`
|
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)
|
// USB transfer types (from bmAttributes)
|
||||||
const (
|
const (
|
||||||
TransferTypeControl = 0
|
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"
|
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) {
|
func Enumerate() ([]Device, error) {
|
||||||
entries, err := os.ReadDir(sysfsUSBDevices)
|
entries, err := os.ReadDir(sysfsUSBDevices)
|
||||||
if err != nil {
|
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)
|
return nil, fmt.Errorf("reading sysfs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +53,7 @@ func Enumerate() ([]Device, error) {
|
|||||||
devices = append(devices, *dev)
|
devices = append(devices, *dev)
|
||||||
}
|
}
|
||||||
|
|
||||||
return devices, nil
|
return mergeExternal(devices), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isDevicePath(name string) bool {
|
func isDevicePath(name string) bool {
|
||||||
@@ -93,12 +100,63 @@ func readDevice(busID string) (*Device, error) {
|
|||||||
// Compute dev path
|
// Compute dev path
|
||||||
dev.DevPath = fmt.Sprintf("/dev/bus/usb/%03d/%03d", dev.BusNum, dev.DevNum)
|
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)
|
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
|
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 {
|
func readInterfaces(sysPath, busID string) []Interface {
|
||||||
entries, err := os.ReadDir(sysPath)
|
entries, err := os.ReadDir(sysPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -150,26 +208,14 @@ func readEndpoints(ifacePath string) []Endpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
epPath := filepath.Join(ifacePath, name)
|
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{
|
eps = append(eps, Endpoint{
|
||||||
Address: uint8(addr),
|
Address: readHex8(epPath, "bEndpointAddress"),
|
||||||
TransferType: transferType,
|
TransferType: readHex8(epPath, "bmAttributes") & 0x03,
|
||||||
MaxPacketSize: uint16(maxPkt),
|
MaxPacketSize: readHex16(epPath, "wMaxPacketSize"),
|
||||||
|
Interval: readHex8(epPath, "bInterval"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,221 @@
|
|||||||
|
|
||||||
package usb
|
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) {
|
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
|
package usb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
@@ -126,10 +128,25 @@ type DeviceHandle struct {
|
|||||||
fd int
|
fd int
|
||||||
busID string
|
busID string
|
||||||
devPath 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) {
|
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)
|
fd, err := unix.Open(devPath, unix.O_RDWR, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opening %s: %w", devPath, err)
|
return nil, fmt.Errorf("opening %s: %w", devPath, err)
|
||||||
@@ -320,6 +337,20 @@ func (h *DeviceHandle) SubmitURB(params *SubmitURBParams) (*usbdevfsURB, error)
|
|||||||
return urb, nil
|
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
|
// ReapURB blocks until a URB completes, then returns it
|
||||||
func (h *DeviceHandle) ReapURB() (*usbdevfsURB, error) {
|
func (h *DeviceHandle) ReapURB() (*usbdevfsURB, error) {
|
||||||
var urbPtr uintptr
|
var urbPtr uintptr
|
||||||
@@ -327,7 +358,7 @@ func (h *DeviceHandle) ReapURB() (*usbdevfsURB, error) {
|
|||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
return nil, fmt.Errorf("USBDEVFS_REAPURB: %w", errno)
|
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
|
// ReapURBNonBlock tries to reap a URB without blocking
|
||||||
@@ -337,7 +368,7 @@ func (h *DeviceHandle) ReapURBNonBlock() (*usbdevfsURB, error) {
|
|||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
return nil, fmt.Errorf("USBDEVFS_REAPURBNDELAY: %w", errno)
|
return nil, fmt.Errorf("USBDEVFS_REAPURBNDELAY: %w", errno)
|
||||||
}
|
}
|
||||||
return (*usbdevfsURB)(unsafe.Pointer(urbPtr)), nil
|
return urbFromKernelPtr(urbPtr), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiscardURB cancels a submitted URB
|
// DiscardURB cancels a submitted URB
|
||||||
@@ -456,6 +487,77 @@ type ReapedURBInfo struct {
|
|||||||
ErrorCount int32
|
ErrorCount int32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrNoURBReady is returned by ReapURBInfoNonBlock when no URB has completed.
|
||||||
|
var ErrNoURBReady = errors.New("no completed URB available")
|
||||||
|
|
||||||
|
// ErrDeviceGone is returned when the device has been unplugged or the file
|
||||||
|
// descriptor is no longer usable.
|
||||||
|
var ErrDeviceGone = errors.New("device gone")
|
||||||
|
|
||||||
|
// WaitForURB waits up to timeout for at least one URB to complete.
|
||||||
|
// It returns true if a URB is ready to be reaped, false on timeout.
|
||||||
|
//
|
||||||
|
// usbdevfs signals completed URBs via POLLOUT, so polling lets the reap loop
|
||||||
|
// stay responsive to shutdown without either spinning on a non-blocking ioctl
|
||||||
|
// or blocking indefinitely in USBDEVFS_REAPURB. The latter matters: a blocking
|
||||||
|
// reap can only be broken by closing the fd, which races with the fd being
|
||||||
|
// reused by another goroutine.
|
||||||
|
func (h *DeviceHandle) WaitForURB(timeout time.Duration) (bool, error) {
|
||||||
|
fds := []unix.PollFd{{Fd: int32(h.fd), Events: unix.POLLOUT}}
|
||||||
|
|
||||||
|
ms := int(timeout.Milliseconds())
|
||||||
|
if ms < 0 {
|
||||||
|
ms = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, err := unix.Poll(fds, ms)
|
||||||
|
if err == unix.EINTR {
|
||||||
|
continue // interrupted by a signal, not an error
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("poll: %w", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return false, nil // timeout
|
||||||
|
}
|
||||||
|
// POLLERR/POLLHUP/POLLNVAL mean the device is gone or the fd was closed.
|
||||||
|
if fds[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 {
|
||||||
|
return false, ErrDeviceGone
|
||||||
|
}
|
||||||
|
return fds[0].Revents&unix.POLLOUT != 0, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReapURBInfoNonBlock reaps one completed URB without blocking.
|
||||||
|
// Returns ErrNoURBReady if none has completed, ErrDeviceGone if the device
|
||||||
|
// has been disconnected.
|
||||||
|
func (h *DeviceHandle) ReapURBInfoNonBlock() (*ReapedURBInfo, error) {
|
||||||
|
var urbPtr uintptr
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsReapURBNDelay, uintptr(unsafe.Pointer(&urbPtr)))
|
||||||
|
if errno != 0 {
|
||||||
|
switch errno {
|
||||||
|
case unix.EAGAIN:
|
||||||
|
return nil, ErrNoURBReady
|
||||||
|
case unix.ENODEV, unix.ESHUTDOWN, unix.EBADF, unix.ENOENT:
|
||||||
|
return nil, ErrDeviceGone
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("USBDEVFS_REAPURBNDELAY: %w", errno)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if urbPtr == 0 {
|
||||||
|
return nil, ErrNoURBReady
|
||||||
|
}
|
||||||
|
urb := urbFromKernelPtr(urbPtr)
|
||||||
|
return &ReapedURBInfo{
|
||||||
|
UserContext: urb.UserContext,
|
||||||
|
Status: urb.Status,
|
||||||
|
ActualLength: urb.ActualLength,
|
||||||
|
StartFrame: urb.StartFrame,
|
||||||
|
ErrorCount: urb.ErrorCount,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ReapURBInfo blocks until a URB completes and returns exported info
|
// ReapURBInfo blocks until a URB completes and returns exported info
|
||||||
func (h *DeviceHandle) ReapURBInfo() (*ReapedURBInfo, error) {
|
func (h *DeviceHandle) ReapURBInfo() (*ReapedURBInfo, error) {
|
||||||
var urbPtr uintptr
|
var urbPtr uintptr
|
||||||
@@ -463,7 +565,7 @@ func (h *DeviceHandle) ReapURBInfo() (*ReapedURBInfo, error) {
|
|||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
return nil, fmt.Errorf("USBDEVFS_REAPURB: %w", errno)
|
return nil, fmt.Errorf("USBDEVFS_REAPURB: %w", errno)
|
||||||
}
|
}
|
||||||
urb := (*usbdevfsURB)(unsafe.Pointer(urbPtr))
|
urb := urbFromKernelPtr(urbPtr)
|
||||||
return &ReapedURBInfo{
|
return &ReapedURBInfo{
|
||||||
UserContext: urb.UserContext,
|
UserContext: urb.UserContext,
|
||||||
Status: urb.Status,
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -17,6 +18,19 @@ import (
|
|||||||
"github.com/duffy/usb-server/internal/usb"
|
"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.
|
// Server handles USB/IP protocol on the share side.
|
||||||
// It manages a single USB device and forwards URBs between
|
// It manages a single USB device and forwards URBs between
|
||||||
// the USB/IP client (via tunnel) and the physical device (via usbdevfs).
|
// the USB/IP client (via tunnel) and the physical device (via usbdevfs).
|
||||||
@@ -25,8 +39,33 @@ type Server struct {
|
|||||||
handle *usb.DeviceHandle
|
handle *usb.DeviceHandle
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
pendingURBs map[uint32]*pendingURB // seqnum -> pending URB
|
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
|
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 {
|
type pendingURB struct {
|
||||||
@@ -42,12 +81,22 @@ type pendingURB struct {
|
|||||||
packetLens []uint32 // original request lengths per ISO packet (for offset computation)
|
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
|
// NewServer creates a USB/IP server for a specific device
|
||||||
func NewServer(dev *usb.Device) *Server {
|
func NewServer(dev *usb.Device) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
device: dev,
|
device: dev,
|
||||||
pendingURBs: make(map[uint32]*pendingURB),
|
pendingURBs: make(map[uint32]*pendingURB),
|
||||||
|
unlinkedURBs: make(map[uint32]*pendingURB),
|
||||||
epTypes: make(map[uint8]uint8),
|
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.
|
// Detach releases all interfaces, closes the device, and rebinds kernel drivers.
|
||||||
func (s *Server) Detach() {
|
func (s *Server) Detach() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
alreadyClosed := s.closed
|
||||||
s.closed = true
|
s.closed = true
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if s.handle == nil {
|
if alreadyClosed || s.handle == nil {
|
||||||
return
|
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()
|
s.mu.Lock()
|
||||||
for seqNum, pending := range s.pendingURBs {
|
for _, pending := range s.pendingURBs {
|
||||||
if pending.urbPtr != nil {
|
if pending.urbPtr != nil {
|
||||||
s.handle.DiscardURBByPtr(pending.urbPtr)
|
s.handle.DiscardURBByPtr(pending.urbPtr)
|
||||||
}
|
}
|
||||||
delete(s.pendingURBs, seqNum)
|
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
// 2. Release all claimed interfaces
|
// 3. Release all claimed interfaces
|
||||||
for _, iface := range s.device.Interfaces {
|
for _, iface := range s.device.Interfaces {
|
||||||
if err := s.handle.ReleaseInterface(uint32(iface.Number)); err != nil {
|
if err := s.handle.ReleaseInterface(uint32(iface.Number)); err != nil {
|
||||||
log.Printf("[usbip-server] release interface %d: %v", iface.Number, err)
|
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.
|
// The kernel auto-cancels remaining URBs on close.
|
||||||
s.handle.Close()
|
s.handle.Close()
|
||||||
s.handle = nil
|
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.
|
// After USBDEVFS_DISCONNECT_CLAIM, the kernel sets privileges_dropped=true.
|
||||||
// This means closing the fd does NOT auto-rebind drivers.
|
// This means closing the fd does NOT auto-rebind drivers.
|
||||||
// Also USBDEVFS_RESET after ReleaseInterface doesn't rebind because
|
// Also USBDEVFS_RESET after ReleaseInterface doesn't rebind because
|
||||||
@@ -139,6 +202,13 @@ func (s *Server) Detach() {
|
|||||||
s.rebindDrivers()
|
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
|
// rebindDrivers forces the kernel to re-bind drivers to the device
|
||||||
// by toggling the sysfs authorized attribute.
|
// by toggling the sysfs authorized attribute.
|
||||||
func (s *Server) rebindDrivers() {
|
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() {
|
func (s *Server) buildEndpointTypeMap() {
|
||||||
for _, iface := range s.device.Interfaces {
|
record := func(ep usb.Endpoint) {
|
||||||
for _, ep := range iface.Endpoints {
|
|
||||||
epNum := ep.Address & 0x0F
|
|
||||||
// Map USB descriptor transfer type to usbdevfs URB type
|
|
||||||
var urbType uint8
|
var urbType uint8
|
||||||
switch ep.TransferType {
|
switch ep.TransferType {
|
||||||
case usb.TransferTypeControl:
|
case usb.TransferTypeControl:
|
||||||
urbType = 2
|
urbType = usbdevfsTypeControl
|
||||||
case usb.TransferTypeIsochronous:
|
case usb.TransferTypeIsochronous:
|
||||||
urbType = 0
|
urbType = usbdevfsTypeISO
|
||||||
case usb.TransferTypeBulk:
|
case usb.TransferTypeBulk:
|
||||||
urbType = 3
|
urbType = usbdevfsTypeBulk
|
||||||
case usb.TransferTypeInterrupt:
|
case usb.TransferTypeInterrupt:
|
||||||
urbType = 1
|
urbType = usbdevfsTypeInterrupt
|
||||||
default:
|
default:
|
||||||
urbType = 3 // default bulk
|
urbType = usbdevfsTypeBulk
|
||||||
}
|
}
|
||||||
s.epTypes[epNum] = urbType
|
s.epTypes[ep.Address] = 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])
|
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
|
// getURBType returns the usbdevfs URB type for a full endpoint address.
|
||||||
func (s *Server) getURBType(endpoint uint8) uint8 {
|
//
|
||||||
if endpoint == 0 {
|
// interval and numPackets come from the incoming CMD_SUBMIT and act as a
|
||||||
return 2 // control
|
// 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 t
|
||||||
}
|
}
|
||||||
return 3 // default: bulk
|
if interval > 0 {
|
||||||
|
return usbdevfsTypeInterrupt
|
||||||
|
}
|
||||||
|
return usbdevfsTypeBulk
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildDeviceDescriptor creates a USB/IP device descriptor from our device info
|
// 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.
|
// 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.
|
// This is the main loop for handling a connected USB/IP client.
|
||||||
func (s *Server) HandleConnection(r io.Reader, w io.Writer) error {
|
func (s *Server) HandleConnection(r io.Reader, w io.Writer) error {
|
||||||
// Start the URB reaper goroutine
|
retChan := make(chan []byte, 256)
|
||||||
retChan := make(chan []byte, 64)
|
|
||||||
done := make(chan struct{})
|
|
||||||
defer close(done)
|
|
||||||
|
|
||||||
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() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case data, ok := <-retChan:
|
case data := <-retChan:
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := w.Write(data); err != nil {
|
if _, err := w.Write(data); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case <-done:
|
case <-connDone:
|
||||||
|
return
|
||||||
|
case <-s.stop:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,10 +402,9 @@ func (s *Server) HandleConnection(r io.Reader, w io.Writer) error {
|
|||||||
|
|
||||||
// Read and process incoming USB/IP messages
|
// Read and process incoming USB/IP messages
|
||||||
for {
|
for {
|
||||||
// Read the URB header (20 bytes basic + 28 bytes specific = 48 total)
|
|
||||||
hdr, err := ReadURBHeader(r)
|
hdr, err := ReadURBHeader(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == io.EOF {
|
if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.ErrClosedPipe) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("reading URB header: %w", err)
|
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 {
|
func (s *Server) handleCmdSubmit(r io.Reader, hdr *URBHeader, retChan chan<- []byte) error {
|
||||||
body, err := ReadCmdSubmit(r)
|
body, err := ReadCmdSubmit(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -331,122 +461,22 @@ func (s *Server) handleCmdSubmit(r io.Reader, hdr *URBHeader, retChan chan<- []b
|
|||||||
}
|
}
|
||||||
|
|
||||||
endpoint := uint8(hdr.Endpoint)
|
endpoint := uint8(hdr.Endpoint)
|
||||||
urbType := s.getURBType(endpoint)
|
|
||||||
|
|
||||||
dirStr := "OUT"
|
// Control transfers go to the dedicated worker: USBDEVFS_CONTROL is a
|
||||||
if hdr.Direction == DirIn {
|
// blocking ioctl and running it inline would stall every later URB behind
|
||||||
dirStr = "IN"
|
// a transfer that can take up to controlTimeout milliseconds.
|
||||||
}
|
|
||||||
|
|
||||||
// Log all transfers for debugging
|
|
||||||
if endpoint == 0 {
|
if endpoint == 0 {
|
||||||
bmReqType := body.Setup[0]
|
req := &ctrlRequest{hdr: hdr, body: body, transferBuf: transferBuf}
|
||||||
bReq := body.Setup[1]
|
select {
|
||||||
wVal := binary.LittleEndian.Uint16(body.Setup[2:4])
|
case s.ctrlQueue <- req:
|
||||||
wIdx := binary.LittleEndian.Uint16(body.Setup[4:6])
|
case <-s.stop:
|
||||||
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)
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Generic OUT control transfer
|
log.Printf("[usbip-server] control queue full, stalling on seq=%d", hdr.SeqNum)
|
||||||
buf := transferBuf
|
select {
|
||||||
if buf == nil {
|
case s.ctrlQueue <- req:
|
||||||
buf = make([]byte, 0)
|
case <-s.stop:
|
||||||
}
|
|
||||||
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] 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,10 +485,11 @@ func (s *Server) handleCmdSubmit(r io.Reader, hdr *URBHeader, retChan chan<- []b
|
|||||||
ep |= 0x80
|
ep |= 0x80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
urbType := s.getURBType(ep, body.Interval, numPackets)
|
||||||
|
|
||||||
// Handle isochronous transfers.
|
// Handle isochronous transfers.
|
||||||
// Trust the USB/IP NumberOfPackets field rather than our endpoint type map,
|
// Trust the USB/IP NumberOfPackets field rather than our endpoint type map,
|
||||||
// because the map is built at enumeration time (alternate setting 0) and
|
// because a webcam only activates its ISO endpoints after SET_INTERFACE.
|
||||||
// webcams only activate ISO endpoints after SET_INTERFACE to alt > 0.
|
|
||||||
if numPackets > 0 {
|
if numPackets > 0 {
|
||||||
return s.handleISOSubmit(hdr, body, transferBuf, isoDescs, numPackets, ep, retChan)
|
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),
|
UserContext: uintptr(hdr.SeqNum),
|
||||||
})
|
})
|
||||||
if err != nil {
|
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)
|
resp, _ := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, -32, 0, nil)
|
||||||
retChan <- resp
|
s.send(retChan, resp)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,6 +531,123 @@ func (s *Server) handleCmdSubmit(r io.Reader, hdr *URBHeader, retChan chan<- []b
|
|||||||
return nil
|
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
|
// handleISOSubmit handles isochronous URB submission
|
||||||
func (s *Server) handleISOSubmit(hdr *URBHeader, body *CmdSubmitBody, transferBuf []byte,
|
func (s *Server) handleISOSubmit(hdr *URBHeader, body *CmdSubmitBody, transferBuf []byte,
|
||||||
isoDescs []ISOPacketDescriptor, numPackets int32, ep uint8, retChan chan<- []byte) error {
|
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{
|
urb, isoMem, err := s.handle.SubmitISOURB(&usb.SubmitISOURBParams{
|
||||||
Endpoint: ep,
|
Endpoint: ep,
|
||||||
Flags: 0x02, // URB_ISO_ASAP
|
Flags: 0x02, // URB_ISO_ASAP
|
||||||
@@ -545,10 +690,10 @@ func (s *Server) handleISOSubmit(hdr *URBHeader, body *CmdSubmitBody, transferBu
|
|||||||
UserContext: uintptr(hdr.SeqNum),
|
UserContext: uintptr(hdr.SeqNum),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[usbip-server] ISO submit FAILED: %v", err)
|
log.Printf("[usbip-server] ISO submit FAILED (ep=0x%02x pkts=%d buf=%d): %v",
|
||||||
// Submit failed - send error response
|
ep, numPackets, totalBufLen, err)
|
||||||
resp, _ := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, -32, 0, nil)
|
resp, _ := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, -32, 0, nil)
|
||||||
retChan <- resp
|
s.send(retChan, resp)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,18 +721,22 @@ func (s *Server) handleCmdUnlink(r io.Reader, hdr *URBHeader, retChan chan<- []b
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[usbip-server] UNLINK seq=%d target_seq=%d", hdr.SeqNum, body.UnlinkSeqNum)
|
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
pending, exists := s.pendingURBs[body.UnlinkSeqNum]
|
pending, exists := s.pendingURBs[body.UnlinkSeqNum]
|
||||||
if exists {
|
if exists {
|
||||||
delete(s.pendingURBs, body.UnlinkSeqNum)
|
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()
|
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
|
var status int32
|
||||||
if exists && pending.urbPtr != nil {
|
if exists && pending.urbPtr != nil && handle != nil {
|
||||||
if err := s.handle.DiscardURBByPtr(pending.urbPtr); err == nil {
|
if err := handle.DiscardURBByPtr(pending.urbPtr); err == nil {
|
||||||
status = -104 // -ECONNRESET
|
status = -104 // -ECONNRESET
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -596,37 +745,61 @@ func (s *Server) handleCmdUnlink(r io.Reader, hdr *URBHeader, retChan chan<- []b
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
retChan <- resp
|
s.send(retChan, resp)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reapLoop continuously reaps completed URBs and sends responses
|
// reapLoop collects completed URBs and turns them into RET_SUBMIT responses.
|
||||||
func (s *Server) reapLoop(retChan chan<- []byte, done <-chan struct{}) {
|
func (s *Server) reapLoop(retChan chan<- []byte) {
|
||||||
|
defer s.workers.Done()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-s.stop:
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.closed || s.handle == nil {
|
closed := s.closed
|
||||||
s.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Save handle reference under lock to prevent nil deref race
|
|
||||||
handle := s.handle
|
handle := s.handle
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
if closed || handle == nil {
|
||||||
urbInfo, err := handle.ReapURBInfo()
|
|
||||||
if err != nil {
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
return
|
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
|
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)
|
seqNum := uint32(urbInfo.UserContext)
|
||||||
@@ -635,34 +808,24 @@ func (s *Server) reapLoop(retChan chan<- []byte, done <-chan struct{}) {
|
|||||||
pending, exists := s.pendingURBs[seqNum]
|
pending, exists := s.pendingURBs[seqNum]
|
||||||
if exists {
|
if exists {
|
||||||
delete(s.pendingURBs, seqNum)
|
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()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
|
// Already unlinked; the client is not expecting a RET_SUBMIT.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if urbInfo.Status != 0 {
|
||||||
dirStr := "OUT"
|
dirStr := "OUT"
|
||||||
if pending.direction == DirIn {
|
if pending.direction == DirIn {
|
||||||
dirStr = "IN"
|
dirStr = "IN"
|
||||||
}
|
}
|
||||||
urbType := s.getURBType(uint8(pending.endpoint))
|
log.Printf("[usbip-server] URB error: seq=%d EP%d %s status=%d actual=%d",
|
||||||
typeNames := map[uint8]string{0: "ISO", 1: "INT", 2: "CTRL", 3: "BULK"}
|
pending.seqNum, pending.endpoint, dirStr, urbInfo.Status, urbInfo.ActualLength)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp []byte
|
var resp []byte
|
||||||
@@ -671,7 +834,11 @@ func (s *Server) reapLoop(retChan chan<- []byte, done <-chan struct{}) {
|
|||||||
} else {
|
} else {
|
||||||
var data []byte
|
var data []byte
|
||||||
if pending.direction == DirIn && urbInfo.ActualLength > 0 {
|
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(
|
resp, err = BuildRetSubmit(
|
||||||
pending.seqNum,
|
pending.seqNum,
|
||||||
@@ -689,7 +856,7 @@ func (s *Server) reapLoop(retChan chan<- []byte, done <-chan struct{}) {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case retChan <- resp:
|
case retChan <- resp:
|
||||||
case <-done:
|
case <-s.stop:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -723,8 +890,10 @@ func (s *Server) buildISOResponse(urbInfo *usb.ReapedURBInfo, pending *pendingUR
|
|||||||
if end > uint32(len(pending.buffer)) {
|
if end > uint32(len(pending.buffer)) {
|
||||||
end = uint32(len(pending.buffer))
|
end = uint32(len(pending.buffer))
|
||||||
}
|
}
|
||||||
|
if bufOffset < end {
|
||||||
packedData = append(packedData, pending.buffer[bufOffset:end]...)
|
packedData = append(packedData, pending.buffer[bufOffset:end]...)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bufOffset += pktLen
|
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
|
package usbip
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/duffy/usb-server/internal/usb"
|
"github.com/duffy/usb-server/internal/usb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server is a stub on Windows - USB/IP server requires Linux usbdevfs.
|
// USB/IP server for Windows, driving devices through the usbshare filter
|
||||||
type Server struct{}
|
// 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 {
|
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 {
|
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 {
|
||||||
func (s *Server) BuildDeviceDescriptor() DeviceDescriptor {
|
return fmt.Errorf("claiming %s: %w", s.device.BusID, err)
|
||||||
return DeviceDescriptor{}
|
|
||||||
}
|
}
|
||||||
|
s.handle = handle
|
||||||
|
|
||||||
func (s *Server) BuildInterfaceDescriptors() []InterfaceDescriptor {
|
s.buildEndpointTypeMap()
|
||||||
return nil
|
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 {
|
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) {
|
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) {
|
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) {
|
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"
|
"net/http"
|
||||||
|
|
||||||
"github.com/duffy/usb-server/internal/config"
|
"github.com/duffy/usb-server/internal/config"
|
||||||
|
"github.com/duffy/usb-server/internal/protocol"
|
||||||
"github.com/duffy/usb-server/internal/token"
|
"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"`
|
Name string `json:"name"`
|
||||||
WebPort int `json:"web_port"`
|
WebPort int `json:"web_port"`
|
||||||
AllowForceDetach *bool `json:"allow_force_detach,omitempty"`
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||||
writeJSON(w, map[string]interface{}{"ok": false, "error": "invalid request"})
|
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 != "" {
|
if updates.RelayAddr != "" {
|
||||||
h.cfg.RelayAddr = updates.RelayAddr
|
h.cfg.RelayAddr = updates.RelayAddr
|
||||||
}
|
}
|
||||||
if updates.Mode == "share" || updates.Mode == "use" {
|
if protocol.ValidMode(updates.Mode) {
|
||||||
h.cfg.Mode = updates.Mode
|
h.cfg.Mode = updates.Mode
|
||||||
}
|
}
|
||||||
if updates.Name != "" {
|
if updates.Name != "" {
|
||||||
@@ -192,6 +195,12 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
if updates.AllowForceDetach != nil {
|
if updates.AllowForceDetach != nil {
|
||||||
h.cfg.AllowForceDetach = *updates.AllowForceDetach
|
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 {
|
if err := h.cfg.Save(h.cfgPath); err != nil {
|
||||||
writeJSON(w, map[string]interface{}{"ok": false, "error": err.Error()})
|
writeJSON(w, map[string]interface{}{"ok": false, "error": err.Error()})
|
||||||
|
|||||||
+66
-28
@@ -27,10 +27,17 @@ async function updateStatus() {
|
|||||||
el.className = 'status disconnected';
|
el.className = 'status disconnected';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modeNames = {
|
||||||
|
share: 'Freigeben',
|
||||||
|
use: 'Empfangen',
|
||||||
|
both: 'Freigeben und Empfangen',
|
||||||
|
};
|
||||||
document.getElementById('mode-info').innerHTML =
|
document.getElementById('mode-info').innerHTML =
|
||||||
`<strong>Modus:</strong> ${data.mode === 'share' ? 'Freigeben' : 'Empfangen'} | ` +
|
`<strong>Modus:</strong> ${escapeHtml(modeNames[data.mode] || data.mode)} | ` +
|
||||||
`<strong>Name:</strong> ${data.name} | ` +
|
`<strong>Name:</strong> ${escapeHtml(data.name)} | ` +
|
||||||
`<strong>Client ID:</strong> ${data.client_id ? data.client_id.substring(0, 8) + '...' : '-'}`;
|
`<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) {
|
} catch (e) {
|
||||||
const el = document.getElementById('status');
|
const el = document.getElementById('status');
|
||||||
el.textContent = 'Fehler';
|
el.textContent = 'Fehler';
|
||||||
@@ -53,26 +60,34 @@ async function updateDevices() {
|
|||||||
function renderDevices(data) {
|
function renderDevices(data) {
|
||||||
const container = document.getElementById('device-list');
|
const container = document.getElementById('device-list');
|
||||||
|
|
||||||
if (data.mode === 'share') {
|
// In "both" mode show remote devices first — those are the ones you act
|
||||||
renderShareDevices(container, data.local_devices || []);
|
// on — then the local devices this machine offers.
|
||||||
} else {
|
let html = '';
|
||||||
renderUseDevices(container, data.available_devices || [], data.attached_devices || []);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderShareDevices(container, devices) {
|
container.innerHTML = html || '<p class="no-devices">Keine Geraete</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShareSection(devices) {
|
||||||
if (!devices || devices.length === 0) {
|
if (!devices || devices.length === 0) {
|
||||||
container.innerHTML = '<p class="no-devices">Keine USB-Geraete gefunden</p>';
|
return '<p class="no-devices">Keine USB-Geraete gefunden</p>';
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = devices.map(dev => `
|
return devices.map(dev => `
|
||||||
<div class="device-card">
|
<div class="device-card">
|
||||||
<div class="device-info">
|
<div class="device-info">
|
||||||
<div class="device-name">${escapeHtml(dev.name)}</div>
|
<div class="device-name">${escapeHtml(dev.name)}</div>
|
||||||
<div class="device-details">
|
<div class="device-details">
|
||||||
<span>Bus: ${dev.bus_id}</span>
|
<span>Bus: ${escapeHtml(dev.bus_id)}</span>
|
||||||
<span>VID:PID: ${dev.vendor_id}:${dev.product_id}</span>
|
<span>VID:PID: ${escapeHtml(dev.vendor_id)}:${escapeHtml(dev.product_id)}</span>
|
||||||
<span>Speed: ${speedName(dev.speed)}</span>
|
<span>Speed: ${speedName(dev.speed)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +100,7 @@ function renderShareDevices(container, devices) {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUseDevices(container, available, attached) {
|
function renderUseSection(available, attached) {
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
// Attached devices first
|
// 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-name">${escapeHtml(dev.name || dev.bus_id)}</div>
|
||||||
<div class="device-details">
|
<div class="device-details">
|
||||||
<span>Von: ${escapeHtml(dev.client_name || dev.client_id)}</span>
|
<span>Von: ${escapeHtml(dev.client_name || dev.client_id)}</span>
|
||||||
${dev.vendor_id ? `<span>VID:PID: ${dev.vendor_id}:${dev.product_id}</span>` : ''}
|
${dev.vendor_id ? `<span>VID:PID: ${escapeHtml(dev.vendor_id)}:${escapeHtml(dev.product_id)}</span>` : ''}
|
||||||
<span>VHCI Port: ${dev.vhci_port}</span>
|
<span>VHCI Port: ${escapeHtml(dev.vhci_port)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="device-status">
|
<div class="device-status">
|
||||||
<label class="auto-connect-label" title="Beim Start automatisch verbinden">
|
<label class="auto-connect-label" title="Beim Start automatisch verbinden">
|
||||||
<input type="checkbox" ${dev.auto_connect ? 'checked' : ''}
|
<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
|
Autostart
|
||||||
</label>
|
</label>
|
||||||
<span class="badge attached">Verbunden</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -125,12 +140,11 @@ function renderUseDevices(container, available, attached) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (Object.keys(byClient).length === 0 && (!attached || attached.length === 0)) {
|
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 '<p class="no-devices">Keine fremden Geraete verfuegbar. Warte auf Share-Clients...</p>';
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [clientId, info] of Object.entries(byClient)) {
|
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 => {
|
html += info.devices.map(dev => {
|
||||||
const isAttached = (attached || []).some(a =>
|
const isAttached = (attached || []).some(a =>
|
||||||
a.bus_id === dev.bus_id && a.client_id === clientId
|
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-info">
|
||||||
<div class="device-name">${escapeHtml(dev.name)}</div>
|
<div class="device-name">${escapeHtml(dev.name)}</div>
|
||||||
<div class="device-details">
|
<div class="device-details">
|
||||||
<span>Bus: ${dev.bus_id}</span>
|
<span>Bus: ${escapeHtml(dev.bus_id)}</span>
|
||||||
<span>VID:PID: ${dev.vendor_id}:${dev.product_id}</span>
|
<span>VID:PID: ${escapeHtml(dev.vendor_id)}:${escapeHtml(dev.product_id)}</span>
|
||||||
<span>Speed: ${speedName(dev.speed)}</span>
|
<span>Speed: ${speedName(dev.speed)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="device-status">
|
<div class="device-status">
|
||||||
${dev.status === 'in_use'
|
${dev.status === 'in_use'
|
||||||
? `<span class="badge in-use">In Benutzung</span>
|
? `<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
|
: isAttached
|
||||||
? '<span class="badge attached">Verbunden</span>'
|
? '<span class="badge attached">Verbunden</span>'
|
||||||
: `<span class="badge available">Verfuegbar</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +174,7 @@ function renderUseDevices(container, available, attached) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = html || '<p class="no-devices">Keine Geraete verfuegbar</p>';
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach/Detach
|
// Attach/Detach
|
||||||
@@ -246,6 +260,8 @@ async function loadSettings() {
|
|||||||
document.getElementById('client-name').value = cfg.name || '';
|
document.getElementById('client-name').value = cfg.name || '';
|
||||||
document.getElementById('web-port').value = cfg.web_port || 8080;
|
document.getElementById('web-port').value = cfg.web_port || 8080;
|
||||||
document.getElementById('allow-force-detach').checked = cfg.allow_force_detach || false;
|
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('token1').value = cfg.token1 || '';
|
||||||
document.getElementById('token2').value = cfg.token2 || '';
|
document.getElementById('token2').value = cfg.token2 || '';
|
||||||
document.getElementById('token3').value = cfg.token3 || '';
|
document.getElementById('token3').value = cfg.token3 || '';
|
||||||
@@ -269,6 +285,8 @@ document.getElementById('settings-form').addEventListener('submit', async (e) =>
|
|||||||
name: document.getElementById('client-name').value,
|
name: document.getElementById('client-name').value,
|
||||||
web_port: parseInt(document.getElementById('web-port').value),
|
web_port: parseInt(document.getElementById('web-port').value),
|
||||||
allow_force_detach: document.getElementById('allow-force-detach').checked,
|
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();
|
const data = await resp.json();
|
||||||
@@ -354,8 +372,28 @@ function speedName(speed) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
if (!str) return '';
|
if (str === null || str === undefined) return '';
|
||||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
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
|
// Init
|
||||||
|
|||||||
@@ -43,9 +43,11 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="mode">Modus</label>
|
<label for="mode">Modus</label>
|
||||||
<select id="mode">
|
<select id="mode">
|
||||||
<option value="share">Freigeben (Share)</option>
|
<option value="both">Beides (Freigeben und Empfangen)</option>
|
||||||
<option value="use">Empfangen (Use)</option>
|
<option value="share">Nur Freigeben (Share)</option>
|
||||||
|
<option value="use">Nur Empfangen (Use)</option>
|
||||||
</select>
|
</select>
|
||||||
|
<small>Nach dem Speichern neu starten, damit der Modus wirkt</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="client-name">Client Name</label>
|
<label for="client-name">Client Name</label>
|
||||||
@@ -62,6 +64,20 @@
|
|||||||
</label>
|
</label>
|
||||||
<small>Erlaubt Use-Clients, Geraete die von anderen benutzt werden zu trennen</small>
|
<small>Erlaubt Use-Clients, Geraete die von anderen benutzt werden zu trennen</small>
|
||||||
</div>
|
</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>
|
<button type="submit" class="btn primary">Speichern</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user