Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func RootCommand() *cli.Command {
hostRunCommand(),
flatpakCommand(),
headlessCommand(),
usbCommand(),
},
}

Expand Down
39 changes: 39 additions & 0 deletions cmd/cli/usb.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
271 changes: 212 additions & 59 deletions internal/runners/util/usb/devices.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading