diff --git a/src/exec/QemuSystem.go b/src/exec/QemuSystem.go index e8524aa9c31e9f072702eba3b68a342df209b6d9..cab58522388a2389f1d1525560ee99659a76a06d 100644 --- a/src/exec/QemuSystem.go +++ b/src/exec/QemuSystem.go @@ -38,7 +38,8 @@ func CheckQemuSystem() error { // Creates a qemu-system command. // The nic argument must be either "none" or "user". -func NewQemuSystem(qgaSock string, cpus, ram int, nic, diskFile string, spicePort int, secretPwdFile, certDir string) (*exec.Cmd, error) { +func NewQemuSystem(qgaSock string, cpus, ram int, nic string, usbDevs []string, diskFile string, spicePort int, secretPwdFile, certDir string) (*exec.Cmd, error) { + // Network config nicArgs := "none" if nic == "user" { addr, err := utils.RandMacAddress() @@ -48,6 +49,35 @@ func NewQemuSystem(qgaSock string, cpus, ram int, nic, diskFile string, spicePor nicArgs = "user,mac="+addr+",model=virtio-net-pci" } + // If there are USB devices, generates the QEMU command line to support them + usb := []string{} + + if len(usbDevs) > 0 { + // A qemu-xhci device is required + usb = append(usb, "-device qemu-xhci") + + // Generate the USB devices' filter list, for instance: + // "-1:0x0781:0x5567:-1:1|-1:0x067b:0x2303:-1:1|-1:-1:-1:-1:0" + var filter strings.Builder + for _, dev := range usbDevs { + filter.WriteString("-1:") + ids := strings.Split(dev, ":") // Extracts vendorID (vid) and productID (pid) + usbDev := "0x"+ids[0]+":0x"+ids[1] + filter.WriteString(usbDev) + filter.WriteString(":-1:1|") + } + filter.WriteString("-1:-1:-1:-1:0") + + // Generate QEMU's arguments for the USB devices + for i, _ := range usbDevs { + idx := strconv.Itoa(i+1) + usb = append(usb, "-chardev") + usb = append(usb, "spicevmc,name=usbredir,id=usbredir"+idx) + usb = append(usb, "-device") + usb = append(usb, "usb-redir,filter='"+filter.String()+"',chardev=usbredir"+idx) + } + } + // Remarks: // 1) This device is to allow copy/paste between guest & client // -device virtserialport,chardev=spicechannel0,name=com.redhat.spice.0 @@ -57,7 +87,32 @@ func NewQemuSystem(qgaSock string, cpus, ram int, nic, diskFile string, spicePor // - In the guest: mkdir shared && sudo mount -t 9p sharedfs shared // 4) For QEMU Guest Agent support: // -device virtio-serial -device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0 -chardev socket,path=path_to_sock_file,server=on,wait=off,id=qga0 - cmd := exec.Command(qemusystemBinary, "-enable-kvm", "-cpu", "host", "-smp", "cpus="+strconv.Itoa(cpus), "-m", strconv.Itoa(ram), "-drive", "file="+diskFile+",index=0,media=disk,format=qcow2,discard=unmap,detect-zeroes=unmap,if=virtio", "-vga", "virtio", "-device", "virtio-serial-pci", "-object", "secret,id=sec,file="+secretPwdFile+",format=base64", "-spice", "tls-port="+strconv.Itoa(spicePort)+",password-secret=sec,x509-dir="+certDir, "-device", "virtserialport,chardev=spicechannel0,name=com.redhat.spice.0", "-chardev", "spicevmc,id=spicechannel0,name=vdagent", "-nic", nicArgs, "-device", "virtio-serial", "-device", "virtserialport,chardev=qga0,name=org.qemu.guest_agent.0", "-chardev", "socket,path="+qgaSock+",server=on,wait=off,id=qga0") + // Enable KVM + args := []string{"-enable-kvm"} + // CPU + args = append(args, "-cpu", "host", "-smp", "cpus="+strconv.Itoa(cpus)) + // RAM + args = append(args, "-m", strconv.Itoa(ram)) + // Harddrive + args = append(args, "-drive", "file="+diskFile+",index=0,media=disk,format=qcow2,discard=unmap,detect-zeroes=unmap,if=virtio") + // Display + args = append(args, "-vga", "virtio") + // Virtio serial + args = append(args, "-device", "virtio-serial-pci") + // Spice + args = append(args, "-object", "secret,id=sec,file="+secretPwdFile+",format=base64", "-spice", "tls-port="+strconv.Itoa(spicePort)+",password-secret=sec,x509-dir="+certDir, "-device", "virtserialport,chardev=spicechannel0,name=com.redhat.spice.0", "-chardev", "spicevmc,id=spicechannel0,name=vdagent") + // Network + args = append(args, "-nic", nicArgs) + // QEMU Guest Agent + args = append(args, "-device", "virtio-serial", "-device", "virtserialport,chardev=qga0,name=org.qemu.guest_agent.0", "-chardev", "socket,path="+qgaSock+",server=on,wait=off,id=qga0") + // USB redirection + for _, u := range usb { + args = append(args, u) + } + + //cmd := exec.Command(qemusystemBinary, "-enable-kvm", "-cpu", "host", "-smp", "cpus="+strconv.Itoa(cpus), "-m", strconv.Itoa(ram), "-drive", "file="+diskFile+",index=0,media=disk,format=qcow2,discard=unmap,detect-zeroes=unmap,if=virtio", "-vga", "virtio", "-device", "virtio-serial-pci", "-object", "secret,id=sec,file="+secretPwdFile+",format=base64", "-spice", "tls-port="+strconv.Itoa(spicePort)+",password-secret=sec,x509-dir="+certDir, "-device", "virtserialport,chardev=spicechannel0,name=com.redhat.spice.0", "-chardev", "spicevmc,id=spicechannel0,name=vdagent", "-nic", nicArgs, "-device", "virtio-serial", "-device", "virtserialport,chardev=qga0,name=org.qemu.guest_agent.0", "-chardev", "socket,path="+qgaSock+",server=on,wait=off,id=qga0", usb) + + cmd := exec.Command(qemusystemBinary, args...) return cmd, nil } diff --git a/src/router/routerVMs.go b/src/router/routerVMs.go index 964d35a3151654a8c8be43ff3b8247e443058293..dfb38f58454463a2091280d315d01b42f1c40acb 100644 --- a/src/router/routerVMs.go +++ b/src/router/routerVMs.go @@ -181,8 +181,9 @@ func (r *RouterVMs)CreateVM(c echo.Context) error { type Parameters struct { Name string `json:"name" validate:"required,min=4,max=256"` Cpus int `json:"cpus" validate:"required,gte=1,lte=16"` - Ram int `json:"ram" validate:"required,gte=512,lte=32768"` // in MB - Nic vms.NicType `json:"nic" validate:"required"` // "none" or "user" + Ram int `json:"ram" validate:"required,gte=512,lte=32768"` + Nic vms.NicType `json:"nic" validate:"required` + UsbDevs []string `json:"usbDevs" validate:"required` TemplateID uuid.UUID `json:"templateID" validate:"required"` } p := new(Parameters) @@ -191,7 +192,7 @@ func (r *RouterVMs)CreateVM(c echo.Context) error { } // Creates a new VM from the client's parameters. - vm, err := vms.NewVM(user.Email, p.Name, p.Cpus, p.Ram, p.Nic, p.TemplateID, user.Email) + vm, err := vms.NewVM(user.Email, p.Name, p.Cpus, p.Ram, p.Nic, p.UsbDevs, p.TemplateID, user.Email) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } diff --git a/src/version/version.go b/src/version/version.go index 45abd3ec7d26b8f1c8299766d0dad2dc7bbaf0ae..20b181ad02d2d4b5924a1d99582569c012b4d5e6 100644 --- a/src/version/version.go +++ b/src/version/version.go @@ -6,7 +6,7 @@ import ( const ( major = 1 - minor = 5 + minor = 6 bugfix = 0 ) diff --git a/src/vms/vm.go b/src/vms/vm.go index ac6ccf0d5a034f6990de0538122954511074e0fc..fcdbd4b672ac7ee5dc29746ed8f62f0777cf5f5a 100644 --- a/src/vms/vm.go +++ b/src/vms/vm.go @@ -5,6 +5,7 @@ import ( "sync" "path" "errors" + "strings" "syscall" "net/mail" "io/ioutil" @@ -13,8 +14,8 @@ import ( "encoding/base64" "nexus-server/qga" "nexus-server/caps" - "nexus-server/paths" "nexus-server/exec" + "nexus-server/paths" "github.com/google/uuid" "github.com/go-playground/validator/v10" "github.com/sethvargo/go-password/password" @@ -28,9 +29,10 @@ type ( Name string `json:"name" validate:"required,min=2,max=256"` Cpus int `json:"cpus" validate:"required,gte=1,lte=16"` Ram int `json:"ram" validate:"required,gte=512,lte=32768"` // in MB - Nic NicType `json:"nic" validate:"required"` // "none" or "user" + Nic NicType `json:"nic" validate:"required"` + UsbDevs []string `json:"usbDevs" validate:"required"` TemplateID uuid.UUID `json:"templateID" validate:"required"` - Access map[string]caps.Capabilities `json:"access" validate:"required"` + Access map[string]caps.Capabilities `json:"access"` // None of the fields below are serialized to disk. dir string // VM directory @@ -40,44 +42,46 @@ type ( mutex *sync.Mutex } - runStates struct { - State VMState - Pid int - Port int - Pwd string - } - - VMState string - NicType string - // VM fields to be serialized to disk. VMDiskSerialized struct { ID uuid.UUID `json:"id"` Owner string `json:"owner"` Name string `json:"name"` Cpus int `json:"cpus"` - Ram int `json:"ram"` // in MB + Ram int `json:"ram"` Nic NicType `json:"nic"` + UsbDevs []string `json:"usbDevs"` TemplateID uuid.UUID `json:"templateID"` Access map[string]caps.Capabilities `json:"access"` } // VM fields to be serialized over the network (sent to the client). VMNetworkSerialized struct { - ID uuid.UUID - Owner string - Name string - Cpus int - Ram int - Nic NicType - TemplateID uuid.UUID - Access map[string]caps.Capabilities + ID uuid.UUID `json:"id"` + Owner string `json:"owner"` + Name string `json:"name"` + Cpus int `json:"cpus"` + Ram int `json:"ram"` + Nic NicType `json:"nic"` + UsbDevs []string `json:"usbDevs"` + TemplateID uuid.UUID `json:"templateID"` + Access map[string]caps.Capabilities `json:"access"` + State VMState `json:"state"` + Port int `json:"port"` + Pwd string `json:"pwd"` + DiskBusy bool `json:"diskBusy"` + } + + runStates struct { State VMState + Pid int Port int Pwd string - DiskBusy bool // If true the VM's disk is busy (cannot be modified or deleted) } + VMState string // see stateXXX defined below + NicType string // see nicXXX defined below + endOfExecCallback func(vm *VM) ) @@ -104,7 +108,7 @@ var passwordGen, _ = password.NewGenerator(&password.GeneratorInput{ }) // Creates a VM. -func NewVM(creatorEmail string, name string, cpus, ram int, nic NicType, templateID uuid.UUID, owner string) (*VM, error) { +func NewVM(creatorEmail string, name string, cpus, ram int, nic NicType, usbDevs []string, templateID uuid.UUID, owner string) (*VM, error) { vmID, err := uuid.NewRandom() if err != nil { @@ -118,6 +122,7 @@ func NewVM(creatorEmail string, name string, cpus, ram int, nic NicType, templat vm.Cpus = cpus vm.Ram = ram vm.Nic = nic + vm.UsbDevs = usbDevs vm.TemplateID = templateID id := vmID.String() vm.dir = filepath.Join(vms.dir, id[0:3], id[3:6], id) @@ -139,6 +144,7 @@ func (vm *VM)SerializeToDisk() VMDiskSerialized { Cpus: vm.Cpus, Ram: vm.Ram, Nic: vm.Nic, + UsbDevs: vm.UsbDevs, TemplateID: vm.TemplateID, Access: vm.Access, } @@ -152,6 +158,7 @@ func (vm *VM)SerializeToNetwork() VMNetworkSerialized { Cpus: vm.Cpus, Ram: vm.Ram, Nic: vm.Nic, + UsbDevs: vm.UsbDevs, TemplateID: vm.TemplateID, Access: vm.Access, State: vm.Run.State, @@ -180,6 +187,7 @@ func newEmptyVM() *VM { Cpus: 0, Ram: 0, Nic: "", + UsbDevs: []string{}, TemplateID: uuid.Nil, Access: make(map[string]caps.Capabilities), @@ -237,6 +245,10 @@ func (vm *VM)validate() error { return errors.New("Invalid nic value: "+string(vm.Nic)) } + if err := validateUsbDevs(vm.UsbDevs); err != nil { + return err + } + if err := validator.New().Struct(vm); err != nil { return err } @@ -473,7 +485,7 @@ func (vm *VM)reboot() error { // Executes the VM in QEMU using the specified spice port and password. func (vm *VM)runQEMU(port int, pwd, pwdFile string, endofExecFn endOfExecCallback) error { pkiDir := paths.GetInstance().NexusPkiDir - cmd, err := exec.NewQemuSystem(vm.qgaSock, vm.Cpus, vm.Ram, string(vm.Nic), filepath.Join(vm.dir, vmDiskFile), port, pwdFile, pkiDir) + cmd, err := exec.NewQemuSystem(vm.qgaSock, vm.Cpus, vm.Ram, string(vm.Nic), vm.UsbDevs, filepath.Join(vm.dir, vmDiskFile), port, pwdFile, pkiDir) if err != nil { return err } @@ -503,3 +515,33 @@ func (vm *VM)runQEMU(port int, pwd, pwdFile string, endofExecFn endOfExecCallbac func (vm *VM)resetStates() { vm.Run = runStates { State: stateStopped, Pid: 0, Port: 0, Pwd: "" } } + +// Validates and convert a string of USB devices of the form "1fc9:001d,067b:2303" +// into a slice of string where each element is a string of the form "1fc9:001d". +func validateUsbDevs(devs []string) error { + for _, dev := range devs { + ids := strings.Split(dev, ":") // Extracts vendorID (vid) and productID (pid) + if len(ids) != 2 { // expect exactly two values: vid and pid + return errors.New("Invalid USB device syntax") + } + vid, pid := ids[0], ids[1] + if !isUsbId(vid) || !isUsbId(pid) { + return errors.New("Invalid USB vendor/product ID") + } + } + return nil +} + +// A USB ID is either a vendor ID or a product ID. +// It's a string containing exactly 4 hexadecimal digits. +func isUsbId(s string) (bool) { + if len(s) != 4 { + return false + } + for _, r := range s { + if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F') { + return false + } + } + return true +}