added other dsconnect settings

This commit is contained in:
duffyduck 2026-02-19 11:13:14 +01:00
parent 019c60689e
commit 09af99946e
13 changed files with 204 additions and 35 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -184,7 +184,7 @@ func cmdRun(mode string) {
webHandler := web.NewHandler(cfg, cfgPath) webHandler := web.NewHandler(cfg, cfgPath)
if mode == "share" { if mode == "share" {
sm := client.NewShareManager(c) sm := client.NewShareManager(c, cfg)
webHandler.GetDevices = func() interface{} { webHandler.GetDevices = func() interface{} {
return map[string]interface{}{ return map[string]interface{}{
"mode": "share", "mode": "share",
@ -217,14 +217,15 @@ func cmdRun(mode string) {
var availList []map[string]interface{} var availList []map[string]interface{}
for _, d := range available { for _, d := range available {
availList = append(availList, map[string]interface{}{ availList = append(availList, map[string]interface{}{
"bus_id": d.BusID, "bus_id": d.BusID,
"vendor_id": d.VendorID, "vendor_id": d.VendorID,
"product_id": d.ProductID, "product_id": d.ProductID,
"name": d.Name, "name": d.Name,
"status": d.Status, "status": d.Status,
"speed": d.Speed, "speed": d.Speed,
"client_id": d.ClientID, "client_id": d.ClientID,
"client_name": d.ClientName, "client_name": d.ClientName,
"allow_force_detach": um.IsForceDetachable(d.ClientID),
}) })
} }
@ -251,6 +252,7 @@ func cmdRun(mode string) {
} }
webHandler.AttachDevice = um.AttachDevice webHandler.AttachDevice = um.AttachDevice
webHandler.DetachDevice = um.DetachDevice webHandler.DetachDevice = um.DetachDevice
webHandler.ForceDetachDevice = um.ForceDetachDevice
webHandler.SetAutoConnect = um.SetAutoConnect webHandler.SetAutoConnect = um.SetAutoConnect
webHandler.IsAutoConnect = um.IsAutoConnect webHandler.IsAutoConnect = um.IsAutoConnect
webHandler.GetStatus = func() map[string]interface{} { webHandler.GetStatus = func() map[string]interface{} {
@ -281,7 +283,7 @@ func cmdRun(mode string) {
} else { } else {
// No GUI mode // No GUI mode
if mode == "share" { if mode == "share" {
sm := client.NewShareManager(c) sm := client.NewShareManager(c, cfg)
go sm.Run() go sm.Run()
} else { } else {
client.NewUseManager(c, cfg, cfgPath) client.NewUseManager(c, cfg, cfgPath)

View File

@ -31,6 +31,7 @@ type Client struct {
OnClientLeft func(msg *protocol.ClientLeft) OnClientLeft func(msg *protocol.ClientLeft)
OnRequestDevice func(targetClient, fromClient, busID, requestID string) OnRequestDevice func(targetClient, fromClient, busID, requestID string)
OnReleaseDevice func(busID, fromClient string) OnReleaseDevice func(busID, fromClient string)
OnForceRelease func(targetClient, fromClient, busID string)
OnTunnelData func(tunnelID string, data []byte) OnTunnelData func(tunnelID string, data []byte)
ctx context.Context 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: case protocol.MsgReleaseDevice:
if c.OnReleaseDevice != nil { if c.OnReleaseDevice != nil {
var msg struct { var msg struct {

View File

@ -7,6 +7,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/duffy/usb-server/internal/config"
"github.com/duffy/usb-server/internal/protocol" "github.com/duffy/usb-server/internal/protocol"
"github.com/duffy/usb-server/internal/usb" "github.com/duffy/usb-server/internal/usb"
"github.com/duffy/usb-server/internal/usbip" "github.com/duffy/usb-server/internal/usbip"
@ -16,6 +17,7 @@ import (
// ShareManager handles sharing USB devices // ShareManager handles sharing USB devices
type ShareManager struct { type ShareManager struct {
client *Client client *Client
cfg *config.Config
mu sync.RWMutex mu sync.RWMutex
devices []usb.Device devices []usb.Device
active map[string]*activeShare // busID -> active share active map[string]*activeShare // busID -> active share
@ -38,9 +40,10 @@ type shareTunnel struct {
} }
// NewShareManager creates a share manager // NewShareManager creates a share manager
func NewShareManager(client *Client) *ShareManager { func NewShareManager(client *Client, cfg *config.Config) *ShareManager {
sm := &ShareManager{ sm := &ShareManager{
client: client, client: client,
cfg: cfg,
active: make(map[string]*activeShare), active: make(map[string]*activeShare),
tunnels: make(map[string]*shareTunnel), tunnels: make(map[string]*shareTunnel),
} }
@ -50,6 +53,7 @@ func NewShareManager(client *Client) *ShareManager {
client.OnReleaseDevice = sm.handleReleaseDevice client.OnReleaseDevice = sm.handleReleaseDevice
client.OnTunnelData = sm.handleTunnelData client.OnTunnelData = sm.handleTunnelData
client.OnClientLeft = sm.handleClientLeft client.OnClientLeft = sm.handleClientLeft
client.OnForceRelease = sm.handleForceRelease
return sm return sm
} }
@ -129,10 +133,11 @@ func (sm *ShareManager) broadcastDeviceList() {
} }
msg := &protocol.DeviceList{ msg := &protocol.DeviceList{
Type: protocol.MsgDeviceList, Type: protocol.MsgDeviceList,
ClientID: sm.client.ID(), ClientID: sm.client.ID(),
ClientName: sm.client.Config().Name, ClientName: sm.client.Config().Name,
Devices: protoDevices, Devices: protoDevices,
AllowForceDetach: sm.cfg.AllowForceDetach,
} }
sm.client.SendJSON(msg) sm.client.SendJSON(msg)
@ -312,6 +317,24 @@ func (sm *ShareManager) handleReleaseDevice(busID, fromClient string) {
sm.broadcastDeviceList() 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) { func (sm *ShareManager) handleClientLeft(msg *protocol.ClientLeft) {
sm.mu.RLock() sm.mu.RLock()
var toRelease []string var toRelease []string

View File

@ -33,10 +33,11 @@ type UseManager struct {
cfg *config.Config cfg *config.Config
cfgPath string cfgPath string
mu sync.RWMutex mu sync.RWMutex
available map[string][]RemoteDevice // clientID -> devices available map[string][]RemoteDevice // clientID -> devices
attached map[string]*AttachedDevice // busID@clientID -> attached info attached map[string]*AttachedDevice // busID@clientID -> attached info
tunnels map[string]*useTunnel // tunnelID -> tunnel tunnels map[string]*useTunnel // tunnelID -> tunnel
pending map[string]chan *protocol.DeviceGranted // requestID -> response channel pending map[string]chan *protocol.DeviceGranted // requestID -> response channel
forceDetachable map[string]bool // clientID -> allow_force_detach
} }
type useTunnel struct { type useTunnel struct {
@ -53,10 +54,11 @@ func NewUseManager(client *Client, cfg *config.Config, cfgPath string) *UseManag
client: client, client: client,
cfg: cfg, cfg: cfg,
cfgPath: cfgPath, cfgPath: cfgPath,
available: make(map[string][]RemoteDevice), available: make(map[string][]RemoteDevice),
attached: make(map[string]*AttachedDevice), attached: make(map[string]*AttachedDevice),
tunnels: make(map[string]*useTunnel), tunnels: make(map[string]*useTunnel),
pending: make(map[string]chan *protocol.DeviceGranted), pending: make(map[string]chan *protocol.DeviceGranted),
forceDetachable: make(map[string]bool),
} }
client.OnDeviceList = um.handleDeviceList client.OnDeviceList = um.handleDeviceList
@ -284,6 +286,7 @@ func (um *UseManager) handleDeviceList(msg *protocol.DeviceList) {
}) })
} }
um.available[msg.ClientID] = remoteDevs um.available[msg.ClientID] = remoteDevs
um.forceDetachable[msg.ClientID] = msg.AllowForceDetach
// Collect devices to auto-connect (while holding the lock to check attached map) // Collect devices to auto-connect (while holding the lock to check attached map)
var toAutoConnect []RemoteDevice var toAutoConnect []RemoteDevice
@ -382,6 +385,22 @@ func (um *UseManager) IsAutoConnect(vendorID, productID string) bool {
return false 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) { func (um *UseManager) handleDeviceGranted(msg *protocol.DeviceGranted) {
um.mu.RLock() um.mu.RLock()
ch, exists := um.pending[msg.RequestID] 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) { func (um *UseManager) handleClientLeft(msg *protocol.ClientLeft) {
um.mu.Lock() um.mu.Lock()
delete(um.available, msg.ClientID) delete(um.available, msg.ClientID)
delete(um.forceDetachable, msg.ClientID)
// Detach any devices from this client // Detach any devices from this client
for key, dev := range um.attached { for key, dev := range um.attached {

View File

@ -30,6 +30,9 @@ type Config struct {
// Auto-connect rules (use mode only) // Auto-connect rules (use mode only)
AutoConnect []AutoConnectRule `json:"auto_connect,omitempty"` 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 // DefaultConfig returns a config with sensible defaults

View File

@ -11,6 +11,7 @@ const (
MsgDeviceReleased = "device_released" MsgDeviceReleased = "device_released"
MsgClientJoined = "client_joined" MsgClientJoined = "client_joined"
MsgClientLeft = "client_left" MsgClientLeft = "client_left"
MsgForceRelease = "force_release"
MsgPing = "ping" MsgPing = "ping"
MsgPong = "pong" MsgPong = "pong"
MsgError = "error" MsgError = "error"
@ -63,10 +64,11 @@ type USBDevice struct {
// DeviceList is sent by share clients to announce available devices // DeviceList is sent by share clients to announce available devices
type DeviceList struct { type DeviceList struct {
Type string `json:"type"` Type string `json:"type"`
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
ClientName string `json:"client_name"` ClientName string `json:"client_name"`
Devices []USBDevice `json:"devices"` Devices []USBDevice `json:"devices"`
AllowForceDetach bool `json:"allow_force_detach,omitempty"`
} }
// RequestDevice is sent by use clients to request a specific device // RequestDevice is sent by use clients to request a specific device
@ -108,6 +110,13 @@ type DeviceReleased struct {
BusID string `json:"bus_id"` 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 // ClientJoined is broadcast when a new client joins the group
type ClientJoined struct { type ClientJoined struct {
Type string `json:"type"` Type string `json:"type"`

View File

@ -128,6 +128,8 @@ func (h *Hub) HandleTextMessage(sender *Client, data []byte) {
h.handleDeviceGranted(sender, data) h.handleDeviceGranted(sender, data)
case protocol.MsgDeviceDenied: case protocol.MsgDeviceDenied:
h.handleDeviceDenied(sender, data) h.handleDeviceDenied(sender, data)
case protocol.MsgForceRelease:
h.handleForceRelease(sender, data)
case protocol.MsgReleaseDevice: case protocol.MsgReleaseDevice:
h.handleReleaseDevice(sender, data) h.handleReleaseDevice(sender, data)
case protocol.MsgDeviceReleased: case protocol.MsgDeviceReleased:
@ -311,6 +313,41 @@ func (h *Hub) handleReleaseDevice(sender *Client, data []byte) {
h.mu.RUnlock() 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 // handleDeviceReleased broadcasts device released notification
func (h *Hub) handleDeviceReleased(sender *Client, data []byte) { func (h *Hub) handleDeviceReleased(sender *Client, data []byte) {
h.mu.RLock() h.mu.RLock()

View File

@ -26,9 +26,10 @@ type Handler struct {
DetachDevice func(clientID, busID string) error DetachDevice func(clientID, busID string) error
SetAutoConnect func(vendorID, productID string, enabled bool) error SetAutoConnect func(vendorID, productID string, enabled bool) error
IsAutoConnect func(vendorID, productID string) bool IsAutoConnect func(vendorID, productID string) bool
InstallService func() error ForceDetachDevice func(clientID, busID string) error
UninstallService func() error InstallService func() error
GetStatus func() map[string]interface{} UninstallService func() error
GetStatus func() map[string]interface{}
} }
// NewHandler creates a new web handler // 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/generate-token", h.handleGenerateToken)
h.mux.HandleFunc("/api/apply-tokens", h.handleApplyTokens) h.mux.HandleFunc("/api/apply-tokens", h.handleApplyTokens)
h.mux.HandleFunc("/api/auto-connect", h.handleAutoConnect) 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/install", h.handleServiceInstall)
h.mux.HandleFunc("/api/service/uninstall", h.handleServiceUninstall) 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" { if r.Method == "POST" {
var updates struct { var updates struct {
RelayAddr string `json:"relay_addr"` RelayAddr string `json:"relay_addr"`
Mode string `json:"mode"` Mode string `json:"mode"`
Name string `json:"name"` Name string `json:"name"`
WebPort int `json:"web_port"` WebPort int `json:"web_port"`
AllowForceDetach *bool `json:"allow_force_detach,omitempty"`
} }
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
writeJSON(w, map[string]interface{}{"ok": false, "error": "invalid request"}) 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 { if updates.WebPort > 0 {
h.cfg.WebPort = updates.WebPort h.cfg.WebPort = updates.WebPort
} }
if updates.AllowForceDetach != nil {
h.cfg.AllowForceDetach = *updates.AllowForceDetach
}
if err := h.cfg.Save(h.cfgPath); err != nil { if err := h.cfg.Save(h.cfgPath); err != nil {
writeJSON(w, map[string]interface{}{"ok": false, "error": err.Error()}) 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}) 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) { func (h *Handler) handleServiceInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "Method not allowed", 405) http.Error(w, "Method not allowed", 405)

View File

@ -147,7 +147,8 @@ function renderUseDevices(container, available, attached) {
</div> </div>
<div class="device-status"> <div class="device-status">
${dev.status === 'in_use' ${dev.status === 'in_use'
? '<span class="badge in-use">In Benutzung</span>' ? `<span class="badge in-use">In Benutzung</span>
${dev.allow_force_detach ? `<button class="btn small danger" onclick="forceDetach('${clientId}', '${dev.bus_id}')">Trennen</button>` : ''}`
: isAttached : isAttached
? '<span class="badge attached">Verbunden</span>' ? '<span class="badge attached">Verbunden</span>'
: `<span class="badge available">Verfuegbar</span> : `<span class="badge available">Verfuegbar</span>
@ -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 // Auto-connect toggle
async function toggleAutoConnect(vendorId, productId, enabled) { async function toggleAutoConnect(vendorId, productId, enabled) {
try { try {
@ -226,6 +245,7 @@ async function loadSettings() {
document.getElementById('mode').value = cfg.mode || 'use'; document.getElementById('mode').value = cfg.mode || 'use';
document.getElementById('client-name').value = cfg.name || ''; document.getElementById('client-name').value = cfg.name || '';
document.getElementById('web-port').value = cfg.web_port || 8080; 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('token1').value = cfg.token1 || '';
document.getElementById('token2').value = cfg.token2 || ''; document.getElementById('token2').value = cfg.token2 || '';
document.getElementById('token3').value = cfg.token3 || ''; document.getElementById('token3').value = cfg.token3 || '';
@ -248,6 +268,7 @@ document.getElementById('settings-form').addEventListener('submit', async (e) =>
mode: document.getElementById('mode').value, mode: document.getElementById('mode').value,
name: document.getElementById('client-name').value, name: document.getElementById('client-name').value,
web_port: parseInt(document.getElementById('web-port').value), web_port: parseInt(document.getElementById('web-port').value),
allow_force_detach: document.getElementById('allow-force-detach').checked,
}) })
}); });
const data = await resp.json(); const data = await resp.json();

View File

@ -55,6 +55,13 @@
<label for="web-port">Web-UI Port</label> <label for="web-port">Web-UI Port</label>
<input type="number" id="web-port" value="8080"> <input type="number" id="web-port" value="8080">
</div> </div>
<div class="form-group">
<label class="auto-connect-label">
<input type="checkbox" id="allow-force-detach">
Andere Clients duerfen Geraete trennen (Share-Modus)
</label>
<small>Erlaubt Use-Clients, Geraete die von anderen benutzt werden zu trennen</small>
</div>
<button type="submit" class="btn primary">Speichern</button> <button type="submit" class="btn primary">Speichern</button>
</form> </form>
</section> </section>