commit 5464e553b30bd050c7e7b68895eabd58be78d6f3 Author: Stefan Hacker Date: Wed Feb 18 22:01:54 2026 +0100 first commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8c6d9f2 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8703d7a --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0edcb2c --- /dev/null +++ b/Makefile @@ -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/ diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..0fbb0b8 --- /dev/null +++ b/NOTICE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc5da4e --- /dev/null +++ b/README.md @@ -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 Config-Datei (Standard: ~/.usb-server/config.json) +--relay Relay-Server (z.B. ws://localhost:8443) +--hash Gruppen-Hash +--name Client-Name +--web-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. diff --git a/cmd/usb-client/main.go b/cmd/usb-client/main.go new file mode 100644 index 0000000..fdcaf13 --- /dev/null +++ b/cmd/usb-client/main.go @@ -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 [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 Config file path (default: ~/.usb-server/config.json) + --relay Relay server address (e.g. ws://localhost:8443) + --hash Group hash + --name Client name + --web-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] + "..." +} diff --git a/cmd/usb-relay/main.go b/cmd/usb-relay/main.go new file mode 100644 index 0000000..592a720 --- /dev/null +++ b/cmd/usb-relay/main.go @@ -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) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1f52d00 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e452a46 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9b6513e --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..e74ba91 --- /dev/null +++ b/internal/client/client.go @@ -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) + } +} diff --git a/internal/client/share.go b/internal/client/share.go new file mode 100644 index 0000000..b38819d --- /dev/null +++ b/internal/client/share.go @@ -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 +} + diff --git a/internal/client/socket_linux.go b/internal/client/socket_linux.go new file mode 100644 index 0000000..5807ab5 --- /dev/null +++ b/internal/client/socket_linux.go @@ -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) +} diff --git a/internal/client/socket_windows.go b/internal/client/socket_windows.go new file mode 100644 index 0000000..2d2cef4 --- /dev/null +++ b/internal/client/socket_windows.go @@ -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 +} diff --git a/internal/client/use.go b/internal/client/use.go new file mode 100644 index 0000000..1f5ca6b --- /dev/null +++ b/internal/client/use.go @@ -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) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4f051fd --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/protocol/messages.go b/internal/protocol/messages.go new file mode 100644 index 0000000..b9c3014 --- /dev/null +++ b/internal/protocol/messages.go @@ -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 diff --git a/internal/relay/hub.go b/internal/relay/hub.go new file mode 100644 index 0000000..bd0a07c --- /dev/null +++ b/internal/relay/hub.go @@ -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) + } + } +} diff --git a/internal/relay/server.go b/internal/relay/server.go new file mode 100644 index 0000000..d898a8d --- /dev/null +++ b/internal/relay/server.go @@ -0,0 +1,138 @@ +package relay + +import ( + "encoding/json" + "log" + "net/http" + "time" + + "github.com/duffy/usb-server/internal/protocol" + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 64 * 1024, + WriteBufferSize: 64 * 1024, + CheckOrigin: func(r *http.Request) bool { + return true // relay accepts all origins + }, +} + +// Server is the WebSocket relay server +type Server struct { + hub *Hub + addr string +} + +// NewServer creates a new relay server +func NewServer(addr string) *Server { + return &Server{ + hub: NewHub(), + addr: addr, + } +} + +// Run starts the relay server +func (s *Server) Run() error { + mux := http.NewServeMux() + mux.HandleFunc("/ws", s.handleWebSocket) + mux.HandleFunc("/health", s.handleHealth) + + log.Printf("[relay] starting on %s", s.addr) + return http.ListenAndServe(s.addr, mux) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("[relay] upgrade error: %v", err) + return + } + defer conn.Close() + + // Set read limits and deadlines + conn.SetReadLimit(1024 * 1024) // 1MB max message + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + + // Wait for registration message + _, msgData, err := conn.ReadMessage() + if err != nil { + log.Printf("[relay] read error during registration: %v", err) + return + } + + var reg protocol.Register + if err := json.Unmarshal(msgData, ®); err != nil || reg.Type != protocol.MsgRegister { + log.Printf("[relay] invalid registration message") + conn.WriteJSON(&protocol.ErrorMsg{Type: protocol.MsgError, Message: "invalid registration"}) + return + } + + if reg.Hash == "" || reg.ClientID == "" || (reg.Mode != protocol.ModeShare && reg.Mode != protocol.ModeUse) { + conn.WriteJSON(&protocol.ErrorMsg{Type: protocol.MsgError, Message: "missing required fields"}) + return + } + + client := &Client{ + ID: reg.ClientID, + Hash: reg.Hash, + Mode: reg.Mode, + Name: reg.Name, + Conn: conn, + Send: make(chan []byte, 256), + } + + s.hub.Register(client) + defer s.hub.Unregister(client) + + // Start ping ticker + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + client.mu.Lock() + err := conn.WriteMessage(websocket.PingMessage, nil) + client.mu.Unlock() + if err != nil { + return + } + case <-done: + return + } + } + }() + defer close(done) + + // Read loop + for { + msgType, data, err := conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + log.Printf("[relay] read error from %s: %v", client.ID, err) + } + break + } + + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + + switch msgType { + case websocket.TextMessage: + s.hub.HandleTextMessage(client, data) + case websocket.BinaryMessage: + s.hub.HandleBinaryMessage(client, data) + } + } +} diff --git a/internal/service/service_linux.go b/internal/service/service_linux.go new file mode 100644 index 0000000..aa7ab06 --- /dev/null +++ b/internal/service/service_linux.go @@ -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 +} diff --git a/internal/service/service_windows.go b/internal/service/service_windows.go new file mode 100644 index 0000000..7d7acb0 --- /dev/null +++ b/internal/service/service_windows.go @@ -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 +} diff --git a/internal/token/token.go b/internal/token/token.go new file mode 100644 index 0000000..30a824d --- /dev/null +++ b/internal/token/token.go @@ -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 +} diff --git a/internal/usb/device.go b/internal/usb/device.go new file mode 100644 index 0000000..4124bb9 --- /dev/null +++ b/internal/usb/device.go @@ -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" +} diff --git a/internal/usb/enumerate_linux.go b/internal/usb/enumerate_linux.go new file mode 100644 index 0000000..b296f85 --- /dev/null +++ b/internal/usb/enumerate_linux.go @@ -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 + } +} diff --git a/internal/usb/enumerate_windows.go b/internal/usb/enumerate_windows.go new file mode 100644 index 0000000..909e377 --- /dev/null +++ b/internal/usb/enumerate_windows.go @@ -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") +} diff --git a/internal/usb/usbdevfs.go b/internal/usb/usbdevfs.go new file mode 100644 index 0000000..7234fca --- /dev/null +++ b/internal/usb/usbdevfs.go @@ -0,0 +1,306 @@ +//go:build linux + +package usb + +import ( + "fmt" + "os" + "unsafe" + + "golang.org/x/sys/unix" +) + +// ioctl direction constants +const ( + iocNone = 0 + iocWrite = 1 + iocRead = 2 +) + +// ioctl encoding helpers +func ioc(dir, typ, nr, size uintptr) uintptr { + return (dir << 30) | (size << 16) | (typ << 8) | nr +} + +func ior(typ, nr, size uintptr) uintptr { return ioc(iocRead, typ, nr, size) } +func iow(typ, nr, size uintptr) uintptr { return ioc(iocWrite, typ, nr, size) } +func iowr(typ, nr, size uintptr) uintptr { return ioc(iocRead|iocWrite, typ, nr, size) } +func io_(typ, nr uintptr) uintptr { return ioc(iocNone, typ, nr, 0) } + +// USB device file system ioctl numbers +var ( + usbdevfsControl = iowr('U', 0, unsafe.Sizeof(usbdevfsCtrlTransfer{})) + usbdevfsBulk = iowr('U', 2, unsafe.Sizeof(usbdevfsBulkTransfer{})) + usbdevfsSetInterface = ior('U', 4, unsafe.Sizeof(usbdevfsSetIntf{})) + usbdevfsSetConfig = ior('U', 5, 4) + usbdevfsSubmitURB = ior('U', 10, unsafe.Sizeof(usbdevfsURB{})) + usbdevfsDiscardURB = io_('U', 11) + usbdevfsReapURB = iow('U', 12, unsafe.Sizeof(uintptr(0))) + usbdevfsReapURBNDelay = iow('U', 13, unsafe.Sizeof(uintptr(0))) + usbdevfsClaimInterface = ior('U', 15, 4) + usbdevfsReleaseInterface = ior('U', 16, 4) + usbdevfsReset = io_('U', 20) + usbdevfsClearHalt = ior('U', 21, 4) + usbdevfsDisconnect = io_('U', 22) + usbdevfsConnect = io_('U', 23) + usbdevfsGetCapabilities = ior('U', 26, 4) + usbdevfsGetSpeed = io_('U', 31) +) + +// URB type constants +const ( + urbTypeISO = 0 + urbTypeInterrupt = 1 + urbTypeControl = 2 + urbTypeBulk = 3 +) + +// usbdevfs structures for ioctls + +type usbdevfsCtrlTransfer struct { + RequestType uint8 + Request uint8 + Value uint16 + Index uint16 + Length uint16 + Timeout uint32 + Data uintptr +} + +type usbdevfsBulkTransfer struct { + Endpoint uint32 + Length uint32 + Timeout uint32 + Data uintptr +} + +type usbdevfsSetIntf struct { + Interface uint32 + AltSetting uint32 +} + +type usbdevfsISOPacketDesc struct { + Length uint32 + ActualLength uint32 + Status uint32 +} + +type usbdevfsURB struct { + Type uint8 + Endpoint uint8 + Status int32 + Flags uint32 + Buffer uintptr + BufferLength int32 + ActualLength int32 + StartFrame int32 + NumberOfPackets int32 // or StreamID + ErrorCount int32 + Signr uint32 + UserContext uintptr + // ISO packet descriptors follow in memory if Type == urbTypeISO +} + +// DeviceHandle provides low-level USB device access via usbdevfs +type DeviceHandle struct { + fd int + busID string + devPath string +} + +// OpenDevice opens a USB device file for direct access +func OpenDevice(devPath string, busID string) (*DeviceHandle, error) { + fd, err := unix.Open(devPath, unix.O_RDWR, 0) + if err != nil { + return nil, fmt.Errorf("opening %s: %w", devPath, err) + } + + return &DeviceHandle{ + fd: fd, + busID: busID, + devPath: devPath, + }, nil +} + +// Close closes the device handle +func (h *DeviceHandle) Close() error { + return unix.Close(h.fd) +} + +// Fd returns the file descriptor +func (h *DeviceHandle) Fd() int { + return h.fd +} + +// DisconnectDriver disconnects the kernel driver from the device +func (h *DeviceHandle) DisconnectDriver() error { + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsDisconnect, 0) + if errno != 0 { + return fmt.Errorf("USBDEVFS_DISCONNECT: %w", errno) + } + return nil +} + +// ConnectDriver reconnects the kernel driver +func (h *DeviceHandle) ConnectDriver() error { + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsConnect, 0) + if errno != 0 { + return fmt.Errorf("USBDEVFS_CONNECT: %w", errno) + } + return nil +} + +// ClaimInterface claims exclusive access to a USB interface +func (h *DeviceHandle) ClaimInterface(ifnum uint32) error { + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsClaimInterface, uintptr(unsafe.Pointer(&ifnum))) + if errno != 0 { + return fmt.Errorf("USBDEVFS_CLAIMINTERFACE(%d): %w", ifnum, errno) + } + return nil +} + +// ReleaseInterface releases a claimed interface +func (h *DeviceHandle) ReleaseInterface(ifnum uint32) error { + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsReleaseInterface, uintptr(unsafe.Pointer(&ifnum))) + if errno != 0 { + return fmt.Errorf("USBDEVFS_RELEASEINTERFACE(%d): %w", ifnum, errno) + } + return nil +} + +// SetConfiguration sets the device configuration +func (h *DeviceHandle) SetConfiguration(config uint32) error { + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsSetConfig, uintptr(unsafe.Pointer(&config))) + if errno != 0 { + return fmt.Errorf("USBDEVFS_SETCONFIGURATION(%d): %w", config, errno) + } + return nil +} + +// SetInterface sets alternate setting for an interface +func (h *DeviceHandle) SetInterface(iface, altSetting uint32) error { + si := usbdevfsSetIntf{Interface: iface, AltSetting: altSetting} + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsSetInterface, uintptr(unsafe.Pointer(&si))) + if errno != 0 { + return fmt.Errorf("USBDEVFS_SETINTERFACE(%d, %d): %w", iface, altSetting, errno) + } + return nil +} + +// ClearHalt clears endpoint halt/stall condition +func (h *DeviceHandle) ClearHalt(endpoint uint32) error { + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsClearHalt, uintptr(unsafe.Pointer(&endpoint))) + if errno != 0 { + return fmt.Errorf("USBDEVFS_CLEAR_HALT(%d): %w", endpoint, errno) + } + return nil +} + +// ResetDevice resets the USB device +func (h *DeviceHandle) ResetDevice() error { + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsReset, 0) + if errno != 0 { + return fmt.Errorf("USBDEVFS_RESET: %w", errno) + } + return nil +} + +// GetSpeed returns the device speed +func (h *DeviceHandle) GetSpeed() (uint32, error) { + r, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsGetSpeed, 0) + if errno != 0 { + return 0, fmt.Errorf("USBDEVFS_GET_SPEED: %w", errno) + } + return uint32(r), nil +} + +// ControlTransfer performs a synchronous control transfer +func (h *DeviceHandle) ControlTransfer(requestType, request uint8, value, index, length uint16, timeout uint32, data []byte) (int, error) { + var dataPtr uintptr + if len(data) > 0 { + dataPtr = uintptr(unsafe.Pointer(&data[0])) + } + + ct := usbdevfsCtrlTransfer{ + RequestType: requestType, + Request: request, + Value: value, + Index: index, + Length: length, + Timeout: timeout, + Data: dataPtr, + } + + r, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsControl, uintptr(unsafe.Pointer(&ct))) + if errno != 0 { + return 0, fmt.Errorf("USBDEVFS_CONTROL: %w", errno) + } + return int(r), nil +} + +// SubmitURBParams holds parameters for async URB submission +type SubmitURBParams struct { + Type uint8 + Endpoint uint8 + Flags uint32 + Buffer []byte + UserContext uintptr +} + +// SubmitURB submits an asynchronous URB +func (h *DeviceHandle) SubmitURB(params *SubmitURBParams) (*usbdevfsURB, error) { + var bufPtr uintptr + if len(params.Buffer) > 0 { + bufPtr = uintptr(unsafe.Pointer(¶ms.Buffer[0])) + } + + urb := &usbdevfsURB{ + Type: params.Type, + Endpoint: params.Endpoint, + Flags: params.Flags, + Buffer: bufPtr, + BufferLength: int32(len(params.Buffer)), + NumberOfPackets: -1, // 0xFFFFFFFF for non-ISO + UserContext: params.UserContext, + } + + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsSubmitURB, uintptr(unsafe.Pointer(urb))) + if errno != 0 { + return nil, fmt.Errorf("USBDEVFS_SUBMITURB: %w", errno) + } + return urb, nil +} + +// ReapURB blocks until a URB completes, then returns it +func (h *DeviceHandle) ReapURB() (*usbdevfsURB, error) { + var urbPtr uintptr + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsReapURB, uintptr(unsafe.Pointer(&urbPtr))) + if errno != 0 { + return nil, fmt.Errorf("USBDEVFS_REAPURB: %w", errno) + } + return (*usbdevfsURB)(unsafe.Pointer(urbPtr)), nil +} + +// ReapURBNonBlock tries to reap a URB without blocking +func (h *DeviceHandle) ReapURBNonBlock() (*usbdevfsURB, error) { + var urbPtr uintptr + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsReapURBNDelay, uintptr(unsafe.Pointer(&urbPtr))) + if errno != 0 { + return nil, fmt.Errorf("USBDEVFS_REAPURBNDELAY: %w", errno) + } + return (*usbdevfsURB)(unsafe.Pointer(urbPtr)), nil +} + +// DiscardURB cancels a submitted URB +func (h *DeviceHandle) DiscardURB(urb *usbdevfsURB) error { + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(h.fd), usbdevfsDiscardURB, uintptr(unsafe.Pointer(urb))) + if errno != 0 { + return fmt.Errorf("USBDEVFS_DISCARDURB: %w", errno) + } + return nil +} + +// GetFile returns an os.File wrapping the device fd (useful for epoll/select) +func (h *DeviceHandle) GetFile() *os.File { + return os.NewFile(uintptr(h.fd), h.devPath) +} diff --git a/internal/usb/usbdevfs_windows.go b/internal/usb/usbdevfs_windows.go new file mode 100644 index 0000000..caf5998 --- /dev/null +++ b/internal/usb/usbdevfs_windows.go @@ -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") } diff --git a/internal/usbip/protocol.go b/internal/usbip/protocol.go new file mode 100644 index 0000000..791e217 --- /dev/null +++ b/internal/usbip/protocol.go @@ -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) + } +} diff --git a/internal/usbip/server.go b/internal/usbip/server.go new file mode 100644 index 0000000..a30664c --- /dev/null +++ b/internal/usbip/server.go @@ -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) + } +} diff --git a/internal/usbip/vhci.go b/internal/usbip/vhci.go new file mode 100644 index 0000000..dcf80b9 --- /dev/null +++ b/internal/usbip/vhci.go @@ -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: " " + 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 +} diff --git a/internal/web/handler.go b/internal/web/handler.go new file mode 100644 index 0000000..b2a9f8e --- /dev/null +++ b/internal/web/handler.go @@ -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) + } +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js new file mode 100644 index 0000000..05a384a --- /dev/null +++ b/internal/web/static/app.js @@ -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 = + `Modus: ${data.mode === 'share' ? 'Freigeben' : 'Empfangen'} | ` + + `Name: ${data.name} | ` + + `Client ID: ${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 = + '

Fehler beim Laden der Geraete

'; + } +} + +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 = '

Keine USB-Geraete gefunden

'; + return; + } + + container.innerHTML = devices.map(dev => ` +
+
+
${escapeHtml(dev.name)}
+
+ Bus: ${dev.bus_id} + VID:PID: ${dev.vendor_id}:${dev.product_id} + Speed: ${speedName(dev.speed)} +
+
+
+ + ${dev.status === 'available' ? 'Verfuegbar' : 'In Benutzung'} + +
+
+ `).join(''); +} + +function renderUseDevices(container, available, attached) { + let html = ''; + + // Attached devices first + if (attached && attached.length > 0) { + html += '
Verbundene Geraete
'; + html += attached.map(dev => ` +
+
+
${escapeHtml(dev.bus_id)}
+
+ Von: ${escapeHtml(dev.client_name || dev.client_id)} + VHCI Port: ${dev.vhci_port} +
+
+
+ Verbunden + +
+
+ `).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 = '

Keine Geraete verfuegbar. Warte auf Share-Clients...

'; + return; + } + + for (const [clientId, info] of Object.entries(byClient)) { + html += `
${escapeHtml(info.name)} (${clientId.substring(0, 8)}...)
`; + html += info.devices.map(dev => { + const isAttached = (attached || []).some(a => + a.bus_id === dev.bus_id && a.client_id === clientId + ); + return ` +
+
+
${escapeHtml(dev.name)}
+
+ Bus: ${dev.bus_id} + VID:PID: ${dev.vendor_id}:${dev.product_id} + Speed: ${speedName(dev.speed)} +
+
+
+ ${dev.status === 'in_use' + ? 'In Benutzung' + : isAttached + ? 'Verbunden' + : `Verfuegbar + ` + } +
+
+ `; + }).join(''); + } + + container.innerHTML = html || '

Keine Geraete verfuegbar

'; +} + +// Attach/Detach +async function attachDevice(clientId, busId) { + try { + const resp = await fetch(API_BASE + '/api/attach', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: clientId, bus_id: busId }) + }); + const data = await resp.json(); + if (!data.ok) { + alert('Fehler: ' + (data.error || 'Unbekannt')); + } + updateDevices(); + } catch (e) { + alert('Verbindungsfehler: ' + e.message); + } +} + +async function detachDevice(clientId, busId) { + try { + const resp = await fetch(API_BASE + '/api/detach', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: clientId, bus_id: busId }) + }); + const data = await resp.json(); + if (!data.ok) { + alert('Fehler: ' + (data.error || 'Unbekannt')); + } + updateDevices(); + } catch (e) { + alert('Verbindungsfehler: ' + e.message); + } +} + +// Settings +async function loadSettings() { + try { + const resp = await fetch(API_BASE + '/api/config'); + const cfg = await resp.json(); + document.getElementById('relay-addr').value = cfg.relay_addr || ''; + document.getElementById('hash').value = cfg.hash || ''; + document.getElementById('mode').value = cfg.mode || 'use'; + document.getElementById('client-name').value = cfg.name || ''; + document.getElementById('web-port').value = cfg.web_port || 8080; + document.getElementById('token1').value = cfg.token1 || ''; + document.getElementById('token2').value = cfg.token2 || ''; + document.getElementById('token3').value = cfg.token3 || ''; + if (cfg.hash) { + document.getElementById('computed-hash').textContent = cfg.hash; + } + } catch (e) { + console.error('Failed to load settings:', e); + } +} + +document.getElementById('settings-form').addEventListener('submit', async (e) => { + e.preventDefault(); + try { + const resp = await fetch(API_BASE + '/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + relay_addr: document.getElementById('relay-addr').value, + mode: document.getElementById('mode').value, + name: document.getElementById('client-name').value, + web_port: parseInt(document.getElementById('web-port').value), + }) + }); + const data = await resp.json(); + if (data.ok) { + alert('Einstellungen gespeichert. Neustart erforderlich fuer Aenderungen.'); + } else { + alert('Fehler: ' + (data.error || 'Unbekannt')); + } + } catch (e) { + alert('Fehler: ' + e.message); + } +}); + +// Token generation +document.getElementById('generate-tokens').addEventListener('click', async () => { + try { + const resp = await fetch(API_BASE + '/api/generate-token', { method: 'POST' }); + const data = await resp.json(); + document.getElementById('token1').value = data.token1; + document.getElementById('token2').value = data.token2; + document.getElementById('token3').value = data.token3; + document.getElementById('computed-hash').textContent = data.hash; + document.getElementById('hash').value = data.hash; + } catch (e) { + alert('Fehler: ' + e.message); + } +}); + +document.getElementById('apply-tokens').addEventListener('click', async () => { + const token1 = document.getElementById('token1').value; + const token2 = document.getElementById('token2').value; + const token3 = document.getElementById('token3').value; + + if (!token1 || !token2 || !token3) { + alert('Bitte alle 3 Tokens eingeben'); + return; + } + + try { + const resp = await fetch(API_BASE + '/api/apply-tokens', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token1, token2, token3 }) + }); + const data = await resp.json(); + if (data.ok) { + document.getElementById('computed-hash').textContent = data.hash; + document.getElementById('hash').value = data.hash; + alert('Tokens angewandt. Hash: ' + data.hash.substring(0, 16) + '...'); + } else { + alert('Fehler: ' + (data.error || 'Unbekannt')); + } + } catch (e) { + alert('Fehler: ' + e.message); + } +}); + +// Service management +document.getElementById('install-service').addEventListener('click', async () => { + try { + const resp = await fetch(API_BASE + '/api/service/install', { method: 'POST' }); + const data = await resp.json(); + document.getElementById('service-status').textContent = data.message || data.error; + } catch (e) { + document.getElementById('service-status').textContent = 'Fehler: ' + e.message; + } +}); + +document.getElementById('uninstall-service').addEventListener('click', async () => { + try { + const resp = await fetch(API_BASE + '/api/service/uninstall', { method: 'POST' }); + const data = await resp.json(); + document.getElementById('service-status').textContent = data.message || data.error; + } catch (e) { + document.getElementById('service-status').textContent = 'Fehler: ' + e.message; + } +}); + +// Helpers +function speedName(speed) { + const names = { 1: 'Low (1.5M)', 2: 'Full (12M)', 3: 'High (480M)', 5: 'Super (5G)', 6: 'Super+ (10G)' }; + return names[speed] || 'Unknown'; +} + +function escapeHtml(str) { + if (!str) return ''; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// Init +loadSettings(); +updateStatus(); +updateDevices(); + +// Periodic updates +setInterval(updateStatus, 5000); +setInterval(updateDevices, 3000); diff --git a/internal/web/static/index.html b/internal/web/static/index.html new file mode 100644 index 0000000..9056d9f --- /dev/null +++ b/internal/web/static/index.html @@ -0,0 +1,101 @@ + + + + + + USB Server + + + +
+
+

USB Server

+
Nicht verbunden
+
+ + + + +
+
+
+

Lade Geraete...

+
+
+ + +
+
+
+ + +
+
+ + + Wird aus den 3 Tokens berechnet +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
-
+
+
+ + +
+
+ + +
+
+

USB Client als Systemdienst installieren fuer automatischen Start.

+
+
+ + +
+
+
+
+ + + + diff --git a/internal/web/static/style.css b/internal/web/static/style.css new file mode 100644 index 0000000..4a347d8 --- /dev/null +++ b/internal/web/static/style.css @@ -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; +} diff --git a/usb-relay b/usb-relay new file mode 100755 index 0000000..1955718 Binary files /dev/null and b/usb-relay differ