{ config, pkgs, lib, vmixLib, ... }: with lib; with vmixLib.network; let vmixCfg = config.vmix; mkShortIfaceName = prefix: seed: "${prefix}-${builtins.substring 0 8 (builtins.hashString "sha256" seed)}"; mkServices4aVMInNamespace = spaceName: vmName: cfg: let vmCfg = cfg // { name = vmName; }; netCfg = vmCfg.networks; mkTap4aLan = lanName: tapCfg: let tapInterfaceName = mkShortIfaceName "vt" "${spaceName}-${vmCfg.name}-${lanName}"; lanInterfaceName = "brx-${lanName}"; in { name = lanName; iface = tapInterfaceName; mac = tapCfg.mac; create = '' ip tuntap add dev ${tapInterfaceName} mode tap ip link set dev ${tapInterfaceName} up ip link set dev ${tapInterfaceName} master ${lanInterfaceName} '' + lib.optionalString (tapCfg.ip != null) '' # Make the static ip somehow part of the script so nixOs thinks the service has changed when the IP changes, which will trigger a VM restart. # So whenever IP changes, VM will restart automatically # The IP is actually bein assigned to VM by dnsmasq, when VM requests it via DHCP # Static IP - ${tapCfg.ip} ''; delete = '' ip link del ${tapInterfaceName} ''; }; mkMacvtap = macvtapName: macvtapVmCfg: let macvtapNetworkCfg = config.vmix.namespaces.${spaceName}.networks.macvtaps.${macvtapName}; macvtapInterfaceName = mkShortIfaceName "mt" "${spaceName}-${vmCfg.name}-${macvtapNetworkCfg.uplink.iface}-${macvtapName}"; uplinkNamespaceArg = lib.optionalString (macvtapNetworkCfg.uplink.namespace != null) "-n ${macvtapNetworkCfg.uplink.namespace}"; in { name = macvtapName; iface = macvtapInterfaceName; mac = macvtapVmCfg.mac; create = '' ip ${uplinkNamespaceArg} link add link ${macvtapNetworkCfg.uplink.iface} name ${macvtapInterfaceName} type macvtap mode bridge ${lib.optionalString (macvtapVmCfg.mac != null) "ip ${uplinkNamespaceArg} link set dev ${macvtapInterfaceName} address ${macvtapVmCfg.mac}"} ip ${uplinkNamespaceArg} link set ${macvtapInterfaceName} netns ${spaceName}.vmix ip -n ${spaceName}.vmix link set dev ${macvtapInterfaceName} up ''; delete = '' ip -n ${spaceName}.vmix link del ${macvtapInterfaceName} ''; }; allTaps = (mapAttrsToList mkTap4aLan netCfg.lans); allMacvtaps = (mapAttrsToList mkMacvtap netCfg.macvtaps); createTapsforLansScript = pkgs.writeShellScript "${vmCfg.name}-taps-vmix" ( concatStringsSep "\n" (builtins.map (tap: tap.create) allTaps) ); deleteTapsforLansScript = pkgs.writeShellScript "${vmCfg.name}-taps-vmix" ( concatStringsSep "\n" (builtins.map (tap: tap.delete) allTaps) ); createMacvTapsScript = pkgs.writeShellScript "${vmCfg.name}-taps-vmix" ( concatStringsSep "\n" (builtins.map (macvtap: macvtap.create) allMacvtaps) ); deleteMacvTapsScript = pkgs.writeShellScript "${vmCfg.name}-taps-vmix" ( concatStringsSep "\n" (builtins.map (macvtap: macvtap.delete) allMacvtaps) ); hasOsDisk = vmCfg.disks.os.file != null; # Auto-detect Windows / macOS from _vmixOsType marker on the disk image osType = if hasOsDisk then vmCfg.disks.os.file._vmixOsType or "linux" else "linux"; isWindows = vmCfg.windows.enable || osType == "windows"; isMacos = vmCfg.macos.enable || osType == "macos"; macosMac = if vmCfg.macos.mac != null then vmCfg.macos.mac else if hasOsDisk then vmCfg.disks.os.file.macAddress or "52:54:00:c9:18:27" else "52:54:00:c9:18:27"; # OpenCore marks this PCI slot built-in (en0) macosNicPlacement = ",bus=pcie.0,addr=${vmixLib.macos.qemu.nicAddr}"; cpuArg = if isMacos && vmCfg.cpu.model == "host" then vmCfg.macos.cpu else "${vmCfg.cpu.model}${optionalString (vmCfg.cpu.hideVirtualized && !isMacos) ",kvm=off,hv_vendor_id=1234567890ab,-hypervisor"}"; # Interrupt remapping in the virtual IOMMU only works on a split irqchip, # so viommu wins over the full in-kernel irqchip hideVirtualized asks for. # (macOS keeps the default irqchip: hideVirtualized does not apply to it.) machineIrqchipArg = if vmCfg.pci.viommu.enable then ",kernel-irqchip=split" else optionalString (vmCfg.cpu.hideVirtualized && !isMacos) ",kernel_irqchip=on"; # Functions of one physical device have to reach the guest as functions # of one device too. Giving each address its own root port splits a GPU # from its own HDMI audio, and Navi cannot then reset or power-manage # either half -- the guest ends up with a card stuck in D3. So group by # everything left of the function digit and place each group behind a # single root port, multifunction, at the same slot. pciDeviceOf = addr: head (splitString "." addr); pciFunctionOf = addr: last (splitString "." addr); pciGroups = map (dev: filter (a: pciDeviceOf a == dev) vmCfg.pci.passthrough) (unique (map pciDeviceOf vmCfg.pci.passthrough)); # --- macOS: guest agent, virtio-fs shares, persistent home volume macosQemu = vmixLib.macos.qemu; qgaSock = "/run/vmix/qga-${vmCfg.name}.sock"; macosGuestAgent = isMacos && vmCfg.macos.guestAgent.enable; macosShares = if isMacos then vmCfg.shares else {}; macosShareNames = attrNames macosShares; macosAutomountShare = if macosShares ? automount then "automount" else if macosShareNames != [] then head macosShareNames else null; macosShareTag = n: if n == macosAutomountShare then macosQemu.automountTag else n; macosShareSock = n: "/run/vmix/vfs-${vmCfg.name}-${n}.sock"; macosHome = isMacos && vmCfg.macos.homeDisk.enable; macosFormatHome = if macosHome then vmixLib.macos.formatVolume { image = vmCfg.disks.os.file; label = vmCfg.macos.homeDisk.label; } else null; # shares beyond the automounted one are mounted through the guest agent once it answers macosMountSharesScript = pkgs.writeShellScript "${vmCfg.name}-macos-shares-vmix" '' for i in $(seq 1 120); do [ -S ${qgaSock} ] && printf '{"execute":"guest-ping"}\n' | ${pkgs.socat}/bin/socat -T5 - UNIX-CONNECT:${qgaSock} 2>/dev/null | grep -q return && break sleep 5 done ${concatMapStrings (n: optionalString (n != macosAutomountShare) '' printf '%s\n' '{"execute":"guest-exec","arguments":{"path":"/bin/bash","arg":["-c","mkdir -p ${macosShares.${n}.target}; mount -t virtiofs ${n} ${macosShares.${n}.target}"]}}' \ | ${pkgs.socat}/bin/socat -T10 - UNIX-CONNECT:${qgaSock} >/dev/null 2>&1 || true '') macosShareNames} ''; seedHomeDiskScript = pkgs.writeShellScript "${vmCfg.name}-home-disk-vmix" '' F="${vmCfg.macos.homeDisk.file}" if [ ! -e "$F" ]; then echo "Creating persistent home volume $F (${vmCfg.macos.homeDisk.size}, ${vmCfg.macos.homeDisk.format})..." mkdir -p "$(dirname "$F")" ${pkgs.qemu}/bin/qemu-img create -q -f ${vmCfg.macos.homeDisk.format} "$F" ${vmCfg.macos.homeDisk.size} chmod 600 "$F" ${macosFormatHome} "$F" ${vmCfg.macos.homeDisk.format} fi ''; # Linux VMs: apply customizeImage with 9p fstab and machine-id setup linuxOsImage = vmixLib.linux.customizeImage vmCfg.disks.os.file { name = vmCfg.name; commands = '' truncate /etc/machine-id run-command systemd-machine-id-setup run-command ssh-keygen -A run ${vmixLib.linux.scriptsNFiles.add-9p-mounts-to-fstab vmCfg.shares} ''; }; # Windows/macOS VMs: use disk image as-is (customization done at image build time) storeImage = if !hasOsDisk then null else if isWindows || isMacos then vmCfg.disks.os.file else linuxOsImage; # When persist = true, QEMU needs a mutable disk outside /nix/store. # The store image is copied to persistPath on first boot. osDiskPath = if !hasOsDisk then null else if vmCfg.disks.os.persist then vmCfg.disks.os.persistPath else toString storeImage; # Script to seed the persistent disk from the store image on first boot seedPersistentDiskScript = pkgs.writeShellScript "${vmCfg.name}-seed-disk-vmix" '' PERSIST_PATH="${vmCfg.disks.os.persistPath}" if [ ! -f "$PERSIST_PATH" ]; then echo "Seeding persistent disk from store image..." mkdir -p "$(dirname "$PERSIST_PATH")" cp --no-preserve=mode "${toString storeImage}" "$PERSIST_PATH" chmod 600 "$PERSIST_PATH" fi ''; persistExecStartPre = lib.optional (hasOsDisk && vmCfg.disks.os.persist) seedPersistentDiskScript ++ lib.optional macosHome seedHomeDiskScript; # QEMU expects single-letter boot codes (e.g. c,d,n), while vmix uses readable names. bootOrderQemu = let bootDeviceAliases = { os = "c"; iso = "d"; net = "n"; floppy = "a"; }; in concatStrings (builtins.map (device: bootDeviceAliases.${device}) vmCfg.boot.order); spiceUsbRedirArgs = if vmCfg.spice.enable && vmCfg.spice.usbRedir.enable then concatStringsSep " \\\n " ([ "-device qemu-xhci,id=spice-usb-xhci" ] ++ (concatMap (i: [ "-chardev spicevmc,name=usbredir,id=spice-usbredirchardev${toString i}" "-device usb-redir,chardev=spice-usbredirchardev${toString i},id=spice-usbredirdev${toString i}" ]) (range 1 vmCfg.spice.usbRedir.channels))) else ""; vncArgs = concatStringsSep "," ( [ "${vmCfg.vnc.addr}:${toString (vmCfg.vnc.port - 5900)}" "share=${vmCfg.vnc.sharePolicy}" ] ++ optional (vmCfg.vnc.websocketPort != null) "websocket=${toString vmCfg.vnc.websocketPort}" ++ optional (vmCfg.vnc.passwordFile != null) "password-secret=vnc-pass-${vmCfg.name}" ); qemuStartVMScript = pkgs.writeShellScript "${vmCfg.name}-qemu-vmix" '' ${optionalString (isMacos && macosShareNames != []) '' mkdir -p /run/vmix ${concatMapStrings (n: '' rm -f ${macosShareSock n} ${pkgs.virtiofsd}/bin/virtiofsd --socket-path=${macosShareSock n} --shared-dir ${toString macosShares.${n}.source} --cache auto --sandbox none & '') macosShareNames} for i in $(seq 1 50); do ${concatMapStringsSep " && " (n: "[ -S ${macosShareSock n} ]") macosShareNames} && break; sleep 0.2; done ${optionalString macosGuestAgent "${macosMountSharesScript} &"} ''} ${optionalString macosGuestAgent "mkdir -p /run/vmix; rm -f ${qgaSock}"} ${optionalString vmCfg.vnc.enable '' ${optionalString (vmCfg.vnc.passwordFile != null) '' if [ ! -r ${escapeShellArg vmCfg.vnc.passwordFile} ]; then echo "VNC password file is not readable: ${vmCfg.vnc.passwordFile}" >&2 exit 1 fi if [ ! -s ${escapeShellArg vmCfg.vnc.passwordFile} ]; then echo "VNC password file is empty: ${vmCfg.vnc.passwordFile}" >&2 exit 1 fi ''} ''} ${optionalString vmCfg.spice.enable '' ${optionalString (vmCfg.spice.passwordFile != null) '' if [ ! -r ${escapeShellArg vmCfg.spice.passwordFile} ]; then echo "SPICE password file is not readable: ${vmCfg.spice.passwordFile}" >&2 exit 1 fi if [ ! -s ${escapeShellArg vmCfg.spice.passwordFile} ]; then echo "SPICE password file is empty: ${vmCfg.spice.passwordFile}" >&2 exit 1 fi ''} ''} exec qemu-system-${vmCfg.arch} \ ${if vmCfg.nographic && vmCfg.pci.passthrough != [] then "-display none -vga none" else optionalString vmCfg.nographic "-nographic"} \ ${# QEMU realizes devices in command-line order and intel-iommu must # exist before anything it translates, so it leads the device list. optionalString vmCfg.pci.viommu.enable "-device intel-iommu,intremap=on,caching-mode=on"} \ ${optionalString (vmCfg.vnc.enable && vmCfg.vnc.passwordFile != null) "-object secret,id=vnc-pass-${vmCfg.name},file=${escapeShellArg vmCfg.vnc.passwordFile}"} \ ${optionalString vmCfg.vnc.enable "-vnc ${vncArgs}"} \ ${optionalString (vmCfg.spice.enable && vmCfg.spice.passwordFile != null) "-object secret,id=spice-pass-${vmCfg.name},file=${escapeShellArg vmCfg.spice.passwordFile}"} \ ${optionalString vmCfg.spice.enable "-spice addr=${vmCfg.spice.addr},port=${toString vmCfg.spice.port}${optionalString (vmCfg.spice.passwordFile == null) ",disable-ticketing=on"}${optionalString (vmCfg.spice.passwordFile != null) ",password-secret=spice-pass-${vmCfg.name}"}"} \ ${optionalString (vmCfg.spice.enable && !isMacos) (if vmCfg.spice.displayDevice == "qxl" && vmCfg.spice.vgamem != null then "-vga none -device qxl-vga,vgamem_mb=${toString vmCfg.spice.vgamem}" else "-vga ${vmCfg.spice.displayDevice}")} \ ${optionalString (vmCfg.spice.enable && vmCfg.spice.agent.enable) "-device virtio-serial-pci -chardev spicevmc,id=vdagent,debug=0,name=vdagent -device virtserialport,chardev=vdagent,name=com.redhat.spice.0"} \ ${# Guest agent channel — prevents qemu-ga from spinning when virtio-win guest tools are installed optionalString isWindows "${optionalString (!vmCfg.spice.enable || !vmCfg.spice.agent.enable) "-device virtio-serial-pci"} -chardev socket,path=/tmp/qga-${vmCfg.name}.sock,server=on,wait=off,id=qga0 -device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0"} \ ${spiceUsbRedirArgs} \ ${optionalString vmCfg.kvm "-accel kvm"} \ -name ${vmCfg.name} \ -m ${toString vmCfg.mem.size} \ ${optionalString vmCfg.mem.balloon "-device virtio-balloon-pci"} \ -smp cores=${toString vmCfg.cpu.cores} \ -cpu ${cpuArg} \ -machine type=${vmCfg.pc.type}${machineIrqchipArg} \ ${optionalString vmCfg.bios.efi "-bios ${pkgs.OVMF.fd}/FV/OVMF.fd"} \ ${optionalString vmCfg.bios.tpm "-chardev socket,id=chrtpm,path=/tmp/mytpm-sock -tpmdev emulator,id=tpm0,chardev=chrtpm -device tpm-tis,tpmdev=tpm0"} \ ${# Windows: localtime RTC, USB tablet for mouse, disable S3/S4 sleep optionalString isWindows '' -rtc base=localtime,clock=host \ -device qemu-xhci -device usb-tablet \ -global ICH9-LMB.disable_s3=1 -global ICH9-LMB.disable_s4=1 \ ''} \ ${# macOS: VirtualSMC, USB keyboard/tablet, AHCI system disk, VMware SVGA (also under SPICE), # Apple's guest agent, virtio-fs shares (shared memory backend), virtio-blk home volume optionalString isMacos '' ${macosQemu.deviceArgs} ${if vmCfg.spice.enable && vmCfg.spice.displayDevice == "std" then "-vga std" else macosQemu.vgaArgs} \ ${optionalString macosGuestAgent (macosQemu.guestAgentArgs qgaSock)} \ ${optionalString (macosShareNames != []) (macosQemu.memBackendArgs vmCfg.mem.size)} \ ${concatMapStrings (n: "${macosQemu.virtioFsArgs { tag = macosShareTag n; sock = macosShareSock n; id = n; }} \\\n ") macosShareNames} \ ${optionalString macosHome (macosQemu.virtioBlkArgs { id = "home"; file = vmCfg.macos.homeDisk.file; format = vmCfg.macos.homeDisk.format; })} \ ''} \ ${optionalString hasOsDisk (if isMacos then "-drive id=os,if=none,file=${osDiskPath},format=qcow2${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"} -device ide-hd,bus=sata.0,drive=os" else "-drive file=${osDiskPath},format=qcow2,if=virtio${optionalString (vmCfg.disks.os.persist == false) ",snapshot=on"}")} \ ${optionalString (vmCfg.disks.iso.file != null) "-drive file=${toString vmCfg.disks.iso.file},media=cdrom,readonly=on"} \ ${concatMapStrings (diskCfg: '' -drive file=${toString diskCfg.file},format=${diskCfg.format},if=${vmCfg.disks.bus} \ '') (attrValues vmCfg.disks.add)} \ ${optionalString (!isMacos) (concatStrings (mapAttrsToList (shareName: shareCfg: '' -virtfs local,path=${toString shareCfg.source},security_model=passthrough,mount_tag=${shareName} \ '') vmCfg.shares))} \ ${optionalString cfg.networks.user.enable " -netdev user,id=user \ -device ${vmCfg.nicModel},netdev=user${optionalString isMacos ",mac=${macosMac}${macosNicPlacement}"} \ "} \ ${concatStrings (imap1 (i: tapCfg: '' -device ${vmCfg.nicModel},netdev=lan-${tapCfg.name},mac=${tapCfg.mac}${optionalString (isMacos && i == 1 && !cfg.networks.user.enable) macosNicPlacement} \ -netdev tap,id=lan-${tapCfg.name},ifname=${tapCfg.iface},script=no,downscript=no \ '') allTaps)} \ ${concatStrings (imap1 (i: macvtap: '' -device ${vmCfg.nicModel},netdev=macvtap-${macvtap.name},mac=$(ip l show ${macvtap.iface} | awk '/link\/ether/{print $2}') \ -netdev tap,id=macvtap-${macvtap.name},fd=${toString (i+2)} ${toString (i+2)}<>/dev/tap$(ip l show ${macvtap.iface} | awk -F':' '/${macvtap.iface}/{print $1}') \ '') allMacvtaps)} \ ${concatStrings (imap1 (i: group: '' -device pcie-root-port,id=pci-passthrough${toString i},chassis=${toString i},slot=${toString i} \ '' + concatStrings (imap0 (j: pciAddr: '' -device vfio-pci,host=${pciAddr},bus=pci-passthrough${toString i},addr=0x0.${pciFunctionOf pciAddr}${optionalString (length group > 1 && j == 0) ",multifunction=on"}${optionalString (i == 1 && j == 0) "${optionalString vmCfg.pci.vgaPassthrough ",x-vga=on"}${optionalString (vmCfg.pci.romFile != null) ",romfile=${vmCfg.pci.romFile}"}"} \ '') group)) pciGroups)} \ ${concatMapStrings (usbDev: '' -device usb-host,vendorid=0x${usbDev.vendorId},productid=0x${usbDev.productId} \ '') vmCfg.usb.hostDevices} \ ${optionalString (vmCfg.boot.menu == true) "-boot menu=on"} \ ${optionalString (length vmCfg.boot.order > 0) "-boot order=${bootOrderQemu}"} \ ''; in lib.optionalAttrs (cfg.enable) { "vm.vmix@${vmCfg.name}" = rec { bindsTo = [ "net.vmix@${spaceName}.target" ] ++ lib.optional (allMacvtaps != []) "macvtaps.vm.vmix@${vmCfg.name}.service"; unitConfig.JoinsNamespaceOf = "ns.net.vmix@${spaceName}.service"; after = bindsTo; path = with pkgs; [ iproute2 qemu gawk coreutils ]; serviceConfig = { ExecStartPre = persistExecStartPre ++ [ createTapsforLansScript ]; ExecStart = qemuStartVMScript; ExecStopPost = deleteTapsforLansScript; PrivateTmp = true; ProtectSystem = true; ProtectHome = true; PrivateNetwork = true; RuntimeDirectory = "vmix"; RuntimeDirectoryPreserve = "yes"; } // lib.optionalAttrs (vmCfg.pci.passthrough != []) { # VFIO passthrough needs raw device access — relax sandboxing ProtectSystem = lib.mkForce false; SupplementaryGroups = [ "kvm" ]; }; wantedBy = lib.mkIf vmCfg.autostart [ "multi-user.target" ]; }; } // lib.optionalAttrs (allMacvtaps != []) { "macvtaps.vm.vmix@${vmCfg.name}" = rec { bindsTo = [ "net.vmix@${spaceName}.target" ]; after = bindsTo; partOf = [ "vm.vmix@${vmCfg.name}.service" ]; path = with pkgs; [ iproute2 ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; ExecStart = createMacvTapsScript; ExecStop = deleteMacvTapsScript; }; }; }; vmServices = concatMapAttrs (spaceName: namespaceCfg: (concatMapAttrs (mkServices4aVMInNamespace spaceName) namespaceCfg.vms)) vmixCfg.namespaces; in { config.systemd.services = vmServices; }