diff --git a/README.md b/README.md index 4115d99..f0aa66f 100644 --- a/README.md +++ b/README.md @@ -149,11 +149,13 @@ choco install make | Funktion | Linux | Windows | |----------|-------|---------| | Share-Modus (USB-Geraete freigeben) | Ja | Nein (kein usbdevfs) | -| Use-Modus (USB-Geraete empfangen) | Ja | Nein (kein VHCI-Treiber) | +| Use-Modus (USB-Geraete empfangen) | Ja (vhci-hcd) | Ja (usbip-win2) | | Relay-Server | Ja | Ja | | Web-UI / Config | Ja | Ja | -**Windows-Einschraenkung:** Der Windows-Client kann zur Zeit nur als Relay-Server, fuer die Web-UI und zur Konfiguration genutzt werden. Share- und Use-Modus erfordern Linux-spezifische Kernel-Schnittstellen (usbdevfs bzw. vhci-hcd). +**Windows Use-Modus:** Benoetigt den [usbip-win2](https://github.com/cezanne/usbip-win2/releases) VHCI-Treiber (WHKL-zertifiziert, Microsoft-signiert). Der Client erkennt automatisch ob usbip-win2 installiert ist. + +**Windows Share-Modus:** Nicht verfuegbar - erfordert Linux-spezifische Kernel-Schnittstelle (usbdevfs). ### Voraussetzungen (Laufzeit) diff --git a/bin/usb-client b/bin/usb-client index 890048c..75f121f 100755 Binary files a/bin/usb-client and b/bin/usb-client differ diff --git a/bin/usb-client.exe b/bin/usb-client.exe index 43521d5..f1d833f 100755 Binary files a/bin/usb-client.exe and b/bin/usb-client.exe differ diff --git a/bin/usb-relay b/bin/usb-relay index a3eebba..0b65661 100755 Binary files a/bin/usb-relay and b/bin/usb-relay differ diff --git a/internal/client/socket_linux.go b/internal/client/socket_linux.go index ca83e81..10abb10 100644 --- a/internal/client/socket_linux.go +++ b/internal/client/socket_linux.go @@ -3,16 +3,58 @@ package client import ( + "context" "fmt" "log" + "net" "os" "path/filepath" "strings" "time" + "github.com/duffy/usb-server/internal/protocol" + "github.com/duffy/usb-server/internal/usbip" "golang.org/x/sys/unix" ) +// createVHCIAttachment creates a VHCI attachment on Linux using socketpair + sysfs. +// Returns the tunnel connection (our end of the socketpair), the VHCI port number, and any error. +func createVHCIAttachment(_ context.Context, granted *protocol.DeviceGranted, _ *RemoteDevice) (net.Conn, int, error) { + // Create a socketpair - one end for VHCI, one for our tunnel + fds, err := createSocketPair() + if err != nil { + return nil, -1, 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 nil, -1, 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 nil, -1, 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 nil, -1, fmt.Errorf("creating tunnel conn: %w", err) + } + + return tunnelConn, port, nil +} + // createSocketPair creates a Unix domain socket pair func createSocketPair() ([2]int, error) { fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) diff --git a/internal/client/socket_windows.go b/internal/client/socket_windows.go index 26e0a74..41b0624 100644 --- a/internal/client/socket_windows.go +++ b/internal/client/socket_windows.go @@ -3,12 +3,194 @@ package client import ( + "context" "fmt" + "io" + "log" + "net" "os" + "os/exec" + "regexp" + "strconv" + "strings" + + "github.com/duffy/usb-server/internal/protocol" + "github.com/duffy/usb-server/internal/usbip" ) +// createVHCIAttachment creates a VHCI attachment on Windows using usbip-win2. +// It starts a local TCP proxy, launches usbip.exe to connect to it, +// handles the USB/IP management phase (OP_REQ_IMPORT) locally, +// and returns the TCP connection for the transfer phase bridge. +func createVHCIAttachment(ctx context.Context, granted *protocol.DeviceGranted, devInfo *RemoteDevice) (net.Conn, int, error) { + // Find usbip.exe + usbipExe, err := usbip.FindUsbipExe() + if err != nil { + return nil, -1, err + } + + // Start TCP listener on localhost with random port + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, -1, fmt.Errorf("starting TCP listener: %w", err) + } + tcpPort := listener.Addr().(*net.TCPAddr).Port + log.Printf("[vhci-win] TCP proxy listening on 127.0.0.1:%d", tcpPort) + + // Channel for the accepted connection after management phase + type acceptResult struct { + conn net.Conn + err error + } + resultCh := make(chan acceptResult, 1) + + // Accept connection and handle management phase in goroutine + go func() { + conn, err := listener.Accept() + listener.Close() // only accept one connection + if err != nil { + resultCh <- acceptResult{nil, fmt.Errorf("accepting connection: %w", err)} + return + } + + log.Printf("[vhci-win] usbip.exe connected, handling management phase") + + // Handle OP_REQ_IMPORT from usbip.exe + if err := handleImportRequest(conn, granted, devInfo); err != nil { + conn.Close() + resultCh <- acceptResult{nil, fmt.Errorf("management phase: %w", err)} + return + } + + log.Printf("[vhci-win] management phase complete, entering transfer phase") + resultCh <- acceptResult{conn, nil} + }() + + // Launch usbip.exe attach + cmd := exec.CommandContext(ctx, usbipExe, + "--tcp-port", fmt.Sprintf("%d", tcpPort), + "attach", "-r", "127.0.0.1", "-b", granted.BusID) + output, err := cmd.CombinedOutput() + outputStr := strings.TrimSpace(string(output)) + if err != nil { + // Close listener to unblock Accept goroutine + listener.Close() + return nil, -1, fmt.Errorf("usbip.exe attach failed: %w (output: %s)", err, outputStr) + } + + log.Printf("[vhci-win] usbip.exe output: %s", outputStr) + + // Parse VHCI port from usbip.exe output (e.g. "succesfully attached to port 0") + vhciPort := parsePortFromOutput(outputStr) + + // Wait for management phase to complete + result := <-resultCh + if result.err != nil { + return nil, -1, result.err + } + + log.Printf("[vhci-win] device attached on VHCI port %d", vhciPort) + return result.conn, vhciPort, nil +} + +// handleImportRequest reads OP_REQ_IMPORT from the usbip.exe client +// and responds with OP_REP_IMPORT containing the device descriptor. +func handleImportRequest(conn net.Conn, granted *protocol.DeviceGranted, devInfo *RemoteDevice) error { + // Read the OpHeader (8 bytes) + hdr, err := usbip.ReadOpHeader(conn) + if err != nil { + return fmt.Errorf("reading op header: %w", err) + } + + if hdr.Command != usbip.OpReqImport { + return fmt.Errorf("unexpected command: 0x%04x (expected OP_REQ_IMPORT 0x%04x)", hdr.Command, usbip.OpReqImport) + } + + // Read the 32-byte bus ID + var busIDBuf [32]byte + if _, err := io.ReadFull(conn, busIDBuf[:]); err != nil { + return fmt.Errorf("reading bus ID: %w", err) + } + + requestedBusID := usbip.GetBusID(busIDBuf) + log.Printf("[vhci-win] OP_REQ_IMPORT for bus ID: %s", requestedBusID) + + // Build device descriptor from available info + desc := buildDeviceDescriptor(granted, devInfo) + + // Build and send OP_REP_IMPORT reply + reply, err := usbip.BuildImportReply(0, &desc) + if err != nil { + return fmt.Errorf("building import reply: %w", err) + } + + if _, err := conn.Write(reply); err != nil { + return fmt.Errorf("writing import reply: %w", err) + } + + return nil +} + +// buildDeviceDescriptor creates a USB/IP DeviceDescriptor from the +// information available in the DeviceGranted message and RemoteDevice. +func buildDeviceDescriptor(granted *protocol.DeviceGranted, devInfo *RemoteDevice) usbip.DeviceDescriptor { + var desc usbip.DeviceDescriptor + + usbip.SetBusID(&desc.BusID, granted.BusID) + usbip.SetPath(&desc.Path, "/sys/bus/usb/"+granted.BusID) + + desc.Speed = granted.Speed + desc.BusNum = granted.DevID >> 16 + desc.DevNum = granted.DevID & 0xFFFF + + // Fill from RemoteDevice if available + if devInfo != nil { + desc.BusNum = devInfo.BusNum + desc.DevNum = devInfo.DevNum + + // Parse hex VendorID/ProductID + if vid, err := strconv.ParseUint(devInfo.VendorID, 16, 16); err == nil { + desc.IDVendor = uint16(vid) + } + if pid, err := strconv.ParseUint(devInfo.ProductID, 16, 16); err == nil { + desc.IDProduct = uint16(pid) + } + + desc.BDeviceClass = devInfo.Class + desc.BDeviceSubClass = devInfo.SubClass + desc.BDeviceProtocol = devInfo.Protocol + desc.BNumInterfaces = devInfo.NumInterfaces + } + + // Defaults for fields not available in the protocol + desc.BcdDevice = 0x0100 + desc.BConfigurationValue = 1 + desc.BNumConfigurations = 1 + + if desc.BNumInterfaces == 0 { + desc.BNumInterfaces = 1 + } + + return desc +} + +// parsePortFromOutput extracts the VHCI port number from usbip.exe output. +// Returns -1 if the port cannot be parsed. +func parsePortFromOutput(output string) int { + // Match patterns like "port 0", "port 1", etc. + re := regexp.MustCompile(`(?i)port\s+(\d+)`) + matches := re.FindStringSubmatch(output) + if len(matches) >= 2 { + if port, err := strconv.Atoi(matches[1]); err == nil { + return port + } + } + return -1 +} + +// createSocketPair is not used on Windows but required for compilation. func createSocketPair() ([2]int, error) { - return [2]int{}, fmt.Errorf("socketpair not implemented on Windows") + return [2]int{}, fmt.Errorf("socketpair not available on Windows") } func closeFDs(fds [2]int) {} @@ -17,6 +199,5 @@ func fdToFile(fd int, name string) *os.File { return nil } -func fixVHCIDevicePermissions(port int) { - // Not applicable on Windows -} +// fixVHCIDevicePermissions is not needed on Windows. +func fixVHCIDevicePermissions(port int) {} diff --git a/internal/client/use.go b/internal/client/use.go index 9a73fb2..567d50b 100644 --- a/internal/client/use.go +++ b/internal/client/use.go @@ -23,7 +23,6 @@ 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 @@ -184,36 +183,22 @@ func (um *UseManager) DetachDevice(clientID, busID string) error { } 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) + // Look up device info from available list (needed for Windows management phase) + var devInfo *RemoteDevice + um.mu.RLock() + for _, d := range um.available[clientID] { + if d.BusID == busID { + cp := d + devInfo = &cp + break + } } + um.mu.RUnlock() - vhciFD := fds[0] - tunnelFD := fds[1] - - // Find a free VHCI port - port, err := usbip.FindFreePort(granted.Speed) + // Platform-specific VHCI attachment (Linux: socketpair+sysfs, Windows: TCP proxy+usbip.exe) + tunnelConn, vhciPort, err := createVHCIAttachment(um.client.ctx, granted, devInfo) 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) + return fmt.Errorf("VHCI attachment: %w", err) } tunnel := &useTunnel{ @@ -233,19 +218,18 @@ func (um *UseManager) setupVHCI(clientID, busID string, granted *protocol.Device ClientID: clientID, }, TunnelID: granted.TunnelID, - VHCIPort: port, - SocketFD: vhciFD, + VHCIPort: vhciPort, } 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) + log.Printf("[use] device %s attached on VHCI port %d", key, vhciPort) // Fix permissions on newly created device nodes (e.g. /dev/video*) // VHCI-created devices don't get normal udev permissions - go fixVHCIDevicePermissions(port) + go fixVHCIDevicePermissions(vhciPort) return nil } diff --git a/internal/usbip/vhci_windows.go b/internal/usbip/vhci_windows.go index bb2d77a..29aa957 100644 --- a/internal/usbip/vhci_windows.go +++ b/internal/usbip/vhci_windows.go @@ -2,25 +2,78 @@ package usbip -import "fmt" +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) +// FindUsbipExe locates the usbip.exe binary from usbip-win2. +// Checks PATH first, then the standard installation directory. +func FindUsbipExe() (string, error) { + // Check PATH + if path, err := exec.LookPath("usbip"); err == nil { + return path, nil + } + + // Check standard installation path + standardPath := filepath.Join(os.Getenv("ProgramFiles"), "USBip", "usbip.exe") + if _, err := os.Stat(standardPath); err == nil { + return standardPath, nil + } + + // Check x86 program files too + x86Path := filepath.Join(os.Getenv("ProgramFiles(x86)"), "USBip", "usbip.exe") + if _, err := os.Stat(x86Path); err == nil { + return x86Path, nil + } + + return "", fmt.Errorf("usbip.exe nicht gefunden. Bitte usbip-win2 installieren: https://github.com/cezanne/usbip-win2/releases") +} + +// IsVHCIAvailable checks if usbip-win2 is installed func IsVHCIAvailable() bool { - return false + _, err := FindUsbipExe() + return err == nil } -// VHCIUnavailableError returns an error describing why VHCI is not available. +// VHCIUnavailableError returns an error describing why VHCI is not available, +// or nil if usbip-win2 is installed and ready. func VHCIUnavailableError() error { - return fmt.Errorf("VHCI wird unter Windows nicht unterstuetzt. Der Use-Modus (USB-Geraete empfangen) ist nur unter Linux verfuegbar") -} - -func FindFreePort(speed uint32) (int, error) { - return -1, fmt.Errorf("VHCI not supported on Windows") -} - -func AttachDevice(port int, sockfd int, devID uint32, speed uint32) error { - return fmt.Errorf("VHCI not supported on Windows") + if IsVHCIAvailable() { + return nil + } + return fmt.Errorf("usbip-win2 nicht installiert. Der Use-Modus benoetigt den usbip-win2 VHCI-Treiber. Download: https://github.com/cezanne/usbip-win2/releases") } +// DetachDevice detaches a device from the VHCI driver using usbip.exe func DetachDevice(port int) error { - return fmt.Errorf("VHCI not supported on Windows") + usbipExe, err := FindUsbipExe() + if err != nil { + return err + } + + cmd := exec.Command(usbipExe, "detach", "-p", fmt.Sprintf("%d", port)) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("usbip detach failed: %w (output: %s)", err, strings.TrimSpace(string(output))) + } + + log.Printf("[vhci-win] detached port %d", port) + return nil +} + +// FindFreePort is not used on Windows (usbip.exe selects the port automatically). +// Signature required for cross-platform compilation. +func FindFreePort(speed uint32) (int, error) { + return -1, fmt.Errorf("not used on Windows - usbip.exe selects port automatically") +} + +// AttachDevice is not used on Windows (usbip.exe handles attachment). +// Signature required for cross-platform compilation. +func AttachDevice(port int, sockfd int, devID uint32, speed uint32) error { + return fmt.Errorf("not used on Windows - use createVHCIAttachment instead") }