first commit

This commit is contained in:
Stefan Hacker 2026-02-18 22:01:54 +01:00
commit 5464e553b3
35 changed files with 5432 additions and 0 deletions

20
Dockerfile Normal file
View File

@ -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"]

24
LICENSE Normal file
View File

@ -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.

24
Makefile Normal file
View File

@ -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/

97
NOTICE Normal file
View File

@ -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.

232
README.md Normal file
View File

@ -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.

436
cmd/usb-client/main.go Normal file
View File

@ -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] + "..."
}

34
cmd/usb-relay/main.go Normal file
View File

@ -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)
}
}

11
docker-compose.yml Normal file
View File

@ -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

10
go.mod Normal file
View File

@ -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
)

6
go.sum Normal file
View File

@ -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=

315
internal/client/client.go Normal file
View File

@ -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)
}
}

358
internal/client/share.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

368
internal/client/use.go Normal file
View File

@ -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)
}

90
internal/config/config.go Normal file
View File

@ -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
}

View File

@ -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

336
internal/relay/hub.go Normal file
View File

@ -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)
}
}
}

138
internal/relay/server.go Normal file
View File

@ -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, &reg); 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)
}
}
}

View File

@ -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
}

View File

@ -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
}

60
internal/token/token.go Normal file
View File

@ -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
}

53
internal/usb/device.go Normal file
View File

@ -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"
}

View File

@ -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
}
}

View File

@ -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")
}

306
internal/usb/usbdevfs.go Normal file
View File

@ -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(&params.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)
}

View File

@ -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") }

374
internal/usbip/protocol.go Normal file
View File

@ -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)
}
}

460
internal/usbip/server.go Normal file
View File

@ -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)
}
}

168
internal/usbip/vhci.go Normal file
View File

@ -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
}

292
internal/web/handler.go Normal file
View File

@ -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)
}
}

322
internal/web/static/app.js Normal file
View File

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Init
loadSettings();
updateStatus();
updateDevices();
// Periodic updates
setInterval(updateStatus, 5000);
setInterval(updateDevices, 3000);

View File

@ -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>

View File

@ -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;
}

BIN
usb-relay Executable file

Binary file not shown.