diff --git a/flake.lock b/flake.lock index c323439..fd9da52 100644 --- a/flake.lock +++ b/flake.lock @@ -3,7 +3,9 @@ "agenix": { "inputs": { "darwin": "darwin", - "home-manager": "home-manager", + "home-manager": [ + "home-manager" + ], "nixpkgs": [ "nixpkgs" ] @@ -54,15 +56,15 @@ "stable": "stable" }, "locked": { - "lastModified": 1684127527, - "narHash": "sha256-tAzgb2jgmRaX9HETry38h2OvBf9YkHEH1fFvIJQV9A0=", - "owner": "zhaofengli", + "lastModified": 1684497694, + "narHash": "sha256-vFIB57ZqUftCfJcjkzEkvNVAdCbn80A2HXZ2OXl6wtA=", + "owner": "oddlama", "repo": "colmena", - "rev": "caf33af7d854c8d9b88a8f3dae7adb1c24c1407b", + "rev": "888e238953cf4ceb2577b668ea78318849a07529", "type": "github" }, "original": { - "owner": "zhaofengli", + "owner": "oddlama", "repo": "colmena", "type": "github" } @@ -117,11 +119,11 @@ ] }, "locked": { - "lastModified": 1684170997, - "narHash": "sha256-WgwqHeYv2sDA0eWghnYCUNx7dm5S8lqDVZjp7ufzm30=", + "lastModified": 1684472660, + "narHash": "sha256-P4sR6f27FKoQuGnThELALUuJeu9mZ9Zh7/dYdaAd2ek=", "owner": "nix-community", "repo": "disko", - "rev": "10402e31443941b50bf62e67900743dcb26b3b27", + "rev": "efb2016c8e6a91ea64e0604d69e332d8aceabb95", "type": "github" }, "original": { @@ -204,36 +206,15 @@ "home-manager": { "inputs": { "nixpkgs": [ - "agenix", "nixpkgs" ] }, "locked": { - "lastModified": 1682203081, - "narHash": "sha256-kRL4ejWDhi0zph/FpebFYhzqlOBrk0Pl3dzGEKSAlEw=", + "lastModified": 1684484967, + "narHash": "sha256-P3ftCqeJmDYS9LSr2gGC4XGGcp5vv8TOasJX6fVHWsw=", "owner": "nix-community", "repo": "home-manager", - "rev": "32d3e39c491e2f91152c84f8ad8b003420eab0a1", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "home-manager", - "type": "github" - } - }, - "home-manager_2": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1684157850, - "narHash": "sha256-xGHTCgvAxO5CgAL6IAgE/VGRX2wob2Y+DPyqpXJ32oQ=", - "owner": "nix-community", - "repo": "home-manager", - "rev": "c0deab0effd576e70343cb5df0c64428e0e0d010", + "rev": "b9a52ad20e58ebd003444915e35e3dd2c18fc715", "type": "github" }, "original": { @@ -244,11 +225,11 @@ }, "impermanence": { "locked": { - "lastModified": 1684144492, - "narHash": "sha256-5TBG9kZGdKrZGHdyjLA04ODSzhx1Bx/vwMxfRgWF+JU=", + "lastModified": 1684264534, + "narHash": "sha256-K0zr+ry3FwIo3rN2U/VWAkCJSgBslBisvfRIPwMbuCQ=", "owner": "nix-community", "repo": "impermanence", - "rev": "ec1a8e70d61261f9ada30f4e450ea7230d9efb62", + "rev": "89253fb1518063556edd5e54509c30ac3089d5e6", "type": "github" }, "original": { @@ -368,7 +349,7 @@ "nixpkgs": { "locked": { "lastModified": 1684049129, - "narHash": "sha256-dyq0Cc+C/WaVHWSIICqIlteLzzQyRAfw3rQQGrBAzWM=", + "narHash": "sha256-FfWznSgzYGFYpbcVcI6QHHiBc8x4EOxaB6U8RtOtFOU=", "type": "git", "url": "file:///root/projects/nixpkgs-test" }, @@ -428,7 +409,7 @@ "colmena": "colmena", "disko": "disko", "flake-utils": "flake-utils", - "home-manager": "home-manager_2", + "home-manager": "home-manager", "impermanence": "impermanence", "lib-net": "lib-net", "microvm": "microvm", diff --git a/flake.nix b/flake.nix index 9fd70a4..ad14c13 100644 --- a/flake.nix +++ b/flake.nix @@ -3,7 +3,7 @@ inputs = { colmena = { - url = "github:zhaofengli/colmena"; + url = "github:oddlama/colmena"; inputs.nixpkgs.follows = "nixpkgs"; inputs.flake-utils.follows = "flake-utils"; }; @@ -53,6 +53,7 @@ agenix = { url = "github:ryantm/agenix"; + inputs.home-manager.follows = "home-manager"; inputs.nixpkgs.follows = "nixpkgs"; }; agenix-rekey = { diff --git a/hosts/common/core/system.nix b/hosts/common/core/system.nix index 224863e..e5be4be 100644 --- a/hosts/common/core/system.nix +++ b/hosts/common/core/system.nix @@ -14,7 +14,12 @@ lib.recursiveUpdate libWithNet { net = { cidr = rec { - hostCidr = n: x: "${libWithNet.net.cidr.host n x}/${libWithNet.net.cidr.length x}"; + host = i: n: let + cap = libWithNet.net.cidr.capacity n; + in + assert lib.assertMsg (i >= (-cap) && i < cap) "The host ${toString i} lies outside of ${n}"; + libWithNet.net.cidr.host i n; + hostCidr = n: x: "${libWithNet.net.cidr.host n x}/${toString (libWithNet.net.cidr.length x)}"; ip = x: lib.head (lib.splitString "/" x); canonicalize = x: libWithNet.net.cidr.make (libWithNet.net.cidr.length x) (ip x); }; @@ -50,6 +55,7 @@ boot = { initrd.systemd.enable = true; + # Add "rd.systemd.unit=rescue.target" to debug initrd kernelParams = ["log_buf_len=10M"]; tmp.useTmpfs = true; }; diff --git a/hosts/ward/default.nix b/hosts/ward/default.nix index 4cbec23..4a589ff 100644 --- a/hosts/ward/default.nix +++ b/hosts/ward/default.nix @@ -3,7 +3,6 @@ inputs, lib, nixos-hardware, - nodeSecrets, pkgs, ... }: { @@ -26,20 +25,18 @@ boot.initrd.availableKernelModules = ["xhci_pci" "ahci" "nvme" "usbhid" "usb_storage" "sd_mod" "sdhci_pci" "r8169"]; - extra.microvms = let - macOffset = config.lib.net.mac.addPrivate nodeSecrets.networking.interfaces.lan.mac; - in { - test = { + extra.microvms = { + vms.test = { + id = 11; + host = "test.local"; + system = "x86_64-linux"; + autostart = true; zfs = { enable = true; pool = "rpool"; dataset = "safe/vms/test"; mountpoint = "/persist/vms/test"; }; - autostart = true; - mac = macOffset "00:00:00:00:00:11"; - macvtap = "lan"; - system = "x86_64-linux"; }; }; @@ -99,10 +96,4 @@ # }; # }; #}; - - #microvm.vms.agag = { - # flake = self; - # updateFlake = microvm; - #}; - #microvm.autostart = ["guest"]; } diff --git a/hosts/ward/fs.nix b/hosts/ward/fs.nix index 0839f4a..d60c8ab 100644 --- a/hosts/ward/fs.nix +++ b/hosts/ward/fs.nix @@ -23,7 +23,7 @@ }; zpool = with extraLib.disko.zfs; { rpool = - encryptedZpool + defaultZpoolOptions // { datasets = { "local" = unmountable; diff --git a/hosts/ward/net.nix b/hosts/ward/net.nix index 197e3f7..0296336 100644 --- a/hosts/ward/net.nix +++ b/hosts/ward/net.nix @@ -4,7 +4,7 @@ nodeSecrets, ... }: let - inherit (config.lib.net) cidr; + inherit (config.lib.net) ip cidr; net.lan.ipv4cidr = "192.168.100.1/24"; net.lan.ipv6cidr = "fd00::1/64"; @@ -94,6 +94,7 @@ in { zones = lib.mkForce { lan.interfaces = ["lan-self"]; wan.interfaces = ["wan"]; + "local-vms".interfaces = ["wg-local-vms"]; }; rules = lib.mkForce { @@ -133,7 +134,6 @@ in { }; }; - # TODO to microvm! services.kea = { dhcp4 = { enable = true; @@ -153,7 +153,7 @@ in { option-data = [ { name = "domain-name-servers"; - # TODO pihole self + # TODO pihole via self data = "1.1.1.1, 8.8.8.8"; } ]; @@ -161,10 +161,8 @@ in { { interface = "lan-self"; subnet = cidr.canonicalize net.lan.ipv4cidr; - # TODO calculate this automatically, start at 40 or so - # to have enough for reservations pools = [ - {pool = "192.168.100.20 - 192.168.100.250";} + {pool = "${cidr.host 20 net.lan.ipv4cidr} - ${cidr.host (-6) net.lan.ipv4cidr}";} ]; option-data = [ { @@ -172,13 +170,6 @@ in { data = cidr.ip net.lan.ipv4cidr; } ]; - # TODO reserve addresses for each VM - #reservations = [ - # { - # duid = "aa:bb:cc:dd:ee:ff"; - # ip-address = cidr.ip net.lan.ipv4cidr; - # } - #]; } ]; }; @@ -187,13 +178,9 @@ in { systemd.services.kea-dhcp4-server.after = ["sys-subsystem-net-devices-lan.device"]; - #extra.wireguard.vms = { - # server = { - # enable = true; - # host = "192.168.1.231"; - # port = 51822; - # openFirewall = true; - # }; - # addresses = ["10.0.0.1/24"]; - #}; + extra.microvms.networking = { + baseMac = nodeSecrets.networking.interfaces.lan.mac; + host = cidr.ip net.lan.ipv4cidr; + macvtapInterface = "lan"; + }; } diff --git a/modules/microvms.nix b/modules/microvms.nix index 661f711..7249548 100644 --- a/modules/microvms.nix +++ b/modules/microvms.nix @@ -25,11 +25,13 @@ mkMerge mkOption optional + optionalAttrs recursiveUpdate types ; cfg = config.extra.microvms; + inherit (config.extra.microvms) vms; # Configuration for each microvm microvmConfig = vmName: vmCfg: { @@ -61,8 +63,16 @@ inherit (vmCfg) system; config = nodePath + "/microvms/${vmName}"; }; + mac = config.lib.net.mac.addPrivate vmCfg.id cfg.networking.baseMac; in { - inherit (node) pkgs specialArgs; + # Allow children microvms to know which node is their parent + specialArgs = + { + parentNode = config; + parentNodeName = nodeName; + } + // node.specialArgs; + inherit (node) pkgs; inherit (vmCfg) autostart; config = { imports = [microvm.microvm] ++ node.imports; @@ -75,11 +85,11 @@ { type = "macvtap"; id = "vm-${vmName}"; + inherit mac; macvtap = { - link = vmCfg.macvtap; + link = cfg.macvtapInterface; mode = "bridge"; }; - inherit (vmCfg) mac; } ]; @@ -114,7 +124,7 @@ gc.automatic = mkForce false; }; - extra.networking.renameInterfacesByMac.${vmCfg.linkName} = vmCfg.mac; + extra.networking.renameInterfacesByMac.${vmCfg.linkName} = mac; systemd.network.networks = { "10-${vmCfg.linkName}" = { @@ -130,6 +140,46 @@ # TODO change once microvms are compatible with stage-1 systemd boot.initrd.systemd.enable = mkForce false; + + # Create a firewall zone for the bridged traffic and secure vm traffic + networking.nftables.firewall = { + zones = lib.mkForce { + "${vmCfg.linkName}".interfaces = [vmCfg.linkName]; + "local-vms".interfaces = ["wg-local-vms"]; + }; + + rules = lib.mkForce { + "${vmCfg.linkName}-to-local" = { + from = [vmCfg.linkName]; + to = ["local"]; + }; + + local-vms-to-local = { + from = ["wg-local-vms"]; + to = ["local"]; + }; + }; + }; + + extra.wireguard."local-vms" = { + # We have a resolvable hostname / static ip, so all peers can directly communicate with us + server = optionalAttrs (cfg.networking.host != null) { + inherit (vmCfg) host; + port = 51829; + openFirewallInRules = ["${vmCfg.linkName}-to-local"]; + }; + # We have no static hostname, so we must use a client-server architecture. + client = optionalAttrs (cfg.networking.host == null) { + via = nodeName; + keepalive = false; + }; + # TODO check error: addresses = ["10.22.22.2/30"]; + # TODO switch wg module to explicit v4 and v6 + addresses = [ + "${config.lib.net.cidr.host vmCfg.id cfg.networking.wireguard.netv4}/32" + "${config.lib.net.cidr.host vmCfg.id cfg.networking.wireguard.netv6}/128" + ]; + }; }; }; }; @@ -138,78 +188,151 @@ in { # Add the host module, but only enable if it necessary microvm.host # This is opt-out, so we can't put this into the mkIf below - {microvm.host.enable = cfg != {};} + {microvm.host.enable = vms != {};} ]; - options.extra.microvms = mkOption { - default = {}; - description = "Handles the necessary base setup for MicroVMs."; - type = types.attrsOf (types.submodule { - options = { - zfs = { - enable = mkEnableOption (mdDoc "Enable persistent data on separate zfs dataset"); + options.extra.microvms = { + networking = { + baseMac = mkOption { + type = config.lib.net.types.mac; + description = mdDoc '' + This MAC address will be used as a base address to derive all MicroVM MAC addresses from. + A good practise is to use the physical address of the macvtap interface. + ''; + }; - pool = mkOption { - type = types.str; - description = mdDoc "The host's zfs pool on which the dataset resides"; - }; + host = mkOption { + type = types.str; + description = mdDoc '' + The host as which this machine can be reached from other participants of the bridged macvtap network. + This can either be a resolvable hostname or an IP address. + ''; + }; - dataset = mkOption { - type = types.str; - description = mdDoc "The host's dataset that should be used for this vm's state (will automatically be created, parent dataset must exist)"; - }; + macvtapInterface = mkOption { + type = types.str; + description = mdDoc "The macvtap interface to which MicroVMs should be attached"; + }; - mountpoint = mkOption { - type = types.str; - description = mdDoc "The host's mountpoint for the vm's dataset (will be shared via virtofs as /persist in the vm)"; - }; + wireguard = { + netv4 = mkOption { + type = config.lib.net.types.cidrv4; + description = mdDoc "The ipv4 network address range to use for internal vm traffic."; + default = "172.31.0.0/24"; }; - autostart = mkOption { - type = types.bool; - default = false; - description = mdDoc "Whether this VM should be started automatically with the host"; - }; - - linkName = mkOption { - type = types.str; - default = "wan"; - description = mdDoc "The main ethernet link name inside of the VM"; - }; - - mac = mkOption { - type = config.lib.net.types.mac; - description = mdDoc "The MAC address to assign to this VM"; - }; - - macvtap = mkOption { - type = types.str; - description = mdDoc "The macvtap interface to attach to"; - }; - - system = mkOption { - type = types.str; - description = mdDoc "The system that this microvm should use"; + netv6 = mkOption { + type = config.lib.net.types.cidrv6; + description = mdDoc "The ipv6 network address range to use for internal vm traffic."; + default = "fddd::/64"; }; }; - }); + # TODO check plus no overflow + }; + + vms = mkOption { + default = {}; + description = "Defines the actual vms and handles the necessary base setup for them."; + type = types.attrsOf (types.submodule { + options = { + id = mkOption { + type = + types.addCheck types.int (x: x > 1) + // { + name = "positiveInt1"; + description = "positive integer greater than 1"; + }; + description = mdDoc '' + A unique id for this VM. It will be used to derive a MAC address from the host's + base MAC, and may be used as a stable id by your MicroVM config if necessary. + + Ids don't need to be contiguous. It is recommended to use small numbers here to not + overflow any offset calculations. Consider that this is used for example to determine a + static ip-address by means of (baseIp + vm.id) for a wireguard network. That's also + why id 1 is reserved for the host. While this is usually checked to be in-range, + it might still be a good idea to assign greater ids with care. + ''; + }; + + zfs = { + enable = mkEnableOption (mdDoc "Enable persistent data on separate zfs dataset"); + + pool = mkOption { + type = types.str; + description = mdDoc "The host's zfs pool on which the dataset resides"; + }; + + dataset = mkOption { + type = types.str; + description = mdDoc "The host's dataset that should be used for this vm's state (will automatically be created, parent dataset must exist)"; + }; + + mountpoint = mkOption { + type = types.str; + description = mdDoc "The host's mountpoint for the vm's dataset (will be shared via virtofs as /persist in the vm)"; + }; + }; + + autostart = mkOption { + type = types.bool; + default = false; + description = mdDoc "Whether this VM should be started automatically with the host"; + }; + + # TODO allow configuring static ipv4 and ipv6 instead of dhcp? + # maybe create networking. namespace and have options = dhcpwithRA and static. + + linkName = mkOption { + type = types.str; + default = "wan"; + description = mdDoc "The main ethernet link name inside of the VM"; + }; + + host = mkOption { + type = types.nullOr types.str; + default = null; + description = mdDoc '' + The host as which this VM can be reached from other participants of the bridged macvtap network. + If this is unset, the wireguard connection will use a client-server architecture with the host as the server. + Otherwise, all clients will communicate directly, meaning the host cannot listen to traffic. + + This can either be a resolvable hostname or an IP address. + ''; + }; + + system = mkOption { + type = types.str; + description = mdDoc "The system that this microvm should use"; + }; + }; + }); + }; }; - config = mkIf (cfg != {}) ( + config = mkIf (vms != {}) ( { assertions = let - duplicateMacs = extraLib.duplicates (mapAttrsToList (_: vmCfg: vmCfg.mac) cfg); + duplicateIds = extraLib.duplicates (mapAttrsToList (_: vmCfg: toString vmCfg.id) vms); in [ { - assertion = duplicateMacs == []; - message = "Duplicate MicroVM MAC addresses: ${concatStringsSep ", " duplicateMacs}"; + assertion = duplicateIds == []; + message = "Duplicate MicroVM ids: ${concatStringsSep ", " duplicateIds}"; } ]; + + # Define a local wireguard server to communicate with vms securely + extra.wireguard."local-vms" = { + server = { + inherit (cfg.networking) host; + port = 51829; + openFirewallInRules = ["lan-to-local"]; + }; + addresses = [ + (config.lib.net.cidr.hostCidr 1 cfg.networking.wireguard.netv4) + (config.lib.net.cidr.hostCidr 1 cfg.networking.wireguard.netv6) + ]; + }; } - // lib.genAttrs ["disko" "microvm" "systemd"] - (attr: - mkMerge (map - (c: c.${attr}) - (mapAttrsToList microvmConfig cfg))) + // extraLib.mergeToplevelConfigs ["disko" "microvm" "systemd"] (mapAttrsToList microvmConfig vms) ); } diff --git a/modules/wireguard.nix b/modules/wireguard.nix index bd9c5c2..027c5b1 100644 --- a/modules/wireguard.nix +++ b/modules/wireguard.nix @@ -17,14 +17,13 @@ concatStringsSep filter filterAttrs + genAttrs head mapAttrsToList mdDoc mergeAttrs mkIf mkOption - mkEnableOption - net optionalAttrs optionals splitString @@ -35,57 +34,102 @@ (extraLib) concatAttrs duplicates + mergeToplevelConfigs ; + inherit (config.lib) net; cfg = config.extra.wireguard; - # TODO use netlib types!!!!!! - # TODO use netlib types!!!!!! - # TODO use netlib types!!!!!! - # TODO use netlib types!!!!!! - # TODO use netlib types!!!!!! - # TODO use netlib types!!!!!! - # TODO use netlib types!!!!!! - # TODO use netlib types!!!!!! configForNetwork = wgName: wgCfg: let inherit (extraLib.wireguard wgName) + associatedNodes associatedServerNodes associatedClientNodes externalPeerName + externalPeerNamesRaw peerPresharedKeyPath peerPresharedKeySecret peerPrivateKeyPath peerPrivateKeySecret peerPublicKeyPath + usedAddresses ; + isServer = wgCfg.server.host != null; + isClient = wgCfg.client.via != null; + filterSelf = filter (x: x != nodeName); wgCfgOf = node: nodes.${node}.config.extra.wireguard.${wgName}; + # All nodes that use our node as the via into the wireguard network ourClientNodes = - optionals wgCfg.server.enable - (filter (n: (wgCfgOf n).via == nodeName) associatedClientNodes); + optionals isServer + (filter (n: (wgCfgOf n).client.via == nodeName) associatedClientNodes); - # The list of peers that we have to know the psk to. + # The list of peers for which we have to know the psk. neededPeers = - if wgCfg.server.enable + if isServer then + # Other servers in the same network filterSelf associatedServerNodes + # Our external peers ++ map externalPeerName (attrNames wgCfg.server.externalPeers) + # Our clients ++ ourClientNodes - else [wgCfg.via]; - in { - secrets = - concatAttrs (map (other: { - ${peerPresharedKeySecret nodeName other}.file = peerPresharedKeyPath nodeName other; - }) - neededPeers) - // { - ${peerPrivateKeySecret nodeName}.file = peerPrivateKeyPath nodeName; - }; + else [wgCfg.client.via]; - netdevs."${wgCfg.priority}-${wgName}" = { + # Figure out if there are duplicate peers or addresses so we can + # make an assertion later. + duplicatePeers = duplicates externalPeerNamesRaw; + duplicateAddrs = duplicates (map (x: head (splitString "/" x)) usedAddresses); + + # Adds context information to the assertions for this network + assertionPrefix = "Wireguard network '${wgName}' on '${nodeName}'"; + in { + assertions = [ + { + assertion = any (n: (wgCfgOf n).server.host != null) associatedNodes; + message = "${assertionPrefix}: At least one node in a network must be a server."; + } + { + assertion = duplicatePeers == []; + message = "${assertionPrefix}: Multiple definitions for external peer(s):${concatMapStrings (x: " '${x}'") duplicatePeers}"; + } + { + assertion = duplicateAddrs == []; + message = "${assertionPrefix}: Addresses used multiple times: ${concatStringsSep ", " duplicateAddrs}"; + } + { + assertion = isServer != isClient; + message = "${assertionPrefix}: A node must either be a server (define server.host) or a client (define client.via)."; + } + { + assertion = isClient -> ((wgCfgOf wgCfg.client.via).server.host != null); + message = "${assertionPrefix}: The specified via node '${wgCfg.client.via}' must be a wireguard server."; + } + # TODO externalPeers != {} -> ip forwarding + # TODO no overlapping cidrs in (external peers + peers using via = this). + # TODO no overlapping cidrs between server nodes + ]; + + networking.firewall.allowedUDPPorts = + mkIf + (isServer && wgCfg.server.openFirewall) + [wgCfg.server.port]; + + networking.nftables.firewall.rules = + mkIf + (isServer && wgCfg.server.openFirewallInRules != []) + (genAttrs wgCfg.server.openFirewallInRules (_: {allowedUDPPorts = [wgCfg.server.port];})); + + rekey.secrets = + concatAttrs (map + (other: {${peerPresharedKeySecret nodeName other}.file = peerPresharedKeyPath nodeName other;}) + neededPeers) + // {${peerPrivateKeySecret nodeName}.file = peerPrivateKeyPath nodeName;}; + + systemd.network.netdevs."${toString wgCfg.priority}-${wgName}" = { netdevConfig = { Kind = "wireguard"; Name = "${wgName}"; @@ -95,17 +139,17 @@ { PrivateKeyFile = config.rekey.secrets.${peerPrivateKeySecret nodeName}.path; } - // optionalAttrs wgCfg.server.enable { + // optionalAttrs isServer { ListenPort = wgCfg.server.port; }; wireguardPeers = - if wgCfg.server.enable + if isServer then # Always include all other server nodes. - map (serverNode: let - snCfg = wgCfgOf serverNode; - in { - wireguardPeerConfig = { + map (serverNode: { + wireguardPeerConfig = let + snCfg = wgCfgOf serverNode; + in { PublicKey = builtins.readFile (peerPublicKeyPath serverNode); PresharedKeyFile = config.rekey.secrets.${peerPresharedKeySecret nodeName serverNode}.path; # The allowed ips of a server node are it's own addreses, @@ -117,7 +161,8 @@ # ++ map (n: (wgCfgOf n).addresses) snCfg.ourClientNodes; Endpoint = "${snCfg.server.host}:${toString snCfg.server.port}"; }; - }) (filterSelf associatedServerNodes) + }) + (filterSelf associatedServerNodes) # All our external peers ++ mapAttrsToList (extPeer: allowedIPs: let peerName = externalPeerName extPeer; @@ -126,18 +171,24 @@ PublicKey = builtins.readFile (peerPublicKeyPath peerName); PresharedKeyFile = config.rekey.secrets.${peerPresharedKeySecret nodeName peerName}.path; AllowedIPs = allowedIPs; + # Connections to external peers should always be kept alive PersistentKeepalive = 25; }; }) wgCfg.server.externalPeers # All client nodes that have their via set to us. - ++ mapAttrsToList (clientNode: { - wireguardPeerConfig = { - PublicKey = builtins.readFile (peerPublicKeyPath clientNode); - PresharedKeyFile = config.rekey.secrets.${peerPresharedKeySecret nodeName clientNode}.path; - AllowedIPs = (wgCfgOf clientNode).addresses; - PersistentKeepalive = 25; - }; + ++ mapAttrsToList (clientNode: let + clientCfg = wgCfgOf clientNode; + in { + wireguardPeerConfig = + { + PublicKey = builtins.readFile (peerPublicKeyPath clientNode); + PresharedKeyFile = config.rekey.secrets.${peerPresharedKeySecret nodeName clientNode}.path; + AllowedIPs = clientCfg.addresses; + } + // optionalAttrs clientCfg.keepalive { + PersistentKeepalive = 25; + }; }) ourClientNodes else @@ -145,15 +196,16 @@ [ { wireguardPeerConfig = { - PublicKey = builtins.readFile (peerPublicKeyPath wgCfg.via); - PresharedKeyFile = config.rekey.secrets.${peerPresharedKeySecret nodeName wgCfg.via}.path; - AllowedIPs = (wgCfgOf wgCfg.via).addresses; + PublicKey = builtins.readFile (peerPublicKeyPath wgCfg.client.via); + PresharedKeyFile = config.rekey.secrets.${peerPresharedKeySecret nodeName wgCfg.client.via}.path; + # TODO this should be 0.0.0.0 if the client wants to route all traffic + AllowedIPs = (wgCfgOf wgCfg.client.via).addresses; }; } ]; }; - networks."${wgCfg.priority}-${wgName}" = { + systemd.network.networks."${toString wgCfg.priority}-${wgName}" = { matchConfig.Name = wgName; networkConfig.Address = wgCfg.addresses; }; @@ -162,14 +214,17 @@ in { options.extra.wireguard = mkOption { default = {}; description = "Configures wireguard networks via systemd-networkd."; - type = types.attrsOf (types.submodule { + type = types.lazyAttrsOf (types.submodule ({ + config, + name, + ... + }: { options = { server = { - enable = mkEnableOption (mdDoc "wireguard server"); - host = mkOption { - type = types.str; - description = mdDoc "The hostname or ip address which other peers can use to reach this host."; + default = null; + type = types.nullOr types.str; + description = mdDoc "The hostname or ip address which other peers can use to reach this host. No server funnctionality will be activated if set to null."; }; port = mkOption { @@ -181,11 +236,17 @@ in { openFirewall = mkOption { default = false; type = types.bool; - description = mdDoc "Whether to open the firewall for the specified `listenPort`, if {option}`listen` is `true`."; + description = mdDoc "Whether to open the firewall for the specified {option}`port`."; + }; + + openFirewallInRules = mkOption { + default = []; + type = types.listOf types.str; + description = mdDoc "The {option}`port` will be opened for all of the given rules in the nftable-firewall."; }; externalPeers = mkOption { - type = types.attrsOf (types.listOf types.str); + type = types.attrsOf (types.listOf (net.types.cidr-in config.addresses)); default = {}; example = {my-android-phone = ["10.0.0.97/32"];}; description = mdDoc '' @@ -199,82 +260,55 @@ in { }; }; + client = { + via = mkOption { + default = null; + type = types.nullOr types.str; + description = mdDoc '' + The server node via which to connect to the network. + No client functionality will be activated if set to null. + ''; + }; + + keepalive = mkOption { + default = true; + type = types.bool; + description = mdDoc "Whether to keep this connection alive using PersistentKeepalive. Set to false only for networks where client and server IPs are stable."; + }; + + # TODO one option for allowing it, but also one to allow defining two + # profiles / interfaces that can be activated manually. + #routeAllTraffic = mkOption { + # default = false; + # type = types.bool; + # description = mdDoc '' + # Whether to allow routing all traffic through the via server. + # ''; + #}; + }; + priority = mkOption { - default = "20"; - type = types.str; + default = 40; + type = types.int; description = mdDoc "The order priority used when creating systemd netdev and network files."; }; - via = mkOption { - default = null; - type = types.uniq (types.nullOr types.str); - description = mdDoc '' - The server node via which to connect to the network. - This must defined if and only if this node is not a server. - ''; - }; - addresses = mkOption { - type = types.listOf types.str; + type = types.listOf ( + if config.client.via != null + then net.types.cidr-in nodes.${config.client.via}.config.extra.wireguard.${name}.addresses + else net.types.cidr + ); description = mdDoc '' The addresses to configure for this interface. Will automatically be added - as this peer's allowed addresses to all other peers. + as this peer's allowed addresses on all other peers. ''; }; }; - }); + })); }; - config = mkIf (cfg != {}) (let - networkCfgs = mapAttrsToList configForNetwork cfg; - collectAllNetworkAttrs = x: concatAttrs (map (y: y.${x}) networkCfgs); - in { - assertions = concatMap (wgName: let - inherit - (extraLib.wireguard wgName) - externalPeerNamesRaw - usedAddresses - associatedNodes - ; - - wgCfg = cfg.${wgName}; - wgCfgOf = node: nodes.${node}.config.extra.wireguard.${wgName}; - duplicatePeers = duplicates externalPeerNamesRaw; - duplicateAddrs = duplicates (map (x: head (splitString "/" x)) usedAddresses); - in [ - { - assertion = any (n: nodes.${n}.config.extra.wireguard.${wgName}.server.enable) associatedNodes; - message = "Wireguard network '${wgName}': At least one node must be a server."; - } - { - assertion = duplicatePeers == []; - message = "Wireguard network '${wgName}': Multiple definitions for external peer(s):${concatMapStrings (x: " '${x}'") duplicatePeers}"; - } - { - assertion = duplicateAddrs == []; - message = "Wireguard network '${wgName}': Addresses used multiple times: ${concatStringsSep ", " duplicateAddrs}"; - } - { - assertion = wgCfg.server.externalPeers != {} -> wgCfg.server.enable; - message = "Wireguard network '${wgName}': Defining external peers requires server.enable = true."; - } - { - assertion = wgCfg.server.enable == (wgCfg.via == null); - message = "Wireguard network '${wgName}': A via server must be defined exactly iff this isn't a server node."; - } - { - assertion = wgCfg.via != null -> (wgCfgOf wgCfg.via).server.enable; - message = "Wireguard network '${wgName}': The specified via node '${wgCfg.via}' must be a wireguard server."; - } - # TODO externalPeers != {} -> ip forwarding - # TODO no overlapping allowed ip range? 0.0.0.0 would be ok to overlap though - ]) (attrNames cfg); - - networking.firewall.allowedUDPPorts = mkIf (cfg.server.enable && cfg.server.openFirewall) [cfg.server.port]; - rekey.secrets = collectAllNetworkAttrs "secrets"; - systemd.network = { - netdevs = collectAllNetworkAttrs "netdevs"; - networks = collectAllNetworkAttrs "networks"; - }; - }); + config = mkIf (cfg != {}) (mergeToplevelConfigs + ["assertions" "rekey" "networking" "systemd"] + (mapAttrsToList configForNetwork cfg)); } diff --git a/nix/extra-builtins.nix b/nix/extra-builtins.nix index b13d451..3bc9c26 100644 --- a/nix/extra-builtins.nix +++ b/nix/extra-builtins.nix @@ -10,7 +10,7 @@ # plugin-files = ${pkgs.nix-plugins}/lib/nix/plugins # # Please adjust path accordingly, or leave this out and alternativaly # # pass `--option extra-builtins-file ./extra-builtins.nix` to each invocation -# extra-builtins-file = ./extra-builtins.nix +# extra-builtins-file = ${./extra-builtins.nix} # ''; # } # ``` diff --git a/nix/lib.nix b/nix/lib.nix index 04ad58d..f01d0b8 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -19,6 +19,7 @@ head mapAttrs' mergeAttrs + mkMerge nameValuePair optionalAttrs partition @@ -50,6 +51,11 @@ in rec { # True if the path or string starts with / isAbsolutePath = x: substring 0 1 x == "/"; + # Merges all given attributes from the given attrsets using mkMerge. + # Useful to merge several top-level configs in a module. + mergeToplevelConfigs = keys: attrs: + genAttrs keys (attr: mkMerge (map (x: x.${attr} or {}) attrs)); + disko = { gpt = { partEfi = name: start: end: { @@ -85,7 +91,7 @@ in rec { }; }; zfs = { - encryptedZpool = { + defaultZpoolOptions = { type = "zpool"; mountRoot = "/mnt"; rootFsOptions = { @@ -164,7 +170,7 @@ in rec { # Partition nodes by whether they are servers _associatedNodes_isServerPartition = partition - (n: self.nodes.${n}.config.extra.wireguard.${wgName}.server.enable) + (n: self.nodes.${n}.config.extra.wireguard.${wgName}.server.host != null) associatedNodes; associatedServerNodes = _associatedNodes_isServerPartition.right;