diff --git a/common/vboxusb/assets/amd64/VBoxUSB.cat b/common/vboxusb/assets/amd64/VBoxUSB.cat new file mode 100644 index 000000000..25c481eaf Binary files /dev/null and b/common/vboxusb/assets/amd64/VBoxUSB.cat differ diff --git a/common/vboxusb/assets/amd64/VBoxUSB.inf b/common/vboxusb/assets/amd64/VBoxUSB.inf new file mode 100644 index 000000000..48e4bf49b --- /dev/null +++ b/common/vboxusb/assets/amd64/VBoxUSB.inf @@ -0,0 +1,103 @@ +; $Id: VBoxUSB.inf 170711 2025-09-29 16:57:21Z klaus $ +;; @file +; VBox host drivers - USB drivers - Win32 USB device +; + +; +; Copyright (C) 2011-2025 Oracle and/or its affiliates. +; +; This file is part of VirtualBox base platform packages, as +; available from https://www.virtualbox.org. +; +; This program is free software; you can redistribute it and/or +; modify it under the terms of the GNU General Public License +; as published by the Free Software Foundation, in version 3 of the +; License. +; +; This program is distributed in the hope that it will be useful, but +; WITHOUT ANY WARRANTY; without even the implied warranty of +; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +; General Public License for more details. +; +; You should have received a copy of the GNU General Public License +; along with this program; if not, see . +; +; The contents of this file may alternatively be used under the terms +; of the Common Development and Distribution License Version 1.0 +; (CDDL), a copy of it is provided in the "COPYING.CDDL" file included +; in the VirtualBox distribution, in which case the provisions of the +; CDDL are applicable instead of those of the GPL. +; +; You may elect to license modified versions of this file under the +; terms and conditions of either the GPL or the CDDL or both. +; +; SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +; + +[Version] +Signature="$Windows NT$" +Class=USB +ClassGUID={36FC9E60-C465-11CF-8056-444553540000} +provider=%ORACLE% +DriverVer = 04/18/2026,7.2.8.23730 ; r173730 +CatalogFile=VBoxUSB.cat +PnpLockdown=1 + +[SourceDisksNames] +1=%VBoxUSB_DiskDesc%,,, + +[SourceDisksFiles] +VBoxUSB.sys = 1 + +[DestinationDirs] +DefaultDestDir = 12 + +[Manufacturer] +%ORACLE%=VBoxUSB,NTAMD64 + +[VBoxUSB.NTAMD64] +%VBoxUSB_DrvDesc%=VBoxUSB_Drv,USB\VID_80EE&PID_CAFE + +[VBoxUSB_Drv.NTAMD64] +CopyFiles=VBoxUSB_CopyFiles + +[VBoxUSB_Drv.NTAMD64.Services] +AddService=VBoxUSB,0x00000002,VBoxUSB_Service,VBoxUSB_AddReg + +[DefaultUninstall.NTAMD64.Services] +DelService = VBoxUSB,0x00000200 +DelReg = VBoxUSB_Registry +LegacyUninstall=1 + +[VBoxUSB_Service] +DisplayName = %VBoxUSB_SvcDesc% +ServiceType = %SERVICE_TYPE_KERNEL_DRIVER% +StartType = %SERVICE_STARTTYPE_DEMAND% +ErrorControl = %SERVICE_ERRORCTL_NORMAL% +ServiceBinary = %12%\VBoxUSB.sys +LoadOrderGroup = Base + +[VBoxUSB_AddReg] +AddReg = VBoxUSB_Registry + +[VBoxUSB_Registry] +HKR,,DevLoader,,*ntkern +HKR,,NTMPDriver,,VBoxUSB.sys + +[VBoxUSB_CopyFiles] +VBoxUSB.sys + +[Strings] +ORACLE="Oracle Corporation" +VBoxUSB_SvcDesc="VirtualBox USB Service" +VBoxUSB_DrvDesc="VirtualBox USB Driver" +VBoxUSB_DiskDesc="VBoxUSB Installation Disk" + +; Useful constants +SERVICE_TYPE_KERNEL_DRIVER = 1 +SERVICE_STARTTYPE_BOOT = 0 +SERVICE_STARTTYPE_AUTO = 1 +SERVICE_STARTTYPE_DEMAND = 3 +SERVICE_ERRORCTL_IGNORE = 0 +SERVICE_ERRORCTL_NORMAL = 1 + diff --git a/common/vboxusb/assets/amd64/VBoxUSB.sys b/common/vboxusb/assets/amd64/VBoxUSB.sys new file mode 100644 index 000000000..1efd42b81 Binary files /dev/null and b/common/vboxusb/assets/amd64/VBoxUSB.sys differ diff --git a/common/vboxusb/assets/amd64/VBoxUSBMon.sys b/common/vboxusb/assets/amd64/VBoxUSBMon.sys new file mode 100644 index 000000000..4a689f9d0 Binary files /dev/null and b/common/vboxusb/assets/amd64/VBoxUSBMon.sys differ diff --git a/common/vboxusb/assets/arm64/VBoxUSB.cat b/common/vboxusb/assets/arm64/VBoxUSB.cat new file mode 100644 index 000000000..e30b544fb Binary files /dev/null and b/common/vboxusb/assets/arm64/VBoxUSB.cat differ diff --git a/common/vboxusb/assets/arm64/VBoxUSB.inf b/common/vboxusb/assets/arm64/VBoxUSB.inf new file mode 100644 index 000000000..9acf2f26e --- /dev/null +++ b/common/vboxusb/assets/arm64/VBoxUSB.inf @@ -0,0 +1,103 @@ +; $Id: VBoxUSB.inf 170711 2025-09-29 16:57:21Z klaus $ +;; @file +; VBox host drivers - USB drivers - Win32 USB device +; + +; +; Copyright (C) 2011-2025 Oracle and/or its affiliates. +; +; This file is part of VirtualBox base platform packages, as +; available from https://www.virtualbox.org. +; +; This program is free software; you can redistribute it and/or +; modify it under the terms of the GNU General Public License +; as published by the Free Software Foundation, in version 3 of the +; License. +; +; This program is distributed in the hope that it will be useful, but +; WITHOUT ANY WARRANTY; without even the implied warranty of +; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +; General Public License for more details. +; +; You should have received a copy of the GNU General Public License +; along with this program; if not, see . +; +; The contents of this file may alternatively be used under the terms +; of the Common Development and Distribution License Version 1.0 +; (CDDL), a copy of it is provided in the "COPYING.CDDL" file included +; in the VirtualBox distribution, in which case the provisions of the +; CDDL are applicable instead of those of the GPL. +; +; You may elect to license modified versions of this file under the +; terms and conditions of either the GPL or the CDDL or both. +; +; SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0 +; + +[Version] +Signature="$Windows NT$" +Class=USB +ClassGUID={36FC9E60-C465-11CF-8056-444553540000} +provider=%ORACLE% +DriverVer = 04/18/2026,7.2.8.23730 ; r173730 +CatalogFile=VBoxUSB.cat +PnpLockdown=1 + +[SourceDisksNames] +1=%VBoxUSB_DiskDesc%,,, + +[SourceDisksFiles] +VBoxUSB.sys = 1 + +[DestinationDirs] +DefaultDestDir = 12 + +[Manufacturer] +%ORACLE%=VBoxUSB,NTARM64 + +[VBoxUSB.NTARM64] +%VBoxUSB_DrvDesc%=VBoxUSB_Drv,USB\VID_80EE&PID_CAFE + +[VBoxUSB_Drv.NTARM64] +CopyFiles=VBoxUSB_CopyFiles + +[VBoxUSB_Drv.NTARM64.Services] +AddService=VBoxUSB,0x00000002,VBoxUSB_Service,VBoxUSB_AddReg + +[DefaultUninstall.NTARM64.Services] +DelService = VBoxUSB,0x00000200 +DelReg = VBoxUSB_Registry +LegacyUninstall=1 + +[VBoxUSB_Service] +DisplayName = %VBoxUSB_SvcDesc% +ServiceType = %SERVICE_TYPE_KERNEL_DRIVER% +StartType = %SERVICE_STARTTYPE_DEMAND% +ErrorControl = %SERVICE_ERRORCTL_NORMAL% +ServiceBinary = %12%\VBoxUSB.sys +LoadOrderGroup = Base + +[VBoxUSB_AddReg] +AddReg = VBoxUSB_Registry + +[VBoxUSB_Registry] +HKR,,DevLoader,,*ntkern +HKR,,NTMPDriver,,VBoxUSB.sys + +[VBoxUSB_CopyFiles] +VBoxUSB.sys + +[Strings] +ORACLE="Oracle Corporation" +VBoxUSB_SvcDesc="VirtualBox USB Service" +VBoxUSB_DrvDesc="VirtualBox USB Driver" +VBoxUSB_DiskDesc="VBoxUSB Installation Disk" + +; Useful constants +SERVICE_TYPE_KERNEL_DRIVER = 1 +SERVICE_STARTTYPE_BOOT = 0 +SERVICE_STARTTYPE_AUTO = 1 +SERVICE_STARTTYPE_DEMAND = 3 +SERVICE_ERRORCTL_IGNORE = 0 +SERVICE_ERRORCTL_NORMAL = 1 + diff --git a/common/vboxusb/assets/arm64/VBoxUSB.sys b/common/vboxusb/assets/arm64/VBoxUSB.sys new file mode 100644 index 000000000..8c6d39305 Binary files /dev/null and b/common/vboxusb/assets/arm64/VBoxUSB.sys differ diff --git a/common/vboxusb/assets/arm64/VBoxUSBMon.sys b/common/vboxusb/assets/arm64/VBoxUSBMon.sys new file mode 100644 index 000000000..fd47da1a0 Binary files /dev/null and b/common/vboxusb/assets/arm64/VBoxUSBMon.sys differ diff --git a/common/vboxusb/assets_windows_amd64.go b/common/vboxusb/assets_windows_amd64.go new file mode 100644 index 000000000..476324fe8 --- /dev/null +++ b/common/vboxusb/assets_windows_amd64.go @@ -0,0 +1,26 @@ +//go:build windows && amd64 + +package vboxusb + +import _ "embed" + +//go:embed assets/amd64/VBoxUSB.sys +var vboxUSBSys []byte + +//go:embed assets/amd64/VBoxUSB.inf +var vboxUSBInf []byte + +//go:embed assets/amd64/VBoxUSB.cat +var vboxUSBCat []byte + +//go:embed assets/amd64/VBoxUSBMon.sys +var vboxUSBMonSys []byte + +func assetFiles() []assetFile { + return []assetFile{ + {"VBoxUSB.sys", vboxUSBSys}, + {"VBoxUSB.inf", vboxUSBInf}, + {"VBoxUSB.cat", vboxUSBCat}, + {"VBoxUSBMon.sys", vboxUSBMonSys}, + } +} diff --git a/common/vboxusb/assets_windows_arm64.go b/common/vboxusb/assets_windows_arm64.go new file mode 100644 index 000000000..d0b6743c9 --- /dev/null +++ b/common/vboxusb/assets_windows_arm64.go @@ -0,0 +1,26 @@ +//go:build windows && arm64 + +package vboxusb + +import _ "embed" + +//go:embed assets/arm64/VBoxUSB.sys +var vboxUSBSys []byte + +//go:embed assets/arm64/VBoxUSB.inf +var vboxUSBInf []byte + +//go:embed assets/arm64/VBoxUSB.cat +var vboxUSBCat []byte + +//go:embed assets/arm64/VBoxUSBMon.sys +var vboxUSBMonSys []byte + +func assetFiles() []assetFile { + return []assetFile{ + {"VBoxUSB.sys", vboxUSBSys}, + {"VBoxUSB.inf", vboxUSBInf}, + {"VBoxUSB.cat", vboxUSBCat}, + {"VBoxUSBMon.sys", vboxUSBMonSys}, + } +} diff --git a/common/vboxusb/assets_windows_unsupported.go b/common/vboxusb/assets_windows_unsupported.go new file mode 100644 index 000000000..025e81947 --- /dev/null +++ b/common/vboxusb/assets_windows_unsupported.go @@ -0,0 +1,5 @@ +//go:build windows && !amd64 && !arm64 + +package vboxusb + +func assetFiles() []assetFile { return nil } diff --git a/common/vboxusb/device_windows.go b/common/vboxusb/device_windows.go new file mode 100644 index 000000000..d69091203 --- /dev/null +++ b/common/vboxusb/device_windows.go @@ -0,0 +1,310 @@ +//go:build windows + +package vboxusb + +import ( + "encoding/binary" + "errors" + "runtime" + "sync" + "unsafe" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/windows" +) + +// Device holds an open handle to one VBoxUSB-claimed USB device plus a +// private event for overlapped I/O. Methods are not safe for +// concurrent use on the same Device — the session layer serializes +// per-endpoint via per-endpoint goroutines. +type Device struct { + handle windows.Handle + event windows.Handle + closing sync.Once + closeErr error +} + +// OpenDevice opens a per-device VBoxUSB handle by its setupapi-resolved +// interface path (typically obtained via SetupDiEnumDeviceInterfaces +// over MonitorAccessGUID). The handle is opened FILE_FLAG_OVERLAPPED. +// FILE_SKIP_COMPLETION_PORT_ON_SUCCESS is set so synchronously +// completed URBs do not bounce through the IOCP — matches the +// usbipd-win fast path. +func OpenDevice(interfacePath string) (*Device, error) { + pathW, err := windows.UTF16PtrFromString(interfacePath) + if err != nil { + return nil, E.Cause(err, "vboxusb: utf16 device path") + } + handle, err := windows.CreateFile( + pathW, + windows.GENERIC_READ|windows.GENERIC_WRITE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OVERLAPPED, + 0, + ) + if err != nil { + return nil, E.Cause(err, "vboxusb: open ", interfacePath) + } + // Skip IOCP wakeup on synchronous completion. Tolerated on Windows + // 7+; ignore errors since the slow path still works. + _ = windows.SetFileCompletionNotificationModes(handle, windows.FILE_SKIP_COMPLETION_PORT_ON_SUCCESS) + event, err := windows.CreateEvent(nil, 1, 0, nil) + if err != nil { + windows.CloseHandle(handle) + return nil, E.Cause(err, "vboxusb: create event") + } + return &Device{handle: handle, event: event}, nil +} + +// Close releases the handle. Aborts any in-flight IOCTLs (they return +// ERROR_OPERATION_ABORTED). Idempotent. +func (d *Device) Close() error { + d.closing.Do(func() { + var errs []error + if d.handle != 0 { + err := windows.CloseHandle(d.handle) + if err != nil { + errs = append(errs, err) + } + d.handle = 0 + } + if d.event != 0 { + err := windows.CloseHandle(d.event) + if err != nil { + errs = append(errs, err) + } + d.event = 0 + } + d.closeErr = E.Errors(errs...) + }) + return d.closeErr +} + +// GetVersion returns the VBoxUSB driver version (8 bytes: major + minor). +// Call before Claim to verify the driver is at least DriverMajorVersion. +func (d *Device) GetVersion() (uint32, uint32, error) { + var buf [8]byte + _, err := d.ioctl(IOCTLGetVersion, nil, buf[:]) + if err != nil { + return 0, 0, E.Cause(err, "vboxusb: GET_VERSION") + } + return binary.LittleEndian.Uint32(buf[0:4]), binary.LittleEndian.Uint32(buf[4:8]), nil +} + +// Claim acquires exclusive ownership of the device. The driver returns +// Claimed=false if another handle owns the device. The input field is +// unused by the driver but the buffer must equal the output size, so +// pass a zero-initialized 2-byte input. +func (d *Device) Claim() (bool, error) { + var in [2]byte + var out [2]byte + _, err := d.ioctl(IOCTLUSBClaimDevice, in[:], out[:]) + if err != nil { + return false, E.Cause(err, "vboxusb: USB_CLAIM_DEVICE") + } + return out[1] != 0, nil +} + +// SetConfig issues USBSUP_IOCTL_USB_SET_CONFIG so VBoxUSB rebuilds its +// pipe-handle table for the requested bConfigurationValue. Must be +// awaited before any URBs targeting endpoints in the new configuration. +func (d *Device) SetConfig(value byte) error { + in := [1]byte{value} + _, err := d.ioctl(IOCTLUSBSetConfig, in[:], nil) + if err != nil { + return E.Cause(err, "vboxusb: USB_SET_CONFIG") + } + return nil +} + +// SelectInterface issues USBSUP_IOCTL_USB_SELECT_INTERFACE. Same +// pipe-handle implications as SetConfig. +func (d *Device) SelectInterface(num, alt byte) error { + in := [2]byte{num, alt} + _, err := d.ioctl(IOCTLUSBSelectInterface, in[:], nil) + if err != nil { + return E.Cause(err, "vboxusb: USB_SELECT_INTERFACE") + } + return nil +} + +// ClearEndpoint clears the STALL condition on a halted endpoint. The +// argument is the raw 8-bit address (direction bit in MSB). +func (d *Device) ClearEndpoint(rawEndpoint byte) error { + in := [1]byte{rawEndpoint} + _, err := d.ioctl(IOCTLUSBClearEndpoint, in[:], nil) + if err != nil { + return E.Cause(err, "vboxusb: USB_CLEAR_ENDPOINT") + } + return nil +} + +// AbortEndpoint aborts all pending submits on the given raw endpoint +// address. VBoxUSB has no per-URB cancel; callers must use the +// abort-holdoff heuristic to avoid aborting URBs that completed in the +// race window. +func (d *Device) AbortEndpoint(rawEndpoint byte) error { + in := [1]byte{rawEndpoint} + _, err := d.ioctl(IOCTLUSBAbortEndpoint, in[:], nil) + if err != nil { + return E.Cause(err, "vboxusb: USB_ABORT_ENDPOINT") + } + return nil +} + +// URB is the high-level form of USBSUP_URB. The session layer fills it +// and hands to SendURB; on return Length and IsoPackets carry the +// driver's response. Buffer must remain referenced by the caller until +// SendURB returns; SendURB internally calls runtime.KeepAlive. +type URB struct { + Type TransferType + Endpoint uint32 // 4-bit endpoint index, no direction bit + Direction Direction + Flags TransferFlags + Length uint64 + Buffer []byte + IsoPackets []IsoPacket +} + +// IsoPacket is one USBSUP_ISOCPKT entry. Length is in/out (requested +// then actual); Offset is in-only; Status is out-only. +type IsoPacket struct { + Length uint16 + Offset uint16 + Status URBError +} + +// URBResult mirrors the fields VBoxUSB writes back into the URB struct. +type URBResult struct { + Error URBError + Length uint64 + IsoPackets []IsoPacket +} + +// urbStructSize is the on-the-wire size of USBSUP_URB with Pack=4 on +// 64-bit systems (both amd64 and arm64; nint is 8 bytes either way). +// Layout (offsets in bytes): +// +// 0 type uint32 +// 4 ep uint32 +// 8 dir uint32 +// 12 flags uint32 +// 16 error uint32 +// 20 len uint64 +// 28 buf uint64 (native pointer) +// 36 numIsoPkts uint32 +// 40 aIsoPkts [8]uint64 (each entry is cb(u16) off(u16) stat(u32) = 8 bytes) +// +// Total = 104. +const urbStructSize = 104 + +// SendURB marshals urb into a USBSUP_URB, dispatches IOCTL_SEND_URB, +// and unmarshals the response back into urb.Length / urb.IsoPackets. +// Caller must keep urb.Buffer alive across the call (SendURB does so +// internally for the duration of the syscall). +func (d *Device) SendURB(urb *URB) error { + if len(urb.IsoPackets) > MaxIsoPacketsPerURB { + return E.New("vboxusb: too many iso packets: ", len(urb.IsoPackets), " > ", MaxIsoPacketsPerURB) + } + var raw [urbStructSize]byte + binary.LittleEndian.PutUint32(raw[0:4], uint32(urb.Type)) + binary.LittleEndian.PutUint32(raw[4:8], urb.Endpoint) + binary.LittleEndian.PutUint32(raw[8:12], uint32(urb.Direction)) + binary.LittleEndian.PutUint32(raw[12:16], uint32(urb.Flags)) + // raw[16:20] error is filled by the driver + binary.LittleEndian.PutUint64(raw[20:28], urb.Length) + var bufPtr uintptr + if len(urb.Buffer) > 0 { + bufPtr = uintptr(unsafe.Pointer(&urb.Buffer[0])) + } + binary.LittleEndian.PutUint64(raw[28:36], uint64(bufPtr)) + binary.LittleEndian.PutUint32(raw[36:40], uint32(len(urb.IsoPackets))) + for i, iso := range urb.IsoPackets { + base := 40 + i*8 + binary.LittleEndian.PutUint16(raw[base:base+2], iso.Length) + binary.LittleEndian.PutUint16(raw[base+2:base+4], iso.Offset) + binary.LittleEndian.PutUint32(raw[base+4:base+8], uint32(iso.Status)) + } + _, err := d.ioctl(IOCTLSendURB, raw[:], raw[:]) + runtime.KeepAlive(urb.Buffer) + if err != nil { + return E.Cause(err, "vboxusb: SEND_URB") + } + urb.Length = binary.LittleEndian.Uint64(raw[20:28]) + errCode := URBError(binary.LittleEndian.Uint32(raw[16:20])) + for i := range urb.IsoPackets { + base := 40 + i*8 + urb.IsoPackets[i].Length = binary.LittleEndian.Uint16(raw[base : base+2]) + urb.IsoPackets[i].Offset = binary.LittleEndian.Uint16(raw[base+2 : base+4]) + urb.IsoPackets[i].Status = URBError(binary.LittleEndian.Uint32(raw[base+4 : base+8])) + } + if errCode != URBOK { + // Surface the device-reported error through URB.Length on + // the caller side: it's already set to actual transferred + // bytes by the driver. Return a typed error so the engine + // layer can translate to a USBIP status. + return &URBStatusError{Code: errCode} + } + return nil +} + +// URBStatusError signals a USB-level failure reported by the driver +// (STALL, DNR, CRC, etc.) rather than a Windows-level IOCTL failure. +// The engine layer translates these into USBIP wire status. +type URBStatusError struct { + Code URBError +} + +func (e *URBStatusError) Error() string { + switch e.Code { + case URBStall: + return "vboxusb: URB stalled" + case URBDeviceNotResponding: + return "vboxusb: device not responding" + case URBCRCError: + return "vboxusb: CRC error" + case URBNACError: + return "vboxusb: NAC error" + case URBUnderrun: + return "vboxusb: data underrun" + case URBOverrun: + return "vboxusb: data overrun" + default: + return "vboxusb: unknown URB error" + } +} + +// ioctl is the single synchronous overlapped DeviceIoControl primitive. +// Ported from common/windivert/handle_windows.go:263-290. The event is +// the per-Device event (reused across calls) so we avoid CreateEvent on +// every URB. +func (d *Device) ioctl(code uint32, in []byte, out []byte) (uint32, error) { + var overlapped windows.Overlapped + overlapped.HEvent = d.event + _ = windows.ResetEvent(d.event) + var inPtr *byte + var inLen uint32 + if len(in) > 0 { + inPtr = &in[0] + inLen = uint32(len(in)) + } + var outPtr *byte + var outLen uint32 + if len(out) > 0 { + outPtr = &out[0] + outLen = uint32(len(out)) + } + var returned uint32 + err := windows.DeviceIoControl(d.handle, code, inPtr, inLen, outPtr, outLen, &returned, &overlapped) + if err != nil && !errors.Is(err, windows.ERROR_IO_PENDING) { + return 0, err + } + err = windows.GetOverlappedResult(d.handle, &overlapped, &returned, true) + if err != nil { + return 0, err + } + return returned, nil +} diff --git a/common/vboxusb/driver_windows.go b/common/vboxusb/driver_windows.go new file mode 100644 index 000000000..4853e7d38 --- /dev/null +++ b/common/vboxusb/driver_windows.go @@ -0,0 +1,260 @@ +//go:build windows + +package vboxusb + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strconv" + "sync" + "unsafe" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/windows" +) + +// EnsureDrivers extracts the bundled VBoxUSB + VBoxUSBMon binaries and +// makes both drivers available to PnP: +// +// 1. VBoxUSBMon is registered as a SERVICE_KERNEL_DRIVER demand-start +// service. The actual handle to \\.\VBoxUSBMon is opened separately +// via OpenMonitor. +// 2. VBoxUSB.inf is copied into the driver store via SetupCopyOEMInfW +// so PnP knows the driver exists; actual per-device binding is +// orchestrated later via VBoxUSBMon's ADD_FILTER mechanism. +// +// Requires Administrator (SeLoadDriverPrivilege). Safe to call from +// multiple processes concurrently; a named global mutex serializes +// the install. +func EnsureDrivers() error { + driverOnce.Do(func() { + driverErr = installDrivers() + }) + return driverErr +} + +var ( + driverOnce sync.Once + driverErr error +) + +func installDrivers() error { + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + return E.New("vboxusb: unsupported GOARCH ", runtime.GOARCH) + } + + dir, err := ensureExtracted() + if err != nil { + return err + } + + mutexName, _ := windows.UTF16PtrFromString("Global\\SingBoxVBoxUSBInstallMutex") + mutex, err := windows.CreateMutex(nil, false, mutexName) + if err != nil { + return E.Cause(err, "vboxusb: create install mutex") + } + defer windows.CloseHandle(mutex) + _, err = windows.WaitForSingleObject(mutex, windows.INFINITE) + if err != nil { + return E.Cause(err, "vboxusb: wait install mutex") + } + defer windows.ReleaseMutex(mutex) + + err = installMonitorService(dir) + if err != nil { + return err + } + err = installVBoxUSBInf(dir) + if err != nil { + return err + } + return nil +} + +func installMonitorService(dir string) error { + sysPath := filepath.Join(dir, "VBoxUSBMon.sys") + sysPathW, err := windows.UTF16PtrFromString(sysPath) + if err != nil { + return E.Cause(err, "vboxusb: utf16 monitor path") + } + + manager, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_ALL_ACCESS) + if err != nil { + return E.Cause(err, "vboxusb: open SCM") + } + defer windows.CloseServiceHandle(manager) + + serviceNameW, _ := windows.UTF16PtrFromString(MonitorServiceName) + service, err := windows.OpenService(manager, serviceNameW, windows.SERVICE_ALL_ACCESS) + if err != nil { + service, err = windows.CreateService( + manager, + serviceNameW, + serviceNameW, + windows.SERVICE_ALL_ACCESS, + windows.SERVICE_KERNEL_DRIVER, + windows.SERVICE_DEMAND_START, + windows.SERVICE_ERROR_NORMAL, + sysPathW, + nil, nil, nil, nil, nil, + ) + if err != nil { + if errors.Is(err, windows.ERROR_SERVICE_EXISTS) { + service, err = windows.OpenService(manager, serviceNameW, windows.SERVICE_ALL_ACCESS) + } + if err != nil { + return wrapInstallError(err) + } + } + } + defer windows.CloseServiceHandle(service) + + err = windows.StartService(service, 0, nil) + if err != nil && errors.Is(err, windows.ERROR_SERVICE_DISABLED) { + // A prior process called DeleteService on a still-loaded + // driver: SCM marks the record for deletion and flips + // START_TYPE to DISABLED until the last handle closes. + // Re-enable so we can start instead of waiting for a reboot. + err = windows.ChangeServiceConfig( + service, + windows.SERVICE_NO_CHANGE, + windows.SERVICE_DEMAND_START, + windows.SERVICE_NO_CHANGE, + nil, nil, nil, nil, nil, nil, nil, + ) + if err != nil { + return E.Cause(err, "vboxusb: re-enable disabled monitor service") + } + err = windows.StartService(service, 0, nil) + } + if err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { + return E.Cause(err, "vboxusb: start monitor service") + } + return nil +} + +func wrapInstallError(err error) error { + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return E.Cause(err, "vboxusb: installing the kernel driver requires Administrator privileges") + } + return E.Cause(err, "vboxusb: create monitor service") +} + +// installVBoxUSBInf copies VBoxUSB.inf into the Windows driver store +// via SetupCopyOEMInfW so PnP knows the driver is available. The +// actual per-device binding is triggered later by VBoxUSBMon's +// ADD_FILTER + a port cycle. +// +// SetupCopyOEMInfW is not exposed by golang.org/x/sys/windows, so we +// resolve it manually via the setupapi DLL. +func installVBoxUSBInf(dir string) error { + infPath := filepath.Join(dir, "VBoxUSB.inf") + infPathW, err := windows.UTF16PtrFromString(infPath) + if err != nil { + return E.Cause(err, "vboxusb: utf16 inf path") + } + dirW, err := windows.UTF16PtrFromString(dir) + if err != nil { + return E.Cause(err, "vboxusb: utf16 inf dir") + } + const ( + spostPath = 1 // SPOST_PATH + spCopyNoStyle = 0 + ) + ret, _, callErr := procSetupCopyOEMInfW.Call( + uintptr(unsafe.Pointer(infPathW)), + uintptr(unsafe.Pointer(dirW)), + uintptr(spostPath), + uintptr(spCopyNoStyle), + 0, 0, 0, 0, + ) + if ret == 0 { + if errors.Is(callErr, windows.ERROR_FILE_EXISTS) { + // Already present in driver store; not an error. + return nil + } + return E.Cause(callErr, "vboxusb: SetupCopyOEMInfW") + } + return nil +} + +var ( + modSetupAPI = windows.NewLazyDLL("setupapi.dll") + procSetupCopyOEMInfW = modSetupAPI.NewProc("SetupCopyOEMInfW") +) + +type assetFile struct { + name string + data []byte +} + +var ( + extractOnce sync.Once + extractErr error + extractDir string +) + +// The on-disk copy is protected by Authenticode signature enforcement +// at SCM StartService time; any tampering with the .sys is rejected +// by the kernel loader before we ever see it. +func ensureExtracted() (string, error) { + extractOnce.Do(func() { + extractDir, extractErr = extractImpl() + }) + return extractDir, extractErr +} + +func extractImpl() (string, error) { + files := assetFiles() + if len(files) == 0 { + return "", E.New("vboxusb: unsupported architecture ", runtime.GOARCH) + } + base, err := os.UserCacheDir() + if err != nil { + return "", E.Cause(err, "vboxusb: locate user cache dir") + } + dir := filepath.Join(base, "sing-box", "vboxusb", "v"+AssetVersion) + err = os.MkdirAll(dir, 0o755) + if err != nil { + return "", E.Cause(err, "vboxusb: mkdir ", dir) + } + for _, asset := range files { + err = ensureAsset(dir, asset) + if err != nil { + return "", err + } + } + return dir, nil +} + +// Concurrent sing-box processes race on os.Rename (atomic on NTFS); +// whichever wins creates the final file. Writers that lose the race +// silently discard their temp copy. +func ensureAsset(dir string, asset assetFile) error { + target := filepath.Join(dir, asset.name) + _, err := os.Stat(target) + if err == nil { + return nil + } + if !os.IsNotExist(err) { + return E.Cause(err, "vboxusb: stat ", asset.name) + } + tmp := target + ".tmp-" + strconv.Itoa(os.Getpid()) + err = os.WriteFile(tmp, asset.data, 0o644) + if err != nil { + return E.Cause(err, "vboxusb: write ", asset.name) + } + err = os.Rename(tmp, target) + if err != nil { + os.Remove(tmp) + _, statErr := os.Stat(target) + if statErr == nil { + return nil + } + return E.Cause(err, "vboxusb: rename ", asset.name) + } + return nil +} diff --git a/common/vboxusb/monitor_windows.go b/common/vboxusb/monitor_windows.go new file mode 100644 index 000000000..63ef3edc7 --- /dev/null +++ b/common/vboxusb/monitor_windows.go @@ -0,0 +1,216 @@ +//go:build windows + +package vboxusb + +import ( + "encoding/binary" + "errors" + "sync" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/windows" +) + +// Monitor is a handle to \\.\VBoxUSBMon. It is shared across the +// process (one global monitor handle per sing-box server); per-device +// filters are added/removed against it. Concurrent ADD_FILTER / +// REMOVE_FILTER calls are serialized inside the driver but our handle +// reuses a single overlapped event — so Monitor methods are not safe +// for concurrent use across goroutines. The caller (the export host) +// is expected to serialize. +type Monitor struct { + handle windows.Handle + event windows.Handle + closing sync.Once + closeErr error +} + +// OpenMonitor opens \\.\VBoxUSBMon. EnsureDrivers must have been +// called first (and must have succeeded) so the VBoxUSBMon kernel +// service is loaded; otherwise CreateFile returns FILE_NOT_FOUND. +func OpenMonitor() (*Monitor, error) { + pathW, err := windows.UTF16PtrFromString(MonitorDevicePath) + if err != nil { + return nil, E.Cause(err, "vboxusb: utf16 monitor path") + } + handle, err := windows.CreateFile( + pathW, + windows.GENERIC_READ|windows.GENERIC_WRITE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OVERLAPPED, + 0, + ) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + return nil, E.Cause(err, "vboxusb: open monitor (driver not loaded?)") + } + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return nil, E.Cause(err, "vboxusb: open monitor (administrator required)") + } + return nil, E.Cause(err, "vboxusb: open monitor") + } + event, err := windows.CreateEvent(nil, 1, 0, nil) + if err != nil { + windows.CloseHandle(handle) + return nil, E.Cause(err, "vboxusb: create monitor event") + } + return &Monitor{handle: handle, event: event}, nil +} + +func (m *Monitor) Close() error { + m.closing.Do(func() { + var errs []error + if m.handle != 0 { + err := windows.CloseHandle(m.handle) + if err != nil { + errs = append(errs, err) + } + m.handle = 0 + } + if m.event != 0 { + err := windows.CloseHandle(m.event) + if err != nil { + errs = append(errs, err) + } + m.event = 0 + } + m.closeErr = E.Errors(errs...) + }) + return m.closeErr +} + +// GetVersion returns the monitor driver version (8 bytes packed: +// major + minor). Reject if major mismatches DriverMajorVersion. +func (m *Monitor) GetVersion() (uint32, uint32, error) { + var buf [8]byte + _, err := m.ioctl(IOCTLMonitorGetVersion, nil, buf[:]) + if err != nil { + return 0, 0, E.Cause(err, "vboxusb: monitor GET_VERSION") + } + return binary.LittleEndian.Uint32(buf[0:4]), binary.LittleEndian.Uint32(buf[4:8]), nil +} + +// AddFilter installs a one-shot filter that captures the next PnP +// arrival matching the filter. Returns a filter id that must be passed +// to RemoveFilter on detach. usbipd-win uses a permanent CAPTURE +// filter; we follow the same pattern so the filter survives transient +// PnP retries during RestartingDevice. +func (m *Monitor) AddFilter(filter Filter) (uint64, error) { + in := encodeFilter(filter) + var out [12]byte // UsbSupFltAddOut: uint64 uId + int32 rc + _, err := m.ioctl(IOCTLMonitorAddFilter, in[:], out[:]) + if err != nil { + return 0, E.Cause(err, "vboxusb: monitor ADD_FILTER") + } + id := binary.LittleEndian.Uint64(out[0:8]) + rc := int32(binary.LittleEndian.Uint32(out[8:12])) + if rc < 0 { + return 0, E.New("vboxusb: monitor ADD_FILTER returned rc=", rc) + } + return id, nil +} + +// RemoveFilter releases a filter previously returned by AddFilter. +// Safe to call after the device has already left. +func (m *Monitor) RemoveFilter(id uint64) error { + var in [8]byte + binary.LittleEndian.PutUint64(in[:], id) + _, err := m.ioctl(IOCTLMonitorRemoveFilter, in[:], nil) + if err != nil { + return E.Cause(err, "vboxusb: monitor REMOVE_FILTER") + } + return nil +} + +func (m *Monitor) ioctl(code uint32, in []byte, out []byte) (uint32, error) { + var overlapped windows.Overlapped + overlapped.HEvent = m.event + _ = windows.ResetEvent(m.event) + var inPtr *byte + var inLen uint32 + if len(in) > 0 { + inPtr = &in[0] + inLen = uint32(len(in)) + } + var outPtr *byte + var outLen uint32 + if len(out) > 0 { + outPtr = &out[0] + outLen = uint32(len(out)) + } + var returned uint32 + err := windows.DeviceIoControl(m.handle, code, inPtr, inLen, outPtr, outLen, &returned, &overlapped) + if err != nil && !errors.Is(err, windows.ERROR_IO_PENDING) { + return 0, err + } + err = windows.GetOverlappedResult(m.handle, &overlapped, &returned, true) + if err != nil { + return 0, err + } + return returned, nil +} + +// encodeFilter builds a 312-byte USBFILTER packed struct matching the +// layout in VirtualBox usbfilter.h (also documented in +// /tmp/usbipd-win/Usbipd/Interop/VBoxUsbMon.cs:77-105). +// +// Layout (offsets): +// +// 0 u32Magic uint32 (0x19670408) +// 4 enmType uint32 (5 = CAPTURE) +// 8 aFields [11]{enmMatch uint16, u16Value uint16} (44 bytes) +// 52 offCurEnd uint32 (0) +// 56 achStrTab [256]byte (0) +// +// All entries default to IGNORE; caller-specified fields are upgraded +// to NUM_EXACT with the supplied value. String matches and offCurEnd +// stay zero (we never use string filters; the driver rejects nonzero +// offCurEnd with strange offsets). +func encodeFilter(f Filter) [312]byte { + const ( + filterMagic uint32 = 0x19670408 + filterCapture uint32 = 5 // UsbFilterType.CAPTURE + matchIgnore uint16 = 1 // UsbFilterMatch.IGNORE + matchNumExact uint16 = 3 // UsbFilterMatch.NUM_EXACT + ) + const ( + idxVendorID = 0 + idxProductID = 1 + idxDeviceRev = 2 + idxDeviceClass = 3 + idxBus = 6 + idxPort = 7 + ) + var raw [312]byte + binary.LittleEndian.PutUint32(raw[0:4], filterMagic) + binary.LittleEndian.PutUint32(raw[4:8], filterCapture) + for i := 0; i < 11; i++ { + binary.LittleEndian.PutUint16(raw[8+i*4:8+i*4+2], matchIgnore) + } + setField := func(idx int, value uint16) { + binary.LittleEndian.PutUint16(raw[8+idx*4:8+idx*4+2], matchNumExact) + binary.LittleEndian.PutUint16(raw[8+idx*4+2:8+idx*4+4], value) + } + if f.VendorID != nil { + setField(idxVendorID, *f.VendorID) + } + if f.ProductID != nil { + setField(idxProductID, *f.ProductID) + } + if f.DeviceRev != nil { + setField(idxDeviceRev, *f.DeviceRev) + } + if f.DeviceClass != nil { + setField(idxDeviceClass, *f.DeviceClass) + } + if f.Bus != nil { + setField(idxBus, *f.Bus) + } + if f.Port != nil { + setField(idxPort, *f.Port) + } + return raw +} diff --git a/common/vboxusb/pnp_windows.go b/common/vboxusb/pnp_windows.go new file mode 100644 index 000000000..b4ed12e34 --- /dev/null +++ b/common/vboxusb/pnp_windows.go @@ -0,0 +1,265 @@ +//go:build windows + +package vboxusb + +import ( + "encoding/binary" + "errors" + "strconv" + "strings" + "sync" + "time" + "unsafe" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/windows" +) + +// USBDeviceInfo describes one USB device enumerated by Windows +// regardless of which function driver currently owns it. Bus/Address +// values are normalized into a stable bus-id string ("-
") +// matching Linux usbip conventions. +type USBDeviceInfo struct { + InstanceID string + HardwareID string + VendorID uint16 + ProductID uint16 + Revision uint16 + BusNumber uint32 + Address uint32 // device address on the bus (port path leaf) + BusID string // "-
" + DeviceClass uint8 +} + +// EnumerateUSBDevices walks GUID_DEVINTERFACE_USB_DEVICE and returns +// one record per attached USB device. Hubs are not filtered out here; +// the caller (export host) skips DeviceClass == 0x09. +func EnumerateUSBDevices() ([]USBDeviceInfo, error) { + devInfo, err := windows.SetupDiGetClassDevsEx( + &windows.GUID{ + Data1: USBDeviceInterfaceGUID.Data1, + Data2: USBDeviceInterfaceGUID.Data2, + Data3: USBDeviceInterfaceGUID.Data3, + Data4: USBDeviceInterfaceGUID.Data4, + }, + "", + 0, + windows.DIGCF_PRESENT|windows.DIGCF_DEVICEINTERFACE, + 0, + "", + ) + if err != nil { + return nil, E.Cause(err, "vboxusb: SetupDiGetClassDevsEx") + } + defer devInfo.Close() + + var out []USBDeviceInfo + for i := 0; ; i++ { + data, err := windows.SetupDiEnumDeviceInfo(devInfo, i) + if err != nil { + if errors.Is(err, windows.ERROR_NO_MORE_ITEMS) { + break + } + return nil, E.Cause(err, "vboxusb: SetupDiEnumDeviceInfo[", i, "]") + } + info := USBDeviceInfo{} + info.InstanceID, err = windows.SetupDiGetDeviceInstanceId(devInfo, data) + if err != nil { + continue + } + hardwareIDValue, err := windows.SetupDiGetDeviceRegistryProperty(devInfo, data, windows.SPDRP_HARDWAREID) + if err == nil { + info.HardwareID = firstString(hardwareIDValue) + info.VendorID, info.ProductID, info.Revision = parseHardwareID(info.HardwareID) + } + busNumberValue, err := windows.SetupDiGetDeviceRegistryProperty(devInfo, data, windows.SPDRP_BUSNUMBER) + if err == nil { + info.BusNumber = toUint32(busNumberValue) + } + addressValue, err := windows.SetupDiGetDeviceRegistryProperty(devInfo, data, windows.SPDRP_ADDRESS) + if err == nil { + info.Address = toUint32(addressValue) + } + info.BusID = strconv.FormatUint(uint64(info.BusNumber), 10) + "-" + strconv.FormatUint(uint64(info.Address), 10) + out = append(out, info) + } + return out, nil +} + +// CycleHubPort issues IOCTL_USB_HUB_CYCLE_PORT on the parent hub for +// the given 1-based port number. This is the supported equivalent of +// physically unplugging and replugging the device; combined with a +// VBoxUSBMon filter it triggers PnP to bind VBoxUSB to the device. +// +// hubInterfacePath is the setupapi-resolved interface path of the +// parent hub (obtained via CM_Get_Device_Interface_List with +// USBHubInterfaceGUID). +func CycleHubPort(hubInterfacePath string, port uint32) error { + pathW, err := windows.UTF16PtrFromString(hubInterfacePath) + if err != nil { + return E.Cause(err, "vboxusb: utf16 hub path") + } + handle, err := windows.CreateFile( + pathW, + windows.GENERIC_READ|windows.GENERIC_WRITE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL, + 0, + ) + if err != nil { + return E.Cause(err, "vboxusb: open hub ", hubInterfacePath) + } + defer windows.CloseHandle(handle) + + // USB_CYCLE_PORT_PARAMS: ULONG ConnectionIndex; ULONG StatusReturned; + var params [8]byte + binary.LittleEndian.PutUint32(params[0:4], port) + var returned uint32 + err = windows.DeviceIoControl( + handle, + IOCTLHubCyclePort, + ¶ms[0], uint32(len(params)), + ¶ms[0], uint32(len(params)), + &returned, nil, + ) + if err != nil { + return E.Cause(err, "vboxusb: IOCTL_USB_HUB_CYCLE_PORT") + } + return nil +} + +// WaitForVBoxUSBInterface polls CM_Get_Device_Interface_List for the +// VBoxUSB class GUID until an interface path matching instanceID +// appears, or timeout elapses. After RestartDevice + filter trigger, +// PnP needs a moment to load VBoxUSB.sys on the device; matching +// usbipd-win's 10-second window. +func WaitForVBoxUSBInterface(instanceID string, timeout time.Duration) (string, error) { + guid := windows.GUID{ + Data1: MonitorAccessGUID.Data1, + Data2: MonitorAccessGUID.Data2, + Data3: MonitorAccessGUID.Data3, + Data4: MonitorAccessGUID.Data4, + } + deadline := time.Now().Add(timeout) + for { + paths, err := windows.CM_Get_Device_Interface_List(instanceID, &guid, windows.CM_GET_DEVICE_INTERFACE_LIST_PRESENT) + if err == nil { + for _, p := range paths { + if p != "" { + return p, nil + } + } + } + if time.Now().After(deadline) { + return "", E.New("vboxusb: VBoxUSB interface for ", instanceID, " did not appear within ", timeout) + } + time.Sleep(100 * time.Millisecond) + } +} + +// RestartDevice triggers the PnP unplug/replug cycle that lets a +// pending VBoxUSBMon CAPTURE filter activate. usbipd-win uses +// CM_Query_And_Remove_SubTree -> IOCTL_USB_HUB_CYCLE_PORT -> +// CM_Setup_DevNode. +// +// TODO(phase B follow-up): CM_Query_And_Remove_SubTree and +// CM_Setup_DevNode are not exposed by golang.org/x/sys/windows and +// need direct LazyDLL wrappers. Until that is in place, callers +// should arrange for the device to be physically replugged. +func RestartDevice(_ string) error { + return E.New("vboxusb: RestartDevice not yet implemented (use CycleHubPort for now)") +} + +// WatchDeviceArrival registers a callback for device arrival/removal +// on GUID_DEVINTERFACE_USB_DEVICE. Returns a handle that must be +// closed when the watcher is no longer needed. +// +// TODO(phase B follow-up): CM_Register_Notification is not exposed by +// golang.org/x/sys/windows. Until wired up, the export host will need +// to poll via EnumerateUSBDevices on a reconcile timer. +func WatchDeviceArrival(_ func()) (DeviceWatcher, error) { + return nil, E.New("vboxusb: WatchDeviceArrival not yet implemented (poll EnumerateUSBDevices instead)") +} + +// DeviceWatcher is the handle returned by WatchDeviceArrival. Close +// stops delivery and releases the underlying CM_Notify_HNOTIFICATION. +type DeviceWatcher interface { + Close() error +} + +// firstString returns the first NUL-separated string in a REG_MULTI_SZ +// value (returned by SetupDiGetDeviceRegistryProperty for HardwareID). +// Returns the value unchanged for REG_SZ. +func firstString(value any) string { + switch v := value.(type) { + case string: + return v + case []string: + if len(v) > 0 { + return v[0] + } + } + return "" +} + +func toUint32(value any) uint32 { + switch v := value.(type) { + case uint32: + return v + case uint64: + return uint32(v) + } + return 0 +} + +// parseHardwareID extracts VID/PID/REV from a USB hardware ID such as +// "USB\VID_046D&PID_C31C&REV_6400". Returns zeros on parse failure; +// the caller decides whether that disqualifies the device. +func parseHardwareID(hwid string) (vid, pid, rev uint16) { + upper := strings.ToUpper(hwid) + vid = extractHex16(upper, "VID_") + pid = extractHex16(upper, "PID_") + rev = extractHex16(upper, "REV_") + return +} + +func extractHex16(s, prefix string) uint16 { + idx := strings.Index(s, prefix) + if idx < 0 { + return 0 + } + tail := s[idx+len(prefix):] + end := len(tail) + for i, r := range tail { + if !isHex(r) { + end = i + break + } + } + if end == 0 { + return 0 + } + v, err := strconv.ParseUint(tail[:end], 16, 16) + if err != nil { + return 0 + } + return uint16(v) +} + +func isHex(r rune) bool { + return (r >= '0' && r <= '9') || (r >= 'A' && r <= 'F') || (r >= 'a' && r <= 'f') +} + +// pnpProcOnce + LazyProc handles for direct syscall to functions that +// golang.org/x/sys/windows does not yet wrap. Kept in one place so the +// RestartDevice / WatchDeviceArrival follow-up has the resolver +// boilerplate already laid out. +var ( + pnpProcOnce sync.Once + modCfgMgr32 = windows.NewLazyDLL("cfgmgr32.dll") + _ = modCfgMgr32 // referenced by upcoming RestartDevice impl + _ unsafe.Pointer +) diff --git a/common/vboxusb/privilege_windows.go b/common/vboxusb/privilege_windows.go new file mode 100644 index 000000000..dceef76fa --- /dev/null +++ b/common/vboxusb/privilege_windows.go @@ -0,0 +1,56 @@ +//go:build windows + +package vboxusb + +import ( + "sync" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/windows" +) + +// EnableLoadDriverPrivilege flips SeLoadDriverPrivilege ON in the +// current process token. Required to open \\.\VBoxUSBMon and to drive +// PnP install/uninstall flows; non-interactive services have it +// disabled by default. Idempotent. +func EnableLoadDriverPrivilege() error { + loadDriverPrivOnce.Do(func() { + loadDriverPrivErr = enableLoadDriverPrivilege() + }) + return loadDriverPrivErr +} + +var ( + loadDriverPrivOnce sync.Once + loadDriverPrivErr error +) + +func enableLoadDriverPrivilege() error { + var token windows.Token + err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_ADJUST_PRIVILEGES|windows.TOKEN_QUERY, &token) + if err != nil { + return E.Cause(err, "vboxusb: open process token") + } + defer token.Close() + + privName, _ := windows.UTF16PtrFromString("SeLoadDriverPrivilege") + var luid windows.LUID + err = windows.LookupPrivilegeValue(nil, privName, &luid) + if err != nil { + return E.Cause(err, "vboxusb: lookup SeLoadDriverPrivilege") + } + + tp := windows.Tokenprivileges{ + PrivilegeCount: 1, + Privileges: [1]windows.LUIDAndAttributes{{ + Luid: luid, + Attributes: windows.SE_PRIVILEGE_ENABLED, + }}, + } + err = windows.AdjustTokenPrivileges(token, false, &tp, 0, nil, nil) + if err != nil { + return E.Cause(err, "vboxusb: adjust token privileges") + } + return nil +} diff --git a/common/vboxusb/vboxusb.go b/common/vboxusb/vboxusb.go new file mode 100644 index 000000000..77c319934 --- /dev/null +++ b/common/vboxusb/vboxusb.go @@ -0,0 +1,175 @@ +// Package vboxusb provides a Go binding for Oracle's VBoxUSB + +// VBoxUSBMon kernel drivers on Windows (amd64 and arm64), packaged +// for embedding in sing-box. +// +// The driver pair is shipped verbatim from dorssel/usbipd-win, which +// in turn carries the binaries unchanged from upstream VirtualBox. +// VBoxUSB.sys provides per-device user-space URB submission via +// SUPUSB_IOCTL_* IOCTLs; VBoxUSBMon.sys is the system-wide monitor +// that arranges for VBoxUSB to bind matching devices on PnP arrival. +// +// Administrator is required for the first Open in a process so SCM +// can load VBoxUSBMon. The drivers are Microsoft-signed via Oracle's +// VirtualBox publishing chain; we never re-sign or modify them. +// +// Upstream: +// - https://github.com/dorssel/usbipd-win (driver packaging) +// - https://github.com/VirtualBox/VirtualBox (driver source) +// +// License: GPLv2-or-later (drivers) and GPLv3 (this binding); see +// assets/*.license files for the per-asset SPDX records. +package vboxusb + +// AssetVersion identifies the bundled VBoxUSB driver release. Bumped +// in lock-step with the .sys files copied from +// /tmp/usbipd-win/Drivers/{x64,arm64}/. The on-disk extraction path +// (and SCM service version reuse) is keyed on this string, so a +// version bump triggers re-extraction. +const AssetVersion = "7.2.8.23730" + +// DriverVersion enforces the minimum acceptable VBoxUSB/VBoxUSBMon +// driver major version reported via GET_VERSION. Mirrors usbipd-win's +// USBDRV_MAJOR_VERSION / USBMON_MAJOR_VERSION (both 5). +const ( + DriverMajorVersion = 5 + DriverMinorVersion = 0 +) + +// Driver and device names. The Windows side opens the monitor via +// CreateFile(MonitorDevicePath); per-device VBoxUSB handles are opened +// via SetupDi-resolved interface paths under the GUID below. +const ( + MonitorServiceName = "VBoxUSBMon" + MonitorDevicePath = `\\.\VBoxUSBMon` +) + +// MonitorAccessGUID is GUID_CLASS_VBOXUSB from VirtualBox usblib-win.h. +// Used with SetupDiEnumDeviceInterfaces to find the per-device file +// path after VBoxUSB binds. +var MonitorAccessGUID = GUID{ + Data1: 0x00873fdf, + Data2: 0xCAFE, + Data3: 0x80EE, + Data4: [8]byte{0xaa, 0x5e, 0x00, 0xc0, 0x4f, 0xb1, 0x72, 0x0b}, +} + +// USBDeviceInterfaceGUID is GUID_DEVINTERFACE_USB_DEVICE +// ({a5dcbf10-6530-11d2-901f-00c04fb951ed}). Used to enumerate plugged +// USB devices regardless of which function driver currently owns them. +var USBDeviceInterfaceGUID = GUID{ + Data1: 0xa5dcbf10, + Data2: 0x6530, + Data3: 0x11d2, + Data4: [8]byte{0x90, 0x1f, 0x00, 0xc0, 0x4f, 0xb9, 0x51, 0xed}, +} + +// USBHubInterfaceGUID is GUID_DEVINTERFACE_USB_HUB +// ({f18a0e88-c30c-11d0-8815-00a0c906bed8}). The parent hub of a target +// device is opened with this GUID to issue IOCTL_USB_HUB_CYCLE_PORT. +var USBHubInterfaceGUID = GUID{ + Data1: 0xf18a0e88, + Data2: 0xc30c, + Data3: 0x11d0, + Data4: [8]byte{0x88, 0x15, 0x00, 0xa0, 0xc9, 0x06, 0xbe, 0xd8}, +} + +// GUID matches the Windows GUID layout exactly. We carry our own copy +// so the (cross-platform) package-level vars above can be declared +// without depending on golang.org/x/sys/windows. +type GUID struct { + Data1 uint32 + Data2 uint16 + Data3 uint16 + Data4 [8]byte +} + +// IOCTL codes from VirtualBox usblib-win.h, identical to those used by +// usbipd-win (Usbipd/Interop/VBoxUsb.cs:26-39 and VBoxUsbMon.cs:122-129). +// Encoding is the standard CTL_CODE shape: +// +// (DeviceType << 16) | (Access << 14) | (Function << 2) | Method +// +// DeviceType = FILE_DEVICE_UNKNOWN (0x22), Access = FILE_WRITE_ACCESS (2), +// Method = METHOD_BUFFERED (0). +const ( + // Per-device VBoxUSB.sys (\\?\). + IOCTLSendURB uint32 = 0x0022_181C // function 0x607 + IOCTLUSBSelectInterface uint32 = 0x0022_1824 // function 0x609 + IOCTLUSBSetConfig uint32 = 0x0022_1828 // function 0x60a + IOCTLUSBClaimDevice uint32 = 0x0022_182C // function 0x60b + IOCTLUSBClearEndpoint uint32 = 0x0022_1838 // function 0x60e + IOCTLGetVersion uint32 = 0x0022_183C // function 0x60f + IOCTLUSBAbortEndpoint uint32 = 0x0022_1840 // function 0x610 + + // VBoxUSBMon (\\.\VBoxUSBMon). Note GET_VERSION shares the numeric + // code with VBoxUSB's USB_ABORT_ENDPOINT — different handles. + IOCTLMonitorGetVersion uint32 = 0x0022_1840 + IOCTLMonitorAddFilter uint32 = 0x0022_1844 + IOCTLMonitorRemoveFilter uint32 = 0x0022_1848 +) + +// IOCTLHubCyclePort = IOCTL_USB_HUB_CYCLE_PORT from usbioctl.h +// (FILE_DEVICE_USB=0x22, FILE_ANY_ACCESS=0, function=0x111, +// METHOD_BUFFERED=0). +const IOCTLHubCyclePort uint32 = 0x0022_0444 + +// USB/IP-style transfer type enum, matching VirtualBox USBSUP_TRANSFER_TYPE. +type TransferType uint32 + +const ( + TransferTypeControl TransferType = iota + TransferTypeIso + TransferTypeBulk + TransferTypeInterrupt + TransferTypeMessage // control with setup packet inline +) + +// Direction matches USBSUP_DIRECTION. +type Direction uint32 + +const ( + DirectionSetup Direction = iota + DirectionIn + DirectionOut +) + +// TransferFlags matches USBSUP_XFER_FLAG. ShortOK is required for IN +// transfers unless the USB/IP request flags include URB_SHORT_NOT_OK. +type TransferFlags uint32 + +const ( + TransferFlagNone TransferFlags = 0 + TransferFlagShortOK TransferFlags = 1 << 0 +) + +// URBError mirrors USBSUP_ERROR. The session layer maps these into +// USBIP-wire-format status (negated Linux errno). +type URBError uint32 + +const ( + URBOK URBError = iota + URBStall + URBDeviceNotResponding + URBCRCError + URBNACError + URBUnderrun + URBOverrun +) + +// MaxIsoPacketsPerURB is the hard VBoxUSB limit (USBSUP_URB.aIsoPkts +// is sized for 8 entries). Callers with more iso packets must split +// into multiple URBs sharing one pinned buffer; offsets must stay +// within ushort range. +const MaxIsoPacketsPerURB = 8 + +// Filter is a logical builder for VBoxUSBMon ADD_FILTER. The Go side +// owns the byte layout (in monitor_windows.go) so callers see a clean +// API even though the on-the-wire struct is fixed-size packed. +type Filter struct { + VendorID *uint16 + ProductID *uint16 + DeviceRev *uint16 + Bus *uint16 + Port *uint16 + DeviceClass *uint16 +} diff --git a/service/usbip/data_protocol.go b/service/usbip/data_protocol.go index 8d82d72cb..d5828d4b3 100644 --- a/service/usbip/data_protocol.go +++ b/service/usbip/data_protocol.go @@ -26,6 +26,7 @@ const ( maxUSBIPIsoPackets = 1024 nonIsoPacketCount = -1 usbipTransferFlagIsoASAP = 0x0002 + usbipStatusEIO = -5 usbipStatusECONNRESET = -104 ) diff --git a/service/usbip/engine_darwin.go b/service/usbip/engine_darwin.go new file mode 100644 index 000000000..bc10fb77e --- /dev/null +++ b/service/usbip/engine_darwin.go @@ -0,0 +1,39 @@ +//go:build darwin && cgo + +package usbip + +// darwinIOUSBHostEngine drives one captured IOUSBHost device for the +// duration of one USBIP attachment. The device handle itself is owned +// by darwinExportHost (kept across attachments for re-capture), so +// Close here is intentionally a no-op. +type darwinIOUSBHostEngine struct { + device *darwinUSBHostDevice +} + +func newDarwinIOUSBHostEngine(device *darwinUSBHostDevice) *darwinIOUSBHostEngine { + return &darwinIOUSBHostEngine{device: device} +} + +func (e *darwinIOUSBHostEngine) Submit(req URBRequest) URBResponse { + command := req.Command + switch { + case command.Header.Endpoint == 0: + status, actual, outBuf, err := e.device.control(command.Setup, req.Buffer) + return URBResponse{Status: status, ActualLength: actual, Buffer: outBuf, Error: err} + case command.NumberOfPackets > 0: + asap := command.TransferFlags&usbipTransferFlagIsoASAP != 0 + status, actual, outBuf, isoOut, err := e.device.iso(req.Endpoint, req.Buffer, command.StartFrame, asap, req.IsoPackets) + return URBResponse{Status: status, ActualLength: actual, Buffer: outBuf, IsoPackets: isoOut, Error: err} + default: + status, actual, outBuf, err := e.device.io(req.Endpoint, req.Buffer) + return URBResponse{Status: status, ActualLength: actual, Buffer: outBuf, Error: err} + } +} + +func (e *darwinIOUSBHostEngine) AbortEndpoint(endpoint uint8) error { + return e.device.abortEndpoint(endpoint) +} + +func (e *darwinIOUSBHostEngine) Close() error { + return nil +} diff --git a/service/usbip/host_darwin.go b/service/usbip/host_darwin.go index c8bb09943..d7385c281 100644 --- a/service/usbip/host_darwin.go +++ b/service/usbip/host_darwin.go @@ -4,9 +4,7 @@ package usbip import ( "context" - "errors" "fmt" - "io" "maps" "net" "slices" @@ -14,10 +12,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" - - "golang.org/x/sys/unix" ) func newPlatformExportHost(ctx context.Context, logger log.ContextLogger, matches []option.USBIPDeviceMatch) (ExportHost, error) { @@ -348,7 +343,7 @@ func (e *darwinExport) NewServerDataSession(ctx context.Context, conn net.Conn) if e.device == nil { return nil, E.New("darwin export ", e.busid, " has no device handle") } - return newDarwinServerDataSession(ctx, e.logger, conn, e.device), nil + return newUserspaceURBSession(ctx, e.logger, conn, newDarwinIOUSBHostEngine(e.device)), nil } type darwinImportHost struct { @@ -372,456 +367,3 @@ func (h *darwinImportHost) Attach(ctx context.Context, info DeviceInfoTruncated, } return controller, nil } - -var _ DataSession = (*darwinServerDataSession)(nil) - -type darwinServerDataSession struct { - ctx context.Context - logger log.ContextLogger - conn net.Conn - device *darwinUSBHostDevice - writeAccess sync.Mutex - access sync.Mutex - pending map[uint32]darwinServerSubmitState - endpoints map[uint8]*darwinServerEndpointState - wg sync.WaitGroup - - done chan struct{} - doneOnce sync.Once - runErr error - - stateAccess sync.Mutex - started bool - closed bool - closeOnce sync.Once - closeErr error -} - -type darwinServerSubmitState struct { - command SubmitCommand - endpoint uint8 - started bool - unlinked bool - drained chan struct{} -} - -type darwinServerEndpointState struct { - active uint32 - queued []uint32 -} - -type darwinServerNextSubmit struct { - sequence uint32 - command SubmitCommand -} - -func newDarwinServerDataSession(ctx context.Context, logger log.ContextLogger, conn net.Conn, device *darwinUSBHostDevice) *darwinServerDataSession { - return &darwinServerDataSession{ - ctx: ctx, - logger: logger, - conn: conn, - device: device, - pending: make(map[uint32]darwinServerSubmitState), - endpoints: make(map[uint8]*darwinServerEndpointState), - done: make(chan struct{}), - } -} - -func (s *darwinServerDataSession) Done() <-chan struct{} { - return s.done -} - -func (s *darwinServerDataSession) Err() error { - return s.runErr -} - -func (s *darwinServerDataSession) Start() error { - s.stateAccess.Lock() - defer s.stateAccess.Unlock() - if s.started || s.closed { - return nil - } - s.started = true - go s.run() - return nil -} - -func (s *darwinServerDataSession) Close() error { - s.closeOnce.Do(func() { - s.closeErr = common.Close(s.conn) - }) - s.stateAccess.Lock() - started := s.started - s.closed = true - s.stateAccess.Unlock() - if started { - <-s.done - } else { - s.markDone(nil) - } - return s.closeErr -} - -func (s *darwinServerDataSession) markDone(err error) { - s.doneOnce.Do(func() { - s.runErr = err - close(s.done) - }) -} - -func (s *darwinServerDataSession) run() { - err := s.serve() - if err != nil && (errors.Is(err, io.EOF) || E.IsClosedOrCanceled(err)) { - err = nil - } - s.markDone(err) -} - -func (s *darwinServerDataSession) serve() error { - stopCloseOnCancel := closeConnOnContextDone(s.ctx, s.conn) - defer stopCloseOnCancel() - defer func() { - s.abortPendingSubmits() - s.wg.Wait() - }() - for { - header, err := ReadDataHeader(s.conn) - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - switch header.Command { - case CmdSubmit: - command, err := ReadSubmitCommandBody(s.conn, header) - if err != nil { - return err - } - next, shouldStart := s.enqueueSubmit(command) - if shouldStart { - s.startSubmit(next) - } - case CmdUnlink: - command, err := ReadUnlinkCommandBody(s.conn, header) - if err != nil { - return err - } - status := int32(0) - endpoint, drained, shouldAbort, found := s.unlinkSubmit(command.SeqNum) - if found { - if shouldAbort { - abortErr := s.device.abortEndpoint(endpoint) - if abortErr != nil { - s.logger.Debug("abort endpoint 0x", hex8(endpoint), ": ", abortErr) - } - } - <-drained - status = usbipStatusECONNRESET - } - s.writeAccess.Lock() - err = WriteUnlinkResponse(s.conn, UnlinkResponse{ - Header: DataHeader{Command: RetUnlink, SeqNum: header.SeqNum, DevID: header.DevID, Direction: header.Direction, Endpoint: header.Endpoint}, - Status: status, - }) - s.writeAccess.Unlock() - if err != nil { - return err - } - default: - return E.New("unexpected USB/IP command ", fmt.Sprintf("0x%08x", header.Command)) - } - } -} - -func (s *darwinServerDataSession) enqueueSubmit(command SubmitCommand) (darwinServerNextSubmit, bool) { - endpoint := submitScheduleEndpoint(command) - sequence := command.Header.SeqNum - - s.access.Lock() - defer s.access.Unlock() - - state := darwinServerSubmitState{ - command: command, - endpoint: endpoint, - } - endpointState, found := s.endpoints[endpoint] - if !found { - endpointState = &darwinServerEndpointState{} - s.endpoints[endpoint] = endpointState - } - if endpointState.active == 0 { - state.started = true - s.pending[sequence] = state - endpointState.active = sequence - return darwinServerNextSubmit{ - sequence: sequence, - command: command, - }, true - } - s.pending[sequence] = state - endpointState.queued = append(endpointState.queued, sequence) - return darwinServerNextSubmit{}, false -} - -func (s *darwinServerDataSession) startSubmit(next darwinServerNextSubmit) { - s.wg.Add(1) - go func() { - defer s.wg.Done() - - response := s.handleSubmit(next.command) - shouldSend, followUp, hasFollowUp := s.finishSubmit(next.sequence) - if shouldSend { - s.writeAccess.Lock() - err := WriteSubmitResponse(s.conn, response) - s.writeAccess.Unlock() - if err != nil { - _ = s.conn.Close() - } - } - if hasFollowUp { - s.startSubmit(followUp) - } - }() -} - -func (s *darwinServerDataSession) handleSubmit(command SubmitCommand) SubmitResponse { - response := SubmitResponse{ - Header: DataHeader{ - Command: RetSubmit, - SeqNum: command.Header.SeqNum, - DevID: command.Header.DevID, - Direction: command.Header.Direction, - Endpoint: command.Header.Endpoint, - }, - StartFrame: command.StartFrame, - NumberOfPackets: command.NumberOfPackets, - IsoPackets: slices.Clone(command.IsoPackets), - } - buffer := command.Buffer - if command.Header.Direction == USBIPDirIn && command.TransferBufferLength > 0 { - buffer = make([]byte, int(command.TransferBufferLength)) - } - var ( - status int32 - actual int32 - err error - ) - endpoint := commandEndpoint(command) - switch { - case command.Header.Endpoint == 0: - status, actual, buffer, err = s.device.control(command.Setup, buffer) - case command.NumberOfPackets > 0: - asap := command.TransferFlags&usbipTransferFlagIsoASAP != 0 - status, actual, buffer, response.IsoPackets, err = s.device.iso(endpoint, buffer, command.StartFrame, asap, response.IsoPackets) - default: - status, actual, buffer, err = s.device.io(endpoint, buffer) - } - if err != nil { - s.logger.Debug("submit seq ", command.Header.SeqNum, " endpoint 0x", hex8(endpoint), ": ", err) - response.Status = -int32(unix.EIO) - return response - } - response.Status = status - if actual < 0 { - actual = 0 - } - response.ActualLength = actual - if command.Header.Direction == USBIPDirIn && actual > 0 { - if command.NumberOfPackets > 0 { - response.Buffer = packIsoInResponseBuffer(buffer, response.IsoPackets) - response.ActualLength = int32(len(response.Buffer)) - } else { - response.Buffer = buffer[:min(int(actual), len(buffer))] - } - } - return response -} - -func packIsoInResponseBuffer(buffer []byte, packets []IsoPacketDescriptor) []byte { - var total int - for i := range packets { - length := int(packets[i].ActualLength) - if length <= 0 { - packets[i].ActualLength = 0 - continue - } - offset := int(packets[i].Offset) - if offset < 0 || offset >= len(buffer) { - packets[i].ActualLength = 0 - continue - } - if offset+length > len(buffer) { - length = len(buffer) - offset - packets[i].ActualLength = int32(length) - } - total += length - } - if total == 0 { - return nil - } - packed := make([]byte, 0, total) - for i := range packets { - length := int(packets[i].ActualLength) - if length <= 0 { - continue - } - offset := int(packets[i].Offset) - packed = append(packed, buffer[offset:offset+length]...) - } - return packed -} - -func (s *darwinServerDataSession) unlinkSubmit(seq uint32) (uint8, <-chan struct{}, bool, bool) { - var drained chan struct{} - - s.access.Lock() - pending, found := s.pending[seq] - if !found { - s.access.Unlock() - return 0, nil, false, false - } - if pending.drained == nil { - pending.drained = make(chan struct{}) - } - drained = pending.drained - if !pending.started { - endpointState := s.endpoints[pending.endpoint] - if endpointState != nil { - endpointState.queued = removeQueuedSequence(endpointState.queued, seq) - if endpointState.active == 0 && len(endpointState.queued) == 0 { - delete(s.endpoints, pending.endpoint) - } - } - delete(s.pending, seq) - s.access.Unlock() - close(drained) - return pending.endpoint, drained, false, true - } - shouldAbort := !pending.unlinked - pending.unlinked = true - s.pending[seq] = pending - s.access.Unlock() - return pending.endpoint, drained, shouldAbort, true -} - -func (s *darwinServerDataSession) finishSubmit(seq uint32) (bool, darwinServerNextSubmit, bool) { - var drained chan struct{} - var followUp darwinServerNextSubmit - var hasFollowUp bool - - s.access.Lock() - pending, found := s.pending[seq] - if !found { - s.access.Unlock() - return true, darwinServerNextSubmit{}, false - } - endpointState := s.endpoints[pending.endpoint] - if endpointState != nil && endpointState.active == seq { - endpointState.active = 0 - } - delete(s.pending, seq) - if endpointState != nil { - for len(endpointState.queued) > 0 { - nextSequence := endpointState.queued[0] - endpointState.queued = endpointState.queued[1:] - nextPending, nextFound := s.pending[nextSequence] - if !nextFound { - continue - } - nextPending.started = true - s.pending[nextSequence] = nextPending - endpointState.active = nextSequence - followUp = darwinServerNextSubmit{ - sequence: nextSequence, - command: nextPending.command, - } - hasFollowUp = true - break - } - if endpointState.active == 0 && len(endpointState.queued) == 0 { - delete(s.endpoints, pending.endpoint) - } - } - drained = pending.drained - unlinked := pending.unlinked - s.access.Unlock() - if drained != nil { - close(drained) - } - return !unlinked, followUp, hasFollowUp -} - -func (s *darwinServerDataSession) abortPendingSubmits() { - var ( - activeEndpoints []uint8 - drained []chan struct{} - ) - - s.access.Lock() - seen := make(map[uint8]struct{}) - for seq, pending := range s.pending { - if !pending.started { - delete(s.pending, seq) - if pending.drained != nil { - drained = append(drained, pending.drained) - } - continue - } - if !pending.unlinked { - seen[pending.endpoint] = struct{}{} - } - pending.unlinked = true - s.pending[seq] = pending - } - for endpoint := range s.endpoints { - endpointState := s.endpoints[endpoint] - if endpointState != nil { - endpointState.queued = nil - } - } - s.access.Unlock() - - for _, drainedChannel := range drained { - close(drainedChannel) - } - activeEndpoints = make([]uint8, 0, len(seen)) - for endpoint := range seen { - activeEndpoints = append(activeEndpoints, endpoint) - } - slices.Sort(activeEndpoints) - if s.device == nil { - return - } - for _, endpoint := range activeEndpoints { - err := s.device.abortEndpoint(endpoint) - if err != nil { - s.logger.Debug("abort endpoint 0x", hex8(endpoint), ": ", err) - } - } -} - -func removeQueuedSequence(queue []uint32, sequence uint32) []uint32 { - for index, current := range queue { - if current != sequence { - continue - } - return append(queue[:index], queue[index+1:]...) - } - return queue -} - -func submitScheduleEndpoint(command SubmitCommand) uint8 { - if command.Header.Endpoint == 0 { - return 0 - } - return commandEndpoint(command) -} - -func commandEndpoint(command SubmitCommand) uint8 { - endpoint := uint8(command.Header.Endpoint & 0x0f) - if command.Header.Direction == USBIPDirIn { - endpoint |= 0x80 - } - return endpoint -} diff --git a/service/usbip/session_userspace.go b/service/usbip/session_userspace.go new file mode 100644 index 000000000..8fab029ef --- /dev/null +++ b/service/usbip/session_userspace.go @@ -0,0 +1,470 @@ +//go:build linux || (darwin && cgo) + +package usbip + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "slices" + "sync" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +var _ DataSession = (*userspaceURBSession)(nil) + +// userspaceURBSession drives one USBIP attachment when the per-URB I/O +// happens in user space (Darwin IOUSBHost, Windows VBoxUSB). It reads +// CMD_SUBMIT/CMD_UNLINK from conn, serializes per endpoint, dispatches +// to a URBEngine, frames RET_SUBMIT/RET_UNLINK back over conn. Linux +// kernel-handoff sessions use kernelHandoffSession instead. +type userspaceURBSession struct { + ctx context.Context + logger log.ContextLogger + conn net.Conn + engine URBEngine + + writeAccess sync.Mutex + access sync.Mutex + pending map[uint32]userspaceSubmitState + endpoints map[uint8]*userspaceEndpointState + wg sync.WaitGroup + + done chan struct{} + doneOnce sync.Once + runErr error + + stateAccess sync.Mutex + started bool + closed bool + closeOnce sync.Once + closeErr error +} + +type userspaceSubmitState struct { + command SubmitCommand + endpoint uint8 + started bool + unlinked bool + drained chan struct{} +} + +type userspaceEndpointState struct { + active uint32 + queued []uint32 +} + +type userspaceNextSubmit struct { + sequence uint32 + command SubmitCommand +} + +func newUserspaceURBSession(ctx context.Context, logger log.ContextLogger, conn net.Conn, engine URBEngine) *userspaceURBSession { + return &userspaceURBSession{ + ctx: ctx, + logger: logger, + conn: conn, + engine: engine, + pending: make(map[uint32]userspaceSubmitState), + endpoints: make(map[uint8]*userspaceEndpointState), + done: make(chan struct{}), + } +} + +func (s *userspaceURBSession) Done() <-chan struct{} { + return s.done +} + +func (s *userspaceURBSession) Err() error { + return s.runErr +} + +func (s *userspaceURBSession) Start() error { + s.stateAccess.Lock() + defer s.stateAccess.Unlock() + if s.started || s.closed { + return nil + } + s.started = true + go s.run() + return nil +} + +func (s *userspaceURBSession) Close() error { + s.closeOnce.Do(func() { + s.closeErr = common.Close(s.conn) + }) + s.stateAccess.Lock() + started := s.started + s.closed = true + s.stateAccess.Unlock() + if started { + <-s.done + } else { + s.markDone(nil) + } + _ = s.engine.Close() + return s.closeErr +} + +func (s *userspaceURBSession) markDone(err error) { + s.doneOnce.Do(func() { + s.runErr = err + close(s.done) + }) +} + +func (s *userspaceURBSession) run() { + err := s.serve() + if err != nil && (errors.Is(err, io.EOF) || E.IsClosedOrCanceled(err)) { + err = nil + } + s.markDone(err) +} + +func (s *userspaceURBSession) serve() error { + stopCloseOnCancel := closeConnOnContextDone(s.ctx, s.conn) + defer stopCloseOnCancel() + defer func() { + s.abortPendingSubmits() + s.wg.Wait() + }() + for { + header, err := ReadDataHeader(s.conn) + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + switch header.Command { + case CmdSubmit: + command, err := ReadSubmitCommandBody(s.conn, header) + if err != nil { + return err + } + next, shouldStart := s.enqueueSubmit(command) + if shouldStart { + s.startSubmit(next) + } + case CmdUnlink: + command, err := ReadUnlinkCommandBody(s.conn, header) + if err != nil { + return err + } + status := int32(0) + endpoint, drained, shouldAbort, found := s.unlinkSubmit(command.SeqNum) + if found { + if shouldAbort { + abortErr := s.engine.AbortEndpoint(endpoint) + if abortErr != nil { + s.logger.Debug("abort endpoint 0x", hex8(endpoint), ": ", abortErr) + } + } + <-drained + status = usbipStatusECONNRESET + } + s.writeAccess.Lock() + err = WriteUnlinkResponse(s.conn, UnlinkResponse{ + Header: DataHeader{Command: RetUnlink, SeqNum: header.SeqNum, DevID: header.DevID, Direction: header.Direction, Endpoint: header.Endpoint}, + Status: status, + }) + s.writeAccess.Unlock() + if err != nil { + return err + } + default: + return E.New("unexpected USB/IP command ", fmt.Sprintf("0x%08x", header.Command)) + } + } +} + +func (s *userspaceURBSession) enqueueSubmit(command SubmitCommand) (userspaceNextSubmit, bool) { + endpoint := submitScheduleEndpoint(command) + sequence := command.Header.SeqNum + + s.access.Lock() + defer s.access.Unlock() + + state := userspaceSubmitState{ + command: command, + endpoint: endpoint, + } + endpointState, found := s.endpoints[endpoint] + if !found { + endpointState = &userspaceEndpointState{} + s.endpoints[endpoint] = endpointState + } + if endpointState.active == 0 { + state.started = true + s.pending[sequence] = state + endpointState.active = sequence + return userspaceNextSubmit{ + sequence: sequence, + command: command, + }, true + } + s.pending[sequence] = state + endpointState.queued = append(endpointState.queued, sequence) + return userspaceNextSubmit{}, false +} + +func (s *userspaceURBSession) startSubmit(next userspaceNextSubmit) { + s.wg.Add(1) + go func() { + defer s.wg.Done() + + response := s.handleSubmit(next.command) + shouldSend, followUp, hasFollowUp := s.finishSubmit(next.sequence) + if shouldSend { + s.writeAccess.Lock() + err := WriteSubmitResponse(s.conn, response) + s.writeAccess.Unlock() + if err != nil { + _ = s.conn.Close() + } + } + if hasFollowUp { + s.startSubmit(followUp) + } + }() +} + +func (s *userspaceURBSession) handleSubmit(command SubmitCommand) SubmitResponse { + response := SubmitResponse{ + Header: DataHeader{ + Command: RetSubmit, + SeqNum: command.Header.SeqNum, + DevID: command.Header.DevID, + Direction: command.Header.Direction, + Endpoint: command.Header.Endpoint, + }, + StartFrame: command.StartFrame, + NumberOfPackets: command.NumberOfPackets, + IsoPackets: slices.Clone(command.IsoPackets), + } + buffer := command.Buffer + if command.Header.Direction == USBIPDirIn && command.TransferBufferLength > 0 { + buffer = make([]byte, int(command.TransferBufferLength)) + } + endpoint := commandEndpoint(command) + result := s.engine.Submit(URBRequest{ + Command: command, + Endpoint: endpoint, + Buffer: buffer, + IsoPackets: response.IsoPackets, + }) + if result.Error != nil { + s.logger.Debug("submit seq ", command.Header.SeqNum, " endpoint 0x", hex8(endpoint), ": ", result.Error) + response.Status = usbipStatusEIO + return response + } + response.Status = result.Status + if result.IsoPackets != nil { + response.IsoPackets = result.IsoPackets + } + actual := result.ActualLength + if actual < 0 { + actual = 0 + } + response.ActualLength = actual + if command.Header.Direction == USBIPDirIn && actual > 0 { + if command.NumberOfPackets > 0 { + response.Buffer = packIsoInResponseBuffer(result.Buffer, response.IsoPackets) + response.ActualLength = int32(len(response.Buffer)) + } else { + response.Buffer = result.Buffer[:min(int(actual), len(result.Buffer))] + } + } + return response +} + +func packIsoInResponseBuffer(buffer []byte, packets []IsoPacketDescriptor) []byte { + var total int + for i := range packets { + length := int(packets[i].ActualLength) + if length <= 0 { + packets[i].ActualLength = 0 + continue + } + offset := int(packets[i].Offset) + if offset < 0 || offset >= len(buffer) { + packets[i].ActualLength = 0 + continue + } + if offset+length > len(buffer) { + length = len(buffer) - offset + packets[i].ActualLength = int32(length) + } + total += length + } + if total == 0 { + return nil + } + packed := make([]byte, 0, total) + for i := range packets { + length := int(packets[i].ActualLength) + if length <= 0 { + continue + } + offset := int(packets[i].Offset) + packed = append(packed, buffer[offset:offset+length]...) + } + return packed +} + +func (s *userspaceURBSession) unlinkSubmit(seq uint32) (uint8, <-chan struct{}, bool, bool) { + var drained chan struct{} + + s.access.Lock() + pending, found := s.pending[seq] + if !found { + s.access.Unlock() + return 0, nil, false, false + } + if pending.drained == nil { + pending.drained = make(chan struct{}) + } + drained = pending.drained + if !pending.started { + endpointState := s.endpoints[pending.endpoint] + if endpointState != nil { + endpointState.queued = removeQueuedSequence(endpointState.queued, seq) + if endpointState.active == 0 && len(endpointState.queued) == 0 { + delete(s.endpoints, pending.endpoint) + } + } + delete(s.pending, seq) + s.access.Unlock() + close(drained) + return pending.endpoint, drained, false, true + } + shouldAbort := !pending.unlinked + pending.unlinked = true + s.pending[seq] = pending + s.access.Unlock() + return pending.endpoint, drained, shouldAbort, true +} + +func (s *userspaceURBSession) finishSubmit(seq uint32) (bool, userspaceNextSubmit, bool) { + var drained chan struct{} + var followUp userspaceNextSubmit + var hasFollowUp bool + + s.access.Lock() + pending, found := s.pending[seq] + if !found { + s.access.Unlock() + return true, userspaceNextSubmit{}, false + } + endpointState := s.endpoints[pending.endpoint] + if endpointState != nil && endpointState.active == seq { + endpointState.active = 0 + } + delete(s.pending, seq) + if endpointState != nil { + for len(endpointState.queued) > 0 { + nextSequence := endpointState.queued[0] + endpointState.queued = endpointState.queued[1:] + nextPending, nextFound := s.pending[nextSequence] + if !nextFound { + continue + } + nextPending.started = true + s.pending[nextSequence] = nextPending + endpointState.active = nextSequence + followUp = userspaceNextSubmit{ + sequence: nextSequence, + command: nextPending.command, + } + hasFollowUp = true + break + } + if endpointState.active == 0 && len(endpointState.queued) == 0 { + delete(s.endpoints, pending.endpoint) + } + } + drained = pending.drained + unlinked := pending.unlinked + s.access.Unlock() + if drained != nil { + close(drained) + } + return !unlinked, followUp, hasFollowUp +} + +func (s *userspaceURBSession) abortPendingSubmits() { + var ( + activeEndpoints []uint8 + drained []chan struct{} + ) + + s.access.Lock() + seen := make(map[uint8]struct{}) + for seq, pending := range s.pending { + if !pending.started { + delete(s.pending, seq) + if pending.drained != nil { + drained = append(drained, pending.drained) + } + continue + } + if !pending.unlinked { + seen[pending.endpoint] = struct{}{} + } + pending.unlinked = true + s.pending[seq] = pending + } + for endpoint := range s.endpoints { + endpointState := s.endpoints[endpoint] + if endpointState != nil { + endpointState.queued = nil + } + } + s.access.Unlock() + + for _, drainedChannel := range drained { + close(drainedChannel) + } + activeEndpoints = make([]uint8, 0, len(seen)) + for endpoint := range seen { + activeEndpoints = append(activeEndpoints, endpoint) + } + slices.Sort(activeEndpoints) + for _, endpoint := range activeEndpoints { + err := s.engine.AbortEndpoint(endpoint) + if err != nil { + s.logger.Debug("abort endpoint 0x", hex8(endpoint), ": ", err) + } + } +} + +func removeQueuedSequence(queue []uint32, sequence uint32) []uint32 { + for index, current := range queue { + if current != sequence { + continue + } + return append(queue[:index], queue[index+1:]...) + } + return queue +} + +func submitScheduleEndpoint(command SubmitCommand) uint8 { + if command.Header.Endpoint == 0 { + return 0 + } + return commandEndpoint(command) +} + +func commandEndpoint(command SubmitCommand) uint8 { + endpoint := uint8(command.Header.Endpoint & 0x0f) + if command.Header.Direction == USBIPDirIn { + endpoint |= 0x80 + } + return endpoint +} diff --git a/service/usbip/shared.go b/service/usbip/shared.go index 38e7e960b..a8600fe34 100644 --- a/service/usbip/shared.go +++ b/service/usbip/shared.go @@ -42,6 +42,11 @@ func closeConnOnContextDone(ctx context.Context, conn net.Conn) func() { } } +func hex8(v uint8) string { + const hexdigits = "0123456789abcdef" + return string([]byte{hexdigits[(v>>4)&0xf], hexdigits[v&0xf]}) +} + func describeMatch(m option.USBIPDeviceMatch) string { var parts []string if m.BusID != "" { diff --git a/service/usbip/shared_darwin.go b/service/usbip/shared_darwin.go deleted file mode 100644 index 80fbba148..000000000 --- a/service/usbip/shared_darwin.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build darwin && cgo - -package usbip - -func hex8(v uint8) string { - const hexdigits = "0123456789abcdef" - return string([]byte{hexdigits[(v>>4)&0xf], hexdigits[v&0xf]}) -} diff --git a/service/usbip/urb_engine.go b/service/usbip/urb_engine.go new file mode 100644 index 000000000..a64576c51 --- /dev/null +++ b/service/usbip/urb_engine.go @@ -0,0 +1,49 @@ +//go:build linux || (darwin && cgo) + +package usbip + +// URBEngine executes USB Request Blocks against an already-claimed +// device. The session layer (session_userspace.go) handles framing, +// per-endpoint ordering, and unlink bookkeeping; the engine performs +// the per-URB I/O and per-endpoint aborts only. +// +// Submit is called from per-endpoint goroutines; the session never +// issues two Submits concurrently for the same endpoint, so the engine +// does not need its own cross-endpoint serialization. +type URBEngine interface { + Submit(request URBRequest) URBResponse + // AbortEndpoint cancels all in-flight submits on the given raw + // endpoint address (direction bit included). It is invoked once per + // pending sequence at CMD_UNLINK time and once per active endpoint + // at session shutdown. + AbortEndpoint(endpoint uint8) error + // Close releases engine-owned resources. For engines that own the + // underlying device handle (e.g. Windows VBoxUSB), this releases it. + // For engines where the host manages the device handle separately + // (e.g. Darwin IOUSBHost capture), Close may be a no-op. Idempotent. + Close() error +} + +// URBRequest carries one decoded CMD_SUBMIT plus session-owned buffers. +// Buffer holds the OUT payload on entry, or a pre-allocated zero buffer +// for IN transfers. IsoPackets is pre-cloned from the wire command so +// the engine may overwrite descriptors in place during iso completion. +type URBRequest struct { + Command SubmitCommand + Endpoint uint8 + Buffer []byte + IsoPackets []IsoPacketDescriptor +} + +// URBResponse is the engine's verdict on one URB. Status follows USBIP +// convention (negated errno, 0 on success). ActualLength is the number +// of payload bytes valid in Buffer. Error is engine-internal failure +// distinct from a USB-level error: on Error the session emits +// Status = usbipStatusEIO and logs at Debug. +type URBResponse struct { + Status int32 + ActualLength int32 + Buffer []byte + IsoPackets []IsoPacketDescriptor + Error error +}