vmix.nix/nixos/networks/config.nix
Git Sagar 2f8925d439 fix: race in parallel wan veth creation cross-wiring namespaces
All wan.net.vmix@* instances created their veth pair with the same
temporary peer name 'vhost' in the host namespace before moving it into
their netns. Parallel starts at boot could steal each other's peer ends,
pairing a host-side vn-<ns> with another namespace's vhost (mismatched
/30s, dead links) or leaving the pair stranded in the host namespace.

Use a per-namespace temporary name (vh-<ns>) and rename to vhost only
after the move, making concurrent creation collision-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:23:54 -03:00

296 lines
14 KiB
Nix

{ 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";
# Temporary peer name, unique per namespace. The peer briefly exists in the
# host namespace before being moved; a shared name ("vhost") lets parallel
# wan.net.vmix@* starts steal each other's peer ends, cross-wiring namespaces.
vethInNSToHost.tempIface = "vh-${wanCfg.spaceName}";
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.tempIface}
ip link set ${vethInNSToHost.tempIface} netns ${wanCfg.spaceName}.vmix
ip netns exec ${wanCfg.spaceName}.vmix ip link set ${vethInNSToHost.tempIface} name ${vethInNSToHost.iface}
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.mkForce 1;
config.boot.kernel.sysctl."net.ipv4.conf.all.forwarding" = lib.mkForce true;
}