Files
sing-box/service/usbip/host_linux.go
T
世界 8799b10b9a usbip: defer darwin RET_UNLINK until completion; route VHCI through primary; close lease-consume race
Three independent correctness bugs that all violated "decide before
publishing": each surface (wire reply, sysfs path, broadcast state)
committed to a state the code later had to contradict.

darwin: switch IOUSBHostPipe abort to IOUSBHostAbortOptionSynchronous and
add the EP-0 path via abortDeviceRequestsWithOption (previously a silent
no-op for control transfers). After abort returns, await a per-pending
drained channel before writing RET_UNLINK so the late RetSubmit
suppression in finishSubmit has actually taken effect — the drained
channel is allocated lazily by markSubmitUnlinked, so the hot submit
path is unchanged.

linux: collapse the per-controller VHCI abstraction. The kernel exposes
attach/detach/status/status.N only on vhci_hcd.0 with globally unique
port numbers; the previous code wrote vhci_hcd.N/attach for N>0 and
silently failed past the primary controller's port range. Glob status*
on the primary, key reservations on bare port int, and carry the
status-suffix as a diagnostic-only secondary index for Description().

export_ledger: ConsumeLeaseAndReserve no longer publishes busy=true and
then conditionally rolls it back. Read seq under fast first, then make
every decision (including the lease.Generation check) inside one slow
critical section before any busy mutation. This removes the window
where a concurrent BroadcastIfChanged could observe transient busy and
poison l.state with no corrective broadcast on rollback.
2026-06-09 10:42:30 +08:00

600 lines
15 KiB
Go

