- Fix gawk D-Bus value parsing to preserve multi-word connection names - Fix userUnits merge to union wantedBy lists across users (was last-writer-wins) - Drop require_root from state/status subcommands (read-only, unprivileged) - Add Restart=on-failure + RestartSec=5 to nmtrust-apply and nmtrust-eval - Merge duplicate mkSystemUnitOverrides/mkUserUnitOverrides into mkUnitOverrides
392 lines
12 KiB
Nix
392 lines
12 KiB
Nix
{
|
|
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);
|
|
|
|
# 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.
|
|
mkUnitOverrides =
|
|
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 = mkUnitOverrides name value;
|
|
}) cfg.systemUnits
|
|
// {
|
|
nmtrust-apply = {
|
|
description = "Evaluate and apply network trust state";
|
|
after = [ "NetworkManager.service" ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStart = "${trustHelper}/bin/nmtrust apply";
|
|
Restart = "on-failure";
|
|
RestartSec = "5";
|
|
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";
|
|
Restart = "on-failure";
|
|
RestartSec = "5";
|
|
ProtectSystem = "strict";
|
|
ReadWritePaths = [ "/run/nmtrust" ];
|
|
ProtectHome = true;
|
|
NoNewPrivileges = true;
|
|
PrivateTmp = true;
|
|
};
|
|
};
|
|
};
|
|
|
|
# --- User unit overrides ---
|
|
|
|
# When the same unit appears under multiple users, union their wantedBy
|
|
# lists. systemd.user.services is system-wide, so per-user allowOffline
|
|
# differences are resolved by taking the most permissive value (any
|
|
# true wins).
|
|
systemd.user.services = lib.foldl' (
|
|
acc: username:
|
|
lib.foldl' (
|
|
acc': unitName:
|
|
let
|
|
strippedName = lib.removeSuffix ".service" (
|
|
lib.removeSuffix ".timer" (lib.removeSuffix ".socket" unitName)
|
|
);
|
|
incoming = mkUnitOverrides unitName cfg.userUnits.${username}.${unitName};
|
|
existing = acc'.${strippedName} or null;
|
|
in
|
|
acc'
|
|
// {
|
|
${strippedName} =
|
|
if existing == null then
|
|
incoming
|
|
else
|
|
existing
|
|
// {
|
|
wantedBy = lib.unique (existing.wantedBy ++ incoming.wantedBy);
|
|
};
|
|
}
|
|
) acc (builtins.attrNames cfg.userUnits.${username})
|
|
) { } userNames;
|
|
|
|
# --- NM dispatcher ---
|
|
|
|
networking.networkmanager.dispatcherScripts = [
|
|
{
|
|
source = dispatcherScript;
|
|
type = "basic";
|
|
}
|
|
];
|
|
};
|
|
|
|
meta.maintainers = [ lib.maintainers.brett ];
|
|
|
|
}
|