first commit
This commit is contained in:
commit
5464e553b3
|
|
@ -0,0 +1,20 @@
|
||||||
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /usb-relay ./cmd/usb-relay/
|
||||||
|
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
|
||||||
|
COPY --from=builder /usb-relay /usr/local/bin/usb-relay
|
||||||
|
|
||||||
|
EXPOSE 8443
|
||||||
|
|
||||||
|
ENTRYPOINT ["usb-relay"]
|
||||||
|
CMD ["--port", "8443"]
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
Proprietary Software License
|
||||||
|
|
||||||
|
Copyright (c) 2026 duffy. All rights reserved.
|
||||||
|
|
||||||
|
This software and associated documentation files (the "Software") are
|
||||||
|
proprietary and confidential. Unauthorized copying, distribution, modification,
|
||||||
|
public display, or public performance of this Software, via any medium, is
|
||||||
|
strictly prohibited.
|
||||||
|
|
||||||
|
The Software is provided under license and may only be used in accordance with
|
||||||
|
the terms of a separate license agreement. No part of this Software may be
|
||||||
|
reproduced, distributed, or transmitted in any form or by any means, including
|
||||||
|
photocopying, recording, or other electronic or mechanical methods, without
|
||||||
|
the prior written permission of the copyright holder.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
For licensing inquiries, please contact the copyright holder.
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
.PHONY: all relay client client-windows clean
|
||||||
|
|
||||||
|
GOOS ?= linux
|
||||||
|
GOARCH ?= amd64
|
||||||
|
|
||||||
|
all: relay client
|
||||||
|
|
||||||
|
relay:
|
||||||
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/usb-relay ./cmd/usb-relay/
|
||||||
|
|
||||||
|
client:
|
||||||
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/usb-client ./cmd/usb-client/
|
||||||
|
|
||||||
|
client-windows:
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o bin/usb-client.exe ./cmd/usb-client/
|
||||||
|
|
||||||
|
docker:
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin/
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
Third-Party Software Notices
|
||||||
|
|
||||||
|
This software uses the following open-source libraries:
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
License: BSD 2-Clause "Simplified" License
|
||||||
|
|
||||||
|
Copyright (c) 2023 The Gorilla Authors. All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||||
|
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||||
|
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
License: BSD 3-Clause "New" or "Revised" License
|
||||||
|
|
||||||
|
Copyright (c) 2009,2014 Google Inc. All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are
|
||||||
|
met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
copyright notice, this list of conditions and the following disclaimer
|
||||||
|
in the documentation and/or other materials provided with the
|
||||||
|
distribution.
|
||||||
|
* Neither the name of Google Inc. nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||||
|
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
golang.org/x/sys v0.41.0
|
||||||
|
License: BSD 3-Clause "New" or "Revised" License
|
||||||
|
|
||||||
|
Copyright 2009 The Go Authors.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are
|
||||||
|
met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
copyright notice, this list of conditions and the following disclaimer
|
||||||
|
in the documentation and/or other materials provided with the
|
||||||
|
distribution.
|
||||||
|
* Neither the name of Google LLC nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||||
|
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
# USB Server
|
||||||
|
|
||||||
|
USB-Sharing ueber Netzwerk mit Relay-Server fuer NAT-Traversal.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐
|
||||||
|
│ Client (share) │──ws/wss─│ Relay Server │─ws/wss──│ Client (use) │
|
||||||
|
│ gibt USB-Geraete│ │ (Docker) │ │ empfaengt USB- │
|
||||||
|
│ frei │ │ gruppiert │ │ Geraete │
|
||||||
|
│ Web-UI :8080 │ │ nach Hash │ │ Web-UI :8080 │
|
||||||
|
└────────┬─────────┘ └──────────────┘ └────────┬─────────┘
|
||||||
|
│ │
|
||||||
|
Physische USB Virtuelle USB
|
||||||
|
Geraete (vhci-hcd)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Relay-Server:** Einfacher WebSocket-Vermittler. Braucht keine Konfiguration - verbindet alle Clients die den gleichen Hash haben. Als Docker-Container deploybar.
|
||||||
|
|
||||||
|
**Client:** Kann in zwei Modi betrieben werden:
|
||||||
|
- **Share-Modus:** Gibt alle lokalen USB-Geraete frei. Geraete werden erst dann vom System getrennt wenn ein Use-Client sie anfordert.
|
||||||
|
- **Use-Modus:** Zeigt verfuegbare Geraete von allen Share-Clients an. Geraete koennen einzeln verbunden/getrennt werden.
|
||||||
|
|
||||||
|
**Gruppierung:** 3 zufaellige Tokens werden zu einem SHA256-Hash kombiniert. Alle Clients mit dem gleichen Hash gehoeren zusammen.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Relay-Server starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mit Docker
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Oder direkt
|
||||||
|
make relay
|
||||||
|
./bin/usb-relay --port 8443
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Tokens generieren
|
||||||
|
|
||||||
|
Auf dem ersten Client:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/usb-client generate-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Ausgabe:
|
||||||
|
```
|
||||||
|
Token 1: aB3dE...
|
||||||
|
Token 2: xY7zW...
|
||||||
|
Token 3: mN4pQ...
|
||||||
|
Hash: a1b2c3d4e5...
|
||||||
|
```
|
||||||
|
|
||||||
|
Die 3 Tokens auf alle weiteren Clients kopieren.
|
||||||
|
|
||||||
|
### 3. USB-Geraete freigeben (Share-Modus)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/usb-client share --relay ws://relay-server:8443
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. USB-Geraete empfangen (Use-Modus)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/usb-client use --relay ws://relay-server:8443
|
||||||
|
```
|
||||||
|
|
||||||
|
Web-UI oeffnen: http://localhost:8080
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
**Linux (Share-Modus):**
|
||||||
|
- Root-Rechte (fuer USB-Geraetezugriff)
|
||||||
|
|
||||||
|
**Linux (Use-Modus):**
|
||||||
|
- Kernel-Modul `vhci-hcd`:
|
||||||
|
```bash
|
||||||
|
sudo modprobe vhci-hcd
|
||||||
|
```
|
||||||
|
- Fuer automatisches Laden bei Boot:
|
||||||
|
```bash
|
||||||
|
echo "vhci-hcd" | sudo tee /etc/modules-load.d/vhci-hcd.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bauen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Benoetigt Go 1.21+
|
||||||
|
make all
|
||||||
|
|
||||||
|
# Fuer Windows (Cross-Compilation):
|
||||||
|
make client-windows
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker (Relay-Server)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Relay lauscht auf Port 8443.
|
||||||
|
|
||||||
|
## CLI Befehle
|
||||||
|
|
||||||
|
```
|
||||||
|
usb-client generate-token # 3 Tokens + Hash generieren
|
||||||
|
usb-client share [optionen] # Share-Modus starten
|
||||||
|
usb-client use [optionen] # Use-Modus starten
|
||||||
|
usb-client list # Lokale USB-Geraete auflisten
|
||||||
|
usb-client gui # Nur Web-UI starten
|
||||||
|
usb-client config # Konfiguration anzeigen
|
||||||
|
usb-client config set [optionen] # Konfiguration aendern
|
||||||
|
usb-client install-service # Als systemd-Service installieren
|
||||||
|
usb-client uninstall-service # Service deinstallieren
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optionen
|
||||||
|
|
||||||
|
```
|
||||||
|
--config <pfad> Config-Datei (Standard: ~/.usb-server/config.json)
|
||||||
|
--relay <adresse> Relay-Server (z.B. ws://localhost:8443)
|
||||||
|
--hash <hash> Gruppen-Hash
|
||||||
|
--name <name> Client-Name
|
||||||
|
--web-port <port> Web-UI Port (Standard: 8080)
|
||||||
|
--no-gui Web-UI deaktivieren
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web-UI
|
||||||
|
|
||||||
|
Die Web-UI ist unter http://localhost:8080 erreichbar und bietet:
|
||||||
|
|
||||||
|
- **Geraete-Tab:** Liste aller verfuegbaren/verbundenen USB-Geraete
|
||||||
|
- **Einstellungen:** Relay-Adresse, Modus, Name konfigurieren
|
||||||
|
- **Token:** Tokens generieren oder eintragen
|
||||||
|
- **Service:** Als Systemdienst installieren/deinstallieren
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Die Konfiguration wird in `~/.usb-server/config.json` gespeichert:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"relay_addr": "ws://localhost:8443",
|
||||||
|
"hash": "a1b2c3d4...",
|
||||||
|
"mode": "use",
|
||||||
|
"name": "Mein-PC",
|
||||||
|
"web_port": 8080,
|
||||||
|
"token1": "...",
|
||||||
|
"token2": "...",
|
||||||
|
"token3": "...",
|
||||||
|
"auto_connect": [
|
||||||
|
{"vendor_id": "1234", "product_id": "5678"},
|
||||||
|
{"bus_id": "1-1.4"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Als Service installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Service installieren (braucht root)
|
||||||
|
sudo ./bin/usb-client install-service
|
||||||
|
|
||||||
|
# Service Status pruefen
|
||||||
|
systemctl status usb-client
|
||||||
|
|
||||||
|
# Service deinstallieren
|
||||||
|
sudo ./bin/usb-client uninstall-service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- **Transport-Verschluesselung:** Verwende `wss://` (WebSocket over TLS) fuer den Relay-Server bei Einsatz ueber das Internet.
|
||||||
|
- **Gruppierung:** Die 3 Tokens dienen als gemeinsames Geheimnis. Nur wer alle 3 Tokens kennt kann den Hash berechnen.
|
||||||
|
- **Relay:** Der Relay-Server sieht nur den Hash, nicht die Tokens.
|
||||||
|
|
||||||
|
Fuer TLS am Relay-Server empfiehlt sich ein Reverse-Proxy (nginx/traefik) mit Let's Encrypt:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name relay.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://localhost:8443;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technische Details
|
||||||
|
|
||||||
|
- **Sprache:** Go (single binary, keine Runtime-Abhaengigkeiten)
|
||||||
|
- **USB/IP:** Natives USB/IP-Protokoll (kein externes `usbip`-Paket noetig)
|
||||||
|
- **USB-Zugriff:** Direkte usbdevfs-ioctls (kein CGO/libusb)
|
||||||
|
- **VHCI:** Direkte sysfs-Schnittstelle zum vhci-hcd Kernel-Modul
|
||||||
|
- **Relay:** WebSocket mit JSON-Kontrollnachrichten und Binary-Tunneldaten
|
||||||
|
- **Tunnel:** USB/IP-Protokolldaten werden durch WebSocket-Binary-Frames getunnelt
|
||||||
|
|
||||||
|
## Verzeichnisstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/usb-relay/ - Relay-Server Binary
|
||||||
|
cmd/usb-client/ - Client Binary (share + use Modi)
|
||||||
|
internal/relay/ - WebSocket Relay & Hub
|
||||||
|
internal/protocol/ - Nachrichtentypen
|
||||||
|
internal/client/ - Client Core, Share & Use Manager
|
||||||
|
internal/usbip/ - USB/IP Protokoll, Server, VHCI
|
||||||
|
internal/usb/ - USB Enumeration & usbdevfs
|
||||||
|
internal/token/ - Token-Generierung
|
||||||
|
internal/config/ - Konfiguration
|
||||||
|
internal/web/ - Web-UI (embedded)
|
||||||
|
internal/service/ - Systemd Service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Proprietaere Software. Alle Rechte vorbehalten.
|
||||||
|
Siehe [LICENSE](LICENSE) fuer Details.
|
||||||
|
|
||||||
|
### Third-Party Lizenzen
|
||||||
|
|
||||||
|
Diese Software verwendet Open-Source-Bibliotheken. Siehe [NOTICE](NOTICE) fuer Details.
|
||||||
|
|
@ -0,0 +1,436 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/duffy/usb-server/internal/client"
|
||||||
|
"github.com/duffy/usb-server/internal/config"
|
||||||
|
"github.com/duffy/usb-server/internal/service"
|
||||||
|
"github.com/duffy/usb-server/internal/token"
|
||||||
|
"github.com/duffy/usb-server/internal/usb"
|
||||||
|
"github.com/duffy/usb-server/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
|
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
printUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "generate-token":
|
||||||
|
cmdGenerateToken()
|
||||||
|
case "share":
|
||||||
|
cmdRun("share")
|
||||||
|
case "use":
|
||||||
|
cmdRun("use")
|
||||||
|
case "list":
|
||||||
|
cmdList()
|
||||||
|
case "gui":
|
||||||
|
cmdGUI()
|
||||||
|
case "config":
|
||||||
|
cmdConfig()
|
||||||
|
case "install-service":
|
||||||
|
cmdInstallService()
|
||||||
|
case "uninstall-service":
|
||||||
|
cmdUninstallService()
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
printUsage()
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
|
||||||
|
printUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printUsage() {
|
||||||
|
fmt.Println(`USB Client - USB over IP
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
usb-client <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
generate-token Generate 3 tokens and compute hash
|
||||||
|
share Start in share mode (expose USB devices)
|
||||||
|
use Start in use mode (consume USB devices)
|
||||||
|
list List local USB devices
|
||||||
|
gui Start web UI only
|
||||||
|
config Show current configuration
|
||||||
|
install-service Install as systemd service
|
||||||
|
uninstall-service Remove systemd service
|
||||||
|
help Show this help
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--config <path> Config file path (default: ~/.usb-server/config.json)
|
||||||
|
--relay <addr> Relay server address (e.g. ws://localhost:8443)
|
||||||
|
--hash <hash> Group hash
|
||||||
|
--name <name> Client name
|
||||||
|
--web-port <port> Web UI port (default: 8080)
|
||||||
|
--no-gui Disable web UI`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (*config.Config, string) {
|
||||||
|
cfgPath := config.DefaultConfigPath()
|
||||||
|
|
||||||
|
// Check for --config flag in os.Args
|
||||||
|
for i, arg := range os.Args {
|
||||||
|
if arg == "--config" && i+1 < len(os.Args) {
|
||||||
|
cfgPath = os.Args[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
cfg = config.DefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override with flags
|
||||||
|
for i := 2; i < len(os.Args); i++ {
|
||||||
|
switch os.Args[i] {
|
||||||
|
case "--relay":
|
||||||
|
if i+1 < len(os.Args) {
|
||||||
|
cfg.RelayAddr = os.Args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "--hash":
|
||||||
|
if i+1 < len(os.Args) {
|
||||||
|
cfg.Hash = os.Args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "--name":
|
||||||
|
if i+1 < len(os.Args) {
|
||||||
|
cfg.Name = os.Args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "--web-port":
|
||||||
|
if i+1 < len(os.Args) {
|
||||||
|
fmt.Sscanf(os.Args[i+1], "%d", &cfg.WebPort)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, cfgPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdGenerateToken() {
|
||||||
|
tokens, err := token.Generate()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error generating tokens: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := tokens.Hash()
|
||||||
|
|
||||||
|
fmt.Println("Generated Tokens:")
|
||||||
|
fmt.Println("=================")
|
||||||
|
fmt.Printf("Token 1: %s\n", tokens.Token1)
|
||||||
|
fmt.Printf("Token 2: %s\n", tokens.Token2)
|
||||||
|
fmt.Printf("Token 3: %s\n", tokens.Token3)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Hash: %s\n", hash)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Copy all 3 tokens to all clients that should share USB devices.")
|
||||||
|
fmt.Println("The hash is computed from the tokens and used for grouping.")
|
||||||
|
|
||||||
|
// Optionally save to config
|
||||||
|
cfg, cfgPath := loadConfig()
|
||||||
|
cfg.Token1 = tokens.Token1
|
||||||
|
cfg.Token2 = tokens.Token2
|
||||||
|
cfg.Token3 = tokens.Token3
|
||||||
|
cfg.Hash = hash
|
||||||
|
if err := cfg.Save(cfgPath); err != nil {
|
||||||
|
log.Printf("Warning: could not save config: %v", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("\nTokens saved to %s\n", cfgPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdRun(mode string) {
|
||||||
|
cfg, cfgPath := loadConfig()
|
||||||
|
cfg.Mode = mode
|
||||||
|
|
||||||
|
if cfg.Hash == "" {
|
||||||
|
fmt.Println("Error: No hash configured. Run 'usb-client generate-token' first or set --hash.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for --no-gui
|
||||||
|
noGUI := false
|
||||||
|
for _, arg := range os.Args {
|
||||||
|
if arg == "--no-gui" {
|
||||||
|
noGUI = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create client
|
||||||
|
c := client.NewClient(cfg)
|
||||||
|
|
||||||
|
// Setup signal handling
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
// Start web UI unless disabled
|
||||||
|
if !noGUI {
|
||||||
|
webHandler := web.NewHandler(cfg, cfgPath)
|
||||||
|
|
||||||
|
if mode == "share" {
|
||||||
|
sm := client.NewShareManager(c)
|
||||||
|
webHandler.GetDevices = func() interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"mode": "share",
|
||||||
|
"local_devices": sm.DeviceListForAPI(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webHandler.GetStatus = func() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"connected": true, // simplified
|
||||||
|
"mode": mode,
|
||||||
|
"name": cfg.Name,
|
||||||
|
"client_id": c.ID(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webHandler.InstallService = func() error { return service.Install(mode, cfgPath) }
|
||||||
|
webHandler.UninstallService = service.Uninstall
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := c.Run(); err != nil {
|
||||||
|
log.Printf("Client error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go sm.Run()
|
||||||
|
} else {
|
||||||
|
um := client.NewUseManager(c)
|
||||||
|
webHandler.GetDevices = func() interface{} {
|
||||||
|
available := um.GetAvailableDevices()
|
||||||
|
attached := um.GetAttachedDevices()
|
||||||
|
|
||||||
|
var availList []map[string]interface{}
|
||||||
|
for _, d := range available {
|
||||||
|
availList = append(availList, map[string]interface{}{
|
||||||
|
"bus_id": d.BusID,
|
||||||
|
"vendor_id": d.VendorID,
|
||||||
|
"product_id": d.ProductID,
|
||||||
|
"name": d.Name,
|
||||||
|
"status": d.Status,
|
||||||
|
"speed": d.Speed,
|
||||||
|
"client_id": d.ClientID,
|
||||||
|
"client_name": d.ClientName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachList []map[string]interface{}
|
||||||
|
for _, d := range attached {
|
||||||
|
attachList = append(attachList, map[string]interface{}{
|
||||||
|
"bus_id": d.BusID,
|
||||||
|
"client_id": d.ClientID,
|
||||||
|
"client_name": d.ClientName,
|
||||||
|
"tunnel_id": d.TunnelID,
|
||||||
|
"vhci_port": d.VHCIPort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"mode": "use",
|
||||||
|
"available_devices": availList,
|
||||||
|
"attached_devices": attachList,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webHandler.AttachDevice = um.AttachDevice
|
||||||
|
webHandler.DetachDevice = um.DetachDevice
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
go sm.Run()
|
||||||
|
} else {
|
||||||
|
client.NewUseManager(c)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Wait for signal
|
||||||
|
sig := <-sigChan
|
||||||
|
log.Printf("Received signal %v, shutting down...", sig)
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdList() {
|
||||||
|
devices, err := usb.Enumerate()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error enumerating USB devices: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(devices) == 0 {
|
||||||
|
fmt.Println("No USB devices found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-10s %-10s %-30s %-8s %s\n", "BUS-ID", "VID:PID", "NAME", "SPEED", "DRIVER")
|
||||||
|
fmt.Println(strings.Repeat("-", 80))
|
||||||
|
|
||||||
|
for _, dev := range devices {
|
||||||
|
driver := ""
|
||||||
|
if len(dev.Interfaces) > 0 {
|
||||||
|
driver = dev.Interfaces[0].Driver
|
||||||
|
}
|
||||||
|
speedNames := map[uint32]string{
|
||||||
|
1: "Low", 2: "Full", 3: "High", 5: "Super", 6: "Super+",
|
||||||
|
}
|
||||||
|
speed := speedNames[dev.Speed]
|
||||||
|
if speed == "" {
|
||||||
|
speed = "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-10s %04x:%04x %-30s %-8s %s\n",
|
||||||
|
dev.BusID,
|
||||||
|
dev.VendorID, dev.ProductID,
|
||||||
|
truncate(dev.DisplayName(), 30),
|
||||||
|
speed,
|
||||||
|
driver,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdGUI() {
|
||||||
|
cfg, cfgPath := loadConfig()
|
||||||
|
|
||||||
|
webHandler := web.NewHandler(cfg, cfgPath)
|
||||||
|
webHandler.GetDevices = func() interface{} {
|
||||||
|
devices, _ := usb.Enumerate()
|
||||||
|
var devList []map[string]interface{}
|
||||||
|
for _, d := range devices {
|
||||||
|
devList = append(devList, map[string]interface{}{
|
||||||
|
"bus_id": d.BusID,
|
||||||
|
"vendor_id": fmt.Sprintf("%04x", d.VendorID),
|
||||||
|
"product_id": fmt.Sprintf("%04x", d.ProductID),
|
||||||
|
"name": d.DisplayName(),
|
||||||
|
"status": "available",
|
||||||
|
"speed": d.Speed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"mode": cfg.Mode,
|
||||||
|
"local_devices": devList,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webHandler.GetStatus = func() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"connected": false,
|
||||||
|
"mode": cfg.Mode,
|
||||||
|
"name": cfg.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", cfg.WebPort)
|
||||||
|
fmt.Printf("Web UI: http://localhost%s\n", addr)
|
||||||
|
log.Fatal(http.ListenAndServe(addr, webHandler))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdConfig() {
|
||||||
|
cfg, cfgPath := loadConfig()
|
||||||
|
|
||||||
|
// Check for subcommand
|
||||||
|
if len(os.Args) > 2 && os.Args[2] == "show" || len(os.Args) == 2 {
|
||||||
|
fmt.Printf("Config file: %s\n\n", cfgPath)
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error encoding config: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println(string(data))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle set subcommand
|
||||||
|
if os.Args[2] == "set" {
|
||||||
|
fs := flag.NewFlagSet("config set", flag.ExitOnError)
|
||||||
|
relay := fs.String("relay", "", "Relay server address")
|
||||||
|
hash := fs.String("hash", "", "Group hash")
|
||||||
|
mode := fs.String("mode", "", "Client mode (share/use)")
|
||||||
|
name := fs.String("name", "", "Client name")
|
||||||
|
fs.Parse(os.Args[3:])
|
||||||
|
|
||||||
|
if *relay != "" {
|
||||||
|
cfg.RelayAddr = *relay
|
||||||
|
}
|
||||||
|
if *hash != "" {
|
||||||
|
cfg.Hash = *hash
|
||||||
|
}
|
||||||
|
if *mode != "" {
|
||||||
|
cfg.Mode = *mode
|
||||||
|
}
|
||||||
|
if *name != "" {
|
||||||
|
cfg.Name = *name
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.Save(cfgPath); err != nil {
|
||||||
|
log.Fatalf("Error saving config: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Config saved.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
printUsage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdInstallService() {
|
||||||
|
cfg, cfgPath := loadConfig()
|
||||||
|
if err := service.Install(cfg.Mode, cfgPath); err != nil {
|
||||||
|
log.Fatalf("Error installing service: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Service installed and started.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdUninstallService() {
|
||||||
|
if err := service.Uninstall(); err != nil {
|
||||||
|
log.Fatalf("Error uninstalling service: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Service uninstalled.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max-3] + "..."
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/duffy/usb-server/internal/relay"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := flag.Int("port", 8443, "listen port")
|
||||||
|
addr := flag.String("addr", "0.0.0.0", "listen address")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Also check environment variables (for Docker)
|
||||||
|
if envPort := os.Getenv("RELAY_PORT"); envPort != "" {
|
||||||
|
fmt.Sscanf(envPort, "%d", port)
|
||||||
|
}
|
||||||
|
if envAddr := os.Getenv("RELAY_ADDR"); envAddr != "" {
|
||||||
|
*addr = envAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
listenAddr := fmt.Sprintf("%s:%d", *addr, *port)
|
||||||
|
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
|
log.Printf("USB Relay Server starting on %s", listenAddr)
|
||||||
|
|
||||||
|
server := relay.NewServer(listenAddr)
|
||||||
|
if err := server.Run(); err != nil {
|
||||||
|
log.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
services:
|
||||||
|
relay:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8543:8443"
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8443/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
module github.com/duffy/usb-server
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require github.com/gorilla/websocket v1.5.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
golang.org/x/sys v0.41.0
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
|
@ -0,0 +1,315 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/duffy/usb-server/internal/config"
|
||||||
|
"github.com/duffy/usb-server/internal/protocol"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client manages the connection to the relay server
|
||||||
|
type Client struct {
|
||||||
|
cfg *config.Config
|
||||||
|
clientID string
|
||||||
|
conn *websocket.Conn
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// Event callbacks
|
||||||
|
OnDeviceList func(msg *protocol.DeviceList)
|
||||||
|
OnDeviceGranted func(msg *protocol.DeviceGranted)
|
||||||
|
OnDeviceDenied func(msg *protocol.DeviceDenied)
|
||||||
|
OnDeviceReleased func(msg *protocol.DeviceReleased)
|
||||||
|
OnClientJoined func(msg *protocol.ClientJoined)
|
||||||
|
OnClientLeft func(msg *protocol.ClientLeft)
|
||||||
|
OnRequestDevice func(targetClient, fromClient, busID, requestID string)
|
||||||
|
OnReleaseDevice func(busID, fromClient string)
|
||||||
|
OnTunnelData func(tunnelID string, data []byte)
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new client instance
|
||||||
|
func NewClient(cfg *config.Config) *Client {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
return &Client{
|
||||||
|
cfg: cfg,
|
||||||
|
clientID: uuid.New().String(),
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID returns the client ID
|
||||||
|
func (c *Client) ID() string {
|
||||||
|
return c.clientID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config returns the client config
|
||||||
|
func (c *Client) Config() *config.Config {
|
||||||
|
return c.cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect establishes connection to the relay server
|
||||||
|
func (c *Client) Connect() error {
|
||||||
|
u, err := url.Parse(c.cfg.RelayAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid relay address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure WebSocket scheme
|
||||||
|
switch u.Scheme {
|
||||||
|
case "ws", "wss":
|
||||||
|
// ok
|
||||||
|
case "http":
|
||||||
|
u.Scheme = "ws"
|
||||||
|
case "https":
|
||||||
|
u.Scheme = "wss"
|
||||||
|
default:
|
||||||
|
u.Scheme = "ws"
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Path == "" {
|
||||||
|
u.Path = "/ws"
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[client] connecting to %s", u.String())
|
||||||
|
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connecting to relay: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.conn = conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
// Send registration
|
||||||
|
reg := &protocol.Register{
|
||||||
|
Type: protocol.MsgRegister,
|
||||||
|
Hash: c.cfg.Hash,
|
||||||
|
Mode: c.cfg.Mode,
|
||||||
|
ClientID: c.clientID,
|
||||||
|
Name: c.cfg.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.WriteJSON(reg); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return fmt.Errorf("sending registration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[client] registered as %s (mode=%s, name=%s)", c.clientID, c.cfg.Mode, c.cfg.Name)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunReadLoop reads messages from the relay and dispatches them
|
||||||
|
func (c *Client) RunReadLoop() error {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
msgType, data, err := c.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||||
|
return fmt.Errorf("read error: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msgType {
|
||||||
|
case websocket.TextMessage:
|
||||||
|
c.handleTextMessage(data)
|
||||||
|
case websocket.BinaryMessage:
|
||||||
|
c.handleBinaryMessage(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run connects and runs the main loop with auto-reconnect
|
||||||
|
func (c *Client) Run() error {
|
||||||
|
for {
|
||||||
|
if err := c.Connect(); err != nil {
|
||||||
|
log.Printf("[client] connection failed: %v, retrying in 5s...", err)
|
||||||
|
select {
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
continue
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.RunReadLoop()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[client] disconnected: %v, reconnecting in 5s...", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[client] disconnected, reconnecting in 5s...")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the client
|
||||||
|
func (c *Client) Close() {
|
||||||
|
c.cancel()
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendJSON sends a JSON message to the relay
|
||||||
|
func (c *Client) SendJSON(v interface{}) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
return c.conn.WriteJSON(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendBinary sends a binary message to the relay
|
||||||
|
func (c *Client) SendBinary(data []byte) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
return c.conn.WriteMessage(websocket.BinaryMessage, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTunnelData sends tunnel data with the tunnel ID prefix
|
||||||
|
func (c *Client) SendTunnelData(tunnelID string, data []byte) error {
|
||||||
|
// Tunnel header: 16 bytes tunnel ID + payload
|
||||||
|
msg := make([]byte, protocol.TunnelHeaderSize+len(data))
|
||||||
|
copy(msg[:protocol.TunnelHeaderSize], tunnelID)
|
||||||
|
copy(msg[protocol.TunnelHeaderSize:], data)
|
||||||
|
return c.SendBinary(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleTextMessage(data []byte) {
|
||||||
|
var env protocol.Envelope
|
||||||
|
if err := json.Unmarshal(data, &env); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch env.Type {
|
||||||
|
case protocol.MsgDeviceList:
|
||||||
|
if c.OnDeviceList != nil {
|
||||||
|
var msg protocol.DeviceList
|
||||||
|
if json.Unmarshal(data, &msg) == nil {
|
||||||
|
c.OnDeviceList(&msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case protocol.MsgRequestDevice:
|
||||||
|
if c.OnRequestDevice != nil {
|
||||||
|
var msg struct {
|
||||||
|
TargetClient string `json:"target_client"`
|
||||||
|
FromClient string `json:"from_client"`
|
||||||
|
BusID string `json:"bus_id"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &msg) == nil {
|
||||||
|
c.OnRequestDevice(msg.TargetClient, msg.FromClient, msg.BusID, msg.RequestID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case protocol.MsgDeviceGranted:
|
||||||
|
if c.OnDeviceGranted != nil {
|
||||||
|
var msg protocol.DeviceGranted
|
||||||
|
if json.Unmarshal(data, &msg) == nil {
|
||||||
|
c.OnDeviceGranted(&msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case protocol.MsgDeviceDenied:
|
||||||
|
if c.OnDeviceDenied != nil {
|
||||||
|
var msg protocol.DeviceDenied
|
||||||
|
if json.Unmarshal(data, &msg) == nil {
|
||||||
|
c.OnDeviceDenied(&msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case protocol.MsgReleaseDevice:
|
||||||
|
if c.OnReleaseDevice != nil {
|
||||||
|
var msg struct {
|
||||||
|
BusID string `json:"bus_id"`
|
||||||
|
FromClient string `json:"from_client"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &msg) == nil {
|
||||||
|
c.OnReleaseDevice(msg.BusID, msg.FromClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case protocol.MsgDeviceReleased:
|
||||||
|
if c.OnDeviceReleased != nil {
|
||||||
|
var msg protocol.DeviceReleased
|
||||||
|
if json.Unmarshal(data, &msg) == nil {
|
||||||
|
c.OnDeviceReleased(&msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case protocol.MsgClientJoined:
|
||||||
|
if c.OnClientJoined != nil {
|
||||||
|
var msg protocol.ClientJoined
|
||||||
|
if json.Unmarshal(data, &msg) == nil {
|
||||||
|
c.OnClientJoined(&msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case protocol.MsgClientLeft:
|
||||||
|
if c.OnClientLeft != nil {
|
||||||
|
var msg protocol.ClientLeft
|
||||||
|
if json.Unmarshal(data, &msg) == nil {
|
||||||
|
c.OnClientLeft(&msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case protocol.MsgPong:
|
||||||
|
// ignore pong
|
||||||
|
|
||||||
|
case protocol.MsgError:
|
||||||
|
var msg protocol.ErrorMsg
|
||||||
|
if json.Unmarshal(data, &msg) == nil {
|
||||||
|
log.Printf("[client] error from relay: %s", msg.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleBinaryMessage(data []byte) {
|
||||||
|
if len(data) < protocol.TunnelHeaderSize {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelID := string(data[:protocol.TunnelHeaderSize])
|
||||||
|
payload := data[protocol.TunnelHeaderSize:]
|
||||||
|
|
||||||
|
if c.OnTunnelData != nil {
|
||||||
|
c.OnTunnelData(tunnelID, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,358 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/duffy/usb-server/internal/protocol"
|
||||||
|
"github.com/duffy/usb-server/internal/usb"
|
||||||
|
"github.com/duffy/usb-server/internal/usbip"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShareManager handles sharing USB devices
|
||||||
|
type ShareManager struct {
|
||||||
|
client *Client
|
||||||
|
mu sync.RWMutex
|
||||||
|
devices []usb.Device
|
||||||
|
active map[string]*activeShare // busID -> active share
|
||||||
|
tunnels map[string]*shareTunnel // tunnelID -> tunnel
|
||||||
|
}
|
||||||
|
|
||||||
|
type activeShare struct {
|
||||||
|
device *usb.Device
|
||||||
|
server *usbip.Server
|
||||||
|
usedBy string // client ID using this device
|
||||||
|
tunnelID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type shareTunnel struct {
|
||||||
|
id string
|
||||||
|
busID string
|
||||||
|
inPipe *io.PipeWriter
|
||||||
|
outPipe *io.PipeReader
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewShareManager creates a share manager
|
||||||
|
func NewShareManager(client *Client) *ShareManager {
|
||||||
|
sm := &ShareManager{
|
||||||
|
client: client,
|
||||||
|
active: make(map[string]*activeShare),
|
||||||
|
tunnels: make(map[string]*shareTunnel),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up callbacks
|
||||||
|
client.OnRequestDevice = sm.handleRequestDevice
|
||||||
|
client.OnReleaseDevice = sm.handleReleaseDevice
|
||||||
|
client.OnTunnelData = sm.handleTunnelData
|
||||||
|
|
||||||
|
return sm
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the share manager: periodic device enumeration + event handling
|
||||||
|
func (sm *ShareManager) Run() error {
|
||||||
|
// Initial enumeration
|
||||||
|
sm.refreshDevices()
|
||||||
|
sm.broadcastDeviceList()
|
||||||
|
|
||||||
|
// Periodic refresh
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
sm.refreshDevices()
|
||||||
|
sm.broadcastDeviceList()
|
||||||
|
case <-sm.client.ctx.Done():
|
||||||
|
sm.cleanup()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDevices returns the current device list
|
||||||
|
func (sm *ShareManager) GetDevices() []usb.Device {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
result := make([]usb.Device, len(sm.devices))
|
||||||
|
copy(result, sm.devices)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *ShareManager) refreshDevices() {
|
||||||
|
devices, err := usb.Enumerate()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[share] USB enumeration error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.mu.Lock()
|
||||||
|
sm.devices = devices
|
||||||
|
sm.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *ShareManager) broadcastDeviceList() {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
|
||||||
|
var protoDevices []protocol.USBDevice
|
||||||
|
for _, dev := range sm.devices {
|
||||||
|
status := protocol.StatusAvailable
|
||||||
|
usedBy := ""
|
||||||
|
if share, ok := sm.active[dev.BusID]; ok {
|
||||||
|
status = protocol.StatusInUse
|
||||||
|
usedBy = share.usedBy
|
||||||
|
}
|
||||||
|
|
||||||
|
protoDevices = append(protoDevices, protocol.USBDevice{
|
||||||
|
BusID: dev.BusID,
|
||||||
|
BusNum: dev.BusNum,
|
||||||
|
DevNum: dev.DevNum,
|
||||||
|
Speed: dev.Speed,
|
||||||
|
VendorID: fmt.Sprintf("%04x", dev.VendorID),
|
||||||
|
ProductID: fmt.Sprintf("%04x", dev.ProductID),
|
||||||
|
Class: dev.DeviceClass,
|
||||||
|
SubClass: dev.DeviceSubClass,
|
||||||
|
Protocol: dev.DeviceProtocol,
|
||||||
|
Name: dev.DisplayName(),
|
||||||
|
Manufacturer: dev.Manufacturer,
|
||||||
|
NumInterfaces: uint8(len(dev.Interfaces)),
|
||||||
|
Status: status,
|
||||||
|
UsedBy: usedBy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &protocol.DeviceList{
|
||||||
|
Type: protocol.MsgDeviceList,
|
||||||
|
ClientID: sm.client.ID(),
|
||||||
|
ClientName: sm.client.Config().Name,
|
||||||
|
Devices: protoDevices,
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.client.SendJSON(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *ShareManager) handleRequestDevice(targetClient, fromClient, busID, requestID string) {
|
||||||
|
log.Printf("[share] device request: busID=%s from=%s", busID, fromClient)
|
||||||
|
|
||||||
|
sm.mu.Lock()
|
||||||
|
|
||||||
|
// Check if device exists
|
||||||
|
var dev *usb.Device
|
||||||
|
for i := range sm.devices {
|
||||||
|
if sm.devices[i].BusID == busID {
|
||||||
|
dev = &sm.devices[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if dev == nil {
|
||||||
|
sm.mu.Unlock()
|
||||||
|
sm.client.SendJSON(map[string]interface{}{
|
||||||
|
"type": protocol.MsgDeviceDenied,
|
||||||
|
"bus_id": busID,
|
||||||
|
"request_id": requestID,
|
||||||
|
"reason": "device not found",
|
||||||
|
"target_client": fromClient,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already in use
|
||||||
|
if _, inUse := sm.active[busID]; inUse {
|
||||||
|
sm.mu.Unlock()
|
||||||
|
sm.client.SendJSON(map[string]interface{}{
|
||||||
|
"type": protocol.MsgDeviceDenied,
|
||||||
|
"bus_id": busID,
|
||||||
|
"request_id": requestID,
|
||||||
|
"reason": "device already in use",
|
||||||
|
"target_client": fromClient,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create USB/IP server for this device
|
||||||
|
server := usbip.NewServer(dev)
|
||||||
|
if err := server.Attach(); err != nil {
|
||||||
|
sm.mu.Unlock()
|
||||||
|
log.Printf("[share] failed to attach device %s: %v", busID, err)
|
||||||
|
sm.client.SendJSON(map[string]interface{}{
|
||||||
|
"type": protocol.MsgDeviceDenied,
|
||||||
|
"bus_id": busID,
|
||||||
|
"request_id": requestID,
|
||||||
|
"reason": fmt.Sprintf("attach failed: %v", err),
|
||||||
|
"target_client": fromClient,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelID := uuid.New().String()[:16] // 16 chars for tunnel header
|
||||||
|
for len(tunnelID) < 16 {
|
||||||
|
tunnelID += "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
inReader, inWriter := io.Pipe()
|
||||||
|
outReader, outWriter := io.Pipe()
|
||||||
|
|
||||||
|
tunnel := &shareTunnel{
|
||||||
|
id: tunnelID,
|
||||||
|
busID: busID,
|
||||||
|
inPipe: inWriter,
|
||||||
|
outPipe: outReader,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
share := &activeShare{
|
||||||
|
device: dev,
|
||||||
|
server: server,
|
||||||
|
usedBy: fromClient,
|
||||||
|
tunnelID: tunnelID,
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.active[busID] = share
|
||||||
|
sm.tunnels[tunnelID] = tunnel
|
||||||
|
sm.mu.Unlock()
|
||||||
|
|
||||||
|
// Start USB/IP protocol handler in background
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
close(tunnel.done)
|
||||||
|
inWriter.Close()
|
||||||
|
outReader.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// First handle the management phase (import request from client)
|
||||||
|
// The USB/IP client will send OP_REQ_IMPORT, we respond, then enter transfer phase
|
||||||
|
err := server.HandleConnection(inReader, outWriter)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[share] USB/IP connection error for %s: %v", busID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Forward outgoing data from USB/IP server to tunnel
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 65536)
|
||||||
|
for {
|
||||||
|
n, err := outReader.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := sm.client.SendTunnelData(tunnelID, buf[:n]); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Send grant message
|
||||||
|
sm.client.SendJSON(map[string]interface{}{
|
||||||
|
"type": protocol.MsgDeviceGranted,
|
||||||
|
"bus_id": busID,
|
||||||
|
"tunnel_id": tunnelID,
|
||||||
|
"request_id": requestID,
|
||||||
|
"dev_id": dev.DevID(),
|
||||||
|
"speed": dev.Speed,
|
||||||
|
"target_client": fromClient,
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("[share] device %s granted to %s (tunnel=%s)", busID, fromClient, tunnelID)
|
||||||
|
|
||||||
|
// Broadcast updated device list
|
||||||
|
sm.refreshDevices()
|
||||||
|
sm.broadcastDeviceList()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *ShareManager) handleReleaseDevice(busID, fromClient string) {
|
||||||
|
log.Printf("[share] device release: busID=%s from=%s", busID, fromClient)
|
||||||
|
|
||||||
|
sm.mu.Lock()
|
||||||
|
share, exists := sm.active[busID]
|
||||||
|
if !exists {
|
||||||
|
sm.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up tunnel
|
||||||
|
if tunnel, ok := sm.tunnels[share.tunnelID]; ok {
|
||||||
|
tunnel.inPipe.Close()
|
||||||
|
delete(sm.tunnels, share.tunnelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach device (release interfaces, reconnect kernel driver)
|
||||||
|
share.server.Detach()
|
||||||
|
delete(sm.active, busID)
|
||||||
|
sm.mu.Unlock()
|
||||||
|
|
||||||
|
// Notify client
|
||||||
|
sm.client.SendJSON(&protocol.DeviceReleased{
|
||||||
|
Type: protocol.MsgDeviceReleased,
|
||||||
|
BusID: busID,
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("[share] device %s released", busID)
|
||||||
|
|
||||||
|
// Refresh device list
|
||||||
|
sm.refreshDevices()
|
||||||
|
sm.broadcastDeviceList()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *ShareManager) handleTunnelData(tunnelID string, data []byte) {
|
||||||
|
sm.mu.RLock()
|
||||||
|
tunnel, exists := sm.tunnels[tunnelID]
|
||||||
|
sm.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write incoming data to the USB/IP server's input pipe
|
||||||
|
tunnel.inPipe.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *ShareManager) cleanup() {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
for busID, share := range sm.active {
|
||||||
|
if tunnel, ok := sm.tunnels[share.tunnelID]; ok {
|
||||||
|
tunnel.inPipe.Close()
|
||||||
|
}
|
||||||
|
share.server.Detach()
|
||||||
|
log.Printf("[share] cleaned up device %s", busID)
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.active = make(map[string]*activeShare)
|
||||||
|
sm.tunnels = make(map[string]*shareTunnel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceListForAPI returns device info formatted for the web API
|
||||||
|
func (sm *ShareManager) DeviceListForAPI() []map[string]interface{} {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
|
||||||
|
var result []map[string]interface{}
|
||||||
|
for _, dev := range sm.devices {
|
||||||
|
status := "available"
|
||||||
|
usedBy := ""
|
||||||
|
if share, ok := sm.active[dev.BusID]; ok {
|
||||||
|
status = "in_use"
|
||||||
|
usedBy = share.usedBy
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"bus_id": dev.BusID,
|
||||||
|
"vendor_id": fmt.Sprintf("%04x", dev.VendorID),
|
||||||
|
"product_id": fmt.Sprintf("%04x", dev.ProductID),
|
||||||
|
"name": dev.DisplayName(),
|
||||||
|
"status": status,
|
||||||
|
"used_by": usedBy,
|
||||||
|
"speed": dev.Speed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createSocketPair creates a Unix domain socket pair
|
||||||
|
func createSocketPair() ([2]int, error) {
|
||||||
|
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0)
|
||||||
|
if err != nil {
|
||||||
|
return [2]int{}, fmt.Errorf("socketpair: %w", err)
|
||||||
|
}
|
||||||
|
return fds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeFDs(fds [2]int) {
|
||||||
|
unix.Close(fds[0])
|
||||||
|
unix.Close(fds[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func fdToFile(fd int, name string) *os.File {
|
||||||
|
return os.NewFile(uintptr(fd), name)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createSocketPair() ([2]int, error) {
|
||||||
|
return [2]int{}, fmt.Errorf("socketpair not implemented on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeFDs(fds [2]int) {}
|
||||||
|
|
||||||
|
func fdToFile(fd int, name string) *os.File {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,368 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/duffy/usb-server/internal/protocol"
|
||||||
|
"github.com/duffy/usb-server/internal/usbip"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RemoteDevice represents a USB device available from a share client
|
||||||
|
type RemoteDevice struct {
|
||||||
|
protocol.USBDevice
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientName string `json:"client_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachedDevice represents a device currently attached via VHCI
|
||||||
|
type AttachedDevice struct {
|
||||||
|
RemoteDevice
|
||||||
|
TunnelID string `json:"tunnel_id"`
|
||||||
|
VHCIPort int `json:"vhci_port"`
|
||||||
|
SocketFD int `json:"socket_fd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseManager handles receiving/using remote USB devices
|
||||||
|
type UseManager struct {
|
||||||
|
client *Client
|
||||||
|
mu sync.RWMutex
|
||||||
|
available map[string][]RemoteDevice // clientID -> devices
|
||||||
|
attached map[string]*AttachedDevice // busID@clientID -> attached info
|
||||||
|
tunnels map[string]*useTunnel // tunnelID -> tunnel
|
||||||
|
pending map[string]chan *protocol.DeviceGranted // requestID -> response channel
|
||||||
|
}
|
||||||
|
|
||||||
|
type useTunnel struct {
|
||||||
|
id string
|
||||||
|
busID string
|
||||||
|
clientID string
|
||||||
|
conn net.Conn // our end of the socketpair
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUseManager creates a use manager
|
||||||
|
func NewUseManager(client *Client) *UseManager {
|
||||||
|
um := &UseManager{
|
||||||
|
client: client,
|
||||||
|
available: make(map[string][]RemoteDevice),
|
||||||
|
attached: make(map[string]*AttachedDevice),
|
||||||
|
tunnels: make(map[string]*useTunnel),
|
||||||
|
pending: make(map[string]chan *protocol.DeviceGranted),
|
||||||
|
}
|
||||||
|
|
||||||
|
client.OnDeviceList = um.handleDeviceList
|
||||||
|
client.OnDeviceGranted = um.handleDeviceGranted
|
||||||
|
client.OnDeviceDenied = um.handleDeviceDenied
|
||||||
|
client.OnDeviceReleased = um.handleDeviceReleased
|
||||||
|
client.OnTunnelData = um.handleTunnelData
|
||||||
|
client.OnClientLeft = um.handleClientLeft
|
||||||
|
|
||||||
|
return um
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailableDevices returns all available remote devices
|
||||||
|
func (um *UseManager) GetAvailableDevices() []RemoteDevice {
|
||||||
|
um.mu.RLock()
|
||||||
|
defer um.mu.RUnlock()
|
||||||
|
|
||||||
|
var all []RemoteDevice
|
||||||
|
for _, devs := range um.available {
|
||||||
|
all = append(all, devs...)
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAttachedDevices returns currently attached devices
|
||||||
|
func (um *UseManager) GetAttachedDevices() []*AttachedDevice {
|
||||||
|
um.mu.RLock()
|
||||||
|
defer um.mu.RUnlock()
|
||||||
|
|
||||||
|
var result []*AttachedDevice
|
||||||
|
for _, dev := range um.attached {
|
||||||
|
result = append(result, dev)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachDevice requests and attaches a remote USB device
|
||||||
|
func (um *UseManager) AttachDevice(clientID, busID string) error {
|
||||||
|
// Check if VHCI is available
|
||||||
|
if !usbip.IsVHCIAvailable() {
|
||||||
|
return fmt.Errorf("vhci-hcd kernel module not loaded (run: sudo modprobe vhci-hcd)")
|
||||||
|
}
|
||||||
|
|
||||||
|
key := busID + "@" + clientID
|
||||||
|
um.mu.RLock()
|
||||||
|
if _, already := um.attached[key]; already {
|
||||||
|
um.mu.RUnlock()
|
||||||
|
return fmt.Errorf("device %s already attached", key)
|
||||||
|
}
|
||||||
|
um.mu.RUnlock()
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
requestID := uuid.New().String()
|
||||||
|
respChan := make(chan *protocol.DeviceGranted, 1)
|
||||||
|
|
||||||
|
um.mu.Lock()
|
||||||
|
um.pending[requestID] = respChan
|
||||||
|
um.mu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
um.mu.Lock()
|
||||||
|
delete(um.pending, requestID)
|
||||||
|
um.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Send request to relay
|
||||||
|
err := um.client.SendJSON(&protocol.RequestDevice{
|
||||||
|
Type: protocol.MsgRequestDevice,
|
||||||
|
TargetClient: clientID,
|
||||||
|
BusID: busID,
|
||||||
|
RequestID: requestID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sending request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[use] requesting device %s from %s", busID, clientID)
|
||||||
|
|
||||||
|
// Wait for response (with timeout via context)
|
||||||
|
select {
|
||||||
|
case granted := <-respChan:
|
||||||
|
return um.setupVHCI(clientID, busID, granted)
|
||||||
|
case <-um.client.ctx.Done():
|
||||||
|
return fmt.Errorf("client shutting down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetachDevice releases an attached device
|
||||||
|
func (um *UseManager) DetachDevice(clientID, busID string) error {
|
||||||
|
key := busID + "@" + clientID
|
||||||
|
|
||||||
|
um.mu.Lock()
|
||||||
|
dev, exists := um.attached[key]
|
||||||
|
if !exists {
|
||||||
|
um.mu.Unlock()
|
||||||
|
return fmt.Errorf("device %s not attached", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up tunnel
|
||||||
|
if tunnel, ok := um.tunnels[dev.TunnelID]; ok {
|
||||||
|
close(tunnel.done)
|
||||||
|
if tunnel.conn != nil {
|
||||||
|
tunnel.conn.Close()
|
||||||
|
}
|
||||||
|
delete(um.tunnels, dev.TunnelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach from VHCI
|
||||||
|
if dev.VHCIPort >= 0 {
|
||||||
|
if err := usbip.DetachDevice(dev.VHCIPort); err != nil {
|
||||||
|
log.Printf("[use] warning: VHCI detach error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(um.attached, key)
|
||||||
|
um.mu.Unlock()
|
||||||
|
|
||||||
|
// Notify share client
|
||||||
|
um.client.SendJSON(&protocol.ReleaseDevice{
|
||||||
|
Type: protocol.MsgReleaseDevice,
|
||||||
|
TargetClient: clientID,
|
||||||
|
BusID: busID,
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("[use] device %s detached", key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (um *UseManager) setupVHCI(clientID, busID string, granted *protocol.DeviceGranted) error {
|
||||||
|
// Create a socketpair - one end for VHCI, one for our tunnel
|
||||||
|
fds, err := createSocketPair()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating socketpair: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vhciFD := fds[0]
|
||||||
|
tunnelFD := fds[1]
|
||||||
|
|
||||||
|
// Find a free VHCI port
|
||||||
|
port, err := usbip.FindFreePort(granted.Speed)
|
||||||
|
if err != nil {
|
||||||
|
closeFDs(fds)
|
||||||
|
return fmt.Errorf("finding free VHCI port: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach to VHCI
|
||||||
|
if err := usbip.AttachDevice(port, vhciFD, granted.DevID, granted.Speed); err != nil {
|
||||||
|
closeFDs(fds)
|
||||||
|
return fmt.Errorf("VHCI attach: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The VHCI driver now owns vhciFD, so we don't close it
|
||||||
|
// Create a net.Conn from the tunnel FD
|
||||||
|
tunnelFile := fdToFile(tunnelFD, "usb-tunnel")
|
||||||
|
tunnelConn, err := net.FileConn(tunnelFile)
|
||||||
|
tunnelFile.Close() // FileConn dups the fd
|
||||||
|
if err != nil {
|
||||||
|
usbip.DetachDevice(port)
|
||||||
|
return fmt.Errorf("creating tunnel conn: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnel := &useTunnel{
|
||||||
|
id: granted.TunnelID,
|
||||||
|
busID: busID,
|
||||||
|
clientID: clientID,
|
||||||
|
conn: tunnelConn,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
key := busID + "@" + clientID
|
||||||
|
um.mu.Lock()
|
||||||
|
um.tunnels[granted.TunnelID] = tunnel
|
||||||
|
um.attached[key] = &AttachedDevice{
|
||||||
|
RemoteDevice: RemoteDevice{
|
||||||
|
USBDevice: protocol.USBDevice{BusID: busID},
|
||||||
|
ClientID: clientID,
|
||||||
|
},
|
||||||
|
TunnelID: granted.TunnelID,
|
||||||
|
VHCIPort: port,
|
||||||
|
SocketFD: vhciFD,
|
||||||
|
}
|
||||||
|
um.mu.Unlock()
|
||||||
|
|
||||||
|
// Start reading from the tunnel socket (VHCI -> relay)
|
||||||
|
go um.tunnelReadLoop(tunnel)
|
||||||
|
|
||||||
|
log.Printf("[use] device %s attached on VHCI port %d", key, port)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tunnelReadLoop reads from the VHCI socket and sends to relay
|
||||||
|
func (um *UseManager) tunnelReadLoop(tunnel *useTunnel) {
|
||||||
|
buf := make([]byte, 65536)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-tunnel.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := tunnel.conn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-tunnel.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
log.Printf("[use] tunnel read error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := um.client.SendTunnelData(tunnel.id, buf[:n]); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (um *UseManager) handleDeviceList(msg *protocol.DeviceList) {
|
||||||
|
um.mu.Lock()
|
||||||
|
var remoteDevs []RemoteDevice
|
||||||
|
for _, dev := range msg.Devices {
|
||||||
|
remoteDevs = append(remoteDevs, RemoteDevice{
|
||||||
|
USBDevice: dev,
|
||||||
|
ClientID: msg.ClientID,
|
||||||
|
ClientName: msg.ClientName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
um.available[msg.ClientID] = remoteDevs
|
||||||
|
um.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[use] received device list from %s (%s): %d devices",
|
||||||
|
msg.ClientName, msg.ClientID[:8], len(msg.Devices))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (um *UseManager) handleDeviceGranted(msg *protocol.DeviceGranted) {
|
||||||
|
um.mu.RLock()
|
||||||
|
ch, exists := um.pending[msg.RequestID]
|
||||||
|
um.mu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
ch <- msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (um *UseManager) handleDeviceDenied(msg *protocol.DeviceDenied) {
|
||||||
|
log.Printf("[use] device request denied: %s - %s", msg.BusID, msg.Reason)
|
||||||
|
|
||||||
|
um.mu.RLock()
|
||||||
|
ch, exists := um.pending[msg.RequestID]
|
||||||
|
um.mu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
close(ch) // signal denial by closing channel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (um *UseManager) handleDeviceReleased(msg *protocol.DeviceReleased) {
|
||||||
|
log.Printf("[use] device released by share client: %s", msg.BusID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (um *UseManager) handleTunnelData(tunnelID string, data []byte) {
|
||||||
|
um.mu.RLock()
|
||||||
|
tunnel, exists := um.tunnels[tunnelID]
|
||||||
|
um.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to the tunnel socket (relay -> VHCI)
|
||||||
|
tunnel.conn.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (um *UseManager) handleClientLeft(msg *protocol.ClientLeft) {
|
||||||
|
um.mu.Lock()
|
||||||
|
delete(um.available, msg.ClientID)
|
||||||
|
|
||||||
|
// Detach any devices from this client
|
||||||
|
for key, dev := range um.attached {
|
||||||
|
if dev.ClientID == msg.ClientID {
|
||||||
|
if tunnel, ok := um.tunnels[dev.TunnelID]; ok {
|
||||||
|
close(tunnel.done)
|
||||||
|
tunnel.conn.Close()
|
||||||
|
delete(um.tunnels, dev.TunnelID)
|
||||||
|
}
|
||||||
|
if dev.VHCIPort >= 0 {
|
||||||
|
usbip.DetachDevice(dev.VHCIPort)
|
||||||
|
}
|
||||||
|
delete(um.attached, key)
|
||||||
|
log.Printf("[use] device %s auto-detached (client left)", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
um.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup releases all attached devices
|
||||||
|
func (um *UseManager) Cleanup() {
|
||||||
|
um.mu.Lock()
|
||||||
|
defer um.mu.Unlock()
|
||||||
|
|
||||||
|
for key, dev := range um.attached {
|
||||||
|
if tunnel, ok := um.tunnels[dev.TunnelID]; ok {
|
||||||
|
close(tunnel.done)
|
||||||
|
tunnel.conn.Close()
|
||||||
|
}
|
||||||
|
if dev.VHCIPort >= 0 {
|
||||||
|
usbip.DetachDevice(dev.VHCIPort)
|
||||||
|
}
|
||||||
|
log.Printf("[use] cleaned up device %s", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
um.attached = make(map[string]*AttachedDevice)
|
||||||
|
um.tunnels = make(map[string]*useTunnel)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutoConnectRule defines a rule for automatic device connection
|
||||||
|
type AutoConnectRule struct {
|
||||||
|
BusID string `json:"bus_id,omitempty"`
|
||||||
|
VendorID string `json:"vendor_id,omitempty"`
|
||||||
|
ProductID string `json:"product_id,omitempty"`
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds the client configuration
|
||||||
|
type Config struct {
|
||||||
|
RelayAddr string `json:"relay_addr"` // e.g. "ws://localhost:8443" or "wss://relay.example.com:8443"
|
||||||
|
Hash string `json:"hash"` // SHA256 hash of 3 tokens
|
||||||
|
Mode string `json:"mode"` // "share" or "use"
|
||||||
|
Name string `json:"name"` // friendly name for this client
|
||||||
|
WebPort int `json:"web_port"` // web UI port (default 8080)
|
||||||
|
|
||||||
|
// Tokens (optional, stored for convenience - hash is what matters)
|
||||||
|
Token1 string `json:"token1,omitempty"`
|
||||||
|
Token2 string `json:"token2,omitempty"`
|
||||||
|
Token3 string `json:"token3,omitempty"`
|
||||||
|
|
||||||
|
// Auto-connect rules (use mode only)
|
||||||
|
AutoConnect []AutoConnectRule `json:"auto_connect,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a config with sensible defaults
|
||||||
|
func DefaultConfig() *Config {
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
if hostname == "" {
|
||||||
|
hostname = "unknown"
|
||||||
|
}
|
||||||
|
return &Config{
|
||||||
|
RelayAddr: "ws://localhost:8443",
|
||||||
|
Mode: "use",
|
||||||
|
Name: hostname,
|
||||||
|
WebPort: 8080,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfigPath returns the default config file path
|
||||||
|
func DefaultConfigPath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "usb-client.json"
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".usb-server", "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads config from a JSON file
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if err := json.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes config to a JSON file
|
||||||
|
func (c *Config) Save(path string) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("creating config directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(c, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encoding config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("writing config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
package protocol
|
||||||
|
|
||||||
|
// Message types
|
||||||
|
const (
|
||||||
|
MsgRegister = "register"
|
||||||
|
MsgDeviceList = "device_list"
|
||||||
|
MsgRequestDevice = "request_device"
|
||||||
|
MsgDeviceGranted = "device_granted"
|
||||||
|
MsgDeviceDenied = "device_denied"
|
||||||
|
MsgReleaseDevice = "release_device"
|
||||||
|
MsgDeviceReleased = "device_released"
|
||||||
|
MsgClientJoined = "client_joined"
|
||||||
|
MsgClientLeft = "client_left"
|
||||||
|
MsgPing = "ping"
|
||||||
|
MsgPong = "pong"
|
||||||
|
MsgError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client modes
|
||||||
|
const (
|
||||||
|
ModeShare = "share"
|
||||||
|
ModeUse = "use"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Device status
|
||||||
|
const (
|
||||||
|
StatusAvailable = "available"
|
||||||
|
StatusInUse = "in_use"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Envelope is the top-level message wrapper
|
||||||
|
type Envelope struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register is sent by a client when connecting to the relay
|
||||||
|
type Register struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// USBDevice describes a USB device
|
||||||
|
type USBDevice struct {
|
||||||
|
BusID string `json:"bus_id"`
|
||||||
|
BusNum uint32 `json:"bus_num"`
|
||||||
|
DevNum uint32 `json:"dev_num"`
|
||||||
|
Speed uint32 `json:"speed"`
|
||||||
|
VendorID string `json:"vendor_id"`
|
||||||
|
ProductID string `json:"product_id"`
|
||||||
|
DeviceBCD string `json:"device_bcd,omitempty"`
|
||||||
|
Class uint8 `json:"class"`
|
||||||
|
SubClass uint8 `json:"sub_class"`
|
||||||
|
Protocol uint8 `json:"protocol"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
|
NumInterfaces uint8 `json:"num_interfaces"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
UsedBy string `json:"used_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceList is sent by share clients to announce available devices
|
||||||
|
type DeviceList struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientName string `json:"client_name"`
|
||||||
|
Devices []USBDevice `json:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestDevice is sent by use clients to request a specific device
|
||||||
|
type RequestDevice struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
TargetClient string `json:"target_client"`
|
||||||
|
BusID string `json:"bus_id"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceGranted is sent by share clients when a device is ready
|
||||||
|
type DeviceGranted struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
BusID string `json:"bus_id"`
|
||||||
|
TunnelID string `json:"tunnel_id"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
DevID uint32 `json:"dev_id"` // (busnum << 16) | devnum
|
||||||
|
Speed uint32 `json:"speed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceDenied is sent when a device request is rejected
|
||||||
|
type DeviceDenied struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
BusID string `json:"bus_id"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseDevice is sent by use clients to release a device
|
||||||
|
type ReleaseDevice struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
TargetClient string `json:"target_client"`
|
||||||
|
BusID string `json:"bus_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceReleased is sent when a device is released
|
||||||
|
type DeviceReleased struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
BusID string `json:"bus_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientJoined is broadcast when a new client joins the group
|
||||||
|
type ClientJoined struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientLeft is broadcast when a client leaves the group
|
||||||
|
type ClientLeft struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping/Pong for keepalive
|
||||||
|
type Ping struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pong struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
type ErrorMsg struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunnelHeader is prepended to binary WebSocket frames for tunnel data.
|
||||||
|
// Format: [16 bytes UUID][payload]
|
||||||
|
const TunnelHeaderSize = 16
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
package relay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/duffy/usb-server/internal/protocol"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client represents a connected WebSocket client
|
||||||
|
type Client struct {
|
||||||
|
ID string
|
||||||
|
Hash string
|
||||||
|
Mode string // "share" or "use"
|
||||||
|
Name string
|
||||||
|
Conn *websocket.Conn
|
||||||
|
Send chan []byte // buffered channel for outgoing messages
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteJSON sends a JSON message to the client
|
||||||
|
func (c *Client) WriteJSON(v interface{}) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.Conn.WriteJSON(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteBinary sends a binary message to the client
|
||||||
|
func (c *Client) WriteBinary(data []byte) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.Conn.WriteMessage(websocket.BinaryMessage, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub manages all connected clients and routes messages between them
|
||||||
|
type Hub struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
groups map[string]map[string]*Client // hash -> client_id -> client
|
||||||
|
tunnels map[string]*Tunnel // tunnel_id -> tunnel info
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tunnel tracks an active USB/IP tunnel between two clients
|
||||||
|
type Tunnel struct {
|
||||||
|
ID string
|
||||||
|
ShareClient string
|
||||||
|
UseClient string
|
||||||
|
BusID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHub creates a new Hub
|
||||||
|
func NewHub() *Hub {
|
||||||
|
return &Hub{
|
||||||
|
groups: make(map[string]map[string]*Client),
|
||||||
|
tunnels: make(map[string]*Tunnel),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds a client to its hash group
|
||||||
|
func (h *Hub) Register(client *Client) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
if h.groups[client.Hash] == nil {
|
||||||
|
h.groups[client.Hash] = make(map[string]*Client)
|
||||||
|
}
|
||||||
|
h.groups[client.Hash][client.ID] = client
|
||||||
|
|
||||||
|
log.Printf("[hub] client registered: id=%s hash=%s..%s mode=%s name=%s",
|
||||||
|
client.ID, client.Hash[:8], client.Hash[len(client.Hash)-4:], client.Mode, client.Name)
|
||||||
|
|
||||||
|
// Notify other clients in the group
|
||||||
|
h.broadcastToGroup(client.Hash, client.ID, &protocol.ClientJoined{
|
||||||
|
Type: protocol.MsgClientJoined,
|
||||||
|
ClientID: client.ID,
|
||||||
|
Mode: client.Mode,
|
||||||
|
Name: client.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister removes a client and cleans up its tunnels
|
||||||
|
func (h *Hub) Unregister(client *Client) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
group := h.groups[client.Hash]
|
||||||
|
if group == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(group, client.ID)
|
||||||
|
if len(group) == 0 {
|
||||||
|
delete(h.groups, client.Hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up tunnels involving this client
|
||||||
|
for tid, tunnel := range h.tunnels {
|
||||||
|
if tunnel.ShareClient == client.ID || tunnel.UseClient == client.ID {
|
||||||
|
delete(h.tunnels, tid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[hub] client unregistered: id=%s name=%s", client.ID, client.Name)
|
||||||
|
|
||||||
|
// Notify others
|
||||||
|
h.broadcastToGroup(client.Hash, client.ID, &protocol.ClientLeft{
|
||||||
|
Type: protocol.MsgClientLeft,
|
||||||
|
ClientID: client.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleTextMessage processes a JSON control message
|
||||||
|
func (h *Hub) HandleTextMessage(sender *Client, data []byte) {
|
||||||
|
var env protocol.Envelope
|
||||||
|
if err := json.Unmarshal(data, &env); err != nil {
|
||||||
|
log.Printf("[hub] invalid message from %s: %v", sender.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch env.Type {
|
||||||
|
case protocol.MsgDeviceList:
|
||||||
|
h.handleDeviceList(sender, data)
|
||||||
|
case protocol.MsgRequestDevice:
|
||||||
|
h.handleRequestDevice(sender, data)
|
||||||
|
case protocol.MsgDeviceGranted:
|
||||||
|
h.handleDeviceGranted(sender, data)
|
||||||
|
case protocol.MsgDeviceDenied:
|
||||||
|
h.handleDeviceDenied(sender, data)
|
||||||
|
case protocol.MsgReleaseDevice:
|
||||||
|
h.handleReleaseDevice(sender, data)
|
||||||
|
case protocol.MsgDeviceReleased:
|
||||||
|
h.handleDeviceReleased(sender, data)
|
||||||
|
case protocol.MsgPing:
|
||||||
|
sender.WriteJSON(&protocol.Pong{Type: protocol.MsgPong})
|
||||||
|
default:
|
||||||
|
log.Printf("[hub] unknown message type from %s: %s", sender.ID, env.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleBinaryMessage forwards tunnel data to the other end
|
||||||
|
func (h *Hub) HandleBinaryMessage(sender *Client, data []byte) {
|
||||||
|
if len(data) < protocol.TunnelHeaderSize {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelID := string(data[:protocol.TunnelHeaderSize])
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
tunnel := h.tunnels[tunnelID]
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
if tunnel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward to the other end of the tunnel
|
||||||
|
var targetID string
|
||||||
|
if sender.ID == tunnel.ShareClient {
|
||||||
|
targetID = tunnel.UseClient
|
||||||
|
} else if sender.ID == tunnel.UseClient {
|
||||||
|
targetID = tunnel.ShareClient
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
group := h.groups[sender.Hash]
|
||||||
|
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
|
||||||
|
func (h *Hub) handleDeviceList(sender *Client, data []byte) {
|
||||||
|
if sender.Mode != protocol.ModeShare {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
group := h.groups[sender.Hash]
|
||||||
|
for _, client := range group {
|
||||||
|
if client.ID != sender.ID && client.Mode == protocol.ModeUse {
|
||||||
|
client.mu.Lock()
|
||||||
|
client.Conn.WriteMessage(websocket.TextMessage, data)
|
||||||
|
client.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRequestDevice forwards a device request to the target share client
|
||||||
|
func (h *Hub) handleRequestDevice(sender *Client, data []byte) {
|
||||||
|
var msg protocol.RequestDevice
|
||||||
|
if err := json.Unmarshal(data, &msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
group := h.groups[sender.Hash]
|
||||||
|
if group != nil {
|
||||||
|
if target := group[msg.TargetClient]; target != nil && target.Mode == protocol.ModeShare {
|
||||||
|
// Add the sender's ID so the share client knows who's requesting
|
||||||
|
enriched := map[string]interface{}{
|
||||||
|
"type": protocol.MsgRequestDevice,
|
||||||
|
"target_client": msg.TargetClient,
|
||||||
|
"bus_id": msg.BusID,
|
||||||
|
"request_id": msg.RequestID,
|
||||||
|
"from_client": sender.ID,
|
||||||
|
}
|
||||||
|
target.WriteJSON(enriched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeviceGranted registers the tunnel and forwards to the requesting client
|
||||||
|
func (h *Hub) handleDeviceGranted(sender *Client, data []byte) {
|
||||||
|
var granted struct {
|
||||||
|
protocol.DeviceGranted
|
||||||
|
TargetClient string `json:"target_client"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &granted); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register tunnel
|
||||||
|
h.mu.Lock()
|
||||||
|
h.tunnels[granted.TunnelID] = &Tunnel{
|
||||||
|
ID: granted.TunnelID,
|
||||||
|
ShareClient: sender.ID,
|
||||||
|
UseClient: granted.TargetClient,
|
||||||
|
BusID: granted.BusID,
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[hub] tunnel created: %s (share=%s, use=%s, device=%s)",
|
||||||
|
granted.TunnelID, sender.ID, granted.TargetClient, granted.BusID)
|
||||||
|
|
||||||
|
// Forward to use client
|
||||||
|
h.mu.RLock()
|
||||||
|
group := h.groups[sender.Hash]
|
||||||
|
if group != nil {
|
||||||
|
if target := group[granted.TargetClient]; target != nil {
|
||||||
|
target.mu.Lock()
|
||||||
|
target.Conn.WriteMessage(websocket.TextMessage, data)
|
||||||
|
target.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeviceDenied forwards denial to the requesting client
|
||||||
|
func (h *Hub) handleDeviceDenied(sender *Client, data []byte) {
|
||||||
|
var denied struct {
|
||||||
|
protocol.DeviceDenied
|
||||||
|
TargetClient string `json:"target_client"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &denied); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
group := h.groups[sender.Hash]
|
||||||
|
if group != nil {
|
||||||
|
if target := group[denied.TargetClient]; target != nil {
|
||||||
|
target.mu.Lock()
|
||||||
|
target.Conn.WriteMessage(websocket.TextMessage, data)
|
||||||
|
target.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReleaseDevice forwards a release to the share client
|
||||||
|
func (h *Hub) handleReleaseDevice(sender *Client, data []byte) {
|
||||||
|
var msg protocol.ReleaseDevice
|
||||||
|
if err := json.Unmarshal(data, &msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up tunnel
|
||||||
|
h.mu.Lock()
|
||||||
|
for tid, tunnel := range h.tunnels {
|
||||||
|
if tunnel.UseClient == sender.ID && tunnel.BusID == msg.BusID {
|
||||||
|
delete(h.tunnels, tid)
|
||||||
|
log.Printf("[hub] tunnel closed: %s", tid)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
// Forward to share client
|
||||||
|
h.mu.RLock()
|
||||||
|
group := h.groups[sender.Hash]
|
||||||
|
if group != nil {
|
||||||
|
if target := group[msg.TargetClient]; target != nil {
|
||||||
|
enriched := map[string]interface{}{
|
||||||
|
"type": protocol.MsgReleaseDevice,
|
||||||
|
"target_client": msg.TargetClient,
|
||||||
|
"bus_id": msg.BusID,
|
||||||
|
"from_client": sender.ID,
|
||||||
|
}
|
||||||
|
target.WriteJSON(enriched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeviceReleased broadcasts device released notification
|
||||||
|
func (h *Hub) handleDeviceReleased(sender *Client, data []byte) {
|
||||||
|
h.mu.RLock()
|
||||||
|
group := h.groups[sender.Hash]
|
||||||
|
for _, client := range group {
|
||||||
|
if client.ID != sender.ID && client.Mode == protocol.ModeUse {
|
||||||
|
client.mu.Lock()
|
||||||
|
client.Conn.WriteMessage(websocket.TextMessage, data)
|
||||||
|
client.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcastToGroup sends a message to all clients in a hash group except the sender
|
||||||
|
func (h *Hub) broadcastToGroup(hash, excludeID string, msg interface{}) {
|
||||||
|
group := h.groups[hash]
|
||||||
|
for _, client := range group {
|
||||||
|
if client.ID != excludeID {
|
||||||
|
client.WriteJSON(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
package relay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/duffy/usb-server/internal/protocol"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 64 * 1024,
|
||||||
|
WriteBufferSize: 64 * 1024,
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true // relay accepts all origins
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is the WebSocket relay server
|
||||||
|
type Server struct {
|
||||||
|
hub *Hub
|
||||||
|
addr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new relay server
|
||||||
|
func NewServer(addr string) *Server {
|
||||||
|
return &Server{
|
||||||
|
hub: NewHub(),
|
||||||
|
addr: addr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the relay server
|
||||||
|
func (s *Server) Run() error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/ws", s.handleWebSocket)
|
||||||
|
mux.HandleFunc("/health", s.handleHealth)
|
||||||
|
|
||||||
|
log.Printf("[relay] starting on %s", s.addr)
|
||||||
|
return http.ListenAndServe(s.addr, mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[relay] upgrade error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Set read limits and deadlines
|
||||||
|
conn.SetReadLimit(1024 * 1024) // 1MB max message
|
||||||
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for registration message
|
||||||
|
_, msgData, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[relay] read error during registration: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var reg protocol.Register
|
||||||
|
if err := json.Unmarshal(msgData, ®); err != nil || reg.Type != protocol.MsgRegister {
|
||||||
|
log.Printf("[relay] invalid registration message")
|
||||||
|
conn.WriteJSON(&protocol.ErrorMsg{Type: protocol.MsgError, Message: "invalid registration"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if reg.Hash == "" || reg.ClientID == "" || (reg.Mode != protocol.ModeShare && reg.Mode != protocol.ModeUse) {
|
||||||
|
conn.WriteJSON(&protocol.ErrorMsg{Type: protocol.MsgError, Message: "missing required fields"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
ID: reg.ClientID,
|
||||||
|
Hash: reg.Hash,
|
||||||
|
Mode: reg.Mode,
|
||||||
|
Name: reg.Name,
|
||||||
|
Conn: conn,
|
||||||
|
Send: make(chan []byte, 256),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.hub.Register(client)
|
||||||
|
defer s.hub.Unregister(client)
|
||||||
|
|
||||||
|
// Start ping ticker
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
client.mu.Lock()
|
||||||
|
err := conn.WriteMessage(websocket.PingMessage, nil)
|
||||||
|
client.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
// Read loop
|
||||||
|
for {
|
||||||
|
msgType, data, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||||
|
log.Printf("[relay] read error from %s: %v", client.ID, err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
|
||||||
|
switch msgType {
|
||||||
|
case websocket.TextMessage:
|
||||||
|
s.hub.HandleTextMessage(client, data)
|
||||||
|
case websocket.BinaryMessage:
|
||||||
|
s.hub.HandleBinaryMessage(client, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
const systemdUnitTemplate = `[Unit]
|
||||||
|
Description=USB Client (%s mode)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=%s %s --config %s
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
`
|
||||||
|
|
||||||
|
const serviceName = "usb-client"
|
||||||
|
|
||||||
|
// Install creates and enables a systemd service
|
||||||
|
func Install(mode, configPath string) error {
|
||||||
|
// Find the executable
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("finding executable: %w", err)
|
||||||
|
}
|
||||||
|
exePath, err = filepath.Abs(exePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
absConfigPath, err := filepath.Abs(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving config path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unitContent := fmt.Sprintf(systemdUnitTemplate, mode, exePath, mode, absConfigPath)
|
||||||
|
unitPath := fmt.Sprintf("/etc/systemd/system/%s.service", serviceName)
|
||||||
|
|
||||||
|
if err := os.WriteFile(unitPath, []byte(unitContent), 0644); err != nil {
|
||||||
|
return fmt.Errorf("writing unit file: %w (need root?)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload systemd
|
||||||
|
if err := exec.Command("systemctl", "daemon-reload").Run(); err != nil {
|
||||||
|
return fmt.Errorf("daemon-reload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable and start
|
||||||
|
if err := exec.Command("systemctl", "enable", serviceName).Run(); err != nil {
|
||||||
|
return fmt.Errorf("enable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := exec.Command("systemctl", "start", serviceName).Run(); err != nil {
|
||||||
|
return fmt.Errorf("start: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall stops and removes the systemd service
|
||||||
|
func Uninstall() error {
|
||||||
|
// Stop and disable
|
||||||
|
exec.Command("systemctl", "stop", serviceName).Run()
|
||||||
|
exec.Command("systemctl", "disable", serviceName).Run()
|
||||||
|
|
||||||
|
// Remove unit file
|
||||||
|
unitPath := fmt.Sprintf("/etc/systemd/system/%s.service", serviceName)
|
||||||
|
os.Remove(unitPath)
|
||||||
|
|
||||||
|
// Reload
|
||||||
|
exec.Command("systemctl", "daemon-reload").Run()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the systemd service status
|
||||||
|
func Status() (string, error) {
|
||||||
|
out, err := exec.Command("systemctl", "status", serviceName).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(out), nil // status returns non-zero for inactive services
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func Install(mode, configPath string) error {
|
||||||
|
return fmt.Errorf("Windows service installation not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Uninstall() error {
|
||||||
|
return fmt.Errorf("Windows service uninstallation not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Status() (string, error) {
|
||||||
|
return "not implemented on Windows", nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package token
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const TokenLength = 32 // 32 bytes = 256 bits per token
|
||||||
|
|
||||||
|
// Tokens holds the 3 authentication tokens
|
||||||
|
type Tokens struct {
|
||||||
|
Token1 string `json:"token1"`
|
||||||
|
Token2 string `json:"token2"`
|
||||||
|
Token3 string `json:"token3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate creates 3 cryptographically random tokens
|
||||||
|
func Generate() (*Tokens, error) {
|
||||||
|
t := &Tokens{}
|
||||||
|
var err error
|
||||||
|
|
||||||
|
t.Token1, err = randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generating token 1: %w", err)
|
||||||
|
}
|
||||||
|
t.Token2, err = randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generating token 2: %w", err)
|
||||||
|
}
|
||||||
|
t.Token3, err = randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generating token 3: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash computes the SHA256 hash of the 3 tokens combined
|
||||||
|
func (t *Tokens) Hash() string {
|
||||||
|
combined := strings.Join([]string{t.Token1, t.Token2, t.Token3}, ":")
|
||||||
|
sum := sha256.Sum256([]byte(combined))
|
||||||
|
return fmt.Sprintf("%x", sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashFromString computes the hash from a pre-combined token string
|
||||||
|
func HashFromTokens(token1, token2, token3 string) string {
|
||||||
|
t := &Tokens{Token1: token1, Token2: token2, Token3: token3}
|
||||||
|
return t.Hash()
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken() (string, error) {
|
||||||
|
b := make([]byte, TokenLength)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.URLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package usb
|
||||||
|
|
||||||
|
// Device represents a USB device
|
||||||
|
type Device struct {
|
||||||
|
BusID string `json:"bus_id"` // e.g. "1-1.4"
|
||||||
|
BusNum uint32 `json:"bus_num"`
|
||||||
|
DevNum uint32 `json:"dev_num"`
|
||||||
|
Speed uint32 `json:"speed"`
|
||||||
|
VendorID uint16 `json:"vendor_id"`
|
||||||
|
ProductID uint16 `json:"product_id"`
|
||||||
|
BcdDevice uint16 `json:"bcd_device"`
|
||||||
|
DeviceClass uint8 `json:"device_class"`
|
||||||
|
DeviceSubClass uint8 `json:"device_sub_class"`
|
||||||
|
DeviceProtocol uint8 `json:"device_protocol"`
|
||||||
|
ConfigValue uint8 `json:"config_value"`
|
||||||
|
NumConfigs uint8 `json:"num_configs"`
|
||||||
|
Manufacturer string `json:"manufacturer"`
|
||||||
|
Product string `json:"product"`
|
||||||
|
Serial string `json:"serial"`
|
||||||
|
SysPath string `json:"sys_path"` // sysfs path
|
||||||
|
DevPath string `json:"dev_path"` // /dev/bus/usb path
|
||||||
|
Interfaces []Interface `json:"interfaces"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface represents a USB interface
|
||||||
|
type Interface struct {
|
||||||
|
Number uint8 `json:"number"`
|
||||||
|
Class uint8 `json:"class"`
|
||||||
|
SubClass uint8 `json:"sub_class"`
|
||||||
|
Protocol uint8 `json:"protocol"`
|
||||||
|
Driver string `json:"driver"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DevID returns the USB/IP device ID (busnum << 16 | devnum)
|
||||||
|
func (d *Device) DevID() uint32 {
|
||||||
|
return (d.BusNum << 16) | d.DevNum
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHub returns true if this is a USB hub
|
||||||
|
func (d *Device) IsHub() bool {
|
||||||
|
return d.DeviceClass == 9
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayName returns a human-readable device name
|
||||||
|
func (d *Device) DisplayName() string {
|
||||||
|
if d.Product != "" {
|
||||||
|
if d.Manufacturer != "" {
|
||||||
|
return d.Manufacturer + " " + d.Product
|
||||||
|
}
|
||||||
|
return d.Product
|
||||||
|
}
|
||||||
|
return "Unknown USB Device"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package usb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sysfsUSBDevices = "/sys/bus/usb/devices"
|
||||||
|
|
||||||
|
// Enumerate lists all USB devices by reading sysfs
|
||||||
|
func Enumerate() ([]Device, error) {
|
||||||
|
entries, err := os.ReadDir(sysfsUSBDevices)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading sysfs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var devices []Device
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
|
||||||
|
// Skip interfaces (contain ":") and "usb*" root hubs
|
||||||
|
if strings.Contains(name, ":") || strings.HasPrefix(name, "usb") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be a device path like "1-1", "1-1.4", "2-3", etc.
|
||||||
|
if !isDevicePath(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dev, err := readDevice(name)
|
||||||
|
if err != nil {
|
||||||
|
continue // skip devices we can't read
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip hubs
|
||||||
|
if dev.IsHub() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
devices = append(devices, *dev)
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDevicePath(name string) bool {
|
||||||
|
// Device paths look like "1-1", "1-1.4", "2-3.1.2"
|
||||||
|
// First char is a digit (bus number)
|
||||||
|
if len(name) < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if name[0] < '1' || name[0] > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if name[1] != '-' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDevice(busID string) (*Device, error) {
|
||||||
|
sysPath := filepath.Join(sysfsUSBDevices, busID)
|
||||||
|
|
||||||
|
dev := &Device{
|
||||||
|
BusID: busID,
|
||||||
|
SysPath: sysPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read basic attributes
|
||||||
|
dev.BusNum = readUint32(sysPath, "busnum")
|
||||||
|
dev.DevNum = readUint32(sysPath, "devnum")
|
||||||
|
dev.Speed = parseSpeed(readString(sysPath, "speed"))
|
||||||
|
dev.VendorID = readHex16(sysPath, "idVendor")
|
||||||
|
dev.ProductID = readHex16(sysPath, "idProduct")
|
||||||
|
dev.BcdDevice = readHex16(sysPath, "bcdDevice")
|
||||||
|
dev.DeviceClass = readHex8(sysPath, "bDeviceClass")
|
||||||
|
dev.DeviceSubClass = readHex8(sysPath, "bDeviceSubClass")
|
||||||
|
dev.DeviceProtocol = readHex8(sysPath, "bDeviceProtocol")
|
||||||
|
dev.ConfigValue = uint8(readUint32(sysPath, "bConfigurationValue"))
|
||||||
|
dev.NumConfigs = uint8(readUint32(sysPath, "bNumConfigurations"))
|
||||||
|
|
||||||
|
// Read string descriptors
|
||||||
|
dev.Manufacturer = readString(sysPath, "manufacturer")
|
||||||
|
dev.Product = readString(sysPath, "product")
|
||||||
|
dev.Serial = readString(sysPath, "serial")
|
||||||
|
|
||||||
|
// Compute dev path
|
||||||
|
dev.DevPath = fmt.Sprintf("/dev/bus/usb/%03d/%03d", dev.BusNum, dev.DevNum)
|
||||||
|
|
||||||
|
// Read interfaces
|
||||||
|
dev.Interfaces = readInterfaces(sysPath, busID)
|
||||||
|
|
||||||
|
return dev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readInterfaces(sysPath, busID string) []Interface {
|
||||||
|
entries, err := os.ReadDir(sysPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ifaces []Interface
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
// Interface directories look like "1-1.4:1.0"
|
||||||
|
if !strings.HasPrefix(name, busID+":") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ifacePath := filepath.Join(sysPath, name)
|
||||||
|
iface := Interface{
|
||||||
|
Number: readHex8(ifacePath, "bInterfaceNumber"),
|
||||||
|
Class: readHex8(ifacePath, "bInterfaceClass"),
|
||||||
|
SubClass: readHex8(ifacePath, "bInterfaceSubClass"),
|
||||||
|
Protocol: readHex8(ifacePath, "bInterfaceProtocol"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read driver
|
||||||
|
driverLink, err := os.Readlink(filepath.Join(ifacePath, "driver"))
|
||||||
|
if err == nil {
|
||||||
|
iface.Driver = filepath.Base(driverLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
ifaces = append(ifaces, iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ifaces
|
||||||
|
}
|
||||||
|
|
||||||
|
func readString(dir, attr string) string {
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, attr))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUint32(dir, attr string) uint32 {
|
||||||
|
s := readString(dir, attr)
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v, _ := strconv.ParseUint(s, 10, 32)
|
||||||
|
return uint32(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHex16(dir, attr string) uint16 {
|
||||||
|
s := readString(dir, attr)
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v, _ := strconv.ParseUint(s, 16, 16)
|
||||||
|
return uint16(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHex8(dir, attr string) uint8 {
|
||||||
|
s := readString(dir, attr)
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v, _ := strconv.ParseUint(s, 16, 8)
|
||||||
|
return uint8(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSpeed(s string) uint32 {
|
||||||
|
switch s {
|
||||||
|
case "1.5":
|
||||||
|
return 1 // Low
|
||||||
|
case "12":
|
||||||
|
return 2 // Full
|
||||||
|
case "480":
|
||||||
|
return 3 // High
|
||||||
|
case "5000":
|
||||||
|
return 5 // Super
|
||||||
|
case "10000":
|
||||||
|
return 6 // Super+
|
||||||
|
case "20000":
|
||||||
|
return 6 // Super+ (USB 3.2 2x1)
|
||||||
|
default:
|
||||||
|
return 0 // Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package usb
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Enumerate lists all USB devices (Windows stub)
|
||||||
|
func Enumerate() ([]Device, error) {
|
||||||
|
return nil, fmt.Errorf("USB enumeration not yet implemented on Windows")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package usb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ioctl direction constants
|
||||||
|
const (
|
||||||
|
iocNone = 0
|
||||||
|
iocWrite = 1
|
||||||
|
iocRead = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// ioctl encoding helpers
|
||||||
|
func ioc(dir, typ, nr, size uintptr) uintptr {
|
||||||
|
return (dir << 30) | (size << 16) | (typ << 8) | nr
|
||||||
|
}
|
||||||
|
|
||||||
|
func ior(typ, nr, size uintptr) uintptr { return ioc(iocRead, typ, nr, size) }
|
||||||
|
func iow(typ, nr, size uintptr) uintptr { return ioc(iocWrite, typ, nr, size) }
|
||||||
|
func iowr(typ, nr, size uintptr) uintptr { return ioc(iocRead|iocWrite, typ, nr, size) }
|
||||||
|
func io_(typ, nr uintptr) uintptr { return ioc(iocNone, typ, nr, 0) }
|
||||||
|
|
||||||
|
// USB device file system ioctl numbers
|
||||||
|
var (
|
||||||
|
usbdevfsControl = iowr('U', 0, unsafe.Sizeof(usbdevfsCtrlTransfer{}))
|
||||||
|
usbdevfsBulk = iowr('U', 2, unsafe.Sizeof(usbdevfsBulkTransfer{}))
|
||||||
|
usbdevfsSetInterface = ior('U', 4, unsafe.Sizeof(usbdevfsSetIntf{}))
|
||||||
|
usbdevfsSetConfig = ior('U', 5, 4)
|
||||||
|
usbdevfsSubmitURB = ior('U', 10, unsafe.Sizeof(usbdevfsURB{}))
|
||||||
|
usbdevfsDiscardURB = io_('U', 11)
|
||||||
|
usbdevfsReapURB = iow('U', 12, unsafe.Sizeof(uintptr(0)))
|
||||||
|
usbdevfsReapURBNDelay = iow('U', 13, unsafe.Sizeof(uintptr(0)))
|
||||||
|
usbdevfsClaimInterface = ior('U', 15, 4)
|
||||||
|
usbdevfsReleaseInterface = ior('U', 16, 4)
|
||||||
|
usbdevfsReset = io_('U', 20)
|
||||||
|
usbdevfsClearHalt = ior('U', 21, 4)
|
||||||
|
usbdevfsDisconnect = io_('U', 22)
|
||||||
|
usbdevfsConnect = io_('U', 23)
|
||||||
|
usbdevfsGetCapabilities = ior('U', 26, 4)
|
||||||
|
usbdevfsGetSpeed = io_('U', 31)
|
||||||
|
)
|
||||||
|
|
||||||
|
// URB type constants
|
||||||
|
const (
|
||||||
|
urbTypeISO = 0
|
||||||
|
urbTypeInterrupt = 1
|
||||||
|
urbTypeControl = 2
|
||||||
|
urbTypeBulk = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// usbdevfs structures for ioctls
|
||||||
|
|
||||||
|
type usbdevfsCtrlTransfer struct {
|
||||||
|
RequestType uint8
|
||||||
|
Request uint8
|
||||||
|
Value uint16
|
||||||
|
Index uint16
|
||||||
|
Length uint16
|
||||||
|
Timeout uint32
|
||||||
|
Data uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
type usbdevfsBulkTransfer struct {
|
||||||
|
Endpoint uint32
|
||||||
|
Length uint32
|
||||||
|
Timeout uint32
|
||||||
|
Data uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
type usbdevfsSetIntf struct {
|
||||||
|
Interface uint32
|
||||||
|
AltSetting uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type usbdevfsISOPacketDesc struct {
|
||||||
|
Length uint32
|
||||||
|
ActualLength uint32
|
||||||
|
Status uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type usbdevfsURB struct {
|
||||||
|
Type uint8
|
||||||
|
Endpoint uint8
|
||||||
|
Status int32
|
||||||
|
Flags uint32
|
||||||
|
Buffer uintptr
|
||||||
|
BufferLength int32
|
||||||
|
ActualLength int32
|
||||||
|
StartFrame int32
|
||||||
|
NumberOfPackets int32 // or StreamID
|
||||||
|
ErrorCount int32
|
||||||
|
Signr uint32
|
||||||
|
UserContext uintptr
|
||||||
|
// ISO packet descriptors follow in memory if Type == urbTypeISO
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceHandle provides low-level USB device access via usbdevfs
|
||||||
|
type DeviceHandle struct {
|
||||||
|
fd int
|
||||||
|
busID string
|
||||||
|
devPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenDevice opens a USB device file for direct access
|
||||||
|
func OpenDevice(devPath string, busID string) (*DeviceHandle, error) {
|
||||||
|
fd, err := unix.Open(devPath, unix.O_RDWR, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening %s: %w", devPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DeviceHandle{
|
||||||
|
fd: fd,
|
||||||
|
busID: busID,
|
||||||
|
devPath: devPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the device handle
|
||||||
|
func (h *DeviceHandle) Close() error {
|
||||||
|
return unix.Close(h.fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fd returns the file descriptor
|
||||||
|
func (h *DeviceHandle) Fd() int {
|
||||||
|
return h.fd
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisconnectDriver disconnects the kernel driver from the device
|
||||||
|
func (h *DeviceHandle) DisconnectDriver() error {
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsDisconnect, 0)
|
||||||
|
if errno != 0 {
|
||||||
|
return fmt.Errorf("USBDEVFS_DISCONNECT: %w", errno)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectDriver reconnects the kernel driver
|
||||||
|
func (h *DeviceHandle) ConnectDriver() error {
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsConnect, 0)
|
||||||
|
if errno != 0 {
|
||||||
|
return fmt.Errorf("USBDEVFS_CONNECT: %w", errno)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimInterface claims exclusive access to a USB interface
|
||||||
|
func (h *DeviceHandle) ClaimInterface(ifnum uint32) error {
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsClaimInterface, uintptr(unsafe.Pointer(&ifnum)))
|
||||||
|
if errno != 0 {
|
||||||
|
return fmt.Errorf("USBDEVFS_CLAIMINTERFACE(%d): %w", ifnum, errno)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseInterface releases a claimed interface
|
||||||
|
func (h *DeviceHandle) ReleaseInterface(ifnum uint32) error {
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsReleaseInterface, uintptr(unsafe.Pointer(&ifnum)))
|
||||||
|
if errno != 0 {
|
||||||
|
return fmt.Errorf("USBDEVFS_RELEASEINTERFACE(%d): %w", ifnum, errno)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConfiguration sets the device configuration
|
||||||
|
func (h *DeviceHandle) SetConfiguration(config uint32) error {
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsSetConfig, uintptr(unsafe.Pointer(&config)))
|
||||||
|
if errno != 0 {
|
||||||
|
return fmt.Errorf("USBDEVFS_SETCONFIGURATION(%d): %w", config, errno)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetInterface sets alternate setting for an interface
|
||||||
|
func (h *DeviceHandle) SetInterface(iface, altSetting uint32) error {
|
||||||
|
si := usbdevfsSetIntf{Interface: iface, AltSetting: altSetting}
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsSetInterface, uintptr(unsafe.Pointer(&si)))
|
||||||
|
if errno != 0 {
|
||||||
|
return fmt.Errorf("USBDEVFS_SETINTERFACE(%d, %d): %w", iface, altSetting, errno)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearHalt clears endpoint halt/stall condition
|
||||||
|
func (h *DeviceHandle) ClearHalt(endpoint uint32) error {
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsClearHalt, uintptr(unsafe.Pointer(&endpoint)))
|
||||||
|
if errno != 0 {
|
||||||
|
return fmt.Errorf("USBDEVFS_CLEAR_HALT(%d): %w", endpoint, errno)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetDevice resets the USB device
|
||||||
|
func (h *DeviceHandle) ResetDevice() error {
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsReset, 0)
|
||||||
|
if errno != 0 {
|
||||||
|
return fmt.Errorf("USBDEVFS_RESET: %w", errno)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpeed returns the device speed
|
||||||
|
func (h *DeviceHandle) GetSpeed() (uint32, error) {
|
||||||
|
r, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsGetSpeed, 0)
|
||||||
|
if errno != 0 {
|
||||||
|
return 0, fmt.Errorf("USBDEVFS_GET_SPEED: %w", errno)
|
||||||
|
}
|
||||||
|
return uint32(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlTransfer performs a synchronous control transfer
|
||||||
|
func (h *DeviceHandle) ControlTransfer(requestType, request uint8, value, index, length uint16, timeout uint32, data []byte) (int, error) {
|
||||||
|
var dataPtr uintptr
|
||||||
|
if len(data) > 0 {
|
||||||
|
dataPtr = uintptr(unsafe.Pointer(&data[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := usbdevfsCtrlTransfer{
|
||||||
|
RequestType: requestType,
|
||||||
|
Request: request,
|
||||||
|
Value: value,
|
||||||
|
Index: index,
|
||||||
|
Length: length,
|
||||||
|
Timeout: timeout,
|
||||||
|
Data: dataPtr,
|
||||||
|
}
|
||||||
|
|
||||||
|
r, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsControl, uintptr(unsafe.Pointer(&ct)))
|
||||||
|
if errno != 0 {
|
||||||
|
return 0, fmt.Errorf("USBDEVFS_CONTROL: %w", errno)
|
||||||
|
}
|
||||||
|
return int(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitURBParams holds parameters for async URB submission
|
||||||
|
type SubmitURBParams struct {
|
||||||
|
Type uint8
|
||||||
|
Endpoint uint8
|
||||||
|
Flags uint32
|
||||||
|
Buffer []byte
|
||||||
|
UserContext uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitURB submits an asynchronous URB
|
||||||
|
func (h *DeviceHandle) SubmitURB(params *SubmitURBParams) (*usbdevfsURB, error) {
|
||||||
|
var bufPtr uintptr
|
||||||
|
if len(params.Buffer) > 0 {
|
||||||
|
bufPtr = uintptr(unsafe.Pointer(¶ms.Buffer[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
urb := &usbdevfsURB{
|
||||||
|
Type: params.Type,
|
||||||
|
Endpoint: params.Endpoint,
|
||||||
|
Flags: params.Flags,
|
||||||
|
Buffer: bufPtr,
|
||||||
|
BufferLength: int32(len(params.Buffer)),
|
||||||
|
NumberOfPackets: -1, // 0xFFFFFFFF for non-ISO
|
||||||
|
UserContext: params.UserContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsSubmitURB, uintptr(unsafe.Pointer(urb)))
|
||||||
|
if errno != 0 {
|
||||||
|
return nil, fmt.Errorf("USBDEVFS_SUBMITURB: %w", errno)
|
||||||
|
}
|
||||||
|
return urb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReapURB blocks until a URB completes, then returns it
|
||||||
|
func (h *DeviceHandle) ReapURB() (*usbdevfsURB, error) {
|
||||||
|
var urbPtr uintptr
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsReapURB, uintptr(unsafe.Pointer(&urbPtr)))
|
||||||
|
if errno != 0 {
|
||||||
|
return nil, fmt.Errorf("USBDEVFS_REAPURB: %w", errno)
|
||||||
|
}
|
||||||
|
return (*usbdevfsURB)(unsafe.Pointer(urbPtr)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReapURBNonBlock tries to reap a URB without blocking
|
||||||
|
func (h *DeviceHandle) ReapURBNonBlock() (*usbdevfsURB, error) {
|
||||||
|
var urbPtr uintptr
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsReapURBNDelay, uintptr(unsafe.Pointer(&urbPtr)))
|
||||||
|
if errno != 0 {
|
||||||
|
return nil, fmt.Errorf("USBDEVFS_REAPURBNDELAY: %w", errno)
|
||||||
|
}
|
||||||
|
return (*usbdevfsURB)(unsafe.Pointer(urbPtr)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscardURB cancels a submitted URB
|
||||||
|
func (h *DeviceHandle) DiscardURB(urb *usbdevfsURB) error {
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsDiscardURB, uintptr(unsafe.Pointer(urb)))
|
||||||
|
if errno != 0 {
|
||||||
|
return fmt.Errorf("USBDEVFS_DISCARDURB: %w", errno)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFile returns an os.File wrapping the device fd (useful for epoll/select)
|
||||||
|
func (h *DeviceHandle) GetFile() *os.File {
|
||||||
|
return os.NewFile(uintptr(h.fd), h.devPath)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package usb
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// DeviceHandle provides USB device access (Windows stub)
|
||||||
|
type DeviceHandle struct{}
|
||||||
|
|
||||||
|
func OpenDevice(devPath string, busID string) (*DeviceHandle, error) {
|
||||||
|
return nil, fmt.Errorf("USB device access not yet implemented on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DeviceHandle) Close() error { return nil }
|
||||||
|
func (h *DeviceHandle) Fd() int { return -1 }
|
||||||
|
func (h *DeviceHandle) DisconnectDriver() error { return fmt.Errorf("not implemented") }
|
||||||
|
func (h *DeviceHandle) ConnectDriver() error { return fmt.Errorf("not implemented") }
|
||||||
|
func (h *DeviceHandle) ClaimInterface(uint32) error { return fmt.Errorf("not implemented") }
|
||||||
|
func (h *DeviceHandle) ReleaseInterface(uint32) error { return fmt.Errorf("not implemented") }
|
||||||
|
|
@ -0,0 +1,374 @@
|
||||||
|
package usbip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Protocol version
|
||||||
|
const ProtocolVersion = 0x0111
|
||||||
|
|
||||||
|
// Management phase opcodes
|
||||||
|
const (
|
||||||
|
OpReqDevlist = 0x8005
|
||||||
|
OpRepDevlist = 0x0005
|
||||||
|
OpReqImport = 0x8003
|
||||||
|
OpRepImport = 0x0003
|
||||||
|
)
|
||||||
|
|
||||||
|
// Data transfer phase commands
|
||||||
|
const (
|
||||||
|
CmdSubmit = 0x00000001
|
||||||
|
CmdUnlink = 0x00000002
|
||||||
|
RetSubmit = 0x00000003
|
||||||
|
RetUnlink = 0x00000004
|
||||||
|
)
|
||||||
|
|
||||||
|
// Transfer directions
|
||||||
|
const (
|
||||||
|
DirOut = 0
|
||||||
|
DirIn = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// USB device speeds
|
||||||
|
const (
|
||||||
|
SpeedUnknown = 0
|
||||||
|
SpeedLow = 1
|
||||||
|
SpeedFull = 2
|
||||||
|
SpeedHigh = 3
|
||||||
|
SpeedWireless = 4
|
||||||
|
SpeedSuper = 5
|
||||||
|
SpeedSuperPlus = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpHeader is the 8-byte header for management messages
|
||||||
|
type OpHeader struct {
|
||||||
|
Version uint16
|
||||||
|
Command uint16
|
||||||
|
Status uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceDescriptor describes a USB device in USB/IP protocol
|
||||||
|
type DeviceDescriptor struct {
|
||||||
|
Path [256]byte
|
||||||
|
BusID [32]byte
|
||||||
|
BusNum uint32
|
||||||
|
DevNum uint32
|
||||||
|
Speed uint32
|
||||||
|
IDVendor uint16
|
||||||
|
IDProduct uint16
|
||||||
|
BcdDevice uint16
|
||||||
|
BDeviceClass uint8
|
||||||
|
BDeviceSubClass uint8
|
||||||
|
BDeviceProtocol uint8
|
||||||
|
BConfigurationValue uint8
|
||||||
|
BNumConfigurations uint8
|
||||||
|
BNumInterfaces uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
// InterfaceDescriptor describes a USB interface
|
||||||
|
type InterfaceDescriptor struct {
|
||||||
|
BInterfaceClass uint8
|
||||||
|
BInterfaceSubClass uint8
|
||||||
|
BInterfaceProtocol uint8
|
||||||
|
Padding uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
// URBHeader is the 48-byte common header for USB/IP transfer messages
|
||||||
|
type URBHeader struct {
|
||||||
|
Command uint32
|
||||||
|
SeqNum uint32
|
||||||
|
DevID uint32
|
||||||
|
Direction uint32
|
||||||
|
Endpoint uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// CmdSubmitBody follows URBHeader for USBIP_CMD_SUBMIT
|
||||||
|
type CmdSubmitBody struct {
|
||||||
|
TransferFlags uint32
|
||||||
|
TransferBufferLen uint32
|
||||||
|
StartFrame uint32
|
||||||
|
NumberOfPackets uint32
|
||||||
|
Interval uint32
|
||||||
|
Setup [8]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetSubmitBody follows URBHeader for USBIP_RET_SUBMIT
|
||||||
|
type RetSubmitBody struct {
|
||||||
|
Status int32
|
||||||
|
ActualLength uint32
|
||||||
|
StartFrame uint32
|
||||||
|
NumberOfPackets uint32
|
||||||
|
ErrorCount uint32
|
||||||
|
Padding [8]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// CmdUnlinkBody follows URBHeader for USBIP_CMD_UNLINK
|
||||||
|
type CmdUnlinkBody struct {
|
||||||
|
UnlinkSeqNum uint32
|
||||||
|
Padding [24]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetUnlinkBody follows URBHeader for USBIP_RET_UNLINK
|
||||||
|
type RetUnlinkBody struct {
|
||||||
|
Status int32
|
||||||
|
Padding [24]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISOPacketDescriptor for isochronous transfers
|
||||||
|
type ISOPacketDescriptor struct {
|
||||||
|
Offset uint32
|
||||||
|
Length uint32
|
||||||
|
ActualLength uint32
|
||||||
|
Status uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Encoding/Decoding helpers ---
|
||||||
|
|
||||||
|
// WriteOpHeader writes an operation header
|
||||||
|
func WriteOpHeader(w io.Writer, cmd uint16, status uint32) error {
|
||||||
|
h := OpHeader{Version: ProtocolVersion, Command: cmd, Status: status}
|
||||||
|
return binary.Write(w, binary.BigEndian, &h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadOpHeader reads an operation header
|
||||||
|
func ReadOpHeader(r io.Reader) (*OpHeader, error) {
|
||||||
|
h := &OpHeader{}
|
||||||
|
if err := binary.Read(r, binary.BigEndian, h); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteDeviceDescriptor writes a device descriptor
|
||||||
|
func WriteDeviceDescriptor(w io.Writer, d *DeviceDescriptor) error {
|
||||||
|
return binary.Write(w, binary.BigEndian, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadDeviceDescriptor reads a device descriptor
|
||||||
|
func ReadDeviceDescriptor(r io.Reader) (*DeviceDescriptor, error) {
|
||||||
|
d := &DeviceDescriptor{}
|
||||||
|
if err := binary.Read(r, binary.BigEndian, d); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteInterfaceDescriptor writes an interface descriptor
|
||||||
|
func WriteInterfaceDescriptor(w io.Writer, d *InterfaceDescriptor) error {
|
||||||
|
return binary.Write(w, binary.BigEndian, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadURBHeader reads a URB header
|
||||||
|
func ReadURBHeader(r io.Reader) (*URBHeader, error) {
|
||||||
|
h := &URBHeader{}
|
||||||
|
if err := binary.Read(r, binary.BigEndian, h); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteURBHeader writes a URB header
|
||||||
|
func WriteURBHeader(w io.Writer, h *URBHeader) error {
|
||||||
|
return binary.Write(w, binary.BigEndian, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadCmdSubmit reads a CMD_SUBMIT body (after URB header)
|
||||||
|
func ReadCmdSubmit(r io.Reader) (*CmdSubmitBody, error) {
|
||||||
|
b := &CmdSubmitBody{}
|
||||||
|
if err := binary.Read(r, binary.BigEndian, b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteCmdSubmit writes a CMD_SUBMIT body
|
||||||
|
func WriteCmdSubmit(w io.Writer, b *CmdSubmitBody) error {
|
||||||
|
return binary.Write(w, binary.BigEndian, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadRetSubmit reads a RET_SUBMIT body
|
||||||
|
func ReadRetSubmit(r io.Reader) (*RetSubmitBody, error) {
|
||||||
|
b := &RetSubmitBody{}
|
||||||
|
if err := binary.Read(r, binary.BigEndian, b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteRetSubmit writes a RET_SUBMIT body
|
||||||
|
func WriteRetSubmit(w io.Writer, b *RetSubmitBody) error {
|
||||||
|
return binary.Write(w, binary.BigEndian, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadCmdUnlink reads a CMD_UNLINK body
|
||||||
|
func ReadCmdUnlink(r io.Reader) (*CmdUnlinkBody, error) {
|
||||||
|
b := &CmdUnlinkBody{}
|
||||||
|
if err := binary.Read(r, binary.BigEndian, b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteRetUnlink writes a RET_UNLINK body
|
||||||
|
func WriteRetUnlink(w io.Writer, b *RetUnlinkBody) error {
|
||||||
|
return binary.Write(w, binary.BigEndian, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- High-level message builders ---
|
||||||
|
|
||||||
|
// BuildDevlistReply builds a complete OP_REP_DEVLIST response
|
||||||
|
func BuildDevlistReply(devices []DeviceDescriptor, interfaces [][]InterfaceDescriptor) ([]byte, error) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
if err := WriteOpHeader(buf, OpRepDevlist, 0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number of devices
|
||||||
|
if err := binary.Write(buf, binary.BigEndian, uint32(len(devices))); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each device + its interfaces
|
||||||
|
for i, dev := range devices {
|
||||||
|
if err := WriteDeviceDescriptor(buf, &dev); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if i < len(interfaces) {
|
||||||
|
for _, iface := range interfaces[i] {
|
||||||
|
if err := WriteInterfaceDescriptor(buf, &iface); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildImportReply builds an OP_REP_IMPORT response
|
||||||
|
func BuildImportReply(status uint32, dev *DeviceDescriptor) ([]byte, error) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
if err := WriteOpHeader(buf, OpRepImport, status); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == 0 && dev != nil {
|
||||||
|
if err := WriteDeviceDescriptor(buf, dev); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRetSubmit builds a RET_SUBMIT message
|
||||||
|
func BuildRetSubmit(seqNum, devID, direction, endpoint uint32, status int32, data []byte) ([]byte, error) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
hdr := &URBHeader{
|
||||||
|
Command: RetSubmit,
|
||||||
|
SeqNum: seqNum,
|
||||||
|
DevID: devID,
|
||||||
|
Direction: direction,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
}
|
||||||
|
if err := WriteURBHeader(buf, hdr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
actualLen := uint32(0)
|
||||||
|
if direction == DirIn && data != nil {
|
||||||
|
actualLen = uint32(len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
body := &RetSubmitBody{
|
||||||
|
Status: status,
|
||||||
|
ActualLength: actualLen,
|
||||||
|
NumberOfPackets: 0xFFFFFFFF,
|
||||||
|
}
|
||||||
|
if err := WriteRetSubmit(buf, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer buffer for IN direction
|
||||||
|
if direction == DirIn && len(data) > 0 {
|
||||||
|
buf.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRetUnlink builds a RET_UNLINK message
|
||||||
|
func BuildRetUnlink(seqNum, devID uint32, status int32) ([]byte, error) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
hdr := &URBHeader{
|
||||||
|
Command: RetUnlink,
|
||||||
|
SeqNum: seqNum,
|
||||||
|
DevID: devID,
|
||||||
|
Direction: 0,
|
||||||
|
Endpoint: 0,
|
||||||
|
}
|
||||||
|
if err := WriteURBHeader(buf, hdr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body := &RetUnlinkBody{Status: status}
|
||||||
|
if err := WriteRetUnlink(buf, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBusID sets a bus ID string in a fixed-size byte array
|
||||||
|
func SetBusID(arr *[32]byte, busID string) {
|
||||||
|
copy(arr[:], busID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPath sets a path string in a fixed-size byte array
|
||||||
|
func SetPath(arr *[256]byte, path string) {
|
||||||
|
copy(arr[:], path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBusID extracts a bus ID string from a fixed-size byte array
|
||||||
|
func GetBusID(arr [32]byte) string {
|
||||||
|
n := bytes.IndexByte(arr[:], 0)
|
||||||
|
if n < 0 {
|
||||||
|
n = 32
|
||||||
|
}
|
||||||
|
return string(arr[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPath extracts a path string from a fixed-size byte array
|
||||||
|
func GetPath(arr [256]byte) string {
|
||||||
|
n := bytes.IndexByte(arr[:], 0)
|
||||||
|
if n < 0 {
|
||||||
|
n = 256
|
||||||
|
}
|
||||||
|
return string(arr[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpeedString returns a human-readable speed name
|
||||||
|
func SpeedString(speed uint32) string {
|
||||||
|
switch speed {
|
||||||
|
case SpeedLow:
|
||||||
|
return "1.5 Mbps (Low)"
|
||||||
|
case SpeedFull:
|
||||||
|
return "12 Mbps (Full)"
|
||||||
|
case SpeedHigh:
|
||||||
|
return "480 Mbps (High)"
|
||||||
|
case SpeedSuper:
|
||||||
|
return "5 Gbps (Super)"
|
||||||
|
case SpeedSuperPlus:
|
||||||
|
return "10 Gbps (Super+)"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Unknown (%d)", speed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,460 @@
|
||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package usbip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/duffy/usb-server/internal/usb"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server handles USB/IP protocol on the share side.
|
||||||
|
// It manages a single USB device and forwards URBs between
|
||||||
|
// the USB/IP client (via tunnel) and the physical device (via usbdevfs).
|
||||||
|
type Server struct {
|
||||||
|
device *usb.Device
|
||||||
|
handle *usb.DeviceHandle
|
||||||
|
mu sync.Mutex
|
||||||
|
pendingURBs map[uint32]*pendingURB // seqnum -> pending URB
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingURB struct {
|
||||||
|
seqNum uint32
|
||||||
|
devID uint32
|
||||||
|
direction uint32
|
||||||
|
endpoint uint32
|
||||||
|
buffer []byte
|
||||||
|
urbPtr unsafe.Pointer // pointer to submitted usbdevfs_urb
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a USB/IP server for a specific device
|
||||||
|
func NewServer(dev *usb.Device) *Server {
|
||||||
|
return &Server{
|
||||||
|
device: dev,
|
||||||
|
pendingURBs: make(map[uint32]*pendingURB),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach opens the device, disconnects the kernel driver, and claims all interfaces
|
||||||
|
func (s *Server) Attach() error {
|
||||||
|
handle, err := usb.OpenDevice(s.device.DevPath, s.device.BusID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("opening device: %w", err)
|
||||||
|
}
|
||||||
|
s.handle = handle
|
||||||
|
|
||||||
|
// Disconnect kernel drivers from all interfaces
|
||||||
|
for _, iface := range s.device.Interfaces {
|
||||||
|
if iface.Driver != "" && iface.Driver != "(none)" {
|
||||||
|
// Try to disconnect - ignore errors for already-disconnected interfaces
|
||||||
|
handle.DisconnectDriver()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim all interfaces
|
||||||
|
for _, iface := range s.device.Interfaces {
|
||||||
|
if err := handle.ClaimInterface(uint32(iface.Number)); err != nil {
|
||||||
|
log.Printf("[usbip-server] warning: could not claim interface %d: %v", iface.Number, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach releases all interfaces, reconnects kernel driver, and closes the device
|
||||||
|
func (s *Server) Detach() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.closed = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.handle == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release all interfaces
|
||||||
|
for _, iface := range s.device.Interfaces {
|
||||||
|
s.handle.ReleaseInterface(uint32(iface.Number))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnect kernel driver
|
||||||
|
s.handle.ConnectDriver()
|
||||||
|
|
||||||
|
s.handle.Close()
|
||||||
|
s.handle = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildDeviceDescriptor creates a USB/IP device descriptor from our device info
|
||||||
|
func (s *Server) BuildDeviceDescriptor() DeviceDescriptor {
|
||||||
|
var desc DeviceDescriptor
|
||||||
|
SetPath(&desc.Path, s.device.SysPath)
|
||||||
|
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.
|
||||||
|
// It reads USB/IP requests from the reader, processes them, and writes responses to the writer.
|
||||||
|
// This is the main loop for handling a connected USB/IP client.
|
||||||
|
func (s *Server) HandleConnection(r io.Reader, w io.Writer) error {
|
||||||
|
// Start the URB reaper goroutine
|
||||||
|
retChan := make(chan []byte, 64)
|
||||||
|
done := make(chan struct{})
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
go s.reapLoop(retChan, done)
|
||||||
|
|
||||||
|
// Forward completed URBs to the writer
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case data, ok := <-retChan:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := w.Write(data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Read and process incoming USB/IP messages
|
||||||
|
for {
|
||||||
|
// Read the URB header (20 bytes basic + 28 bytes specific = 48 total)
|
||||||
|
hdr, err := ReadURBHeader(r)
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read transfer buffer for OUT direction
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read ISO packet descriptors if present
|
||||||
|
if body.NumberOfPackets != 0xFFFFFFFF && body.NumberOfPackets > 0 {
|
||||||
|
isoDescs := make([]ISOPacketDescriptor, body.NumberOfPackets)
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &isoDescs); err != nil {
|
||||||
|
return fmt.Errorf("reading ISO descriptors: %w", err)
|
||||||
|
}
|
||||||
|
// TODO: handle ISO transfers properly
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine URB type from endpoint
|
||||||
|
endpoint := uint8(hdr.Endpoint)
|
||||||
|
var urbType uint8
|
||||||
|
if endpoint == 0 {
|
||||||
|
urbType = 2 // control
|
||||||
|
} else {
|
||||||
|
urbType = 3 // bulk (most common, we'll detect interrupt from endpoint descriptor later)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle control transfers specially (endpoint 0)
|
||||||
|
if endpoint == 0 && hdr.Direction == DirIn {
|
||||||
|
// Control IN: send setup packet, receive data
|
||||||
|
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 {
|
||||||
|
status = -32 // -EPIPE
|
||||||
|
n = 0
|
||||||
|
}
|
||||||
|
resp, err := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, status, buf[:n])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
retChan <- resp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if endpoint == 0 && hdr.Direction == DirOut {
|
||||||
|
// Control OUT
|
||||||
|
buf := transferBuf
|
||||||
|
if buf == nil {
|
||||||
|
buf = make([]byte, 0)
|
||||||
|
}
|
||||||
|
_, 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 {
|
||||||
|
status = -32 // -EPIPE
|
||||||
|
}
|
||||||
|
resp, err := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, status, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
retChan <- resp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-control transfers, submit asynchronously
|
||||||
|
var buf []byte
|
||||||
|
if hdr.Direction == DirIn {
|
||||||
|
buf = make([]byte, body.TransferBufferLen)
|
||||||
|
} else {
|
||||||
|
buf = transferBuf
|
||||||
|
}
|
||||||
|
|
||||||
|
ep := endpoint
|
||||||
|
if hdr.Direction == DirIn {
|
||||||
|
ep |= 0x80
|
||||||
|
}
|
||||||
|
|
||||||
|
urb, err := s.handle.SubmitURB(&usb.SubmitURBParams{
|
||||||
|
Type: urbType,
|
||||||
|
Endpoint: ep,
|
||||||
|
Flags: 0,
|
||||||
|
Buffer: buf,
|
||||||
|
UserContext: uintptr(hdr.SeqNum),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Submit failed - send error response immediately
|
||||||
|
resp, _ := BuildRetSubmit(hdr.SeqNum, hdr.DevID, hdr.Direction, hdr.Endpoint, -32, nil)
|
||||||
|
retChan <- resp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.pendingURBs[hdr.SeqNum] = &pendingURB{
|
||||||
|
seqNum: hdr.SeqNum,
|
||||||
|
devID: hdr.DevID,
|
||||||
|
direction: hdr.Direction,
|
||||||
|
endpoint: hdr.Endpoint,
|
||||||
|
buffer: buf,
|
||||||
|
urbPtr: unsafe.Pointer(urb),
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCmdUnlink(r io.Reader, hdr *URBHeader, retChan chan<- []byte) error {
|
||||||
|
body, err := ReadCmdUnlink(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
pending, exists := s.pendingURBs[body.UnlinkSeqNum]
|
||||||
|
if exists {
|
||||||
|
delete(s.pendingURBs, body.UnlinkSeqNum)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
var status int32
|
||||||
|
if exists && pending.urbPtr != nil {
|
||||||
|
// Try to discard the URB
|
||||||
|
// Note: we cast back to the URB type for the ioctl
|
||||||
|
urbForDiscard := (*usbDevfsURBForDiscard)(pending.urbPtr)
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(s.handle.Fd()),
|
||||||
|
uintptr(0x8000550B), // USBDEVFS_DISCARDURB
|
||||||
|
uintptr(pending.urbPtr))
|
||||||
|
_ = urbForDiscard
|
||||||
|
if errno == 0 {
|
||||||
|
status = -104 // -ECONNRESET
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := BuildRetUnlink(hdr.SeqNum, hdr.DevID, status)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
retChan <- resp
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// usbDevfsURBForDiscard is a placeholder to make the Go compiler happy
|
||||||
|
type usbDevfsURBForDiscard struct{}
|
||||||
|
|
||||||
|
// reapLoop continuously reaps completed URBs and sends responses
|
||||||
|
func (s *Server) reapLoop(retChan chan<- []byte, done <-chan struct{}) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.closed || s.handle == nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
urb, err := s.handle.ReapURB()
|
||||||
|
if err != nil {
|
||||||
|
// Check if we should stop
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seqNum := uint32(urb.UserContext)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
pending, exists := s.pendingURBs[seqNum]
|
||||||
|
if exists {
|
||||||
|
delete(s.pendingURBs, seqNum)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var data []byte
|
||||||
|
if pending.direction == DirIn && urb.ActualLength > 0 {
|
||||||
|
data = pending.buffer[:urb.ActualLength]
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := BuildRetSubmit(
|
||||||
|
pending.seqNum,
|
||||||
|
pending.devID,
|
||||||
|
pending.direction,
|
||||||
|
pending.endpoint,
|
||||||
|
urb.Status,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case retChan <- resp:
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDevlistRequest handles an OP_REQ_DEVLIST for this device
|
||||||
|
func (s *Server) HandleDevlistRequest() ([]byte, error) {
|
||||||
|
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) {
|
||||||
|
if requestedBusID != s.device.BusID {
|
||||||
|
return BuildImportReply(1, nil) // device not found
|
||||||
|
}
|
||||||
|
desc := s.BuildDeviceDescriptor()
|
||||||
|
return BuildImportReply(0, &desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadManagementRequest reads and dispatches a management phase message.
|
||||||
|
// Returns the response bytes and whether we should transition to transfer phase.
|
||||||
|
func (s *Server) ReadManagementRequest(r io.Reader) (response []byte, startTransfer bool, err error) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
reqBusID := GetBusID(busID)
|
||||||
|
resp, err := s.HandleImportRequest(reqBusID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if import was successful (status in response)
|
||||||
|
var checkBuf bytes.Buffer
|
||||||
|
checkBuf.Write(resp)
|
||||||
|
checkHdr, _ := ReadOpHeader(&checkBuf)
|
||||||
|
if checkHdr != nil && checkHdr.Status == 0 {
|
||||||
|
return resp, true, nil // successful import -> transfer phase
|
||||||
|
}
|
||||||
|
return resp, false, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, false, fmt.Errorf("unknown management command: 0x%04x", hdr.Command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package usbip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const vhciBasePath = "/sys/devices/platform/vhci_hcd.0"
|
||||||
|
|
||||||
|
// VHCIPort represents a virtual USB port on the VHCI controller
|
||||||
|
type VHCIPort struct {
|
||||||
|
Hub string // "hs" or "ss"
|
||||||
|
Port int
|
||||||
|
Status int
|
||||||
|
Speed int
|
||||||
|
DevID uint32
|
||||||
|
SocketFD int
|
||||||
|
LocalBusID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// VHCI status constants
|
||||||
|
const (
|
||||||
|
VDevStNull = 0x04
|
||||||
|
VDevStNotAssigned = 0x05
|
||||||
|
VDevStUsed = 0x06
|
||||||
|
VDevStError = 0x07
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReadVHCIStatus reads the current VHCI port status
|
||||||
|
func ReadVHCIStatus() ([]VHCIPort, error) {
|
||||||
|
// Try status file directly, then status.0, status.1, etc.
|
||||||
|
var allPorts []VHCIPort
|
||||||
|
|
||||||
|
paths := []string{
|
||||||
|
filepath.Join(vhciBasePath, "status"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for multi-controller status files
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
p := filepath.Join(vhciBasePath, fmt.Sprintf("status.%d", i))
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
paths = append(paths, p)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
ports, err := parseStatusFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allPorts = append(allPorts, ports...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allPorts) == 0 {
|
||||||
|
return nil, fmt.Errorf("vhci-hcd module not loaded or no ports found (check: modprobe vhci-hcd)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return allPorts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStatusFile(path string) ([]VHCIPort, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||||
|
var ports []VHCIPort
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
// Skip header lines
|
||||||
|
if strings.HasPrefix(line, "hub") || strings.HasPrefix(line, "prt") || line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 7 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
port := VHCIPort{Hub: fields[0]}
|
||||||
|
|
||||||
|
if v, err := strconv.Atoi(fields[1]); err == nil {
|
||||||
|
port.Port = v
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(fields[2]); err == nil {
|
||||||
|
port.Status = v
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(fields[3]); err == nil {
|
||||||
|
port.Speed = v
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseUint(fields[4], 16, 32); err == nil {
|
||||||
|
port.DevID = uint32(v)
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(fields[5]); err == nil {
|
||||||
|
port.SocketFD = v
|
||||||
|
}
|
||||||
|
port.LocalBusID = fields[6]
|
||||||
|
|
||||||
|
ports = append(ports, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ports, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindFreePort finds an available VHCI port for the given speed
|
||||||
|
func FindFreePort(speed uint32) (int, error) {
|
||||||
|
ports, err := ReadVHCIStatus()
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine desired hub type based on speed
|
||||||
|
wantHub := "hs" // high-speed and below
|
||||||
|
if speed >= SpeedSuper {
|
||||||
|
wantHub = "ss" // super-speed
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, port := range ports {
|
||||||
|
if port.Status == VDevStNull && port.Hub == wantHub {
|
||||||
|
return port.Port, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1, fmt.Errorf("no free VHCI port available for hub type %s", wantHub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachDevice writes to the VHCI attach file to create a virtual USB device.
|
||||||
|
// sockfd must be a valid TCP socket file descriptor connected to the USB/IP server.
|
||||||
|
func AttachDevice(port int, sockfd int, devID uint32, speed uint32) error {
|
||||||
|
attachPath := filepath.Join(vhciBasePath, "attach")
|
||||||
|
|
||||||
|
// Format: "<port> <sockfd> <devid> <speed>"
|
||||||
|
data := fmt.Sprintf("%d %d %d %d", port, sockfd, devID, speed)
|
||||||
|
|
||||||
|
if err := os.WriteFile(attachPath, []byte(data), 0); err != nil {
|
||||||
|
return fmt.Errorf("writing to VHCI attach: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetachDevice writes to the VHCI detach file to remove a virtual USB device
|
||||||
|
func DetachDevice(port int) error {
|
||||||
|
detachPath := filepath.Join(vhciBasePath, "detach")
|
||||||
|
|
||||||
|
data := fmt.Sprintf("%d", port)
|
||||||
|
|
||||||
|
if err := os.WriteFile(detachPath, []byte(data), 0); err != nil {
|
||||||
|
return fmt.Errorf("writing to VHCI detach: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsVHCIAvailable checks if the vhci-hcd kernel module is loaded
|
||||||
|
func IsVHCIAvailable() bool {
|
||||||
|
_, err := os.Stat(vhciBasePath)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,292 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/duffy/usb-server/internal/config"
|
||||||
|
"github.com/duffy/usb-server/internal/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
// Handler provides the web UI and API
|
||||||
|
type Handler struct {
|
||||||
|
cfg *config.Config
|
||||||
|
cfgPath string
|
||||||
|
mux *http.ServeMux
|
||||||
|
|
||||||
|
// Callbacks for device operations
|
||||||
|
GetDevices func() interface{}
|
||||||
|
AttachDevice func(clientID, busID string) error
|
||||||
|
DetachDevice func(clientID, busID string) error
|
||||||
|
InstallService func() error
|
||||||
|
UninstallService func() error
|
||||||
|
GetStatus func() map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new web handler
|
||||||
|
func NewHandler(cfg *config.Config, cfgPath string) *Handler {
|
||||||
|
h := &Handler{
|
||||||
|
cfg: cfg,
|
||||||
|
cfgPath: cfgPath,
|
||||||
|
mux: http.NewServeMux(),
|
||||||
|
}
|
||||||
|
h.setupRoutes()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) setupRoutes() {
|
||||||
|
// Static files
|
||||||
|
staticFS, _ := fs.Sub(staticFiles, "static")
|
||||||
|
h.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||||
|
h.mux.HandleFunc("/", h.handleIndex)
|
||||||
|
|
||||||
|
// API endpoints
|
||||||
|
h.mux.HandleFunc("/api/status", h.handleStatus)
|
||||||
|
h.mux.HandleFunc("/api/devices", h.handleDevices)
|
||||||
|
h.mux.HandleFunc("/api/attach", h.handleAttach)
|
||||||
|
h.mux.HandleFunc("/api/detach", h.handleDetach)
|
||||||
|
h.mux.HandleFunc("/api/config", h.handleConfig)
|
||||||
|
h.mux.HandleFunc("/api/generate-token", h.handleGenerateToken)
|
||||||
|
h.mux.HandleFunc("/api/apply-tokens", h.handleApplyTokens)
|
||||||
|
h.mux.HandleFunc("/api/service/install", h.handleServiceInstall)
|
||||||
|
h.mux.HandleFunc("/api/service/uninstall", h.handleServiceUninstall)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.mux.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := staticFiles.ReadFile("static/index.html")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal error", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := map[string]interface{}{
|
||||||
|
"connected": false,
|
||||||
|
"mode": h.cfg.Mode,
|
||||||
|
"name": h.cfg.Name,
|
||||||
|
}
|
||||||
|
if h.GetStatus != nil {
|
||||||
|
status = h.GetStatus()
|
||||||
|
}
|
||||||
|
writeJSON(w, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.GetDevices == nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"mode": h.cfg.Mode})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, h.GetDevices())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleAttach(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
http.Error(w, "Method not allowed", 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
BusID string `json:"bus_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": false, "error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.AttachDevice == nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": false, "error": "not in use mode"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.AttachDevice(req.ClientID, req.BusID); err != nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleDetach(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
http.Error(w, "Method not allowed", 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
BusID string `json:"bus_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": false, "error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.DetachDevice == nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": false, "error": "not in use mode"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.DetachDevice(req.ClientID, req.BusID); err != nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" {
|
||||||
|
writeJSON(w, h.cfg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "POST" {
|
||||||
|
var updates struct {
|
||||||
|
RelayAddr string `json:"relay_addr"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
WebPort int `json:"web_port"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": false, "error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if updates.RelayAddr != "" {
|
||||||
|
h.cfg.RelayAddr = updates.RelayAddr
|
||||||
|
}
|
||||||
|
if updates.Mode == "share" || updates.Mode == "use" {
|
||||||
|
h.cfg.Mode = updates.Mode
|
||||||
|
}
|
||||||
|
if updates.Name != "" {
|
||||||
|
h.cfg.Name = updates.Name
|
||||||
|
}
|
||||||
|
if updates.WebPort > 0 {
|
||||||
|
h.cfg.WebPort = updates.WebPort
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.cfg.Save(h.cfgPath); err != nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "Method not allowed", 405)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleGenerateToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
http.Error(w, "Method not allowed", 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := token.Generate()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := tokens.Hash()
|
||||||
|
|
||||||
|
// Save tokens to config
|
||||||
|
h.cfg.Token1 = tokens.Token1
|
||||||
|
h.cfg.Token2 = tokens.Token2
|
||||||
|
h.cfg.Token3 = tokens.Token3
|
||||||
|
h.cfg.Hash = hash
|
||||||
|
h.cfg.Save(h.cfgPath)
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"token1": tokens.Token1,
|
||||||
|
"token2": tokens.Token2,
|
||||||
|
"token3": tokens.Token3,
|
||||||
|
"hash": hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleApplyTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
http.Error(w, "Method not allowed", 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Token1 string `json:"token1"`
|
||||||
|
Token2 string `json:"token2"`
|
||||||
|
Token3 string `json:"token3"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": false, "error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := token.HashFromTokens(req.Token1, req.Token2, req.Token3)
|
||||||
|
|
||||||
|
h.cfg.Token1 = req.Token1
|
||||||
|
h.cfg.Token2 = req.Token2
|
||||||
|
h.cfg.Token3 = req.Token3
|
||||||
|
h.cfg.Hash = hash
|
||||||
|
h.cfg.Save(h.cfgPath)
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": true, "hash": hash})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleServiceInstall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
http.Error(w, "Method not allowed", 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.InstallService == nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"error": "service management not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.InstallService(); err != nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"message": "Service installiert und gestartet"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleServiceUninstall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
http.Error(w, "Method not allowed", 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.UninstallService == nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"error": "service management not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.UninstallService(); err != nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"message": "Service deinstalliert"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
log.Printf("[web] JSON encode error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
// USB Server Web UI
|
||||||
|
|
||||||
|
const API_BASE = '';
|
||||||
|
|
||||||
|
// Tab navigation
|
||||||
|
document.querySelectorAll('.tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status updates
|
||||||
|
async function updateStatus() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/status');
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
if (data.connected) {
|
||||||
|
el.textContent = 'Verbunden';
|
||||||
|
el.className = 'status connected';
|
||||||
|
} else {
|
||||||
|
el.textContent = 'Nicht verbunden';
|
||||||
|
el.className = 'status disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('mode-info').innerHTML =
|
||||||
|
`<strong>Modus:</strong> ${data.mode === 'share' ? 'Freigeben' : 'Empfangen'} | ` +
|
||||||
|
`<strong>Name:</strong> ${data.name} | ` +
|
||||||
|
`<strong>Client ID:</strong> ${data.client_id ? data.client_id.substring(0, 8) + '...' : '-'}`;
|
||||||
|
} catch (e) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = 'Fehler';
|
||||||
|
el.className = 'status disconnected';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device list
|
||||||
|
async function updateDevices() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/devices');
|
||||||
|
const data = await resp.json();
|
||||||
|
renderDevices(data);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('device-list').innerHTML =
|
||||||
|
'<p class="loading">Fehler beim Laden der Geraete</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDevices(data) {
|
||||||
|
const container = document.getElementById('device-list');
|
||||||
|
|
||||||
|
if (data.mode === 'share') {
|
||||||
|
renderShareDevices(container, data.local_devices || []);
|
||||||
|
} else {
|
||||||
|
renderUseDevices(container, data.available_devices || [], data.attached_devices || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShareDevices(container, devices) {
|
||||||
|
if (!devices || devices.length === 0) {
|
||||||
|
container.innerHTML = '<p class="no-devices">Keine USB-Geraete gefunden</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = devices.map(dev => `
|
||||||
|
<div class="device-card">
|
||||||
|
<div class="device-info">
|
||||||
|
<div class="device-name">${escapeHtml(dev.name)}</div>
|
||||||
|
<div class="device-details">
|
||||||
|
<span>Bus: ${dev.bus_id}</span>
|
||||||
|
<span>VID:PID: ${dev.vendor_id}:${dev.product_id}</span>
|
||||||
|
<span>Speed: ${speedName(dev.speed)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-status">
|
||||||
|
<span class="badge ${dev.status === 'available' ? 'available' : 'in-use'}">
|
||||||
|
${dev.status === 'available' ? 'Verfuegbar' : 'In Benutzung'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUseDevices(container, available, attached) {
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Attached devices first
|
||||||
|
if (attached && attached.length > 0) {
|
||||||
|
html += '<div class="client-header">Verbundene Geraete</div>';
|
||||||
|
html += attached.map(dev => `
|
||||||
|
<div class="device-card">
|
||||||
|
<div class="device-info">
|
||||||
|
<div class="device-name">${escapeHtml(dev.bus_id)}</div>
|
||||||
|
<div class="device-details">
|
||||||
|
<span>Von: ${escapeHtml(dev.client_name || dev.client_id)}</span>
|
||||||
|
<span>VHCI Port: ${dev.vhci_port}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-status">
|
||||||
|
<span class="badge attached">Verbunden</span>
|
||||||
|
<button class="btn small danger" onclick="detachDevice('${dev.client_id}', '${dev.bus_id}')">Trennen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group available by client
|
||||||
|
const byClient = {};
|
||||||
|
(available || []).forEach(dev => {
|
||||||
|
const key = dev.client_id;
|
||||||
|
if (!byClient[key]) {
|
||||||
|
byClient[key] = { name: dev.client_name, devices: [] };
|
||||||
|
}
|
||||||
|
byClient[key].devices.push(dev);
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [clientId, info] of Object.entries(byClient)) {
|
||||||
|
html += `<div class="client-header">${escapeHtml(info.name)} (${clientId.substring(0, 8)}...)</div>`;
|
||||||
|
html += info.devices.map(dev => {
|
||||||
|
const isAttached = (attached || []).some(a =>
|
||||||
|
a.bus_id === dev.bus_id && a.client_id === clientId
|
||||||
|
);
|
||||||
|
return `
|
||||||
|
<div class="device-card">
|
||||||
|
<div class="device-info">
|
||||||
|
<div class="device-name">${escapeHtml(dev.name)}</div>
|
||||||
|
<div class="device-details">
|
||||||
|
<span>Bus: ${dev.bus_id}</span>
|
||||||
|
<span>VID:PID: ${dev.vendor_id}:${dev.product_id}</span>
|
||||||
|
<span>Speed: ${speedName(dev.speed)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-status">
|
||||||
|
${dev.status === 'in_use'
|
||||||
|
? '<span class="badge in-use">In Benutzung</span>'
|
||||||
|
: isAttached
|
||||||
|
? '<span class="badge attached">Verbunden</span>'
|
||||||
|
: `<span class="badge available">Verfuegbar</span>
|
||||||
|
<button class="btn small primary" onclick="attachDevice('${clientId}', '${dev.bus_id}')">Verbinden</button>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html || '<p class="no-devices">Keine Geraete verfuegbar</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach/Detach
|
||||||
|
async function attachDevice(clientId, busId) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/attach', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ client_id: clientId, bus_id: busId })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannt'));
|
||||||
|
}
|
||||||
|
updateDevices();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Verbindungsfehler: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detachDevice(clientId, busId) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/detach', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ client_id: clientId, bus_id: busId })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannt'));
|
||||||
|
}
|
||||||
|
updateDevices();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Verbindungsfehler: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/config');
|
||||||
|
const cfg = await resp.json();
|
||||||
|
document.getElementById('relay-addr').value = cfg.relay_addr || '';
|
||||||
|
document.getElementById('hash').value = cfg.hash || '';
|
||||||
|
document.getElementById('mode').value = cfg.mode || 'use';
|
||||||
|
document.getElementById('client-name').value = cfg.name || '';
|
||||||
|
document.getElementById('web-port').value = cfg.web_port || 8080;
|
||||||
|
document.getElementById('token1').value = cfg.token1 || '';
|
||||||
|
document.getElementById('token2').value = cfg.token2 || '';
|
||||||
|
document.getElementById('token3').value = cfg.token3 || '';
|
||||||
|
if (cfg.hash) {
|
||||||
|
document.getElementById('computed-hash').textContent = cfg.hash;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load settings:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('settings-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
relay_addr: document.getElementById('relay-addr').value,
|
||||||
|
mode: document.getElementById('mode').value,
|
||||||
|
name: document.getElementById('client-name').value,
|
||||||
|
web_port: parseInt(document.getElementById('web-port').value),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.ok) {
|
||||||
|
alert('Einstellungen gespeichert. Neustart erforderlich fuer Aenderungen.');
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannt'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fehler: ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token generation
|
||||||
|
document.getElementById('generate-tokens').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/generate-token', { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
document.getElementById('token1').value = data.token1;
|
||||||
|
document.getElementById('token2').value = data.token2;
|
||||||
|
document.getElementById('token3').value = data.token3;
|
||||||
|
document.getElementById('computed-hash').textContent = data.hash;
|
||||||
|
document.getElementById('hash').value = data.hash;
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fehler: ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('apply-tokens').addEventListener('click', async () => {
|
||||||
|
const token1 = document.getElementById('token1').value;
|
||||||
|
const token2 = document.getElementById('token2').value;
|
||||||
|
const token3 = document.getElementById('token3').value;
|
||||||
|
|
||||||
|
if (!token1 || !token2 || !token3) {
|
||||||
|
alert('Bitte alle 3 Tokens eingeben');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/apply-tokens', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token1, token2, token3 })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.ok) {
|
||||||
|
document.getElementById('computed-hash').textContent = data.hash;
|
||||||
|
document.getElementById('hash').value = data.hash;
|
||||||
|
alert('Tokens angewandt. Hash: ' + data.hash.substring(0, 16) + '...');
|
||||||
|
} else {
|
||||||
|
alert('Fehler: ' + (data.error || 'Unbekannt'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fehler: ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Service management
|
||||||
|
document.getElementById('install-service').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/service/install', { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
document.getElementById('service-status').textContent = data.message || data.error;
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('service-status').textContent = 'Fehler: ' + e.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('uninstall-service').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/service/uninstall', { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
document.getElementById('service-status').textContent = data.message || data.error;
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('service-status').textContent = 'Fehler: ' + e.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
function speedName(speed) {
|
||||||
|
const names = { 1: 'Low (1.5M)', 2: 'Full (12M)', 3: 'High (480M)', 5: 'Super (5G)', 6: 'Super+ (10G)' };
|
||||||
|
return names[speed] || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init
|
||||||
|
loadSettings();
|
||||||
|
updateStatus();
|
||||||
|
updateDevices();
|
||||||
|
|
||||||
|
// Periodic updates
|
||||||
|
setInterval(updateStatus, 5000);
|
||||||
|
setInterval(updateDevices, 3000);
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>USB Server</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>USB Server</h1>
|
||||||
|
<div class="status" id="status">Nicht verbunden</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<button class="tab active" data-tab="devices">Geraete</button>
|
||||||
|
<button class="tab" data-tab="settings">Einstellungen</button>
|
||||||
|
<button class="tab" data-tab="token">Token</button>
|
||||||
|
<button class="tab" data-tab="service">Service</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Devices Tab -->
|
||||||
|
<section id="tab-devices" class="tab-content active">
|
||||||
|
<div id="mode-info" class="info-box"></div>
|
||||||
|
<div id="device-list" class="device-list">
|
||||||
|
<p class="loading">Lade Geraete...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Settings Tab -->
|
||||||
|
<section id="tab-settings" class="tab-content">
|
||||||
|
<form id="settings-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="relay-addr">Relay Server Adresse</label>
|
||||||
|
<input type="text" id="relay-addr" placeholder="ws://localhost:8443">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="hash">Hash</label>
|
||||||
|
<input type="text" id="hash" placeholder="SHA256 Hash" readonly>
|
||||||
|
<small>Wird aus den 3 Tokens berechnet</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mode">Modus</label>
|
||||||
|
<select id="mode">
|
||||||
|
<option value="share">Freigeben (Share)</option>
|
||||||
|
<option value="use">Empfangen (Use)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="client-name">Client Name</label>
|
||||||
|
<input type="text" id="client-name" placeholder="Mein PC">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="web-port">Web-UI Port</label>
|
||||||
|
<input type="number" id="web-port" value="8080">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn primary">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Token Tab -->
|
||||||
|
<section id="tab-token" class="tab-content">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="token1">Token 1</label>
|
||||||
|
<input type="text" id="token1" placeholder="Token 1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="token2">Token 2</label>
|
||||||
|
<input type="text" id="token2" placeholder="Token 2">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="token3">Token 3</label>
|
||||||
|
<input type="text" id="token3" placeholder="Token 3">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Berechneter Hash</label>
|
||||||
|
<div id="computed-hash" class="hash-display">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<button id="generate-tokens" class="btn primary">Neue Tokens Generieren</button>
|
||||||
|
<button id="apply-tokens" class="btn">Tokens Anwenden</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Service Tab -->
|
||||||
|
<section id="tab-service" class="tab-content">
|
||||||
|
<div class="info-box">
|
||||||
|
<p>USB Client als Systemdienst installieren fuer automatischen Start.</p>
|
||||||
|
</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<button id="install-service" class="btn primary">Service Installieren</button>
|
||||||
|
<button id="uninstall-service" class="btn danger">Service Deinstallieren</button>
|
||||||
|
</div>
|
||||||
|
<div id="service-status" class="info-box" style="margin-top: 1rem;"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,281 @@
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #00d4ff;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.connected {
|
||||||
|
background: rgba(0, 200, 80, 0.2);
|
||||||
|
color: #00c850;
|
||||||
|
border: 1px solid #00c850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.disconnected {
|
||||||
|
background: rgba(255, 80, 80, 0.2);
|
||||||
|
color: #ff5050;
|
||||||
|
border: 1px solid #ff5050;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 0.5rem 1.2rem;
|
||||||
|
background: #16213e;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: #ccc;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: #0f3460;
|
||||||
|
color: #00d4ff;
|
||||||
|
border-color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card {
|
||||||
|
background: #16213e;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card:hover {
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-details {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-details span {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.available {
|
||||||
|
background: rgba(0, 200, 80, 0.15);
|
||||||
|
color: #00c850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.in-use {
|
||||||
|
background: rgba(255, 165, 0, 0.15);
|
||||||
|
color: #ffa500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.attached {
|
||||||
|
background: rgba(0, 212, 255, 0.15);
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1.2rem;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #16213e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: #1a2a4e;
|
||||||
|
border-color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: #0f3460;
|
||||||
|
border-color: #00d4ff;
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:hover {
|
||||||
|
background: #134080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger {
|
||||||
|
border-color: #ff5050;
|
||||||
|
color: #ff5050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger:hover {
|
||||||
|
background: #3a1010;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.small {
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
background: #0d1b2a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group small {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background: #16213e;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-display {
|
||||||
|
background: #0d1b2a;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-header {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #00d4ff;
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
padding-bottom: 0.3rem;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-devices {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue