first commit

This commit is contained in:
Stefan Hacker
2026-02-18 22:01:54 +01:00
commit 5464e553b3
35 changed files with 5432 additions and 0 deletions
+315
View File
@@ -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)
}
}
+358
View File
@@ -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
}
+28
View File
@@ -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)
}
+18
View File
@@ -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
}
+368
View File
@@ -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)
}
+90
View File
@@ -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
}
+142
View File
@@ -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
+336
View File
@@ -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)
}
}
}
+138
View File
@@ -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, &reg); 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)
}
}
}
+94
View File
@@ -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
}
+17
View File
@@ -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
}
+60
View File
@@ -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
}
+53
View File
@@ -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"
}
+188
View File
@@ -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
}
}
+10
View File
@@ -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")
}
+306
View File
@@ -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(&params.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)
}
+19
View File
@@ -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") }
+374
View File
@@ -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)
}
}
+460
View File
@@ -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)
}
}
+168
View File
@@ -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: "<port> <sockfd> <devid> <speed>"
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
}
+292
View File
@@ -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)
}
}
+322
View File
@@ -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 =
`<strong>Modus:</strong> ${data.mode === 'share' ? 'Freigeben' : 'Empfangen'} | ` +
`<strong>Name:</strong> ${data.name} | ` +
`<strong>Client ID:</strong> ${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 =
'<p class="loading">Fehler beim Laden der Geraete</p>';
}
}
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 = '<p class="no-devices">Keine USB-Geraete gefunden</p>';
return;
}
container.innerHTML = devices.map(dev => `
<div class="device-card">
<div class="device-info">
<div class="device-name">${escapeHtml(dev.name)}</div>
<div class="device-details">
<span>Bus: ${dev.bus_id}</span>
<span>VID:PID: ${dev.vendor_id}:${dev.product_id}</span>
<span>Speed: ${speedName(dev.speed)}</span>
</div>
</div>
<div class="device-status">
<span class="badge ${dev.status === 'available' ? 'available' : 'in-use'}">
${dev.status === 'available' ? 'Verfuegbar' : 'In Benutzung'}
</span>
</div>
</div>
`).join('');
}
function renderUseDevices(container, available, attached) {
let html = '';
// Attached devices first
if (attached && attached.length > 0) {
html += '<div class="client-header">Verbundene Geraete</div>';
html += attached.map(dev => `
<div class="device-card">
<div class="device-info">
<div class="device-name">${escapeHtml(dev.bus_id)}</div>
<div class="device-details">
<span>Von: ${escapeHtml(dev.client_name || dev.client_id)}</span>
<span>VHCI Port: ${dev.vhci_port}</span>
</div>
</div>
<div class="device-status">
<span class="badge attached">Verbunden</span>
<button class="btn small danger" onclick="detachDevice('${dev.client_id}', '${dev.bus_id}')">Trennen</button>
</div>
</div>
`).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 = '<p class="no-devices">Keine Geraete verfuegbar. Warte auf Share-Clients...</p>';
return;
}
for (const [clientId, info] of Object.entries(byClient)) {
html += `<div class="client-header">${escapeHtml(info.name)} (${clientId.substring(0, 8)}...)</div>`;
html += info.devices.map(dev => {
const isAttached = (attached || []).some(a =>
a.bus_id === dev.bus_id && a.client_id === clientId
);
return `
<div class="device-card">
<div class="device-info">
<div class="device-name">${escapeHtml(dev.name)}</div>
<div class="device-details">
<span>Bus: ${dev.bus_id}</span>
<span>VID:PID: ${dev.vendor_id}:${dev.product_id}</span>
<span>Speed: ${speedName(dev.speed)}</span>
</div>
</div>
<div class="device-status">
${dev.status === 'in_use'
? '<span class="badge in-use">In Benutzung</span>'
: isAttached
? '<span class="badge attached">Verbunden</span>'
: `<span class="badge available">Verfuegbar</span>
<button class="btn small primary" onclick="attachDevice('${clientId}', '${dev.bus_id}')">Verbinden</button>`
}
</div>
</div>
`;
}).join('');
}
container.innerHTML = html || '<p class="no-devices">Keine Geraete verfuegbar</p>';
}
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Init
loadSettings();
updateStatus();
updateDevices();
// Periodic updates
setInterval(updateStatus, 5000);
setInterval(updateDevices, 3000);
+101
View File
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>USB Server</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<header>
<h1>USB Server</h1>
<div class="status" id="status">Nicht verbunden</div>
</header>
<nav>
<button class="tab active" data-tab="devices">Geraete</button>
<button class="tab" data-tab="settings">Einstellungen</button>
<button class="tab" data-tab="token">Token</button>
<button class="tab" data-tab="service">Service</button>
</nav>
<!-- Devices Tab -->
<section id="tab-devices" class="tab-content active">
<div id="mode-info" class="info-box"></div>
<div id="device-list" class="device-list">
<p class="loading">Lade Geraete...</p>
</div>
</section>
<!-- Settings Tab -->
<section id="tab-settings" class="tab-content">
<form id="settings-form">
<div class="form-group">
<label for="relay-addr">Relay Server Adresse</label>
<input type="text" id="relay-addr" placeholder="ws://localhost:8443">
</div>
<div class="form-group">
<label for="hash">Hash</label>
<input type="text" id="hash" placeholder="SHA256 Hash" readonly>
<small>Wird aus den 3 Tokens berechnet</small>
</div>
<div class="form-group">
<label for="mode">Modus</label>
<select id="mode">
<option value="share">Freigeben (Share)</option>
<option value="use">Empfangen (Use)</option>
</select>
</div>
<div class="form-group">
<label for="client-name">Client Name</label>
<input type="text" id="client-name" placeholder="Mein PC">
</div>
<div class="form-group">
<label for="web-port">Web-UI Port</label>
<input type="number" id="web-port" value="8080">
</div>
<button type="submit" class="btn primary">Speichern</button>
</form>
</section>
<!-- Token Tab -->
<section id="tab-token" class="tab-content">
<div class="form-group">
<label for="token1">Token 1</label>
<input type="text" id="token1" placeholder="Token 1">
</div>
<div class="form-group">
<label for="token2">Token 2</label>
<input type="text" id="token2" placeholder="Token 2">
</div>
<div class="form-group">
<label for="token3">Token 3</label>
<input type="text" id="token3" placeholder="Token 3">
</div>
<div class="form-group">
<label>Berechneter Hash</label>
<div id="computed-hash" class="hash-display">-</div>
</div>
<div class="button-group">
<button id="generate-tokens" class="btn primary">Neue Tokens Generieren</button>
<button id="apply-tokens" class="btn">Tokens Anwenden</button>
</div>
</section>
<!-- Service Tab -->
<section id="tab-service" class="tab-content">
<div class="info-box">
<p>USB Client als Systemdienst installieren fuer automatischen Start.</p>
</div>
<div class="button-group">
<button id="install-service" class="btn primary">Service Installieren</button>
<button id="uninstall-service" class="btn danger">Service Deinstallieren</button>
</div>
<div id="service-status" class="info-box" style="margin-top: 1rem;"></div>
</section>
</div>
<script src="/static/app.js"></script>
</body>
</html>
+281
View File
@@ -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;
}