package config import ( "fmt" "math" "net" "os" "strconv" "strings" "time" ) // The VirtioDevice interface is an interface which is implemented by all virtio devices. type VirtioDevice VMComponent const ( // Possible values for VirtioInput.InputType VirtioInputPointingDevice = "pointing" VirtioInputKeyboardDevice = "keyboard" // Options for VirtioGPUResolution VirtioGPUResolutionWidth = "width" VirtioGPUResolutionHeight = "height" // Default VirtioGPU Resolution defaultVirtioGPUResolutionWidth = 800 defaultVirtioGPUResolutionHeight = 600 ) // VirtioInput configures an input device, such as a keyboard or pointing device // (mouse) that the virtual machine can use type VirtioInput struct { InputType string `json:"inputType"` // currently supports "pointing" and "keyboard" input types } type VirtioGPUResolution struct { Width int `json:"width"` Height int `json:"height"` } // VirtioGPU configures a GPU device, such as the host computer's display type VirtioGPU struct { UsesGUI bool `json:"usesGUI"` VirtioGPUResolution } // VirtioVsock configures of a virtio-vsock device allowing 2-way communication // between the host and the virtual machine type type VirtioVsock struct { // Port is the virtio-vsock port used for this device, see `man vsock` for more // details. Port uint32 `json:"port"` // SocketURL is the path to a unix socket on the host to use for the virtio-vsock communication with the guest. SocketURL string `json:"socketURL"` // If true, vsock connections will have to be done from guest to host. If false, vsock connections will only be possible // from host to guest Listen bool `json:"listen,omitempty"` } // VirtioBlk configures a disk device. type VirtioBlk struct { DiskStorageConfig DeviceIdentifier string `json:"deviceIdentifier,omitempty"` } type DirectorySharingConfig struct { MountTag string `json:"mountTag"` } // VirtioFs configures directory sharing between the guest and the host. type VirtioFs struct { DirectorySharingConfig SharedDir string `json:"sharedDir"` } // RosettaShare configures rosetta support in the guest to run Intel binaries on Apple CPUs type RosettaShare struct { DirectorySharingConfig InstallRosetta bool `json:"installRosetta"` IgnoreIfMissing bool `json:"ignoreIfMissing"` } // NVMExpressController configures a NVMe controller in the guest type NVMExpressController struct { DiskStorageConfig } // VirtioRng configures a random number generator (RNG) device. type VirtioRng struct { } // TODO: Add BridgedNetwork support // https://github.com/Code-Hex/vz/blob/d70a0533bf8ed0fa9ab22fa4d4ca554b7c3f3ce5/network.go#L81-L82 // VirtioNet configures the virtual machine networking. type VirtioNet struct { Nat bool `json:"nat"` MacAddress net.HardwareAddr `json:"-"` // custom marshaller in json.go // file parameter is holding a connected datagram socket. // see https://github.com/Code-Hex/vz/blob/7f648b6fb9205d6f11792263d79876e3042c33ec/network.go#L113-L155 Socket *os.File `json:"socket,omitempty"` UnixSocketPath string `json:"unixSocketPath,omitempty"` } // VirtioSerial configures the virtual machine serial ports. type VirtioSerial struct { LogFile string `json:"logFile,omitempty"` UsesStdio bool `json:"usesStdio,omitempty"` } type NBDSynchronizationMode string const ( SynchronizationFullMode NBDSynchronizationMode = "full" SynchronizationNoneMode NBDSynchronizationMode = "none" ) type NetworkBlockDevice struct { NetworkBlockStorageConfig DeviceIdentifier string Timeout time.Duration SynchronizationMode NBDSynchronizationMode } type VirtioBalloon struct{} func VirtioBalloonNew() (VirtioDevice, error) { return &VirtioBalloon{}, nil } func (v *VirtioBalloon) FromOptions(options []option) error { if len(options) != 0 { return fmt.Errorf("unknown options for virtio-balloon devices: %s", options) } return nil } func (v *VirtioBalloon) ToCmdLine() ([]string, error) { return []string{"--device", "virtio-balloon"}, nil } type option struct { key string value string } func strToOption(str string) option { splitStr := strings.SplitN(str, "=", 2) opt := option{ key: splitStr[0], } if len(splitStr) > 1 { opt.value = splitStr[1] } return opt } func strvToOptions(opts []string) []option { parsedOpts := []option{} for _, opt := range opts { if len(opt) == 0 { continue } parsedOpts = append(parsedOpts, strToOption(opt)) } return parsedOpts } func deviceFromCmdLine(deviceOpts string) (VirtioDevice, error) { opts := strings.Split(deviceOpts, ",") if len(opts) == 0 { return nil, fmt.Errorf("empty option list in command line argument") } var dev VirtioDevice switch opts[0] { case "rosetta": dev = &RosettaShare{} case "nvme": dev = nvmExpressControllerNewEmpty() case "virtio-blk": dev = virtioBlkNewEmpty() case "virtio-fs": dev = &VirtioFs{} case "virtio-net": dev = &VirtioNet{} case "virtio-rng": dev = &VirtioRng{} case "virtio-serial": dev = &VirtioSerial{} case "virtio-vsock": dev = &VirtioVsock{} case "usb-mass-storage": dev = usbMassStorageNewEmpty() case "virtio-input": dev = &VirtioInput{} case "virtio-gpu": dev = &VirtioGPU{} case "virtio-balloon": dev = &VirtioBalloon{} case "nbd": dev = networkBlockDeviceNewEmpty() default: return nil, fmt.Errorf("unknown device type: %s", opts[0]) } parsedOpts := strvToOptions(opts[1:]) if err := dev.FromOptions(parsedOpts); err != nil { return nil, err } return dev, nil } // VirtioSerialNew creates a new serial device for the virtual machine. The // output the virtual machine sent to the serial port will be written to the // file at logFilePath. func VirtioSerialNew(logFilePath string) (VirtioDevice, error) { return &VirtioSerial{ LogFile: logFilePath, }, nil } func VirtioSerialNewStdio() (VirtioDevice, error) { return &VirtioSerial{ UsesStdio: true, }, nil } func (dev *VirtioSerial) validate() error { if dev.LogFile != "" && dev.UsesStdio { return fmt.Errorf("'logFilePath' and 'stdio' cannot be set at the same time") } if dev.LogFile == "" && !dev.UsesStdio { return fmt.Errorf("one of 'logFilePath' or 'stdio' must be set") } return nil } func (dev *VirtioSerial) ToCmdLine() ([]string, error) { if err := dev.validate(); err != nil { return nil, err } if dev.UsesStdio { return []string{"--device", "virtio-serial,stdio"}, nil } return []string{"--device", fmt.Sprintf("virtio-serial,logFilePath=%s", dev.LogFile)}, nil } func (dev *VirtioSerial) FromOptions(options []option) error { for _, option := range options { switch option.key { case "logFilePath": dev.LogFile = option.value case "stdio": if option.value != "" { return fmt.Errorf("unexpected value for virtio-serial 'stdio' option: %s", option.value) } dev.UsesStdio = true default: return fmt.Errorf("unknown option for virtio-serial devices: %s", option.key) } } return dev.validate() } // VirtioInputNew creates a new input device for the virtual machine. // The inputType parameter is the type of virtio-input device that will be added // to the machine. func VirtioInputNew(inputType string) (VirtioDevice, error) { dev := &VirtioInput{ InputType: inputType, } if err := dev.validate(); err != nil { return nil, err } return dev, nil } func (dev *VirtioInput) validate() error { if dev.InputType != VirtioInputPointingDevice && dev.InputType != VirtioInputKeyboardDevice { return fmt.Errorf("unknown option for virtio-input devices: %s", dev.InputType) } return nil } func (dev *VirtioInput) ToCmdLine() ([]string, error) { if err := dev.validate(); err != nil { return nil, err } return []string{"--device", fmt.Sprintf("virtio-input,%s", dev.InputType)}, nil } func (dev *VirtioInput) FromOptions(options []option) error { for _, option := range options { switch option.key { case VirtioInputPointingDevice, VirtioInputKeyboardDevice: if option.value != "" { return fmt.Errorf("unexpected value for virtio-input %s option: %s", option.key, option.value) } dev.InputType = option.key default: return fmt.Errorf("unknown option for virtio-input devices: %s", option.key) } } return dev.validate() } // VirtioGPUNew creates a new gpu device for the virtual machine. // The usesGUI parameter determines whether a graphical application window will // be displayed func VirtioGPUNew() (VirtioDevice, error) { return &VirtioGPU{ UsesGUI: false, VirtioGPUResolution: VirtioGPUResolution{ Width: defaultVirtioGPUResolutionWidth, Height: defaultVirtioGPUResolutionHeight, }, }, nil } func (dev *VirtioGPU) validate() error { if dev.Height < 1 || dev.Width < 1 { return fmt.Errorf("invalid dimensions for virtio-gpu device resolution: %dx%d", dev.Width, dev.Height) } return nil } func (dev *VirtioGPU) ToCmdLine() ([]string, error) { if err := dev.validate(); err != nil { return nil, err } return []string{"--device", fmt.Sprintf("virtio-gpu,width=%d,height=%d", dev.Width, dev.Height)}, nil } func (dev *VirtioGPU) FromOptions(options []option) error { for _, option := range options { switch option.key { case VirtioGPUResolutionHeight: height, err := strconv.Atoi(option.value) if err != nil || height < 1 { return fmt.Errorf("Invalid value for virtio-gpu %s: %s", option.key, option.value) } dev.Height = height case VirtioGPUResolutionWidth: width, err := strconv.Atoi(option.value) if err != nil || width < 1 { return fmt.Errorf("Invalid value for virtio-gpu %s: %s", option.key, option.value) } dev.Width = width default: return fmt.Errorf("unknown option for virtio-gpu devices: %s", option.key) } } if dev.Width == 0 && dev.Height == 0 { dev.Width = defaultVirtioGPUResolutionWidth dev.Height = defaultVirtioGPUResolutionHeight } return dev.validate() } // VirtioNetNew creates a new network device for the virtual machine. It will // use macAddress as its MAC address. func VirtioNetNew(macAddress string) (*VirtioNet, error) { var hwAddr net.HardwareAddr if macAddress != "" { var err error if hwAddr, err = net.ParseMAC(macAddress); err != nil { return nil, err } } return &VirtioNet{ Nat: true, MacAddress: hwAddr, }, nil } // SetSocket Set the socket to use for the network communication // // This maps the virtual machine network interface to a connected datagram // socket. This means all network traffic on this interface will go through // file. // file must be a connected datagram (SOCK_DGRAM) socket. func (dev *VirtioNet) SetSocket(file *os.File) { dev.Socket = file dev.Nat = false } func (dev *VirtioNet) SetUnixSocketPath(path string) { dev.UnixSocketPath = path dev.Nat = false } func (dev *VirtioNet) validate() error { if dev.Nat && dev.Socket != nil { return fmt.Errorf("'nat' and 'fd' cannot be set at the same time") } if dev.Nat && dev.UnixSocketPath != "" { return fmt.Errorf("'nat' and 'unixSocketPath' cannot be set at the same time") } if dev.Socket != nil && dev.UnixSocketPath != "" { return fmt.Errorf("'fd' and 'unixSocketPath' cannot be set at the same time") } if !dev.Nat && dev.Socket == nil && dev.UnixSocketPath == "" { return fmt.Errorf("one of 'nat' or 'fd' or 'unixSocketPath' must be set") } return nil } func (dev *VirtioNet) ToCmdLine() ([]string, error) { if err := dev.validate(); err != nil { return nil, err } builder := strings.Builder{} builder.WriteString("virtio-net") switch { case dev.Nat: builder.WriteString(",nat") case dev.UnixSocketPath != "": fmt.Fprintf(&builder, ",unixSocketPath=%s", dev.UnixSocketPath) default: fmt.Fprintf(&builder, ",fd=%d", dev.Socket.Fd()) } if len(dev.MacAddress) != 0 { builder.WriteString(fmt.Sprintf(",mac=%s", dev.MacAddress)) } return []string{"--device", builder.String()}, nil } func (dev *VirtioNet) FromOptions(options []option) error { for _, option := range options { switch option.key { case "nat": if option.value != "" { return fmt.Errorf("unexpected value for virtio-net 'nat' option: %s", option.value) } dev.Nat = true case "mac": macAddress, err := net.ParseMAC(option.value) if err != nil { return err } dev.MacAddress = macAddress case "fd": fd, err := strconv.Atoi(option.value) if err != nil { return err } dev.Socket = os.NewFile(uintptr(fd), "vfkit virtio-net socket") case "unixSocketPath": dev.UnixSocketPath = option.value default: return fmt.Errorf("unknown option for virtio-net devices: %s", option.key) } } return dev.validate() } // VirtioRngNew creates a new random number generator device to feed entropy // into the virtual machine. func VirtioRngNew() (VirtioDevice, error) { return &VirtioRng{}, nil } func (dev *VirtioRng) ToCmdLine() ([]string, error) { return []string{"--device", "virtio-rng"}, nil } func (dev *VirtioRng) FromOptions(options []option) error { if len(options) != 0 { return fmt.Errorf("unknown options for virtio-rng devices: %s", options) } return nil } func nvmExpressControllerNewEmpty() *NVMExpressController { return &NVMExpressController{ DiskStorageConfig: DiskStorageConfig{ StorageConfig: StorageConfig{ DevName: "nvme", }, }, } } // NVMExpressControllerNew creates a new NVMExpress controller to use in the // virtual machine. It will use the file at imagePath as the disk image. This // image must be in raw format. func NVMExpressControllerNew(imagePath string) (*NVMExpressController, error) { r := nvmExpressControllerNewEmpty() r.ImagePath = imagePath return r, nil } func virtioBlkNewEmpty() *VirtioBlk { return &VirtioBlk{ DiskStorageConfig: DiskStorageConfig{ StorageConfig: StorageConfig{ DevName: "virtio-blk", }, }, DeviceIdentifier: "", } } // VirtioBlkNew creates a new disk to use in the virtual machine. It will use // the file at imagePath as the disk image. This image must be in raw format. func VirtioBlkNew(imagePath string) (*VirtioBlk, error) { virtioBlk := virtioBlkNewEmpty() virtioBlk.ImagePath = imagePath return virtioBlk, nil } func (dev *VirtioBlk) SetDeviceIdentifier(devID string) { dev.DeviceIdentifier = devID } func (dev *VirtioBlk) FromOptions(options []option) error { unhandledOpts := []option{} for _, option := range options { switch option.key { case "deviceId": dev.DeviceIdentifier = option.value default: unhandledOpts = append(unhandledOpts, option) } } return dev.DiskStorageConfig.FromOptions(unhandledOpts) } func (dev *VirtioBlk) ToCmdLine() ([]string, error) { cmdLine, err := dev.DiskStorageConfig.ToCmdLine() if err != nil { return []string{}, err } if len(cmdLine) != 2 { return []string{}, fmt.Errorf("unexpected storage config commandline") } if dev.DeviceIdentifier != "" { cmdLine[1] = fmt.Sprintf("%s,deviceId=%s", cmdLine[1], dev.DeviceIdentifier) } return cmdLine, nil } // VirtioVsockNew creates a new virtio-vsock device for 2-way communication // between the host and the virtual machine. The communication will happen on // vsock port, and on the host it will use the unix socket at socketURL. // When listen is true, the host will be listening for connections over vsock. // When listen is false, the guest will be listening for connections over vsock. func VirtioVsockNew(port uint, socketURL string, listen bool) (VirtioDevice, error) { if port > math.MaxUint32 { return nil, fmt.Errorf("invalid vsock port: %d", port) } return &VirtioVsock{ Port: uint32(port), //#nosec G115 -- was compared to math.MaxUint32 SocketURL: socketURL, Listen: listen, }, nil } func (dev *VirtioVsock) ToCmdLine() ([]string, error) { if dev.Port == 0 || dev.SocketURL == "" { return nil, fmt.Errorf("virtio-vsock needs both a port and a socket URL") } var listenStr string if dev.Listen { listenStr = "listen" } else { listenStr = "connect" } return []string{"--device", fmt.Sprintf("virtio-vsock,port=%d,socketURL=%s,%s", dev.Port, dev.SocketURL, listenStr)}, nil } func (dev *VirtioVsock) FromOptions(options []option) error { // default to listen for backwards compatibliity dev.Listen = true for _, option := range options { switch option.key { case "socketURL": dev.SocketURL = option.value case "port": port, err := strconv.ParseUint(option.value, 10, 32) if err != nil { return err } dev.Port = uint32(port) //#nosec G115 -- ParseUint(_, _, 32) guarantees no overflow case "listen": dev.Listen = true case "connect": dev.Listen = false default: return fmt.Errorf("unknown option for virtio-vsock devices: %s", option.key) } } return nil } // VirtioFsNew creates a new virtio-fs device for file sharing. It will share // the directory at sharedDir with the virtual machine. This directory can be // mounted in the VM using `mount -t virtiofs mountTag /some/dir` func VirtioFsNew(sharedDir string, mountTag string) (VirtioDevice, error) { return &VirtioFs{ DirectorySharingConfig: DirectorySharingConfig{ MountTag: mountTag, }, SharedDir: sharedDir, }, nil } func (dev *VirtioFs) ToCmdLine() ([]string, error) { if dev.SharedDir == "" { return nil, fmt.Errorf("virtio-fs needs the path to the directory to share") } if dev.MountTag != "" { return []string{"--device", fmt.Sprintf("virtio-fs,sharedDir=%s,mountTag=%s", dev.SharedDir, dev.MountTag)}, nil } return []string{"--device", fmt.Sprintf("virtio-fs,sharedDir=%s", dev.SharedDir)}, nil } func (dev *VirtioFs) FromOptions(options []option) error { for _, option := range options { switch option.key { case "sharedDir": dev.SharedDir = option.value case "mountTag": dev.MountTag = option.value default: return fmt.Errorf("unknown option for virtio-fs devices: %s", option.key) } } return nil } // RosettaShareNew RosettaShare creates a new rosetta share for running x86_64 binaries on M1 machines. // It will share a directory containing the linux rosetta binaries with the // virtual machine. This directory can be mounted in the VM using `mount -t // virtiofs mountTag /some/dir` func RosettaShareNew(mountTag string) (VirtioDevice, error) { return &RosettaShare{ DirectorySharingConfig: DirectorySharingConfig{ MountTag: mountTag, }, }, nil } func (dev *RosettaShare) ToCmdLine() ([]string, error) { if dev.MountTag == "" { return nil, fmt.Errorf("rosetta shares require a mount tag to be specified") } builder := strings.Builder{} builder.WriteString("rosetta") fmt.Fprintf(&builder, ",mountTag=%s", dev.MountTag) if dev.InstallRosetta { builder.WriteString(",install") } if dev.IgnoreIfMissing { builder.WriteString(",ignore-if-missing") } return []string{"--device", builder.String()}, nil } func (dev *RosettaShare) FromOptions(options []option) error { for _, option := range options { switch option.key { case "mountTag": dev.MountTag = option.value case "install": dev.InstallRosetta = true case "ignore-if-missing": dev.IgnoreIfMissing = true default: return fmt.Errorf("unknown option for rosetta share: %s", option.key) } } return nil } func networkBlockDeviceNewEmpty() *NetworkBlockDevice { return &NetworkBlockDevice{ NetworkBlockStorageConfig: NetworkBlockStorageConfig{ StorageConfig: StorageConfig{ DevName: "nbd", }, }, DeviceIdentifier: "", Timeout: time.Duration(15000 * time.Millisecond), // set a default timeout to 15s SynchronizationMode: SynchronizationFullMode, // default mode to full } } // NetworkBlockDeviceNew creates a new disk by connecting to a remote Network Block Device (NBD) server. // The provided uri must be in the format ://
/ // where scheme could have any of these value: nbd, nbds, nbd+unix and nbds+unix. // More info can be found at https://github.com/NetworkBlockDevice/nbd/blob/master/doc/uri.md // This allows the virtual machine to access and use the remote storage as if it were a local disk. func NetworkBlockDeviceNew(uri string, timeout uint32, synchronization NBDSynchronizationMode) (*NetworkBlockDevice, error) { nbd := networkBlockDeviceNewEmpty() nbd.URI = uri nbd.Timeout = time.Duration(timeout) * time.Millisecond nbd.SynchronizationMode = synchronization return nbd, nil } func (nbd *NetworkBlockDevice) ToCmdLine() ([]string, error) { cmdLine, err := nbd.NetworkBlockStorageConfig.ToCmdLine() if err != nil { return []string{}, err } if len(cmdLine) != 2 { return []string{}, fmt.Errorf("unexpected storage config commandline") } if nbd.DeviceIdentifier != "" { cmdLine[1] = fmt.Sprintf("%s,deviceId=%s", cmdLine[1], nbd.DeviceIdentifier) } if nbd.Timeout.Milliseconds() > 0 { cmdLine[1] = fmt.Sprintf("%s,timeout=%d", cmdLine[1], nbd.Timeout.Milliseconds()) } if nbd.SynchronizationMode == "none" || nbd.SynchronizationMode == "full" { cmdLine[1] = fmt.Sprintf("%s,sync=%s", cmdLine[1], nbd.SynchronizationMode) } return cmdLine, nil } func (nbd *NetworkBlockDevice) FromOptions(options []option) error { unhandledOpts := []option{} for _, option := range options { switch option.key { case "deviceId": nbd.DeviceIdentifier = option.value case "timeout": timeoutMS, err := strconv.ParseInt(option.value, 10, 32) if err != nil { return err } nbd.Timeout = time.Duration(timeoutMS) * time.Millisecond case "sync": switch option.value { case string(SynchronizationFullMode): nbd.SynchronizationMode = SynchronizationFullMode case string(SynchronizationNoneMode): nbd.SynchronizationMode = SynchronizationNoneMode default: return fmt.Errorf("invalid sync mode: %s, must be 'full' or 'none'", option.value) } default: unhandledOpts = append(unhandledOpts, option) } } return nbd.NetworkBlockStorageConfig.FromOptions(unhandledOpts) } type USBMassStorage struct { DiskStorageConfig } func usbMassStorageNewEmpty() *USBMassStorage { return &USBMassStorage{ DiskStorageConfig: DiskStorageConfig{ StorageConfig: StorageConfig{ DevName: "usb-mass-storage", }, }, } } // USBMassStorageNew creates a new USB disk to use in the virtual machine. It will use // the file at imagePath as the disk image. This image must be in raw or ISO format. func USBMassStorageNew(imagePath string) (*USBMassStorage, error) { usbMassStorage := usbMassStorageNewEmpty() usbMassStorage.ImagePath = imagePath return usbMassStorage, nil } func (dev *USBMassStorage) SetReadOnly(readOnly bool) { dev.StorageConfig.ReadOnly = readOnly } // StorageConfig configures a disk device. type StorageConfig struct { DevName string `json:"devName"` ReadOnly bool `json:"readOnly,omitempty"` } type DiskStorageConfig struct { StorageConfig ImagePath string `json:"imagePath,omitempty"` } type NetworkBlockStorageConfig struct { StorageConfig URI string `json:"uri,omitempty"` } func (config *DiskStorageConfig) ToCmdLine() ([]string, error) { if config.ImagePath == "" { return nil, fmt.Errorf("%s devices need the path to a disk image", config.DevName) } value := fmt.Sprintf("%s,path=%s", config.DevName, config.ImagePath) if config.ReadOnly { value += ",readonly" } return []string{"--device", value}, nil } func (config *DiskStorageConfig) FromOptions(options []option) error { for _, option := range options { switch option.key { case "path": config.ImagePath = option.value case "readonly": if option.value != "" { return fmt.Errorf("unexpected value for virtio-blk 'readonly' option: %s", option.value) } config.ReadOnly = true default: return fmt.Errorf("unknown option for %s devices: %s", config.DevName, option.key) } } return nil } func (config *NetworkBlockStorageConfig) ToCmdLine() ([]string, error) { if config.URI == "" { return nil, fmt.Errorf("%s devices need the uri to a remote block device", config.DevName) } value := fmt.Sprintf("%s,uri=%s", config.DevName, config.URI) if config.ReadOnly { value += ",readonly" } return []string{"--device", value}, nil } func (config *NetworkBlockStorageConfig) FromOptions(options []option) error { for _, option := range options { switch option.key { case "uri": config.URI = option.value case "readonly": if option.value != "" { return fmt.Errorf("unexpected value for virtio-blk 'readonly' option: %s", option.value) } config.ReadOnly = true default: return fmt.Errorf("unknown option for %s devices: %s", config.DevName, option.key) } } return nil }