Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions nixos/modules/services/networking/nordvpn.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,42 @@ in which case add your user to the `nordvpn` group.
}
```

NordVPN provides several CLI commands, including:
To use NordVPN's Meshnet,
declare the IP to peer mapping explicitly,
for example via `networking.hosts`:

```nix
{
networking.hosts = {
"1.2.3.4" = [
"my-alias"
"my-nick"
];
};
}
```

Find the IP to peer mapping using the command

```bash
nordvpn mesh peer list
```

which also lists devices *not* present inside {file}`/etc/hosts`.

NordVPN provides several useful CLI commands, including:

```bash
nordvpn login # Log in using an OAuth URL
nordvpn login --token <token> # Log in with a token from your 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 mesh on # Turn on meshnet
nordvpn mesh peer list # List meshnet peers
nordvpn fileshare list # List incoming/outgoing file transfers
nordvpn set protocol openvpn_tcp # Switch to OpenVPN protocol
nordvpn c # Reconnect after changing protocol
nordvpn d # Disconnect from the VPN
```

Additionally, if you prefer to use the GUI,
Expand All @@ -65,7 +91,13 @@ The NixOS nordvpn service includes the following security improvements:
- `nordvpnd` runs as an unprivileged user
- `norduserd` spawns as a user service
- `nordvpnd`'s systemd unit applies additional sandboxing,
further minimizing its blast radius
further minimizing its blast radius
- `nordvpnd` can only *read* {file}`/etc/hosts`

Additionally, the service:

- adds `nordlynx` to the firewall's `trustedInterfaces`

**Disclaimer:** NixOS does not support meshnet.
Contributions welcome!
**Disclaimer:** this module does not support nordwhisper,
NordVPN's closed-source protocol,
since it requires the proprietary `quench` library.
14 changes: 12 additions & 2 deletions nixos/modules/services/networking/nordvpn.nix
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ in
# nordvpnd uses resolved to configure dns
services.resolved.enable = true;

networking.firewall = {
# encrypted tunnels with the server and with other meshnet peers
# can trust those
trustedInterfaces = [ "nordlynx" ];
};

# policy that allows nordvpnd to configure dns
security.polkit = {
enable = true;
Expand Down Expand Up @@ -125,16 +131,19 @@ in
);
serviceConfig = {
# nordvpnd needs CAP_NET_ADMIN to configure network interfaces.
# libtelio needs CAP_NET_RAW for ping.
AmbientCapabilities = [
"CAP_NET_ADMIN"
"CAP_NET_RAW"
];
CapabilityBoundingSet = [
"CAP_NET_ADMIN"
"CAP_NET_RAW"
];
DeviceAllow = [
"/dev/net/tun rw"
];
ExecStart = lib.getExe' nordvpn "nordvpnd";
ExecStart = "${lib.getExe' nordvpn "nordvpnd"} --hosts-readonly";
Group = cfg.group;
KillMode = "process";
NoNewPrivileges = "yes";
Expand Down Expand Up @@ -179,13 +188,14 @@ in
systemd.user.services.norduserd = {
after = [ "network-online.target" ];
description = "NordUserD Service";
unitConfig.ConditionGroup = cfg.group;
serviceConfig = {
ExecStart = lib.getExe' nordvpn "norduserd";
NonBlocking = true;
Restart = "on-failure";
RestartSec = 5;
};
wantedBy = [ "default.target" ];
wantedBy = [ "graphical-session.target" ];
wants = [ "network-online.target" ];
};
};
Expand Down
54 changes: 51 additions & 3 deletions nixos/tests/nordvpn.nix
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@
nodes =
let
commonConfig = user: {
# Use the nftables-based firewall backend so `verify_trusted_interfaces`
# can inspect a native nftables ruleset. It achieves this by checking for
# the "trusted interfaces" comment, which the default `iptables-nft`
# backend omits.
networking.nftables.enable = true;

# a user outside the nordvpn group.
users.users.mallory = {
isNormalUser = true;
linger = true;
};

# norduserd reads DBUS_SESSION_BUS_ADDRESS which the
# desktopManager sets on user session creation (i.e. login)
services.xserver.enable = true;
Expand Down Expand Up @@ -100,6 +112,7 @@
self.verify_nordvpn_group()
self.verify_services()
self.verify_polkit_rules()
self.verify_trusted_interfaces()
self.verify_kernel_modules_loaded()

# nordvpnd related tests
Expand All @@ -109,6 +122,11 @@
self.verify_nordvpnd_runtime_directory()
self.verify_nordvpnd_state_directory()

# norduserd/fileshare related tests
self.verify_norduserd_socket()
self.verify_fileshare_socket()
self.verify_nonmember_service()

self.machine.shutdown()

def verify_nordvpn_user(self):
Expand Down Expand Up @@ -137,6 +155,14 @@
expected = f'subject.isInGroup("{self.nordvpn_group}")'
assert expected in rules, f"expected polkit rules to reference group {self.nordvpn_group}"

def verify_trusted_interfaces(self):
ruleset = self.machine.succeed("${lib.getExe pkgs.nftables} list ruleset")
trusted_line = next(
(line for line in ruleset.splitlines() if "trusted interfaces" in line), None
)
assert trusted_line is not None, "expected a trusted interfaces rule in the nftables ruleset"
assert "nordlynx" in trusted_line, f"expected nordlynx to be a trusted interface, got: {trusted_line}"

def verify_kernel_modules_loaded(self):
lines = self.machine.succeed("lsmod").splitlines()
loaded = {line.split()[0] for line in lines[1:] if line.strip()}
Expand All @@ -160,11 +186,11 @@
key, value = line.split(":")
caps[key.strip()] = int(value.strip(), base=16)

# CAP_NET_ADMIN (bit 12)
expected = (1 << 12)
# CAP_NET_ADMIN (bit 12) and CAP_NET_RAW (bit 13)
expected = (1 << 12) | (1 << 13)
for cap_set in ["CapAmb", "CapBnd", "CapEff", "CapPrm"]:
assert caps[cap_set] == expected, (
f"expected {cap_set} to be exactly CAP_NET_ADMIN"
f"expected {cap_set} to be exactly CAP_NET_ADMIN|CAP_NET_RAW "
f"({expected:#x}), got {caps[cap_set]:#x}"
)

Expand All @@ -182,6 +208,28 @@
def verify_nordvpnd_state_directory(self):
self.__verify_group_and_permissions("/var/lib/nordvpn", perm="750")

def verify_norduserd_socket(self):
# norduserd listens on /tmp/<uid>-norduserd.sock
uid = self.machine.succeed(f"id -u {self.machine_user}").strip()
self.__verify_group_and_permissions(f"/tmp/{uid}-norduserd.sock", perm="660")

def verify_fileshare_socket(self):
# fileshare is only spawned by norduserd once meshnet is enabled,
# which requires a logged in account. Instead, run nordfileshare directly.
self.machine.execute(f"sudo -u {self.machine_user} nordfileshare")
self.__verify_group_and_permissions("/tmp/fileshare.sock", perm="660")

def verify_nonmember_service(self):
uid = self.machine.succeed("id -u mallory").strip()
self.machine.wait_for_file(f"/run/user/{uid}/bus")
status, output = self.machine.systemctl("start norduserd.service", user="mallory")
assert status == 0, f"expected norduserd to be skipped for a non-member: {output}"
for prop, expected in [("ConditionResult", "no"), ("ActiveState", "inactive"), ("NRestarts", "0")]:
actual = self.machine.get_unit_property("norduserd.service", prop, user="mallory")
assert actual == expected, f"expected {prop}={expected} for a non-member, got {actual}"
self.machine.fail(f"test -e /tmp/{uid}-norduserd.sock")
self.machine.fail("sudo -u mallory nordvpn status")

def __verify_group_and_permissions(self, path, perm):
# has the correct group
actual_group, actual_perm = self.machine.succeed(
Expand Down
61 changes: 43 additions & 18 deletions pkgs/by-name/no/nordvpn/cli.nix
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
version,

buildGoModule,
callPackage,
copyDesktopItems,
e2fsprogs,
iproute2,
Expand All @@ -15,21 +16,34 @@
nftables,
openvpn,
procps,
sqlite,
systemdMinimal,
wireguard-tools,
}:
let
libdrop = callPackage ./libdrop { };
libtelio = callPackage ./libtelio { };
in
buildGoModule (finalAttrs: {
inherit src version;

pname = "nordvpn-cli";

patches = [ ./hosts-readonly.patch ];

nativeBuildInputs = [
copyDesktopItems
makeWrapper
];

vendorHash = "sha256-8HgrhVg7ERalBJRPC2+mACOyeG0iXENMc3PaWxsX41Y=";

buildInputs = [
libdrop
libtelio
sqlite
];

preBuild = ''
# redirect AppDataPathStatic (/usr/lib/nordvpn) to $out/bin so that
# NorduserdBinaryPath resolves to $out/bin/norduserd.
Expand All @@ -46,11 +60,19 @@ buildGoModule (finalAttrs: {
ldflags = [
"-X main.Environment=prod"
"-X main.Version=${finalAttrs.version}"
"-X main.RemotePath=/apps/linux/config"
];

tags = [
"cdnrc"
"drop"
"telio"
];

subPackages = [
"cmd/cli"
"cmd/daemon"
"cmd/fileshare"
"cmd/norduser"
];

Expand All @@ -63,25 +85,27 @@ buildGoModule (finalAttrs: {

# skip tests requiring network access, root/CAP_NET_ADMIN,
# or network state the sandbox doesn't provide
# skip libtelio tests as the package lacks meshnet support
go test $(go list ./daemon/... | grep -v /daemon/vpn/nordlynx/libtelio) -skip \
'Test.*FileList_RealURL|'\
'TestAddAllowlistRules|'\
'TestAddDevice|'\
'TestConnectionInfo_TransferRatesShallBeProvidedOnlyForConnectedState|'\
'TestCustomTable|'\
'TestDiscoverNameserverIp|'\
'TestFwmarkRule|'\
'TestH1Transport_RoundTrip|'\
'TestMeshnetRuleset|'\
'TestMultiFwmarkRule|'\
'TestNetlinkManager_SetUnset|'\
'TestRouter_Add|'\
'TestSetMTU|'\
'TestSuppressRule|'\
'TestTransports|'\
'TestUpWGInterface|'\
skip_tests=(
'Test.*FileList_RealURL'
'TestAddAllowlistRules'
'TestAddDevice'
'TestConnectionInfo_TransferRatesShallBeProvidedOnlyForConnectedState'
'TestCustomTable'
'TestDiscoverNameserverIp'
'TestFwmarkRule'
'TestH1Transport_RoundTrip'
'TestMeshnetRuleset'
'TestMultiFwmarkRule'
'TestNetlinkManager_SetUnset'
'TestRouter_Add'
'TestSetMTU'
'TestSuppressRule'
'TestTransports'
'TestUpWGInterface'
'TestVPNRuleset'
)
skip_pattern=$(printf '%s|' "''${skip_tests[@]}")
go test ./daemon/... -skip "''${skip_pattern%|}"

runHook postCheck
'';
Expand All @@ -91,6 +115,7 @@ buildGoModule (finalAttrs: {
BIN_DIR=$out/bin
mv $BIN_DIR/cli $BIN_DIR/nordvpn
mv $BIN_DIR/daemon $BIN_DIR/nordvpnd
mv $BIN_DIR/fileshare $BIN_DIR/nordfileshare
mv $BIN_DIR/norduser $BIN_DIR/norduserd

# nordvpn needs icons for the system tray and notifications
Expand Down
Loading
Loading