nixos/wireguard-networkd: init

Adds a networkd backend for the networking.wireguard options.
This commit is contained in:
Majiir Paktu 2023-10-03 21:09:52 -04:00
parent f3f364ed3e
commit a5de36518f
6 changed files with 456 additions and 17 deletions

View File

@ -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

View File

@ -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;
};
}

View File

@ -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.<name>.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);
}
);

View File

@ -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

View File

@ -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")
'';
}
)

View File

@ -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")
'';
}
)