From fe14acf0621e0fc630560c714a7318dcb08eef9a Mon Sep 17 00:00:00 2001 From: Sanfer Date: Sun, 19 Apr 2026 19:51:37 +0530 Subject: [PATCH] nixos/nordvpn: init module --- nixos/doc/manual/redirects.json | 3 + .../manual/release-notes/rl-2611.section.md | 2 + nixos/modules/module-list.nix | 1 + nixos/modules/services/networking/nordvpn.md | 65 +++++++ nixos/modules/services/networking/nordvpn.nix | 171 ++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/nordvpn.nix | 129 +++++++++++++ 7 files changed, 372 insertions(+) create mode 100644 nixos/modules/services/networking/nordvpn.md create mode 100644 nixos/modules/services/networking/nordvpn.nix create mode 100644 nixos/tests/nordvpn.nix diff --git a/nixos/doc/manual/redirects.json b/nixos/doc/manual/redirects.json index fa6f1989ba71..f767735f9e37 100644 --- a/nixos/doc/manual/redirects.json +++ b/nixos/doc/manual/redirects.json @@ -1891,6 +1891,9 @@ "sec-kubernetes": [ "index.html#sec-kubernetes" ], + "module-services-nordvpn": [ + "index.html#module-services-nordvpn" + ], "ch-running": [ "index.html#ch-running" ], diff --git a/nixos/doc/manual/release-notes/rl-2611.section.md b/nixos/doc/manual/release-notes/rl-2611.section.md index 50cec08d2e68..1b7153bfe19e 100644 --- a/nixos/doc/manual/release-notes/rl-2611.section.md +++ b/nixos/doc/manual/release-notes/rl-2611.section.md @@ -54,6 +54,8 @@ - [OO7](https://github.com/linux-credentials/oo7) is a desktop-agnostic Secret Service provider. Available as [services.oo7](#opt-services.oo7.enable) +- [NordVPN](https://github.com/NordSecurity/nordvpn-linux), a NordVPN client for linux. Available as [services.nordvpn](options.html#opt-services.nordvpn.enable). + ## Backward Incompatibilities {#sec-release-26.11-incompatibilities} diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 185b063dd037..d80b46f66bee 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1341,6 +1341,7 @@ ./services/networking/nncp.nix ./services/networking/nntp-proxy.nix ./services/networking/nomad.nix + ./services/networking/nordvpn.nix ./services/networking/nsd.nix ./services/networking/ntopng.nix ./services/networking/ntp/chrony.nix diff --git a/nixos/modules/services/networking/nordvpn.md b/nixos/modules/services/networking/nordvpn.md new file mode 100644 index 000000000000..06016098894a --- /dev/null +++ b/nixos/modules/services/networking/nordvpn.md @@ -0,0 +1,65 @@ +# NordVPN {#module-services-nordvpn} + +*Source:* {file}`modules/services/networking/nordvpn.nix` + +*Upstream documentation:* + +NordVPN offers a paid virtual private network (VPN) service. +The service operates as closed-source, +but the Linux client uses open-source code licensed under GPLv3. +A minimal configuration in NixOS appears as follows: + +```nix +{ + services.nordvpn.enable = true; + networking.firewall.enable = true; + networking.firewall.checkReversePath = "loose"; +} +``` + +When using a firewall, set `networking.firewall.checkReversePath` to `"loose"` or `false`. +NordVPN includes a `kill-switch` feature that blocks all packets not associated with the VPN connection. + +Additionally, add your user to the `nordvpn` group. + +```nix +{ + users.users.yourUser = { + #.. + extraGroups = [ + #.. + "nordvpn" + ]; + }; +} +``` + +If you prefer to use your own user and group, you can do so using + +```nix +{ + services.nordvpn.user = "SOME-USER"; + services.nordvpn.group = "SOME-GROUP"; +} +``` + +NordVPN provides several useful CLI commands, including: + +```bash +nordvpn login # Log in using an OAuth URL +nordvpn login --token # Log in with a token obtained from your NordVPN account +nordvpn c # Connect to the VPN +nordvpn c ie # Connect to a NordVPN server in Ireland +nordvpn d # Disconnect from the VPN +nordvpn set technology openvpn # Switch to OpenVPN technology +nordvpn c # Reconnect after changing technology +``` + +Additionally, if you prefer to use the friendly GUI, + +```bash +nordvpn-gui +``` + +**Disclaimer:** NixOS currently does not support meshnet. +Contributions welcome! diff --git a/nixos/modules/services/networking/nordvpn.nix b/nixos/modules/services/networking/nordvpn.nix new file mode 100644 index 000000000000..b38d75dd519d --- /dev/null +++ b/nixos/modules/services/networking/nordvpn.nix @@ -0,0 +1,171 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.nordvpn; + defaultUser = "nordvpn"; + defaultGroup = "nordvpn"; + + nordvpn = + let + cli = cfg.package.cli.overrideAttrs (old: { + preBuild = + let + extraPreBuild = lib.optionalString (cfg.group != defaultGroup) '' + substituteInPlace internal/permissions.go \ + --replace-fail \ + 'string{"nordvpn"}' \ + 'string{"${cfg.group}"}' + + substituteInPlace internal/constants.go \ + --replace-fail \ + 'NordvpnGroup = "nordvpn"' \ + 'NordvpnGroup = "${cfg.group}"' + ''; + in + extraPreBuild + (old.preBuild or ""); + + # postFixup wraps nordvpnd so that it can find binaries that it calls. + # here, instead, use systemd to update the path to those binaries. + postFixup = ""; + }); + in + pkgs.symlinkJoin { + inherit (cfg.package) pname version meta; + paths = [ + cli + cfg.package.gui + ]; + }; +in +{ + options.services.nordvpn = { + enable = lib.mkEnableOption "Enable NordVPN"; + package = lib.mkPackageOption pkgs "nordvpn" { }; + user = lib.mkOption { + type = lib.types.str; + default = defaultUser; + description = '' + The User that owns the `nordvpnd` systemd process. + If overriding the default, a user with the same name must exist. + ''; + }; + group = lib.mkOption { + type = lib.types.str; + default = defaultGroup; + description = '' + The Group that owns the `nordvpnd` systemd process. + If overriding the default, a group with the same name must exist. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + + # create the default user if that's the one used + users.users = lib.mkIf (cfg.user == defaultUser) { + ${defaultUser} = { + description = "User that runs `nordvpnd`."; + group = cfg.group; + isSystemUser = true; + }; + }; + + # create the default group if that's the one used + users.groups = lib.mkIf (cfg.group == defaultGroup) { + ${defaultGroup} = { }; + }; + + # nordvpnd uses resolved to configure dns + services.resolved.enable = true; + + # policy that allows nordvpnd to configure dns + security.polkit = { + enable = true; + extraConfig = '' + polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.resolve1.set-dns-servers" + && subject.isInGroup("${cfg.group}")) { + return polkit.Result.YES; + } + }); + ''; + }; + + environment.systemPackages = [ + nordvpn + ]; + + systemd.services.nordvpnd = { + after = [ "network-online.target" ]; + description = "NordVPN daemon."; + path = ( + with pkgs; + [ + e2fsprogs + iproute2 + libxslt + nftables + procps + wireguard-tools + ] + ++ [ nordvpn ] + ); + serviceConfig = { + # nordvpnd needs CAP_NET_ADMIN to configure network interfaces + AmbientCapabilities = "CAP_NET_ADMIN"; + CapabilityBoundingSet = "CAP_NET_ADMIN"; + ExecStart = lib.getExe' nordvpn "nordvpnd"; + Group = cfg.group; + KillMode = "process"; + NonBlocking = true; + Requires = "nordvpnd.socket"; + Restart = "on-failure"; + RestartSec = 5; + RuntimeDirectory = "nordvpn"; + RuntimeDirectoryMode = "0750"; + StateDirectory = "nordvpn"; + StateDirectoryMode = "0750"; + User = cfg.user; + }; + wantedBy = [ "default.target" ]; + wants = [ "network-online.target" ]; + }; + + systemd.sockets.nordvpnd = { + description = "NordVPN Daemon Socket"; + listenStreams = [ "/run/nordvpn/nordvpnd.sock" ]; + partOf = [ "nordvpnd.service" ]; + socketConfig = { + DirectoryMode = "0750"; + NoDelay = true; + SocketGroup = cfg.group; + SocketMode = "0770"; + SocketUser = cfg.user; + }; + wantedBy = [ "sockets.target" ]; + }; + + systemd.user.services.norduserd = { + after = [ "network-online.target" ]; + description = "NordUserD Service"; + serviceConfig = { + ExecStart = lib.getExe' nordvpn "norduserd"; + NonBlocking = true; + Restart = "on-failure"; + RestartSec = 5; + }; + wantedBy = [ "graphical-session.target" ]; + wants = [ "network-online.target" ]; + }; + + }; + + meta = { + doc = ./nordvpn.md; + maintainers = with lib.maintainers; [ different-error ]; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index f09c4b473b53..6abfd2d5d6c4 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1205,6 +1205,7 @@ in nominatim = runTest ./nominatim.nix; non-default-filesystems = handleTest ./non-default-filesystems.nix { }; non-switchable-system = runTest ./non-switchable-system.nix; + nordvpn = runTest ./nordvpn.nix; noto-fonts = runTest ./noto-fonts.nix; noto-fonts-cjk-qt-default-weight = runTest ./noto-fonts-cjk-qt-default-weight.nix; novacomd = handleTestOn [ "x86_64-linux" ] ./novacomd.nix { }; diff --git a/nixos/tests/nordvpn.nix b/nixos/tests/nordvpn.nix new file mode 100644 index 000000000000..59af7c54660f --- /dev/null +++ b/nixos/tests/nordvpn.nix @@ -0,0 +1,129 @@ +{ lib, ... }: +{ + name = "nordvpn"; + meta.maintainers = with lib.maintainers; [ different-error ]; + nodes = + let + commonConfig = user: { + # norduserd reads DBUS_SESSION_BUS_ADDRESS which the + # desktopManager sets on user session creation (i.e. login) + services.xserver.enable = true; + services.desktopManager.plasma6.enable = true; + services.displayManager.gdm.enable = true; + services.displayManager.autoLogin = { + enable = true; + user = user; + }; + }; + in + { + nada = { ... }: { }; + basic = + { ... }: + lib.recursiveUpdate { + users.users.alice = { + extraGroups = [ "nordvpn" ]; + isNormalUser = true; + }; + # default: run nordvpnd as nordvpn:nordvpn + services.nordvpn.enable = true; + } (commonConfig "alice"); + userOnly = + { ... }: + lib.recursiveUpdate { + users.users.kanye = { + extraGroups = [ "nordvpn" ]; + isNormalUser = true; + }; + # run nordvpnd as kanye:nordvpn + services.nordvpn = { + enable = true; + user = "kanye"; + }; + } (commonConfig "kanye"); + groupOnly = + { ... }: + lib.recursiveUpdate { + users.users.alice = { + extraGroups = [ "kanye" ]; + isNormalUser = true; + }; + users.groups.kanye = { }; + # run nordvpnd as nordvpn:kanye + services.nordvpn = { + enable = true; + group = "kanye"; + }; + } (commonConfig "alice"); + userAndGroup = + { ... }: + lib.recursiveUpdate { + users.users.kanye = { + group = "kanye"; + isNormalUser = true; + }; + users.groups.kanye = { }; + # run nordvpnd as kanye:kanye + services.nordvpn = { + enable = true; + group = "kanye"; + user = "kanye"; + }; + } (commonConfig "kanye"); + }; + + testScript = '' + class UserGroupTestCase: + def __init__(self, machine, user, has_nordvpn_usr, has_nordvpn_gp): + self.machine = machine + self.user = user + self.has_nordvpn_usr = has_nordvpn_usr + self.has_nordvpn_gp = has_nordvpn_gp + + def run(self): + self.machine.start() + self.verify_nordvpn_user() + self.verify_nordvpn_group() + self.verify_services() + self.machine.shutdown() + + def verify_nordvpn_user(self): + if self.has_nordvpn_usr: + self.machine.succeed("id nordvpn") + else: + self.machine.fail("id nordvpn") + + def verify_nordvpn_group(self): + group_str = self.machine.succeed(f"sudo -u {self.user} groups") + groups = [x.strip() for x in group_str.split(" ")] + if self.has_nordvpn_gp: + assert "nordvpn" in groups, f"nordvpn is not in {groups} but should be" + else: + assert "nordvpn" not in groups, f"nordvpn is in {groups} but should not be" + + def verify_services(self): + self.machine.wait_for_unit("nordvpnd", timeout=60) + self.machine.wait_for_unit("norduserd", self.user, timeout=90) + # verify can talk to nordvpnd. give nordvpnd at most 5s to initialize. + self.machine.wait_until_succeeds("nordvpn status", timeout=5) + self.machine.succeed("nordvpn status") + + test_cases = [ + UserGroupTestCase(basic, "alice", has_nordvpn_usr=True, has_nordvpn_gp=True), + UserGroupTestCase(userOnly, "kanye", has_nordvpn_usr=False, has_nordvpn_gp=True), + UserGroupTestCase(groupOnly, "alice", has_nordvpn_usr=True, has_nordvpn_gp=False), + UserGroupTestCase(userAndGroup, "kanye", has_nordvpn_usr=False, has_nordvpn_gp=False), + ] + + # NADA + nada.start() + nada.wait_for_unit("multi-user.target", timeout=60) + nada.fail("nordvpnd") + nada.fail("nordvpn") + nada.fail("norduserd") + nada.shutdown() + + for test_case in test_cases: + test_case.run() + ''; +}