diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cc9ee0ad8..1e400be75 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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" ./... diff --git a/service/usbip/client_linux.go b/service/usbip/client_linux.go index f26c87db4..d45d23042 100644 --- a/service/usbip/client_linux.go +++ b/service/usbip/client_linux.go @@ -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() diff --git a/service/usbip/linux_interop_test.go b/service/usbip/linux_interop_test.go new file mode 100644 index 000000000..ebeae0286 --- /dev/null +++ b/service/usbip/linux_interop_test.go @@ -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) +} diff --git a/service/usbip/linux_test.go b/service/usbip/linux_test.go index f4dc6f480..01fdcee6f 100644 --- a/service/usbip/linux_test.go +++ b/service/usbip/linux_test.go @@ -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) }