sync with labv2.nix + standalone flake with toDisk app

Previous history:
- c359054 daku working!
- 8de5cff fix integer overflow in vmix network lib
- 9c25a66 daku on 25.05. with ollama
- 385a3bf vmix enables relaxed sandbox
- c363da1 restructure vmixLib into linux/windows subattrs with OS-specific customizeImage
- edd4dc2 vmix: port namespace model and module improvements from conf.nix
- 6666ecf vmix: add SPICE support, install virtio guest tools with SPICE agent
- 46f5671 vmix: add QEMU guest agent channel for Windows VMs
- e1fea34 vmix: add Win11 LTSC 2024 image, refactor VirtIO driver selection
- c27ae68 vmix: make customizeImage chroot-sandboxed by default, opt-in impure
- 305fbac virt customize needs chroot for now due to usr bin env things. could be fixed later
- 264d30f vmix: add win10 VM on desk, disable SMB signing for guest Samba access
- 9b64f51 vmix: split Windows templates into per-category files, add comprehensive debloat
- ef91bf8 vmix: fix missing parent registry keys in Windows templates
- f87f340 win10 VM on panda with AMD GPU + USB passthrough
- 38e474f vmix: split Windows build into Audit Mode install + composable templates
- a6a8db3 vmix: win11 support, remove build VNC, switch VMs to SPICE
- 6cf5a21 generalize stage sets bg color, accent color and sets visual effects to performance
- a84849f remove rdp template since it doesn't even work
- 5245263 vmix: best performance template + generalize cleanup
- ab12dd3 vmix: use CopyProfile for best performance visual effects
- bce3326 vmix: CopyProfile for best performance visual effects
- 2496107 vmix: add app templates (7zip, VLC, ImageGlass, Edge WebView, VC++ runtimes)
- 29a6123 wip: debug default associations xml
- 2a2e5f5 vmix: fix DefaultAssociations.xml cmd.exe escaping
- cc6ff9d vmix: move DefaultAssociations.xml to template only
- a4a78ec vmix: add removeWMP template to remove Windows Media Player
- 3fe56de vmix: improved Edge removal (files, shortcuts, scheduled tasks)
- a491767 vmix: fully remove Edge via post-oobe AppxPackage removal
- 6ca1619 vmix: remove Edge DevToolsClient SystemApps + AppxPackage
- 0c1ec35 vmix: sandboxie windows app template
- 628bbd2 vmix: add Sandboxie-Plus template
- f055a41 vmix: reorganize templates, add file associations, remove Paint
- 34326f4 vmix: set Thorium as default browser via PS-SFTA in post-oobe
- 86af258 vmix: Active Setup for default browser (all users, no post-oobe needed)
- 35b8cb0 remove vnc display from thorium template
- c7e0af6 vmix: fix Win11 generalize timeout + UCPD disable for URL associations
- 43a1345 vmix: add Office 2024 template + Ohook activation in generalize
- 03bbce0 vmix: updated office installation xml. more privacy options enabled
- 790a0ee vmix: thorium installation - hide SFTA window
- a0e5c18 vmix: fix office install.bat call + add privacy registry policies
- 3df38ca vmix: fix Ohook activation + suppress Office theme dialog
- df39ba3 vmix: remove sandboxie shortcut from desktop
- 50d5972 vmix: skip Sandboxie desktop shortcut via installer flag
- ee2fa0f vmix: fix win10 default browser
- 938315b vmix: windows: set accent color to automatic. remove accent color from unnecessary elements
- beceda8 vmix: allow ISO-only VMs without OS disk, add WinPE VM to panda

Flake outputs: overlays.default, nixosModules.default, lib, apps.toDisk

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Git Sagar 2026-05-23 19:16:35 -03:00
parent dd1fb16e1b
commit 94f299bb81
77 changed files with 2785 additions and 796 deletions

View file

