diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 14e564d..0b5af30 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -42,6 +42,7 @@ func RootCommand() *cli.Command { hostRunCommand(), flatpakCommand(), headlessCommand(), + usbCommand(), }, } diff --git a/cmd/cli/usb.go b/cmd/cli/usb.go new file mode 100644 index 0000000..b77a638 --- /dev/null +++ b/cmd/cli/usb.go @@ -0,0 +1,39 @@ +package cli + +import ( + "context" + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/qubesome/cli/internal/runners/util/usb" + "github.com/urfave/cli/v3" +) + +func usbCommand() *cli.Command { + cmd := &cli.Command{ + Name: "usb", + Hidden: true, + Usage: "lists USB devices detected on the host", + Description: `Lists the USB devices detected on the host, showing the +vendor:product identifier, product name and the /dev paths that would be made +available to a workload. Use the vendor:product identifier in a workload's +usbDevices configuration.`, + Action: func(ctx context.Context, cmd *cli.Command) error { + devices, err := usb.List() + if err != nil { + return err + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "ID\tPRODUCT\tPATHS") + for _, d := range devices { + fmt.Fprintf(w, "%s:%s\t%s\t%s\n", + d.VendorID, d.ProductID, d.Product, strings.Join(d.Paths, ", ")) + } + return w.Flush() + }, + } + return cmd +} diff --git a/go.mod b/go.mod index c298621..c8a8997 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/cyphar/filepath-securejoin v0.6.1 github.com/go-git/go-git/v6 v6.0.0-alpha.4 github.com/google/uuid v1.6.0 + github.com/qubesome/libudev v0.0.2 github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v3 v3.9.0 github.com/zalando/go-keyring v0.2.8 diff --git a/go.sum b/go.sum index 4368054..8986f73 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,8 @@ github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/qubesome/libudev v0.0.2 h1:oEGLVnU0MK9lo0RZiCUiXhv0XG4XqG6u1PE8zzkS09s= +github.com/qubesome/libudev v0.0.2/go.mod h1:9OQG4OdPVTbUpBtIUq+5VtWAwkPAzUhWNSM17ByFi5Q= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/runners/util/usb/devices.go b/internal/runners/util/usb/devices.go index 2107bbe..95321bc 100644 --- a/internal/runners/util/usb/devices.go +++ b/internal/runners/util/usb/devices.go @@ -1,94 +1,247 @@ package usb import ( - "bufio" - "bytes" + "errors" "fmt" + "io/fs" "log/slog" "os" "path/filepath" + "regexp" + "sort" "strconv" "strings" + + "github.com/qubesome/libudev" + "github.com/qubesome/libudev/types" +) + +const ( + sysDevicesDir = "/sys/devices" + udevDataDir = "/run/udev/data" ) +// vendorProductRE matches a USB "vendor:product" identifier, e.g. 1050:0407. +var vendorProductRE = regexp.MustCompile(`^[0-9a-fA-F]{4}:[0-9a-fA-F]{4}$`) + +// hidrawRE matches a hidraw device node name, e.g. hidraw9. +var hidrawRE = regexp.MustCompile(`^hidraw[0-9]+$`) + +// Info describes a USB device detected on the host. +type Info struct { + VendorID string + ProductID string + Product string + Paths []string +} + +// NamedDevices returns the /dev paths for the USB devices selected by names. +// +// Each name is either a "vendor:product" identifier (e.g. 1050:0407) matched +// against the device IDs, or, for backwards compatibility, a prefix of the USB +// product name. func NamedDevices(names []string) ([]string, error) { - devs := []string{} + devices, err := scan() + if err != nil { + return nil, err + } + + return selectDevices(devices, names) +} + +// List returns the USB devices detected on the host. +func List() ([]Info, error) { + devices, err := scan() + if err != nil { + return nil, err + } + + return listDevices(devices), nil +} + +func scan() ([]*types.Device, error) { + devicesRoot, err := os.OpenRoot(sysDevicesDir) + if err != nil { + return nil, fmt.Errorf("failed to open %s: %w", sysDevicesDir, err) + } + defer devicesRoot.Close() - products, err := filepath.Glob("/sys/bus/usb/devices/*/product") + // The udev runtime data enriches devices with tags. This tool does not + // need it, and /run/udev/data may not exist. Fall back to an empty + // directory so the scanner does not fail when it is absent. + udevRoot, cleanup, err := openUdevDataRoot() if err != nil { - return nil, fmt.Errorf("failed to get USB device files: %w", err) + return nil, err } + defer cleanup() + + s, err := libudev.NewScanner( + libudev.WithDevicesRoot(devicesRoot), + libudev.WithUDevDataRoot(udevRoot), + ) + if err != nil { + return nil, fmt.Errorf("failed to create udev scanner: %w", err) + } + + devices, err := s.ScanDevices() + if err != nil { + return nil, fmt.Errorf("failed to scan udev devices: %w", err) + } + + return devices, nil +} + +func openUdevDataRoot() (*os.Root, func(), error) { + root, err := os.OpenRoot(udevDataDir) + if err == nil { + return root, func() { _ = root.Close() }, nil + } + if !errors.Is(err, fs.ErrNotExist) { + return nil, nil, fmt.Errorf("failed to open %s: %w", udevDataDir, err) + } + + tmp, err := os.MkdirTemp("", "qubesome-udev-*") + if err != nil { + return nil, nil, fmt.Errorf("failed to create temp udev data dir: %w", err) + } + root, err = os.OpenRoot(tmp) + if err != nil { + _ = os.RemoveAll(tmp) + return nil, nil, fmt.Errorf("failed to open temp udev data dir: %w", err) + } + + return root, func() { + _ = root.Close() + _ = os.RemoveAll(tmp) + }, nil +} - for _, fn := range products { - d, err := readFile(fn, names) +func selectDevices(devices []*types.Device, names []string) ([]string, error) { + var devs []string + + for _, d := range devices { + if !isUSBDevice(d) { + continue + } + if !matchesAny(d, names) { + continue + } + + paths, err := devicePaths(d) if err != nil { - return nil, fmt.Errorf("failed to get USB device files: %w", err) + return nil, err } - devs = append(devs, d...) + devs = append(devs, paths...) } return devs, nil } -func readFile(fn string, names []string) ([]string, error) { - devs := []string{} +func listDevices(devices []*types.Device) []Info { + var infos []Info - f, err := os.Open(fn) - if err != nil { - return nil, fmt.Errorf("failed to open USB device file: %w", err) - } - - r := bufio.NewScanner(f) - for r.Scan() { - devName := r.Text() - - for _, n := range names { - if strings.HasPrefix(devName, n) { - parent := filepath.Dir(fn) - - busNum, err := getValue(filepath.Join(parent, "busnum")) - if err != nil { - return nil, err - } - devNum, err := getValue(filepath.Join(parent, "devnum")) - if err != nil { - return nil, err - } - - devs = append(devs, fmt.Sprintf("/dev/bus/usb/%03d/%03d", busNum, devNum)) - - // More on USB and /sys/bus/usb - // https://www.makelinux.net/ldd3/chp-13-sect-2.shtml - // - // Some devices will have multiple hidraw files, such as YubiKeys: - // /sys/bus/usb/devices/5-2.2.3/5-2.2.3:1.0/*/hidraw/hidraw9 - // /sys/bus/usb/devices/5-2.2.3/5-2.2.3:1.1/*/hidraw/hidraw10 - hidfiles, err := filepath.Glob(filepath.Join(parent, fmt.Sprintf("%s:*", filepath.Base(parent)), "*", "hidraw", "hidraw*")) - if err != nil { - return nil, fmt.Errorf("failed to Glob for hidraw files: %w", err) - } - if len(hidfiles) == 0 { - slog.Debug("no hidraw files found", "device", n) - } - for _, hid := range hidfiles { - devs = append(devs, fmt.Sprintf("/dev/%s", filepath.Base(hid))) - } + for _, d := range devices { + if !isUSBDevice(d) { + continue + } + + info := Info{ + VendorID: d.VendorID, + ProductID: d.ProductID, + Product: d.Attrs["product"], + } + if paths, err := devicePaths(d); err == nil { + info.Paths = paths + } else { + info.Paths = []string{fmt.Sprintf("error: %v", err)} + } + infos = append(infos, info) + } + + sort.Slice(infos, func(i, j int) bool { + if infos[i].VendorID != infos[j].VendorID { + return infos[i].VendorID < infos[j].VendorID + } + return infos[i].ProductID < infos[j].ProductID + }) + + return infos +} + +// isUSBDevice reports whether d is a top-level USB device node. Only those nodes +// carry busnum and devnum attributes. Interfaces and hidraw children inherit the +// vendor and product IDs but not these, so this avoids duplicate matches. +func isUSBDevice(d *types.Device) bool { + return d.Attrs["busnum"] != "" && d.Attrs["devnum"] != "" +} + +func matchesAny(d *types.Device, names []string) bool { + for _, n := range names { + if vendor, product, ok := parseVendorProduct(n); ok { + if strings.EqualFold(d.VendorID, vendor) && strings.EqualFold(d.ProductID, product) { + return true } + continue + } + + if strings.HasPrefix(d.Attrs["product"], n) { + return true } } - return devs, nil + return false } -func getValue(fn string) (int, error) { - bn, err := os.ReadFile(fn) +func devicePaths(d *types.Device) ([]string, error) { + busNum, err := strconv.Atoi(d.Attrs["busnum"]) if err != nil { - return 0, fmt.Errorf("failed to read USB file: %w", err) + return nil, fmt.Errorf("failed to parse busnum %q: %w", d.Attrs["busnum"], err) } - - n, err := strconv.Atoi(string(bytes.TrimSpace(bn))) + devNum, err := strconv.Atoi(d.Attrs["devnum"]) if err != nil { - return 0, fmt.Errorf("failed to convert %s to int: %w", bn, err) + return nil, fmt.Errorf("failed to parse devnum %q: %w", d.Attrs["devnum"], err) + } + + // Some USB devices, such as YubiKeys, have multiple hidraw nodes nested + // under their interfaces. Include all of them so tools relying on hidraw + // (e.g. FIDO/SK keys) work inside the container. + hids := hidrawNodes(d) + if len(hids) == 0 { + slog.Debug("no hidraw files found", "device", d.Attrs["product"]) + } + + paths := make([]string, 0, 1+len(hids)) + paths = append(paths, fmt.Sprintf("/dev/bus/usb/%03d/%03d", busNum, devNum)) + paths = append(paths, hids...) + + return paths, nil +} + +// hidrawNodes collects the /dev paths of the hidraw nodes belonging to d. It +// descends through d's interfaces but stops at downstream USB devices (e.g. a +// hub's connected devices), which own their hidraw nodes and are handled on +// their own. +func hidrawNodes(d *types.Device) []string { + var hids []string + + for _, c := range d.Children { + if isUSBDevice(c) { + continue + } + if hidrawRE.MatchString(filepath.Base(c.Devpath)) { + hids = append(hids, "/dev/"+filepath.Base(c.Devpath)) + } + hids = append(hids, hidrawNodes(c)...) } - return n, nil + + return hids +} + +func parseVendorProduct(s string) (vendor, product string, ok bool) { + if !vendorProductRE.MatchString(s) { + return "", "", false + } + + v, p, _ := strings.Cut(s, ":") + return v, p, true } diff --git a/internal/runners/util/usb/devices_test.go b/internal/runners/util/usb/devices_test.go new file mode 100644 index 0000000..00257ac --- /dev/null +++ b/internal/runners/util/usb/devices_test.go @@ -0,0 +1,278 @@ +package usb + +import ( + "testing" + + "github.com/qubesome/libudev/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// yubikeyTree returns a flattened device list mimicking what libudev's +// ScanDevices produces for a YubiKey: a top-level USB device node carrying +// busnum/devnum/product attrs, two interfaces, and two hidraw descendants. +func yubikeyTree() []*types.Device { + root := &types.Device{ + Devpath: "/sys/devices/pci0000:00/usb5/5-2/5-2.2/5-2.2.3", + VendorID: "1050", + ProductID: "0407", + Attrs: map[string]string{ + "busnum": "5", + "devnum": "12", + "product": "YubiKey OTP+FIDO+CCID", + "idVendor": "1050", + }, + } + iface0 := &types.Device{ + Devpath: root.Devpath + "/5-2.2.3:1.0", + VendorID: "1050", + ProductID: "0407", + Attrs: map[string]string{}, + } + iface1 := &types.Device{ + Devpath: root.Devpath + "/5-2.2.3:1.1", + VendorID: "1050", + ProductID: "0407", + Attrs: map[string]string{}, + } + hid9 := &types.Device{ + Devpath: iface0.Devpath + "/0003:1050:0407.000A/hidraw/hidraw9", + VendorID: "1050", + ProductID: "0407", + Attrs: map[string]string{}, + } + hid10 := &types.Device{ + Devpath: iface1.Devpath + "/0003:1050:0407.000B/hidraw/hidraw10", + VendorID: "1050", + ProductID: "0407", + Attrs: map[string]string{}, + } + + iface0.Children = []*types.Device{hid9} + iface1.Children = []*types.Device{hid10} + root.Children = []*types.Device{iface0, iface1} + + return []*types.Device{root, iface0, iface1, hid9, hid10} +} + +func TestSelectDevices_MatchByVendorProductID(t *testing.T) { + t.Parallel() + + got, err := selectDevices(yubikeyTree(), []string{"1050:0407"}) + + require.NoError(t, err) + assert.Equal(t, []string{ + "/dev/bus/usb/005/012", + "/dev/hidraw9", + "/dev/hidraw10", + }, got) +} + +func TestSelectDevices_MatchByIDIsCaseInsensitive(t *testing.T) { + t.Parallel() + + dev := &types.Device{ + Devpath: "/sys/devices/usb1/1-1", + VendorID: "abcd", + ProductID: "00ef", + Attrs: map[string]string{"busnum": "1", "devnum": "2"}, + } + + got, err := selectDevices([]*types.Device{dev}, []string{"ABCD:00EF"}) + + require.NoError(t, err) + assert.Equal(t, []string{"/dev/bus/usb/001/002"}, got) +} + +func TestSelectDevices_MatchByProductNamePrefix(t *testing.T) { + t.Parallel() + + got, err := selectDevices(yubikeyTree(), []string{"YubiKey"}) + + require.NoError(t, err) + assert.Equal(t, []string{ + "/dev/bus/usb/005/012", + "/dev/hidraw9", + "/dev/hidraw10", + }, got) +} + +func TestSelectDevices_IDFormTakesPrecedenceOverProductName(t *testing.T) { + t.Parallel() + + // The product name is literally "1050:0407" but the IDs differ. An + // ID-shaped entry must be matched as an ID, not as a product name. + dev := &types.Device{ + Devpath: "/sys/devices/usb1/1-1", + VendorID: "9999", + ProductID: "8888", + Attrs: map[string]string{ + "busnum": "1", + "devnum": "2", + "product": "1050:0407", + }, + } + + got, err := selectDevices([]*types.Device{dev}, []string{"1050:0407"}) + + require.NoError(t, err) + assert.Empty(t, got) +} + +// hubWithDownstream models a hub whose downstream USB device owns a hidraw +// node. A hub must not claim hidraw nodes belonging to downstream devices. +func hubWithDownstream() []*types.Device { + hub := &types.Device{ + Devpath: "/sys/devices/pci0000:00/usb9", + VendorID: "1d6b", + ProductID: "0002", + Attrs: map[string]string{"busnum": "9", "devnum": "1", "product": "xHCI Host Controller"}, + } + downstream := &types.Device{ + Devpath: hub.Devpath + "/9-1", + VendorID: "046d", + ProductID: "c090", + Attrs: map[string]string{"busnum": "9", "devnum": "5", "product": "G703"}, + } + iface := &types.Device{ + Devpath: downstream.Devpath + "/9-1:1.0", + Attrs: map[string]string{}, + } + hid := &types.Device{ + Devpath: iface.Devpath + "/0003:046d:c090.0001/hidraw/hidraw9", + Attrs: map[string]string{}, + } + + iface.Children = []*types.Device{hid} + downstream.Children = []*types.Device{iface} + hub.Children = []*types.Device{downstream} + + return []*types.Device{hub, downstream, iface, hid} +} + +func TestSelectDevices_DoesNotCrossIntoDownstreamUSBDevices(t *testing.T) { + t.Parallel() + + got, err := selectDevices(hubWithDownstream(), []string{"1d6b:0002"}) + + require.NoError(t, err) + assert.Equal(t, []string{"/dev/bus/usb/009/001"}, got) +} + +func TestSelectDevices_DownstreamDeviceKeepsOwnHidraw(t *testing.T) { + t.Parallel() + + got, err := selectDevices(hubWithDownstream(), []string{"046d:c090"}) + + require.NoError(t, err) + assert.Equal(t, []string{"/dev/bus/usb/009/005", "/dev/hidraw9"}, got) +} + +func TestSelectDevices_NoMatch(t *testing.T) { + t.Parallel() + + got, err := selectDevices(yubikeyTree(), []string{"dead:beef"}) + + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestSelectDevices_SkipsNonUSBDevices(t *testing.T) { + t.Parallel() + + // An interface with an inherited VendorID but no busnum/devnum is not a + // USB device node and must not be selected. + iface := &types.Device{ + Devpath: "/sys/devices/usb1/1-1/1-1:1.0", + VendorID: "1050", + ProductID: "0407", + Attrs: map[string]string{}, + } + + got, err := selectDevices([]*types.Device{iface}, []string{"1050:0407"}) + + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestSelectDevices_InvalidBusnumErrors(t *testing.T) { + t.Parallel() + + dev := &types.Device{ + Devpath: "/sys/devices/usb1/1-1", + VendorID: "1050", + ProductID: "0407", + Attrs: map[string]string{"busnum": "notanumber", "devnum": "2"}, + } + + _, err := selectDevices([]*types.Device{dev}, []string{"1050:0407"}) + + require.Error(t, err) +} + +func TestListDevices(t *testing.T) { + t.Parallel() + + got := listDevices(yubikeyTree()) + + assert.Equal(t, []Info{ + { + VendorID: "1050", + ProductID: "0407", + Product: "YubiKey OTP+FIDO+CCID", + Paths: []string{ + "/dev/bus/usb/005/012", + "/dev/hidraw9", + "/dev/hidraw10", + }, + }, + }, got) +} + +func TestListDevices_SortedByID(t *testing.T) { + t.Parallel() + + a := &types.Device{ + Devpath: "/sys/devices/usb1/1-1", + VendorID: "1050", + ProductID: "0407", + Attrs: map[string]string{"busnum": "1", "devnum": "3", "product": "YubiKey"}, + } + b := &types.Device{ + Devpath: "/sys/devices/usb1/1-2", + VendorID: "046d", + ProductID: "c52b", + Attrs: map[string]string{"busnum": "1", "devnum": "2", "product": "Unifying Receiver"}, + } + + got := listDevices([]*types.Device{a, b}) + + require.Len(t, got, 2) + assert.Equal(t, "046d", got[0].VendorID) + assert.Equal(t, "1050", got[1].VendorID) +} + +func TestParseVendorProduct(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + vendor, prod string + ok bool + }{ + {"1050:0407", "1050", "0407", true}, + {"ABCD:00EF", "ABCD", "00EF", true}, + {"105:0407", "", "", false}, + {"1050-0407", "", "", false}, + {"gggg:0407", "", "", false}, + {"YubiKey", "", "", false}, + {"1050:0407:extra", "", "", false}, + } + + for _, tc := range tests { + vendor, prod, ok := parseVendorProduct(tc.in) + assert.Equal(t, tc.ok, ok, tc.in) + assert.Equal(t, tc.vendor, vendor, tc.in) + assert.Equal(t, tc.prod, prod, tc.in) + } +} diff --git a/internal/types/workload.go b/internal/types/workload.go index af9a048..04bcb93 100644 --- a/internal/types/workload.go +++ b/internal/types/workload.go @@ -51,10 +51,12 @@ type HostAccess struct { Bluetooth bool `yaml:"bluetooth"` // USBDevices defines the USB devices to be made available to a - // workload, based on the USB product name. + // workload. Each entry is either a "vendor:product" identifier + // (e.g. 1050:0407) matched against the device IDs, or, for backwards + // compatibility, a prefix of the USB product name. // - // To list all USB product names for the current machine use: - // cat /sys/bus/usb/devices/*/product | sort -u + // To list the USB devices detected on the current machine use: + // qubesome usb USBDevices []string `yaml:"usbDevices"` Gpus string `yaml:"gpus"` Paths []string `yaml:"paths"`