1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 inherit (lib)
9 attrValues
10 concatLists
11 concatStringsSep
12 escapeShellArgs
13 filterAttrs
14 getExe
15 literalExpression
16 maintainers
17 makeBinPath
18 mapAttrs'
19 mapAttrsToList
20 mkAliasOptionModule
21 mkDefault
22 mkIf
23 mkMerge
24 mkOption
25 mkOptionDefault
26 mkPackageOption
27 nameValuePair
28 optional
29 optionalAttrs
30 optionalString
31 toShellVars
32 versionAtLeast
33 versionOlder
34 ;
35
36 inherit (lib.types)
37 attrsOf
38 bool
39 enum
40 nullOr
41 package
42 path
43 port
44 str
45 submodule
46 ;
47
48 inherit (config.boot) kernelPackages;
49 inherit (config.boot.kernelPackages) kernel;
50
51 cfg = config.services.netbird;
52
53 toClientList = fn: map fn (attrValues cfg.clients);
54 toClientAttrs = fn: mapAttrs' (_: fn) cfg.clients;
55
56 hardenedClients = filterAttrs (_: client: client.hardened) cfg.clients;
57 toHardenedClientList = fn: map fn (attrValues hardenedClients);
58 toHardenedClientAttrs = fn: mapAttrs' (_: fn) hardenedClients;
59
60 mkBinName =
61 client: name:
62 if client.bin.suffix == "" || client.bin.suffix == "netbird" then
63 name
64 else
65 "${name}-${client.bin.suffix}";
66
67 nixosConfig = config;
68in
69{
70 meta.maintainers = with maintainers; [
71 nazarewk
72 ];
73 meta.doc = ./netbird.md;
74
75 imports = [
76 (mkAliasOptionModule [ "services" "netbird" "tunnels" ] [ "services" "netbird" "clients" ])
77 ];
78
79 options.services.netbird = {
80 enable = mkOption {
81 type = bool;
82 default = false;
83 description = ''
84 Enables backwards compatible Netbird client service.
85
86 This is strictly equivalent to:
87
88 ```nix
89 services.netbird.clients.default = {
90 port = 51820;
91 name = "netbird";
92 systemd.name = "netbird";
93 interface = "wt0";
94 hardened = false;
95 };
96 ```
97 '';
98 };
99 package = mkPackageOption pkgs "netbird" { };
100
101 ui.enable = mkOption {
102 type = bool;
103 default = config.services.displayManager.sessionPackages != [ ] || config.services.xserver.enable;
104 defaultText = literalExpression ''
105 config.services.displayManager.sessionPackages != [ ] || config.services.xserver.enable
106 '';
107 description = ''
108 Controls presence `netbird-ui` wrappers, defaults to presence of graphical sessions.
109 '';
110 };
111 ui.package = mkPackageOption pkgs "netbird-ui" { };
112
113 clients = mkOption {
114 type = attrsOf (
115 submodule (
116 { name, config, ... }:
117 let
118 client = config;
119 in
120 {
121 options = {
122 port = mkOption {
123 type = port;
124 example = literalExpression "51820";
125 description = ''
126 Port the Netbird client listens on.
127 '';
128 };
129
130 name = mkOption {
131 type = str;
132 default = name;
133 description = ''
134 Primary name for use (as a suffix) in:
135 - systemd service name,
136 - hardened user name and group,
137 - [systemd `*Directory=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#RuntimeDirectory=) names,
138 - desktop application identification,
139 '';
140 };
141
142 dns-resolver.address = mkOption {
143 type = nullOr str;
144 default = null;
145 example = "127.0.0.123";
146 description = ''
147 An explicit address that Netbird will serve `*.netbird.cloud.` (usually) entries on.
148
149 Netbird serves DNS on it's own (dynamic) client address by default.
150 '';
151 };
152
153 dns-resolver.port = mkOption {
154 type = port;
155 default = 53;
156 description = ''
157 A port to serve DNS entries on when `dns-resolver.address` is enabled.
158 '';
159 };
160
161 interface = mkOption {
162 type = str;
163 default = "nb-${client.name}";
164 description = ''
165 Name of the network interface managed by this client.
166 '';
167 apply =
168 iface:
169 lib.throwIfNot (
170 builtins.stringLength iface <= 15
171 ) "Network interface name must be 15 characters or less" iface;
172 };
173
174 environment = mkOption {
175 type = attrsOf str;
176 defaultText = literalExpression ''
177 {
178 NB_STATE_DIR = client.dir.state;
179 NB_CONFIG = "''${client.dir.state}/config.json";
180 NB_DAEMON_ADDR = "unix://''${client.dir.runtime}/sock";
181 NB_INTERFACE_NAME = client.interface;
182 NB_LOG_FILE = mkOptionDefault "console";
183 NB_LOG_LEVEL = client.logLevel;
184 NB_SERVICE = client.service.name;
185 NB_WIREGUARD_PORT = toString client.port;
186 } // optionalAttrs (client.dns-resolver.address != null) {
187 NB_DNS_RESOLVER_ADDRESS = "''${client.dns-resolver.address}:''${builtins.toString client.dns-resolver.port}";
188 }
189 '';
190 description = ''
191 Environment for the netbird service, used to pass configuration options.
192 '';
193 };
194
195 autoStart = mkOption {
196 type = bool;
197 default = true;
198 description = ''
199 Start the service with the system.
200
201 As of 2024-02-13 it is not possible to start a Netbird client daemon without immediately
202 connecting to the network, but it is [planned for a near future](https://github.com/netbirdio/netbird/projects/2#card-91718018).
203 '';
204 };
205
206 openFirewall = mkOption {
207 type = bool;
208 default = true;
209 description = ''
210 Opens up firewall `port` for communication between Netbird peers directly over LAN or public IP,
211 without using (internet-hosted) TURN servers as intermediaries.
212 '';
213 };
214
215 hardened = mkOption {
216 type = bool;
217 default = true;
218 description = ''
219 Hardened service:
220 - runs as a dedicated user with minimal set of permissions (see caveats),
221 - restricts daemon configuration socket access to dedicated user group
222 (you can grant access to it with `users.users."<user>".extraGroups = [ ${client.user.group} ]`),
223
224 Even though the local system resources access is restricted:
225 - `CAP_NET_RAW`, `CAP_NET_ADMIN` and `CAP_BPF` still give unlimited network manipulation possibilites,
226 - older kernels don't have `CAP_BPF` and use `CAP_SYS_ADMIN` instead,
227
228 Known security features that are not (yet) integrated into the module:
229 - 2024-02-14: `rosenpass` is an experimental feature configurable solely
230 through `--enable-rosenpass` flag on the `netbird up` command,
231 see [the docs](https://docs.netbird.io/how-to/enable-post-quantum-cryptography)
232 '';
233 };
234
235 logLevel = mkOption {
236 type = enum [
237 # logrus loglevels
238 "panic"
239 "fatal"
240 "error"
241 "warn"
242 "warning"
243 "info"
244 "debug"
245 "trace"
246 ];
247 default = "info";
248 description = "Log level of the Netbird daemon.";
249 };
250
251 ui.enable = mkOption {
252 type = bool;
253 default = nixosConfig.services.netbird.ui.enable;
254 defaultText = literalExpression ''client.ui.enable'';
255 description = ''
256 Controls presence of `netbird-ui` wrapper for this Netbird client.
257 '';
258 };
259
260 wrapper = mkOption {
261 type = package;
262 internal = true;
263 default =
264 let
265 makeWrapperArgs = concatLists (
266 mapAttrsToList (key: value: [
267 "--set-default"
268 key
269 value
270 ]) client.environment
271 );
272 mkBin = mkBinName client;
273 in
274 pkgs.stdenv.mkDerivation {
275 name = "${cfg.package.name}-wrapper-${client.name}";
276 meta.mainProgram = mkBin "netbird";
277 nativeBuildInputs = with pkgs; [ makeWrapper ];
278 phases = [ "installPhase" ];
279 installPhase = concatStringsSep "\n" [
280 ''
281 mkdir -p "$out/bin"
282 makeWrapper ${lib.getExe cfg.package} "$out/bin/${mkBin "netbird"}" \
283 ${escapeShellArgs makeWrapperArgs}
284 ''
285 (optionalString cfg.ui.enable ''
286 # netbird-ui doesn't support envvars
287 makeWrapper ${lib.getExe cfg.ui.package} "$out/bin/${mkBin "netbird-ui"}" \
288 --add-flags '--daemon-addr=${client.environment.NB_DAEMON_ADDR}'
289
290 mkdir -p "$out/share/applications"
291 substitute ${cfg.ui.package}/share/applications/netbird.desktop \
292 "$out/share/applications/${mkBin "netbird"}.desktop" \
293 --replace-fail 'Name=Netbird' "Name=Netbird @ ${client.service.name}" \
294 --replace-fail '${lib.getExe cfg.ui.package}' "$out/bin/${mkBin "netbird-ui"}"
295 '')
296 ];
297 };
298 };
299
300 # see https://github.com/netbirdio/netbird/blob/88747e3e0191abc64f1e8c7ecc65e5e50a1527fd/client/internal/config.go#L49-L82
301 config = mkOption {
302 type = (pkgs.formats.json { }).type;
303 defaultText = literalExpression ''
304 {
305 DisableAutoConnect = !client.autoStart;
306 WgIface = client.interface;
307 WgPort = client.port;
308 } // optionalAttrs (client.dns-resolver.address != null) {
309 CustomDNSAddress = "''${client.dns-resolver.address}:''${builtins.toString client.dns-resolver.port}";
310 }
311 '';
312 description = ''
313 Additional configuration that exists before the first start and
314 later overrides the existing values in `config.json`.
315
316 It is mostly helpful to manage configuration ignored/not yet implemented
317 outside of `netbird up` invocation.
318
319 WARNING: this is not an upstream feature, it could break in the future
320 (by having lower priority) after upstream implements an equivalent.
321
322 It is implemented as a `preStart` script which overrides `config.json`
323 with content of `/etc/${client.dir.baseName}/config.d/*.json` files.
324 This option manages specifically `50-nixos.json` file.
325
326 Consult [the source code](https://github.com/netbirdio/netbird/blob/88747e3e0191abc64f1e8c7ecc65e5e50a1527fd/client/internal/config.go#L49-L82)
327 or inspect existing file for a complete list of available configurations.
328 '';
329 };
330
331 suffixedName = mkOption {
332 type = str;
333 default = if client.name != "netbird" then "netbird-${client.name}" else client.name;
334 description = ''
335 A systemd service name to use (without `.service` suffix).
336 '';
337 };
338 dir.baseName = mkOption {
339 type = str;
340 default = client.suffixedName;
341 description = ''
342 A systemd service name to use (without `.service` suffix).
343 '';
344 };
345 dir.state = mkOption {
346 type = path;
347 default = "/var/lib/${client.dir.baseName}";
348 description = ''
349 A state directory used by Netbird client to store `config.json`, `state.json` & `resolv.conf`.
350 '';
351 };
352 dir.runtime = mkOption {
353 type = path;
354 default = "/var/run/${client.dir.baseName}";
355 description = ''
356 A runtime directory used by Netbird client.
357 '';
358 };
359 service.name = mkOption {
360 type = str;
361 default = client.suffixedName;
362 description = ''
363 A systemd service name to use (without `.service` suffix).
364 '';
365 };
366 user.name = mkOption {
367 type = str;
368 default = client.suffixedName;
369 description = ''
370 A system user name for this client instance.
371 '';
372 };
373 user.group = mkOption {
374 type = str;
375 default = client.suffixedName;
376 description = ''
377 A system group name for this client instance.
378 '';
379 };
380 bin.suffix = mkOption {
381 type = str;
382 default = if client.name != "netbird" then client.name else "";
383 description = ''
384 A system group name for this client instance.
385 '';
386 };
387 };
388
389 config.environment =
390 {
391 NB_STATE_DIR = client.dir.state;
392 NB_CONFIG = "${client.dir.state}/config.json";
393 NB_DAEMON_ADDR = "unix://${client.dir.runtime}/sock";
394 NB_INTERFACE_NAME = client.interface;
395 NB_LOG_FILE = mkOptionDefault "console";
396 NB_LOG_LEVEL = client.logLevel;
397 NB_SERVICE = client.service.name;
398 NB_WIREGUARD_PORT = toString client.port;
399 }
400 // optionalAttrs (client.dns-resolver.address != null) {
401 NB_DNS_RESOLVER_ADDRESS = "${client.dns-resolver.address}:${builtins.toString client.dns-resolver.port}";
402 };
403
404 config.config =
405 {
406 DisableAutoConnect = !client.autoStart;
407 WgIface = client.interface;
408 WgPort = client.port;
409 }
410 // optionalAttrs (client.dns-resolver.address != null) {
411 CustomDNSAddress = "${client.dns-resolver.address}:${builtins.toString client.dns-resolver.port}";
412 };
413 }
414 )
415 );
416 default = { };
417 description = ''
418 Attribute set of Netbird client daemons, by default each one will:
419
420 1. be manageable using dedicated tooling:
421 - `netbird-<name>` script,
422 - `Netbird - netbird-<name>` graphical interface when appropriate (see `ui.enable`),
423 2. run as a `netbird-<name>.service`,
424 3. listen for incoming remote connections on the port `51820` (`openFirewall` by default),
425 4. manage the `netbird-<name>` wireguard interface,
426 5. use the `/var/lib/netbird-<name>/config.json` configuration file,
427 6. override `/var/lib/netbird-<name>/config.json` with values from `/etc/netbird-<name>/config.d/*.json`,
428 7. (`hardened`) be locally manageable by `netbird-<name>` system group,
429
430 With following caveats:
431
432 - multiple daemons will interfere with each other's DNS resolution of `netbird.cloud`, but
433 should remain fully operational otherwise.
434 Setting up custom (non-conflicting) DNS zone is currently possible only when self-hosting.
435 '';
436 example = lib.literalExpression ''
437 {
438 services.netbird.clients.wt0.port = 51820;
439 services.netbird.clients.personal.port = 51821;
440 services.netbird.clients.work1.port = 51822;
441 }
442 '';
443 };
444 };
445
446 config = mkMerge [
447 (mkIf cfg.enable {
448 services.netbird.clients.default = {
449 port = mkDefault 51820;
450 interface = mkDefault "wt0";
451 name = mkDefault "netbird";
452 hardened = mkDefault false;
453 };
454 })
455 {
456 boot.extraModulePackages = optional (
457 cfg.clients != { } && (versionOlder kernel.version "5.6")
458 ) kernelPackages.wireguard;
459
460 environment.systemPackages = toClientList (client: client.wrapper)
461 # omitted due to https://github.com/netbirdio/netbird/issues/1562
462 #++ optional (cfg.clients != { }) cfg.package
463 # omitted due to https://github.com/netbirdio/netbird/issues/1581
464 #++ optional (cfg.clients != { } && cfg.ui.enable) cfg.ui.package
465 ;
466
467 networking.dhcpcd.denyInterfaces = toClientList (client: client.interface);
468 networking.networkmanager.unmanaged = toClientList (client: "interface-name:${client.interface}");
469
470 networking.firewall.allowedUDPPorts = concatLists (
471 toClientList (client: optional client.openFirewall client.port)
472 );
473
474 systemd.network.networks = mkIf config.networking.useNetworkd (
475 toClientAttrs (
476 client:
477 nameValuePair "50-netbird-${client.interface}" {
478 matchConfig = {
479 Name = client.interface;
480 };
481 linkConfig = {
482 Unmanaged = true;
483 ActivationPolicy = "manual";
484 };
485 }
486 )
487 );
488
489 environment.etc = toClientAttrs (
490 client:
491 nameValuePair "${client.dir.baseName}/config.d/50-nixos.json" {
492 text = builtins.toJSON client.config;
493 mode = "0444";
494 }
495 );
496
497 systemd.services = toClientAttrs (
498 client:
499 nameValuePair client.service.name {
500 description = "A WireGuard-based mesh network that connects your devices into a single private network";
501
502 documentation = [ "https://netbird.io/docs/" ];
503
504 after = [ "network.target" ];
505 wantedBy = [ "multi-user.target" ];
506
507 path = optional (!config.services.resolved.enable) pkgs.openresolv;
508
509 serviceConfig = {
510 ExecStart = "${getExe client.wrapper} service run";
511 Restart = "always";
512
513 RuntimeDirectory = client.dir.baseName;
514 RuntimeDirectoryMode = mkDefault "0755";
515 ConfigurationDirectory = client.dir.baseName;
516 StateDirectory = client.dir.baseName;
517 StateDirectoryMode = "0700";
518
519 WorkingDirectory = client.dir.state;
520 };
521
522 unitConfig = {
523 StartLimitInterval = 5;
524 StartLimitBurst = 10;
525 };
526
527 stopIfChanged = false;
528 }
529 );
530 }
531 # Hardening section
532 (mkIf (hardenedClients != { }) {
533 users.groups = toHardenedClientAttrs (client: nameValuePair client.user.group { });
534 users.users = toHardenedClientAttrs (
535 client:
536 nameValuePair client.user.name {
537 isSystemUser = true;
538 home = client.dir.state;
539 group = client.user.group;
540 }
541 );
542
543 systemd.services = toHardenedClientAttrs (
544 client:
545 nameValuePair client.service.name (
546 mkIf client.hardened {
547 serviceConfig = {
548 RuntimeDirectoryMode = "0750";
549
550 User = client.user.name;
551 Group = client.user.group;
552
553 # settings implied by DynamicUser=true, without actully using it,
554 # see https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#DynamicUser=
555 RemoveIPC = true;
556 PrivateTmp = true;
557 ProtectSystem = "strict";
558 ProtectHome = "yes";
559
560 AmbientCapabilities =
561 [
562 # see https://man7.org/linux/man-pages/man7/capabilities.7.html
563 # see https://docs.netbird.io/how-to/installation#running-net-bird-in-docker
564 #
565 # seems to work fine without CAP_SYS_ADMIN and CAP_SYS_RESOURCE
566 # CAP_NET_BIND_SERVICE could be added to allow binding on low ports, but is not required,
567 # see https://github.com/netbirdio/netbird/pull/1513
568
569 # failed creating tunnel interface wt-priv: [operation not permitted
570 "CAP_NET_ADMIN"
571 # failed to pull up wgInterface [wt-priv]: failed to create ipv4 raw socket: socket: operation not permitted
572 "CAP_NET_RAW"
573 ]
574 # required for eBPF filter, used to be subset of CAP_SYS_ADMIN
575 ++ optional (versionAtLeast kernel.version "5.8") "CAP_BPF"
576 ++ optional (versionOlder kernel.version "5.8") "CAP_SYS_ADMIN"
577 ++ optional (
578 client.dns-resolver.address != null && client.dns-resolver.port < 1024
579 ) "CAP_NET_BIND_SERVICE";
580 };
581 }
582 )
583 );
584
585 # see https://github.com/systemd/systemd/blob/17f3e91e8107b2b29fe25755651b230bbc81a514/src/resolve/org.freedesktop.resolve1.policy#L43-L43
586 # see all actions used at https://github.com/netbirdio/netbird/blob/13e7198046a0d73a9cd91bf8e063fafb3d41885c/client/internal/dns/systemd_linux.go#L29-L32
587 security.polkit.extraConfig = mkIf config.services.resolved.enable ''
588 // systemd-resolved access for Netbird clients
589 polkit.addRule(function(action, subject) {
590 var actions = [
591 "org.freedesktop.resolve1.revert",
592 "org.freedesktop.resolve1.set-default-route",
593 "org.freedesktop.resolve1.set-dns-servers",
594 "org.freedesktop.resolve1.set-domains",
595 ];
596 var users = ${builtins.toJSON (toHardenedClientList (client: client.user.name))};
597
598 if (actions.indexOf(action.id) >= 0 && users.indexOf(subject.user) >= 0 ) {
599 return polkit.Result.YES;
600 }
601 });
602 '';
603 })
604 # migration & temporary fixups section
605 {
606 systemd.services = toClientAttrs (
607 client:
608 nameValuePair client.service.name {
609 preStart = ''
610 set -eEuo pipefail
611 ${optionalString (client.logLevel == "trace" || client.logLevel == "debug") "set -x"}
612
613 PATH="${
614 makeBinPath (
615 with pkgs;
616 [
617 coreutils
618 jq
619 diffutils
620 ]
621 )
622 }:$PATH"
623 export ${toShellVars client.environment}
624
625 # merge /etc/${client.dir.baseName}/config.d' into "$NB_CONFIG"
626 {
627 test -e "$NB_CONFIG" || echo -n '{}' > "$NB_CONFIG"
628
629 # merge config.d with "$NB_CONFIG" into "$NB_CONFIG.new"
630 jq -sS 'reduce .[] as $i ({}; . * $i)' \
631 "$NB_CONFIG" \
632 /etc/${client.dir.baseName}/config.d/*.json \
633 > "$NB_CONFIG.new"
634
635 echo "Comparing $NB_CONFIG with $NB_CONFIG.new ..."
636 if ! diff <(jq -S <"$NB_CONFIG") "$NB_CONFIG.new" ; then
637 echo "Updating $NB_CONFIG ..."
638 mv "$NB_CONFIG.new" "$NB_CONFIG"
639 else
640 echo "Files are the same, not doing anything."
641 rm "$NB_CONFIG.new"
642 fi
643 }
644 '';
645 }
646 );
647 }
648 ];
649}