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