From f4d989155419461acd8378e0cfbfa8d5db77f21a Mon Sep 17 00:00:00 2001 From: oddlama Date: Thu, 14 Mar 2024 20:50:39 +0100 Subject: [PATCH] feat: add module for wireguard overlay networks --- README.md | 8 + lib/default.nix | 2 + lib/wireguard.nix | 224 ++++++++++++++++++++ modules/default.nix | 1 + modules/wireguard.nix | 481 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 716 insertions(+) create mode 100644 lib/wireguard.nix create mode 100644 modules/wireguard.nix diff --git a/README.md b/README.md index e21a3e3..7563d9b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Nginx recommended options | Module | [Link](./modules/nginx.nix) | - | agenix | Node options | Module | [Link](./modules/node.nix) | - | - | A module that stores meta information about your nodes (hosts). Required for some other modules that operate across nodes. Guests (MicroVMs & Containers) | Module | [Link](./modules/guests) | zfs, disko, node options | - | This module implements a common interface to use guest systems with microvms or nixos-containers. Restic hetzner storage box setup | Module | [Link](./modules/restic.nix) | agenix, agenix-rekey | - | This module exposes new options for restic backups that allow a simple setup of hetzner storage boxes. There's [an app](./apps/setup-hetzner-storage-boxes.nix) that you should expose on your flake to automate remote setup. +Wireguard overlay networks | Module | [Link](./modules/wireguard.nix) | agenix, agenix-rekey, nftables-firewall, specialArgs.nodes | - | This module automatically creates cross-node wireguard networks including automatic semi-stable ip address assignments #### Home Manager Modules @@ -47,6 +48,10 @@ You also must have a `specialArgs.inputs` that refers to all of your flake's inp and `inputs.self.pkgs.${system}` must refer to an initialized package set for that specific system that includes extra-modules as an overlay. +All cross-node configuration modules (like wireguard) require you to expose +all relevant nodes in your flake as `inputs.self.nodes`, so their configuration +can be accessed by other nodes. + Here's an example configuration: ```nix @@ -130,6 +135,9 @@ Here's an example configuration: inherit (pkgs) lib; }; }; + + # Required for cross-node configuration like in the wireguard module + nodes = self.nixosConfigurations; } // flake-utils.lib.eachDefaultSystem (system: rec { pkgs = import nixpkgs { diff --git a/lib/default.nix b/lib/default.nix index 9b4de49..5c600d7 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -11,6 +11,8 @@ prev.lib.composeManyExtensions ( ./disko.nix # Requires misc ./net.nix + # Requires misc, types + ./wireguard.nix ] ) final diff --git a/lib/wireguard.nix b/lib/wireguard.nix new file mode 100644 index 0000000..0658de0 --- /dev/null +++ b/lib/wireguard.nix @@ -0,0 +1,224 @@ +inputs: final: prev: let + inherit + (inputs.nixpkgs.lib) + assertMsg + attrNames + attrValues + concatLists + concatMap + concatStringsSep + escapeShellArg + filter + flatten + flip + genAttrs + mapAttrs' + nameValuePair + partition + removeSuffix + ; + + inherit + (final.lib) + net + concatAttrs + types + ; + + inherit + (final.lib.secrets) + rageDecryptArgs + ; +in { + lib = + prev.lib + // { + wireguard = userInputs: wgName: let + inherit (userInputs.self) nodes; + # Returns the given node's wireguard configuration of this network + wgCfgOf = node: nodes.${node}.config.wireguard.${wgName}; + + sortedPeers = peerA: peerB: + if peerA < peerB + then { + peer1 = peerA; + peer2 = peerB; + } + else { + peer1 = peerB; + peer2 = peerA; + }; + + peerPublicKeyFile = peerName: "/secrets/wireguard/${wgName}/keys/${peerName}.pub"; + peerPublicKeyPath = peerName: userInputs.self.outPath + peerPublicKeyFile peerName; + + peerPrivateKeyFile = peerName: "/secrets/wireguard/${wgName}/keys/${peerName}.age"; + peerPrivateKeyPath = peerName: userInputs.self.outPath + peerPrivateKeyFile peerName; + peerPrivateKeySecret = peerName: "wireguard-${wgName}-priv-${peerName}"; + + peerPresharedKeyFile = peerA: peerB: let + inherit (sortedPeers peerA peerB) peer1 peer2; + in "/secrets/wireguard/${wgName}/psks/${peer1}+${peer2}.age"; + peerPresharedKeyPath = peerA: peerB: userInputs.self.outPath + peerPresharedKeyFile peerA peerB; + peerPresharedKeySecret = peerA: peerB: let + inherit (sortedPeers peerA peerB) peer1 peer2; + in "wireguard-${wgName}-psks-${peer1}+${peer2}"; + + # All nodes that are part of this network + participatingNodes = + filter + (n: builtins.hasAttr wgName nodes.${n}.config.wireguard) + (attrNames nodes); + + # Partition nodes by whether they are servers + _participatingNodes_isServerPartition = + partition + (n: (wgCfgOf n).server.host != null) + participatingNodes; + + participatingServerNodes = _participatingNodes_isServerPartition.right; + participatingClientNodes = _participatingNodes_isServerPartition.wrong; + + # Maps all nodes that are part of this network to their addresses + nodePeers = genAttrs participatingNodes (n: (wgCfgOf n).addresses); + + externalPeerName = p: "external-${p}"; + + # Only peers that are defined as externalPeers on the given node. + # Prepends "external-" to their name. + externalPeersForNode = node: + mapAttrs' (p: nameValuePair (externalPeerName p)) (wgCfgOf node).server.externalPeers; + + # All peers that are defined as externalPeers on any node. + # Prepends "external-" to their name. + allExternalPeers = concatAttrs (map externalPeersForNode participatingNodes); + + # All peers that are part of this network + allPeers = nodePeers // allExternalPeers; + + # Concatenation of all external peer names names without any transformations. + externalPeerNamesRaw = concatMap (n: attrNames (wgCfgOf n).server.externalPeers) participatingNodes; + + # A list of all occurring addresses. + usedAddresses = + concatMap (n: (wgCfgOf n).addresses) participatingNodes + ++ flatten (concatMap (n: attrValues (wgCfgOf n).server.externalPeers) participatingNodes); + + # A list of all occurring addresses, but only includes addresses that + # are not assigned automatically. + explicitlyUsedAddresses = + flip concatMap participatingNodes + (n: + filter (x: !types.isLazyValue x) + (concatLists + (nodes.${n}.options.wireguard.type.functor.wrapped.getSubOptions (wgCfgOf n)).addresses.definitions)) + ++ flatten (concatMap (n: attrValues (wgCfgOf n).server.externalPeers) participatingNodes); + + # The cidrv4 and cidrv6 of the network spanned by all participating peer addresses. + # This also takes into account any reserved address ranges that should be part of the network. + networkAddresses = + net.cidr.merge (usedAddresses + ++ concatMap (n: (wgCfgOf n).server.reservedAddresses) participatingServerNodes); + + # The network spanning cidr addresses. The respective cidrv4 and cirdv6 are only + # included if they exist. + networkCidrs = filter (x: x != null) (attrValues networkAddresses); + + # The cidrv4 and cidrv6 of the network spanned by all reserved addresses only. + # Used to determine automatically assigned addresses first. + spannedReservedNetwork = + net.cidr.merge (concatMap (n: (wgCfgOf n).server.reservedAddresses) participatingServerNodes); + + # Assigns an ipv4 address from spannedReservedNetwork.cidrv4 + # to each participant that has not explicitly specified an ipv4 address. + assignedIpv4Addresses = assert assertMsg + (spannedReservedNetwork.cidrv4 != null) + "Wireguard network '${wgName}': At least one participating node must reserve a cidrv4 address via `reservedAddresses` so that ipv4 addresses can be assigned automatically from that network."; + net.cidr.assignIps + spannedReservedNetwork.cidrv4 + # Don't assign any addresses that are explicitly configured on other hosts + (filter (x: net.cidr.contains x spannedReservedNetwork.cidrv4) (filter net.ip.isv4 explicitlyUsedAddresses)) + participatingNodes; + + # Assigns an ipv4 address from spannedReservedNetwork.cidrv4 + # to each participant that has not explicitly specified an ipv4 address. + assignedIpv6Addresses = assert assertMsg + (spannedReservedNetwork.cidrv6 != null) + "Wireguard network '${wgName}': At least one participating node must reserve a cidrv6 address via `reservedAddresses` so that ipv4 addresses can be assigned automatically from that network."; + net.cidr.assignIps + spannedReservedNetwork.cidrv6 + # Don't assign any addresses that are explicitly configured on other hosts + (filter (x: net.cidr.contains x spannedReservedNetwork.cidrv6) (filter net.ip.isv6 explicitlyUsedAddresses)) + participatingNodes; + + # 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 + # storing them in the nix-store. + wgQuickConfigScript = system: serverNode: extPeer: let + pkgs = userInputs.self.pkgs.${system}; + snCfg = wgCfgOf serverNode; + 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)}) \ + || { echo "error: Failed to decrypt!" >&2; exit 1; } + serverPsk=$(${pkgs.rage}/bin/rage -d ${rageDecryptArgs} ${escapeShellArg (peerPresharedKeyPath serverNode peerName)}) \ + || { echo "error: Failed to decrypt!" >&2; exit 1; } + + cat < ((wgCfgOf wgCfg.client.via).server.host != null); + message = "${assertionPrefix}: The specified via node '${wgCfg.client.via}' must be a wireguard server."; + } + { + assertion = stringLength wgCfg.linkName < 16; + message = "${assertionPrefix}: The specified linkName '${wgCfg.linkName}' is too long (must be max 15 characters)."; + } + ]; + + # Open the udp port for the wireguard endpoint in the firewall + networking.firewall.allowedUDPPorts = mkIf (isServer && wgCfg.server.openFirewall) [wgCfg.server.port]; + + # If requested, create firewall rules for the network / specific participants and open ports. + networking.nftables.firewall = let + inherit (config.networking.nftables.firewall) localZoneName; + in { + zones = + { + # Parent zone for the whole interface + "wg-${wgCfg.linkName}".interfaces = [wgCfg.linkName]; + } + // listToAttrs (flip map participatingNodes ( + peer: let + peerCfg = wgCfgOf peer; + in + # Subzone to specifically target the peer + nameValuePair "wg-${wgCfg.linkName}-node-${peer}" { + parent = "wg-${wgCfg.linkName}"; + ipv4Addresses = [peerCfg.ipv4]; + ipv6Addresses = [peerCfg.ipv6]; + } + )); + + rules = + { + # Open ports for whole network + "wg-${wgCfg.linkName}-to-${localZoneName}" = { + from = ["wg-${wgCfg.linkName}"]; + to = [localZoneName]; + ignoreEmptyRule = true; + + inherit + (wgCfg.firewallRuleForAll) + allowedTCPPorts + allowedUDPPorts + ; + }; + } + # Open ports for specific nodes network + // listToAttrs (flip map participatingNodes ( + peer: + nameValuePair "wg-${wgCfg.linkName}-node-${peer}-to-${localZoneName}" ( + mkIf (wgCfg.firewallRuleForNode ? peer) { + from = ["wg-${wgCfg.linkName}-node-${peer}"]; + to = [localZoneName]; + ignoreEmptyRule = true; + + inherit + (wgCfg.firewallRuleForNode.${peer}) + allowedTCPPorts + allowedUDPPorts + ; + } + ) + )); + }; + + age.secrets = + concatAttrs (map + (other: { + ${peerPresharedKeySecret nodeName other} = { + rekeyFile = peerPresharedKeyPath nodeName other; + owner = "systemd-network"; + generator.script = {pkgs, ...}: "${pkgs.wireguard-tools}/bin/wg genpsk"; + }; + }) + neededPeers) + // { + ${peerPrivateKeySecret nodeName} = { + rekeyFile = peerPrivateKeyPath nodeName; + owner = "systemd-network"; + generator.script = { + pkgs, + file, + ... + }: '' + priv=$(${pkgs.wireguard-tools}/bin/wg genkey) + ${pkgs.wireguard-tools}/bin/wg pubkey <<< "$priv" > ${lib.escapeShellArg (lib.removeSuffix ".age" file + ".pub")} + echo "$priv" + ''; + }; + }; + + systemd.network.netdevs."${wgCfg.unitConfName}" = { + netdevConfig = { + Kind = "wireguard"; + Name = wgCfg.linkName; + Description = "Wireguard network ${wgName}"; + }; + wireguardConfig = + { + PrivateKeyFile = config.age.secrets.${peerPrivateKeySecret nodeName}.path; + } + // optionalAttrs isServer { + ListenPort = wgCfg.server.port; + }; + wireguardPeers = + if isServer + then + # Always include all other server nodes. + map (serverNode: let + snCfg = wgCfgOf serverNode; + in { + wireguardPeerConfig = { + PublicKey = builtins.readFile (peerPublicKeyPath serverNode); + PresharedKeyFile = config.age.secrets.${peerPresharedKeySecret nodeName serverNode}.path; + AllowedIPs = serverAllowedIPs serverNode; + Endpoint = "${snCfg.server.host}:${toString snCfg.server.port}"; + }; + }) + (filterSelf participatingServerNodes) + # All our external peers + ++ mapAttrsToList (extPeer: ips: let + peerName = externalPeerName extPeer; + in { + wireguardPeerConfig = { + PublicKey = builtins.readFile (peerPublicKeyPath peerName); + PresharedKeyFile = config.age.secrets.${peerPresharedKeySecret nodeName peerName}.path; + AllowedIPs = map (net.cidr.make 128) ips; + # Connections to external peers should always be kept alive + PersistentKeepalive = 25; + }; + }) + wgCfg.server.externalPeers + # All client nodes that have their via set to us. + ++ map (clientNode: let + clientCfg = wgCfgOf clientNode; + in { + wireguardPeerConfig = { + PublicKey = builtins.readFile (peerPublicKeyPath clientNode); + PresharedKeyFile = config.age.secrets.${peerPresharedKeySecret nodeName clientNode}.path; + AllowedIPs = map (net.cidr.make 128) clientCfg.addresses; + }; + }) + ourClientNodes + else + # We are a client node, so only include our via server. + [ + { + wireguardPeerConfig = let + snCfg = wgCfgOf wgCfg.client.via; + in + { + PublicKey = builtins.readFile (peerPublicKeyPath wgCfg.client.via); + PresharedKeyFile = config.age.secrets.${peerPresharedKeySecret nodeName wgCfg.client.via}.path; + Endpoint = "${snCfg.server.host}:${toString snCfg.server.port}"; + # Access to the whole network is routed through our entry node. + AllowedIPs = networkCidrs; + } + // optionalAttrs wgCfg.client.keepalive { + PersistentKeepalive = 25; + }; + } + ]; + }; + + systemd.network.networks."${wgCfg.unitConfName}" = { + matchConfig.Name = wgCfg.linkName; + address = map toNetworkAddr wgCfg.addresses; + }; + }; +in { + options.wireguard = mkOption { + default = {}; + description = "Configures wireguard networks via systemd-networkd."; + type = types.lazyAttrsOf (types.submodule ({ + config, + name, + options, + ... + }: { + options = { + server = { + host = mkOption { + default = null; + type = types.nullOr types.str; + description = "The hostname or ip address which other peers can use to reach this host. No server functionality will be activated if set to null."; + }; + + port = mkOption { + default = 51820; + type = types.port; + description = "The port to listen on."; + }; + + openFirewall = mkOption { + default = false; + type = types.bool; + description = "Whether to open the firewall for the specified {option}`port`."; + }; + + externalPeers = mkOption { + type = types.attrsOf (types.listOf (types.net.ip-in config.addresses)); + default = {}; + example = {my-android-phone = ["10.0.0.97"];}; + description = '' + 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) + + These external peers will only know this node as a peer, which will forward + their traffic to other members of the network if required. This requires + this node to act as a server. + ''; + }; + + reservedAddresses = mkOption { + type = types.listOf types.net.cidr; + default = []; + example = ["10.0.0.0/24" "fd00:cafe::/64"]; + description = '' + Allows defining extra CIDR network ranges that shall be reserved for this network. + Reservation means that those address spaces will be guaranteed to be included in + the spanned network, but no rules will be enforced as to who in the network may use them. + + By default, this module will try to allocate the smallest address space that includes + all network peers. If you know that there might be additional external peers added later, + it may be beneficial to reserve a bigger address space from the start to avoid having + to update existing external peers when the generated address space expands. + ''; + }; + }; + + client = { + via = mkOption { + default = null; + type = types.nullOr types.str; + description = '' + 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 = "Whether to keep this connection alive using PersistentKeepalive. Set to false only for networks where client and server IPs are stable."; + }; + }; + + priority = mkOption { + default = 40; + type = types.int; + description = "The order priority used when creating systemd netdev and network files."; + }; + + linkName = mkOption { + default = name; + type = types.str; + description = "The name for the created network interface."; + }; + + unitConfName = mkOption { + default = "${toString config.priority}-${config.linkName}"; + readOnly = true; + type = types.str; + description = '' + The name used for unit configuration files. This is a read-only option. + Access this if you want to add additional settings to the generated systemd units. + ''; + }; + + ipv4 = mkOption { + type = types.lazyOf types.net.ipv4; + default = types.lazyValue (wireguard inputs name).assignedIpv4Addresses.${nodeName}; + description = '' + The ipv4 address for this machine. If you do not set this explicitly, + a semi-stable ipv4 address will be derived automatically based on the + hostname of this machine. At least one participating server must reserve + a big-enough space of addresses by setting `reservedAddresses`. + See `net.cidr.assignIps` for more information on the algorithm. + ''; + }; + + ipv6 = mkOption { + type = types.lazyOf types.net.ipv6; + default = types.lazyValue (wireguard inputs name).assignedIpv6Addresses.${nodeName}; + description = '' + The ipv6 address for this machine. If you do not set this explicitly, + a semi-stable ipv6 address will be derived automatically based on the + hostname of this machine. At least one participating server must reserve + a big-enough space of addresses by setting `reservedAddresses`. + See `net.cidr.assignIps` for more information on the algorithm. + ''; + }; + + addresses = mkOption { + type = types.listOf (types.lazyOf types.net.ip); + default = [ + (head options.ipv4.definitions) + (head options.ipv6.definitions) + ]; + description = '' + The ip addresses (v4 and/or v6) to use for this machine. + The actual network cidr will automatically be derived from all network participants. + By default this will just include {option}`ipv4` and {option}`ipv6` as configured. + ''; + }; + + firewallRuleForAll = mkOption { + default = {}; + description = '' + Allows you to set specific firewall rules for traffic originating from any participant in this + wireguard network. A corresponding rule `wg--to-` will be created to easily expose + services to the network. + ''; + type = types.submodule { + options = { + allowedTCPPorts = mkOption { + type = types.listOf types.port; + default = []; + description = "Convenience option to open specific TCP ports for traffic from the network."; + }; + allowedUDPPorts = mkOption { + type = types.listOf types.port; + default = []; + description = "Convenience option to open specific UDP ports for traffic from the network."; + }; + }; + }; + }; + + firewallRuleForNode = mkOption { + default = {}; + description = '' + Allows you to set specific firewall rules just for traffic originating from another network node. + A corresponding rule `wg--node--to-` will be created to easily expose + services to that node. + ''; + type = types.attrsOf (types.submodule { + options = { + allowedTCPPorts = mkOption { + type = types.listOf types.port; + default = []; + description = "Convenience option to open specific TCP ports for traffic from another node."; + }; + allowedUDPPorts = mkOption { + type = types.listOf types.port; + default = []; + description = "Convenience option to open specific UDP ports for traffic from another node."; + }; + }; + }); + }; + }; + })); + }; + + config = mkIf (cfg != {}) (mergeToplevelConfigs + ["assertions" "age" "networking" "systemd"] + (mapAttrsToList configForNetwork cfg)); +}