added autostart for windows and autostart for devices

This commit is contained in:
duffyduck 2026-02-19 09:34:38 +01:00
parent e2b853840d
commit 486cf6d239
10 changed files with 290 additions and 19 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

25
build.sh Executable file
View File

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

View File

@ -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()
@ -232,10 +232,14 @@ func cmdRun(mode string) {
for _, d := range attached {
attachList = append(attachList, map[string]interface{}{
"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 {

View File

@ -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,13 +216,18 @@ 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,
},
RemoteDevice: remDev,
TunnelID: granted.TunnelID,
VHCIPort: vhciPort,
}
@ -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) {

View File

@ -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)))
}
func Status() (string, error) {
return "not implemented on Windows", nil
return nil
}
// Status returns the current status of the Scheduled Task
func Status() (string, error) {
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
}

View File

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

View File

@ -94,13 +94,19 @@ function renderUseDevices(container, available, attached) {
html += attached.map(dev => `
<div class="device-card">
<div class="device-info">
<div class="device-name">${escapeHtml(dev.bus_id)}</div>
<div class="device-name">${escapeHtml(dev.name || dev.bus_id)}</div>
<div class="device-details">
<span>Von: ${escapeHtml(dev.client_name || dev.client_id)}</span>
${dev.vendor_id ? `<span>VID:PID: ${dev.vendor_id}:${dev.product_id}</span>` : ''}
<span>VHCI Port: ${dev.vhci_port}</span>
</div>
</div>
<div class="device-status">
<label class="auto-connect-label" title="Beim Start automatisch verbinden">
<input type="checkbox" ${dev.auto_connect ? 'checked' : ''}
onchange="toggleAutoConnect('${dev.vendor_id}', '${dev.product_id}', this.checked)">
Autostart
</label>
<span class="badge attached">Verbunden</span>
<button class="btn small danger" onclick="detachDevice('${dev.client_id}', '${dev.bus_id}')">Trennen</button>
</div>
@ -191,6 +197,25 @@ async function detachDevice(clientId, busId) {
}
}
// Auto-connect toggle
async function toggleAutoConnect(vendorId, productId, enabled) {
try {
const resp = await fetch(API_BASE + '/api/auto-connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vendor_id: vendorId, product_id: productId, enabled: enabled })
});
const data = await resp.json();
if (!data.ok) {
alert('Fehler: ' + (data.error || 'Unbekannt'));
updateDevices();
}
} catch (e) {
alert('Fehler: ' + e.message);
updateDevices();
}
}
// Settings
async function loadSettings() {
try {

View File

@ -279,3 +279,18 @@ nav {
color: #666;
padding: 2rem;
}
.auto-connect-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: #888;
cursor: pointer;
user-select: none;
}
.auto-connect-label input[type="checkbox"] {
accent-color: #00d4ff;
cursor: pointer;
}