Add usbip virtual-device interop tests

This commit is contained in:
世界
2026-04-22 02:37:01 +08:00
parent 5e8c224be3
commit 4608c0c656
4 changed files with 1014 additions and 18 deletions
+5
View File
@@ -46,6 +46,11 @@ jobs:
run: |
echo "BUILD_TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)" >> "$GITHUB_ENV"
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "$GITHUB_ENV"
- name: Install usbip tools
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y usbip
- name: Test (unix)
if: matrix.os != 'windows-latest'
run: go test -v -exec sudo -tags "$BUILD_TAGS" -ldflags "$LDFLAGS_SHARED" ./...
+25 -7
View File
@@ -30,6 +30,7 @@ const (
)
var errImmediateReconnect = errors.New("usbip control reconnect")
var errControlUnsupported = errors.New("usbip control unsupported")
type clientTarget struct {
fixedBusID string
@@ -168,7 +169,7 @@ func (c *ClientService) run() {
if !immediate && !sleepCtx(c.ctx, clientReconnectDelay) {
break
}
err := c.runControlSession()
err := c.runSession()
if c.ctx.Err() != nil {
break
}
@@ -180,6 +181,15 @@ func (c *ClientService) run() {
c.stopAllWorkers()
}
func (c *ClientService) runSession() error {
err := c.runControlSession()
if errors.Is(err, errControlUnsupported) {
c.logger.Info("control channel unsupported by ", c.serverAddr, "; using standard usbip mode")
return c.runStandardSession()
}
return err
}
func (c *ClientService) runControlSession() error {
conn, err := c.dialer.DialContext(c.ctx, N.NetworkTCP, c.serverAddr)
if err != nil {
@@ -192,23 +202,23 @@ func (c *ClientService) runControlSession() error {
_ = conn.SetWriteDeadline(time.Now().Add(controlWriteTimeout))
_ = conn.SetReadDeadline(time.Now().Add(controlWriteTimeout))
if err := WriteControlPreface(conn); err != nil {
return E.Cause(err, "write control preface")
return E.Cause(errControlUnsupported, "write control preface: ", err)
}
if err := WriteControlHello(conn); err != nil {
return E.Cause(err, "write control hello")
return E.Cause(errControlUnsupported, "write control hello: ", err)
}
ack, err := ReadControlFrame(conn)
if err != nil {
return E.Cause(err, "read control ack")
return E.Cause(errControlUnsupported, "read control ack: ", err)
}
if ack.Type != controlFrameAck {
return E.New("unexpected control ack frame ", ack.Type)
return E.Cause(errControlUnsupported, "unexpected control ack frame ", ack.Type)
}
if ack.Version != controlProtocolVersion {
return E.New("unsupported control version ", ack.Version)
return E.Cause(errControlUnsupported, "unsupported control version ", ack.Version)
}
if ack.Capabilities&controlCapabilities != controlCapabilities {
return E.New("missing control capabilities 0x", ack.Capabilities)
return E.Cause(errControlUnsupported, "missing control capabilities 0x", ack.Capabilities)
}
_ = conn.SetWriteDeadline(time.Time{})
_ = conn.SetReadDeadline(time.Time{})
@@ -246,6 +256,14 @@ func (c *ClientService) runControlSession() error {
}
}
func (c *ClientService) runStandardSession() error {
if err := c.syncRemoteState(); err != nil {
return E.Cause(err, "initial devlist sync")
}
<-c.ctx.Done()
return nil
}
func (c *ClientService) controlPingLoop(conn net.Conn, done <-chan struct{}) {
ticker := time.NewTicker(controlPingInterval)
defer ticker.Stop()
+817
View File
@@ -0,0 +1,817 @@
//go:build linux
package usbip
import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/netip"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/json/badoption"
M "github.com/sagernet/sing/common/metadata"
"github.com/stretchr/testify/require"
"golang.org/x/term"
)
const (
testVendorID uint16 = 0x1d6b
testACMProductID uint16 = 0x0104
testHIDProductID uint16 = 0x0105
)
var testHIDReportDescriptor = []byte{
0x06, 0x00, 0xff,
0x09, 0x01,
0xa1, 0x01,
0x15, 0x00,
0x26, 0xff, 0x00,
0x75, 0x08,
0x95, 0x08,
0x09, 0x01,
0x81, 0x02,
0x95, 0x08,
0x09, 0x01,
0x91, 0x02,
0xc0,
}
type testUSBIPTools struct {
usbip string
usbipd string
}
type testVirtualFunction struct {
name string
nodePattern string
configure func(functionPath string) error
}
type testVirtualGadget struct {
path string
serial string
busid string
functions []testVirtualFunction
nodes map[string]string
closeOnce sync.Once
removeOnce sync.Once
udcName string
}
type testACMGadget struct {
*testVirtualGadget
ttyPath string
}
type testHIDGadget struct {
*testVirtualGadget
hidPath string
}
type rawFile struct {
file *os.File
state *term.State
}
type readResult struct {
data []byte
err error
}
func requireUSBIPTools(t *testing.T) testUSBIPTools {
t.Helper()
requireRoot(t)
usbipPath, usbipErr := exec.LookPath("usbip")
usbipdPath, usbipdErr := exec.LookPath("usbipd")
if usbipErr != nil || usbipdErr != nil {
t.Skip("usbip and usbipd are required")
}
return testUSBIPTools{
usbip: usbipPath,
usbipd: usbipdPath,
}
}
func loopbackListenAddr() *badoption.Addr {
addr := badoption.Addr(netip.MustParseAddr("127.0.0.1"))
return &addr
}
func pickFreeTCPPort(t *testing.T) uint16 {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer listener.Close()
return uint16(listener.Addr().(*net.TCPAddr).Port)
}
func startRealUSBIPServer(t *testing.T, devices []option.USBIPDeviceMatch) (*ServerService, M.Socksaddr) {
t.Helper()
serviceInstance, err := NewServerService(context.Background(), newTestLogger(), "usbip-server-test", option.USBIPServerServiceOptions{
ListenOptions: option.ListenOptions{
Listen: loopbackListenAddr(),
ListenPort: pickFreeTCPPort(t),
},
Devices: devices,
})
require.NoError(t, err)
server := serviceInstance.(*ServerService)
require.NoError(t, server.Start(adapter.StartStateStart))
t.Cleanup(func() {
_ = server.Close()
})
return server, M.SocksaddrFromNet(server.listenFD.Addr())
}
func startRealUSBIPClient(t *testing.T, destination M.Socksaddr, devices []option.USBIPDeviceMatch) *ClientService {
t.Helper()
serviceInstance, err := NewClientService(context.Background(), newTestLogger(), "usbip-client-test", option.USBIPClientServiceOptions{
ServerOptions: option.ServerOptions{
Server: destination.AddrString(),
ServerPort: destination.Port,
},
Devices: devices,
})
require.NoError(t, err)
client := serviceInstance.(*ClientService)
require.NoError(t, client.Start(adapter.StartStateStart))
t.Cleanup(func() {
_ = client.Close()
})
return client
}
func runCommand(t *testing.T, name string, args ...string) string {
t.Helper()
command := exec.Command(name, args...)
command.Env = os.Environ()
output, err := command.CombinedOutput()
require.NoErrorf(t, err, "%s %s\n%s", name, strings.Join(args, " "), string(output))
return string(output)
}
func runUSBIP(t *testing.T, tools testUSBIPTools, args ...string) string {
t.Helper()
return runCommand(t, tools.usbip, args...)
}
func startUSBIPD(t *testing.T, tools testUSBIPTools, port uint16) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
command := exec.CommandContext(ctx, tools.usbipd, "--debug", "--tcp-port", strconv.Itoa(int(port)))
var output bytes.Buffer
command.Stdout = &output
command.Stderr = &output
command.Env = os.Environ()
require.NoError(t, command.Start())
waitForTCPPort(t, port)
t.Cleanup(func() {
cancel()
done := make(chan error, 1)
go func() {
done <- command.Wait()
}()
select {
case <-time.After(5 * time.Second):
_ = command.Process.Kill()
<-done
case <-done:
}
})
}
func waitForTCPPort(t *testing.T, port uint16) {
t.Helper()
address := net.JoinHostPort("127.0.0.1", strconv.Itoa(int(port)))
require.Eventually(t, func() bool {
conn, err := net.DialTimeout("tcp", address, 200*time.Millisecond)
if err != nil {
return false
}
_ = conn.Close()
return true
}, 5*time.Second, 100*time.Millisecond)
}
func snapshotPaths(pattern string) map[string]struct{} {
paths, _ := filepath.Glob(pattern)
snapshot := make(map[string]struct{}, len(paths))
for _, path := range paths {
snapshot[path] = struct{}{}
}
return snapshot
}
func newPaths(pattern string, before map[string]struct{}) []string {
paths, _ := filepath.Glob(pattern)
var out []string
for _, path := range paths {
if _, found := before[path]; found {
continue
}
out = append(out, path)
}
sort.Strings(out)
return out
}
func waitForNewPath(t *testing.T, pattern string, before map[string]struct{}) string {
t.Helper()
var found string
require.Eventually(t, func() bool {
paths := newPaths(pattern, before)
if len(paths) == 0 {
return false
}
found = paths[0]
return true
}, 5*time.Second, 100*time.Millisecond)
return found
}
func importedNodeSnapshot(pattern string) map[string]struct{} {
paths, _ := filepath.Glob(pattern)
snapshot := make(map[string]struct{}, len(paths))
for _, path := range paths {
if isVHCINode(path) {
snapshot[path] = struct{}{}
}
}
return snapshot
}
func isVHCINode(path string) bool {
base := filepath.Base(path)
var sysfsPath string
switch {
case strings.HasPrefix(base, "ttyACM"):
sysfsPath = filepath.Join("/sys/class/tty", base, "device")
case strings.HasPrefix(base, "hidraw"):
sysfsPath = filepath.Join("/sys/class/hidraw", base, "device")
default:
return false
}
realPath, err := filepath.EvalSymlinks(sysfsPath)
if err != nil {
return false
}
return strings.Contains(realPath, "vhci_hcd")
}
func waitForNewImportedNode(t *testing.T, pattern string, before map[string]struct{}) string {
t.Helper()
var found string
require.Eventually(t, func() bool {
paths, _ := filepath.Glob(pattern)
var candidates []string
for _, path := range paths {
if !isVHCINode(path) {
continue
}
if _, present := before[path]; present {
continue
}
candidates = append(candidates, path)
}
if len(candidates) == 0 {
return false
}
sort.Strings(candidates)
found = candidates[0]
return true
}, 10*time.Second, 100*time.Millisecond)
return found
}
func waitForPathGone(t *testing.T, path string) {
t.Helper()
require.Eventually(t, func() bool {
_, err := os.Stat(path)
return os.IsNotExist(err)
}, 10*time.Second, 100*time.Millisecond)
}
func ensureNoNewImportedNode(t *testing.T, pattern string, before map[string]struct{}, duration time.Duration) {
t.Helper()
deadline := time.Now().Add(duration)
for time.Now().Before(deadline) {
paths, _ := filepath.Glob(pattern)
for _, path := range paths {
if !isVHCINode(path) {
continue
}
if _, present := before[path]; !present {
t.Fatalf("unexpected imported node %s", path)
}
}
time.Sleep(100 * time.Millisecond)
}
}
func usedVHCIPorts(t *testing.T) map[int]struct{} {
t.Helper()
records, err := readVHCIStatus()
require.NoError(t, err)
ports := make(map[int]struct{})
for _, record := range records {
if record.state == 6 {
ports[record.port] = struct{}{}
}
}
return ports
}
func waitForNewUsedVHCIPort(t *testing.T, before map[int]struct{}) int {
t.Helper()
var port int
require.Eventually(t, func() bool {
records, err := readVHCIStatus()
if err != nil {
return false
}
for _, record := range records {
if record.state != 6 {
continue
}
if _, found := before[record.port]; found {
continue
}
port = record.port
return true
}
return false
}, 10*time.Second, 100*time.Millisecond)
return port
}
func readExactlyAsync(reader io.Reader, size int) <-chan readResult {
results := make(chan readResult, 1)
go func() {
buffer := make([]byte, size)
_, err := io.ReadFull(reader, buffer)
results <- readResult{
data: buffer,
err: err,
}
}()
return results
}
func requireRead(t *testing.T, results <-chan readResult, expected []byte) {
t.Helper()
select {
case result := <-results:
require.NoError(t, result.err)
require.Equal(t, expected, result.data)
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for device I/O")
}
}
func openRawTTY(t *testing.T, path string) *rawFile {
t.Helper()
file, err := os.OpenFile(path, os.O_RDWR, 0)
require.NoError(t, err)
state, err := term.MakeRaw(int(file.Fd()))
require.NoError(t, err)
return &rawFile{
file: file,
state: state,
}
}
func (r *rawFile) Close() {
if r == nil || r.file == nil {
return
}
_ = term.Restore(int(r.file.Fd()), r.state)
_ = r.file.Close()
}
func openBinaryDevice(t *testing.T, path string) *os.File {
t.Helper()
file, err := os.OpenFile(path, os.O_RDWR, 0)
require.NoError(t, err)
return file
}
func newTestVirtualGadget(t *testing.T, productID uint16, productName string, functions []testVirtualFunction) *testVirtualGadget {
t.Helper()
requireRoot(t)
requireKernelModule(t, "configfs")
requireKernelModule(t, "libcomposite")
requireKernelModule(t, "dummy_hcd")
udcs, err := os.ReadDir("/sys/class/udc")
require.NoError(t, err)
require.NotEmpty(t, udcs)
snapshots := make(map[string]map[string]struct{})
for _, function := range functions {
if function.nodePattern == "" {
continue
}
snapshots[function.name] = snapshotPaths(function.nodePattern)
}
gadget := &testVirtualGadget{
path: filepath.Join("/sys/kernel/config/usb_gadget", fmt.Sprintf("codex_usbip_%d", time.Now().UnixNano())),
serial: fmt.Sprintf("codex-usbip-%d", time.Now().UnixNano()),
functions: functions,
nodes: make(map[string]string, len(functions)),
udcName: udcs[0].Name(),
}
require.NoError(t, os.MkdirAll(filepath.Join(gadget.path, "strings/0x409"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(gadget.path, "configs/c.1/strings/0x409"), 0o755))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "idVendor"), fmt.Sprintf("0x%04x", testVendorID)))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "idProduct"), fmt.Sprintf("0x%04x", productID)))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "strings/0x409/serialnumber"), gadget.serial))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "strings/0x409/manufacturer"), "OpenAI"))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "strings/0x409/product"), productName))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "configs/c.1/strings/0x409/configuration"), "config-1"))
for _, function := range functions {
functionPath := filepath.Join(gadget.path, "functions", function.name)
require.NoError(t, os.MkdirAll(functionPath, 0o755))
if function.configure != nil {
require.NoError(t, function.configure(functionPath))
}
require.NoError(t, os.Symlink(functionPath, filepath.Join(gadget.path, "configs/c.1", function.name)))
}
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "UDC"), gadget.udcName))
require.Eventually(t, func() bool {
devices, err := listUSBDevices()
if err != nil {
return false
}
for i := range devices {
if devices[i].VendorID == testVendorID &&
devices[i].ProductID == productID &&
devices[i].Serial == gadget.serial {
gadget.busid = devices[i].BusID
return true
}
}
return false
}, 10*time.Second, 100*time.Millisecond)
for _, function := range functions {
if function.nodePattern == "" {
continue
}
gadget.nodes[function.name] = waitForNewPath(t, function.nodePattern, snapshots[function.name])
}
t.Cleanup(func() {
gadget.Close()
})
return gadget
}
func (g *testVirtualGadget) Close() {
g.closeOnce.Do(func() {
if g.busid != "" {
if driver, err := currentDriver(g.busid); err == nil && driver == "usbip-host" {
_ = hostUnbind(g.busid)
_ = hostMatchBusID(g.busid, false)
}
}
_ = writeSysfsLine(filepath.Join(g.path, "UDC"), "")
for _, function := range g.functions {
_ = os.Remove(filepath.Join(g.path, "configs/c.1", function.name))
}
for _, function := range g.functions {
_ = os.RemoveAll(filepath.Join(g.path, "functions", function.name))
}
_ = os.RemoveAll(filepath.Join(g.path, "configs/c.1/strings/0x409"))
_ = os.RemoveAll(filepath.Join(g.path, "configs/c.1"))
_ = os.RemoveAll(filepath.Join(g.path, "strings/0x409"))
_ = os.RemoveAll(g.path)
})
}
func newTestACMGadget(t *testing.T) *testACMGadget {
t.Helper()
gadget := newTestVirtualGadget(t, testACMProductID, "Codex USBIP ACM", []testVirtualFunction{{
name: "acm.usb0",
nodePattern: "/dev/ttyGS*",
}})
return &testACMGadget{
testVirtualGadget: gadget,
ttyPath: gadget.nodes["acm.usb0"],
}
}
func newTestHIDGadget(t *testing.T) *testHIDGadget {
t.Helper()
gadget := newTestVirtualGadget(t, testHIDProductID, "Codex USBIP HID", []testVirtualFunction{{
name: "hid.usb0",
nodePattern: "/dev/hidg*",
configure: func(functionPath string) error {
if err := writeSysfs(functionPath+"/protocol", "0"); err != nil {
return err
}
if err := writeSysfs(functionPath+"/subclass", "0"); err != nil {
return err
}
if err := writeSysfs(functionPath+"/report_length", "8"); err != nil {
return err
}
return os.WriteFile(functionPath+"/report_desc", testHIDReportDescriptor, 0o644)
},
}})
return &testHIDGadget{
testVirtualGadget: gadget,
hidPath: gadget.nodes["hid.usb0"],
}
}
func (g *testACMGadget) exerciseImportedIO(t *testing.T, importedTTY string) {
t.Helper()
gadgetTTY := openRawTTY(t, g.ttyPath)
imported := openRawTTY(t, importedTTY)
defer gadgetTTY.Close()
defer imported.Close()
gadgetToHost := []byte("acm-g2h!")
hostToGadget := []byte("acm-h2g!")
hostRead := readExactlyAsync(imported.file, len(gadgetToHost))
_, err := gadgetTTY.file.Write(gadgetToHost)
require.NoError(t, err)
requireRead(t, hostRead, gadgetToHost)
gadgetRead := readExactlyAsync(gadgetTTY.file, len(hostToGadget))
_, err = imported.file.Write(hostToGadget)
require.NoError(t, err)
requireRead(t, gadgetRead, hostToGadget)
}
func (g *testHIDGadget) exerciseImportedIO(t *testing.T, importedHID string) {
t.Helper()
gadgetHID := openBinaryDevice(t, g.hidPath)
imported := openBinaryDevice(t, importedHID)
defer gadgetHID.Close()
defer imported.Close()
gadgetToHost := []byte{1, 2, 3, 4, 5, 6, 7, 8}
hostToGadget := []byte{8, 7, 6, 5, 4, 3, 2, 1}
hostRead := readExactlyAsync(imported, len(gadgetToHost))
_, err := gadgetHID.Write(gadgetToHost)
require.NoError(t, err)
requireRead(t, hostRead, gadgetToHost)
gadgetRead := readExactlyAsync(gadgetHID, len(hostToGadget))
_, err = imported.Write(hostToGadget)
require.NoError(t, err)
requireRead(t, gadgetRead, hostToGadget)
}
func bindWithOfficialUSBIP(t *testing.T, tools testUSBIPTools, busid string) {
t.Helper()
runUSBIP(t, tools, "bind", "--busid="+busid)
}
func unbindWithOfficialUSBIP(t *testing.T, tools testUSBIPTools, busid string) {
t.Helper()
runUSBIP(t, tools, "unbind", "--busid="+busid)
}
func TestUSBIPInteropOurServerWithOfficialClientACM(t *testing.T) {
requireRoot(t)
tools := requireUSBIPTools(t)
require.NoError(t, ensureVHCI())
gadget := newTestACMGadget(t)
server, address := startRealUSBIPServer(t, []option.USBIPDeviceMatch{{Serial: gadget.serial}})
beforePorts := usedVHCIPorts(t)
beforeTTY := importedNodeSnapshot("/dev/ttyACM*")
listOutput := runUSBIP(t, tools, "--tcp-port", strconv.Itoa(int(address.Port)), "list", "--remote=127.0.0.1")
require.Contains(t, listOutput, gadget.busid)
runUSBIP(t, tools, "--tcp-port", strconv.Itoa(int(address.Port)), "attach", "--remote=127.0.0.1", "--busid="+gadget.busid)
port := waitForNewUsedVHCIPort(t, beforePorts)
importedTTY := waitForNewImportedNode(t, "/dev/ttyACM*", beforeTTY)
gadget.exerciseImportedIO(t, importedTTY)
portOutput := runUSBIP(t, tools, "port")
require.Contains(t, portOutput, fmt.Sprintf("Port %02d", port))
runUSBIP(t, tools, "detach", "--port="+strconv.Itoa(port))
waitForPathGone(t, importedTTY)
_ = server
}
func TestUSBIPInteropOurServerWithOfficialClientHID(t *testing.T) {
requireRoot(t)
tools := requireUSBIPTools(t)
require.NoError(t, ensureVHCI())
gadget := newTestHIDGadget(t)
_, address := startRealUSBIPServer(t, []option.USBIPDeviceMatch{{Serial: gadget.serial}})
beforePorts := usedVHCIPorts(t)
beforeHID := importedNodeSnapshot("/dev/hidraw*")
listOutput := runUSBIP(t, tools, "--tcp-port", strconv.Itoa(int(address.Port)), "list", "--remote=127.0.0.1")
require.Contains(t, listOutput, gadget.busid)
runUSBIP(t, tools, "--tcp-port", strconv.Itoa(int(address.Port)), "attach", "--remote=127.0.0.1", "--busid="+gadget.busid)
port := waitForNewUsedVHCIPort(t, beforePorts)
importedHID := waitForNewImportedNode(t, "/dev/hidraw*", beforeHID)
gadget.exerciseImportedIO(t, importedHID)
portOutput := runUSBIP(t, tools, "port")
require.Contains(t, portOutput, fmt.Sprintf("Port %02d", port))
runUSBIP(t, tools, "detach", "--port="+strconv.Itoa(port))
waitForPathGone(t, importedHID)
}
func TestUSBIPInteropOurClientWithOfficialServerACM(t *testing.T) {
requireRoot(t)
tools := requireUSBIPTools(t)
require.NoError(t, ensureVHCI())
gadget := newTestACMGadget(t)
bindWithOfficialUSBIP(t, tools, gadget.busid)
t.Cleanup(func() {
unbindWithOfficialUSBIP(t, tools, gadget.busid)
})
port := pickFreeTCPPort(t)
startUSBIPD(t, tools, port)
beforeTTY := importedNodeSnapshot("/dev/ttyACM*")
client := startRealUSBIPClient(t, M.ParseSocksaddrHostPort("127.0.0.1", port), []option.USBIPDeviceMatch{{
VendorID: option.USBIPHexUint16(testVendorID),
ProductID: option.USBIPHexUint16(testACMProductID),
}})
_ = client
importedTTY := waitForNewImportedNode(t, "/dev/ttyACM*", beforeTTY)
gadget.exerciseImportedIO(t, importedTTY)
require.NoError(t, client.Close())
waitForPathGone(t, importedTTY)
}
func TestUSBIPInteropOurClientWithOfficialServerHID(t *testing.T) {
requireRoot(t)
tools := requireUSBIPTools(t)
require.NoError(t, ensureVHCI())
gadget := newTestHIDGadget(t)
bindWithOfficialUSBIP(t, tools, gadget.busid)
t.Cleanup(func() {
unbindWithOfficialUSBIP(t, tools, gadget.busid)
})
port := pickFreeTCPPort(t)
startUSBIPD(t, tools, port)
beforeHID := importedNodeSnapshot("/dev/hidraw*")
client := startRealUSBIPClient(t, M.ParseSocksaddrHostPort("127.0.0.1", port), []option.USBIPDeviceMatch{{
VendorID: option.USBIPHexUint16(testVendorID),
ProductID: option.USBIPHexUint16(testHIDProductID),
}})
_ = client
importedHID := waitForNewImportedNode(t, "/dev/hidraw*", beforeHID)
gadget.exerciseImportedIO(t, importedHID)
require.NoError(t, client.Close())
waitForPathGone(t, importedHID)
}
func TestUSBIPOfficialServerHasStaticDiscoveryOnly(t *testing.T) {
requireRoot(t)
tools := requireUSBIPTools(t)
require.NoError(t, ensureVHCI())
first := newTestACMGadget(t)
bindWithOfficialUSBIP(t, tools, first.busid)
t.Cleanup(func() {
unbindWithOfficialUSBIP(t, tools, first.busid)
})
port := pickFreeTCPPort(t)
startUSBIPD(t, tools, port)
beforeTTY := importedNodeSnapshot("/dev/ttyACM*")
client := startRealUSBIPClient(t, M.ParseSocksaddrHostPort("127.0.0.1", port), nil)
_ = client
importedTTY := waitForNewImportedNode(t, "/dev/ttyACM*", beforeTTY)
first.exerciseImportedIO(t, importedTTY)
second := newTestHIDGadget(t)
bindWithOfficialUSBIP(t, tools, second.busid)
t.Cleanup(func() {
unbindWithOfficialUSBIP(t, tools, second.busid)
})
beforeHID := importedNodeSnapshot("/dev/hidraw*")
ensureNoNewImportedNode(t, "/dev/hidraw*", beforeHID, 3*time.Second)
require.NoError(t, client.Close())
waitForPathGone(t, importedTTY)
}
func TestUSBIPControlHotplugACMReattach(t *testing.T) {
requireRoot(t)
require.NoError(t, ensureVHCI())
_, address := startRealUSBIPServer(t, []option.USBIPDeviceMatch{{
VendorID: option.USBIPHexUint16(testVendorID),
ProductID: option.USBIPHexUint16(testACMProductID),
}})
client := startRealUSBIPClient(t, address, []option.USBIPDeviceMatch{{
VendorID: option.USBIPHexUint16(testVendorID),
ProductID: option.USBIPHexUint16(testACMProductID),
}})
_ = client
beforeTTY := importedNodeSnapshot("/dev/ttyACM*")
first := newTestACMGadget(t)
firstImportedTTY := waitForNewImportedNode(t, "/dev/ttyACM*", beforeTTY)
first.exerciseImportedIO(t, firstImportedTTY)
first.Close()
waitForPathGone(t, firstImportedTTY)
secondBefore := importedNodeSnapshot("/dev/ttyACM*")
second := newTestACMGadget(t)
secondImportedTTY := waitForNewImportedNode(t, "/dev/ttyACM*", secondBefore)
second.exerciseImportedIO(t, secondImportedTTY)
}
func TestUSBIPControlImportAllACMAndHID(t *testing.T) {
requireRoot(t)
require.NoError(t, ensureVHCI())
_, address := startRealUSBIPServer(t, []option.USBIPDeviceMatch{
{VendorID: option.USBIPHexUint16(testVendorID), ProductID: option.USBIPHexUint16(testACMProductID)},
{VendorID: option.USBIPHexUint16(testVendorID), ProductID: option.USBIPHexUint16(testHIDProductID)},
})
client := startRealUSBIPClient(t, address, nil)
_ = client
beforeTTY := importedNodeSnapshot("/dev/ttyACM*")
beforeHID := importedNodeSnapshot("/dev/hidraw*")
acm := newTestACMGadget(t)
hid := newTestHIDGadget(t)
importedTTY := waitForNewImportedNode(t, "/dev/ttyACM*", beforeTTY)
importedHID := waitForNewImportedNode(t, "/dev/hidraw*", beforeHID)
acm.exerciseImportedIO(t, importedTTY)
hid.exerciseImportedIO(t, importedHID)
}
+167 -11
View File
@@ -5,16 +5,20 @@ package usbip
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"slices"
"sync"
"testing"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
M "github.com/sagernet/sing/common/metadata"
"github.com/sagernet/sing/common/shell"
"github.com/stretchr/testify/require"
)
@@ -242,6 +246,105 @@ func startDispatchServer(t *testing.T, server *ServerService) (M.Socksaddr, func
}
}
type testUSBGadget struct {
path string
serial string
busid string
}
func requireRoot(t *testing.T) {
t.Helper()
if os.Geteuid() != 0 {
t.Skip("root required")
}
}
func requireKernelModule(t *testing.T, module string) {
t.Helper()
modprobePath, err := findModprobePath()
require.NoError(t, err)
output, err := shell.Exec(modprobePath, module).Read()
require.NoErrorf(t, err, "modprobe %s: %s", module, output)
}
func writeSysfsLine(path string, content string) error {
return os.WriteFile(path, []byte(content+"\n"), 0)
}
func newTestUSBGadget(t *testing.T) *testUSBGadget {
t.Helper()
requireRoot(t)
requireKernelModule(t, "configfs")
requireKernelModule(t, "libcomposite")
requireKernelModule(t, "dummy_hcd")
udcs, err := os.ReadDir("/sys/class/udc")
require.NoError(t, err)
require.NotEmpty(t, udcs)
gadget := &testUSBGadget{
path: filepath.Join("/sys/kernel/config/usb_gadget", fmt.Sprintf("codex_usbip_%d", time.Now().UnixNano())),
serial: fmt.Sprintf("codex-usbip-%d", time.Now().UnixNano()),
}
require.NoError(t, os.MkdirAll(filepath.Join(gadget.path, "strings/0x409"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(gadget.path, "configs/c.1/strings/0x409"), 0o755))
require.NoError(t, os.Mkdir(filepath.Join(gadget.path, "functions/acm.usb0"), 0o755))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "idVendor"), "0x1d6b"))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "idProduct"), "0x0104"))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "strings/0x409/serialnumber"), gadget.serial))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "strings/0x409/manufacturer"), "OpenAI"))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "strings/0x409/product"), "Codex USBIP Test"))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "configs/c.1/strings/0x409/configuration"), "config-1"))
require.NoError(t, os.Symlink(filepath.Join(gadget.path, "functions/acm.usb0"), filepath.Join(gadget.path, "configs/c.1/acm.usb0")))
require.NoError(t, writeSysfs(filepath.Join(gadget.path, "UDC"), udcs[0].Name()))
require.Eventually(t, func() bool {
devices, err := listUSBDevices()
if err != nil {
return false
}
for i := range devices {
if devices[i].VendorID == 0x1d6b &&
devices[i].ProductID == 0x0104 &&
devices[i].Serial == gadget.serial {
gadget.busid = devices[i].BusID
return true
}
}
return false
}, 5*time.Second, 100*time.Millisecond)
t.Cleanup(func() {
if gadget.busid != "" {
if driver, err := currentDriver(gadget.busid); err == nil {
switch driver {
case "usbip-host":
_ = hostUnbind(gadget.busid)
_ = hostMatchBusID(gadget.busid, false)
_ = bindToDriver(gadget.busid, "usb")
case "usb":
case "":
default:
_ = bindToDriver(gadget.busid, "usb")
}
}
}
_ = writeSysfsLine(filepath.Join(gadget.path, "UDC"), "")
_ = os.Remove(filepath.Join(gadget.path, "configs/c.1/acm.usb0"))
_ = os.Remove(filepath.Join(gadget.path, "functions/acm.usb0"))
_ = os.Remove(filepath.Join(gadget.path, "configs/c.1/strings/0x409"))
_ = os.Remove(filepath.Join(gadget.path, "configs/c.1"))
_ = os.Remove(filepath.Join(gadget.path, "strings/0x409"))
_ = os.Remove(gadget.path)
})
return gadget
}
func TestBuildTargetsDedupesFixedBusID(t *testing.T) {
t.Parallel()
@@ -306,6 +409,40 @@ func TestLinuxHelpers(t *testing.T) {
require.False(t, isUSBUEvent([]byte("ACTION=add\x00SUBSYSTEM=net\x00")))
}
func TestServerStartRequiresHostDriver(t *testing.T) {
t.Parallel()
expectedErr := errors.New("host driver unavailable")
server := &ServerService{
ctx: context.Background(),
logger: newTestLogger(),
exports: make(map[string]serverExport),
controlSubs: make(map[uint64]*serverControlConn),
ops: usbipOps{
ensureHostDriver: func() error { return expectedErr },
},
}
err := server.Start(adapter.StartStateStart)
require.ErrorIs(t, err, expectedErr)
}
func TestClientStartRequiresVHCI(t *testing.T) {
t.Parallel()
expectedErr := errors.New("vhci unavailable")
client := &ClientService{
ctx: context.Background(),
logger: newTestLogger(),
ops: usbipOps{
ensureVHCI: func() error { return expectedErr },
},
}
err := client.Start(adapter.StartStateStart)
require.ErrorIs(t, err, expectedErr)
}
func TestServerReconcileExportsBindsMatchesAndSkipsHub(t *testing.T) {
t.Parallel()
@@ -619,23 +756,42 @@ func TestClientRunControlSessionSyncsAssignmentsOnChanged(t *testing.T) {
}
func TestUSBIPLinuxSmoke(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("usbip smoke test requires root")
}
requireRoot(t)
require.NoError(t, ensureHostDriver())
require.NoError(t, ensureVHCI())
busid := os.Getenv("USBIP_TEST_BUSID")
if busid == "" {
t.Skip("USBIP_TEST_BUSID not set")
gadget := newTestUSBGadget(t)
device, err := readSysfsDevice(gadget.busid, sysBusDevicePath(gadget.busid))
require.NoError(t, err)
require.Equal(t, gadget.busid, device.BusID)
server := &ServerService{
ctx: context.Background(),
logger: newTestLogger(),
exports: make(map[string]serverExport),
controlSubs: make(map[uint64]*serverControlConn),
ops: systemUSBIPOps,
}
require.NoError(t, server.bindOne(&device))
device, err := readSysfsDevice(busid, sysBusDevicePath(busid))
require.NoError(t, err)
require.Equal(t, busid, device.BusID)
_, ok := server.snapshotExports()[gadget.busid]
require.True(t, ok)
_, err = currentDriver(busid)
driver, err := currentDriver(gadget.busid)
require.NoError(t, err)
_, err = readUsbipStatus(busid)
require.Equal(t, "usbip-host", driver)
status, err := readUsbipStatus(gadget.busid)
require.NoError(t, err)
require.Equal(t, usbipStatusAvailable, status)
require.NoError(t, hostUnbind(gadget.busid))
require.NoError(t, hostMatchBusID(gadget.busid, false))
require.NoError(t, bindToDriver(gadget.busid, "usb"))
server.deleteExport(gadget.busid)
driver, err = currentDriver(gadget.busid)
require.NoError(t, err)
require.Equal(t, "usb", driver)
}