Files
Sovran_SystemsOS/tests/test_mullvad_desktop_only.py
Sovran_SystemsOS contributor 8f5e5394e6 mullvad: install the client on the Desktop Role only
The Mullvad VPN app and its daemon now ship with Desktop Only, as the stock
NixOS service with nothing configured on top of it: no local-network boot
service, no DNS or firewall edits of our own, no Hub tile, no Feature Manager
card, no toggles. The account, the relay, the kill switch and the app's other
switches belong to the app.

modules/mullvad.nix is the whole change to the system: a role gate around

  services.mullvad-vpn = {
    enable = true;      # daemon, pkgs.mullvad
    gui.enable = true;  # app, pkgs.mullvad-vpn
  };

Server + Desktop and Bitcoin Node are machines that serve; a VPN client with its
own routing and DNS policy is not something their services asked for, so only
roles.desktop builds it. Every other option stays at the distribution's default
(early boot blocking off, the mullvad-exclude wrapper on), and the only
consequences are the ones the NixOS module already defines — the tun module, and
systemd-resolved with NetworkManager pointed at it while the client is enabled.

The Hub and the desktop shell are untouched: no tile, no feature card, no
service description, no icon, no GNOME folder edit. An app that is simply
installed has no decision to put in front of the user, and no state to report.

README, SECURITY.md (what the app's own kill switch and DNS behaviour mean, so
the change in network behaviour is not a surprise), CHANGELOG, the custom.nix
template (the mkForce line that removes it) and the installer's Desktop card
document it as an app rather than a service.

tests/test_mullvad_desktop_only.py guards the gate and the restraint: the client
builds on Desktop and nowhere else, the module adds no services, no DNS or
firewall edits and no packages, the Hub and desktop shell never mention it, and
it ships no tile icon.

Checked by evaluating the real module against the revision flake.lock pins
(3d23ea05a8a3) with eval-config.nix, four cases: Desktop -> daemon and GUI on,
one unit (mullvad-daemon), mullvad + mullvad-vpn in systemPackages, every other
option at the distribution default; Bitcoin Node and Server + Desktop -> nothing
at all, not even the tun module or resolved; Desktop with
`services.mullvad-vpn.enable = lib.mkForce false;` -> no unit, no package.
2026-10-16 13:07:29 -05:00

113 lines
4.2 KiB
Python

"""Guards for the Mullvad VPN client being a plain Desktop Role install.
The desktop app and its daemon ship with Desktop Only and with no other role:
Server + Desktop and Bitcoin Node are machines that serve, and a VPN client with
its own DNS and routing policy is not something their services asked for.
Beyond that the install is deliberately boring. It is the NixOS service exactly
as NixOS ships it — no local-network boot service, no DNS or firewall edits of
our own, no Hub tile, no Feature Manager card, no toggles. The account, the
relay and the kill switch belong to the app.
Like the other nix-file checks these read the shipped sources as text: nothing is
run and nothing touches the network.
"""
import os
import unittest
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def _read(*parts):
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
return f.read()
def _without_comments(src):
"""The source with whole-line `#` comments removed."""
return "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#"))
class TheClientIsBuiltOnDesktopAndNowhereElse(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.module = _read("modules", "mullvad.nix")
cls.code = _without_comments(cls.module)
def test_the_module_is_imported(self):
self.assertIn("./mullvad.nix", _read("modules", "modules.nix"))
def test_the_gate_is_the_desktop_role(self):
self.assertRegex(
self.code,
r"lib\.mkIf\s+config\.sovran_systemsOS\.roles\.desktop\s*\{",
)
def test_no_other_role_is_mentioned(self):
self.assertNotIn("server_plus_desktop", self.code)
self.assertNotIn("roles.node", self.code)
def test_the_install_is_the_daemon_and_the_app(self):
self.assertRegex(self.code, r"services\.mullvad-vpn\s*=\s*\{")
self.assertRegex(self.code, r"(?m)^ enable = true;$")
self.assertRegex(self.code, r"gui\.enable = true;")
class TheInstallStaysVanilla(unittest.TestCase):
"""Nothing is configured for the user, in the module or out of it."""
@classmethod
def setUpClass(cls):
cls.module = _without_comments(_read("modules", "mullvad.nix"))
def test_no_extra_services_are_added(self):
self.assertNotIn("systemd.services", self.module)
def test_no_dns_or_network_edits_of_our_own(self):
# The NixOS module already turns on systemd-resolved and points
# NetworkManager at it; re-declaring either here would be our policy,
# not the distribution's.
for key in ("services.resolved", "networkmanager", "networking."):
self.assertNotIn(key, self.module)
def test_no_firewall_or_kernel_edits(self):
self.assertNotIn("firewall", self.module)
self.assertNotIn("boot.", self.module)
def test_no_packages_are_forced_in(self):
self.assertNotIn("environment.systemPackages", self.module)
def test_the_feature_flag_does_not_come_back(self):
# A Hub-managed feature flag on an app that is simply installed would
# put a toggle in front of something that has no decision to make.
for path in (("modules", "core", "roles.nix"),
("modules", "core", "role-logic.nix")):
self.assertNotIn("mullvad", _read(*path).lower(), f"{path[-1]} mentions mullvad")
class TheHubDoesNotMentionIt(unittest.TestCase):
"""The app is not a service the Hub manages or announces."""
def test_the_hub_tiles_and_roles_are_untouched(self):
self.assertNotIn("mullvad", _read("modules", "core", "sovran-hub.nix").lower())
def test_the_hub_backend_is_untouched(self):
self.assertNotIn("mullvad", _read("app", "sovran_systemsos_web", "server.py").lower())
def test_the_desktop_shell_is_untouched(self):
# The app appears in the GNOME app grid by itself; no folder edit, no
# dock entry.
self.assertNotIn(
"mullvad", _read("modules", "core", "sovran_systemsos-desktop.nix").lower())
def test_there_is_no_icon_for_it(self):
# app/icons holds Hub tile icons only.
self.assertFalse(
os.path.exists(os.path.join(_ROOT, "app", "icons", "mullvad.svg")))
if __name__ == "__main__":
unittest.main()