1{ config, lib, pkgs, ... }:
2
3with lib;
4let
5 cfg = config.networking.wg-quick;
6
7 kernel = config.boot.kernelPackages;
8
9 # interface options
10
11 interfaceOpts = { ... }: {
12 options = {
13
14 configFile = mkOption {
15 example = "/secret/wg0.conf";
16 default = null;
17 type = with types; nullOr str;
18 description = lib.mdDoc ''
19 wg-quick .conf file, describing the interface.
20 This overrides any other configuration interface configuration options.
21 See wg-quick manpage for more details.
22 '';
23 };
24
25 address = mkOption {
26 example = [ "192.168.2.1/24" ];
27 default = [];
28 type = with types; listOf str;
29 description = lib.mdDoc "The IP addresses of the interface.";
30 };
31
32 autostart = mkOption {
33 description = lib.mdDoc "Whether to bring up this interface automatically during boot.";
34 default = true;
35 example = false;
36 type = types.bool;
37 };
38
39 dns = mkOption {
40 example = [ "192.168.2.2" ];
41 default = [];
42 type = with types; listOf str;
43 description = lib.mdDoc "The IP addresses of DNS servers to configure.";
44 };
45
46 privateKey = mkOption {
47 example = "yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=";
48 type = with types; nullOr str;
49 default = null;
50 description = lib.mdDoc ''
51 Base64 private key generated by {command}`wg genkey`.
52
53 Warning: Consider using privateKeyFile instead if you do not
54 want to store the key in the world-readable Nix store.
55 '';
56 };
57
58 privateKeyFile = mkOption {
59 example = "/private/wireguard_key";
60 type = with types; nullOr str;
61 default = null;
62 description = lib.mdDoc ''
63 Private key file as generated by {command}`wg genkey`.
64 '';
65 };
66
67 listenPort = mkOption {
68 default = null;
69 type = with types; nullOr int;
70 example = 51820;
71 description = lib.mdDoc ''
72 16-bit port for listening. Optional; if not specified,
73 automatically generated based on interface name.
74 '';
75 };
76
77 preUp = mkOption {
78 example = literalExpression ''"''${pkgs.iproute2}/bin/ip netns add foo"'';
79 default = "";
80 type = with types; coercedTo (listOf str) (concatStringsSep "\n") lines;
81 description = lib.mdDoc ''
82 Commands called at the start of the interface setup.
83 '';
84 };
85
86 preDown = mkOption {
87 example = literalExpression ''"''${pkgs.iproute2}/bin/ip netns del foo"'';
88 default = "";
89 type = with types; coercedTo (listOf str) (concatStringsSep "\n") lines;
90 description = lib.mdDoc ''
91 Command called before the interface is taken down.
92 '';
93 };
94
95 postUp = mkOption {
96 example = literalExpression ''"''${pkgs.iproute2}/bin/ip netns add foo"'';
97 default = "";
98 type = with types; coercedTo (listOf str) (concatStringsSep "\n") lines;
99 description = lib.mdDoc ''
100 Commands called after the interface setup.
101 '';
102 };
103
104 postDown = mkOption {
105 example = literalExpression ''"''${pkgs.iproute2}/bin/ip netns del foo"'';
106 default = "";
107 type = with types; coercedTo (listOf str) (concatStringsSep "\n") lines;
108 description = lib.mdDoc ''
109 Command called after the interface is taken down.
110 '';
111 };
112
113 table = mkOption {
114 example = "main";
115 default = null;
116 type = with types; nullOr str;
117 description = lib.mdDoc ''
118 The kernel routing table to add this interface's
119 associated routes to. Setting this is useful for e.g. policy routing
120 ("ip rule") or virtual routing and forwarding ("ip vrf"). Both
121 numeric table IDs and table names (/etc/rt_tables) can be used.
122 Defaults to "main".
123 '';
124 };
125
126 mtu = mkOption {
127 example = 1248;
128 default = null;
129 type = with types; nullOr int;
130 description = lib.mdDoc ''
131 If not specified, the MTU is automatically determined
132 from the endpoint addresses or the system default route, which is usually
133 a sane choice. However, to manually specify an MTU to override this
134 automatic discovery, this value may be specified explicitly.
135 '';
136 };
137
138 peers = mkOption {
139 default = [];
140 description = lib.mdDoc "Peers linked to the interface.";
141 type = with types; listOf (submodule peerOpts);
142 };
143 };
144 };
145
146 # peer options
147
148 peerOpts = {
149 options = {
150 publicKey = mkOption {
151 example = "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=";
152 type = types.str;
153 description = lib.mdDoc "The base64 public key to the peer.";
154 };
155
156 presharedKey = mkOption {
157 default = null;
158 example = "rVXs/Ni9tu3oDBLS4hOyAUAa1qTWVA3loR8eL20os3I=";
159 type = with types; nullOr str;
160 description = lib.mdDoc ''
161 Base64 preshared key generated by {command}`wg genpsk`.
162 Optional, and may be omitted. This option adds an additional layer of
163 symmetric-key cryptography to be mixed into the already existing
164 public-key cryptography, for post-quantum resistance.
165
166 Warning: Consider using presharedKeyFile instead if you do not
167 want to store the key in the world-readable Nix store.
168 '';
169 };
170
171 presharedKeyFile = mkOption {
172 default = null;
173 example = "/private/wireguard_psk";
174 type = with types; nullOr str;
175 description = lib.mdDoc ''
176 File pointing to preshared key as generated by {command}`wg genpsk`.
177 Optional, and may be omitted. This option adds an additional layer of
178 symmetric-key cryptography to be mixed into the already existing
179 public-key cryptography, for post-quantum resistance.
180 '';
181 };
182
183 allowedIPs = mkOption {
184 example = [ "10.192.122.3/32" "10.192.124.1/24" ];
185 type = with types; listOf str;
186 description = lib.mdDoc ''List of IP (v4 or v6) addresses with CIDR masks from
187 which this peer is allowed to send incoming traffic and to which
188 outgoing traffic for this peer is directed. The catch-all 0.0.0.0/0 may
189 be specified for matching all IPv4 addresses, and ::/0 may be specified
190 for matching all IPv6 addresses.'';
191 };
192
193 endpoint = mkOption {
194 default = null;
195 example = "demo.wireguard.io:12913";
196 type = with types; nullOr str;
197 description = lib.mdDoc ''Endpoint IP or hostname of the peer, followed by a colon,
198 and then a port number of the peer.'';
199 };
200
201 persistentKeepalive = mkOption {
202 default = null;
203 type = with types; nullOr int;
204 example = 25;
205 description = lib.mdDoc ''This is optional and is by default off, because most
206 users will not need it. It represents, in seconds, between 1 and 65535
207 inclusive, how often to send an authenticated empty packet to the peer,
208 for the purpose of keeping a stateful firewall or NAT mapping valid
209 persistently. For example, if the interface very rarely sends traffic,
210 but it might at anytime receive traffic from a peer, and it is behind
211 NAT, the interface might benefit from having a persistent keepalive
212 interval of 25 seconds; however, most users will not need this.'';
213 };
214 };
215 };
216
217 writeScriptFile = name: text: ((pkgs.writeShellScriptBin name text) + "/bin/${name}");
218
219 generateUnit = name: values:
220 assert assertMsg (values.configFile != null || ((values.privateKey != null) != (values.privateKeyFile != null))) "Only one of privateKey, configFile or privateKeyFile may be set";
221 let
222 preUpFile = if values.preUp != "" then writeScriptFile "preUp.sh" values.preUp else null;
223 postUp =
224 optional (values.privateKeyFile != null) "wg set ${name} private-key <(cat ${values.privateKeyFile})" ++
225 (concatMap (peer: optional (peer.presharedKeyFile != null) "wg set ${name} peer ${peer.publicKey} preshared-key <(cat ${peer.presharedKeyFile})") values.peers) ++
226 optional (values.postUp != "") values.postUp;
227 postUpFile = if postUp != [] then writeScriptFile "postUp.sh" (concatMapStringsSep "\n" (line: line) postUp) else null;
228 preDownFile = if values.preDown != "" then writeScriptFile "preDown.sh" values.preDown else null;
229 postDownFile = if values.postDown != "" then writeScriptFile "postDown.sh" values.postDown else null;
230 configDir = pkgs.writeTextFile {
231 name = "config-${name}";
232 executable = false;
233 destination = "/${name}.conf";
234 text =
235 ''
236 [interface]
237 ${concatMapStringsSep "\n" (address:
238 "Address = ${address}"
239 ) values.address}
240 ${concatMapStringsSep "\n" (dns:
241 "DNS = ${dns}"
242 ) values.dns}
243 '' +
244 optionalString (values.table != null) "Table = ${values.table}\n" +
245 optionalString (values.mtu != null) "MTU = ${toString values.mtu}\n" +
246 optionalString (values.privateKey != null) "PrivateKey = ${values.privateKey}\n" +
247 optionalString (values.listenPort != null) "ListenPort = ${toString values.listenPort}\n" +
248 optionalString (preUpFile != null) "PreUp = ${preUpFile}\n" +
249 optionalString (postUpFile != null) "PostUp = ${postUpFile}\n" +
250 optionalString (preDownFile != null) "PreDown = ${preDownFile}\n" +
251 optionalString (postDownFile != null) "PostDown = ${postDownFile}\n" +
252 concatMapStringsSep "\n" (peer:
253 assert assertMsg (!((peer.presharedKeyFile != null) && (peer.presharedKey != null))) "Only one of presharedKey or presharedKeyFile may be set";
254 "[Peer]\n" +
255 "PublicKey = ${peer.publicKey}\n" +
256 optionalString (peer.presharedKey != null) "PresharedKey = ${peer.presharedKey}\n" +
257 optionalString (peer.endpoint != null) "Endpoint = ${peer.endpoint}\n" +
258 optionalString (peer.persistentKeepalive != null) "PersistentKeepalive = ${toString peer.persistentKeepalive}\n" +
259 optionalString (peer.allowedIPs != []) "AllowedIPs = ${concatStringsSep "," peer.allowedIPs}\n"
260 ) values.peers;
261 };
262 configPath =
263 if values.configFile != null then
264 # This uses bind-mounted private tmp folder (/tmp/systemd-private-***)
265 "/tmp/${name}.conf"
266 else
267 "${configDir}/${name}.conf";
268 in
269 nameValuePair "wg-quick-${name}"
270 {
271 description = "wg-quick WireGuard Tunnel - ${name}";
272 requires = [ "network-online.target" ];
273 after = [ "network.target" "network-online.target" ];
274 wantedBy = optional values.autostart "multi-user.target";
275 environment.DEVICE = name;
276 path = [
277 pkgs.wireguard-tools
278 config.networking.firewall.package # iptables or nftables
279 config.networking.resolvconf.package # openresolv or systemd
280 ];
281
282 serviceConfig = {
283 Type = "oneshot";
284 RemainAfterExit = true;
285 };
286
287 script = ''
288 ${optionalString (!config.boot.isContainer) "${pkgs.kmod}/bin/modprobe wireguard"}
289 ${optionalString (values.configFile != null) ''
290 cp ${values.configFile} ${configPath}
291 ''}
292 wg-quick up ${configPath}
293 '';
294
295 serviceConfig = {
296 # Used to privately store renamed copies of external config files during activation
297 PrivateTmp = true;
298 };
299
300 preStop = ''
301 wg-quick down ${configPath}
302 '';
303 };
304in {
305
306 ###### interface
307
308 options = {
309 networking.wg-quick = {
310 interfaces = mkOption {
311 description = lib.mdDoc "Wireguard interfaces.";
312 default = {};
313 example = {
314 wg0 = {
315 address = [ "192.168.20.4/24" ];
316 privateKey = "yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=";
317 peers = [
318 { allowedIPs = [ "192.168.20.1/32" ];
319 publicKey = "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=";
320 endpoint = "demo.wireguard.io:12913"; }
321 ];
322 };
323 };
324 type = with types; attrsOf (submodule interfaceOpts);
325 };
326 };
327 };
328
329
330 ###### implementation
331
332 config = mkIf (cfg.interfaces != {}) {
333 boot.extraModulePackages = optional (versionOlder kernel.kernel.version "5.6") kernel.wireguard;
334 environment.systemPackages = [ pkgs.wireguard-tools ];
335 systemd.services = mapAttrs' generateUnit cfg.interfaces;
336
337 # Prevent networkd from clearing the rules set by wg-quick when restarted (e.g. when waking up from suspend).
338 systemd.network.config.networkConfig.ManageForeignRoutingPolicyRules = mkDefault false;
339
340 # WireGuard interfaces should be ignored in determining whether the network is online.
341 systemd.network.wait-online.ignoredInterfaces = builtins.attrNames cfg.interfaces;
342 };
343}