From e270fda6b02ee25e71ac4d1503beba812ef401c7 Mon Sep 17 00:00:00 2001 From: Brett Eisenberg Date: Thu, 16 Apr 2026 17:28:16 -0700 Subject: [PATCH] nixos/nmtrust: init --- nixos/modules/module-list.nix | 1 + nixos/modules/services/networking/nmtrust.nix | 389 ++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/nmtrust.nix | 92 +++++ 4 files changed, 483 insertions(+) create mode 100644 nixos/modules/services/networking/nmtrust.nix create mode 100644 nixos/tests/nmtrust.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index f89194a4ef39..f0a3b80cc47e 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1318,6 +1318,7 @@ ./services/networking/nix-store-gcs-proxy.nix ./services/networking/nixops-dns.nix ./services/networking/nm-file-secret-agent.nix + ./services/networking/nmtrust.nix ./services/networking/nncp.nix ./services/networking/nntp-proxy.nix ./services/networking/nomad.nix diff --git a/nixos/modules/services/networking/nmtrust.nix b/nixos/modules/services/networking/nmtrust.nix new file mode 100644 index 000000000000..710ba0b5f58f --- /dev/null +++ b/nixos/modules/services/networking/nmtrust.nix @@ -0,0 +1,389 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.nmtrust; + + # Resolve trusted UUIDs from ensureProfiles + extra + profileUUIDs = map ( + name: config.networking.networkmanager.ensureProfiles.profiles.${name}.connection.uuid + ) cfg.trustedConnections; + + trustedUUIDs = profileUUIDs ++ cfg.trustedUUIDsExtra; + + userNames = builtins.attrNames cfg.userUnits; + + # The package reads config from /etc/nmtrust/config at runtime + trustHelper = pkgs.nmtrust; + + # Trust target names + trustTargets = [ + "nmtrust-trusted" + "nmtrust-untrusted" + "nmtrust-offline" + ]; + + # Generate Conflicts= for a target (all other trust targets) + conflictsFor = target: map (t: "${t}.target") (builtins.filter (t: t != target) trustTargets); + + # Generate systemd unit overrides for a system unit. + # Uses StopWhenUnneeded instead of PartOf to avoid same-transaction + # issues: when transitioning between targets that both want a unit + # (e.g. offline -> trusted for allowOffline units), PartOf on the + # old target would stop the unit before WantedBy on the new target + # can restart it. StopWhenUnneeded only stops the unit when NO + # active target wants it. + mkSystemUnitOverrides = + unitName: unitCfg: + let + targets = [ + "nmtrust-trusted.target" + ] + ++ lib.optional unitCfg.allowOffline "nmtrust-offline.target"; + in + { + unitConfig.StopWhenUnneeded = true; + wantedBy = targets; + }; + + # Generate user unit overrides + mkUserUnitOverrides = + unitName: unitCfg: + let + targets = [ + "nmtrust-trusted.target" + ] + ++ lib.optional unitCfg.allowOffline "nmtrust-offline.target"; + in + { + unitConfig.StopWhenUnneeded = true; + wantedBy = targets; + }; + + # NM dispatcher script + dispatcherScript = pkgs.writeShellScript "nmtrust-dispatcher" '' + case "$2" in + up|down|vpn-up|vpn-down|connectivity-change) + ${config.systemd.package}/bin/systemd-run \ + --no-block \ + --on-active=1s \ + --unit=nmtrust-apply-debounce \ + ${config.systemd.package}/bin/systemctl start nmtrust-apply.service \ + 2>/dev/null || true + ;; + esac + ''; + +in +{ + + # + # Options + # + + options.services.nmtrust = { + + enable = lib.mkEnableOption "network trust management"; + + trustedConnections = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + List of NetworkManager profile names from + {option}`networking.networkmanager.ensureProfiles`. + UUIDs are resolved at evaluation time. + ''; + }; + + trustedUUIDsExtra = lib.mkOption { + type = lib.types.listOf ( + lib.types.strMatching "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + ); + default = [ ]; + description = '' + Additional trusted connection UUIDs not managed via + {option}`networking.networkmanager.ensureProfiles`. + Must be valid UUID format. + ''; + }; + + excludedConnectionPatterns = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Glob patterns matched against connection names at runtime using + fnmatch(3) with FNM_NOESCAPE. Connection names are treated as + literal strings (no backslash interpretation). + Matching connections are ignored when computing trust state. + ''; + }; + + mixedPolicy = lib.mkOption { + type = lib.types.enum [ + "trusted" + "untrusted" + ]; + default = "untrusted"; + description = '' + How to treat mixed trust state (some connections trusted, + some untrusted). + ''; + }; + + evalFailurePolicy = lib.mkOption { + type = lib.types.enum [ + "untrusted" + "offline" + ]; + default = "untrusted"; + description = '' + How to handle trust evaluation failures (D-Bus errors, NM + unavailable). `"untrusted"` (default) is fail-closed: trusted-only + units stop. `"offline"` allows units with + {option}`allowOffline` to run. + ''; + }; + + systemUnits = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options.allowOffline = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether this unit should also run when offline."; + }; + } + ); + default = { }; + description = '' + System units to bind to the trusted network target. + Keys are systemd unit names. + ''; + }; + + userUnits = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.attrsOf ( + lib.types.submodule { + options.allowOffline = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether this unit should also run when offline."; + }; + } + ) + ); + default = { }; + example = lib.literalExpression '' + { + alice = { + "etesync-dav.service" = { }; + "syncthing.service" = { allowOffline = true; }; + }; + } + ''; + description = '' + Per-user units to bind to the trusted network target. + Outer keys are usernames, inner keys are systemd unit names. + Users must have linger enabled + ({option}`users.users..linger`). + ''; + }; + }; + + # + # Config + # + + config = lib.mkIf cfg.enable { + + # --- Assertions --- + + assertions = + # NetworkManager is required + [ + { + assertion = config.networking.networkmanager.enable; + message = "services.nmtrust requires networking.networkmanager.enable = true."; + } + ] + ++ + # trustedConnections -> ensureProfiles UUID resolution + (map (name: { + assertion = + config.networking.networkmanager.ensureProfiles.profiles ? ${name} + && config.networking.networkmanager.ensureProfiles.profiles.${name}.connection ? uuid; + message = + "services.nmtrust.trustedConnections references '${name}' " + + "but no matching networking.networkmanager.ensureProfiles entry with a UUID exists."; + }) cfg.trustedConnections) + ++ + # userUnits -> user existence + (map (username: { + assertion = config.users.users ? ${username}; + message = + "services.nmtrust.userUnits references user '${username}' " + + "but no matching users.users entry exists."; + }) userNames) + ++ + # userUnits -> linger enabled + (map (username: { + assertion = + let + l = config.users.users.${username}.linger; + in + l != null && l; + message = + "services.nmtrust.userUnits references user '${username}' but " + + "linger is not enabled. Set users.users.${username}.linger = true to " + + "ensure the user's systemd instance is running for trust-based unit management. " + + "Note: enabling linger causes ALL of this user's enabled user services to run " + + "persistently, not just trust-managed units."; + }) (builtins.filter (u: config.users.users ? ${u}) userNames)); + + # --- Helper package on PATH --- + + environment.systemPackages = [ trustHelper ]; + + # --- Runtime config file --- + + environment.etc."nmtrust/config" = { + text = + let + toBashArray = xs: "(" + lib.concatMapStringsSep " " (x: lib.escapeShellArg x) xs + ")"; + in + '' + # Generated by NixOS module — do not edit + TRUSTED_UUIDS=${toBashArray trustedUUIDs} + EXCLUDED_PATTERNS=${toBashArray (cfg.excludedConnectionPatterns)} + MIXED_POLICY=${lib.escapeShellArg cfg.mixedPolicy} + EVAL_FAILURE_POLICY=${lib.escapeShellArg cfg.evalFailurePolicy} + MANAGED_USERS=${toBashArray userNames} + ''; + }; + + # --- tmpfiles.d --- + + systemd.tmpfiles.rules = [ + "d /run/nmtrust 0700 root root -" + ]; + + # --- System trust targets --- + + systemd.targets = lib.listToAttrs ( + map (target: { + name = target; + value = { + description = "Network Trust State: ${ + if target == "nmtrust-trusted" then + "Trusted" + else if target == "nmtrust-untrusted" then + "Untrusted" + else + "Offline" + }"; + unitConfig.Conflicts = conflictsFor target; + }; + }) trustTargets + ); + + # --- User trust targets --- + + systemd.user.targets = lib.listToAttrs ( + map (target: { + name = target; + value = { + description = "Network Trust State: ${ + if target == "nmtrust-trusted" then + "Trusted (User)" + else if target == "nmtrust-untrusted" then + "Untrusted (User)" + else + "Offline (User)" + }"; + unitConfig.Conflicts = conflictsFor target; + }; + }) trustTargets + ); + + # --- System unit overrides + services --- + + # Strip .service/.timer/.socket suffixes — NixOS appends them automatically + systemd.services = + lib.mapAttrs' (name: value: { + name = lib.removeSuffix ".service" (lib.removeSuffix ".timer" (lib.removeSuffix ".socket" name)); + value = mkSystemUnitOverrides name value; + }) cfg.systemUnits + // { + nmtrust-apply = { + description = "Evaluate and apply network trust state"; + after = [ "NetworkManager.service" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${trustHelper}/bin/nmtrust apply"; + ProtectSystem = "strict"; + ReadWritePaths = [ "/run/nmtrust" ]; + ProtectHome = true; + NoNewPrivileges = true; + PrivateTmp = true; + }; + }; + nmtrust-eval = { + description = "Evaluate network trust state on boot"; + wantedBy = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + after = [ + "NetworkManager.service" + "network-online.target" + ]; + restartTriggers = [ + config.environment.etc."nmtrust/config".source + ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${trustHelper}/bin/nmtrust apply"; + ProtectSystem = "strict"; + ReadWritePaths = [ "/run/nmtrust" ]; + ProtectHome = true; + NoNewPrivileges = true; + PrivateTmp = true; + }; + }; + }; + + # --- User unit overrides --- + + systemd.user.services = lib.foldl' ( + acc: username: + lib.foldl' ( + acc': unitName: + let + strippedName = lib.removeSuffix ".service" ( + lib.removeSuffix ".timer" (lib.removeSuffix ".socket" unitName) + ); + in + acc' + // { + ${strippedName} = mkUserUnitOverrides unitName cfg.userUnits.${username}.${unitName}; + } + ) acc (builtins.attrNames cfg.userUnits.${username}) + ) { } userNames; + + # --- NM dispatcher --- + + networking.networkmanager.dispatcherScripts = [ + { + source = dispatcherScript; + type = "basic"; + } + ]; + }; + + meta.maintainers = [ lib.maintainers.brett ]; + +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 929e8362e8f3..5f2f27cfb445 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1134,6 +1134,7 @@ in pkgs.callPackage ../../pkgs/stdenv/generic/check-meta-test.nix { }; nixseparatedebuginfod2 = runTest ./nixseparatedebuginfod2.nix; + nmtrust = runTest ./nmtrust.nix; node-red = runTest ./node-red.nix; nohang = runTest ./nohang.nix; nomad = runTest ./nomad.nix; diff --git a/nixos/tests/nmtrust.nix b/nixos/tests/nmtrust.nix new file mode 100644 index 000000000000..5954746b254d --- /dev/null +++ b/nixos/tests/nmtrust.nix @@ -0,0 +1,92 @@ +{ lib, pkgs, ... }: +{ + name = "nmtrust"; + + nodes.machine = + { pkgs, ... }: + { + networking.networkmanager.enable = true; + + # Prevent the VM's built-in interfaces from polluting trust state. + networking.networkmanager.unmanaged = [ + "eth0" + "eth1" + "lo" + ]; + + networking.networkmanager.ensureProfiles.profiles = { + trusted-net = { + connection = { + id = "trusted-net"; + uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + type = "dummy"; + interface-name = "dummy-trusted"; + autoconnect = "false"; + }; + ipv4.method = "manual"; + ipv4.addresses = "10.99.1.1/24"; + }; + untrusted-net = { + connection = { + id = "untrusted-net"; + uuid = "11111111-2222-3333-4444-555555555555"; + type = "dummy"; + interface-name = "dummy-untrusted"; + autoconnect = "false"; + }; + ipv4.method = "manual"; + ipv4.addresses = "10.99.2.1/24"; + }; + }; + + services.nmtrust = { + enable = true; + trustedConnections = [ "trusted-net" ]; + systemUnits."trust-canary.service" = { }; + }; + + # Canary service: runs only while the trusted target is active. + systemd.services.trust-canary = { + description = "nmtrust test canary"; + serviceConfig = { + Type = "simple"; + ExecStart = "${pkgs.coreutils}/bin/sleep infinity"; + }; + }; + }; + + testScript = '' + import time + + def apply(machine): + """Trigger nmtrust-apply and wait for it to finish.""" + time.sleep(1) + machine.succeed("systemctl start nmtrust-apply.service") + machine.wait_until_succeeds( + "systemctl show nmtrust-apply.service -p ActiveState --value | grep -q inactive", + timeout=10, + ) + + machine.wait_for_unit("multi-user.target") + + with subtest("offline on boot with no connections active"): + apply(machine) + machine.succeed("systemctl is-active nmtrust-offline.target") + machine.fail("systemctl is-active trust-canary.service") + + with subtest("trusted when trusted connection is up"): + machine.succeed("nmcli connection up trusted-net") + apply(machine) + machine.succeed("systemctl is-active nmtrust-trusted.target") + machine.succeed("systemctl is-active trust-canary.service") + + with subtest("untrusted when untrusted connection replaces trusted"): + machine.succeed("nmcli connection down trusted-net") + machine.succeed("nmcli connection up untrusted-net") + apply(machine) + machine.succeed("systemctl is-active nmtrust-untrusted.target") + machine.fail("systemctl is-active trust-canary.service") + ''; + + meta.maintainers = with lib.maintainers; [ brett ]; +}