diff --git a/bin/usb-client b/bin/usb-client index 9ae0787..9dea612 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 33ace47..4f9fe46 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 9996fca..dcd01b9 100755 Binary files a/bin/usb-relay and b/bin/usb-relay differ diff --git a/cmd/usb-client/main.go b/cmd/usb-client/main.go index f822e15..756adec 100644 --- a/cmd/usb-client/main.go +++ b/cmd/usb-client/main.go @@ -184,7 +184,7 @@ func cmdRun(mode string) { webHandler := web.NewHandler(cfg, cfgPath) if mode == "share" { - sm := client.NewShareManager(c) + sm := client.NewShareManager(c, cfg) webHandler.GetDevices = func() interface{} { return map[string]interface{}{ "mode": "share", @@ -217,14 +217,15 @@ func cmdRun(mode string) { 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, + "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, + "allow_force_detach": um.IsForceDetachable(d.ClientID), }) } @@ -251,6 +252,7 @@ func cmdRun(mode string) { } webHandler.AttachDevice = um.AttachDevice webHandler.DetachDevice = um.DetachDevice + webHandler.ForceDetachDevice = um.ForceDetachDevice webHandler.SetAutoConnect = um.SetAutoConnect webHandler.IsAutoConnect = um.IsAutoConnect webHandler.GetStatus = func() map[string]interface{} { @@ -281,7 +283,7 @@ func cmdRun(mode string) { } else { // No GUI mode if mode == "share" { - sm := client.NewShareManager(c) + sm := client.NewShareManager(c, cfg) go sm.Run() } else { client.NewUseManager(c, cfg, cfgPath) diff --git a/internal/client/client.go b/internal/client/client.go index e74ba91..ec23746 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -31,6 +31,7 @@ type Client struct { OnClientLeft func(msg *protocol.ClientLeft) OnRequestDevice func(targetClient, fromClient, busID, requestID string) OnReleaseDevice func(busID, fromClient string) + OnForceRelease func(targetClient, fromClient, busID string) OnTunnelData func(tunnelID string, data []byte) ctx context.Context @@ -255,6 +256,18 @@ func (c *Client) handleTextMessage(data []byte) { } } + case protocol.MsgForceRelease: + if c.OnForceRelease != nil { + var msg struct { + TargetClient string `json:"target_client"` + FromClient string `json:"from_client"` + BusID string `json:"bus_id"` + } + if json.Unmarshal(data, &msg) == nil { + c.OnForceRelease(msg.TargetClient, msg.FromClient, msg.BusID) + } + } + case protocol.MsgReleaseDevice: if c.OnReleaseDevice != nil { var msg struct { diff --git a/internal/client/share.go b/internal/client/share.go index 23698a8..1551e51 100644 --- a/internal/client/share.go +++ b/internal/client/share.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "github.com/duffy/usb-server/internal/config" "github.com/duffy/usb-server/internal/protocol" "github.com/duffy/usb-server/internal/usb" "github.com/duffy/usb-server/internal/usbip" @@ -16,6 +17,7 @@ import ( // ShareManager handles sharing USB devices type ShareManager struct { client *Client + cfg *config.Config mu sync.RWMutex devices []usb.Device active map[string]*activeShare // busID -> active share @@ -38,9 +40,10 @@ type shareTunnel struct { } // NewShareManager creates a share manager -func NewShareManager(client *Client) *ShareManager { +func NewShareManager(client *Client, cfg *config.Config) *ShareManager { sm := &ShareManager{ client: client, + cfg: cfg, active: make(map[string]*activeShare), tunnels: make(map[string]*shareTunnel), } @@ -50,6 +53,7 @@ func NewShareManager(client *Client) *ShareManager { client.OnReleaseDevice = sm.handleReleaseDevice client.OnTunnelData = sm.handleTunnelData client.OnClientLeft = sm.handleClientLeft + client.OnForceRelease = sm.handleForceRelease return sm } @@ -129,10 +133,11 @@ func (sm *ShareManager) broadcastDeviceList() { } msg := &protocol.DeviceList{ - Type: protocol.MsgDeviceList, - ClientID: sm.client.ID(), - ClientName: sm.client.Config().Name, - Devices: protoDevices, + Type: protocol.MsgDeviceList, + ClientID: sm.client.ID(), + ClientName: sm.client.Config().Name, + Devices: protoDevices, + AllowForceDetach: sm.cfg.AllowForceDetach, } sm.client.SendJSON(msg) @@ -312,6 +317,24 @@ func (sm *ShareManager) handleReleaseDevice(busID, fromClient string) { sm.broadcastDeviceList() } +func (sm *ShareManager) handleForceRelease(targetClient, fromClient, busID string) { + if !sm.cfg.AllowForceDetach { + log.Printf("[share] force-release denied for %s (not allowed by config)", busID) + return + } + + sm.mu.RLock() + share, exists := sm.active[busID] + sm.mu.RUnlock() + + if !exists { + return + } + + log.Printf("[share] force-releasing %s (requested by %s, was used by %s)", busID, fromClient[:8], share.usedBy[:8]) + sm.handleReleaseDevice(busID, share.usedBy) +} + func (sm *ShareManager) handleClientLeft(msg *protocol.ClientLeft) { sm.mu.RLock() var toRelease []string diff --git a/internal/client/use.go b/internal/client/use.go index 3411486..12c0a35 100644 --- a/internal/client/use.go +++ b/internal/client/use.go @@ -33,10 +33,11 @@ type UseManager struct { cfg *config.Config cfgPath string 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 + 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 + forceDetachable map[string]bool // clientID -> allow_force_detach } type useTunnel struct { @@ -53,10 +54,11 @@ func NewUseManager(client *Client, cfg *config.Config, cfgPath string) *UseManag client: client, cfg: cfg, cfgPath: cfgPath, - available: make(map[string][]RemoteDevice), - attached: make(map[string]*AttachedDevice), - tunnels: make(map[string]*useTunnel), - pending: make(map[string]chan *protocol.DeviceGranted), + available: make(map[string][]RemoteDevice), + attached: make(map[string]*AttachedDevice), + tunnels: make(map[string]*useTunnel), + pending: make(map[string]chan *protocol.DeviceGranted), + forceDetachable: make(map[string]bool), } client.OnDeviceList = um.handleDeviceList @@ -284,6 +286,7 @@ func (um *UseManager) handleDeviceList(msg *protocol.DeviceList) { }) } um.available[msg.ClientID] = remoteDevs + um.forceDetachable[msg.ClientID] = msg.AllowForceDetach // Collect devices to auto-connect (while holding the lock to check attached map) var toAutoConnect []RemoteDevice @@ -382,6 +385,22 @@ func (um *UseManager) IsAutoConnect(vendorID, productID string) bool { return false } +// IsForceDetachable checks if a share client allows force-detach +func (um *UseManager) IsForceDetachable(clientID string) bool { + um.mu.RLock() + defer um.mu.RUnlock() + return um.forceDetachable[clientID] +} + +// ForceDetachDevice sends a force-release request to the share client +func (um *UseManager) ForceDetachDevice(clientID, busID string) error { + return um.client.SendJSON(&protocol.ForceRelease{ + Type: protocol.MsgForceRelease, + TargetClient: clientID, + BusID: busID, + }) +} + func (um *UseManager) handleDeviceGranted(msg *protocol.DeviceGranted) { um.mu.RLock() ch, exists := um.pending[msg.RequestID] @@ -430,6 +449,7 @@ func (um *UseManager) handleTunnelData(tunnelID string, data []byte) { func (um *UseManager) handleClientLeft(msg *protocol.ClientLeft) { um.mu.Lock() delete(um.available, msg.ClientID) + delete(um.forceDetachable, msg.ClientID) // Detach any devices from this client for key, dev := range um.attached { diff --git a/internal/config/config.go b/internal/config/config.go index 4f051fd..f6eb78a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,9 @@ type Config struct { // Auto-connect rules (use mode only) AutoConnect []AutoConnectRule `json:"auto_connect,omitempty"` + + // Share mode: allow other clients to force-detach devices in use + AllowForceDetach bool `json:"allow_force_detach,omitempty"` } // DefaultConfig returns a config with sensible defaults diff --git a/internal/protocol/messages.go b/internal/protocol/messages.go index b9c3014..5b52330 100644 --- a/internal/protocol/messages.go +++ b/internal/protocol/messages.go @@ -11,6 +11,7 @@ const ( MsgDeviceReleased = "device_released" MsgClientJoined = "client_joined" MsgClientLeft = "client_left" + MsgForceRelease = "force_release" MsgPing = "ping" MsgPong = "pong" MsgError = "error" @@ -63,10 +64,11 @@ type USBDevice struct { // 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"` + Type string `json:"type"` + ClientID string `json:"client_id"` + ClientName string `json:"client_name"` + Devices []USBDevice `json:"devices"` + AllowForceDetach bool `json:"allow_force_detach,omitempty"` } // RequestDevice is sent by use clients to request a specific device @@ -108,6 +110,13 @@ type DeviceReleased struct { BusID string `json:"bus_id"` } +// ForceRelease is sent by use clients to force-release a device from another user +type ForceRelease struct { + Type string `json:"type"` + TargetClient string `json:"target_client"` + BusID string `json:"bus_id"` +} + // ClientJoined is broadcast when a new client joins the group type ClientJoined struct { Type string `json:"type"` diff --git a/internal/relay/hub.go b/internal/relay/hub.go index bd0a07c..2dfc948 100644 --- a/internal/relay/hub.go +++ b/internal/relay/hub.go @@ -128,6 +128,8 @@ func (h *Hub) HandleTextMessage(sender *Client, data []byte) { h.handleDeviceGranted(sender, data) case protocol.MsgDeviceDenied: h.handleDeviceDenied(sender, data) + case protocol.MsgForceRelease: + h.handleForceRelease(sender, data) case protocol.MsgReleaseDevice: h.handleReleaseDevice(sender, data) case protocol.MsgDeviceReleased: @@ -311,6 +313,41 @@ func (h *Hub) handleReleaseDevice(sender *Client, data []byte) { h.mu.RUnlock() } +// handleForceRelease forwards a force-release request to the target share client +func (h *Hub) handleForceRelease(sender *Client, data []byte) { + var msg protocol.ForceRelease + if err := json.Unmarshal(data, &msg); err != nil { + return + } + + // Clean up tunnel for this device (by BusID, regardless of who owns it) + h.mu.Lock() + for tid, tunnel := range h.tunnels { + if tunnel.BusID == msg.BusID && tunnel.ShareClient == msg.TargetClient { + delete(h.tunnels, tid) + log.Printf("[hub] tunnel force-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 && target.Mode == protocol.ModeShare { + enriched := map[string]interface{}{ + "type": protocol.MsgForceRelease, + "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() diff --git a/internal/web/handler.go b/internal/web/handler.go index 91b936a..e17cb61 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -26,9 +26,10 @@ type Handler struct { 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{} + ForceDetachDevice func(clientID, busID string) error + InstallService func() error + UninstallService func() error + GetStatus func() map[string]interface{} } // NewHandler creates a new web handler @@ -57,6 +58,7 @@ func (h *Handler) setupRoutes() { 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/force-detach", h.handleForceDetach) h.mux.HandleFunc("/api/service/install", h.handleServiceInstall) h.mux.HandleFunc("/api/service/uninstall", h.handleServiceUninstall) } @@ -164,10 +166,11 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { 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"` + RelayAddr string `json:"relay_addr"` + Mode string `json:"mode"` + Name string `json:"name"` + WebPort int `json:"web_port"` + AllowForceDetach *bool `json:"allow_force_detach,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { writeJSON(w, map[string]interface{}{"ok": false, "error": "invalid request"}) @@ -186,6 +189,9 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { if updates.WebPort > 0 { h.cfg.WebPort = updates.WebPort } + if updates.AllowForceDetach != nil { + h.cfg.AllowForceDetach = *updates.AllowForceDetach + } if err := h.cfg.Save(h.cfgPath); err != nil { writeJSON(w, map[string]interface{}{"ok": false, "error": err.Error()}) @@ -284,6 +290,34 @@ func (h *Handler) handleAutoConnect(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]interface{}{"ok": true}) } +func (h *Handler) handleForceDetach(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.ForceDetachDevice == nil { + writeJSON(w, map[string]interface{}{"ok": false, "error": "not in use mode"}) + return + } + + if err := h.ForceDetachDevice(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) 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 1e723a6..469eb69 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -147,7 +147,8 @@ function renderUseDevices(container, available, attached) {
${dev.status === 'in_use' - ? 'In Benutzung' + ? `In Benutzung + ${dev.allow_force_detach ? `` : ''}` : isAttached ? 'Verbunden' : `Verfuegbar @@ -197,6 +198,24 @@ async function detachDevice(clientId, busId) { } } +// Force detach +async function forceDetach(clientId, busId) { + try { + const resp = await fetch(API_BASE + '/api/force-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); + } +} + // Auto-connect toggle async function toggleAutoConnect(vendorId, productId, enabled) { try { @@ -226,6 +245,7 @@ async function loadSettings() { 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('allow-force-detach').checked = cfg.allow_force_detach || false; document.getElementById('token1').value = cfg.token1 || ''; document.getElementById('token2').value = cfg.token2 || ''; document.getElementById('token3').value = cfg.token3 || ''; @@ -248,6 +268,7 @@ document.getElementById('settings-form').addEventListener('submit', async (e) => mode: document.getElementById('mode').value, name: document.getElementById('client-name').value, web_port: parseInt(document.getElementById('web-port').value), + allow_force_detach: document.getElementById('allow-force-detach').checked, }) }); const data = await resp.json(); diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 9056d9f..a899415 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -55,6 +55,13 @@
+
+ + Erlaubt Use-Clients, Geraete die von anderen benutzt werden zu trennen +