From c23963d7a89ddcfea3ef2ba6bdc457e20f131b0a Mon Sep 17 00:00:00 2001 From: Victor Toso Date: Wed, 8 Nov 2023 23:38:53 +0100 Subject: [PATCH] machine: qemu: add usb host passthrough QEMU usb-host driver which is the one for passthrough, supports two options for selecting an USB devices in the host to provide it to the VM: - Bus and Device number the device is plugged - Vendor and Product information of the USB devices https://qemu-project.gitlab.io/qemu/system/devices/usb.html This commit allows a user to configure podman machine with either of options, with new --usb command line option for podman machine init. Examples podman machine init tosovm4 --usb vendor=13d3,product=5406 podman machine init tosovm3 --usb bus=1,devnum=4 --usb bus=1,devnum=3 This commit also allows a user to change the USBs configured with --usb command line option for podman machine set. Note that this commit does not handle host device permissions nor verify that the USB devices exists. Signed-off-by: Victor Toso --- cmd/podman/machine/init.go | 5 ++ cmd/podman/machine/set.go | 11 +++ .../markdown/podman-machine-init.1.md.in | 16 ++++ .../markdown/podman-machine-set.1.md.in | 14 ++++ pkg/machine/applehv/config.go | 4 + pkg/machine/applehv/machine.go | 3 + pkg/machine/config.go | 11 +++ pkg/machine/hyperv/config.go | 3 + pkg/machine/hyperv/machine.go | 4 + pkg/machine/qemu/command.go | 19 +++++ pkg/machine/qemu/config.go | 76 ++++++++++++++++++ pkg/machine/qemu/config_test.go | 79 +++++++++++++++++++ pkg/machine/qemu/machine.go | 8 ++ pkg/machine/wsl/config.go | 4 + pkg/machine/wsl/machine.go | 3 + 15 files changed, 260 insertions(+) create mode 100644 pkg/machine/qemu/config_test.go diff --git a/cmd/podman/machine/init.go b/cmd/podman/machine/init.go index f9ee9704e6..62c1c9be44 100644 --- a/cmd/podman/machine/init.go +++ b/cmd/podman/machine/init.go @@ -107,6 +107,11 @@ func init() { flags.StringArrayVarP(&initOpts.Volumes, VolumeFlagName, "v", cfg.ContainersConfDefaultsRO.Machine.Volumes.Get(), "Volumes to mount, source:target") _ = initCmd.RegisterFlagCompletionFunc(VolumeFlagName, completion.AutocompleteDefault) + USBFlagName := "usb" + flags.StringArrayVarP(&initOpts.USBs, USBFlagName, "", []string{}, + "USB Host passthrough: bus=$1,devnum=$2 or vendor=$1,product=$2") + _ = initCmd.RegisterFlagCompletionFunc(USBFlagName, completion.AutocompleteDefault) + VolumeDriverFlagName := "volume-driver" flags.StringVar(&initOpts.VolumeDriver, VolumeDriverFlagName, "", "Optional volume driver") _ = initCmd.RegisterFlagCompletionFunc(VolumeDriverFlagName, completion.AutocompleteDefault) diff --git a/cmd/podman/machine/set.go b/cmd/podman/machine/set.go index df73486c04..0682d4f87a 100644 --- a/cmd/podman/machine/set.go +++ b/cmd/podman/machine/set.go @@ -37,6 +37,7 @@ type SetFlags struct { Memory uint64 Rootful bool UserModeNetworking bool + USBs []string } func init() { @@ -74,6 +75,13 @@ func init() { ) _ = setCmd.RegisterFlagCompletionFunc(memoryFlagName, completion.AutocompleteNone) + usbFlagName := "usb" + flags.StringArrayVarP( + &setFlags.USBs, + usbFlagName, "", []string{}, + "USBs bus=$1,devnum=$2 or vendor=$1,product=$2") + _ = setCmd.RegisterFlagCompletionFunc(usbFlagName, completion.AutocompleteNone) + userModeNetFlagName := "user-mode-networking" flags.BoolVar(&setFlags.UserModeNetworking, userModeNetFlagName, false, // defaults not-relevant due to use of Changed() "Whether this machine should use user-mode networking, routing traffic through a host user-space process") @@ -110,6 +118,9 @@ func setMachine(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("user-mode-networking") { setOpts.UserModeNetworking = &setFlags.UserModeNetworking } + if cmd.Flags().Changed("usb") { + setOpts.USBs = &setFlags.USBs + } setErrs, lasterr := vm.Set(vmName, setOpts) for _, err := range setErrs { diff --git a/docs/source/markdown/podman-machine-init.1.md.in b/docs/source/markdown/podman-machine-init.1.md.in index f3d0c2cff5..3a3d023004 100644 --- a/docs/source/markdown/podman-machine-init.1.md.in +++ b/docs/source/markdown/podman-machine-init.1.md.in @@ -104,6 +104,20 @@ means to use the timezone of the machine host. The timezone setting is not used with WSL. WSL automatically sets the timezone to the same as the host Windows operating system. +#### **--usb**=*bus=number,devnum=number* or *vendor=hexadecimal,product=hexadecimal* + +Assign a USB device from the host to the VM via USB passthrough. +Only supported for QEMU Machines. + +The device needs to have proper permissions in order to be passed to the machine. This +means the device needs to be under your user group. + +Note that using bus and device number are simpler but the values can change every boot +or when the device is unplugged. + +When specifying a USB using vendor and product ID's, if more than one device has the +same vendor and product ID, the first available device is assigned. + @@option user-mode-networking #### **--username** @@ -160,6 +174,8 @@ $ podman machine init --rootful $ podman machine init --disk-size 50 $ podman machine init --memory=1024 myvm $ podman machine init -v /Users:/mnt/Users +$ podman machine init --usb vendor=13d3,product=5406 +$ podman machine init --usb bus=1,devnum=3 ``` ## SEE ALSO diff --git a/docs/source/markdown/podman-machine-set.1.md.in b/docs/source/markdown/podman-machine-set.1.md.in index 1471ab3521..1f0e416479 100644 --- a/docs/source/markdown/podman-machine-set.1.md.in +++ b/docs/source/markdown/podman-machine-set.1.md.in @@ -52,6 +52,20 @@ are no longer visible with the default connection/socket. This is because the ro users in the VM are completely separated and do not share any storage. The data however is not lost and you can always change this option back or use the other connection to access it. +#### **--usb**=*bus=number,devnum=number* or *vendor=hexadecimal,product=hexadecimal* or *""* + +Assign a USB device from the host to the VM. +Only supported for QEMU Machines. + +The device needs to be present when the VM starts. +The device needs to have proper permissions in order to be assign to podman machine. + +Use an empty string to remove all previously set USB devices. + +Note that using bus and device number are simpler but the values can change every boot or when the +device is unplugged. Using vendor and product might lead to collision in the case of multiple +devices with the same vendor product value, the first available device is assigned. + @@option user-mode-networking ## EXAMPLES diff --git a/pkg/machine/applehv/config.go b/pkg/machine/applehv/config.go index be4dfabbe9..671df990b7 100644 --- a/pkg/machine/applehv/config.go +++ b/pkg/machine/applehv/config.go @@ -113,6 +113,10 @@ func (v AppleHVVirtualization) LoadVMByName(name string) (machine.VM, error) { func (v AppleHVVirtualization) NewMachine(opts machine.InitOptions) (machine.VM, error) { m := MacMachine{Name: opts.Name} + if len(opts.USBs) > 0 { + return nil, fmt.Errorf("USB host passtrough not supported for applehv machines") + } + configDir, err := machine.GetConfDir(machine.AppleHvVirt) if err != nil { return nil, err diff --git a/pkg/machine/applehv/machine.go b/pkg/machine/applehv/machine.go index 8da6f3f74c..e7518ed8fa 100644 --- a/pkg/machine/applehv/machine.go +++ b/pkg/machine/applehv/machine.go @@ -458,6 +458,9 @@ func (m *MacMachine) Set(name string, opts machine.SetOptions) ([]error, error) } } } + if opts.USBs != nil { + setErrors = append(setErrors, errors.New("changing USBs not supported for applehv machines")) + } // Write the machine config to the filesystem err = m.writeConfig() diff --git a/pkg/machine/config.go b/pkg/machine/config.go index 96c283b7cc..b97cda77c3 100644 --- a/pkg/machine/config.go +++ b/pkg/machine/config.go @@ -39,6 +39,7 @@ type InitOptions struct { Rootful bool UID string // uid of the user that called machine UserModeNetworking *bool // nil = use backend/system default, false = disable, true = enable + USBs []string } type Status = string @@ -106,6 +107,7 @@ type SetOptions struct { Memory *uint64 Rootful *bool UserModeNetworking *bool + USBs *[]string } type SSHOptions struct { @@ -271,6 +273,13 @@ func ConfDirPrefix() (string, error) { return confDir, nil } +type USBConfig struct { + Bus string + DevNumber string + Vendor int + Product int +} + // ResourceConfig describes physical attributes of the machine type ResourceConfig struct { // CPUs to be assigned to the VM @@ -279,6 +288,8 @@ type ResourceConfig struct { DiskSize uint64 // Memory in megabytes assigned to the vm Memory uint64 + // Usbs + USBs []USBConfig } type Mount struct { diff --git a/pkg/machine/hyperv/config.go b/pkg/machine/hyperv/config.go index 223241d9f4..5d0fad405a 100644 --- a/pkg/machine/hyperv/config.go +++ b/pkg/machine/hyperv/config.go @@ -111,6 +111,9 @@ func (v HyperVVirtualization) NewMachine(opts machine.InitOptions) (machine.VM, if len(opts.ImagePath) < 1 { return nil, errors.New("must define --image-path for hyperv support") } + if len(opts.USBs) > 0 { + return nil, fmt.Errorf("USB host passtrough not supported for hyperv machines") + } m.RemoteUsername = opts.Username diff --git a/pkg/machine/hyperv/machine.go b/pkg/machine/hyperv/machine.go index ffacfa4ebb..257dc3d5bc 100644 --- a/pkg/machine/hyperv/machine.go +++ b/pkg/machine/hyperv/machine.go @@ -518,6 +518,10 @@ func (m *HyperVMachine) Set(name string, opts machine.SetOptions) ([]error, erro memoryChanged = true } + if opts.USBs != nil { + setErrors = append(setErrors, errors.New("changing USBs not supported for hyperv machines")) + } + if cpuChanged || memoryChanged { err := vm.UpdateProcessorMemSettings(func(ps *hypervctl.ProcessorSettings) { if cpuChanged { diff --git a/pkg/machine/qemu/command.go b/pkg/machine/qemu/command.go index 6e6c8f31be..c8dc315106 100644 --- a/pkg/machine/qemu/command.go +++ b/pkg/machine/qemu/command.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" + "github.com/containers/podman/v4/pkg/machine" "github.com/containers/podman/v4/pkg/machine/define" ) @@ -46,6 +47,24 @@ func (q *QemuCmd) SetNetwork() { *q = append(*q, "-netdev", "socket,id=vlan,fd=3", "-device", "virtio-net-pci,netdev=vlan,mac=5a:94:ef:e4:0c:ee") } +// SetNetwork adds a network device to the machine +func (q *QemuCmd) SetUSBHostPassthrough(usbs []machine.USBConfig) { + if len(usbs) == 0 { + return + } + // Add xhci usb emulation first and then each usb device + *q = append(*q, "-device", "qemu-xhci") + for _, usb := range usbs { + var dev string + if usb.Bus != "" && usb.DevNumber != "" { + dev = fmt.Sprintf("usb-host,hostbus=%s,hostaddr=%s", usb.Bus, usb.DevNumber) + } else { + dev = fmt.Sprintf("usb-host,vendorid=%d,productid=%d", usb.Vendor, usb.Product) + } + *q = append(*q, "-device", dev) + } +} + // SetSerialPort adds a serial port to the machine for readiness func (q *QemuCmd) SetSerialPort(readySocket, vmPidFile define.VMFile, name string) { *q = append(*q, diff --git a/pkg/machine/qemu/config.go b/pkg/machine/qemu/config.go index eb776097f2..8afe6498b0 100644 --- a/pkg/machine/qemu/config.go +++ b/pkg/machine/qemu/config.go @@ -6,6 +6,7 @@ import ( "io/fs" "os" "path/filepath" + "strconv" "strings" "time" @@ -59,6 +60,78 @@ func (v *MachineVM) setNewMachineCMD(qemuBinary string, cmdOpts *setNewMachineCM v.CmdLine.SetQmpMonitor(v.QMPMonitor) v.CmdLine.SetNetwork() v.CmdLine.SetSerialPort(v.ReadySocket, v.VMPidFilePath, v.Name) + v.CmdLine.SetUSBHostPassthrough(v.USBs) +} + +func parseUSBs(usbs []string) ([]machine.USBConfig, error) { + configs := []machine.USBConfig{} + for _, str := range usbs { + if str == "" { + // Ignore --usb="" as it can be used to reset USBConfigs + continue + } + + vals := strings.Split(str, ",") + if len(vals) != 2 { + return configs, fmt.Errorf("usb: fail to parse: missing ',': %s", str) + } + + left := strings.Split(vals[0], "=") + if len(left) != 2 { + return configs, fmt.Errorf("usb: fail to parse: missing '=': %s", str) + } + + right := strings.Split(vals[1], "=") + if len(right) != 2 { + return configs, fmt.Errorf("usb: fail to parse: missing '=': %s", str) + } + + option := "" + if (left[0] == "bus" && right[0] == "devnum") || + (right[0] == "bus" && left[0] == "devnum") { + option = "bus_devnum" + } + if (left[0] == "vendor" && right[0] == "product") || + (right[0] == "vendor" && left[0] == "product") { + option = "vendor_product" + } + + switch option { + case "bus_devnum": + bus, devnumber := left[1], right[1] + if right[0] == "bus" { + bus, devnumber = devnumber, bus + } + + configs = append(configs, machine.USBConfig{ + Bus: bus, + DevNumber: devnumber, + }) + case "vendor_product": + vendorStr, productStr := left[1], right[1] + if right[0] == "vendor" { + vendorStr, productStr = productStr, vendorStr + } + + vendor, err := strconv.ParseInt(vendorStr, 16, 0) + if err != nil { + return configs, fmt.Errorf("fail to convert vendor of %s: %s", str, err) + } + + product, err := strconv.ParseInt(productStr, 16, 0) + if err != nil { + return configs, fmt.Errorf("fail to convert product of %s: %s", str, err) + } + + configs = append(configs, machine.USBConfig{ + Vendor: int(vendor), + Product: int(product), + }) + default: + return configs, fmt.Errorf("usb: fail to parse: %s", str) + } + } + return configs, nil } // NewMachine initializes an instance of a virtual machine based on the qemu @@ -104,6 +177,9 @@ func (p *QEMUVirtualization) NewMachine(opts machine.InitOptions) (machine.VM, e vm.CPUs = opts.CPUS vm.Memory = opts.Memory vm.DiskSize = opts.DiskSize + if vm.USBs, err = parseUSBs(opts.USBs); err != nil { + return nil, err + } vm.Created = time.Now() diff --git a/pkg/machine/qemu/config_test.go b/pkg/machine/qemu/config_test.go new file mode 100644 index 0000000000..d1bd0f291e --- /dev/null +++ b/pkg/machine/qemu/config_test.go @@ -0,0 +1,79 @@ +package qemu + +import ( + "reflect" + "testing" + + "github.com/containers/podman/v4/pkg/machine" +) + +func TestUSBParsing(t *testing.T) { + tests := []struct { + name string + args []string + result []machine.USBConfig + wantErr bool + }{ + { + name: "Good vendor and product", + args: []string{"vendor=13d3,product=5406", "vendor=08ec,product=0016"}, + result: []machine.USBConfig{ + { + Vendor: 5075, + Product: 21510, + }, + { + Vendor: 2284, + Product: 22, + }, + }, + wantErr: false, + }, + { + name: "Good bus and device number", + args: []string{"bus=1,devnum=4", "bus=1,devnum=3"}, + result: []machine.USBConfig{ + { + Bus: "1", + DevNumber: "4", + }, + { + Bus: "1", + DevNumber: "3", + }, + }, + wantErr: false, + }, + { + name: "Bad vendor and product, not hexa", + args: []string{"vendor=13dk,product=5406"}, + result: []machine.USBConfig{}, + wantErr: true, + }, + { + name: "Bad vendor and product, bad separator", + args: []string{"vendor=13d3:product=5406"}, + result: []machine.USBConfig{}, + wantErr: true, + }, + { + name: "Bad vendor and product, missing equal", + args: []string{"vendor=13d3:product-5406"}, + result: []machine.USBConfig{}, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := parseUSBs(test.args) + if (err != nil) != test.wantErr { + t.Errorf("parseUUBs error = %v, wantErr %v", err, test.wantErr) + return + } + if !reflect.DeepEqual(got, test.result) { + t.Errorf("parseUUBs got %v, want %v", got, test.result) + } + }) + } +} diff --git a/pkg/machine/qemu/machine.go b/pkg/machine/qemu/machine.go index f7fd7ef38b..ad22527b80 100644 --- a/pkg/machine/qemu/machine.go +++ b/pkg/machine/qemu/machine.go @@ -401,6 +401,14 @@ func (v *MachineVM) Set(_ string, opts machine.SetOptions) ([]error, error) { } } + if opts.USBs != nil { + if usbConfigs, err := parseUSBs(*opts.USBs); err != nil { + setErrors = append(setErrors, fmt.Errorf("failed to set usb: %w", err)) + } else { + v.USBs = usbConfigs + } + } + err = v.writeConfig() if err != nil { setErrors = append(setErrors, err) diff --git a/pkg/machine/wsl/config.go b/pkg/machine/wsl/config.go index 001d7c675a..d46a0959f1 100644 --- a/pkg/machine/wsl/config.go +++ b/pkg/machine/wsl/config.go @@ -4,6 +4,7 @@ package wsl import ( + "fmt" "io/fs" "path/filepath" "strings" @@ -29,6 +30,9 @@ func VirtualizationProvider() machine.VirtProvider { // NewMachine initializes an instance of a wsl machine func (p *WSLVirtualization) NewMachine(opts machine.InitOptions) (machine.VM, error) { vm := new(MachineVM) + if len(opts.USBs) > 0 { + return nil, fmt.Errorf("USB host passtrough not supported for WSL machines") + } if len(opts.Name) > 0 { vm.Name = opts.Name } diff --git a/pkg/machine/wsl/machine.go b/pkg/machine/wsl/machine.go index 51d96e7434..bd6b391cf4 100644 --- a/pkg/machine/wsl/machine.go +++ b/pkg/machine/wsl/machine.go @@ -1135,7 +1135,10 @@ func (v *MachineVM) Set(_ string, opts machine.SetOptions) ([]error, error) { if opts.Memory != nil { setErrors = append(setErrors, errors.New("changing memory not supported for WSL machines")) + } + if opts.USBs != nil { + setErrors = append(setErrors, errors.New("changing USBs not supported for WSL machines")) } if opts.DiskSize != nil {