nixos/nmtrust: init

This commit is contained in:
Brett Eisenberg
2026-04-16 17:28:16 -07:00
parent 2fb2da4230
commit e270fda6b0
4 changed files with 483 additions and 0 deletions

View File

@@ -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

View File

@@ -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.<name>.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 ];
}

View File

@@ -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;

92
nixos/tests/nmtrust.nix Normal file
View File

@@ -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 ];
}