//go:build linux
package usbip
import (
"context"
"errors"
"fmt"
"maps"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/sys/unix"
)
func newPlatformExportHost(logger log.ContextLogger, matches []option.USBIPDeviceMatch) (ExportHost, error) {
return newLinuxExportHost(logger, matches), nil
}
func newPlatformImportHost(logger log.ContextLogger) (ImportHost, error) {
return &linuxImportHost{
logger: logger,
ports: make(map[int]struct{}),
}, nil
}
func isMissingUSBDeviceError(err error) bool {
return errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENODEV)
}
func linuxUSBIPStatusReason(status int) string {
switch status {
case usbipStatusAvailable:
return "available"
case usbipStatusUsed:
return "used"
case usbipStatusError:
return "error"
default:
return fmt.Sprintf("status=0x%08x", uint32(status))
}
}
type linuxExportHost struct {
logger log.ContextLogger
matches []option.USBIPDeviceMatch
access sync.Mutex
exports map[string]*linuxExport
}
func newLinuxExportHost(logger log.ContextLogger, matches []option.USBIPDeviceMatch) *linuxExportHost {
return &linuxExportHost{
logger: logger,
matches: matches,
exports: make(map[string]*linuxExport),
}
}
func (h *linuxExportHost) Start(ctx context.Context) error {
return ensureKernelPath(sysUsbipHostDriver, "usbip-host", "usbip-host driver")
}
func (h *linuxExportHost) Close() error {
h.access.Lock()
exports := h.exports
h.exports = make(map[string]*linuxExport)
h.access.Unlock()
for _, exp := range exports {
_, statErr := os.Stat(filepath.Join(sysBusUSBDevices, exp.busid))
restore := statErr == nil
releaseErr := h.releaseExport(exp, restore)
if releaseErr != nil {
h.logger.Warn("rollback ", exp.busid, ": ", releaseErr)
}
}
return nil
}
func (h *linuxExportHost) Events(ctx context.Context) (<-chan struct{}, error) {
ch := make(chan struct{}, 1)
go h.ueventLoop(ctx, ch)
return ch, nil
}
func (h *linuxExportHost) ueventLoop(ctx context.Context, ch chan<- struct{}) {
defer close(ch)
signal := func() {
select {
case ch <- struct{}{}:
default:
}
}
backoff := ueventListenerBackoffInitial
for {
listener, err := newUEventListener()
if err != nil {
if ctx.Err() != nil {
return
}
h.logger.Warn("open uevent listener: ", err)
if !sleepCtx(ctx, backoff) {
return
}
backoff *= 2
if backoff > ueventListenerBackoffMax {
backoff = ueventListenerBackoffMax
}
continue
}
backoff = ueventListenerBackoffInitial
listenerDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = listener.Close()
case <-listenerDone:
}
}()
signal()
for {
err = listener.WaitUSBEvent()
if err != nil {
close(listenerDone)
_ = listener.Close()
if ctx.Err() != nil {
return
}
h.logger.Warn("read uevent: ", err)
if !sleepCtx(ctx, backoff) {
return
}
backoff *= 2
if backoff > ueventListenerBackoffMax {
backoff = ueventListenerBackoffMax
}
break
}
signal()
}
}
}
const (
ueventListenerBackoffInitial = time.Second
ueventListenerBackoffMax = 30 * time.Second
)
func (h *linuxExportHost) Reconcile(ctx context.Context, isBusy func(busid string) bool) (map[string]Export, []string, error) {
devices, err := listUSBDevices()
if err != nil {
return h.snapshotSelf(), nil, E.Cause(err, "enumerate usb devices")
}
desired := make(map[string]sysfsDevice)
present := make(map[string]struct{}, len(devices))
for i := range devices {
present[devices[i].BusID] = struct{}{}
}
for _, m := range h.matches {
for i := range devices {
deviceKey := DeviceKey{
BusID: devices[i].BusID,
VendorID: devices[i].VendorID,
ProductID: devices[i].ProductID,
Serial: devices[i].Serial,
}
if !matches(m, deviceKey) {
continue
}
path := devices[i].Path
isVHCIImport := strings.Contains(path, "vhci_hcd")
if !isVHCIImport {
realPath, err := filepath.EvalSymlinks(path)
if err == nil {
isVHCIImport = strings.Contains(realPath, "vhci_hcd")
}
}
if isVHCIImport {
h.logger.Debug("skip vhci-imported device ", devices[i].BusID, " matched by ", describeMatch(m))
continue
}
if devices[i].DeviceClass == 0x09 {
h.logger.Warn("skip hub device ", devices[i].BusID, " matched by ", describeMatch(m))
continue
}
desired[devices[i].BusID] = devices[i]
}
}
h.access.Lock()
current := make(map[string]*linuxExport, len(h.exports))
maps.Copy(current, h.exports)
h.access.Unlock()
for busid, device := range desired {
if _, ok := current[busid]; ok {
continue
}
exp, bindErr := h.bindOne(&device)
if bindErr != nil {
return h.snapshotSelf(), nil, E.Cause(bindErr, "bind ", busid)
}
h.access.Lock()
h.exports[busid] = exp
h.access.Unlock()
}
var released []string
for busid, exp := range current {
if _, ok := desired[busid]; ok {
continue
}
_, restore := present[busid]
err := h.releaseExport(exp, restore)
if err != nil {
h.logger.Warn("release ", busid, ": ", err)
}
h.access.Lock()
delete(h.exports, busid)
h.access.Unlock()
released = append(released, busid)
}
return h.snapshotSelf(), released, nil
}
func (h *linuxExportHost) FinishImport(ctx context.Context, busid string) (bool, error) {
err := writeSysfs(filepath.Join(sysBusUSBDevices, busid, "usbip_sockfd"), "-1")
if err != nil && !os.IsNotExist(err) && !isMissingUSBDeviceError(err) {
h.logger.Debug("release ", busid, " from usbip-host: ", err)
}
waitForUsbipStatusCleared(ctx, busid)
return false, nil
}
func (h *linuxExportHost) snapshotSelf() map[string]Export {
h.access.Lock()
defer h.access.Unlock()
out := make(map[string]Export, len(h.exports))
for busid, exp := range h.exports {
out[busid] = exp
}
return out
}
func (h *linuxExportHost) bindOne(d *sysfsDevice) (*linuxExport, error) {
var (
exp *linuxExport
err error
)
for attempt := range 2 {
exp, err = h.bindOneOnce(d)
if err == nil {
return exp, nil
}
if attempt > 0 || !errors.Is(err, unix.ENODEV) {
break
}
h.logger.Warn("reset usbip-host after bind failure on ", d.BusID, ": ", err)
h.access.Lock()
active := len(h.exports) > 0
h.access.Unlock()
if active {
return nil, E.Cause(E.New("active usbip-host exports are present"), "reset usbip-host after bind failure")
}
resetErr := reloadHostDriver()
if resetErr != nil {
return nil, E.Cause(resetErr, "reset usbip-host after bind failure")
}
}
return nil, err
}
func (h *linuxExportHost) bindOneOnce(d *sysfsDevice) (*linuxExport, error) {
driver, err := currentDriver(d.BusID)
if err != nil {
return nil, err
}
if driver == "usbip-host" {
h.logger.Info("device ", d.BusID, " already bound to usbip-host; co-opting")
return h.newExport(*d, false, ""), nil
}
if driver != "" {
err = writeSysfs(filepath.Join("/sys/bus/usb/drivers", driver, "unbind"), d.BusID)
if err != nil {
return nil, E.Cause(err, "unbind from ", driver)
}
}
matchBusIDPath := filepath.Join(sysUsbipHostDriver, "match_busid")
err = writeSysfs(matchBusIDPath, "add "+d.BusID)
if err != nil {
if driver != "" {
_ = writeSysfs(filepath.Join("/sys/bus/usb/drivers", driver, "bind"), d.BusID)
}
return nil, E.Cause(err, "match_busid add")
}
err = writeSysfs(filepath.Join(sysUsbipHostDriver, "bind"), d.BusID)
if err != nil {
_ = writeSysfs(matchBusIDPath, "del "+d.BusID)
if driver != "" {
_ = writeSysfs(filepath.Join("/sys/bus/usb/drivers", driver, "bind"), d.BusID)
}
return nil, E.Cause(err, "bind to usbip-host")
}
previousDriver := driver
if previousDriver == "" {
previousDriver = "(no driver)"
}
h.logger.Info("exported ", d.BusID, " (previously on ", previousDriver, ")")
return h.newExport(*d, true, driver), nil
}
func (h *linuxExportHost) releaseExport(exp *linuxExport, restore bool) error {
if !exp.managed {
h.logger.Info("stopped tracking ", exp.busid, " on usbip-host")
return nil
}
status, statusErr := readUsbipStatus(exp.busid)
if statusErr != nil && !os.IsNotExist(statusErr) && !isMissingUSBDeviceError(statusErr) {
return statusErr
}
if statusErr == nil && status == usbipStatusUsed {
err := writeSysfs(filepath.Join(sysBusUSBDevices, exp.busid, "usbip_sockfd"), "-1")
if err != nil && !os.IsNotExist(err) {
return err
}
}
err := writeSysfs(filepath.Join(sysUsbipHostDriver, "unbind"), exp.busid)
if err != nil && !os.IsNotExist(err) && !(isMissingUSBDeviceError(err) && !restore) {
return err
}
err = writeSysfs(filepath.Join(sysUsbipHostDriver, "match_busid"), "del "+exp.busid)
if err != nil {
return err
}
if !restore {
h.logger.Info("removed export state for disappeared device ", exp.busid)
return nil
}
if exp.originalDriver == "" {
h.logger.Info("released ", exp.busid, " from usbip-host")
return nil
}
err = writeSysfs(filepath.Join("/sys/bus/usb/drivers", exp.originalDriver, "bind"), exp.busid)
if err != nil {
return err
}
h.logger.Info("restored ", exp.busid, " to ", exp.originalDriver)
return nil
}
func (h *linuxExportHost) newExport(descriptor sysfsDevice, managed bool, originalDriver string) *linuxExport {
return &linuxExport{
busid: descriptor.BusID,
descriptor: descriptor,
managed: managed,
originalDriver: originalDriver,
logger: h.logger,
}
}
// linuxExport caches the bind-time descriptor because it is immutable
// post-enumeration; Snapshot only re-reads usbip_status.
type linuxExport struct {
busid string
descriptor sysfsDevice
managed bool
originalDriver string
logger log.ContextLogger
}
func (e *linuxExport) BusID() string {
return e.busid
}
func (e *linuxExport) Snapshot(ctx context.Context, busy bool) ExportSnapshot {
stableID := "linux-busid:" + e.descriptor.BusID
if e.descriptor.Serial != "" {
stableID = fmt.Sprintf("usb:%04x:%04x:%s", e.descriptor.VendorID, e.descriptor.ProductID, e.descriptor.Serial)
}
status, statusErr := readUsbipStatus(e.busid)
var state, reason string
switch {
case statusErr != nil:
state = deviceStateUnavailable
reason = statusErr.Error()
case busy:
status = usbipStatusUsed
state = deviceStateBusy
reason = linuxUSBIPStatusReason(status)
case status == usbipStatusAvailable:
state = deviceStateAvailable
reason = linuxUSBIPStatusReason(status)
case status == usbipStatusUsed:
state = deviceStateBusy
reason = linuxUSBIPStatusReason(status)
default:
state = deviceStateUnavailable
reason = linuxUSBIPStatusReason(status)
}
return ExportSnapshot{
Entry: DeviceEntry{
Info: e.descriptor.toProtocol(),
Interfaces: e.descriptor.Interfaces,
Serial: e.descriptor.Serial,
},
Backend: backendIDLinuxSysfs,
StableID: stableID,
State: state,
StatusReason: reason,
RawStatus: status,
}
}
func (e *linuxExport) LeaseCheck(ctx context.Context) (bool, string) {
status, err := readUsbipStatus(e.busid)
if err != nil {
return false, err.Error()
}
if status != usbipStatusAvailable {
return false, linuxUSBIPStatusReason(status)
}
return true, ""
}
func (e *linuxExport) DeviceInfo(ctx context.Context) (DeviceInfoTruncated, error) {
return e.descriptor.toProtocol(), nil
}
func (e *linuxExport) NewServerDataSession(ctx context.Context, conn net.Conn) (DataSession, error) {
handoff, err := newKernelHandoffSession(ctx, conn, e.logger, "server", e.busid)
if err != nil {
return nil, E.Cause(err, "prepare handoff")
}
mode := "direct"
if handoff.relayConn != nil {
mode = "relay"
}
e.logger.Debug("usbip server handoff ", e.busid, ": ", mode)
err = writeSysfs(filepath.Join(sysBusUSBDevices, e.busid, "usbip_sockfd"), strconv.Itoa(int(handoff.file.Fd())))
if err != nil {
_ = handoff.Close()
return nil, E.Cause(err, "hand off ", e.busid, " to kernel")
}
closeErr := handoff.closeKernelFD()
if closeErr != nil {
e.logger.Debug("close kernel fd ", e.busid, ": ", closeErr)
}
return handoff, nil
}
type linuxImportHost struct {
logger log.ContextLogger
portsAccess sync.Mutex
ports map[int]struct{}
}
func (h *linuxImportHost) Start(ctx context.Context) error {
return ensureKernelPath(sysVHCIControllerV0, "vhci-hcd", "vhci_hcd.0")
}
func (h *linuxImportHost) Close() error {
return nil
}
func (h *linuxImportHost) Attach(ctx context.Context, info DeviceInfoTruncated, conn net.Conn) (AttachedSession, error) {
handoff, err := newKernelHandoffSession(ctx, conn, h.logger, "client", info.BusIDString())
if err != nil {
return nil, E.Cause(err, "prepare handoff")
}
mode := "direct"
if handoff.relayConn != nil {
mode = "relay"
}
h.logger.Debug("usbip client handoff ", info.BusIDString(), ": ", mode)
port, secondary, attachErr := h.attachOnce(ctx, info, handoff)
if attachErr != nil {
_ = handoff.Close()
return nil, attachErr
}
_ = handoff.Start()
return &linuxClientSession{
handoff: handoff,
host: h,
port: port,
secondary: secondary,
}, nil
}
func (h *linuxImportHost) attachOnce(ctx context.Context, info DeviceInfoTruncated, handoff *kernelHandoffSession) (int, int, error) {
triedPorts := make(map[int]struct{})
for {
port, err := vhciPickFreePort(info.Speed, triedPorts)
if err != nil {
return -1, 0, err
}
if !h.reservePort(port) {
triedPorts[port] = struct{}{}
continue
}
attachLine := fmt.Sprintf("%d %d %d %d", port, int(handoff.file.Fd()), info.DevID(), info.Speed)
err = writeSysfs(filepath.Join(sysVHCIControllerV0, "attach"), attachLine)
if err != nil {
h.releasePort(port)
if errors.Is(err, unix.EBUSY) {
triedPorts[port] = struct{}{}
continue
}
return -1, 0, E.Cause(err, "vhci attach")
}
err = handoff.closeKernelFD()
if err != nil {
h.logger.Debug("close kernel fd ", info.BusIDString(), ": ", err)
}
return port, lookupSecondaryForPort(port), nil
}
}
func lookupSecondaryForPort(port int) int {
records, err := readPrimaryVHCIStatus()
if err != nil {
return 0
}
for _, record := range records {
if record.port == port {
return record.secondary
}
}
return 0
}
func (h *linuxImportHost) reservePort(port int) bool {
h.portsAccess.Lock()
defer h.portsAccess.Unlock()
_, exists := h.ports[port]
if exists {
h.logger.Debug("vhci port ", port, " already reserved locally")
return false
}
h.logger.Debug("reserve vhci port ", port)
h.ports[port] = struct{}{}
return true
}
func (h *linuxImportHost) releasePort(port int) {
h.portsAccess.Lock()
defer h.portsAccess.Unlock()
h.logger.Debug("release vhci port ", port)
delete(h.ports, port)
}
type linuxClientSession struct {
handoff *kernelHandoffSession
host *linuxImportHost
port int
secondary int
closeOnce sync.Once
closeErr error
}
func (s *linuxClientSession) Done() <-chan struct{} {
return s.handoff.Done()
}
func (s *linuxClientSession) Err() error {
return s.handoff.Err()
}
func (s *linuxClientSession) Start() error {
return s.handoff.Start()
}
func (s *linuxClientSession) Close() error {
s.closeOnce.Do(func() {
detachErr := writeSysfs(filepath.Join(sysVHCIControllerV0, "detach"), strconv.Itoa(s.port))
closeErr := s.handoff.Close()
s.host.releasePort(s.port)
s.closeErr = E.Errors(detachErr, closeErr)
})
return s.closeErr
}
func (s *linuxClientSession) Description() string {
if s.secondary == 0 {
return fmt.Sprintf("vhci_hcd.0 port %d", s.port)
}
return fmt.Sprintf("vhci_hcd.0 (controller %d) port %d", s.secondary, s.port)
}