@ -1,27 +1,96 @@
{
description = "builds a qemu image and qemu command scripts to run with systemd services";
description = "vmix composable QEMU VM image building and orchestration";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
};
outputs = { self, nixpkgs, ... }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
};
lib = pkgs.lib;
vmixLib = (import ./lib { inherit pkgs lib system; });
vmixLib = import ./lib { inherit pkgs lib system; };
in {
packages."${system}" = with vmixLib; rec {
playfuldeb = customizeImage images.debian.v12.play {
name = "playfulness";
overlays.default = import ./overlay.nix;
nixosModules.default = import ./module.nix;
lib.${system} = vmixLib;
packages.${system} = {
toDisk = pkgs.writeShellScriptBin "vmix-to-disk" ''
set -euo pipefail
usage() {
echo "Usage: vmix-to-disk <qcow2-image> <target-disk>"
echo ""
echo "Write a vmix qcow2 image to a physical disk and expand the Windows partition."
echo ""
echo "Examples:"
echo " vmix-to-disk /nix/store/...-win10-ltsc-vmix.qcow2 /dev/sda"
echo " vmix-to-disk ./my-image.qcow2 /dev/nvme0n1"
exit 1
}
[[ ''${#} -ne 2 ]] && usage
IMAGE="$1"
DISK="$2"
[[ $EUID -ne 0 ]] && { echo "Error: must run as root"; exit 1; }
[[ ! -f "$IMAGE" ]] && { echo "Error: image not found: $IMAGE"; exit 1; }
[[ ! -b "$DISK" ]] && { echo "Error: not a block device: $DISK"; exit 1; }
if ${pkgs.util-linux}/bin/mount | grep -q "^''${DISK}"; then
echo "Error: $DISK has mounted partitions unmount first"
exit 1
fi
DISK_SIZE=$(${pkgs.util-linux}/bin/blockdev --getsize64 "$DISK")
DISK_SIZE_GB=$(( DISK_SIZE / 1024 / 1024 / 1024 ))
echo "=== vmix to physical disk ==="
echo "Image: $IMAGE"
echo "Target: $DISK ($DISK_SIZE_GB GB)"
echo ""
echo "WARNING: This will DESTROY all data on $DISK"
read -rp "Continue? [y/N] " confirm
[[ "$confirm" != "y" && "$confirm" != "Y" ]] && { echo "Aborted."; exit 0; }
echo ""
echo "[1/4] Writing image to disk..."
${pkgs.qemu}/bin/qemu-img convert -p -f qcow2 -O raw "$IMAGE" "$DISK"
echo "[2/4] Fixing GPT backup header..."
${pkgs.gptfdisk}/bin/sgdisk -e "$DISK"
echo "[3/4] Expanding Windows partition (partition 3) to fill disk..."
${pkgs.parted}/bin/parted -s "$DISK" resizepart 3 100%
# detect partition device name (nvme/mmcblk use p3, others use 3)
if [[ "$DISK" == *nvme* ]] || [[ "$DISK" == *mmcblk* ]]; then
WIN_PART="''${DISK}p3"
else
WIN_PART="''${DISK}3"
fi
echo "[4/4] Expanding NTFS filesystem on $WIN_PART..."
${pkgs.ntfs3g}/bin/ntfsresize --force --no-action "$WIN_PART"
echo "y" | ${pkgs.ntfs3g}/bin/ntfsresize --force "$WIN_PART"
echo ""
echo "Done. Windows partition expanded to fill $DISK."
echo "You can now boot from the disk."
'';
};
nixmox = customizeImage images.debian.v12.proxmox (images.debian.templates.rooted // {
name = "nixmox";
});
default = playfuldeb;
apps.${system}.toDisk = {
type = "app";
program = "${self.packages.${system}.toDisk}/bin/vmix-to-disk";
};
};
}

View file

@ -4,7 +4,6 @@ let
network = import ./network.nix { inherit pkgs lib; };
in
{
inherit images;
inherit (images.commons) customizeImage customizeImageFold;
inherit (images) linux windows;
inherit network;
}

View file

@ -1,6 +0,0 @@
{ pkgs, lib, ... }: rec {
# basic scripts and files used across various OS images
scriptsNFiles = (import ./scripts-n-files.nix) { inherit pkgs lib; };
customizeImage = (import ./customizeImage.nix) { inherit pkgs lib; };
customizeImageFold = builtins.foldl' customizeImage;
}

View file

@ -1,7 +1,5 @@
{ pkgs, lib, system, ... }:
let
commons = (import ./commons) { inherit pkgs lib system; };
debian = (import ./debian) { inherit pkgs lib system commons; };
in {
inherit commons debian;
{
linux = (import ./linux) { inherit pkgs lib system; };
windows = (import ./windows) { inherit pkgs lib system; };
}

View file

@ -1,4 +1,4 @@
# wrapper function around virt-customize to create custom OS image from an original OS image
# wrapper function around virt-customize to create custom Linux image from an original OS image
{ pkgs, lib, ... }:
originalImage: {
name ? "",
@ -10,19 +10,16 @@
install ? [],
run ? "",
commands ? "",
osType ? "linux",
debug ? false
debug ? false,
impure ? true,
# Linux-only: script to run on first boot (via systemd --firstboot)
firstboot ? ""
}:
let
originalImageName = lib.strings.removeSuffix "-vmix" (lib.strings.removeSuffix ".qcow2" originalImage.name);
customImageName = (if name != "" then name else "custom") + "-${originalImageName}-vmix.qcow2";
resultImg = "./disk.qcow2";
qemuWrapperScript = (pkgs.writeShellScript "qemu-wrapper-script" ''
export PATH="${pkgs.qemu}/bin:$PATH"
exec qemu-kvm -nic user,model=virtio-net-pci "$@"
'');
setHostname = if hostname != "" then hostname else if nameToHostname then name else "";
virtCustomizeArgsHostname = if setHostname != "" then "--hostname '${setHostname}'" else "";
@ -30,6 +27,9 @@
virtCustomizeArgsCommandsFile = if commands != "" then ("--commands-from-file " + pkgs.writeText "${name}-virt-customize-commands-file" commands) else "";
virtCustomizeArgsRun = if run != "" then ("--run " + pkgs.writeScript "${name}-virt-customize-run-script" "${run}") else "";
virtCustomizeArgsFirstboot = lib.optionalString (firstboot != "")
("--firstboot " + pkgs.writeScript "${name}-firstboot" firstboot);
builderCommand = ''
export PATH="${pkgs.qemu}/bin:${pkgs.curl}/bin:$PATH"
@ -39,7 +39,6 @@
# run script inside image using virt-customize
export LIBGUESTFS_APPEND="ipv6.disable=1"
#export LIBGUESTFS_HV="${qemuWrapperScript}"
${pkgs.guestfs-tools}/bin/virt-customize \
${lib.optionalString debug "-v"} \
@ -48,10 +47,13 @@
--memsize ${builtins.toString memSize} \
${virtCustomizeArgsHostname} \
${virtCustomizeArgsInstall} \
${virtCustomizeArgsFirstboot} \
${virtCustomizeArgsCommandsFile} \
${virtCustomizeArgsRun}
mv ${resultImg} $out
'';
builtImage = pkgs.runCommand customImageName (lib.optionalAttrs impure { __noChroot = true; }) builderCommand;
in
pkgs.runCommand customImageName { __noChroot = true; } builderCommand
builtImage // { _vmixOsType = "linux"; }

View file

@ -1,10 +1,10 @@
{ pkgs, lib, system, commons, ... }:
{ pkgs, lib, system, linux, ... }:
let
# upstream distro images
upstreamImagesJSON = lib.importJSON ./upstream.json;
upstreamImages = lib.mapAttrs (name: src: pkgs.fetchurl src) upstreamImagesJSON.${system};
templates = (import ./templates.nix) { inherit pkgs lib system commons; };
customs = (import ./customs.nix) { inherit pkgs lib system commons upstreamImages templates; };
upstreamImages = lib.mapAttrs (name: src: (pkgs.fetchurl src) // { _vmixOsType = "linux"; }) upstreamImagesJSON.${system};
templates = (import ./templates.nix) { inherit pkgs lib system linux; };
customs = (import ./images.nix) { inherit pkgs lib system linux upstreamImages templates; };
mergeUpstreamAndCustomImages =
name: upstreamImage:
let

View file

@ -1,6 +1,6 @@
# create additional useful customized images from templates and upstream images
{ pkgs, lib, system, commons, upstreamImages, templates, ... }:
with commons;
{ pkgs, lib, system, linux, upstreamImages, templates, ... }:
with linux;
with scriptsNFiles;
let
upstreamImageName = "v12";
@ -20,7 +20,7 @@ in
});
# proxmox
proxmox = customizeImage default (templates.proxmoxOnDebian12 // {
proxmox = customizeImage upstreamImages.${upstreamImageName} (templates.proxmoxOnDebian12 // {
name = "proxmox";
});
};

View file

@ -1,15 +1,17 @@
# ready to use customization templates to apply on images
{ pkgs, lib, system, commons, ... }:
with commons;
{ pkgs, lib, system, linux, ... }:
with linux;
with scriptsNFiles;
{
# essential functionalities like ssh, networking etc
essentials = {
impure = true;
install = [ "htop" "openssh-server" "inetutils-ping" "dnsutils" "cloud-guest-utils" "qemu-guest-agent" ];
commands = ''
upload ${grub-ifnames-0}:/etc/default/grub.d/90-ifnames-0.cfg
upload ${grub-disable-microcode}:/etc/default/grub.d/00-disable-microcode.cfg
run-command mount /boot/efi && update-grub
run-command mountpoint -q /boot/efi || mount /boot/efi
run-command update-grub
upload ${dhcp-network-for-iface { iface = "eth0"; }}:/etc/systemd/network/00-eth0-dhcp.network
run ${ssh-service-override-conf-create}
upload ${grow-root-sh}:/usr/local/sbin/grow-root.sh
@ -22,6 +24,7 @@ with scriptsNFiles;
# set easy root access
rooted = {
impure = true;
install = [ "openssh-server" ];
commands = ''
run ${ssh-service-override-conf-create}
@ -36,17 +39,43 @@ with scriptsNFiles;
# install proxmox
proxmoxOnDebian12 = {
diskSize = "+2G";
impure = true;
diskSize = "+3G";
smp = 4;
memSize = 4096;
install = [ "cloud-guest-utils" ];
commands = ''
debug = true;
commands =
let
# proxmox makes it very hard to manually add interfaces directly on /etc/network/interfaces while the pve services are not running
# it also doesn't pick up files in interfaces.d
# so manually do that via service after boot
mergeNetIfacesDService = pkgs.writeText "manual-net-ifaces.d.service" ''
[Service]
Type = oneshot
ExecStart = /bin/bash -c "cat /etc/network/interfaces.d/* >> /etc/network/interfaces; rm /etc/network/interfaces.d/*; ifreload -a;"
After = network.target
[Install]
WantedBy = multi-user.target
'';
in
''
upload ${grub-ifnames-0}:/etc/default/grub.d/90-ifnames-0.cfg
upload ${grub-disable-microcode}:/etc/default/grub.d/00-disable-microcode.cfg
truncate /etc/machine-id
delete /var/lib/dbus/machine-id
upload ${grow-root-sh}:/usr/local/sbin/grow-root.sh
upload ${grow-root-service}:/etc/systemd/system/grow-root.service
run-command systemctl enable grow-root.service
upload ${mergeNetIfacesDService}:/etc/systemd/system/manual-net-ifaces.d.service
run-command systemctl enable manual-net-ifaces.d.service
'';
run = ''
# script originally taken from https://pve.proxmox.com/wiki/Install_Proxmox_VE_on_Debian_12_Bookworm
# script originally taken and modified from https://pve.proxmox.com/wiki/Install_Proxmox_VE_on_Debian_12_Bookworm
# exit if error
set -e
@ -54,7 +83,7 @@ with scriptsNFiles;
/usr/local/sbin/grow-root.sh
# mount efi for grub changes
mount /boot/efi
mount /boot/efi || true
# add proxmox repo
echo "deb [arch=amd64] http://download.proxmox.com/debian/pve bookworm pve-no-subscription" > /etc/apt/sources.list.d/pve-install-repo.list
@ -70,6 +99,29 @@ with scriptsNFiles;
# remove previous kernels
apt remove -y os-prober linux-image-amd64 'linux-image-6.*';
# otherwise grub upgrades make the device unbootable
echo 'grub-efi-amd64 grub2/force_efi_extra_removable boolean true' | debconf-set-selections -v -u
rm -rf /boot/efi/*
grub-install /dev/sda
grub-install --target=x86_64-efi --removable
# disable subscription warning
# https://dannyda.com/2020/05/17/how-to-remove-you-do-not-have-a-valid-subscription-for-this-server-from-proxmox-virtual-environment-6-1-2-proxmox-ve-6-1-2-pve-6-1-2/
sed -i -z "s/res === null ||\n\s* res === undefined ||\n\s* \!res ||\n\s* res.data.status.toLowerCase() \!== 'active'/false/g" /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js
# stop hangs due to network
systemctl disable systemd-networkd-wait-online.service
# create vmbr0 conf, enable dhcp. this conf will be picked by manual-net-ifaces.d.service
cat >> /etc/network/interfaces.d/vmbr0.conf << EOF
auto vmbr0
iface vmbr0 inet dhcp
bridge-ports eth0
bridge-stp off
bridge-fd 0
EOF
'';
};
}

View file

@ -11,12 +11,12 @@
},
"x86_64-linux": {
"v12": {
"sha256": "0inga3c772wr9b296w86n8prlqvw47wd6b5z8347pygiw810y5yq",
"url": "https://cloud.debian.org/images/cloud/bookworm/20240507-1740/debian-12-generic-amd64-20240507-1740.qcow2"
"sha256": "5fvoe45ooVSPwQ3FRn8+ge18sAvSVk+m3iUO71WTM1A=",
"url": "https://cloud.debian.org/images/cloud/bookworm/20250530-2128/debian-12-generic-amd64-20250530-2128.qcow2"
},
"v13": {
"sha256": "1bixl6gnzigwryac1arc3n81nv4hwdi6wxpwmvrgigzni64b3x6w",
"url": "https://cloud.debian.org/images/cloud/trixie/daily/20240512-1745/debian-13-generic-amd64-daily-20240512-1745.qcow2"
"url": "https://cloud.debian.org/images/cloud/trixie/daily/20250605-2134/debian-13-generic-amd64-daily-20250605-2134.raw"
}
}
}

View file

@ -0,0 +1,14 @@
{ pkgs, lib, system, ... }:
let
linux = rec {
# basic scripts and files used across various Linux images
scriptsNFiles = (import ./scripts-n-files.nix) { inherit pkgs lib; };
customizeImage = (import ./customizeImage.nix) { inherit pkgs lib; };
customizeImageFold = builtins.foldl' customizeImage;
};
debian = (import ./debian) { inherit pkgs lib system linux; };
in linux // {
images = {
debian = debian;
};
}

View file

@ -69,4 +69,14 @@
[Install]
WantedBy = multi-user.target
'';
add-9p-mounts-to-fstab = shares:
let
shareToFstabEntry = name: share: "${name} ${share.target} 9p trans=virtio,version=9p2000.L,rw,posixacl,msize=104857600,cache=loose 0 0";
in
pkgs.writeText "9p-to-fstab-sh" ''
cat >> /etc/fstab << EOF
${lib.concatStringsSep "\n" (lib.mapAttrsToList shareToFstabEntry shares)}
EOF
'';
}

View file

@ -0,0 +1,32 @@
{ pkgs, lib, system, ... }:
let
windows = rec {
drivers = import ./drivers { inherit pkgs system; };
makeFilesISO = (import ./helpers/makeFilesISO.nix) { inherit pkgs; };
customizeImage = (import ./helpers/customizeImage.nix) { inherit pkgs lib; };
customizeImageFold = builtins.foldl' customizeImage;
templates = (import ./templates) { inherit pkgs lib system drivers makeFilesISO; };
makeAuditModeAutounattend = (import ./helpers/makeAuditModeAutounattend.nix) { inherit pkgs lib; };
makeWinISO = (import ./helpers/makeWinISO.nix) { inherit pkgs; };
makeImage = (import ./helpers/makeImage.nix) { inherit pkgs lib drivers makeWinISO makeAuditModeAutounattend; };
};
win10 = (import ./win10) { inherit pkgs lib system windows; };
win11 = (import ./win11) { inherit pkgs lib system windows; };
# Recursively add .generalize to every derivation leaf in the image tree
addGeneralize = val:
if val ? _vmixOsType then
val // { generalize = args:
let
templateArgs = builtins.removeAttrs args [ "vncDisplay" ];
displayArgs = lib.optionalAttrs (args ? vncDisplay) { inherit (args) vncDisplay; };
in windows.customizeImage val (windows.templates.generalize templateArgs // displayArgs);
}
else if builtins.isAttrs val then
lib.mapAttrs (_: addGeneralize) val
else val;
in windows // {
images = addGeneralize { inherit win10 win11; };
}

View file

@ -0,0 +1,5 @@
{ pkgs }:
pkgs.fetchurl {
url = "https://github.com/Matishzz/AMD-Install-Drivers/releases/download/25.3.1/25.3.1.w11-w10.zip";
sha256 = "0mrz3kkiavs0l4x00978vdbw9la1j16jar0y6lny0l8z59hqp9gq";
}

View file

@ -0,0 +1,4 @@
{ pkgs, system, ... }: {
virtio-iso = import ./virtio-iso-w10-11.nix { inherit pkgs; };
amd-gpu-zip = import ./amd-gpu-drivers.nix { inherit pkgs; };
}

View file

@ -0,0 +1,6 @@
# VirtIO drivers ISO for Windows guests (stable release from Fedora)
{ pkgs, ... }:
pkgs.fetchurl {
url = " https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/archive-virtio/virtio-win-0.1.285-1/virtio-win-0.1.285.iso";
sha256 = "0cb3jyik40mkmyjwbagfj6609cnyzvysf2q7y0jykhwj8jwz4k71";
}

View file

@ -0,0 +1,113 @@
# Customize Windows images via offline registry merge and/or Audit Mode script execution.
#
# Templates can provide:
# windowsRegistry — merged offline via virt-win-reg (fast, no boot needed)
# auditScript — injected into image and run in Audit Mode via QEMU boot
# cdroms — ISO files to attach as CDs when booting for auditScript
#
# Both can be combined: registry is applied first (offline), then the script runs (online).
{ pkgs, lib, ... }:
originalImage: {
name ? "",
diskSize ? "",
impure ? true,
# Offline: merge .reg file into registry via virt-win-reg
windowsRegistry ? "",
# Online: boot into Audit Mode and run this script
auditScript ? "",
# CD-ROMs to attach when booting for auditScript (e.g. VirtIO ISO)
cdroms ? [],
# Files to upload into the image before running auditScript
# List of { source = <drv or path>; dest = "/Windows/path"; }
uploads ? [],
# QEMU settings for auditScript boot
vncDisplay ? null,
smp ? 4,
memSize ? 4096,
}:
let
originalImageName = lib.strings.removeSuffix "-vmix" (lib.strings.removeSuffix ".qcow2" originalImage.name);
customImageName = (if name != "" then name else "custom") + "-${originalImageName}-vmix.qcow2";
resultImg = "./disk.qcow2";
hasRegistry = windowsRegistry != "";
hasAuditScript = auditScript != "";
# Offline registry merge
windowsRegFile = pkgs.writeText "${name}-registry.reg" windowsRegistry;
virtWinRegMerge = lib.optionalString hasRegistry ''
echo "=== vmix: merging registry entries (${name}) ==="
virt-win-reg --merge ${resultImg} ${windowsRegFile}
'';
# Audit Mode script injection + QEMU boot
auditScriptFile = pkgs.writeText "${name}-audit.cmd" auditScript;
wrapperScript = pkgs.writeText "${name}-audit-wrapper.cmd" ''
@echo off
echo === vmix audit: ${name} ===
call C:\vmix-audit-script.cmd
echo === vmix audit: ${name} complete ===
del /q C:\vmix-audit-script.cmd 2>nul
shutdown /s /t 5 /c "vmix: ${name} complete" 2>nul
del /q C:\vmix-audit-wrapper.cmd 2>nul
'';
runOnceReg = pkgs.writeText "${name}-runonce.reg" (lib.concatStringsSep "\n" [
"Windows Registry Editor Version 5.00"
""
''[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce]''
''"vmixAudit"="C:\\vmix-audit-wrapper.cmd"''
""
]);
cdromArgs = lib.concatMapStringsSep " \\\n " (cd: "-drive file=${cd},media=cdrom,readonly=on") cdroms;
auditBootCommands = lib.optionalString hasAuditScript ''
echo "=== vmix: injecting audit script (${name}) ==="
virt-customize -a ${resultImg} \
--upload ${auditScriptFile}:/vmix-audit-script.cmd \
--upload ${wrapperScript}:/vmix-audit-wrapper.cmd \
${lib.concatMapStringsSep " \\\n " (u: let dir = builtins.dirOf u.dest; in "${lib.optionalString (dir != "/") "--mkdir ${dir}"} --upload ${u.source}:${u.dest}") uploads}
echo "=== vmix: adding RunOnce entry ==="
virt-win-reg --merge ${resultImg} ${runOnceReg}
# Boot into Audit Mode to run the script
cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd
chmod +w vars.fd
echo "=== vmix: booting Audit Mode for ${name} (VNC: ${if vncDisplay != null then vncDisplay else "disabled"}) ==="
timeout 1800 qemu-system-x86_64 \
${if vncDisplay != null then "-vnc ${vncDisplay}" else "-nographic"} \
-accel kvm \
-m ${toString memSize} \
-smp ${toString smp} \
-cpu host \
-machine type=q35 \
-drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd \
-drive if=pflash,format=raw,file=vars.fd \
-rtc base=localtime,clock=host \
-device qemu-xhci -device usb-tablet \
-global ICH9-LMB.disable_s3=1 -global ICH9-LMB.disable_s4=1 \
-drive file=${resultImg},format=qcow2,if=virtio \
${cdromArgs} \
-nic user,model=virtio-net-pci
echo "=== vmix: audit script ${name} complete ==="
'';
builderCommand = ''
# create resulting image backed by original image
qemu-img create -f qcow2 -b ${originalImage} -F qcow2 ${resultImg}
[ -n "${diskSize}" ] && qemu-img resize ${resultImg} ${diskSize}
${virtWinRegMerge}
${auditBootCommands}
mv ${resultImg} $out
'';
builtImage = pkgs.runCommand customImageName ({
nativeBuildInputs = with pkgs; [ qemu perl guestfs-tools ];
requiredSystemFeatures = [ "kvm" ];
} // lib.optionalAttrs impure { __noChroot = true; }) builderCommand;
in
builtImage // { _vmixOsType = "windows"; }

View file

@ -0,0 +1,174 @@
# Autounattend.xml for unattended Windows installation into Audit Mode.
# Minimal — only what's needed to install Windows and enter Audit Mode.
# All customization (hostname, timezone, telemetry, UAC, etc.) is done via templates.
{ pkgs, lib, ... }:
{
locale ? "en-US",
productKey ? "",
diskIndex ? 0,
imageIndex ? 1,
efi ? true,
virtioDriverLetter ? "E",
bypassRequirements ? false,
windowsVersionForVirtioDrivers
}: pkgs.writeText "Autounattend.xml" ''
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<!-- windowsPE locale, product key, VirtIO drivers, disk partitioning -->
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<SetupUILanguage>
<UILanguage>${locale}</UILanguage>
</SetupUILanguage>
<InputLocale>${locale}</InputLocale>
<SystemLocale>${locale}</SystemLocale>
<UILanguage>${locale}</UILanguage>
<UserLocale>${locale}</UserLocale>
</component>
<component name="Microsoft-Windows-PnpCustomizationsWinPE" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<DriverPaths>
<PathAndCredentials wcm:action="add" wcm:keyValue="1">
<Path>${virtioDriverLetter}:\amd64\${windowsVersionForVirtioDrivers}</Path>
</PathAndCredentials>
</DriverPaths>
</component>
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<UserData>
${lib.optionalString (productKey != "") ''
<ProductKey>
<Key>${productKey}</Key>
</ProductKey>
''}
<AcceptEula>true</AcceptEula>
</UserData>
<ImageInstall>
<OSImage>
<InstallFrom>
<MetaData wcm:action="add">
<Key>/IMAGE/INDEX</Key>
<Value>${toString imageIndex}</Value>
</MetaData>
</InstallFrom>
<InstallTo>
<DiskID>${toString diskIndex}</DiskID>
<PartitionID>${if efi then "3" else "1"}</PartitionID>
</InstallTo>
</OSImage>
</ImageInstall>
<DiskConfiguration>
<Disk wcm:action="add">
<DiskID>${toString diskIndex}</DiskID>
<WillWipeDisk>true</WillWipeDisk>
${if efi then ''
<CreatePartitions>
<CreatePartition wcm:action="add">
<Order>1</Order>
<Size>100</Size>
<Type>EFI</Type>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>2</Order>
<Size>16</Size>
<Type>MSR</Type>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>3</Order>
<Extend>true</Extend>
<Type>Primary</Type>
</CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add">
<Order>1</Order>
<PartitionID>1</PartitionID>
<Format>FAT32</Format>
<Label>EFI</Label>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>2</Order>
<PartitionID>2</PartitionID>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>3</Order>
<PartitionID>3</PartitionID>
<Format>NTFS</Format>
<Label>Windows</Label>
</ModifyPartition>
</ModifyPartitions>
'' else ''
<CreatePartitions>
<CreatePartition wcm:action="add">
<Order>1</Order>
<Extend>true</Extend>
<Type>Primary</Type>
</CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add">
<Order>1</Order>
<PartitionID>1</PartitionID>
<Format>NTFS</Format>
<Label>Windows</Label>
<Active>true</Active>
</ModifyPartition>
</ModifyPartitions>
''}
</Disk>
</DiskConfiguration>
${lib.optionalString bypassRequirements ''
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>3</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassRAMCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
''}
</component>
</settings>
<!-- specialize inject RunOnce to clean up cached Autounattend and shutdown -->
<settings pass="specialize">
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce" /v vmixCleanup /t REG_SZ /d "cmd /c del /q C:\Windows\Panther\unattend.xml" /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce" /v vmixShutdown /t REG_SZ /d "shutdown /s /t 10 /c vmix-audit-ready" /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<!-- oobeSystem enter Audit Mode -->
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<Reseal>
<Mode>Audit</Mode>
</Reseal>
</component>
</settings>
</unattend>
''

View file

@ -0,0 +1,23 @@
# Create an ISO from a list of files. Zips are automatically extracted.
# Nix store hash prefixes are stripped from filenames.
# Usage:
# makeFilesISO { name = "my-stuff"; files = [ ./foo.exe someZip anotherFile ]; }
# makeFilesISO { name = "my-stuff"; files = [ (pkgs.fetchurl { ... }) ]; }
{ pkgs, ... }:
{ name ? "files", files }:
pkgs.runCommand "${name}.iso" {
nativeBuildInputs = with pkgs; [ unzip cdrkit file ];
} ''
mkdir -p content
${builtins.concatStringsSep "\n" (map (f: ''
if file --mime-type -b "${f}" | grep -q "application/zip"; then
unzip -q -o "${f}" -d content
else
# Strip nix store hash prefix (e.g. "abc123-foo.exe" -> "foo.exe")
fname=$(basename "${f}")
stripped="''${fname#*-}"
cp "${f}" "content/$stripped"
fi
'') files)}
genisoimage -o $out -joliet -joliet-long -rational-rock content
''

View file

@ -0,0 +1,69 @@
# Build a pre-installed Windows qcow2 image using QEMU unattended install.
# Boots into Audit Mode after install and shuts down.
# Apply templates via customizeImageFold to install software (auditScript)
# and customize registry (windowsRegistry), then generalize when done.
{ pkgs, lib, drivers, makeWinISO, makeAuditModeAutounattend, ... }:
{
name ? "windows",
upstreamISO,
productKey ? "",
imageIndex ? 1,
diskSize ? "64G",
bypassRequirements ? false,
locale ? "en-US",
efi ? true,
smp ? 4,
memSize ? 4096,
vncDisplay ? null, # e.g. ":10" to enable VNC on port 5910 for monitoring
windowsVersionForVirtioDrivers ? "w10", # "w10", "w11", "2k22", "2k19", etc.
}:
let
AutounattendedXml = makeAuditModeAutounattend {
inherit locale productKey imageIndex
efi bypassRequirements windowsVersionForVirtioDrivers;
diskIndex = 0;
virtioDriverLetter = "E";
};
iso = makeWinISO {
iso = upstreamISO;
inherit AutounattendedXml;
};
drv = pkgs.runCommand "${name}-vmix.qcow2" {
__noChroot = true;
requiredSystemFeatures = [ "kvm" ];
nativeBuildInputs = with pkgs; [ qemu ];
} ''
# create empty disk
qemu-img create -f qcow2 disk.qcow2 ${diskSize}
# writable UEFI NVRAM so boot order persists across reboots
cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd
chmod +w vars.fd
echo "=== Starting Windows unattended install (this takes 15-30 minutes) ==="
# Windows installs unattended, reboots into Audit Mode,
# deletes cached Autounattend, shuts down → QEMU exits.
timeout 3600 qemu-system-x86_64 \
${if vncDisplay != null then "-vnc ${vncDisplay}" else "-nographic"} \
-accel kvm \
-m ${toString memSize} \
-smp ${toString smp} \
-cpu host \
-machine type=q35 \
-drive if=pflash,format=raw,readonly=on,file=${pkgs.OVMF.fd}/FV/OVMF_CODE.fd \
-drive if=pflash,format=raw,file=vars.fd \
-rtc base=localtime,clock=host \
-device qemu-xhci -device usb-tablet \
-global ICH9-LMB.disable_s3=1 -global ICH9-LMB.disable_s4=1 \
-drive file=disk.qcow2,format=qcow2,if=virtio \
-drive file=${iso},media=cdrom,readonly=on \
-drive file=${drivers.virtio-iso},media=cdrom,readonly=on \
-nic user,model=virtio-net-pci
echo "=== Windows install complete (Audit Mode image) ==="
mv disk.qcow2 $out
'';
in drv // { _vmixOsType = "windows"; }

View file

@ -0,0 +1,40 @@
# Remaster a Windows ISO: remove boot prompt, optionally inject Autounattend.xml and scripts
{ pkgs, ... }:
{ iso, AutounattendedXml ? null, postInstallScript ? null }:
pkgs.runCommand "windows-remastered.iso" {
nativeBuildInputs = with pkgs; [ p7zip cdrkit ];
} ''
workdir=$(mktemp -d)
isopath="${iso}"
if [ -d "$isopath" ]; then
isopath="$(find "$isopath" -maxdepth 1 -name '*.iso' -type f | head -n1)"
if [ -z "$isopath" ]; then
echo "ERROR: no .iso file found in $isopath"
exit 1
fi
fi
7z x -o"$workdir" "$isopath" > /dev/null
# remove "Press any key to boot from CD/DVD" prompt
if [ -f "$workdir/efi/microsoft/boot/efisys_noprompt.bin" ]; then
cp "$workdir/efi/microsoft/boot/efisys_noprompt.bin" \
"$workdir/efi/microsoft/boot/efisys.bin"
fi
if [ -f "$workdir/efi/microsoft/boot/cdboot_noprompt.efi" ]; then
cp "$workdir/efi/microsoft/boot/cdboot_noprompt.efi" \
"$workdir/efi/microsoft/boot/cdboot.efi"
fi
rm -f "$workdir/boot/bootfix.bin"
# inject unattend and post-install script into ISO root
${if AutounattendedXml != null then ''cp ${AutounattendedXml} "$workdir/Autounattend.xml"'' else ""}
${if postInstallScript != null then ''cp ${postInstallScript} "$workdir/post-install.cmd"'' else ""}
genisoimage \
-allow-limited-size -iso-level 3 -o "$out" \
-joliet -joliet-long -rational-rock -udf \
-b boot/etfsboot.com -no-emul-boot -boot-load-size 8 -boot-info-table \
-eltorito-alt-boot -e efi/microsoft/boot/efisys.bin -no-emul-boot \
"$workdir"
rm -rf "$workdir"
''

View file

@ -0,0 +1,20 @@
# Install 7-Zip and set file associations
{ pkgs, makeFilesISO, ... }:
let
installer = pkgs.fetchurl {
url = "https://github.com/ip7z/7zip/releases/download/26.00/7z2600-x64.msi";
hash = "sha256-w4jQREhxyhGyEjcAGvFYz92tfhN4UXleW2XO5ptRhJU=";
};
assocReg = ./7zip-file-associations.reg;
in {
name = "7zip";
cdroms = [ (makeFilesISO { name = "7zip"; files = [ installer assocReg ]; }) ];
auditScript = ''
@echo off
echo Installing 7-Zip...
msiexec /i "D:\7z2600-x64.msi" /qn /norestart
echo Importing 7-Zip file associations...
regedit /s "D:\7zip-file-associations.reg"
'';
}

View file

@ -0,0 +1,18 @@
# Install Microsoft Edge WebView2 Runtime
{ pkgs, makeFilesISO, ... }:
let
installer = pkgs.fetchurl {
url = "https://msedge.sf.dl.delivery.mp.microsoft.com/filestreamingservice/files/7ff41be4-dbce-4efe-823f-c7d33a4e3c5e/MicrosoftEdgeWebview2Setup.exe";
hash = "sha256-Asl+XjKpfYlhY+xoo+o6sDOeV/J0NIuGiTmWOLZJsrk=";
};
in {
name = "edge-webview";
cdroms = [ (makeFilesISO { name = "edge-webview"; files = [ installer ]; }) ];
auditScript = ''
@echo off
echo Installing Edge WebView2 Runtime...
copy D:\MicrosoftEdgeWebview2Setup.exe C:\webview-setup.exe
start /wait C:\webview-setup.exe /silent /install
del /q C:\webview-setup.exe
'';
}

View file

@ -0,0 +1,26 @@
# Install ImageGlass and set file associations
{ pkgs, makeFilesISO, ... }:
let
installer = pkgs.fetchurl {
url = "https://github.com/d2phap/ImageGlass/releases/download/9.4.1.15/ImageGlass_9.4.1.15_x64.msi";
hash = "sha256-o7q+5XpGLK+P/GQxvN/Ud5w1NxPpJ24AL54HNwda32Q=";
};
assocReg = ./imageglass-file-associations.reg;
in {
name = "imageglass";
cdroms = [ (makeFilesISO { name = "imageglass"; files = [ installer assocReg ]; }) ];
auditScript = ''
@echo off
echo Installing ImageGlass...
msiexec /i "D:\ImageGlass_9.4.1.15_x64.msi" /qn /norestart ALLUSERS=1
echo Importing ImageGlass file associations...
regedit /s "D:\imageglass-file-associations.reg"
:: delete shortcut on desktop
del /q "C:\Users\Public\Desktop\ImageGlass.lnk"
:: Remove Paint Brush class registration
reg delete "HKLM\SOFTWARE\Classes\PBrush" /f 2>nul
'';
}

View file

@ -0,0 +1,29 @@
# Install Microsoft Office 2024 (Word, Excel, PowerPoint) via offline deployment
{ pkgs, makeFilesISO, ... }:
let
officeSrc = pkgs.fetchurl {
url = "https://git.sagar.ch/dotfiles/office-2024-word-excel-powerpoint/archive/4351e8d93ceb28f65de9ec4dc9c27dccbe91ff34.zip";
hash = "sha256-ulNecpkUH6O9cqYhgtmG++a1gi+c4HIR1Vvo22VygJs=";
};
in {
name = "office";
cdroms = [ (makeFilesISO { name = "office"; files = [ officeSrc ]; }) ];
auditScript = ''
@echo off
echo Installing Microsoft Office 2024...
call D:\office-2024-word-excel-powerpoint\install.bat
:: Office privacy policies (HKCU, preserved to new users via CopyProfile)
reg add "HKCU\Software\Policies\Microsoft\office\16.0\common\privacy" /v "disconnectedstate" /t REG_DWORD /d 2 /f >nul
reg add "HKCU\Software\Policies\Microsoft\office\16.0\common\privacy" /v "usercontentdisabled" /t REG_DWORD /d 2 /f >nul
reg add "HKCU\Software\Policies\Microsoft\office\16.0\common\privacy" /v "downloadcontentdisabled" /t REG_DWORD /d 2 /f >nul
reg add "HKCU\Software\Policies\Microsoft\office\16.0\common\privacy" /v "controllerconnectedservicesenabled" /t REG_DWORD /d 2 /f >nul
reg add "HKCU\Software\Policies\Microsoft\office\common\clienttelemetry" /v "sendtelemetry" /t REG_DWORD /d 3 /f >nul
:: incase of enabling connected experiences, this disables the dialog box that shows at first run
:: reg add "HKCU\SOFTWARE\Microsoft\Office\16.0\Common\Privacy\SettingsStore\Anonymous" /v "OptionalConnectedExperiencesNoticeVersion" /t REG_DWORD /d 2 /f >nul
:: supress dialogbox - Choose your theme for 365 Apps
reg add "HKCU\SOFTWARE\Microsoft\Office\16.0\Common\PromoDialog" /v "FluentWelcomeDialogShown" /t REG_DWORD /d 1 /f >nul
'';
}

View file

@ -0,0 +1,16 @@
# Install Sandboxie-Plus
{ pkgs, makeFilesISO, ... }:
let
installer = pkgs.fetchurl {
url = "https://github.com/sandboxie-plus/Sandboxie/releases/download/v1.15.6/Sandboxie-Plus-x64-v1.15.6.exe";
hash = "sha256-0VQXy9rFCJCfVyHLJYXe/s6imcwEZugsNOKMhUoLUVM=";
};
in {
name = "sandboxie";
cdroms = [ (makeFilesISO { name = "sandboxie"; files = [ installer ]; }) ];
auditScript = ''
@echo off
echo Installing Sandboxie-Plus...
start /wait D:\Sandboxie-Plus-x64-v1.15.6.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /MERGETASKS="!desktopicon"
'';
}

View file

@ -0,0 +1,40 @@
# Install Thorium browser and set as default via Active Setup (all users)
# PS-SFTA is stored in Program Files and runs at each user's first logon.
# post-oobe.cmd also runs SFTA for the initial user.
{ pkgs, makeFilesISO, ... }:
let
installer = pkgs.fetchurl {
url = "https://github.com/Alex313031/Thorium-Win/releases/download/M138.0.7204.303/thorium_AVX2_mini_installer.exe";
hash = "sha256-43daDPX4N7NbVx1UfmO6KDMKKufhmpeQMO7qTeRggqo=";
};
sfta = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/DanysysTeam/PS-SFTA/master/SFTA.ps1";
hash = "sha256-Prb23uP9jJFgQEIGC51ljwjshdP9ChR2kRnf14vDCFE=";
};
in {
name = "thorium";
cdroms = [ (makeFilesISO { name = "thorium"; files = [ installer sfta ]; }) ];
auditScript = ''
@echo off
echo Installing Thorium browser...
copy D:\thorium_AVX2_mini_installer.exe C:\thorium_installer.exe
start /wait C:\thorium_installer.exe --silent --system-level --do-not-launch-chrome
del /q C:\thorium_installer.exe
echo Waiting for setup.exe to finish...
:wait_loop
tasklist /fi "imagename eq setup.exe" 2>nul | find /i "setup.exe" >nul
if not errorlevel 1 (
timeout /t 5 /nobreak >nul
goto wait_loop
)
echo Setting up Thorium as default browser for all users...
:: Store SFTA in Program Files (survives sysprep as installed program)
mkdir "C:\Program Files\vmix" 2>nul
copy D:\SFTA.ps1 "C:\Program Files\vmix\SFTA.ps1" >nul
:: Register Active Setup: runs at first logon for every new user
reg add "HKLM\SOFTWARE\Microsoft\Active Setup\Installed Components\vmix-default-browser" /v "(Default)" /t REG_SZ /d "Set default browser" /f >nul
reg add "HKLM\SOFTWARE\Microsoft\Active Setup\Installed Components\vmix-default-browser" /v "StubPath" /t REG_SZ /d "powershell -WindowStyle Hidden -ExecutionPolicy Bypass -Command \". 'C:\\Program Files\\vmix\\SFTA.ps1'; Set-FTA 'ThoriumHTM' '.htm'; Set-FTA 'ThoriumHTM' '.html'; Set-PTA 'ThoriumHTM' 'http'; Set-PTA 'ThoriumHTM' 'https'\"" /f >nul
reg add "HKLM\SOFTWARE\Microsoft\Active Setup\Installed Components\vmix-default-browser" /v "Version" /t REG_SZ /d "1,0,0,0" /f >nul
'';
}

View file

@ -0,0 +1,19 @@
# Install VLC and register shell handlers for video/audio formats
{ pkgs, makeFilesISO, ... }:
let
installer = pkgs.fetchurl {
url = "https://get.videolan.org/vlc/3.0.21/win64/vlc-3.0.21-win64.exe";
hash = "sha256-l0JomlDpbdwE2Azv8EayjaK+79YXvhgWb4xecV7GDFk=";
};
in {
name = "vlc";
cdroms = [ (makeFilesISO { name = "vlc"; files = [ installer ]; }) ];
auditScript = ''
@echo off
echo Installing VLC...
start /wait D:\vlc-3.0.21-win64.exe /S /L=1033
:: delete shortcut on desktop
del /q "C:\Users\Public\Desktop\VLC media player.lnk"
'';
}

View file

@ -0,0 +1,49 @@
# Set default file associations via DefaultAssociations.xml policy.
# Writes XML to C:\Program Files\vmix\ (survives sysprep as installed program).
# Sets Group Policy registry to point at the XML.
{ pkgs, lib, ... }:
{
name = "default-apps";
auditScript = ''
@echo off
set "DA=C:\Program Files\vmix\DefaultAssociations.xml"
mkdir "C:\Program Files\vmix" 2>nul
echo ^<?xml version="1.0" encoding="UTF-8"?^> > "%DA%"
echo ^<DefaultAssociations^> >> "%DA%"
echo ^<Association Identifier=".7z" ProgId="7-Zip.7z" ApplicationName="7-Zip"/^> >> "%DA%"
echo ^<Association Identifier=".zip" ProgId="7-Zip.zip" ApplicationName="7-Zip"/^> >> "%DA%"
echo ^<Association Identifier=".rar" ProgId="7-Zip.rar" ApplicationName="7-Zip"/^> >> "%DA%"
echo ^<Association Identifier=".tar" ProgId="7-Zip.tar" ApplicationName="7-Zip"/^> >> "%DA%"
echo ^<Association Identifier=".gz" ProgId="7-Zip.gz" ApplicationName="7-Zip"/^> >> "%DA%"
echo ^<Association Identifier=".xz" ProgId="7-Zip.xz" ApplicationName="7-Zip"/^> >> "%DA%"
echo ^<Association Identifier=".cab" ProgId="7-Zip.cab" ApplicationName="7-Zip"/^> >> "%DA%"
echo ^<Association Identifier=".mp4" ProgId="VLC.mp4" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".mkv" ProgId="VLC.mkv" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".avi" ProgId="VLC.avi" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".mov" ProgId="VLC.mov" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".wmv" ProgId="VLC.wmv" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".webm" ProgId="VLC.webm" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".mpg" ProgId="VLC.mpg" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".ts" ProgId="VLC.ts" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".mp3" ProgId="VLC.mp3" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".flac" ProgId="VLC.flac" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".wav" ProgId="VLC.wav" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".aac" ProgId="VLC.aac" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".ogg" ProgId="VLC.ogg" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".wma" ProgId="VLC.wma" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".m4a" ProgId="VLC.m4a" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".opus" ProgId="VLC.opus" ApplicationName="VLC"/^> >> "%DA%"
echo ^<Association Identifier=".jpg" ProgId="ImageGlass.jpg" ApplicationName="ImageGlass"/^> >> "%DA%"
echo ^<Association Identifier=".jpeg" ProgId="ImageGlass.jpeg" ApplicationName="ImageGlass"/^> >> "%DA%"
echo ^<Association Identifier=".png" ProgId="ImageGlass.png" ApplicationName="ImageGlass"/^> >> "%DA%"
echo ^<Association Identifier=".gif" ProgId="ImageGlass.gif" ApplicationName="ImageGlass"/^> >> "%DA%"
echo ^<Association Identifier=".bmp" ProgId="ImageGlass.bmp" ApplicationName="ImageGlass"/^> >> "%DA%"
echo ^<Association Identifier=".webp" ProgId="ImageGlass.webp" ApplicationName="ImageGlass"/^> >> "%DA%"
echo ^<Association Identifier=".tif" ProgId="ImageGlass.tif" ApplicationName="ImageGlass"/^> >> "%DA%"
echo ^<Association Identifier=".ico" ProgId="ImageGlass.ico" ApplicationName="ImageGlass"/^> >> "%DA%"
echo ^<Association Identifier=".svg" ProgId="ImageGlass.svg" ApplicationName="ImageGlass"/^> >> "%DA%"
echo ^<Association Identifier=".heic" ProgId="ImageGlass.heic" ApplicationName="ImageGlass"/^> >> "%DA%"
echo ^</DefaultAssociations^> >> "%DA%"
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\System" /v DefaultAssociationsConfiguration /t REG_SZ /d "C:\Program Files\vmix\DefaultAssociations.xml" /f
'';
}

View file

@ -0,0 +1,43 @@
# Windows customization templates.
# Templates can provide:
# windowsRegistry — offline registry merge (fast, no boot)
# auditScript — runs in Audit Mode via QEMU boot
# cdroms — ISOs to attach when booting for auditScript
# uploads — files to inject into the image before auditScript
{ pkgs, lib, system, drivers, makeFilesISO, ... }:
let
args = { inherit pkgs lib system drivers makeFilesISO; };
in {
# Essentials (drivers, runtimes, removals, performance)
essentials = {
virtioTools = import ./essentials/virtio-tools.nix args;
removeEdge = import ./essentials/remove-edge.nix args;
removeIE = import ./essentials/remove-ie.nix args;
removeWMP = import ./essentials/remove-wmp.nix args;
removePaint = import ./essentials/remove-paint.nix args;
amdGpuDrivers = import ./essentials/amd-gpu-drivers.nix args;
vcppRuntimes = import ./essentials/vcpp-runtimes.nix args;
bestPerformance = import ./essentials/best-performance.nix args;
clearFileAssociations = import ./essentials/clear-file-associations.nix args;
};
# Applications
apps = {
thorium = import ./apps/thorium.nix args;
edgeWebview = import ./apps/edge-webview.nix args;
sevenZip = import ./apps/7zip.nix args;
vlc = import ./apps/vlc.nix args;
imageGlass = import ./apps/imageglass.nix args;
sandboxie = import ./apps/sandboxie.nix args;
office = import ./apps/office.nix args;
};
# Default file associations policy
defaultApps = import ./default-apps.nix args;
# Generalize (sysprep + OOBE)
generalize = import ./generalize.nix args;
# Offline registry templates
reg = import ./registry args;
}

View file

@ -0,0 +1,11 @@
# Install AMD GPU drivers (silent install via INF from ISO)
{ drivers, makeFilesISO, ... }:
{
name = "amd-gpu";
cdroms = [ (makeFilesISO { name = "amd-gpu-drivers"; files = [ drivers.amd-gpu-zip ]; }) ];
auditScript = ''
@echo off
echo Installing AMD GPU drivers (INF)...
pnputil /add-driver "D:\WT6A_INF\u0413647.inf" /install /subdirs
'';
}

View file

@ -0,0 +1,46 @@
# Best Performance visual effects — set on Administrator profile in Audit Mode.
# Used with generalize's CopyProfile=true: the Administrator's profile
# (with these settings applied) becomes the default profile for new users.
# CopyProfile bypasses SystemParametersInfo defaults during profile creation.
# CopyProfile is the only approach that reliably actually works. Editing NTUSER.dat still reset some parameters
{ ... }:
{
name = "best-perf";
auditScript = ''
@echo off
echo Applying best performance to current profile...
:: Set appearance options to "custom"
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\VisualEffects" /v VisualFXSetting /t REG_DWORD /d 3 /f
:: Animate controls, fade/slide menus, fade/slide tooltips,
:: fade out menu items, shadows under mouse, shadows under windows,
:: slide open combo boxes, smooth-scroll list boxes (disabled)
reg add "HKCU\Control Panel\Desktop" /v UserPreferencesMask /t REG_BINARY /d 9012038010000000 /f
:: Animate windows when minimizing and maximizing (disabled)
reg add "HKCU\Control Panel\Desktop\WindowMetrics" /v MinAnimate /t REG_SZ /d "0" /f
:: Animations in the taskbar (disabled)
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v TaskbarAnimations /t REG_DWORD /d 0 /f
:: Enable Peek (disabled)
reg add "HKCU\Software\Microsoft\Windows\DWM" /v EnableAeroPeek /t REG_DWORD /d 0 /f
:: Save taskbar thumbnail previews (disabled)
reg add "HKCU\Software\Microsoft\Windows\DWM" /v AlwaysHibernateThumbnails /t REG_DWORD /d 0 /f
:: Show thumbnails instead of icons (disabled)
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v IconsOnly /t REG_DWORD /d 1 /f
:: Show translucent selection rectangle (disabled)
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v ListviewAlphaSelect /t REG_DWORD /d 0 /f
:: Show window contents while dragging (disabled)
reg add "HKCU\Control Panel\Desktop" /v DragFullWindows /t REG_SZ /d "0" /f
:: Smooth edges of screen fonts (enabled)
reg add "HKCU\Control Panel\Desktop" /v FontSmoothing /t REG_SZ /d "2" /f
reg add "HKCU\Control Panel\Desktop" /v FontSmoothingGamma /t REG_DWORD /d 0 /f
reg add "HKCU\Control Panel\Desktop" /v FontSmoothingOrientation /t REG_DWORD /d 1 /f
reg add "HKCU\Control Panel\Desktop" /v FontSmoothingType /t REG_DWORD /d 2 /f
:: Use drop shadows for icon labels on the desktop (disabled)
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v ListviewShadow /t REG_DWORD /d 0 /f
:: Disable transparency effects
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" /v EnableTransparency /t REG_DWORD /d 0 /f
:: Disable accent color on taskbar and window borders
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" /v ColorPrevalence /t REG_DWORD /d 0 /f
reg add "HKCU\Software\Microsoft\Windows\DWM" /v ColorPrevalence /t REG_DWORD /d 0 /f
'';
}

View file

@ -0,0 +1,13 @@
# Clear all FileExts UserChoice entries on the Administrator profile.
# In Audit Mode these keys aren't hash-protected yet.
# With CopyProfile=true in generalize, the clean profile (without UserChoice)
# is copied to new users, so HKLM Classes become the effective defaults.
{ ... }:
{
name = "clear-assoc";
auditScript = ''
@echo off
echo Clearing FileExts UserChoice entries...
reg delete "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FileExts" /f 2>nul
'';
}

View file

@ -0,0 +1,50 @@
# Remove Microsoft Edge (both Chromium and built-in LTSC versions)
{ ... }:
{
name = "no-edge";
auditScript = ''
@echo off
:: Remove Chromium Edge using its installer
for /f "delims=" %%i in ('dir /b /ad "C:\Program Files (x86)\Microsoft\Edge\Application" 2^>nul') do (
if exist "C:\Program Files (x86)\Microsoft\Edge\Application\%%i\Installer\setup.exe" (
"C:\Program Files (x86)\Microsoft\Edge\Application\%%i\Installer\setup.exe" --uninstall --system-level --force-uninstall
)
)
:: Remove Edge Update
if exist "C:\Program Files (x86)\Microsoft\EdgeUpdate\MicrosoftEdgeUpdate.exe" (
"C:\Program Files (x86)\Microsoft\EdgeUpdate\MicrosoftEdgeUpdate.exe" /uninstall
)
:: Remove Edge directories
rmdir /s /q "C:\Program Files (x86)\Microsoft\Edge" 2>nul
rmdir /s /q "C:\Program Files (x86)\Microsoft\EdgeUpdate" 2>nul
rmdir /s /q "C:\Program Files (x86)\Microsoft\EdgeCore" 2>nul
:: Remove Edge and DevToolsClient SystemApps
for /d %%d in ("C:\Windows\SystemApps\Microsoft.MicrosoftEdge_*") do (
takeown /f "%%d" /r /d y >nul 2>nul
icacls "%%d" /grant Administrators:F /t >nul 2>nul
rmdir /s /q "%%d" 2>nul
)
for /d %%d in ("C:\Windows\SystemApps\Microsoft.MicrosoftEdgeDevToolsClient_*") do (
takeown /f "%%d" /r /d y >nul 2>nul
icacls "%%d" /grant Administrators:F /t >nul 2>nul
rmdir /s /q "%%d" 2>nul
)
:: Remove Edge AppxPackage for all users (audit mode)
powershell -Command "Get-AppxPackage -AllUsers *MicrosoftEdge* | Remove-AppxPackage -AllUsers -EA SilentlyContinue"
:: Remove Edge shortcuts
del /q "%ProgramData%\Microsoft\Windows\Start Menu\Programs\Microsoft Edge.lnk" 2>nul
del /q "%PUBLIC%\Desktop\Microsoft Edge.lnk" 2>nul
del /q "%APPDATA%\Microsoft\Internet Explorer\Quick Launch\Microsoft Edge.lnk" 2>nul
del /q "%APPDATA%\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\Microsoft Edge.lnk" 2>nul
:: Remove Edge Update scheduled tasks
schtasks /delete /tn "\MicrosoftEdgeUpdateTaskMachineCore" /f 2>nul
schtasks /delete /tn "\MicrosoftEdgeUpdateTaskMachineUA" /f 2>nul
reg add "HKLM\SOFTWARE\Microsoft\EdgeUpdate" /v DoNotUpdateToEdgeWithChromium /t REG_DWORD /d 1 /f
'';
}

View file

@ -0,0 +1,12 @@
# Remove Internet Explorer
{ ... }:
{
name = "no-ie";
auditScript = ''
@echo off
echo Removing Internet Explorer...
dism /online /Remove-Capability /CapabilityName:Browser.InternetExplorer~~~~0.0.11.0 /NoRestart 2>nul
:: Disable IE feature if still present
dism /online /Disable-Feature /FeatureName:Internet-Explorer-Optional-amd64 /NoRestart 2>nul
'';
}

View file

@ -0,0 +1,20 @@
# Remove Microsoft Paint
{ ... }:
{
name = "no-paint";
auditScript = ''
@echo off
echo Removing Microsoft Paint...
:: Take ownership and delete mspaint.exe
takeown /f "C:\Windows\System32\mspaint.exe" >nul 2>nul
icacls "C:\Windows\System32\mspaint.exe" /grant Administrators:F >nul 2>nul
del /f "C:\Windows\System32\mspaint.exe" 2>nul
:: Remove Paint optional feature
dism /online /Remove-Capability /CapabilityName:Microsoft.Windows.MSPaint~~~~0.0.1.0 /NoRestart 2>nul
:: Remove Paint shortcuts
del /q "%ProgramData%\Microsoft\Windows\Start Menu\Programs\Accessories\Paint.lnk" 2>nul
:: Remove PBrush class registration
reg delete "HKLM\SOFTWARE\Classes\PBrush" /f 2>nul
reg delete "HKLM\SOFTWARE\Classes\pbrush" /f 2>nul
'';
}

View file

@ -0,0 +1,11 @@
# Remove Windows Media Player
{ ... }:
{
name = "no-wmp";
auditScript = ''
@echo off
echo Removing Windows Media Player...
dism /online /Disable-Feature /FeatureName:WindowsMediaPlayer /NoRestart 2>nul
dism /online /Remove-Capability /CapabilityName:Microsoft.Windows.MediaPlayer~~~~0.0.12.0 /NoRestart 2>nul
'';
}

View file

@ -0,0 +1,18 @@
# Install all Visual C++ Redistributable runtimes (2005-2022, x86+x64)
{ pkgs, makeFilesISO, ... }:
let
installer = pkgs.fetchurl {
url = "https://github.com/abbodi1406/vcredist/releases/download/v0.103.0/VisualCppRedist_AIO_x86_x64.exe";
hash = "sha256-PBiORlG8wH3yvbBtaVhRWHl7G6+5FVewkhdiFijNWyM=";
};
in {
name = "vcpp";
cdroms = [ (makeFilesISO { name = "vcpp-runtimes"; files = [ installer ]; }) ];
auditScript = ''
@echo off
echo Installing Visual C++ Redistributable runtimes...
copy D:\VisualCppRedist_AIO_x86_x64.exe C:\vcpp-setup.exe
start /wait C:\vcpp-setup.exe /ai /gm2
del /q C:\vcpp-setup.exe
'';
}

View file

@ -0,0 +1,18 @@
# Install VirtIO guest tools (QEMU agent + SPICE vdagent)
{ drivers, ... }:
{
name = "virtio";
cdroms = [ drivers.virtio-iso ];
auditScript = ''
@echo off
:: VirtIO ISO is the first (and only) CD drive letter D:
if exist D:\cert\virtio_win_cert.cer (
certutil -addstore TrustedPublisher D:\cert\virtio_win_cert.cer
)
if exist D:\virtio-win-guest-tools.exe (
D:\virtio-win-guest-tools.exe /install /passive /norestart
) else if exist D:\guest-agent\qemu-ga-x86_64.msi (
msiexec /i D:\guest-agent\qemu-ga-x86_64.msi /qn /norestart
)
'';
}

View file

@ -0,0 +1,168 @@
# Generalize image via sysprep + OOBE in two phases.
# Phase 1 (sysprep): runs sysprep /generalize /oobe /shutdown in Audit Mode
# Phase 2 (oobe): boots through OOBE, creates user, activates Windows, shuts down
# Between phases, NTUSER.DAT can be modified offline.
# Usage: (templates.generalize { username = "User"; password = ""; })
{ pkgs, lib, makeFilesISO, ... }:
let
masScript = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/massgravel/Microsoft-Activation-Scripts/97602941e5724316aa31b6ca1da5c70245d234d5/MAS/All-In-One-Version-KL/MAS_AIO.cmd";
hash = "sha256-1hl89jQf2p+RtE3ue/+cZevSoz7Ra3p3u350aE/Xy74=";
};
in
{
username ? "User",
password ? "",
autoLogon ? true,
hostname ? "WIN-VM",
locale ? "en-US",
timezone ? "UTC",
# Desktop background solid color as hex string (e.g. "8e8cd8")
bgColor ? null,
}: let
# Convert "8e8cd8" hex to "142 140 216" decimal RGB for Windows registry
hexToRgbStr = hex: let
hexChars = lib.stringToCharacters hex;
hexToDec = h: let
c = lib.toLower h;
m = { "0"=0; "1"=1; "2"=2; "3"=3; "4"=4; "5"=5; "6"=6; "7"=7; "8"=8; "9"=9; "a"=10; "b"=11; "c"=12; "d"=13; "e"=14; "f"=15; };
in m.${c};
r = hexToDec (builtins.elemAt hexChars 0) * 16 + hexToDec (builtins.elemAt hexChars 1);
g = hexToDec (builtins.elemAt hexChars 2) * 16 + hexToDec (builtins.elemAt hexChars 3);
b = hexToDec (builtins.elemAt hexChars 4) * 16 + hexToDec (builtins.elemAt hexChars 5);
in "${toString r} ${toString g} ${toString b}";
stripHash = s: lib.removePrefix "#" s;
bgRgb = if bgColor != null then hexToRgbStr (stripHash bgColor) else null;
# Post-OOBE script: runs as the created user via FirstLogonCommands.
postOobeScript = pkgs.writeText "post-oobe.cmd" ''
@echo off
${lib.optionalString (!autoLogon) ''
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v AutoAdminLogon /f 2>nul
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v DefaultUserName /f 2>nul
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v DefaultPassword /f 2>nul
''}
${lib.optionalString (bgColor != null) ''
:: Set solid background color
reg add "HKCU\Control Panel\Desktop" /v WallPaper /t REG_SZ /d "" /f
reg add "HKCU\Control Panel\Colors" /v Background /t REG_SZ /d "${bgRgb}" /f
reg add "HKCU\Control Panel\Desktop" /v WallpaperStyle /t REG_SZ /d "0" /f
''}
:: Remove Edge AppxPackage for current user (runs in user context during OOBE)
:: The app is already removed on one of the templates but a ghost appx entry remains that can only be deleted at the user level
powershell -Command "Get-AppxPackage *MicrosoftEdge* | Remove-AppxPackage -ErrorAction SilentlyContinue"
powershell -Command "Get-AppxPackage *MicrosoftEdgeDevToolsClient* | Remove-AppxPackage -ErrorAction SilentlyContinue"
:: Activate Windows using HWID method
if exist C:\MAS_AIO.cmd (
echo. | call C:\MAS_AIO.cmd /HWID
)
:: Activate Office using Ohook method (if Office is installed)
if exist "C:\Program Files\Microsoft Office\root\Office16\WINWORD.EXE" (
if exist C:\MAS_AIO.cmd (
echo. | call C:\MAS_AIO.cmd /Ohook
)
)
del /q C:\MAS_AIO.cmd 2>nul
:: Clean up
del /q C:\oobe-unattend.xml 2>nul
del /q C:\vmix-audit-script.cmd 2>nul
del /q C:\vmix-audit-wrapper.cmd 2>nul
shutdown /s /t 5 /c "vmix generalize complete"
del /q C:\post-oobe.cmd 2>nul
'';
oobeXml = pkgs.writeText "oobe-unattend.xml" ''
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<!-- Copy Administrator profile to default (preserves Audit Mode customizations) -->
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<CopyProfile>true</CopyProfile>
<Themes>
<WindowColor>Automatic</WindowColor>
</Themes>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<InputLocale>${locale}</InputLocale>
<SystemLocale>${locale}</SystemLocale>
<UILanguage>${locale}</UILanguage>
<UserLocale>${locale}</UserLocale>
</component>
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideLocalAccountScreen>true</HideLocalAccountScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Work</NetworkLocation>
<SkipMachineOOBE>true</SkipMachineOOBE>
<SkipUserOOBE>true</SkipUserOOBE>
<ProtectYourPC>3</ProtectYourPC>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Password>
<Value>${password}</Value>
</Password>
<Group>Administrators</Group>
<Name>${username}</Name>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Password>
<Value>${password}</Value>
</Password>
<Enabled>true</Enabled>
<Username>${username}</Username>
</AutoLogon>
<ComputerName>${hostname}</ComputerName>
<TimeZone>${timezone}</TimeZone>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<CommandLine>C:\post-oobe.cmd</CommandLine>
<RequiresUserInput>false</RequiresUserInput>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>
'';
in {
name = "generalize";
uploads = [
{ source = oobeXml; dest = "/oobe-unattend.xml"; }
{ source = postOobeScript; dest = "/post-oobe.cmd"; }
{ source = masScript; dest = "/MAS_AIO.cmd"; }
];
# Sysprep reboots into OOBE within the same QEMU session
auditScript = ''
@echo off
C:\Windows\System32\Sysprep\sysprep.exe /generalize /oobe /reboot /quiet /unattend:C:\oobe-unattend.xml
'';
}
# :: Enable RDP (sysprep resets offline registry changes)
# reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server" /v fDenyTSConnections /t REG_DWORD /d 0 /f
# reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" /v UserAuthentication /t REG_DWORD /d 0 /f
# netsh advfirewall firewall add rule name="RDP" dir=in protocol=tcp localport=3389 action=allow
# :: Start and enable the RDP service
# sc config TermService start= auto
# net start TermService

View file

@ -0,0 +1,10 @@
# Disable Copilot, Recall, and AI data analysis
''
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\WindowsCopilot]
"TurnOffWindowsCopilot"=dword:00000001
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\WindowsAI]
"AllowRecallEnablement"=dword:00000000
"DisableAIDataAnalysis"=dword:00000001
''

View file

@ -0,0 +1,50 @@
# Disable consumer features, suggested apps, push-to-install, widgets, Cortana
''
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\CloudContent]
"DisableWindowsConsumerFeatures"=dword:00000001
"DisableTailoredExperiencesWithDiagnosticData"=dword:00000001
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\PushToInstall]
"DisablePushToInstall"=dword:00000001
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\Windows Feeds]
"EnableFeeds"=dword:00000000
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Dsh]
"AllowNewsAndInterests"=dword:00000000
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\Windows Search]
"AllowCortana"=dword:00000000
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\Explorer]
"DisableSearchBoxSuggestions"=dword:00000001
"HideSCAMeetNow"=dword:00000001
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\GameDVR]
"AllowGameDVR"=dword:00000000
[HKEY_LOCAL_MACHINE\System\GameConfigStore]
"GameDVR_Enabled"=dword:00000000
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\Maps]
"AutoDownloadAndUpdateMapData"=dword:00000000
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\MapsBroker]
"Start"=dword:00000004
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\XblAuthManager]
"Start"=dword:00000004
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\XblGameSave]
"Start"=dword:00000004
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\XboxNetApiSvc]
"Start"=dword:00000004
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\WMPNetworkSvc]
"Start"=dword:00000004
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\RetailDemo]
"Start"=dword:00000004
''

View file

@ -0,0 +1,63 @@
# Offline registry customization templates.
# Each file returns raw registry entries (no header).
# Templates are composed into bundles via mkReg which adds the .reg header.
{ ... }:
let
regHeader = "Windows Registry Editor Version 5.00";
mkReg = entries: ''
${regHeader}
${entries}
'';
rdpEntries = import ./rdp.nix;
telemetryEntries = import ./telemetry.nix;
errorReportingEntries = import ./error-reporting.nix;
defenderEntries = import ./defender.nix;
updatesEntries = import ./updates.nix;
smartScreenEntries = import ./smart-screen.nix;
hibernationEntries = import ./hibernation.nix;
systemRestoreEntries = import ./system-restore.nix;
networkEntries = import ./insecure-samba.nix;
privacyEntries = import ./privacy.nix;
aiEntries = import ./ai.nix;
consumerEntries = import ./consumer.nix;
performanceEntries = import ./performance.nix;
disableUcpdEntries = import ./disable-ucpd.nix;
in rec {
# === Individual templates ===
disableTelemetry = { name = "no-telemetry"; windowsRegistry = mkReg telemetryEntries; };
disableErrorReporting = { name = "no-wer"; windowsRegistry = mkReg errorReportingEntries; };
disableDefender = { name = "no-defender"; windowsRegistry = mkReg defenderEntries; };
disableUpdates = { name = "no-updates"; windowsRegistry = mkReg updatesEntries; };
disableSmartScreen = { name = "no-smartscreen"; windowsRegistry = mkReg smartScreenEntries; };
disableHibernation = { name = "no-hibernate"; windowsRegistry = mkReg hibernationEntries; };
disableSystemRestore = { name = "no-restore"; windowsRegistry = mkReg systemRestoreEntries; };
networkTweaks = { name = "network"; windowsRegistry = mkReg networkEntries; };
disablePrivacyTracking = { name = "no-tracking"; windowsRegistry = mkReg privacyEntries; };
disableAI = { name = "no-ai"; windowsRegistry = mkReg aiEntries; };
disableConsumerFeatures = { name = "no-consumer"; windowsRegistry = mkReg consumerEntries; };
performanceTweaks = { name = "performance"; windowsRegistry = mkReg performanceEntries; };
disableUCPD = { name = "no-ucpd"; windowsRegistry = mkReg disableUcpdEntries; };
# === Convenience bundles ==
# Hardened: comprehensive debloat for lab VMs
hardened = {
name = "hardened";
windowsRegistry = mkReg (
telemetryEntries
+ errorReportingEntries
+ defenderEntries
+ updatesEntries
+ smartScreenEntries
+ hibernationEntries
+ systemRestoreEntries
+ networkEntries
+ privacyEntries
+ aiEntries
+ consumerEntries
+ performanceEntries
);
};
}

View file

@ -0,0 +1,11 @@
# Disable Windows Defender
''
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft]
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows Defender]
"DisableAntiSpyware"=dword:00000001
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows Defender\Real-Time Protection]
"DisableRealtimeMonitoring"=dword:00000001
''

View file

@ -0,0 +1,10 @@
# Disable User Choice Protection Driver (UCPD) on Win11
# This allows programmatic changes to file/URL associations via UserChoice
''
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\System]
"EnableUCPD"=dword:00000000
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\UCPD]
"Start"=dword:00000004
''

View file

@ -0,0 +1,9 @@
# Disable Windows Error Reporting
''
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\Windows Error Reporting]
"Disabled"=dword:00000001
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\WerSvc]
"Start"=dword:00000004
''

View file

@ -0,0 +1,9 @@
# Disable hibernation and fast startup (avoids disk corruption with snapshots)
''
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Control\Session Manager\Power]
"HiberbootEnabled"=dword:00000000
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Control\Power]
"HibernateEnabled"=dword:00000000
''

View file

@ -0,0 +1,12 @@
# Suppress network discovery popup and allow insecure guest logons for SMB
''
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Control\Network\NewNetworkWindowOff]
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\LanmanWorkstation]
"AllowInsecureGuestAuth"=dword:00000001
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\LanmanWorkstation\Parameters]
"AllowInsecureGuestAuth"=dword:00000001
"RequireSecuritySignature"=dword:00000000
''

View file

@ -0,0 +1,34 @@
# VM performance optimizations: disable power throttling, SysMain, lock screen, autorun
''
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Control\Power]
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Control\Power\PowerThrottling]
"PowerThrottlingOff"=dword:00000001
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Control]
"SvcHostSplitThresholdInKB"=dword:00400000
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Control\FileSystem]
"LongPathsEnabled"=dword:00000001
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Control\Remote Assistance]
"fAllowToGetHelp"=dword:00000000
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\Personalization]
"NoLockScreen"=dword:00000001
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies]
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer]
"NoDriveTypeAutoRun"=dword:000000ff
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\FileHistory]
"Disabled"=dword:00000001
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\SysMain]
"Start"=dword:00000004
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\RemoteRegistry]
"Start"=dword:00000004
''

View file

@ -0,0 +1,44 @@
# Disable activity tracking, advertising ID, location, and input data collection
''
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\System]
"EnableActivityFeed"=dword:00000000
"PublishUserActivities"=dword:00000000
"UploadUserActivities"=dword:00000000
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\AdvertisingInfo]
"DisabledByGroupPolicy"=dword:00000001
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Privacy]
"TailoredExperiencesWithDiagnosticDataEnabled"=dword:00000000
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\LocationAndSensors]
"DisableLocation"=dword:00000001
"DisableLocationScripting"=dword:00000001
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\lfsvc]
"Start"=dword:00000004
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\HandwritingErrorReports]
"PreventHandwritingErrorReports"=dword:00000001
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\TabletPC]
"PreventHandwritingDataSharing"=dword:00000001
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\InputPersonalization]
"RestrictImplicitTextCollection"=dword:00000001
"RestrictImplicitInkCollection"=dword:00000001
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\WindowsInkWorkspace]
"AllowWindowsInkWorkspace"=dword:00000000
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Speech_OneCore]
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Speech_OneCore\Settings]
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Speech_OneCore\Settings\OnlineSpeechPrivacy]
"HasAccepted"=dword:00000000
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\AppPrivacy]
"LetAppsRunInBackground"=dword:00000002
''

View file

@ -0,0 +1,6 @@
# Disable SmartScreen
''
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\System]
"EnableSmartScreen"=dword:00000000
''

View file

@ -0,0 +1,8 @@
# Disable system restore
''
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows NT]
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows NT\SystemRestore]
"DisableSR"=dword:00000001
''

View file

@ -0,0 +1,15 @@
# Disable telemetry, diagnostics tracking, and feedback
''
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows]
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\DataCollection]
"AllowTelemetry"=dword:00000000
"DoNotShowFeedbackNotifications"=dword:00000001
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\DiagTrack]
"Start"=dword:00000004
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\dmwappushservice]
"Start"=dword:00000004
''

View file

@ -0,0 +1,21 @@
# Disable automatic Windows updates, driver searching, and update-related annoyances
''
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate]
[HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU]
"NoAutoUpdate"=dword:00000001
"NoAutoRebootWithLoggedOnUsers"=dword:00000001
"AUPowerManagement"=dword:00000000
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\DeliveryOptimization]
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\DeliveryOptimization\Config]
"DODownloadMode"=dword:00000000
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\DriverSearching]
"SearchOrderConfig"=dword:00000000
[HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Services\DoSvc]
"Start"=dword:00000004
''

View file

@ -0,0 +1,12 @@
{ pkgs, lib, system, windows, ... }:
let
upstreamISOsJSON = lib.importJSON ./upstream.json;
fetchUpstream = name: src:
if (src.type or "") == "fetchgit"
then pkgs.fetchgit { inherit (src) url rev hash fetchLFS; }
else pkgs.fetchurl { inherit (src) url sha256; };
upstreamISOs = lib.mapAttrs fetchUpstream upstreamISOsJSON.${system};
images = (import ./images.nix) { inherit pkgs lib system windows upstreamISOs; };
in images

View file

@ -0,0 +1,34 @@
# Pre-built Win10 LTSC 2021 images from upstream ISO
# Pipeline: makeImage (Audit Mode) → essentials → apps → registry → generalize
{ pkgs, lib, system, windows, upstreamISOs, ... }:
with windows;
{
ltsc = rec {
upstream = makeImage {
name = "win10-ltsc-2021";
upstreamISO = upstreamISOs.win10-ltsc-2021;
productKey = "M7XTQ-FN8P6-TTKYV-9D4CC-J462D";
};
basic = customizeImageFold upstream (with templates; [
essentials.virtioTools
essentials.removeIE
essentials.removeWMP
essentials.removeEdge
essentials.vcppRuntimes
essentials.bestPerformance
reg.hardened
apps.edgeWebview
apps.thorium
]);
withApps = customizeImageFold basic (with templates; [
apps.sandboxie
apps.sevenZip
apps.vlc
apps.imageGlass
apps.office
]);
withAMDGPU = customizeImage basic templates.essentials.amdGpuDrivers;
};
}

View file

@ -0,0 +1,11 @@
{
"x86_64-linux": {
"win10-ltsc-2021": {
"type": "fetchgit",
"url": "https://git.sagar.ch/dotfiles/win10ltsc2021-official.iso.git",
"rev": "9bb55c21ceaf224504c4240677e808b3ec91a610",
"hash": "sha256-AeGnOnkToOSiD5iRblLKaRmR8qBUkCaGp8F8KkGlVIA=",
"fetchLFS": true
}
}
}

View file

@ -0,0 +1,12 @@
{ pkgs, lib, system, windows, ... }:
let
upstreamISOsJSON = lib.importJSON ./upstream.json;
fetchUpstream = name: src:
if (src.type or "") == "fetchgit"
then pkgs.fetchgit { inherit (src) url rev hash fetchLFS; }
else pkgs.fetchurl { inherit (src) url sha256; };
upstreamISOs = lib.mapAttrs fetchUpstream upstreamISOsJSON.${system};
images = (import ./images.nix) { inherit pkgs lib system windows upstreamISOs; };
in images

View file

@ -0,0 +1,38 @@
# Pre-built Win11 LTSC 2024 images from upstream ISO
# Pipeline: makeImage (Audit Mode) → essentials → apps → registry
{ pkgs, lib, system, windows, upstreamISOs, ... }:
with windows;
{
ltsc = rec {
upstream = makeImage {
name = "win11-ltsc-2024";
upstreamISO = upstreamISOs.win11-ltsc-2024;
productKey = "M7XTQ-FN8P6-TTKYV-9D4CC-J462D";
bypassRequirements = true;
windowsVersionForVirtioDrivers = "w11";
};
basic = customizeImageFold upstream (with templates; [
essentials.virtioTools
essentials.removeIE
essentials.removeWMP
essentials.removeEdge
essentials.vcppRuntimes
essentials.bestPerformance
reg.hardened
reg.disableUCPD
apps.edgeWebview
apps.thorium
]);
withApps = customizeImageFold basic (with templates; [
apps.sandboxie
apps.sevenZip
apps.vlc
apps.imageGlass
apps.office
]);
withAMDGPU = customizeImage basic templates.essentials.amdGpuDrivers;
};
}

View file

@ -0,0 +1,11 @@
{
"x86_64-linux": {
"win11-ltsc-2024": {
"type": "fetchgit",
"url": "https://git.sagar.ch/dotfiles/win11ltsc2024-official.iso.git",
"rev": "d4a749719eb593884d1ba9835b96104e1f06290b",
"hash": "sha256-AWp4SPVfpHT7jAczaq24bgrYUZy4sLByRHMhGbPFBwg=",
"fetchLFS": true
}
}
}

View file

@ -1,5 +1,5 @@
{ pkgs, lib, ... }: rec {
calc = (import ((builtins.fetchTarball "https://gist.github.com/duairc/5c9bb3c922e5d501a1edb9e7b3b845ba/archive/3885f7cd9ed0a746a9d675da6f265d41e9fd6704.tar.gz") + "/net.nix" ) { inherit lib; }).lib.net;
calc = (import ((builtins.fetchTarball { url = "https://gist.github.com/duairc/5c9bb3c922e5d501a1edb9e7b3b845ba/archive/3885f7cd9ed0a746a9d675da6f265d41e9fd6704.tar.gz"; sha256 = "sha256:0s17g6jdkfnsqxwh1k9arhn4r2aa3rknpnhpkppw1sbzjix36c4b"; }) + "/net.nix" ) { inherit lib; }).lib.net;
regex.ipv4 =
let
compRegex = "(25[0-5]|(2[0-4]|10|1?[1-9])?[0-9])";
@ -8,6 +8,33 @@
regex.cidr4 = "${regex.ipv4}/(3[0-2]|[1-2]?[0-9])";
regex.ipOrCidr4 = "(${regex.ipv4}|${regex.cidr4})";
ipv4ToInt = ipv4: calc.ip.diff ipv4 "0.0.0.0";
intToipv4 = inte: calc.ip.add "0.0.0.0" inte;
ipv4ToInt = ip:
with builtins;
with lib;
let
octets = match ''^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$'' ip;
in
if octets == null then
throw "Invalid IPv4 address: ${ip}"
else
let
a = toInt (elemAt octets 0);
b = toInt (elemAt octets 1);
c = toInt (elemAt octets 2);
d = toInt (elemAt octets 3);
in
if a > 255 || b > 255 || c > 255 || d > 255 then
throw "Invalid IPv4 octet > 255: ${ip}"
else
(a * 256 * 256 * 256) + (b * 256 * 256) + (c * 256) + d;
intToIpv4 = n:
let
a = n / (256 * 256 * 256);
b = builtins.mod (n / (256 * 256)) 256;
c = builtins.mod (n / 256) 256;
d = builtins.mod n 256;
in
"${toString a}.${toString b}.${toString c}.${toString d}";
}

6
module.nix Normal file
View file

@ -0,0 +1,6 @@
{ ... }:
{
imports = [
./nixos/default.nix
];
}

View file

@ -5,5 +5,15 @@ let
args = { inherit config pkgs lib vmixLib; };
in
{
imports = [ (import ./network args) (import ./vm args) ];
imports = [ (import ./networks args) (import ./vms args) ];
config.nix.settings.sandbox = "relaxed"; # for vm customize to work properly
options.vmix.namespaces = mkOption {
type = types.attrsOf
(types.submodule (import ./namespaceSubmoduleOptions.nix args));
default = {};
};
config.nixpkgs.overlays = [ (import ../overlay.nix) ];
}

View file

@ -0,0 +1,13 @@
{ lib, ... }@args :
with lib;
{
options = {
networks = (import ./networks/options.nix args);
vms = mkOption {
type = types.attrsOf
(types.submodule (import ./vms/submoduleOptions.nix args));
default = { };
};
};
}

View file

@ -1,215 +0,0 @@
{ config, pkgs, lib, vmixLib, ... }:
with vmixLib.network;
let
vmixCfg = config.vmix;
# creates a /30 network from available range for veth-pair wan interfaces
mkVethIPv4Range = index: availableIPv4Range:
let
vethIPv4RangeLength = 30;
in
(calc.cidr.subnet (vethIPv4RangeLength - (calc.cidr.length availableIPv4Range)) index availableIPv4Range);
namespaceGlobalService = {
"ns.net.vmix@" = {
description = "network namespace %I for vmix";
before = [ "network.target" ];
path = with pkgs; [ iproute2 utillinux ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
PrivateMounts = false;
PrivateNetwork = true;
ExecStart = (pkgs.writeShellScript "ns.net.vmix-start" ''
NAMESPACE="$1.vmix"
ip netns add $NAMESPACE
umount /var/run/netns/$NAMESPACE
mount --bind /proc/self/ns/net /var/run/netns/$NAMESPACE
'') + " %I";
ExecStop = "${pkgs.iproute2}/bin/ip netns del %I.vmix";
};
};
};
mkLanDomainName = networkName: lanName: lanCfg:
if (lanCfg.domain != null) then lanCfg.domain else "${lanName}.${networkName}.vmix";
mkLan = networkName: staticRoutes: lanName: cfg:
let
lanCfg = cfg // { name = lanName; namespace = "${networkName}"; };
lanInterfaceName = "brx-${lanCfg.name}";
lanInterfaceIPAddress = calc.cidr.host 1 lanCfg.ipv4.range;
netmask = calc.cidr.netmask lanCfg.ipv4.range;
networkPrefix = builtins.elemAt (lib.splitString "/" lanCfg.ipv4.range) 1;
dhcpStartAddress =
if (lanCfg.ipv4.dhcp.startAddress != null)
then lanCfg.ipv4.dhcp.startAddress
else (calc.cidr.host 2 lanCfg.ipv4.range);
dhcpEndAddress =
if (lanCfg.ipv4.dhcp.endAddress != null)
then lanCfg.ipv4.dhcp.endAddress
else (calc.cidr.host ((calc.cidr.capacity lanCfg.ipv4.range) - 2) lanCfg.ipv4.range);
createLanInterface = ''
ip link add ${lanInterfaceName} type bridge
ip address add ${lanInterfaceIPAddress}/${networkPrefix} dev ${lanInterfaceName}
ip link set ${lanInterfaceName} up
'';
deleteLanInterface = ''
ip link del ${lanInterfaceName}
'';
lanDomainName = mkLanDomainName networkName lanName lanCfg;
lanDnsmasqConf = ''
# lan ${lanName}
dhcp-range=${lanInterfaceName},${dhcpStartAddress},${dhcpEndAddress},${netmask},12h
domain=${lanDomainName},${lanInterfaceName}
dhcp-option=${lanInterfaceName},option:classless-static-route,${lib.concatStringsSep "," (builtins.map (route: "${route},${lanInterfaceIPAddress}") ([ "0.0.0.0/0" ] ++ (builtins.filter (route: route != lanCfg.ipv4.range) staticRoutes)))}
'' + (lib.optionalString (lanCfg.ipv4.dhcp.dns.nameservers != []) ("dhcp-option=${lanInterfaceName},option:dns-server,${(lib.concatStringsSep "," lanCfg.ipv4.dhcp.dns.nameservers)}\n"));
in
lanCfg // {
createIface = createLanInterface;
deleteIface = deleteLanInterface;
dnsmasqConf = lanDnsmasqConf;
domain = lanDomainName;
};
mkLansService = networkName: wanCfg: lansCfg:
let
dhcpLeaseFile="/tmp/vmix/lans.${networkName}.dhcp.leases";
staticRoutes = [ wanCfg.ipv4.range ] ++ (builtins.map (lanCfg: lanCfg.ipv4.range) (lib.attrValues lansCfg));
lansList = lib.attrValues(lib.mapAttrs (mkLan networkName staticRoutes) lansCfg);
dnsmasqConf = pkgs.writeText "dnsmasq-${networkName}.conf" (''
dhcp-host=*:*:*:*:*:*,id:*
except-interface=lo
dhcp-authoritative
localise-queries
no-hosts
expand-hosts
dhcp-leasefile=${dhcpLeaseFile}
filter-AAAA
address=/host/${calc.cidr.host 1 wanCfg.ipv4.range}
no-resolv
${lib.concatStringsSep "\n" (builtins.map (nameserver: "server=${nameserver}") wanCfg.dns.nameservers)}
'' + (lib.concatMapStrings (lan: lan.dnsmasqConf) lansList)
);
createLansInterfaces = pkgs.writeShellScript "create-lans-${networkName}-vmix" (''
# for dnsmasq temp files
mkdir -p /tmp/vmix
rm -f ${dhcpLeaseFile}
'' + (lib.concatMapStrings (lan: lan.createIface) lansList)
);
deleteLansInterfaces = pkgs.writeShellScript "delete-lans-${networkName}-vmix" (lib.concatMapStrings (lan: lan.deleteIface) lansList);
in
{
"lans.net.vmix@${networkName}" = rec {
bindsTo = [ "ns.net.vmix@${networkName}.service" ];
after = bindsTo;
wantedBy = [ "net.vmix@${networkName}.target" ];
unitConfig.JoinsNamespaceOf = "ns.net.vmix@${networkName}.service";
path = with pkgs; [ iproute2 ];
serviceConfig = {
ExecStartPre = createLansInterfaces;
ExecStart = "${pkgs.dnsmasq}/bin/dnsmasq -d -C ${dnsmasqConf}";
ExecReload = pkgs.writeShellScript "reload-dnsmasq" "kill -HUP $MAINPID";
ExecStopPost = deleteLansInterfaces;
Restart = "on-failure";
RestartSec = "5";
PrivateTmp = true;
ProtectSystem = true;
ProtectHome = true;
PrivateNetwork = true;
};
};
};
mkWanService = networkName: cfg:
let
wanCfg = cfg // { namespace = networkName; };
vethInNSToHost.iface = "vhost";
vethOnHostToNS.iface = "vn-${wanCfg.namespace}";
vethOnHostToNS.ipv4.address = calc.cidr.host 1 wanCfg.ipv4.range;
vethInNSToHost.ipv4.address = calc.cidr.host 2 wanCfg.ipv4.range;
networkPrefix = builtins.elemAt (lib.splitString "/" wanCfg.ipv4.range) 1;
iptablesMark = builtins.toString (ipv4ToInt vethOnHostToNS.ipv4.address);
createWanCommands = ''
ip link add ${vethOnHostToNS.iface} type veth peer name ${vethInNSToHost.iface}
ip link set ${vethInNSToHost.iface} netns ${wanCfg.namespace}.vmix
ip address add ${vethOnHostToNS.ipv4.address}/${networkPrefix} dev ${vethOnHostToNS.iface}
ip netns exec ${wanCfg.namespace}.vmix ip address add ${vethInNSToHost.ipv4.address}/${networkPrefix} dev ${vethInNSToHost.iface}
iptables -A FORWARD -i ${vethOnHostToNS.iface} -j ACCEPT
iptables -A FORWARD -o ${vethOnHostToNS.iface} -j ACCEPT
#iptables -A INPUT -i ${vethOnHostToNS.iface} -j DROP
iptables -t mangle -A PREROUTING -i ${vethOnHostToNS.iface} -j MARK --set-mark ${iptablesMark}
iptables -t nat -A POSTROUTING -m mark --mark ${iptablesMark} -j MASQUERADE
ip link set ${vethOnHostToNS.iface} up
ip netns exec ${wanCfg.namespace}.vmix ip link set ${vethInNSToHost.iface} up
ip netns exec ${wanCfg.namespace}.vmix ip r add default via ${vethOnHostToNS.ipv4.address}
${lib.concatMapStrings (lanRange: "ip r add ${lanRange} via ${vethInNSToHost.ipv4.address} \n") wanCfg.lanRanges}
'';
createWan = pkgs.writeShellScript "create-wan-${wanCfg.namespace}-vmix" createWanCommands;
deleteWan =
let
createdIptablesRules = lib.filter (line: (lib.hasPrefix "iptables" line)) (lib.splitString "\n" createWanCommands);
delIptablesRules = builtins.map (rule: lib.replaceStrings [ "-A" ] [ "-D"] rule) createdIptablesRules;
in
pkgs.writeShellScript "delete-wan-${wanCfg.namespace}-vmix" (''
ip link del ${vethOnHostToNS.iface}
'' + (lib.concatStringsSep "\n" delIptablesRules));
in
{
"wan.net.vmix@${wanCfg.namespace}" = rec {
bindsTo = [ "ns.net.vmix@${wanCfg.namespace}.service" ];
after = bindsTo;
wantedBy = [ "net.vmix@${wanCfg.namespace}.target" ];
path = with pkgs; [ iproute2 iptables ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = createWan;
ExecStop = deleteWan;
};
};
};
mkMacvlanService = networkName: macvlanName: cfg:
{};
mkNetworkServices = networkName: cfg:
let
netCfg = cfg // { name = networkName; };
vethIPv4RangeForWan = mkVethIPv4Range netCfg.index vmixCfg.global.net.wan.ipv4.range;
wanCfg = netCfg.wan // { ipv4.range = vethIPv4RangeForWan; lanRanges = builtins.map (lan: lan.ipv4.range) (lib.attrValues netCfg.lans); };
in
(mkLansService netCfg.name wanCfg netCfg.lans)
// (mkWanService netCfg.name wanCfg)
// (lib.concatMapAttrs (mkMacvlanService netCfg.name) netCfg.bridges.macvlans);
networkNames = builtins.attrNames vmixCfg.networks;
networkServices = pkgs.unstable.lib.mergeAttrsList (lib.imap0 (index: networkName: (mkNetworkServices networkName (vmixCfg.networks.${networkName} // { inherit index;}))) networkNames);
networkTargets = lib.concatMapAttrs (networkName: netCfg: {
"net.vmix@${networkName}" = {
description = "Network ${networkName} for vmix";
bindsTo = [ "ns.net.vmix@${networkName}.service" "lans.net.vmix@${networkName}.service" "wan.net.vmix@${networkName}.service" ];
};
}) vmixCfg.networks;
in
{
config.systemd.services = namespaceGlobalService // networkServices;
config.systemd.targets = networkTargets;
config.boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
}

View file

@ -1,186 +0,0 @@
{ config, pkgs, lib, vmixLib, ... }:
with lib;
with vmixLib.network;
{
options = {
bridges.macvlans = mkOption {
type = types.attrsOf (types.submodule {
options = {
uplink.iface = mkOption {
type = types.str;
};
uplink.namespace = mkOption {
type = types.nullOr types.str;
default = null;
};
namespace = mkOption {
type = types.nullOr types.str;
default = null;
};
ipv4.static.address = mkOption {
type = types.nullOr (types.strMatching regex.ipOrCidr4);
default = null;
};
ipv4.static.gateway = mkOption {
type = types.nullOr (types.strMatching regex.ipv4);
default = null;
};
ipv4.dhcp.client = mkOption {
type = types.bool;
default = false;
};
ipv4.dhcp.gateway = mkOption {
type = types.bool;
default = false;
};
};
});
};
bridges.macvtaps = mkOption {
type = types.attrsOf (types.submodule {
options = {
uplink.iface = mkOption {
type = types.str;
};
uplink.namespace = mkOption {
type = types.nullOr types.str;
default = null;
};
many = mkOption {
type = types.bool;
default = true;
};
};
});
};
wan = {
enable = mkOption {
type = types.bool;
default = true;
};
dns.nameservers = mkOption {
type = types.listOf (types.strMatching regex.ipv4);
default = [];
description = "List of IP Addresses of DNS servers to use as upstream DNS servers in the DHCP/DNS server. If left empty, it will use host's DNS servers";
};
dns.useHostResolvConf = mkOption {
type = types.bool;
default = true;
description = "Whether to use host's /etc/resolv.conf for upstream DNS queries.";
};
host.wan.enable = mkOption {
type = types.bool;
default = true;
};
host.wan.masquerade = mkOption {
type = types.bool;
default = true;
};
host.lan.enable = mkOption {
type = types.bool;
default = true;
};
host.lan.masquerade = mkOption {
type = types.bool;
default = true;
};
host.self.enable = mkOption {
type = types.bool;
default = true;
};
host.self.dns.addNSLansResolver = mkOption {
type = types.bool;
default = true;
};
host.self.addNSLansRoutes = mkOption {
type = types.bool;
default = true;
};
};
lans = mkOption {
type = types.attrsOf (types.submodule {
options.domain = mkOption {
type = types.nullOr types.str;
default = null;
description = "Domain name for the hosts of this lan.";
};
options.ipv4 = {
range = mkOption {
type = types.strMatching regex.cidr4;
description = "IPv4 Range in x.x.x.x/y format to be assigned to the network.";
};
address = mkOption {
type = types.nullOr (types.strMatching regex.ipv4);
default = null;
description = "IPv4 address to attach to the bridge interface of this Lan.";
};
dhcp.enable = mkOption {
type = types.bool;
default = true;
description = "Whether to start a DHCP server within this network.";
};
dhcp.startAddress = mkOption {
type = types.nullOr (types.strMatching regex.ipv4);
description = "Starting IP Address for DHCP clients.";
default = null;
};
dhcp.endAddress = mkOption {
type = types.nullOr (types.strMatching regex.ipv4);
description = "Ending IP Address for DHCP clients.";
default = null;
};
dhcp.dns.resolver.enable = mkOption {
type = types.bool;
default = true;
description = "Add dnsmasq's built in resolver to lan clients DHCP responses";
};
dhcp.dns.nameservers = mkOption {
type = types.listOf (types.strMatching regex.ipv4);
default = [];
description = "List of IP Addresses of DNS servers to use as upstream DNS servers in the DHCP/DNS server. If left empty, it will use host's DNS servers";
};
dhcp.dns.zonefiles = mkOption {
default = null;
description = "Additional zonefiles to add for the DNS server";
};
};
});
};
# routes.internal.add = mkOption {
# description = "Additional routes to add on the internal network";
# };
# routes.host.add = mkOption {
# description = "Addtional routes to add on the host's network namespace";
# };
};
}

290
nixos/networks/config.nix Normal file
View file

@ -0,0 +1,290 @@
{ config, pkgs, lib, vmixLib, ... }:
with vmixLib.network;
let
vmixCfg = config.vmix;
# creates a /30 network from available range for veth-pair wan interfaces
mkVethIPv4Range = index: availableIPv4Range:
let
vethIPv4RangeLength = 30;
in
(calc.cidr.subnet (vethIPv4RangeLength - (calc.cidr.length availableIPv4Range)) index availableIPv4Range);
namespaceGlobalService = {
"ns.net.vmix@" = {
description = "network namespace %I for vmix";
before = [ "network.target" ];
path = with pkgs; [ iproute2 utillinux ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
PrivateMounts = false;
PrivateNetwork = true;
ExecStart = (pkgs.writeShellScript "ns.net.vmix-start" ''
spaceName="$1.vmix"
ip netns add $spaceName
umount /var/run/netns/$spaceName
mount --bind /proc/self/ns/net /var/run/netns/$spaceName
'') + " %I";
ExecStop = "${pkgs.iproute2}/bin/ip netns del %I.vmix";
};
};
};
mkLanDomainName = spaceName: lanName: lanCfg:
if (lanCfg.domain != null) then lanCfg.domain else "${lanName}.${spaceName}.vmix";
mkLan = spaceName: staticRoutes: wanCfg: lanName: cfg:
let
lanCfg = cfg // { name = lanName; spaceName = "${spaceName}"; };
lanInterfaceName = "brx-${lanCfg.name}";
lanInterfaceIPAddress =
if (lanCfg.ipv4.address != null)
then lanCfg.ipv4.address
else (calc.cidr.host 1 lanCfg.ipv4.range);
netmask = calc.cidr.netmask lanCfg.ipv4.range;
networkPrefix = builtins.elemAt (lib.splitString "/" lanCfg.ipv4.range) 1;
dhcpStartAddress =
if (lanCfg.ipv4.dhcp.startAddress != null)
then lanCfg.ipv4.dhcp.startAddress
else (calc.cidr.host 2 lanCfg.ipv4.range);
dhcpEndAddress =
if (lanCfg.ipv4.dhcp.endAddress != null)
then lanCfg.ipv4.dhcp.endAddress
else (calc.cidr.host ((calc.cidr.capacity lanCfg.ipv4.range) - 2) lanCfg.ipv4.range);
createLanInterface = ''
ip link add ${lanInterfaceName} type bridge
ip address add ${lanInterfaceIPAddress}/${networkPrefix} dev ${lanInterfaceName}
ip link set ${lanInterfaceName} up
'';
deleteLanInterface = ''
ip link del ${lanInterfaceName}
'';
lanDomainName = mkLanDomainName spaceName lanName lanCfg;
lanDnsmasqConf = ''
# lan ${lanName}
domain=${lanDomainName},${lanInterfaceName}
''
+ (lib.optionalString lanCfg.ipv4.dhcp.enable ''
dhcp-range=${lanInterfaceName},${dhcpStartAddress},${dhcpEndAddress},${netmask},12h
dhcp-option=${lanInterfaceName},option:classless-static-route,${lib.concatMapStringsSep "," (route: "${route},${lanInterfaceIPAddress}") ([ "0.0.0.0/0" ] ++ (builtins.filter (route: route != lanCfg.ipv4.range) staticRoutes))}
'')
+ (lib.optionalString (lanCfg.ipv4.dhcp.enable && (!wanCfg.dns.resolver.enable) && (lanCfg.ipv4.dhcp.dns.addresses != null) && (lanCfg.ipv4.dhcp.dns.addresses != []))
("dhcp-option=${lanInterfaceName},option:dns-server,${(lib.concatStringsSep "," lanCfg.ipv4.dhcp.dns.addresses)}\n"));
staticIPsListedOnVMs = with lib; concatMapAttrs (vmName: vmCfg: optionalAttrs ((vmCfg ? networks.lans.${lanName}.ip) && (vmCfg.networks.lans.${lanName}.ip != null)) { ${vmCfg.networks.lans.${lanName}.mac} = vmCfg.networks.lans.${lanName}.ip; }) config.vmix.namespaces.${spaceName}.vms;
in
lanCfg // {
createIface = createLanInterface;
deleteIface = deleteLanInterface;
dnsmasqConf = lanDnsmasqConf;
staticHostsFileContents = (with lib; concatStringsSep "\n" (mapAttrsToList (mac: ipv4: "${mac},${ipv4},infinite") (lanCfg.ipv4.dhcp.statics // staticIPsListedOnVMs)));
domain = lanDomainName;
};
mkLansService = spaceName: wanCfg: lansCfg:
let
lansTmpDir = "/tmp/vmix/${spaceName}";
dhcpDynamicLeaseFileForDnsmasq="${lansTmpDir}/lans.dhcp.dynamic.leases";
dhcpStaticHostsFileForDnsmasq="${lansTmpDir}/lans.dhcp.static.hosts";
staticRoutes = [ wanCfg.ipv4.range ] ++ (builtins.map (lanCfg: lanCfg.ipv4.range) (lib.attrValues lansCfg));
lansList = lib.attrValues(lib.mapAttrs (mkLan spaceName staticRoutes wanCfg) lansCfg);
dnsmasqConf = pkgs.writeText "dnsmasq-${spaceName}.conf" (''
dhcp-host=*:*:*:*:*:*,id:*
except-interface=lo
dhcp-authoritative
${lib.optionalString (!wanCfg.dns.resolver.enable) "port=0"}
localise-queries
no-hosts
expand-hosts
dhcp-leasefile=${dhcpDynamicLeaseFileForDnsmasq}
dhcp-hostsfile=${dhcpStaticHostsFileForDnsmasq}
filter-AAAA
address=/host/${calc.cidr.host 1 wanCfg.ipv4.range}
${lib.optionalString (wanCfg.dns.resolver.enable && wanCfg.dns.resolver.useHostResolvConf) "no-resolv"}
${lib.concatMapStringsSep "\n" (nameserver: "server=${nameserver}") (lib.optionals wanCfg.dns.resolver.enable wanCfg.dns.resolver.upstream)}
'' + (lib.concatMapStrings (lan: lan.dnsmasqConf) lansList)
);
createLansInterfaces = pkgs.writeShellScript "create-lans-${spaceName}-vmix" (''
# for dnsmasq temp files
mkdir -m 700 -p /tmp/vmix/${spaceName}
'' + (lib.concatMapStrings (lan: lan.createIface) lansList)
);
deleteLansInterfaces = pkgs.writeShellScript "delete-lans-${spaceName}-vmix" (''
# for dnsmasq temp files
rm -rf /tmp/vmix/${spaceName}
'' + lib.concatMapStrings (lan: lan.deleteIface) lansList
);
staticHostsFile = pkgs.writeText "dnsmasq-${spaceName}-static-hostsfile" (lib.concatMapStringsSep "\n" (lan: lan.staticHostsFileContents) lansList);
in
{
"lans.net.vmix@${spaceName}" = rec {
bindsTo = [ "ns.net.vmix@${spaceName}.service" ];
after = bindsTo ++ [ "lans.static-leases-hostsfile.net.vmix@${spaceName}.service" ];
wantedBy = [ "net.vmix@${spaceName}.target" ];
unitConfig.JoinsNamespaceOf = "ns.net.vmix@${spaceName}.service";
path = with pkgs; [ iproute2 ];
reloadTriggers = [ staticHostsFile ];
serviceConfig = {
ExecStartPre = createLansInterfaces;
ExecStart = "${pkgs.dnsmasq}/bin/dnsmasq -d -C ${dnsmasqConf}";
ExecReload = pkgs.writeShellScript "reload-dnsmasq" "kill -HUP $MAINPID";
ExecStopPost = deleteLansInterfaces;
Restart = "on-failure";
RestartSec = "5";
PrivateTmp = true;
ProtectSystem = true;
ProtectHome = true;
PrivateNetwork = true;
};
};
"lans.static-leases-hostsfile.net.vmix@${spaceName}" = rec {
bindsTo = [ "ns.net.vmix@${spaceName}.service" ];
after = bindsTo;
requiredBy = [ "sysinit-reactivation.target" ];
before = requiredBy;
wantedBy = [ "net.vmix@${spaceName}.target" ];
unitConfig.JoinsNamespaceOf = "ns.net.vmix@${spaceName}.service";
serviceConfig = {
Type = "oneshot";
ExecCondition = pkgs.writeShellScript "check-if-symlink-already-present.sh" "! [ ${staticHostsFile} -ef ${dhcpStaticHostsFileForDnsmasq} ]";
ExecStart = pkgs.writeShellScript "create-hostsfile-symlink.sh" "mkdir -m 700 -p ${lansTmpDir}; ln -sf ${staticHostsFile} ${dhcpStaticHostsFileForDnsmasq};";
Restart = "on-failure";
RestartSec = "5";
PrivateTmp = true;
ProtectSystem = true;
ProtectHome = true;
PrivateNetwork = true;
};
};
};
mkWanService = spaceName: cfg:
let
wanCfg = cfg // { spaceName = spaceName; };
vethInNSToHost.iface = "vhost";
vethOnHostToNS.iface = "vn-${wanCfg.spaceName}";
vethOnHostToNS.ipv4.address = calc.cidr.host 1 wanCfg.ipv4.range;
vethInNSToHost.ipv4.address = calc.cidr.host 2 wanCfg.ipv4.range;
networkPrefix = builtins.elemAt (lib.splitString "/" wanCfg.ipv4.range) 1;
iptablesMark = builtins.toString (ipv4ToInt vethOnHostToNS.ipv4.address);
portForwardRules = lib.concatStringsSep "\n" (lib.mapAttrsToList (hostIPnPort: nsPort: "iptables -t nat -A PREROUTING -p tcp --dport ${hostIPnPort} -j DNAT --to-destination ${vethInNSToHost.ipv4.address}:${toString nsPort}") wanCfg.forwardPorts);
createWanCommands = ''
ip link add ${vethOnHostToNS.iface} type veth peer name ${vethInNSToHost.iface}
ip link set ${vethInNSToHost.iface} netns ${wanCfg.spaceName}.vmix
ip address add ${vethOnHostToNS.ipv4.address}/${networkPrefix} dev ${vethOnHostToNS.iface}
ip netns exec ${wanCfg.spaceName}.vmix ip address add ${vethInNSToHost.ipv4.address}/${networkPrefix} dev ${vethInNSToHost.iface}
iptables -A FORWARD -i ${vethOnHostToNS.iface} -j ACCEPT
iptables -A FORWARD -o ${vethOnHostToNS.iface} -j ACCEPT
${lib.optionalString (!wanCfg.host.reachable) "iptables -I INPUT 1 -i ${vethOnHostToNS.iface} -j DROP"}
${lib.optionalString wanCfg.masquerade "iptables -t mangle -A PREROUTING -i ${vethOnHostToNS.iface} -j MARK --set-mark ${iptablesMark}"}
${lib.optionalString wanCfg.masquerade "iptables -t nat -A POSTROUTING -m mark --mark ${iptablesMark} -j MASQUERADE"}
${portForwardRules}
ip link set ${vethOnHostToNS.iface} up
ip netns exec ${wanCfg.spaceName}.vmix ip link set ${vethInNSToHost.iface} up
ip netns exec ${wanCfg.spaceName}.vmix ip r add default via ${vethOnHostToNS.ipv4.address}
${lib.optionalString wanCfg.host.addNSLansRoutes (lib.concatMapStrings (lanRange: "ip r add ${lanRange} via ${vethInNSToHost.ipv4.address} \n") wanCfg.lanRanges)}
'';
createWan = pkgs.writeShellScript "create-wan-${wanCfg.spaceName}-vmix" createWanCommands;
deleteWan =
let
createdIptablesRules = lib.filter (line: (lib.hasPrefix "iptables" line)) (lib.splitString "\n" createWanCommands);
delIptablesRules = builtins.map (
rule: lib.replaceStrings [ "-I INPUT 1" "-A" ] [ "-D INPUT" "-D" ] rule
) createdIptablesRules;
in
pkgs.writeShellScript "delete-wan-${wanCfg.spaceName}-vmix" (''
ip link del ${vethOnHostToNS.iface}
'' + (lib.concatStringsSep "\n" delIptablesRules));
in
{
"wan.net.vmix@${wanCfg.spaceName}" = rec {
bindsTo = [ "ns.net.vmix@${wanCfg.spaceName}.service" ];
after = bindsTo;
wantedBy = [ "net.vmix@${wanCfg.spaceName}.target" ];
path = with pkgs; [ iproute2 iptables ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = createWan;
ExecStop = deleteWan;
};
};
};
mkVmGraphicsForwardPorts = spaceName:
let
vms = vmixCfg.namespaces.${spaceName}.vms;
vmGraphicsPortMappings = lib.concatLists (lib.mapAttrsToList (vmName: vmCfg:
(lib.optional (vmCfg.vnc.enable && vmCfg.vnc.forwardHostPort != null) {
hostPort = toString vmCfg.vnc.forwardHostPort;
nsPort = vmCfg.vnc.port;
reason = "vnc";
inherit vmName;
})
++ (lib.optional (vmCfg.spice.enable && vmCfg.spice.forwardHostPort != null) {
hostPort = toString vmCfg.spice.forwardHostPort;
nsPort = vmCfg.spice.port;
reason = "spice";
inherit vmName;
})
) vms);
addForwardPortMapping = acc: mapping:
if builtins.hasAttr mapping.hostPort acc then
throw "Duplicate vmix auto-forward host port ${mapping.hostPort} in namespace '${spaceName}' (at least '${mapping.vmName}' ${mapping.reason} conflicts with another VM mapping)."
else
acc // { ${mapping.hostPort} = mapping.nsPort; };
in
builtins.foldl' addForwardPortMapping {} vmGraphicsPortMappings;
mkNetworkServices = spaceName: cfg:
let
netCfg = cfg // { name = spaceName; };
vethIPv4RangeForWan = mkVethIPv4Range netCfg.index vmixCfg.global.net.wan.ipv4.range;
vmGraphicsForwardPorts = mkVmGraphicsForwardPorts spaceName;
manualForwardPorts = netCfg.wan.forwardPorts;
wanCfg = netCfg.wan // {
ipv4.range = vethIPv4RangeForWan;
lanRanges = builtins.map (lan: lan.ipv4.range) (lib.attrValues netCfg.lans);
forwardPorts = vmGraphicsForwardPorts // manualForwardPorts;
};
in
(mkLansService netCfg.name wanCfg netCfg.lans)
// (lib.optionalAttrs wanCfg.enable (mkWanService netCfg.name wanCfg));
spaceNames = builtins.attrNames vmixCfg.namespaces;
networkServices = lib.mergeAttrsList (lib.imap0 (index: spaceName: (mkNetworkServices spaceName (vmixCfg.namespaces.${spaceName}.networks // { inherit index;}))) spaceNames);
networkTargets = lib.mergeAttrsList (builtins.map (spaceName: {
"net.vmix@${spaceName}" = {
description = "Network ${spaceName} for vmix";
bindsTo = [ "ns.net.vmix@${spaceName}.service" "lans.net.vmix@${spaceName}.service" ]
++ lib.optionals vmixCfg.namespaces.${spaceName}.networks.wan.enable [ "wan.net.vmix@${spaceName}.service" ];
};
}) spaceNames);
in
{
config.systemd.services = namespaceGlobalService // networkServices;
config.systemd.targets = networkTargets;
config.boot.kernel.sysctl."net.ipv4.ip_forward" = lib.mkDefault 1;
}

View file

@ -6,11 +6,5 @@ with lib;
default = "172.27.72.0/24"; # enough to create 64x /30 networks for veth pairs used for wan interfaces
};
options.vmix.networks = mkOption {
type = types.attrsOf
(types.submodule (import ./options.nix args));
default = { };
};
imports = [ (import ./config.nix args) ];
}

137
nixos/networks/options.nix Normal file
View file

@ -0,0 +1,137 @@
{ lib, vmixLib, ... }:
with lib;
with vmixLib.network;
{
macvtaps = mkOption {
description = "Macvtap network definitions available to VMs in this namespace.";
type = types.attrsOf (types.submodule {
options = {
uplink.iface = mkOption {
type = types.str;
description = "Host interface name to attach the macvtap device to.";
};
uplink.namespace = mkOption {
type = types.nullOr types.str;
default = null;
description = "Optional network namespace where the uplink interface exists. Null means the host namespace.";
};
};
});
};
wan = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable forwarding traffic to and from the namespace to the rest of the networks on the host including the internet. (iptables FORWARD chain on the host)";
};
masquerade = mkOption {
type = types.bool;
default = true;
description = "Masquerade outgoing traffic using host's IP";
};
host.reachable = mkOption {
type = types.bool;
default = true;
description = "Allow talking to the host itself from the namespace and VMs on the lan (iptables INPUT chain on the host)";
};
host.addNSLansRoutes = mkOption {
type = types.bool;
default = true;
description = "add routes to the LAN on host so the vms are reachable from the host";
};
# host.dns.addNSLansResolver = mkOption {
# type = types.bool;
# default = true;
# };
dns.resolver.enable = mkOption {
type = types.bool;
default = true;
description = "Add dnsmasq's built in resolver to lan clients DHCP responses";
};
dns.resolver.useHostResolvConf = mkOption {
type = types.bool;
default = false;
description = "Use host's resolvconf for upstreaming dns queries";
};
dns.resolver.upstream = mkOption {
type = types.listOf (types.strMatching regex.ipv4);
default = [];
description = "Upstream DNS servers for dnsmasq's built in resolver";
};
forwardPorts = mkOption {
type = types.attrsOf types.int;
default = {};
description = "Map host TCP port to namespace destination TCP port.";
};
};
lans = mkOption {
description = "Layer-2 LAN bridge networks and DHCP settings for the namespace.";
type = types.attrsOf (types.submodule {
options.domain = mkOption {
type = types.nullOr types.str;
default = null;
description = "Domain name for the hosts of this lan.";
};
options.ipv4 = {
range = mkOption {
type = types.strMatching regex.cidr4;
description = "IPv4 Range in x.x.x.x/y format to be assigned to the network.";
};
address = mkOption {
type = types.nullOr (types.strMatching regex.ipv4);
default = null;
description = "IPv4 address to attach to the bridge interface of this Lan.";
};
dhcp.enable = mkOption {
type = types.bool;
default = true;
description = "Whether to start a DHCP server within this network.";
};
dhcp.startAddress = mkOption {
type = types.nullOr (types.strMatching regex.ipv4);
description = "Starting IP Address for DHCP clients.";
default = null;
};
dhcp.endAddress = mkOption {
type = types.nullOr (types.strMatching regex.ipv4);
description = "Ending IP Address for DHCP clients.";
default = null;
};
dhcp.dns.addresses = mkOption {
type = types.nullOr (types.listOf (types.strMatching regex.ipv4));
description = "List of IP Addresses to pass as DNS servers in the DHCP response. These servers are only passed if dnsmasq's built in resolver is not enabled via wan.dns.resolver.enable";
};
dhcp.statics = mkOption {
description = "Static IP leases for mac addresses";
type = types.attrsOf (types.strMatching regex.ipv4);
default = {};
};
};
});
};
# routes.internal.add = mkOption {
# description = "Additional routes to add on the internal network";
# };
# routes.host.add = mkOption {
# description = "Addtional routes to add on the host's network namespace";
# };
}

View file

@ -1,150 +0,0 @@
{ config, pkgs, lib, vmixLib, ... }:
with lib;
with vmixLib.network;
let
vmixCfg = config.vmix;
mkServices4aVM = name: cfg:
let
vmCfg = cfg // { inherit name; };
netName = head (attrNames vmCfg.network.vmix);
netCfg = vmCfg.network.vmix.${netName} // { name = netName; };
mkTap4aLan = lanName: tapCfg:
let
tapInterfaceName = "vt-${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}
'';
delete = ''
ip link del ${tapInterfaceName}
'';
};
mkMacvtap = macvtapName: macvtapVmCfg:
let
macvtapNetworkCfg = config.vmix.networks.${netCfg.name}.bridges.macvtaps.${macvtapName};
macvtapInterfaceName = "mt-${vmCfg.name}-${macvtapNetworkCfg.uplink.iface}";
in
{
name = macvtapName;
iface = macvtapInterfaceName;
mac = macvtapVmCfg.mac;
create = ''
ip link add link ${macvtapNetworkCfg.uplink.iface} name ${macvtapInterfaceName} type macvtap mode bridge
${lib.optionalString (macvtapVmCfg.mac != null) "ip link set dev ${macvtapInterfaceName} address ${macvtapVmCfg.mac}"}
ip link set ${macvtapInterfaceName} netns ${netName}.vmix
ip netns exec ${netName}.vmix ip link set dev ${macvtapInterfaceName} up
'';
delete = ''
ip netns exec ${netName}.vmix ip 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)
);
createMacvapsScript = pkgs.writeShellScript "${vmCfg.name}-taps-vmix" (
concatStringsSep "\n" (builtins.map (macvtap: macvtap.create) allMacvtaps)
);
deleteMacvapsScript = pkgs.writeShellScript "${vmCfg.name}-taps-vmix" (
concatStringsSep "\n" (builtins.map (macvtap: macvtap.delete) allMacvtaps)
);
osImage = vmixLib.customizeImage vmCfg.disks.os.file {
name = vmCfg.name;
commands = ''
truncate /etc/machine-id
run-command systemd-machine-id-setup
run-command ssh-keygen -A
'';
};
qemuStartVMScript = pkgs.writeShellScript "${vmCfg.name}-qemu-vmix" ''
exec qemu-system-${vmCfg.arch} \
-nographic \
${optionalString vmCfg.kvm "-accel kvm"} \
-name ${vmCfg.name} \
-m ${toString vmCfg.mem.size} \
-smp cores=${toString vmCfg.cpu.cores} \
-cpu ${vmCfg.cpu.model} \
-machine type=${vmCfg.pc.type} \
${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"} \
-drive file=${toString osImage},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=${diskCfg.file},format=qcow2,if=${toString vmCfg.disks.bus} \
'') (attrValues vmCfg.disks.add)} \
${concatMapStrings (shareCfg: ''
-virtfs local,path=${toString shareCfg.source},security_model=passthrough,mount_tag=${shareCfg.target} \
'') (attrValues vmCfg.shares)} \
${concatMapStrings (tapCfg: ''
-device virtio-net-pci,netdev=lan-${tapCfg.name},mac=${tapCfg.mac} \
-netdev tap,id=lan-${tapCfg.name},ifname=${tapCfg.iface},script=no,downscript=no \
'') allTaps} \
${optionalString cfg.network.user.enable "
-netdev user,id=user \
-device virtio-net-pci,netdev=user \
"} \
${optionalString (vmCfg.boot.menu == true) "-boot menu=on"} \
${concatStrings (imap1 (i: macvtap: ''
-device virtio-net-pci,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)} \
#${optionalString (length vmCfg.boot.order > 0) "-boot order=${concatStringsSep "," vmCfg.boot.order}"} \
'';
in
{
"vm.vmix@${vmCfg.name}" = rec {
bindsTo = [ "net.vmix@${netCfg.name}.target" "macvtaps.vm.vmix@${vmCfg.name}.service" ];
unitConfig.JoinsNamespaceOf = "ns.net.vmix@${netCfg.name}.service";
after = bindsTo;
path = with pkgs; [ iproute2 qemu gawk ];
serviceConfig = {
ExecStartPre = createTapsforLansScript;
ExecStart = qemuStartVMScript;
ExecStopPost = deleteTapsforLansScript;
PrivateTmp = true;
ProtectSystem = true;
ProtectHome = true;
PrivateNetwork = true;
};
};
"macvtaps.vm.vmix@${vmCfg.name}" = rec {
bindsTo = [ "net.vmix@${netCfg.name}.target" ];
after = bindsTo;
partOf = [ "vm.vmix@${vmCfg.name}.service" ];
path = with pkgs; [ iproute2 ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = createMacvapsScript;
ExecStop = deleteMacvapsScript;
};
};
};
vmServices = concatMapAttrs mkServices4aVM vmixCfg.vms;
in
{
config.systemd.services = vmServices;
}

View file

@ -1,11 +0,0 @@
args@{ config, pkgs, lib, vmixLib, ... }:
with lib;
{
options.vmix.vms = mkOption {
type = types.attrsOf
(types.submodule (import ./options.nix args));
default = { };
};
imports = [ (import ./config.nix args) ];
}

View file

@ -1,167 +0,0 @@
{ config, pkgs, lib, ... }:
with lib;
{
options = {
cpu.cores = mkOption {
type = types.int;
default = 2;
description = "Number of CPU cores.";
};
cpu.model = mkOption {
type = types.str;
default = "host";
description = "CPU model.";
};
kvm = mkOption {
type = types.bool;
default = true;
description = "Enable KVM.";
};
arch = mkOption {
type = types.str;
default = "x86_64";
description = "Architecture of the VM.";
};
pc.type = mkOption {
type = types.str;
default = "q35";
description = "PC type.";
};
bios.efi = mkOption {
type = types.bool;
default = true;
description = "Enable EFI BIOS.";
};
bios.tpm = mkOption {
type = types.bool;
default = false;
description = "Enable TPM BIOS.";
};
mem.size = mkOption {
type = types.int;
default = 1024;
description = "Memory size in MB.";
};
mem.balloon = mkOption {
type = types.bool;
default = false;
description = "Enable memory ballooning.";
};
disks.os.file = mkOption {
type = types.path;
description = "Path to the OS disk image.";
};
disks.os.persist = mkOption {
type = types.bool;
default = false;
description = "Persist OS disk changes.";
};
disks.iso.file = mkOption {
type = types.nullOr types.path;
description = "Path to the ISO file.";
default = null;
};
disks.add = mkOption {
default = {};
type = types.attrsOf (types.submodule {
options = {
file = mkOption {
type = types.path;
description = "Path to the additional disk.";
};
mounts = mkOption {
type = types.attrsOf types.str;
description = "Mount points for the additional disk.";
};
opts = mkOption {
type = types.str;
description = "additional options in QEMU args for this disk";
};
};
});
description = "Additional disks.";
};
shares = mkOption {
default = {};
type = types.attrsOf (types.submodule {
options = {
source = mkOption {
type = types.path;
description = "Source path for the shared directory.";
};
target = mkOption {
type = types.str;
description = "Target path inside the VM for the shared directory.";
};
};
});
description = "Shared directories.";
};
disks.bus = mkOption {
type = types.str;
default = "virtio";
description = "Bus type for the disks.";
};
boot.order = mkOption {
type = types.listOf types.str;
description = "Boot order.";
default = [ "os" "iso" ];
};
boot.menu = mkOption {
type = types.bool;
default = false;
description = "Enable boot menu.";
};
network.user.enable = mkOption {
type = types.bool;
default = false;
description = "enable qemu user networking";
};
network.vmix = mkOption {
default = {};
type = types.attrsOf (types.submodule {
options = {
lans = mkOption {
default = {};
type = types.attrsOf (types.submodule {
options = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable the LAN interface.";
};
mac = mkOption {
type = types.str;
description = "MAC address for the LAN interface.";
};
};
});
description = "LAN interfaces.";
};
macvtaps = mkOption {
default = {};
type = types.attrsOf (types.submodule {
options = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable the MACVTap interface.";
};
mac = mkOption {
type = types.nullOr types.str;
default = null;
description = "MAC address for the MACVTap interface.";
};
};
});
description = "MACVTap interfaces.";
};
};
});
description = "Network interfaces.";
};
};
}

272
nixos/vms/config.nix Normal file
View file

@ -0,0 +1,272 @@
{ 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 from _vmixOsType marker on the disk image
isWindows = vmCfg.windows.enable || (hasOsDisk && (vmCfg.disks.os.file._vmixOsType or "linux") == "windows");
# 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 VMs: use disk image as-is (customization done at image build time)
storeImage = if !hasOsDisk then null
else if isWindows 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;
# 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 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"} \
${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 "-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 ${vmCfg.cpu.model}${optionalString vmCfg.cpu.hideVirtualized ",kvm=off,hv_vendor_id=1234567890ab,-hypervisor"} \
-machine type=${vmCfg.pc.type}${optionalString vmCfg.cpu.hideVirtualized ",kernel_irqchip=on"} \
${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 \
''} \
${optionalString hasOsDisk "-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)} \
${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 virtio-net-pci,netdev=user \
"} \
${concatMapStrings (tapCfg: ''
-device virtio-net-pci,netdev=lan-${tapCfg.name},mac=${tapCfg.mac} \
-netdev tap,id=lan-${tapCfg.name},ifname=${tapCfg.iface},script=no,downscript=no \
'') allTaps} \
${concatStrings (imap1 (i: macvtap: ''
-device virtio-net-pci,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: pciAddr: ''
-device pcie-root-port,id=pci-passthrough${toString i},chassis=${toString i},slot=${toString i} \
-device vfio-pci,host=${pciAddr},bus=pci-passthrough${toString i}${optionalString (i == 1) ",x-vga=on${optionalString (vmCfg.pci.romFile != null) ",romfile=${vmCfg.pci.romFile}"}"} \
'') vmCfg.pci.passthrough)} \
${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" "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;
} // 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" ];
};
"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;
}

5
nixos/vms/default.nix Normal file
View file

@ -0,0 +1,5 @@
args@{ config, pkgs, lib, vmixLib, ... }:
with lib;
{
imports = [ (import ./config.nix args) ];
}

View file

@ -0,0 +1,309 @@
{ config, pkgs, lib, vmixLib, ... }:
with lib;
{
options = {
autostart = mkOption {
type = types.bool;
default = false;
description = "Start VM on host boot.";
};
enable = mkOption {
type = types.bool;
default = true;
description = "Enable/disable VM service creation.";
};
vnc = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable VNC.";
};
addr = mkOption {
type = types.str;
default = "0.0.0.0";
description = "VNC bind address inside the VM namespace.";
};
port = mkOption {
type = types.ints.between 5900 5999;
default = 5900;
description = "VNC TCP port inside the VM namespace.";
};
forwardHostPort = mkOption {
type = types.nullOr types.port;
default = null;
description = "Optional host TCP port to auto-forward to this VM VNC port.";
};
websocketPort = mkOption {
type = types.nullOr types.port;
default = null;
description = "Optional websocket port for VNC (for noVNC-style clients).";
};
passwordFile = mkOption {
type = types.nullOr types.str;
default = null;
description = "Path to a runtime file containing the VNC password. When set, VNC auth is enabled via password-secret.";
};
sharePolicy = mkOption {
type = types.enum [ "allow-exclusive" "force-shared" "ignore" ];
default = "allow-exclusive";
description = "VNC client sharing policy.";
};
};
spice = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable SPICE display server.";
};
port = mkOption {
type = types.int;
default = 5930;
description = "SPICE TCP port inside the VM namespace.";
};
forwardHostPort = mkOption {
type = types.nullOr types.port;
default = null;
description = "Optional host TCP port to auto-forward to this VM SPICE port.";
};
addr = mkOption {
type = types.str;
default = "0.0.0.0";
description = "SPICE bind address inside the VM namespace.";
};
passwordFile = mkOption {
type = types.nullOr types.str;
default = null;
description = "Path to a runtime file containing SPICE password. When set, ticketing is enabled automatically, otherwise ticketing is disabled.";
};
agent.enable = mkOption {
type = types.bool;
default = true;
description = "Enable SPICE guest agent channel (clipboard/resolution helpers).";
};
usbRedir = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable SPICE USB redirection channels.";
};
channels = mkOption {
type = types.ints.between 1 16;
default = 4;
description = "Number of SPICE USB redirection channels to expose.";
};
};
displayDevice = mkOption {
type = types.enum [ "virtio" "qxl" "std" "none" ];
default = "qxl";
description = "QEMU -vga type to use with SPICE (qxl, virtio, std, none).";
};
};
nographic = mkOption {
type = types.bool;
default = true;
description = "Run QEMU without a graphical window (-nographic).";
};
cpu.cores = mkOption {
type = types.int;
default = 2;
description = "Number of CPU cores.";
};
cpu.model = mkOption {
type = types.str;
default = "host";
description = "CPU model.";
};
cpu.hideVirtualized = mkOption {
type = types.bool;
default = true;
description = "Hide hypervisor from guest. Prevents GPU driver Code 43 errors by stripping hypervisor CPUID leaf.";
};
kvm = mkOption {
type = types.bool;
default = true;
description = "Enable KVM.";
};
arch = mkOption {
type = types.str;
default = "x86_64";
description = "Architecture of the VM.";
};
pc.type = mkOption {
type = types.str;
default = "q35";
description = "PC type.";
};
bios.efi = mkOption {
type = types.bool;
default = true;
description = "Enable EFI BIOS.";
};
bios.tpm = mkOption {
type = types.bool;
default = false;
description = "Enable TPM BIOS.";
};
mem.size = mkOption {
type = types.int;
default = 1024;
description = "Memory size in MB.";
};
mem.balloon = mkOption {
type = types.bool;
default = false;
description = "Enable memory ballooning.";
};
disks.os.file = mkOption {
type = types.nullOr types.path;
default = null;
description = "Path to the OS disk image. Null for ISO-only VMs. For Windows, use a vmixLib.windows.* image config auto-detects via _vmixOsType metadata.";
};
disks.os.persist = mkOption {
type = types.bool;
default = false;
description = "Persist OS disk changes. The store image is copied to persistPath on first boot and QEMU writes to that mutable copy.";
};
disks.os.persistPath = mkOption {
type = types.str;
default = "";
description = "Mutable path for the persistent OS disk (e.g. /storage/vms/myvm/os.qcow2). Required when persist = true.";
};
disks.iso.file = mkOption {
type = types.nullOr (types.either types.path types.str);
description = "Path to the ISO file. Can be a Nix store path or a string path to a local file.";
default = null;
};
disks.add = mkOption {
default = {};
type = types.attrsOf (types.submodule {
options = {
file = mkOption {
type = types.str;
description = "String literal path to the additional disk.";
};
format = mkOption {
type = types.str;
description = "raw/qcow2 etc";
};
mounts = mkOption {
type = types.attrsOf types.str;
description = "Mount points for the additional disk.";
};
opts = mkOption {
type = types.str;
description = "additional options in QEMU args for this disk";
};
};
});
description = "Additional disks.";
};
shares = mkOption {
default = {};
type = types.attrsOf (types.submodule {
options = {
source = mkOption {
type = types.path;
description = "Source path for the shared directory.";
};
target = mkOption {
type = types.str;
description = "Target path inside the VM for the shared directory.";
};
};
});
description = "Shared directories.";
};
disks.bus = mkOption {
type = types.str;
default = "virtio";
description = "Bus type for the disks.";
};
boot.order = mkOption {
type = types.listOf (types.enum [ "os" "iso" "net" "floppy" ]);
description = "Boot order.";
default = [ "os" "iso" ];
};
boot.menu = mkOption {
type = types.bool;
default = false;
description = "Enable boot menu.";
};
windows = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable Windows-optimized QEMU flags. Auto-enabled when disks.os.file carries _vmixOsType = \"windows\" metadata.";
};
};
tpm = {
stateDir = mkOption {
type = types.str;
default = "/tmp";
description = "Directory for TPM state persistence. Set to a /storage path for persistence across reboots.";
};
};
pci.passthrough = mkOption {
type = types.listOf types.str;
default = [];
description = "PCI device addresses to passthrough via VFIO (e.g. [\"0000:03:00.0\" \"0000:03:00.1\"]).";
};
pci.romFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "GPU VBIOS ROM file for the first passthrough device. Required when GPU PCI ROM BAR doesn't expose the full VBIOS (common with AMD Navi+).";
};
usb.hostDevices = mkOption {
default = [];
type = types.listOf (types.submodule {
options = {
vendorId = mkOption { type = types.str; description = "USB vendor ID (e.g. \"1d6b\")."; };
productId = mkOption { type = types.str; description = "USB product ID (e.g. \"0104\")."; };
};
});
description = "USB host devices to passthrough to the VM.";
};
networks.user.enable = mkOption {
type = types.bool;
default = false;
description = "enable qemu user networking";
};
networks.lans = mkOption {
default = {};
type = types.attrsOf (types.submodule {
options = {
mac = mkOption {
type = types.str;
description = "MAC address for the LAN interface.";
};
ip = mkOption {
type = types.nullOr (types.strMatching vmixLib.network.regex.ipv4);
default = null;
description = "assign static IP from the lan pool.";
};
};
});
description = "LAN interfaces.";
};
networks.macvtaps = mkOption {
default = {};
type = types.attrsOf (types.submodule {
options = {
mac = mkOption {
type = types.nullOr types.str;
default = null;
description = "MAC address for the MACVTap interface.";
};
};
});
description = "MACVTap interfaces.";
};
};
}

View file

@ -1,3 +1,7 @@
final: prev: {
vmixLib = prev.callPackage ./lib {};
final: prev:
let
# Pin vmixLib to nixpkgs 25-11 so all VM images are built with a consistent toolchain
vmixPkgs = prev.v25-11 or prev;
in {
vmixLib = vmixPkgs.callPackage ./lib {};
}