diff --git a/bin/usb-client b/bin/usb-client index 75f121f..cb73d07 100755 Binary files a/bin/usb-client and b/bin/usb-client differ diff --git a/bin/usb-client.exe b/bin/usb-client.exe index fc5c65f..6de8db5 100755 Binary files a/bin/usb-client.exe and b/bin/usb-client.exe differ diff --git a/bin/usb-relay b/bin/usb-relay index 0b65661..fbfeff8 100755 Binary files a/bin/usb-relay and b/bin/usb-relay differ diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..57ca18e --- /dev/null +++ b/build.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +export PATH="$PATH:/usr/local/go/bin" + +OUT="bin" +LDFLAGS="-s -w" + +mkdir -p "$OUT" + +echo "=== Building Linux Client ===" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$OUT/usb-client" ./cmd/usb-client/ +echo " -> $OUT/usb-client" + +echo "=== Building Windows Client ===" +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$OUT/usb-client.exe" ./cmd/usb-client/ +echo " -> $OUT/usb-client.exe" + +echo "=== Building Relay ===" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$OUT/usb-relay" ./cmd/usb-relay/ +echo " -> $OUT/usb-relay" + +echo "" +echo "Done. Binaries in $OUT/" +ls -lh "$OUT/" diff --git a/cmd/usb-client/main.go b/cmd/usb-client/main.go index fdcaf13..f822e15 100644 --- a/cmd/usb-client/main.go +++ b/cmd/usb-client/main.go @@ -209,7 +209,7 @@ func cmdRun(mode string) { }() go sm.Run() } else { - um := client.NewUseManager(c) + um := client.NewUseManager(c, cfg, cfgPath) webHandler.GetDevices = func() interface{} { available := um.GetAvailableDevices() attached := um.GetAttachedDevices() @@ -231,11 +231,15 @@ func cmdRun(mode string) { 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, + "bus_id": d.BusID, + "vendor_id": d.VendorID, + "product_id": d.ProductID, + "name": d.Name, + "client_id": d.ClientID, + "client_name": d.ClientName, + "tunnel_id": d.TunnelID, + "vhci_port": d.VHCIPort, + "auto_connect": um.IsAutoConnect(d.VendorID, d.ProductID), }) } @@ -247,6 +251,8 @@ func cmdRun(mode string) { } webHandler.AttachDevice = um.AttachDevice webHandler.DetachDevice = um.DetachDevice + webHandler.SetAutoConnect = um.SetAutoConnect + webHandler.IsAutoConnect = um.IsAutoConnect webHandler.GetStatus = func() map[string]interface{} { return map[string]interface{}{ "connected": true, @@ -278,7 +284,7 @@ func cmdRun(mode string) { sm := client.NewShareManager(c) go sm.Run() } else { - client.NewUseManager(c) + client.NewUseManager(c, cfg, cfgPath) } go func() { if err := c.Run(); err != nil { diff --git a/internal/client/use.go b/internal/client/use.go index 567d50b..3411486 100644 --- a/internal/client/use.go +++ b/internal/client/use.go @@ -4,8 +4,10 @@ import ( "fmt" "log" "net" + "strings" "sync" + "github.com/duffy/usb-server/internal/config" "github.com/duffy/usb-server/internal/protocol" "github.com/duffy/usb-server/internal/usbip" "github.com/google/uuid" @@ -28,6 +30,8 @@ type AttachedDevice struct { // UseManager handles receiving/using remote USB devices type UseManager struct { client *Client + cfg *config.Config + cfgPath string mu sync.RWMutex available map[string][]RemoteDevice // clientID -> devices attached map[string]*AttachedDevice // busID@clientID -> attached info @@ -44,9 +48,11 @@ type useTunnel struct { } // NewUseManager creates a use manager -func NewUseManager(client *Client) *UseManager { +func NewUseManager(client *Client, cfg *config.Config, cfgPath string) *UseManager { um := &UseManager{ client: client, + cfg: cfg, + cfgPath: cfgPath, available: make(map[string][]RemoteDevice), attached: make(map[string]*AttachedDevice), tunnels: make(map[string]*useTunnel), @@ -210,15 +216,20 @@ func (um *UseManager) setupVHCI(clientID, busID string, granted *protocol.Device } key := busID + "@" + clientID + remDev := RemoteDevice{ + USBDevice: protocol.USBDevice{BusID: busID}, + ClientID: clientID, + } + if devInfo != nil { + remDev = *devInfo + } + 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: vhciPort, + RemoteDevice: remDev, + TunnelID: granted.TunnelID, + VHCIPort: vhciPort, } um.mu.Unlock() @@ -273,10 +284,102 @@ func (um *UseManager) handleDeviceList(msg *protocol.DeviceList) { }) } um.available[msg.ClientID] = remoteDevs + + // Collect devices to auto-connect (while holding the lock to check attached map) + var toAutoConnect []RemoteDevice + for _, dev := range remoteDevs { + if dev.Status != protocol.StatusAvailable { + continue + } + key := dev.BusID + "@" + msg.ClientID + if _, attached := um.attached[key]; attached { + continue + } + if um.matchesAutoConnect(dev) { + toAutoConnect = append(toAutoConnect, dev) + } + } um.mu.Unlock() log.Printf("[use] received device list from %s (%s): %d devices", msg.ClientName, msg.ClientID[:8], len(msg.Devices)) + + // Auto-connect matching devices (outside lock, each in its own goroutine) + for _, dev := range toAutoConnect { + log.Printf("[use] auto-connecting %s (%s:%s) from %s", + dev.Name, dev.VendorID, dev.ProductID, msg.ClientName) + go um.AttachDevice(msg.ClientID, dev.BusID) + } +} + +// matchesAutoConnect checks if a device matches any auto-connect rule. +// Must be called with um.mu held (at least RLock). +func (um *UseManager) matchesAutoConnect(dev RemoteDevice) bool { + for _, rule := range um.cfg.AutoConnect { + if rule.VendorID != "" && !strings.EqualFold(rule.VendorID, dev.VendorID) { + continue + } + if rule.ProductID != "" && !strings.EqualFold(rule.ProductID, dev.ProductID) { + continue + } + if rule.BusID != "" && rule.BusID != dev.BusID { + continue + } + if rule.ClientName != "" && rule.ClientName != dev.ClientName { + continue + } + return true // all specified fields match + } + return false +} + +// SetAutoConnect adds or removes an auto-connect rule for a VendorID:ProductID pair. +func (um *UseManager) SetAutoConnect(vendorID, productID string, enabled bool) error { + um.mu.Lock() + defer um.mu.Unlock() + + if enabled { + // Check if rule already exists + for _, rule := range um.cfg.AutoConnect { + if strings.EqualFold(rule.VendorID, vendorID) && strings.EqualFold(rule.ProductID, productID) { + return nil // already exists + } + } + um.cfg.AutoConnect = append(um.cfg.AutoConnect, config.AutoConnectRule{ + VendorID: vendorID, + ProductID: productID, + }) + } else { + // Remove matching rule + filtered := um.cfg.AutoConnect[:0] + for _, rule := range um.cfg.AutoConnect { + if strings.EqualFold(rule.VendorID, vendorID) && strings.EqualFold(rule.ProductID, productID) { + continue + } + filtered = append(filtered, rule) + } + um.cfg.AutoConnect = filtered + } + + if err := um.cfg.Save(um.cfgPath); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + log.Printf("[use] auto-connect %s:%s = %v", vendorID, productID, enabled) + return nil +} + +// IsAutoConnect checks if there is an auto-connect rule for this VendorID:ProductID. +func (um *UseManager) IsAutoConnect(vendorID, productID string) bool { + um.mu.RLock() + defer um.mu.RUnlock() + + for _, rule := range um.cfg.AutoConnect { + if strings.EqualFold(rule.VendorID, vendorID) && strings.EqualFold(rule.ProductID, productID) { + return true + } + } + return false } func (um *UseManager) handleDeviceGranted(msg *protocol.DeviceGranted) { diff --git a/internal/service/service_windows.go b/internal/service/service_windows.go index 7d7acb0..abe0cc0 100644 --- a/internal/service/service_windows.go +++ b/internal/service/service_windows.go @@ -2,16 +2,81 @@ package service -import "fmt" +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) +const taskName = "USB-Client" + +// Install creates a Windows Scheduled Task that runs at system startup func Install(mode, configPath string) error { - return fmt.Errorf("Windows service installation not yet implemented") + 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) + } + + // Remove previous task if it exists + exec.Command("schtasks", "/Delete", "/TN", taskName, "/F").Run() + + // Create task: run at system startup, as SYSTEM, with highest privileges + taskCmd := fmt.Sprintf(`"%s" %s --config "%s" --no-gui`, exePath, mode, absConfigPath) + cmd := exec.Command("schtasks", "/Create", + "/SC", "ONSTART", + "/TN", taskName, + "/TR", taskCmd, + "/RU", "SYSTEM", + "/RL", "HIGHEST", + "/F", + ) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("creating scheduled task: %w (output: %s)", err, strings.TrimSpace(string(output))) + } + + // Start the task immediately + startCmd := exec.Command("schtasks", "/Run", "/TN", taskName) + startOutput, err := startCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("starting task: %w (output: %s)", err, strings.TrimSpace(string(startOutput))) + } + + return nil } +// Uninstall stops and removes the Scheduled Task func Uninstall() error { - return fmt.Errorf("Windows service uninstallation not yet implemented") + // Stop the running task + exec.Command("schtasks", "/End", "/TN", taskName).Run() + + // Delete the task + cmd := exec.Command("schtasks", "/Delete", "/TN", taskName, "/F") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("deleting scheduled task: %w (output: %s)", err, strings.TrimSpace(string(output))) + } + + return nil } +// Status returns the current status of the Scheduled Task func Status() (string, error) { - return "not implemented on Windows", nil + cmd := exec.Command("schtasks", "/Query", "/TN", taskName, "/FO", "LIST") + output, err := cmd.CombinedOutput() + if err != nil { + return "Task nicht gefunden", nil + } + return strings.TrimSpace(string(output)), nil } diff --git a/internal/web/handler.go b/internal/web/handler.go index b2a9f8e..91b936a 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -24,6 +24,8 @@ type Handler struct { GetDevices func() interface{} AttachDevice func(clientID, busID string) error DetachDevice func(clientID, busID string) error + SetAutoConnect func(vendorID, productID string, enabled bool) error + IsAutoConnect func(vendorID, productID string) bool InstallService func() error UninstallService func() error GetStatus func() map[string]interface{} @@ -54,6 +56,7 @@ func (h *Handler) setupRoutes() { 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/auto-connect", h.handleAutoConnect) h.mux.HandleFunc("/api/service/install", h.handleServiceInstall) h.mux.HandleFunc("/api/service/uninstall", h.handleServiceUninstall) } @@ -252,6 +255,35 @@ func (h *Handler) handleApplyTokens(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]interface{}{"ok": true, "hash": hash}) } +func (h *Handler) handleAutoConnect(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "Method not allowed", 405) + return + } + + var req struct { + VendorID string `json:"vendor_id"` + ProductID string `json:"product_id"` + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, map[string]interface{}{"ok": false, "error": "invalid request"}) + return + } + + if h.SetAutoConnect == nil { + writeJSON(w, map[string]interface{}{"ok": false, "error": "not in use mode"}) + return + } + + if err := h.SetAutoConnect(req.VendorID, req.ProductID, req.Enabled); err != nil { + writeJSON(w, map[string]interface{}{"ok": false, "error": err.Error()}) + return + } + + writeJSON(w, map[string]interface{}{"ok": true}) +} + func (h *Handler) handleServiceInstall(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", 405) diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 05a384a..1e723a6 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -94,13 +94,19 @@ function renderUseDevices(container, available, attached) { html += attached.map(dev => `