usbip: extract URBEngine + userspaceURBSession from darwin backend

Both the existing darwin backend and the upcoming windows backend
drive USB devices from user space (IOUSBHost CGO calls vs. VBoxUSB
IOCTLs). Refactor the per-attachment URB loop out of host_darwin.go
into a platform-agnostic userspaceURBSession that talks to a URBEngine
interface; the darwin-specific dispatch becomes a 30-line
darwinIOUSBHostEngine. Linux's kernelHandoffSession is untouched.

Move hex8 into shared.go and add usbipStatusEIO so the shared session
does not depend on golang.org/x/sys/unix (Windows has no equivalent).
This commit is contained in:
世界 2026-05-18 12:38:47 +08:00
parent e59e14d89d
commit 6a4ae5263e
No known key found for this signature in database
GPG key ID: CD109927C34A63C4
24 changed files with 2110 additions and 467 deletions

Binary file not shown.

View file

@ -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 <https://www.gnu.org/licenses>.
;
; 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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 <https://www.gnu.org/licenses>.
;
; 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

Binary file not shown.

Binary file not shown.

View file

@ -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},
}
}

View file

@ -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},
}
}

View file

@ -0,0 +1,5 @@
//go:build windows && !amd64 && !arm64
package vboxusb
func assetFiles() []assetFile { return nil }

View file

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

View file

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

View file

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

View file

@ -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 ("<bus>-<address>")
// 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 // "<bus>-<address>"
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,
&params[0], uint32(len(params)),
&params[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
)

View file

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

175
common/vboxusb/vboxusb.go Normal file
View file

@ -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 (\\?\<setupapi-resolved path>).
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
}

View file

@ -26,6 +26,7 @@ const (
maxUSBIPIsoPackets = 1024
nonIsoPacketCount = -1
usbipTransferFlagIsoASAP = 0x0002
usbipStatusEIO = -5
usbipStatusECONNRESET = -104
)

View file

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

View file

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

View file

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

View file

@ -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 != "" {

View file

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

View file

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