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

@ -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.";
};
};
}