From 4057ee9051ec5902c9ea21033dc07a99774d622d Mon Sep 17 00:00:00 2001 From: oddlama Date: Sat, 20 May 2023 15:57:19 +0200 Subject: [PATCH] feat: implement cidr coersion to automatically determine wireguard network size from participants --- flake.lock | 6 +- hosts/common/core/net.nix | 1 + hosts/common/core/system.nix | 139 ++++++++++++++++++ hosts/ward/microvms/test/secrets/host.pub | 1 + hosts/ward/net.nix | 2 + hosts/zackbiene/net.nix | 1 + modules/microvms.nix | 28 +++- modules/wireguard.nix | 39 +++-- nix/lib.nix | 22 ++- .../ward-local-vms/keys/ward-microvm-test.age | 9 ++ .../ward-local-vms/keys/ward-microvm-test.pub | 1 + .../wireguard/ward-local-vms/keys/ward.age | 9 ++ .../wireguard/ward-local-vms/keys/ward.pub | 1 + .../psks/ward+ward-microvm-test.age | 10 ++ 14 files changed, 240 insertions(+), 29 deletions(-) create mode 100644 hosts/ward/microvms/test/secrets/host.pub create mode 100644 secrets/wireguard/ward-local-vms/keys/ward-microvm-test.age create mode 100644 secrets/wireguard/ward-local-vms/keys/ward-microvm-test.pub create mode 100644 secrets/wireguard/ward-local-vms/keys/ward.age create mode 100644 secrets/wireguard/ward-local-vms/keys/ward.pub create mode 100644 secrets/wireguard/ward-local-vms/psks/ward+ward-microvm-test.age diff --git a/flake.lock b/flake.lock index fd9da52..e910e2c 100644 --- a/flake.lock +++ b/flake.lock @@ -31,11 +31,11 @@ ] }, "locked": { - "lastModified": 1683715679, - "narHash": "sha256-Zq2liHoVTNYql94XPTpEInQq5yY0NjRa9ZLYJv55dgE=", + "lastModified": 1684539260, + "narHash": "sha256-lF3+vp2UZwBjzF4pnOKYZrQOCFdnOdtvGmaFIzsaMN4=", "owner": "oddlama", "repo": "agenix-rekey", - "rev": "e5e84230bfa071685a05acdc11a94e3be672e541", + "rev": "e9a2bad33b7b1634af65cbc809fc31776df41fe5", "type": "github" }, "original": { diff --git a/hosts/common/core/net.nix b/hosts/common/core/net.nix index a65fe79..09f3009 100644 --- a/hosts/common/core/net.nix +++ b/hosts/common/core/net.nix @@ -45,6 +45,7 @@ in { ''; }; + # TODO mkForce nftables nftables.firewall = { zones = lib.mkForce { local.localZone = true; diff --git a/hosts/common/core/system.nix b/hosts/common/core/system.nix index e5be4be..5901f36 100644 --- a/hosts/common/core/system.nix +++ b/hosts/common/core/system.nix @@ -14,14 +14,142 @@ lib.recursiveUpdate libWithNet { net = { cidr = rec { + # host :: (ip | mac | integer) -> cidr -> ip + # + # Wrapper that extends the original host function to + # check whether the argument `n` is in-range for the given cidr. + # + # Examples: + # + # > net.cidr.host 255 "192.168.1.0/24" + # "192.168.1.255" + # > net.cidr.host (256) "192.168.1.0/24" + # + # > net.cidr.host (-1) "192.168.1.0/24" + # "192.168.1.255" + # > net.cidr.host (-256) "192.168.1.0/24" + # "192.168.1.0" + # > net.cidr.host (-257) "192.168.1.0/24" + # 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 :: (ip | mac | integer) -> cidr -> cidr + # + # Returns the nth host in the given cidr range (like cidr.host) + # but as a cidr that retains the original prefix length. + # + # Examples: + # + # > net.cidr.hostCidr 2 "192.168.1.0/24" + # "192.168.1.2/24" hostCidr = n: x: "${libWithNet.net.cidr.host n x}/${toString (libWithNet.net.cidr.length x)}"; + # ip :: (cidr | ip) -> ip + # + # Returns just the ip part of the cidr. + # + # Examples: + # + # > net.cidr.ip "192.168.1.100/24" + # "192.168.1.100" + # > net.cidr.ip "192.168.1.100" + # "192.168.1.100" ip = x: lib.head (lib.splitString "/" x); + # canonicalize :: cidr -> cidr + # + # Replaces the ip of the cidr with the canonical network address + # (first contained address in range) + # + # Examples: + # + # > net.cidr.canonicalize "192.168.1.100/24" + # "192.168.1.0/24" canonicalize = x: libWithNet.net.cidr.make (libWithNet.net.cidr.length x) (ip x); + # coercev4 :: [cidr4] -> (cidr4 | null) + # + # Returns the smallest cidr network that includes all given addresses + # + # Examples: + # + # > net.cidr.coercev4 ["192.168.1.1/24" "192.168.6.1/32"] + # "192.168.0.0/21" + coercev4 = addrs: let + # The smallest occurring length is the first we need to start checking, since + # any greater cidr length represents a smaller address range which + # wouldn't contain all of the original addresses. + startLength = lib.foldl' lib.min 32 (map libWithNet.net.cidr.length addrs); + possibleLengths = lib.reverseList (lib.range 0 startLength); + # The first ip address will be "expanded" in cidr length until it covers all other + # used addresses. + firstIp = ip (lib.head addrs); + # Return the first (i.e. greatest length -> smallest prefix) cidr length + # in the list that covers all used addresses + bestLength = lib.head (lib.filter + # All given addresses must be contained by the generated address. + (len: + lib.all + (x: + libWithNet.net.cidr.contains + (ip x) + (libWithNet.net.cidr.make len firstIp)) + addrs) + possibleLengths); + in + assert lib.assertMsg (!lib.any (lib.hasInfix ":") addrs) "coercev4 cannot operate on ipv6 addresses"; + if addrs == [] + then null + else libWithNet.net.cidr.make bestLength firstIp; + # coercev6 :: [cidr6] -> (cidr6 | null) + # + # Returns the smallest cidr network that includes all given addresses + # + # Examples: + # + # > net.cidr.coercev6 ["fd00:dead:cafe::/64" "fd00:fd12:3456:7890::/56"] + # "fd00:c000::/18" + coercev6 = addrs: let + # The smallest occurring length is the first we need to start checking, since + # any greater cidr length represents a smaller address range which + # wouldn't contain all of the original addresses. + startLength = lib.foldl' lib.min 128 (map libWithNet.net.cidr.length addrs); + possibleLengths = lib.reverseList (lib.range 0 startLength); + # The first ip address will be "expanded" in cidr length until it covers all other + # used addresses. + firstIp = ip (lib.head addrs); + # Return the first (i.e. greatest length -> smallest prefix) cidr length + # in the list that covers all used addresses + bestLength = lib.head (lib.filter + # All given addresses must be contained by the generated address. + (len: + lib.all + (x: + libWithNet.net.cidr.contains + (ip x) + (libWithNet.net.cidr.make len firstIp)) + addrs) + possibleLengths); + in + assert lib.assertMsg (lib.all (lib.hasInfix ":") addrs) "coercev6 cannot operate on ipv4 addresses"; + if addrs == [] + then null + else libWithNet.net.cidr.make bestLength firstIp; + # coerce :: [cidr] -> { cidrv4 = (cidr4 | null); cidrv6 = (cidr4 | null); } + # + # Returns the smallest cidr network that includes all given addresses, + # but yields two separate result for all given ipv4 and ipv6 addresses. + # Equivalent to calling coercev4 and coercev6 on a partition individually. + coerce = addrs: let + v4_and_v6 = lib.partition (lib.hasInfix ":") addrs; + in { + cidrv4 = coercev4 v4_and_v6.wrong; + cidrv6 = coercev6 v4_and_v6.right; + }; + }; + ip = { + # Checks whether the given address (with or without cidr notation) is an ipv6 address. + isv6 = lib.hasInfix ":"; }; mac = { # Adds offset to the given base address and ensures the result is in @@ -55,11 +183,22 @@ boot = { initrd.systemd.enable = true; + # Add "rd.systemd.unit=rescue.target" to debug initrd kernelParams = ["log_buf_len=10M"]; tmp.useTmpfs = true; }; + # Just before switching, remove the agenix directory if it exists. + # This can happen when a secret is used in the initrd because it will + # then be copied to the initramfs under the same path. This materializes + # /run/agenix as a directory which will cause issues when the actual system tries + # to create a link called /run/agenix. Agenix should probably fail in this case, + # but doesn't and instead puts the generation link into the existing directory. + # TODO See https://github.com/ryantm/agenix/pull/187. + system.activationScripts.removeAgenixLink.text = "[[ -d /run/agenix ]] && rm -rf /run/agenix"; + system.activationScripts.agenixInstall.deps = ["removeAgenixLink"]; + # Disable sudo which is entierly unnecessary. security.sudo.enable = false; diff --git a/hosts/ward/microvms/test/secrets/host.pub b/hosts/ward/microvms/test/secrets/host.pub new file mode 100644 index 0000000..e8bb16b --- /dev/null +++ b/hosts/ward/microvms/test/secrets/host.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBXXjI6uB26xOF0DPy/QyLladoGIKfAtofyqPgIkCH/g diff --git a/hosts/ward/net.nix b/hosts/ward/net.nix index fc83658..82f5fdc 100644 --- a/hosts/ward/net.nix +++ b/hosts/ward/net.nix @@ -93,6 +93,7 @@ in { }; }; + # TODO mkForce nftables networking.nftables.firewall = { zones = lib.mkForce { lan.interfaces = ["lan-self"]; @@ -188,5 +189,6 @@ in { baseCidrv4 = lanCidrv4; baseCidrv6 = lanCidrv6; }; + wireguard.openFirewallRules = ["lan-to-local"]; }; } diff --git a/hosts/zackbiene/net.nix b/hosts/zackbiene/net.nix index 627a75c..e4113a7 100644 --- a/hosts/zackbiene/net.nix +++ b/hosts/zackbiene/net.nix @@ -30,6 +30,7 @@ in { }; }; + # TODO mkForce nftables networking.nftables.firewall = { zones = lib.mkForce { lan.interfaces = ["lan1"]; diff --git a/modules/microvms.nix b/modules/microvms.nix index b0de77b..c414591 100644 --- a/modules/microvms.nix +++ b/modules/microvms.nix @@ -142,8 +142,8 @@ static = { matchConfig.Name = vmCfg.networking.mainLinkName; address = [ - vmCfg.networking.static.ipv4 - vmCfg.networking.static.ipv6 + "${vmCfg.networking.static.ipv4}/${toString (net.cidr.length cfg.networking.static.baseCidrv4)}" + "${vmCfg.networking.static.ipv6}/${toString (net.cidr.length cfg.networking.static.baseCidrv6)}" ]; gateway = [ cfg.networking.host @@ -161,13 +161,14 @@ boot.initrd.systemd.enable = mkForce false; # Create a firewall zone for the bridged traffic and secure vm traffic + # TODO mkForce nftables networking.nftables.firewall = { - zones = lib.mkForce { + zones = mkForce { "${vmCfg.networking.mainLinkName}".interfaces = [vmCfg.networking.mainLinkName]; "local-vms".interfaces = ["wg-local-vms"]; }; - rules = lib.mkForce { + rules = mkForce { "${vmCfg.networking.mainLinkName}-to-local" = { from = [vmCfg.networking.mainLinkName]; to = ["local"]; @@ -184,8 +185,8 @@ # We have a resolvable hostname / static ip, so all peers can directly communicate with us server = optionalAttrs (cfg.networking.host != null) { inherit (vmCfg.networking) host; - port = 51829; - openFirewallInRules = ["${vmCfg.networking.mainLinkName}-to-local"]; + inherit (cfg.networking.wireguard) port; + openFirewallRules = ["${vmCfg.networking.mainLinkName}-to-local"]; }; # If We don't have such guarantees, so we must use a client-server architecture. client = optionalAttrs (cfg.networking.host == null) { @@ -262,6 +263,18 @@ in { description = mdDoc "The ipv6 network address range to use for internal vm traffic."; default = "fddd::/64"; }; + + port = mkOption { + default = 51829; + type = types.port; + description = mdDoc "The port to listen on."; + }; + + openFirewallRules = 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."; + }; }; }; @@ -387,8 +400,7 @@ in { extra.wireguard."${nodeName}-local-vms" = { server = { inherit (cfg.networking) host; - port = 51829; - openFirewallInRules = ["lan-to-local"]; + inherit (cfg.networking.wireguard) openFirewallRules port; }; cidrv4 = net.cidr.hostCidr 1 cfg.networking.wireguard.cidrv4; cidrv6 = net.cidr.hostCidr 1 cfg.networking.wireguard.cidrv6; diff --git a/modules/wireguard.nix b/modules/wireguard.nix index c528abf..0c4ecf8 100644 --- a/modules/wireguard.nix +++ b/modules/wireguard.nix @@ -26,7 +26,6 @@ mkOption optionalAttrs optionals - splitString types ; @@ -54,6 +53,7 @@ peerPrivateKeySecret peerPublicKeyPath usedAddresses + toNetworkAddr ; isServer = wgCfg.server.host != null; @@ -82,7 +82,7 @@ # 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); + duplicateAddrs = duplicates (map net.cidr.ip usedAddresses); # Adds context information to the assertions for this network assertionPrefix = "Wireguard network '${wgName}' on '${nodeName}'"; @@ -118,16 +118,27 @@ (isServer && wgCfg.server.openFirewall) [wgCfg.server.port]; + # TODO mkForce nftables networking.nftables.firewall.rules = mkIf - (isServer && wgCfg.server.openFirewallInRules != []) - (genAttrs wgCfg.server.openFirewallInRules (_: {allowedUDPPorts = [wgCfg.server.port];})); + (isServer && wgCfg.server.openFirewallRules != []) + (lib.mkForce (genAttrs wgCfg.server.openFirewallRules (_: {allowedUDPPorts = [wgCfg.server.port];}))); rekey.secrets = concatAttrs (map - (other: {${peerPresharedKeySecret nodeName other}.file = peerPresharedKeyPath nodeName other;}) + (other: { + ${peerPresharedKeySecret nodeName other} = { + file = peerPresharedKeyPath nodeName other; + owner = "systemd-network"; + }; + }) neededPeers) - // {${peerPrivateKeySecret nodeName}.file = peerPrivateKeyPath nodeName;}; + // { + ${peerPrivateKeySecret nodeName} = { + file = peerPrivateKeyPath nodeName; + owner = "systemd-network"; + }; + }; systemd.network.netdevs."${toString wgCfg.priority}-${wgName}" = { netdevConfig = { @@ -156,21 +167,18 @@ # plus each external peer's addresses, # plus each client's addresses that is connected via that node. AllowedIPs = snCfg.addresses; - # TODO this needed? or even wanted at all? - # ++ attrValues snCfg.server.externalPeers; - # ++ map (n: (wgCfgOf n).addresses) snCfg.ourClientNodes; Endpoint = "${snCfg.server.host}:${toString snCfg.server.port}"; }; }) (filterSelf associatedServerNodes) # All our external peers - ++ mapAttrsToList (extPeer: allowedIPs: let + ++ mapAttrsToList (extPeer: ips: let peerName = externalPeerName extPeer; in { wireguardPeerConfig = { PublicKey = builtins.readFile (peerPublicKeyPath peerName); PresharedKeyFile = config.rekey.secrets.${peerPresharedKeySecret nodeName peerName}.path; - AllowedIPs = allowedIPs; + AllowedIPs = map (net.cidr.make 128) ips; # Connections to external peers should always be kept alive PersistentKeepalive = 25; }; @@ -207,7 +215,7 @@ systemd.network.networks."${toString wgCfg.priority}-${wgName}" = { matchConfig.Name = wgName; - networkConfig.Address = wgCfg.addresses; + address = map toNetworkAddr wgCfg.addresses; }; }; in { @@ -239,16 +247,16 @@ in { description = mdDoc "Whether to open the firewall for the specified {option}`port`."; }; - openFirewallInRules = mkOption { + openFirewallRules = 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 (net.types.cidr-in config.addresses)); + type = types.attrsOf (types.listOf (net.types.ip-in config.addresses)); default = {}; - example = {my-android-phone = ["10.0.0.97/32"];}; + example = {my-android-phone = ["10.0.0.97"];}; description = mdDoc '' Allows defining an extra set of peers that should be added to this wireguard network, but will not be managed by this flake. (e.g. phones) @@ -329,6 +337,7 @@ in { description = mdDoc '' The addresses (with cidr mask) to configure for this interface. The cidr mask determines this peers allowed address range as configured on other peers. + The actual network cidr will automatically be derived from all network participants. By default this will just include {option}`cidrv4` and {option}`cidrv6` as configured. ''; }; diff --git a/nix/lib.nix b/nix/lib.nix index f01d0b8..1b2f0c4 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -25,7 +25,6 @@ partition recursiveUpdate removeSuffix - splitString substring unique ; @@ -135,6 +134,10 @@ in rec { # Wireguard related functions that are reused in several files of this flake wireguard = wgName: rec { + # Get access to the networking lib by referring to one of the associated nodes. + # Not ideal, but ok. + inherit (self.nodes.${head associatedNodes}.config.lib) net; + sortedPeers = peerA: peerB: if peerA < peerB then { @@ -199,7 +202,19 @@ in rec { # A list of all occurring addresses. usedAddresses = concatMap (n: self.nodes.${n}.config.extra.wireguard.${wgName}.addresses) associatedNodes - ++ flatten (concatMap (n: attrValues self.nodes.${n}.config.extra.wireguard.${wgName}.server.externalPeers) associatedNodes); + ++ flatten (concatMap (n: map (net.cidr.make 128) (attrValues self.nodes.${n}.config.extra.wireguard.${wgName}.server.externalPeers)) associatedNodes); + + # The cidrv4 and cidrv6 of the network spanned by all participating peer addresses. + networkAddresses = net.cidr.coerce usedAddresses; + + # Appends / replaces the correct cidr length to the argument, + # so that the resulting address is in the cidr. + toNetworkAddr = addr: let + relevantNetworkAddr = + if net.ip.isv6 addr + then networkAddresses.cidrv6 + else networkAddresses.cidrv4; + in "${net.cidr.ip addr}/${toString (net.cidr.length relevantNetworkAddr)}"; # Creates a script that when executed outputs a wg-quick compatible configuration # file for use with external peers. This is a script so we can access secrets without @@ -208,6 +223,7 @@ in rec { pkgs = self.pkgs.${system}; snCfg = self.nodes.${serverNode}.config.extra.wireguard.${wgName}; peerName = externalPeerName extPeer; + addresses = map toNetworkAddr snCfg.server.externalPeers.${extPeer}; in pkgs.writeShellScript "create-wg-conf-${wgName}-${serverNode}-${extPeer}" '' privKey=$(${pkgs.rage}/bin/rage -d ${rageDecryptArgs} ${escapeShellArg (peerPrivateKeyPath peerName)}) \ @@ -217,7 +233,7 @@ in rec { cat < X25519 whbY47wmwEeXqdKJ7MwjiyAIDpj+fruueMmPTEgnJgY +Z3QdAcWt5mkB3eWZeNkq0eq+UJ5DjL98uciSXb91pVg +-> piv-p256 xqSe8Q AxUBFcdy+TQ/aXS8/1dZWQxbHUbPdjdm6RcM3vyj1qxB +huH3sE7CutLMnL2AA7riZLG2q7vfKHq1yw1cCWIUcGo +-> _-~ X25519 Zfuwveyf86nRchq2VM9pUX2GpEJ7fOCD8S/ZpgnohBo +cTqLLXd0WDjeUw8v3Zi7tEu8AuHqGMouNNVMBvDSz/s +-> piv-p256 xqSe8Q AhkBYH9xbuiZzDEEPZKdI+b8cRBwpFynKCcG27IRcxeP +d7JvnhavlhklbmkUna76PL6E+oVVNl8AQs+Y2XgWOLM +-> #+BWuCW-grease -y#r [YV T?;fL)t^ lrGksIs +PWmuTyDWS1KmdmgKW3B7ITyE6Yl/Vb2cTggzNr2rDQ +--- xHLh9TYKLUEcU+rYSNyUomo0H9bNx92gC1To/qTAav8 +iEN¸ÖdÝcˆ ç§úMf›×' ª>Æ:û‚;Ý—Ûm¤x0•v'YY(d¹ñV·¦]…¾r”PVÏŠdÉj,N¦r&¬€”C· \ No newline at end of file diff --git a/secrets/wireguard/ward-local-vms/keys/ward.pub b/secrets/wireguard/ward-local-vms/keys/ward.pub new file mode 100644 index 0000000..fd32d3f --- /dev/null +++ b/secrets/wireguard/ward-local-vms/keys/ward.pub @@ -0,0 +1 @@ +E5VkPLuSW3IJ1fK3FerHCfPc6xyTzD7q8D3AATmWME0= diff --git a/secrets/wireguard/ward-local-vms/psks/ward+ward-microvm-test.age b/secrets/wireguard/ward-local-vms/psks/ward+ward-microvm-test.age new file mode 100644 index 0000000..b801563 --- /dev/null +++ b/secrets/wireguard/ward-local-vms/psks/ward+ward-microvm-test.age @@ -0,0 +1,10 @@ +age-encryption.org/v1 +-> X25519 W2AeVTtVkO93zSxX59GwhBy5NwRacz6w0dEk5JptS0Q +QZfGRkvYZjvWoK64RwH/D1pSm+Q5Z/bWa+wCiStim80 +-> piv-p256 xqSe8Q A5ODsP5r/eJxRYohpCeC/os0qx+HITx9coafiXkO5aCY +Lfzy5uPK315poUK59pDa9UsyjzY0bf94BvpJQC4qAEQ +-> mF-grease d w /I6vG!UZ 1fNC +QAvTqQEf64QZ9WPtav9CjSYIx8UjIHOMdaPyzKG8OaYa6d8QsrTog1OP7sqJemmE ++1gSHFORe7ofpxrzCFE +--- vbL5PR2z5571aWqnQ6+6Vk8Ni11SDvWtH8dl8i3Z44k +Hl-bS_ ª/­¶ÀÞ*B+f_ºÁí̹ß娛Yø/_„ߡϾDC\I!{æ²Ï[ŠY±ÎpêÇä¬^Z©ÂGÁ@ZE)¦Nr·ÑÇ \ No newline at end of file