diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index afed4b049ebd..1508806e5860 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1286,6 +1286,7 @@ ./services/networking/wg-quick.nix ./services/networking/wgautomesh.nix ./services/networking/wireguard.nix + ./services/networking/wireguard-networkd.nix ./services/networking/wpa_supplicant.nix ./services/networking/wstunnel.nix ./services/networking/x2goserver.nix diff --git a/nixos/modules/services/networking/wireguard-networkd.nix b/nixos/modules/services/networking/wireguard-networkd.nix new file mode 100644 index 000000000000..711f6e6808c2 --- /dev/null +++ b/nixos/modules/services/networking/wireguard-networkd.nix @@ -0,0 +1,207 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + inherit (lib) types; + inherit (lib.attrsets) + filterAttrs + mapAttrs + mapAttrs' + mapAttrsToList + nameValuePair + ; + inherit (lib.lists) concatMap concatLists; + inherit (lib.modules) mkIf; + inherit (lib.options) literalExpression mkOption; + inherit (lib.strings) hasInfix; + inherit (lib.trivial) flip; + + removeNulls = filterAttrs (_: v: v != null); + + generateNetdev = + name: interface: + nameValuePair "40-${name}" { + netdevConfig = removeNulls { + Kind = "wireguard"; + Name = name; + MTUBytes = interface.mtu; + }; + wireguardConfig = removeNulls { + PrivateKeyFile = interface.privateKeyFile; + ListenPort = interface.listenPort; + FirewallMark = interface.fwMark; + RouteTable = if interface.allowedIPsAsRoutes then interface.table else null; + RouteMetric = interface.metric; + }; + wireguardPeers = map generateWireguardPeer interface.peers; + }; + + generateWireguardPeer = + peer: + removeNulls { + PublicKey = peer.publicKey; + PresharedKeyFile = peer.presharedKeyFile; + AllowedIPs = peer.allowedIPs; + Endpoint = peer.endpoint; + PersistentKeepalive = peer.persistentKeepalive; + }; + + generateNetwork = name: interface: { + matchConfig.Name = name; + address = interface.ips; + }; + + cfg = config.networking.wireguard; + + refreshEnabledInterfaces = filterAttrs ( + name: interface: interface.dynamicEndpointRefreshSeconds != 0 + ) cfg.interfaces; + + generateRefreshTimer = + name: interface: + nameValuePair "wireguard-dynamic-refresh-${name}" { + partOf = [ "wireguard-dynamic-refresh-${name}.service" ]; + wantedBy = [ "timers.target" ]; + description = "Wireguard dynamic endpoint refresh (${name}) timer"; + timerConfig.OnBootSec = interface.dynamicEndpointRefreshSeconds; + timerConfig.OnUnitInactiveSec = interface.dynamicEndpointRefreshSeconds; + }; + + generateRefreshService = + name: interface: + nameValuePair "wireguard-dynamic-refresh-${name}" { + description = "Wireguard dynamic endpoint refresh (${name})"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + path = with pkgs; [ + iproute2 + systemd + ]; + # networkd doesn't provide a mechanism for refreshing endpoints. + # See: https://github.com/systemd/systemd/issues/9911 + # This hack does the job but takes down the whole interface to do it. + script = '' + ip link delete ${name} + networkctl reload + ''; + }; + +in +{ + meta.maintainers = [ lib.maintainers.majiir ]; + + options.networking.wireguard = { + useNetworkd = mkOption { + default = config.networking.useNetworkd; + defaultText = literalExpression "config.networking.useNetworkd"; + type = types.bool; + description = '' + Whether to use networkd as the network configuration backend for + Wireguard instead of the legacy script-based system. + + ::: {.warning} + Some options have slightly different behavior with the networkd and + script-based backends. Check the documentation for each Wireguard + option you use before enabling this option. + ::: + ''; + }; + }; + + config = mkIf (cfg.enable && cfg.useNetworkd) { + + # TODO: Some of these options may be possible to support in networkd. + # + # privateKey and presharedKey are trivial to support, but we deliberately + # don't in order to discourage putting secrets in the /nix store. + # + # generatePrivateKeyFile can be supported if we can order a service before + # networkd configures interfaces. There is also a systemd feature request + # for key generation: https://github.com/systemd/systemd/issues/14282 + # + # preSetup, postSetup, preShutdown and postShutdown may be possible, but + # networkd is not likely to support script hooks like this directly. See: + # https://github.com/systemd/systemd/issues/11629 + # + # socketNamespace and interfaceNamespace can be implemented once networkd + # supports setting a netdev's namespace. See: + # https://github.com/systemd/systemd/issues/11629 + # https://github.com/systemd/systemd/pull/14915 + + assertions = concatLists ( + flip mapAttrsToList cfg.interfaces ( + name: interface: + [ + # Interface assertions + { + assertion = interface.privateKey == null; + message = "networking.wireguard.interfaces.${name}.privateKey cannot be used with networkd. Use privateKeyFile instead."; + } + { + assertion = !interface.generatePrivateKeyFile; + message = "networking.wireguard.interfaces.${name}.generatePrivateKeyFile cannot be used with networkd."; + } + { + assertion = interface.preSetup == ""; + message = "networking.wireguard.interfaces.${name}.preSetup cannot be used with networkd."; + } + { + assertion = interface.postSetup == ""; + message = "networking.wireguard.interfaces.${name}.postSetup cannot be used with networkd."; + } + { + assertion = interface.preShutdown == ""; + message = "networking.wireguard.interfaces.${name}.preShutdown cannot be used with networkd."; + } + { + assertion = interface.postShutdown == ""; + message = "networking.wireguard.interfaces.${name}.postShutdown cannot be used with networkd."; + } + { + assertion = interface.socketNamespace == null; + message = "networking.wireguard.interfaces.${name}.socketNamespace cannot be used with networkd."; + } + { + assertion = interface.interfaceNamespace == null; + message = "networking.wireguard.interfaces.${name}.interfaceNamespace cannot be used with networkd."; + } + ] + ++ flip concatMap interface.ips (ip: [ + # IP assertions + { + assertion = hasInfix "/" ip; + message = "networking.wireguard.interfaces.${name}.ips value \"${ip}\" requires a subnet (e.g. 192.0.2.1/32) with networkd."; + } + ]) + ++ flip concatMap interface.peers (peer: [ + # Peer assertions + { + assertion = peer.presharedKey == null; + message = "networking.wireguard.interfaces.${name}.peers[].presharedKey cannot be used with networkd. Use presharedKeyFile instead."; + } + { + assertion = peer.dynamicEndpointRefreshSeconds == null; + message = "networking.wireguard.interfaces.${name}.peers[].dynamicEndpointRefreshSeconds cannot be used with networkd. Use networking.wireguard.interfaces.${name}.dynamicEndpointRefreshSeconds instead."; + } + { + assertion = peer.dynamicEndpointRefreshRestartSeconds == null; + message = "networking.wireguard.interfaces.${name}.peers[].dynamicEndpointRefreshRestartSeconds cannot be used with networkd."; + } + ]) + ) + ); + + systemd.network = { + enable = true; + netdevs = mapAttrs' generateNetdev cfg.interfaces; + networks = mapAttrs generateNetwork cfg.interfaces; + }; + + systemd.timers = mapAttrs' generateRefreshTimer refreshEnabledInterfaces; + systemd.services = mapAttrs' generateRefreshService refreshEnabledInterfaces; + }; +} diff --git a/nixos/modules/services/networking/wireguard.nix b/nixos/modules/services/networking/wireguard.nix index 08e5494b63df..613c1ae7d769 100644 --- a/nixos/modules/services/networking/wireguard.nix +++ b/nixos/modules/services/networking/wireguard.nix @@ -49,6 +49,9 @@ let default = null; description = '' Private key file as generated by {command}`wg genkey`. + + When {option}`networking.wireguard.useNetworkd` is enabled, this file + must be readable by the `systemd-network` user. ''; }; @@ -182,6 +185,28 @@ let Set the metric of routes related to this Wireguard interface. ''; }; + + dynamicEndpointRefreshSeconds = mkOption { + default = 0; + example = 300; + type = with types; int; + description = '' + Periodically refresh the endpoint hostname or address for all peers. + Allows WireGuard to notice DNS and IPv4/IPv6 connectivity changes. + This option can be set or overridden for individual peers. + + Setting this to `0` disables periodic refresh. + + ::: {.warning} + When {option}`networking.wireguard.useNetworkd` is enabled, this + option deletes the Wireguard interface and brings it back up by + reconfiguring the network with `networkctl reload` on every refresh. + This could have adverse effects on your network and cause brief + connectivity blips. See [systemd/systemd#9911](https://github.com/systemd/systemd/issues/9911) + for an upstream feature request that can make this less hacky. + ::: + ''; + }; }; }; @@ -234,6 +259,9 @@ let Optional, and may be omitted. This option adds an additional layer of symmetric-key cryptography to be mixed into the already existing public-key cryptography, for post-quantum resistance. + + When {option}`networking.wireguard.useNetworkd` is enabled, this file + must be readable by the `systemd-network` user. ''; }; @@ -269,15 +297,21 @@ let }; dynamicEndpointRefreshSeconds = mkOption { - default = 0; + default = null; + defaultText = literalExpression "config.networking.wireguard.interfaces..dynamicEndpointRefreshSeconds"; example = 5; - type = with types; int; + type = with types; nullOr int; description = '' Periodically re-execute the `wg` utility every this many seconds in order to let WireGuard notice DNS / hostname changes. Setting this to `0` disables periodic reexecution. + + ::: {.note} + This peer-level setting is not available when {option}`networking.wireguard.useNetworkd` + is enabled. The interface-level setting may be used instead. + ::: ''; }; @@ -349,6 +383,11 @@ let in "wireguard-${interfaceName}-peer-${peerName}${refreshSuffix}"; + dynamicRefreshSeconds = interfaceCfg: peer: + if peer.dynamicEndpointRefreshSeconds != null + then peer.dynamicEndpointRefreshSeconds + else interfaceCfg.dynamicEndpointRefreshSeconds; + generatePeerUnit = { interfaceName, interfaceCfg, peer }: let psk = @@ -359,7 +398,8 @@ let dst = interfaceCfg.interfaceNamespace; ip = nsWrap "ip" src dst; wg = nsWrap "wg" src dst; - dynamicRefreshEnabled = peer.dynamicEndpointRefreshSeconds != 0; + dynamicEndpointRefreshSeconds = dynamicRefreshSeconds interfaceCfg peer; + dynamicRefreshEnabled = dynamicEndpointRefreshSeconds != 0; # We generate a different name (a `-refresh` suffix) when `dynamicEndpointRefreshSeconds` # to avoid that the same service switches `Type` (`oneshot` vs `simple`), # with the intent to make scripting more obvious. @@ -395,7 +435,7 @@ let Restart = "always"; RestartSec = if null != peer.dynamicEndpointRefreshRestartSeconds then peer.dynamicEndpointRefreshRestartSeconds - else peer.dynamicEndpointRefreshSeconds; + else dynamicEndpointRefreshSeconds; }; unitConfig = lib.optionalAttrs dynamicRefreshEnabled { StartLimitIntervalSec = 0; @@ -419,13 +459,13 @@ let ${wg_setup} ${route_setup} - ${optionalString (peer.dynamicEndpointRefreshSeconds != 0) '' + ${optionalString (dynamicEndpointRefreshSeconds != 0) '' # Re-execute 'wg' periodically to notice DNS / hostname changes. # Note this will not time out on transient DNS failures such as DNS names # because we have set 'WG_ENDPOINT_RESOLUTION_RETRIES=infinity'. # Also note that 'wg' limits its maximum retry delay to 20 seconds as of writing. while ${wg_setup}; do - sleep "${toString peer.dynamicEndpointRefreshSeconds}"; + sleep "${toString dynamicEndpointRefreshSeconds}"; done ''} ''; @@ -445,7 +485,7 @@ let # the target is required to start new peer units when they are added generateInterfaceTarget = name: values: let - mkPeerUnit = peer: (peerUnitServiceName name peer.name (peer.dynamicEndpointRefreshSeconds != 0)) + ".service"; + mkPeerUnit = peer: (peerUnitServiceName name peer.name (dynamicRefreshSeconds values peer != 0)) + ".service"; in nameValuePair "wireguard-${name}" rec { @@ -530,9 +570,10 @@ in description = '' Whether to enable WireGuard. - Please note that {option}`systemd.network.netdevs` has more features - and is better maintained. When building new things, it is advised to - use that instead. + ::: {.note} + By default, this module is powered by a script-based backend. You can + enable the networkd backend with {option}`networking.wireguard.useNetworkd`. + ::: ''; type = types.bool; # 2019-05-25: Backwards compatibility. @@ -544,10 +585,6 @@ in interfaces = mkOption { description = '' WireGuard interfaces. - - Please note that {option}`systemd.network.netdevs` has more features - and is better maintained. When building new things, it is advised to - use that instead. ''; default = {}; example = { @@ -597,13 +634,13 @@ in boot.kernelModules = [ "wireguard" ]; environment.systemPackages = [ pkgs.wireguard-tools ]; - systemd.services = + systemd.services = mkIf (!cfg.useNetworkd) ( (mapAttrs' generateInterfaceUnit cfg.interfaces) // (listToAttrs (map generatePeerUnit all_peers)) // (mapAttrs' generateKeyServiceUnit - (filterAttrs (name: value: value.generatePrivateKeyFile) cfg.interfaces)); + (filterAttrs (name: value: value.generatePrivateKeyFile) cfg.interfaces))); - systemd.targets = mapAttrs' generateInterfaceTarget cfg.interfaces; + systemd.targets = mkIf (!cfg.useNetworkd) (mapAttrs' generateInterfaceTarget cfg.interfaces); } ); diff --git a/nixos/tests/wireguard/default.nix b/nixos/tests/wireguard/default.nix index fc22f06b778b..16393f533bc7 100644 --- a/nixos/tests/wireguard/default.nix +++ b/nixos/tests/wireguard/default.nix @@ -11,9 +11,12 @@ let tests = let callTest = p: args: import p ({ inherit system pkgs; } // args); in { basic = callTest ./basic.nix; namespaces = callTest ./namespaces.nix; + networkd = callTest ./networkd.nix; wg-quick = callTest ./wg-quick.nix; wg-quick-nftables = args: callTest ./wg-quick.nix ({ nftables = true; } // args); generated = callTest ./generated.nix; + dynamic-refresh = callTest ./dynamic-refresh.nix; + dynamic-refresh-networkd = args: callTest ./dynamic-refresh.nix ({ useNetworkd = true; } // args); }; in diff --git a/nixos/tests/wireguard/dynamic-refresh.nix b/nixos/tests/wireguard/dynamic-refresh.nix new file mode 100644 index 000000000000..e0af6bc025aa --- /dev/null +++ b/nixos/tests/wireguard/dynamic-refresh.nix @@ -0,0 +1,102 @@ +import ../make-test-python.nix ( + { + pkgs, + lib, + kernelPackages ? null, + useNetworkd ? false, + ... + }: + let + wg-snakeoil-keys = import ./snakeoil-keys.nix; + in + { + name = "wireguard-dynamic-refresh"; + meta = with lib.maintainers; { + maintainers = [ majiir ]; + }; + + nodes = { + server = { + virtualisation.vlans = [ + 1 + 2 + ]; + boot = lib.mkIf (kernelPackages != null) { inherit kernelPackages; }; + networking.firewall.allowedUDPPorts = [ 23542 ]; + networking.useDHCP = false; + networking.wireguard.useNetworkd = useNetworkd; + networking.wireguard.interfaces.wg0 = { + ips = [ "10.23.42.1/32" ]; + listenPort = 23542; + + # !!! Don't do this with real keys. The /nix store is world-readable! + privateKeyFile = toString (pkgs.writeText "privateKey" wg-snakeoil-keys.peer0.privateKey); + + peers = lib.singleton { + allowedIPs = [ "10.23.42.2/32" ]; + + inherit (wg-snakeoil-keys.peer1) publicKey; + }; + }; + }; + + client = + { nodes, ... }: + { + virtualisation.vlans = [ + 1 + 2 + ]; + boot = lib.mkIf (kernelPackages != null) { inherit kernelPackages; }; + networking.useDHCP = false; + networking.wireguard.useNetworkd = useNetworkd; + networking.wireguard.interfaces.wg0 = { + ips = [ "10.23.42.2/32" ]; + + # !!! Don't do this with real keys. The /nix store is world-readable! + privateKeyFile = toString (pkgs.writeText "privateKey" wg-snakeoil-keys.peer1.privateKey); + + dynamicEndpointRefreshSeconds = 2; + + peers = lib.singleton { + allowedIPs = [ + "0.0.0.0/0" + "::/0" + ]; + endpoint = "server:23542"; + + inherit (wg-snakeoil-keys.peer0) publicKey; + }; + }; + + specialisation.update-hosts.configuration = { + networking.extraHosts = + let + testCfg = nodes.server.virtualisation.test; + in + lib.mkForce "192.168.2.${toString testCfg.nodeNumber} ${testCfg.nodeName}"; + }; + }; + }; + + testScript = + { nodes, ... }: + '' + start_all() + + server.wait_for_unit("network-online.target") + client.wait_for_unit("network-online.target") + + client.succeed("ping -n -w 1 -c 1 10.23.42.1") + + client.succeed("ip link set down eth1") + + client.fail("ping -n -w 1 -c 1 10.23.42.1") + + with client.nested("update hosts file"): + client.succeed("${nodes.client.system.build.toplevel}/specialisation/update-hosts/bin/switch-to-configuration test") + + client.succeed("sleep 5 && ping -n -w 1 -c 1 10.23.42.1") + ''; + } +) diff --git a/nixos/tests/wireguard/networkd.nix b/nixos/tests/wireguard/networkd.nix new file mode 100644 index 000000000000..beaba5a4343a --- /dev/null +++ b/nixos/tests/wireguard/networkd.nix @@ -0,0 +1,89 @@ +import ../make-test-python.nix ( + { + pkgs, + lib, + kernelPackages ? null, + ... + }: + let + wg-snakeoil-keys = import ./snakeoil-keys.nix; + peer = (import ./make-peer.nix) { inherit lib; }; + in + { + name = "wireguard-networkd"; + meta = with pkgs.lib.maintainers; { + maintainers = [ majiir ]; + }; + + nodes = { + peer0 = peer { + ip4 = "192.168.0.1"; + ip6 = "fd00::1"; + extraConfig = { + boot = lib.mkIf (kernelPackages != null) { inherit kernelPackages; }; + networking.firewall.allowedUDPPorts = [ 23542 ]; + networking.wireguard.useNetworkd = true; + networking.wireguard.interfaces.wg0 = { + ips = [ + "10.23.42.1/32" + "fc00::1/128" + ]; + listenPort = 23542; + + # !!! Don't do this with real keys. The /nix store is world-readable! + privateKeyFile = toString (pkgs.writeText "privateKey" wg-snakeoil-keys.peer0.privateKey); + + peers = lib.singleton { + allowedIPs = [ + "10.23.42.2/32" + "fc00::2/128" + ]; + + inherit (wg-snakeoil-keys.peer1) publicKey; + }; + }; + }; + }; + + peer1 = peer { + ip4 = "192.168.0.2"; + ip6 = "fd00::2"; + extraConfig = { + boot = lib.mkIf (kernelPackages != null) { inherit kernelPackages; }; + networking.wireguard.useNetworkd = true; + networking.wireguard.interfaces.wg0 = { + ips = [ + "10.23.42.2/32" + "fc00::2/128" + ]; + listenPort = 23542; + + # !!! Don't do this with real keys. The /nix store is world-readable! + privateKeyFile = toString (pkgs.writeText "privateKey" wg-snakeoil-keys.peer1.privateKey); + + peers = lib.singleton { + allowedIPs = [ + "0.0.0.0/0" + "::/0" + ]; + endpoint = "192.168.0.1:23542"; + persistentKeepalive = 25; + + inherit (wg-snakeoil-keys.peer0) publicKey; + }; + }; + }; + }; + }; + + testScript = '' + start_all() + + peer0.wait_for_unit("systemd-networkd-wait-online.service") + peer1.wait_for_unit("systemd-networkd-wait-online.service") + + peer1.succeed("ping -c5 fc00::1") + peer1.succeed("ping -c5 10.23.42.1") + ''; + } +)