vmsilo/modules/assertions.nix
2026-03-17 12:58:38 +00:00

244 lines
9.1 KiB
Nix

# Validation assertions for vmsilo NixOS module
{
config,
lib,
...
}:
let
cfg = config.programs.vmsilo;
helpers = import ./lib/helpers.nix { inherit lib; };
inherit (helpers)
isValidCIDR
extractBdf
mkEffectiveSharedDirs
parseCIDR
allocateNetvmSubnet
ipToInt
assignVmIds
;
# User UID/GID for shared directory assertions
userUid = config.users.users.${cfg.user}.uid;
userGid = config.users.groups.${config.users.users.${cfg.user}.group}.gid;
# Normalize all isolated devices
normalizedIsolatedDevices = map helpers.normalizeBdf cfg.isolatedPciDevices;
# Get normalized PCI devices for a VM (extract path from attrset)
normalizedVmPciDevices = vm: map (dev: extractBdf dev.path) vm.pciDevices;
# All PCI devices across all VMs (for duplicate check)
allVmPciDevices = lib.concatMap normalizedVmPciDevices (lib.attrValues cfg.nixosVms);
# netvm helpers
vmsForNetvm = assignVmIds cfg.nixosVms;
clientVmsForAssert = lib.filter (vm: vm.network.netvm != null) vmsForNetvm;
parsedNetvmRange = parseCIDR cfg.netvmRange;
computeBaseAddr =
clientVm:
let
auto =
allocateNetvmSubnet clientVm.network.netvm clientVm.name parsedNetvmRange.ip
parsedNetvmRange.prefix;
in
if clientVm.network.netvmSubnet != null then
ipToInt (parseCIDR clientVm.network.netvmSubnet).ip
else
auto.baseAddr;
allBaseAddrs = map computeBaseAddr clientVmsForAssert;
in
{
config = lib.mkIf cfg.enable {
assertions =
let
vmNames = lib.attrNames cfg.nixosVms;
in
[
{
assertion = !lib.hasAttr "host" cfg.nixosVms;
message = "VM name 'host' is reserved for use with network.netvm = \"host\"";
}
]
# PCI passthrough assertions
++ lib.concatMap (
vm:
map (
dev:
let
normalizedDev = extractBdf dev.path;
in
{
assertion = lib.elem normalizedDev normalizedIsolatedDevices;
message = "VM '${vm.name}' uses PCI device ${dev.path} which is not in isolatedPciDevices";
}
) vm.pciDevices
) (lib.attrValues cfg.nixosVms)
++ [
{
assertion = lib.length allVmPciDevices == lib.length (lib.unique allVmPciDevices);
message = "PCI devices cannot be assigned to multiple VMs";
}
{
assertion = config.users.users.${cfg.user}.uid != null;
message = "programs.vmsilo.user '${cfg.user}' must have an explicit uid set in users.users";
}
]
# Network interface assertions
++ lib.concatMap (
vm:
let
effectiveIfaces =
vm.network.interfaces // (cfg._internal.netvmInjections.${vm.name}.interfaces or { });
ifaceNames = lib.attrNames effectiveIfaces;
in
# Max 15 interfaces per VM (PCI slots 16-31)
[
{
assertion = lib.length ifaceNames <= 15;
message = "VM '${vm.name}' has ${toString (lib.length ifaceNames)} interfaces, max is 15";
}
]
# Validate interface name format
++ lib.mapAttrsToList (name: _iface: {
assertion = builtins.match "[a-zA-Z][a-zA-Z0-9_-]{0,14}" name != null;
message = "VM '${vm.name}' interface '${name}': invalid name (must start with a letter, contain only letters/digits/hyphens/underscores, max 15 chars)";
}) effectiveIfaces
# Validate host TAP name format when explicitly set
++ lib.concatLists (
lib.mapAttrsToList (
name: iface:
lib.optional (iface.tap != null && iface.tap.name != null) {
assertion = builtins.match "[a-zA-Z][a-zA-Z0-9_-]{0,14}" iface.tap.name != null;
message = "VM '${vm.name}' interface '${name}': tap.name '${iface.tap.name}' is invalid (must start with a letter, contain only letters/digits/hyphens/underscores, max 15 chars)";
}
) effectiveIfaces
)
# tap required when type = "tap"
++ lib.mapAttrsToList (name: iface: {
assertion = iface.type == "tap" -> iface.tap != null;
message = "VM '${vm.name}' interface '${name}': tap required when type = 'tap'";
}) effectiveIfaces
# tap only valid when type = "tap"
++ lib.mapAttrsToList (name: iface: {
assertion = iface.type != "tap" -> iface.tap == null;
message = "VM '${vm.name}' interface '${name}': tap only valid when type = 'tap'";
}) effectiveIfaces
# tap.hostAddress and tap.bridge are mutually exclusive
++ lib.mapAttrsToList (name: iface: {
assertion =
iface.type == "tap" && iface.tap != null
-> !(iface.tap.hostAddress != null && iface.tap.bridge != null);
message = "VM '${vm.name}' interface '${name}': tap.hostAddress and tap.bridge are mutually exclusive";
}) effectiveIfaces
# Validate addresses are valid CIDR
++ lib.concatLists (
lib.mapAttrsToList (
name: iface:
map (addr: {
assertion = isValidCIDR addr;
message = "VM '${vm.name}' interface '${name}': address '${addr}' is not valid CIDR notation";
}) iface.addresses
) effectiveIfaces
)
# Validate route destinations are valid CIDR
++ lib.concatLists (
lib.mapAttrsToList (
name: iface:
map (dest: {
assertion = isValidCIDR dest;
message = "VM '${vm.name}' interface '${name}': route destination '${dest}' is not valid CIDR notation";
}) (lib.attrNames iface.routes)
) effectiveIfaces
)
) (lib.attrValues cfg.nixosVms)
# dependsOn assertions: referenced VM names must exist
++ lib.concatMap (
vm:
map (depName: {
assertion = lib.elem depName vmNames;
message = "VM '${vm.name}' has dependsOn '${depName}' which is not a defined VM name";
}) vm.dependsOn
) (lib.attrValues cfg.nixosVms)
# Shared directory assertions: translateUid/translateGid incompatible with posixAcl
++ lib.concatMap (
vm:
let
effectiveSharedDirs = mkEffectiveSharedDirs {
inherit (vm) sharedDirectories sharedHome;
vmName = vm.name;
inherit userUid userGid;
};
in
lib.concatLists (
lib.mapAttrsToList (
tag: d:
lib.optional (d.posixAcl && (d.translateUid != null || d.translateGid != null)) {
assertion = false;
message = "VM '${vm.name}' shared dir '${tag}': translateUid/translateGid are incompatible with posixAcl";
}
) effectiveSharedDirs
)
) (lib.attrValues cfg.nixosVms)
# USB device assertions: no duplicate VID:PID+serial across VMs
++ (
let
allUsbDeviceKeys = lib.concatMap (
vm:
map (
dev:
"${dev.vendorId}:${dev.productId}:${toString (dev.serial or "")}"
) vm.usbDevices
) (lib.attrValues cfg.nixosVms);
in
[
{
assertion = lib.length allUsbDeviceKeys == lib.length (lib.unique allUsbDeviceKeys);
message = "USB devices (VID:PID+serial) cannot be assigned to multiple VMs";
}
]
)
# netvm: network.netvm must reference a VM with isNetvm = true (skip for "host")
++ lib.concatMap (
vm:
lib.optionals (vm.network.netvm != "host") [
{
assertion = lib.hasAttr vm.network.netvm cfg.nixosVms;
message = "VM '${vm.name}': network.netvm = '${vm.network.netvm}' is not a defined VM name";
}
{
assertion =
!lib.hasAttr vm.network.netvm cfg.nixosVms || cfg.nixosVms.${vm.network.netvm}.network.isNetvm;
message = "VM '${vm.name}': network.netvm = '${vm.network.netvm}' does not have network.isNetvm = true";
}
]
++ [
{
assertion = !lib.hasAttr "upstream" vm.network.interfaces;
message = "VM '${vm.name}': cannot set both network.netvm and network.interfaces.upstream (name conflict)";
}
{
assertion =
vm.network.netvmSubnet == null
|| (
let
ip = ipToInt (parseCIDR vm.network.netvmSubnet).ip;
in
ip / 2 * 2 == ip
);
message = "VM '${vm.name}': network.netvmSubnet '${toString vm.network.netvmSubnet}' must specify the lower (even) /31 address. The netvm automatically takes clientIp + 1.";
}
]
) clientVmsForAssert
# netvm: no /31 collisions across all (netvm, client) pairs
++ lib.optional (clientVmsForAssert != [ ]) {
assertion = lib.length allBaseAddrs == lib.length (lib.unique allBaseAddrs);
message = ''
netvm subnet collision detected: two or more VM pairs hashed to the same /31.
Use network.netvmSubnet on one of the conflicting VMs to assign an explicit subnet.
'';
};
};
}