From 241e020a8f38d19b9ba72b1d7fd1e7cef2881a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Thu, 21 Aug 2025 01:36:21 +0200 Subject: [PATCH 01/16] Enable system activation scripts refs #221 --- crates/system-manager-engine/src/activate.rs | 26 ++ examples/example.nix | 4 + nix/modules/default.nix | 3 + .../upstream/nixpkgs/activation-script.nix | 271 ++++++++++++++++++ nix/modules/upstream/nixpkgs/default.nix | 1 + testFlake/vm-tests.nix | 9 + 6 files changed, 314 insertions(+) create mode 100644 nix/modules/upstream/nixpkgs/activation-script.nix diff --git a/crates/system-manager-engine/src/activate.rs b/crates/system-manager-engine/src/activate.rs index 3df263d6..644d3bff 100644 --- a/crates/system-manager-engine/src/activate.rs +++ b/crates/system-manager-engine/src/activate.rs @@ -106,6 +106,19 @@ pub fn activate(store_path: &StorePath, ephemeral: bool) -> Result<()> { }; final_state.write_to_file(state_file)?; + log::info!("Running system activation script..."); + match run_system_activation_script(store_path) { + Ok(status) if status.success() => { + log::info!("System activation script executed successfully."); + } + Ok(status) => { + log::error!("System activation script failed with status: {status}"); + } + Err(e) => { + log::error!("Error running system activation script: {e}"); + } + } + if let Err(e) = tmp_result { return Err(e.into()); } @@ -218,6 +231,19 @@ fn run_preactivation_assertions(store_path: &StorePath) -> Result Result { + let status = process::Command::new( + store_path + .store_path + .join("bin") + .join("systemActivationScript"), + ) + .stderr(process::Stdio::inherit()) + .stdout(process::Stdio::inherit()) + .status()?; + Ok(status) +} + fn get_state_file() -> Result { let state_file = Path::new(SYSTEM_MANAGER_STATE_DIR).join(STATE_FILE_NAME); DirBuilder::new() diff --git a/examples/example.nix b/examples/example.nix index f6fcf75b..1ceed69b 100644 --- a/examples/example.nix +++ b/examples/example.nix @@ -100,5 +100,9 @@ mode = "0755"; }; }; + + system.activationScripts.test = '' + echo "This is a test activation script" + ''; }; } diff --git a/nix/modules/default.nix b/nix/modules/default.nix index f273d883..42d79cfa 100644 --- a/nix/modules/default.nix +++ b/nix/modules/default.nix @@ -233,6 +233,8 @@ ${system-manager}/bin/system-manager-engine deactivate "$@" ''; + systemActivationScript = pkgs.writeShellScript "systemActivationScript" config.system.activationScripts.script; + preActivationAssertionScript = let mkAssertion = @@ -276,6 +278,7 @@ exit 0 fi ''; + }; # TODO: handle globbing diff --git a/nix/modules/upstream/nixpkgs/activation-script.nix b/nix/modules/upstream/nixpkgs/activation-script.nix new file mode 100644 index 00000000..3679b35b --- /dev/null +++ b/nix/modules/upstream/nixpkgs/activation-script.nix @@ -0,0 +1,271 @@ +# copied from modules/system/activation/activation-script.nix to avoid the dependency on systemd.user +{ + config, + lib, + pkgs, + nixosModulesPath, + ... +}: + +with lib; + +let + + addAttributeName = mapAttrs ( + a: v: + v + // { + text = '' + #### Activation script snippet ${a}: + _localstatus=0 + ${v.text} + + if (( _localstatus > 0 )); then + printf "Activation script snippet '%s' failed (%s)\n" "${a}" "$_localstatus" + fi + ''; + } + ); + + systemActivationScript = + set: onlyDry: + let + set' = mapAttrs ( + _: v: if isString v then (noDepEntry v) // { supportsDryActivation = false; } else v + ) set; + withHeadlines = addAttributeName set'; + # When building a dry activation script, this replaces all activation scripts + # that do not support dry mode with a comment that does nothing. Filtering these + # activation scripts out so they don't get generated into the dry activation script + # does not work because when an activation script that supports dry mode depends on + # an activation script that does not, the dependency cannot be resolved and the eval + # fails. + withDrySnippets = mapAttrs ( + a: v: + if onlyDry && !v.supportsDryActivation then + v + // { + text = "#### Activation script snippet ${a} does not support dry activation."; + } + else + v + ) withHeadlines; + in + '' + #!${pkgs.runtimeShell} + + source ${nixosModulesPath}/system/activation/lib/lib.sh + + systemConfig='@out@' + + export PATH=/empty + for i in ${toString path}; do + PATH=$PATH:$i/bin:$i/sbin + done + + _status=0 + trap "_status=1 _localstatus=\$?" ERR + + # Ensure a consistent umask. + umask 0022 + + ${textClosureMap id (withDrySnippets) (attrNames withDrySnippets)} + + '' + + optionalString (!onlyDry) '' + # Make this configuration the current configuration. + # The readlink is there to ensure that when $systemConfig = /system + # (which is a symlink to the store), /run/current-system is still + # used as a garbage collection root. + ln -sfn "$(readlink -f "$systemConfig")" /run/current-system + + exit $_status + ''; + + path = + with pkgs; + map getBin [ + coreutils + gnugrep + findutils + getent + stdenv.cc.libc # nscd in update-users-groups.pl + shadow + util-linux # needed for mount and mountpoint + ]; + + scriptType = + withDry: + with types; + let + scriptOptions = + { + deps = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "List of dependencies. The script will run after these."; + }; + text = mkOption { + type = types.lines; + description = "The content of the script."; + }; + } + // optionalAttrs withDry { + supportsDryActivation = mkOption { + type = types.bool; + default = false; + description = '' + Whether this activation script supports being dry-activated. + These activation scripts will also be executed on dry-activate + activations with the environment variable + `NIXOS_ACTION` being set to `dry-activate`. + it's important that these activation scripts don't + modify anything about the system when the variable is set. + ''; + }; + }; + in + either str (submodule { + options = scriptOptions; + }); + +in + +{ + + ###### interface + + options = { + + system.activationScripts = mkOption { + default = { }; + + example = literalExpression '' + { + stdio = { + # Run after /dev has been mounted + deps = [ "specialfs" ]; + text = + ''' + # Needed by some programs. + ln -sfn /proc/self/fd /dev/fd + ln -sfn /proc/self/fd/0 /dev/stdin + ln -sfn /proc/self/fd/1 /dev/stdout + ln -sfn /proc/self/fd/2 /dev/stderr + '''; + }; + } + ''; + + description = '' + A set of shell script fragments that are executed when a NixOS + system configuration is activated. Examples are updating + /etc, creating accounts, and so on. Since these are executed + every time you boot the system or run + {command}`nixos-rebuild`, it's important that they are + idempotent and fast. + ''; + + type = types.attrsOf (scriptType true); + apply = + set: + set + // { + script = systemActivationScript set false; + }; + }; + + system.dryActivationScript = mkOption { + description = "The shell script that is to be run when dry-activating a system."; + readOnly = true; + internal = true; + default = systemActivationScript (removeAttrs config.system.activationScripts [ "script" ]) true; + defaultText = literalMD "generated activation script"; + }; + + system.userActivationScripts = mkOption { + default = { }; + + example = literalExpression '' + { plasmaSetup = { + text = ''' + ''${pkgs.libsForQt5.kservice}/bin/kbuildsycoca5" + '''; + deps = []; + }; + } + ''; + + description = '' + A set of shell script fragments that are executed by a systemd user + service when a NixOS system configuration is activated. Examples are + rebuilding the .desktop file cache for showing applications in the menu. + Since these are executed every time you run + {command}`nixos-rebuild`, it's important that they are + idempotent and fast. + ''; + + type = with types; attrsOf (scriptType false); + + apply = set: { + script = '' + export PATH= + for i in ${toString path}; do + PATH=$PATH:$i/bin:$i/sbin + done + + _status=0 + trap "_status=1 _localstatus=\$?" ERR + + ${ + let + set' = mapAttrs (n: v: if isString v then noDepEntry v else v) set; + withHeadlines = addAttributeName set'; + in + textClosureMap id (withHeadlines) (attrNames withHeadlines) + } + + exit $_status + ''; + }; + + }; + + environment.usrbinenv = mkOption { + default = "${pkgs.coreutils}/bin/env"; + defaultText = literalExpression ''"''${pkgs.coreutils}/bin/env"''; + example = literalExpression ''"''${pkgs.busybox}/bin/env"''; + type = types.nullOr types.path; + visible = false; + description = '' + The {manpage}`env(1)` executable that is linked system-wide to + `/usr/bin/env`. + ''; + }; + + system.build.installBootLoader = mkOption { + internal = true; + default = pkgs.writeShellScript "no-bootloader" '' + echo 'Warning: do not know how to make this configuration bootable; please enable a boot loader.' 1>&2 + ''; + defaultText = lib.literalExpression '' + pkgs.writeShellScript "no-bootloader" ''' + echo 'Warning: do not know how to make this configuration bootable; please enable a boot loader.' 1>&2 + ''' + ''; + description = '' + A program that writes a bootloader installation script to the path passed in the first command line argument. + + See `pkgs/by-name/sw/switch-to-configuration-ng/src/src/main.rs`. + ''; + type = types.unique { + message = '' + Only one bootloader can be enabled at a time. This requirement has not + been checked until NixOS 22.05. Earlier versions defaulted to the last + definition. Change your configuration to enable only one bootloader. + ''; + } (types.either types.str types.package); + }; + + }; +} diff --git a/nix/modules/upstream/nixpkgs/default.nix b/nix/modules/upstream/nixpkgs/default.nix index c5779e23..45f220a7 100644 --- a/nix/modules/upstream/nixpkgs/default.nix +++ b/nix/modules/upstream/nixpkgs/default.nix @@ -7,6 +7,7 @@ imports = [ ./nginx.nix ./nix.nix + ./activation-script.nix ] ++ # List of imported NixOS modules diff --git a/testFlake/vm-tests.nix b/testFlake/vm-tests.nix index b14b6c0b..7a49073e 100644 --- a/testFlake/vm-tests.nix +++ b/testFlake/vm-tests.nix @@ -147,6 +147,14 @@ let trusted-users = [ "zimbatm" ]; }; }; + + system.activationScripts = { + "system-manager" = { + text = '' + touch /tmp/file-created-by-system-activation-script + ''; + }; + }; }; } ) @@ -242,6 +250,7 @@ forEachUbuntuImage "example" { vm.fail("test -f /etc/a/nested/example/foo3") vm.fail("test -f /etc/baz/bar/foo2") vm.succeed("test -f /etc/foo_new") + vm.succeed("test -f /tmp/file-created-by-system-activation-script") nix_trusted_users = vm.succeed("${hostPkgs.nix}/bin/nix config show trusted-users").strip() assert "zimbatm" in nix_trusted_users, f"Expected 'zimbatm' to be in trusted-users, got {nix_trusted_users}" From 7d9bd7f0d135009eb8d6daef2996f124ca6921ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Tue, 26 Aug 2025 11:40:18 +0200 Subject: [PATCH 02/16] Non functional POC for user/group management Eval works. Work done until now: - add missing `system.etc` and `systemd.sysusers` options. - vendored nixpkgs/nixos/modules/config/users-groups.nix and comment `boot.initrd` and `environment.profiles` configs. - import user ids and userborn modules from nixpkgs. Currently failing on: vm-test> [2025-08-26T09:45:23Z INFO system_manager::activate::etc_files] Done vm-test> [2025-08-26T09:45:23Z INFO system_manager::activate] Activating tmp files... vm-test> /etc/tmpfiles.d/home-directories.conf:1: Failed to resolve user 'zimbatm': No such process Most probably because we don't create the users/group before trying to create tmpfiles. This PR is based on https://github.com/numtide/system-manager/pull/258 because `user-groups.nix` and `userborn.nix` depend on `system.activationScripts`. --- nix/modules/default.nix | 46 +- nix/modules/etc.nix | 16 + nix/modules/systemd.nix | 13 + nix/modules/upstream/nixpkgs/default.nix | 3 + .../upstream/nixpkgs/update-users-groups.pl | 382 +++++ nix/modules/upstream/nixpkgs/users-groups.nix | 1276 +++++++++++++++++ testFlake/vm-tests.nix | 6 + 7 files changed, 1719 insertions(+), 23 deletions(-) create mode 100644 nix/modules/upstream/nixpkgs/update-users-groups.pl create mode 100644 nix/modules/upstream/nixpkgs/users-groups.nix diff --git a/nix/modules/default.nix b/nix/modules/default.nix index 42d79cfa..56f5fbeb 100644 --- a/nix/modules/default.nix +++ b/nix/modules/default.nix @@ -82,23 +82,23 @@ # Statically assigned UIDs and GIDs. # Ideally we use DynamicUser as much as possible to avoid the need for these. - ids = { - uids = lib.mkOption { - internal = true; - description = lib.mdDoc '' - The user IDs used by system-manager. - ''; - type = types.attrsOf types.int; - }; - - gids = lib.mkOption { - internal = true; - description = lib.mdDoc '' - The group IDs used by system-manager. - ''; - type = types.attrsOf types.int; - }; - }; + # ids = { + # uids = lib.mkOption { + # internal = true; + # description = lib.mdDoc '' + # The user IDs used by system-manager. + # ''; + # type = types.attrsOf types.int; + # }; + # + # gids = lib.mkOption { + # internal = true; + # description = lib.mdDoc '' + # The group IDs used by system-manager. + # ''; + # type = types.attrsOf types.int; + # }; + # }; # No-op option for now. # TODO: should we include the settings in /etc/logrotate.d ? @@ -109,12 +109,12 @@ }; # No-op option for now. - users = lib.mkOption { - internal = true; - default = { }; - type = types.attrs; - }; - + # users = lib.mkOption { + # internal = true; + # default = { }; + # type = types.attrs; + # }; + # networking = { enableIPv6 = lib.mkEnableOption "IPv6" // { default = true; diff --git a/nix/modules/etc.nix b/nix/modules/etc.nix index 1b11d1bb..7e96c5b1 100644 --- a/nix/modules/etc.nix +++ b/nix/modules/etc.nix @@ -5,6 +5,19 @@ }: { options = { + system.etc = { + overlay = { + enable = lib.mkEnableOption "systemd-sysusers" // { + description = '' + If enabled, users are created with systemd-sysusers instead of with + the custom `update-users-groups.pl` script. + + Note: This is experimental. + ''; + }; + }; + }; + environment.etc = lib.mkOption { default = { }; example = lib.literalExpression '' @@ -123,4 +136,7 @@ ); }; }; + config = { + system.etc.overlay.enable = false; + }; } diff --git a/nix/modules/systemd.nix b/nix/modules/systemd.nix index 2e40d180..85f8e34b 100644 --- a/nix/modules/systemd.nix +++ b/nix/modules/systemd.nix @@ -140,10 +140,23 @@ in `/etc/systemd/system-shutdown/NAME` to `VALUE`. ''; }; + + sysusers = { + enable = lib.mkEnableOption "systemd-sysusers" // { + description = '' + If enabled, users are created with systemd-sysusers instead of with + the custom `update-users-groups.pl` script. + + Note: This is experimental. + ''; + }; + }; }; config = { systemd = { + sysusers.enable = false; + targets.system-manager = { wantedBy = [ "default.target" ]; }; diff --git a/nix/modules/upstream/nixpkgs/default.nix b/nix/modules/upstream/nixpkgs/default.nix index 45f220a7..04697701 100644 --- a/nix/modules/upstream/nixpkgs/default.nix +++ b/nix/modules/upstream/nixpkgs/default.nix @@ -8,16 +8,19 @@ ./nginx.nix ./nix.nix ./activation-script.nix + ./users-groups.nix ] ++ # List of imported NixOS modules # TODO: how will we manage this in the long term? map (path: nixosModulesPath + path) [ "/misc/meta.nix" + "/misc/ids.nix" "/security/acme/" "/services/web-servers/nginx/" # nix settings "/config/nix.nix" + "/services/system/userborn.nix" ]; options = diff --git a/nix/modules/upstream/nixpkgs/update-users-groups.pl b/nix/modules/upstream/nixpkgs/update-users-groups.pl new file mode 100644 index 00000000..0d192ae0 --- /dev/null +++ b/nix/modules/upstream/nixpkgs/update-users-groups.pl @@ -0,0 +1,382 @@ +use strict; +use warnings; +use File::Path qw(make_path); +use File::Slurp; +use Getopt::Long; +use JSON; +use Time::Piece; + +# Keep track of deleted uids and gids. +my $uidMapFile = "/var/lib/nixos/uid-map"; +my $uidMap = -e $uidMapFile ? decode_json(read_file($uidMapFile)) : {}; + +my $gidMapFile = "/var/lib/nixos/gid-map"; +my $gidMap = -e $gidMapFile ? decode_json(read_file($gidMapFile)) : {}; + +my $is_dry = ($ENV{'NIXOS_ACTION'} // "") eq "dry-activate"; +GetOptions("dry-activate" => \$is_dry); +make_path("/var/lib/nixos", { mode => 0755 }) unless $is_dry; + +sub updateFile { + my ($path, $contents, $perms) = @_; + return if $is_dry; + write_file($path, { atomic => 1, binmode => ':utf8', perms => $perms // 0644 }, $contents) or die; +} + +# Converts an ISO date to number of days since 1970-01-01 +sub dateToDays { + my ($date) = @_; + my $time = Time::Piece->strptime($date, "%Y-%m-%d"); + return $time->epoch / 60 / 60 / 24; +} + +sub nscdInvalidate { + system("nscd", "--invalidate", $_[0]) unless $is_dry; +} + +sub hashPassword { + my ($password) = @_; + my $salt = ""; + my @chars = ('.', '/', 0..9, 'A'..'Z', 'a'..'z'); + $salt .= $chars[rand 64] for (1..8); + return crypt($password, '$6$' . $salt . '$'); +} + +sub dry_print { + if ($is_dry) { + print STDERR ("$_[1] $_[2]\n") + } else { + print STDERR ("$_[0] $_[2]\n") + } +} + + +# Functions for allocating free GIDs/UIDs. FIXME: respect ID ranges in +# /etc/login.defs. +sub allocId { + my ($used, $prevUsed, $idMin, $idMax, $delta, $getid) = @_; + my $id = $delta > 0 ? $idMin : $idMax; + while ($id >= $idMin && $id <= $idMax) { + if (!$used->{$id} && !$prevUsed->{$id} && !defined &$getid($id)) { + $used->{$id} = 1; + return $id; + } + $id += $delta; + } + die "$0: out of free UIDs or GIDs\n"; +} + +my (%gidsUsed, %uidsUsed, %gidsPrevUsed, %uidsPrevUsed); + +sub allocGid { + my ($name) = @_; + my $prevGid = $gidMap->{$name}; + if (defined $prevGid && !defined $gidsUsed{$prevGid}) { + dry_print("reviving", "would revive", "group '$name' with GID $prevGid"); + $gidsUsed{$prevGid} = 1; + return $prevGid; + } + return allocId(\%gidsUsed, \%gidsPrevUsed, 400, 999, -1, sub { my ($gid) = @_; getgrgid($gid) }); +} + +sub allocUid { + my ($name, $isSystemUser) = @_; + my ($min, $max, $delta) = $isSystemUser ? (400, 999, -1) : (1000, 29999, 1); + my $prevUid = $uidMap->{$name}; + if (defined $prevUid && $prevUid >= $min && $prevUid <= $max && !defined $uidsUsed{$prevUid}) { + dry_print("reviving", "would revive", "user '$name' with UID $prevUid"); + $uidsUsed{$prevUid} = 1; + return $prevUid; + } + return allocId(\%uidsUsed, \%uidsPrevUsed, $min, $max, $delta, sub { my ($uid) = @_; getpwuid($uid) }); +} + +# Read the declared users/groups +my $spec = decode_json(read_file($ARGV[0])); + +# Don't allocate UIDs/GIDs that are manually assigned. +foreach my $g (@{$spec->{groups}}) { + $gidsUsed{$g->{gid}} = 1 if defined $g->{gid}; +} + +foreach my $u (@{$spec->{users}}) { + $uidsUsed{$u->{uid}} = 1 if defined $u->{uid}; +} + +# Likewise for previously used but deleted UIDs/GIDs. +$uidsPrevUsed{$_} = 1 foreach values %{$uidMap}; +$gidsPrevUsed{$_} = 1 foreach values %{$gidMap}; + + +# Read the current /etc/group. +sub parseGroup { + chomp; + my @f = split(':', $_, -4); + my $gid = $f[2] eq "" ? undef : int($f[2]); + $gidsUsed{$gid} = 1 if defined $gid; + return ($f[0], { name => $f[0], password => $f[1], gid => $gid, members => $f[3] }); +} + +my %groupsCur = -f "/etc/group" ? map { parseGroup } read_file("/etc/group", { binmode => ":utf8" }) : (); + +# Read the current /etc/passwd. +sub parseUser { + chomp; + my @f = split(':', $_, -7); + my $uid = $f[2] eq "" ? undef : int($f[2]); + $uidsUsed{$uid} = 1 if defined $uid; + return ($f[0], { name => $f[0], fakePassword => $f[1], uid => $uid, + gid => $f[3], description => $f[4], home => $f[5], shell => $f[6] }); +} +my %usersCur = -f "/etc/passwd" ? map { parseUser } read_file("/etc/passwd", { binmode => ":utf8" }) : (); + +# Read the groups that were created declaratively (i.e. not by groups) +# in the past. These must be removed if they are no longer in the +# current spec. +my $declGroupsFile = "/var/lib/nixos/declarative-groups"; +my %declGroups; +$declGroups{$_} = 1 foreach split / /, -e $declGroupsFile ? read_file($declGroupsFile, { binmode => ":utf8" }) : ""; + +# Idem for the users. +my $declUsersFile = "/var/lib/nixos/declarative-users"; +my %declUsers; +$declUsers{$_} = 1 foreach split / /, -e $declUsersFile ? read_file($declUsersFile, { binmode => ":utf8" }) : ""; + + +# Generate a new /etc/group containing the declared groups. +my %groupsOut; +foreach my $g (@{$spec->{groups}}) { + my $name = $g->{name}; + my $existing = $groupsCur{$name}; + + my %members = map { ($_, 1) } @{$g->{members}}; + + if (defined $existing) { + $g->{gid} = $existing->{gid} if !defined $g->{gid}; + if ($g->{gid} != $existing->{gid}) { + dry_print("warning: not applying", "warning: would not apply", "GID change of group ‘$name’ ($existing->{gid} -> $g->{gid}) in /etc/group"); + $g->{gid} = $existing->{gid}; + } + $g->{password} = $existing->{password}; # do we want this? + if ($spec->{mutableUsers}) { + # Merge in non-declarative group members. + foreach my $uname (split /,/, $existing->{members} // "") { + $members{$uname} = 1 if !defined $declUsers{$uname}; + } + } + } else { + $g->{gid} = allocGid($name) if !defined $g->{gid}; + $g->{password} = "x"; + } + + $g->{members} = join ",", sort(keys(%members)); + $groupsOut{$name} = $g; + + $gidMap->{$name} = $g->{gid}; +} + +# Update the persistent list of declarative groups. +updateFile($declGroupsFile, join(" ", sort(keys %groupsOut))); + +# Merge in the existing /etc/group. +foreach my $name (keys %groupsCur) { + my $g = $groupsCur{$name}; + next if defined $groupsOut{$name}; + if (!$spec->{mutableUsers} || defined $declGroups{$name}) { + dry_print("removing group", "would remove group", "‘$name’"); + } else { + $groupsOut{$name} = $g; + } +} + + +# Rewrite /etc/group. FIXME: acquire lock. +my @lines = map { join(":", $_->{name}, $_->{password}, $_->{gid}, $_->{members}) . "\n" } + (sort { $a->{gid} <=> $b->{gid} } values(%groupsOut)); +updateFile($gidMapFile, to_json($gidMap, {canonical => 1})); +updateFile("/etc/group", \@lines); +nscdInvalidate("group"); + +# Generate a new /etc/passwd containing the declared users. +my %usersOut; +foreach my $u (@{$spec->{users}}) { + my $name = $u->{name}; + + # Resolve the gid of the user. + if ($u->{group} =~ /^[0-9]$/) { + $u->{gid} = $u->{group}; + } elsif (defined $groupsOut{$u->{group}}) { + $u->{gid} = $groupsOut{$u->{group}}->{gid} // die; + } else { + warn "warning: user ‘$name’ has unknown group ‘$u->{group}’\n"; + $u->{gid} = 65534; + } + + my $existing = $usersCur{$name}; + if (defined $existing) { + $u->{uid} = $existing->{uid} if !defined $u->{uid}; + if ($u->{uid} != $existing->{uid}) { + dry_print("warning: not applying", "warning: would not apply", "UID change of user ‘$name’ ($existing->{uid} -> $u->{uid}) in /etc/passwd"); + $u->{uid} = $existing->{uid}; + } + } else { + $u->{uid} = allocUid($name, $u->{isSystemUser}) if !defined $u->{uid}; + + if (!defined $u->{hashedPassword}) { + if (defined $u->{initialPassword}) { + $u->{hashedPassword} = hashPassword($u->{initialPassword}); + } elsif (defined $u->{initialHashedPassword}) { + $u->{hashedPassword} = $u->{initialHashedPassword}; + } + } + } + + # Ensure home directory incl. ownership and permissions. + if ($u->{createHome} and !$is_dry) { + make_path($u->{home}, { mode => 0755 }) if ! -e $u->{home}; + chown $u->{uid}, $u->{gid}, $u->{home}; + chmod oct($u->{homeMode}), $u->{home}; + } + + if (defined $u->{hashedPasswordFile}) { + if (-e $u->{hashedPasswordFile}) { + $u->{hashedPassword} = read_file($u->{hashedPasswordFile}); + chomp $u->{hashedPassword}; + } else { + warn "warning: password file ‘$u->{hashedPasswordFile}’ does not exist\n"; + } + } elsif (defined $u->{password}) { + $u->{hashedPassword} = hashPassword($u->{password}); + } + + if (!defined $u->{shell}) { + if (defined $existing) { + $u->{shell} = $existing->{shell}; + } else { + warn "warning: no declarative or previous shell for ‘$name’, setting shell to nologin\n"; + $u->{shell} = "/run/current-system/sw/bin/nologin"; + } + } + + $u->{fakePassword} = $existing->{fakePassword} // "x"; + $usersOut{$name} = $u; + + $uidMap->{$name} = $u->{uid}; +} + +# Update the persistent list of declarative users. +updateFile($declUsersFile, join(" ", sort(keys %usersOut))); + +# Merge in the existing /etc/passwd. +foreach my $name (keys %usersCur) { + my $u = $usersCur{$name}; + next if defined $usersOut{$name}; + if (!$spec->{mutableUsers} || defined $declUsers{$name}) { + dry_print("removing user", "would remove user", "‘$name’"); + } else { + $usersOut{$name} = $u; + } +} + +# Rewrite /etc/passwd. FIXME: acquire lock. +@lines = map { join(":", $_->{name}, $_->{fakePassword}, $_->{uid}, $_->{gid}, $_->{description}, $_->{home}, $_->{shell}) . "\n" } + (sort { $a->{uid} <=> $b->{uid} } (values %usersOut)); +updateFile($uidMapFile, to_json($uidMap, {canonical => 1})); +updateFile("/etc/passwd", \@lines); +nscdInvalidate("passwd"); + + +# Rewrite /etc/shadow to add new accounts or remove dead ones. +my @shadowNew; +my %shadowSeen; + +foreach my $line (-f "/etc/shadow" ? read_file("/etc/shadow", { binmode => ":utf8" }) : ()) { + chomp $line; + # struct name copied from `man 3 shadow` + my ($sp_namp, $sp_pwdp, $sp_lstch, $sp_min, $sp_max, $sp_warn, $sp_inact, $sp_expire, $sp_flag) = split(':', $line, -9); + my $u = $usersOut{$sp_namp};; + next if !defined $u; + $sp_pwdp = "!" if !$spec->{mutableUsers}; + $sp_pwdp = $u->{hashedPassword} if defined $u->{hashedPassword} && !$spec->{mutableUsers}; # FIXME + $sp_expire = dateToDays($u->{expires}) if defined $u->{expires}; + chomp $sp_pwdp; + push @shadowNew, join(":", $sp_namp, $sp_pwdp, $sp_lstch, $sp_min, $sp_max, $sp_warn, $sp_inact, $sp_expire, $sp_flag) . "\n"; + $shadowSeen{$sp_namp} = 1; +} + +foreach my $u (values %usersOut) { + next if defined $shadowSeen{$u->{name}}; + my $hashedPassword = "!"; + $hashedPassword = $u->{hashedPassword} if defined $u->{hashedPassword}; + my $expires = ""; + $expires = dateToDays($u->{expires}) if defined $u->{expires}; + # FIXME: set correct value for sp_lstchg. + push @shadowNew, join(":", $u->{name}, $hashedPassword, "1::::", $expires, "") . "\n"; +} + +updateFile("/etc/shadow", \@shadowNew, 0640); +{ + my $uid = getpwnam "root"; + my $gid = getgrnam "shadow"; + my $path = "/etc/shadow"; + (chown($uid, $gid, $path) || die "Failed to change ownership of $path: $!") unless $is_dry; +} + +# Rewrite /etc/subuid & /etc/subgid to include default container mappings + +my $subUidMapFile = "/var/lib/nixos/auto-subuid-map"; +my $subUidMap = -e $subUidMapFile ? decode_json(read_file($subUidMapFile)) : {}; + +my (%subUidsUsed, %subUidsPrevUsed); + +$subUidsPrevUsed{$_} = 1 foreach values %{$subUidMap}; + +sub allocSubUid { + my ($name, @rest) = @_; + + # TODO: No upper bounds? + my ($min, $max, $delta) = (100000, 100000 + 100 * 65536, 65536); + my $prevId = $subUidMap->{$name}; + if (defined $prevId && !defined $subUidsUsed{$prevId}) { + $subUidsUsed{$prevId} = 1; + return $prevId; + } + + return allocId(\%subUidsUsed, \%subUidsPrevUsed, $min, $max, $delta, sub { undef }); +} + +my @subGids; +my @subUids; +foreach my $u (values %usersOut) { + my $name = $u->{name}; + + foreach my $range (@{$u->{subUidRanges}}) { + my $value = join(":", ($name, $range->{startUid}, $range->{count})); + push @subUids, $value; + } + + foreach my $range (@{$u->{subGidRanges}}) { + my $value = join(":", ($name, $range->{startGid}, $range->{count})); + push @subGids, $value; + } + + if($u->{autoSubUidGidRange}) { + my $subordinate = allocSubUid($name); + if (defined $subUidMap->{$name} && $subUidMap->{$name} != $subordinate) { + print STDERR "warning: The subuids for '$name' changed, as they coincided with the subuids of a different user (see /etc/subuid). " + . "The range now starts with $subordinate instead of $subUidMap->{$name}. " + . "If the subuids were used (e.g. with rootless container managers like podman), please change the ownership of affected files accordingly. " + . "Alternatively, to keep the old overlapping ranges, add this to the system configuration: " + . "users.users.$name.subUidRanges = [{startUid = $subUidMap->{$name}; count = 65536;}]; " + . "users.users.$name.subGidRanges = [{startGid = $subUidMap->{$name}; count = 65536;}];\n"; + } + $subUidMap->{$name} = $subordinate; + my $value = join(":", ($name, $subordinate, 65536)); + push @subUids, $value; + push @subGids, $value; + } +} + +updateFile("/etc/subuid", join("\n", @subUids) . "\n"); +updateFile("/etc/subgid", join("\n", @subGids) . "\n"); +updateFile($subUidMapFile, to_json($subUidMap) . "\n"); diff --git a/nix/modules/upstream/nixpkgs/users-groups.nix b/nix/modules/upstream/nixpkgs/users-groups.nix new file mode 100644 index 00000000..7f6f051a --- /dev/null +++ b/nix/modules/upstream/nixpkgs/users-groups.nix @@ -0,0 +1,1276 @@ +{ + config, + lib, + utils, + pkgs, + ... +}: + +let + inherit (lib) + any + attrNames + attrValues + concatMap + concatMapStringsSep + concatStrings + elem + filter + filterAttrs + flatten + flip + foldr + generators + getAttr + hasAttr + id + length + listToAttrs + literalExpression + mapAttrs' + mapAttrsToList + match + mkAliasOptionModuleMD + mkDefault + mkIf + mkMerge + mkOption + mkRenamedOptionModule + optional + optionals + sort + stringAfter + stringLength + trace + types + xor + ; + + ids = config.ids; + cfg = config.users; + + # Check whether a password hash will allow login. + allowsLogin = + hash: + hash == "" # login without password + || !(lib.elem hash [ + null # password login disabled + "!" # password login disabled + "!!" # a variant of "!" + "*" # password unset + ]); + + overrideOrderMutable = ''{option}`initialHashedPassword` -> {option}`initialPassword` -> {option}`hashedPassword` -> {option}`password` -> {option}`hashedPasswordFile`''; + + overrideOrderImmutable = ''{option}`initialHashedPassword` -> {option}`hashedPassword` -> {option}`initialPassword` -> {option}`password` -> {option}`hashedPasswordFile`''; + + overrideOrderText = isMutable: '' + If the option {option}`users.mutableUsers` is + `${if isMutable then "true" else "false"}`, then the order of precedence is as shown + below, where values on the left are overridden by values on the right: + ${if isMutable then overrideOrderMutable else overrideOrderImmutable} + ''; + + multiplePasswordsWarning = '' + If multiple of these password options are set at the same time then a + specific order of precedence is followed, which can lead to surprising + results. The order of precedence differs depending on whether the + {option}`users.mutableUsers` option is set. + ''; + + overrideDescription = '' + ${multiplePasswordsWarning} + + ${overrideOrderText false} + + ${overrideOrderText true} + ''; + + passwordDescription = '' + The {option}`initialHashedPassword`, {option}`hashedPassword`, + {option}`initialPassword`, {option}`password` and + {option}`hashedPasswordFile` options all control what password is set for + the user. + + In a system where [](#opt-systemd.sysusers.enable) is `false`, typically + only one of {option}`hashedPassword`, {option}`password`, or + {option}`hashedPasswordFile` will be set. + + In a system where [](#opt-systemd.sysusers.enable) is `true`, typically + only one of {option}`initialPassword`, {option}`initialHashedPassword`, + or {option}`hashedPasswordFile` will be set. + + If the option {option}`users.mutableUsers` is true, the password defined + in one of the above password options will only be set when the user is + created for the first time. After that, you are free to change the + password with the ordinary user management commands. If + {option}`users.mutableUsers` is false, you cannot change user passwords, + they will always be set according to the password options. + + If none of the password options are set, then no password is assigned to + the user, and the user will not be able to do password-based logins. + + ${overrideDescription} + ''; + + hashedPasswordDescription = '' + To generate a hashed password run `mkpasswd`. + + If set to an empty string (`""`), this user will be able to log in without + being asked for a password (but not via remote services such as SSH, or + indirectly via {command}`su` or {command}`sudo`). This should only be used + for e.g. bootable live systems. Note: this is different from setting an + empty password, which can be achieved using + {option}`users.users..password`. + + If set to `null` (default) this user will not be able to log in using a + password (i.e. via {command}`login` command). + ''; + + userOpts = + { name, config, ... }: + { + + options = { + + enable = mkOption { + type = types.bool; + default = true; + example = false; + description = '' + If set to false, the user account will not be created. This is useful for when you wish to conditionally + disable user accounts. + ''; + }; + + name = mkOption { + type = types.passwdEntry types.str; + apply = + x: + assert ( + stringLength x < 32 || abort "Username '${x}' is longer than 31 characters which is not allowed!" + ); + x; + description = '' + The name of the user account. If undefined, the name of the + attribute set will be used. + ''; + }; + + description = mkOption { + type = types.passwdEntry types.str; + default = ""; + example = "Alice Q. User"; + description = '' + A short description of the user account, typically the + user's full name. This is actually the “GECOS” or “comment” + field in {file}`/etc/passwd`. + ''; + }; + + uid = mkOption { + type = with types; nullOr int; + default = null; + description = '' + The account UID. If the UID is null, a free UID is picked on + activation. + ''; + }; + + isSystemUser = mkOption { + type = types.bool; + default = false; + description = '' + Indicates if the user is a system user or not. This option + only has an effect if {option}`uid` is + {option}`null`, in which case it determines whether + the user's UID is allocated in the range for system users + (below 1000) or in the range for normal users (starting at + 1000). + Exactly one of `isNormalUser` and + `isSystemUser` must be true. + ''; + }; + + isNormalUser = mkOption { + type = types.bool; + default = false; + description = '' + Indicates whether this is an account for a “real” user. + This automatically sets {option}`group` to `users`, + {option}`createHome` to `true`, + {option}`home` to {file}`/home/«username»`, + {option}`useDefaultShell` to `true`, + and {option}`isSystemUser` to `false`. + Exactly one of `isNormalUser` and `isSystemUser` must be true. + ''; + }; + + group = mkOption { + type = types.str; + apply = + x: + assert ( + stringLength x < 32 || abort "Group name '${x}' is longer than 31 characters which is not allowed!" + ); + x; + default = ""; + description = "The user's primary group."; + }; + + extraGroups = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "The user's auxiliary groups."; + }; + + home = mkOption { + type = types.passwdEntry types.path; + default = "/var/empty"; + description = "The user's home directory."; + }; + + homeMode = mkOption { + type = types.strMatching "[0-7]{1,5}"; + default = "700"; + description = "The user's home directory mode in numeric format. See {manpage}`chmod(1)`. The mode is only applied if {option}`users.users..createHome` is true."; + }; + + cryptHomeLuks = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Path to encrypted luks device that contains + the user's home directory. + ''; + }; + + pamMount = mkOption { + type = with types; attrsOf str; + default = { }; + description = '' + Attributes for user's entry in + {file}`pam_mount.conf.xml`. + Useful attributes might include `path`, + `options`, `fstype`, and `server`. + See + for more information. + ''; + }; + + shell = mkOption { + type = types.nullOr (types.either types.shellPackage (types.passwdEntry types.path)); + default = pkgs.shadow; + defaultText = literalExpression "pkgs.shadow"; + example = literalExpression "pkgs.bashInteractive"; + description = '' + The path to the user's shell. Can use shell derivations, + like `pkgs.bashInteractive`. Don’t + forget to enable your shell in + `programs` if necessary, + like `programs.zsh.enable = true;`. + ''; + }; + + ignoreShellProgramCheck = mkOption { + type = types.bool; + default = false; + description = '' + By default, nixos will check that programs.SHELL.enable is set to + true if the user has a custom shell specified. If that behavior isn't + required and there are custom overrides in place to make sure that the + shell is functional, set this to true. + ''; + }; + + subUidRanges = mkOption { + type = with types; listOf (submodule subordinateUidRange); + default = [ ]; + example = [ + { + startUid = 1000; + count = 1; + } + { + startUid = 100001; + count = 65534; + } + ]; + description = '' + Subordinate user ids that user is allowed to use. + They are set into {file}`/etc/subuid` and are used + by `newuidmap` for user namespaces. + ''; + }; + + subGidRanges = mkOption { + type = with types; listOf (submodule subordinateGidRange); + default = [ ]; + example = [ + { + startGid = 100; + count = 1; + } + { + startGid = 1001; + count = 999; + } + ]; + description = '' + Subordinate group ids that user is allowed to use. + They are set into {file}`/etc/subgid` and are used + by `newgidmap` for user namespaces. + ''; + }; + + autoSubUidGidRange = mkOption { + type = types.bool; + default = false; + example = true; + description = '' + Automatically allocate subordinate user and group ids for this user. + Allocated range is currently always of size 65536. + ''; + }; + + createHome = mkOption { + type = types.bool; + default = false; + description = '' + Whether to create the home directory and ensure ownership as well as + permissions to match the user. + ''; + }; + + useDefaultShell = mkOption { + type = types.bool; + default = false; + description = '' + If true, the user's shell will be set to + {option}`users.defaultUserShell`. + ''; + }; + + hashedPassword = mkOption { + type = with types; nullOr (passwdEntry str); + default = null; + description = '' + Specifies the hashed password for the user. + + ${passwordDescription} + ${hashedPasswordDescription} + ''; + }; + + password = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Specifies the (clear text) password for the user. + Warning: do not set confidential information here + because it is world-readable in the Nix store. This option + should only be used for public accounts. + + ${passwordDescription} + ''; + }; + + hashedPasswordFile = mkOption { + type = with types; nullOr str; + default = cfg.users.${name}.passwordFile; + defaultText = literalExpression "null"; + description = '' + The full path to a file that contains the hash of the user's + password. The password file is read on each system activation. The + file should contain exactly one line, which should be the password in + an encrypted form that is suitable for the `chpasswd -e` command. + + ${passwordDescription} + ''; + }; + + passwordFile = mkOption { + type = with types; nullOr str; + default = null; + visible = false; + description = "Deprecated alias of hashedPasswordFile"; + }; + + initialHashedPassword = mkOption { + type = with types; nullOr (passwdEntry str); + default = null; + description = '' + Specifies the initial hashed password for the user, i.e. the + hashed password assigned if the user does not already + exist. If {option}`users.mutableUsers` is true, the + password can be changed subsequently using the + {command}`passwd` command. Otherwise, it's + equivalent to setting the {option}`hashedPassword` option. + + ${passwordDescription} + ${hashedPasswordDescription} + ''; + }; + + initialPassword = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Specifies the initial password for the user, i.e. the + password assigned if the user does not already exist. If + {option}`users.mutableUsers` is true, the password + can be changed subsequently using the + {command}`passwd` command. Otherwise, it's + equivalent to setting the {option}`password` + option. The same caveat applies: the password specified here + is world-readable in the Nix store, so it should only be + used for guest accounts or passwords that will be changed + promptly. + + ${passwordDescription} + ''; + }; + + packages = mkOption { + type = types.listOf types.package; + default = [ ]; + example = literalExpression "[ pkgs.firefox pkgs.thunderbird ]"; + description = '' + The set of packages that should be made available to the user. + This is in contrast to {option}`environment.systemPackages`, + which adds packages to all users. + ''; + }; + + expires = mkOption { + type = types.nullOr (types.strMatching "[[:digit:]]{4}-[[:digit:]]{2}-[[:digit:]]{2}"); + default = null; + description = '' + Set the date on which the user's account will no longer be + accessible. The date is expressed in the format YYYY-MM-DD, or null + to disable the expiry. + A user whose account is locked must contact the system + administrator before being able to use the system again. + ''; + }; + + linger = mkOption { + type = types.bool; + default = false; + description = '' + Whether to enable lingering for this user. If true, systemd user + units will start at boot, rather than starting at login and stopping + at logout. This is the declarative equivalent of running + `loginctl enable-linger` for this user. + + If false, user units will not be started until the user logs in, and + may be stopped on logout depending on the settings in `logind.conf`. + ''; + }; + }; + + config = mkMerge [ + { + name = mkDefault name; + shell = mkIf config.useDefaultShell (mkDefault cfg.defaultUserShell); + } + (mkIf config.isNormalUser { + group = mkDefault "users"; + createHome = mkDefault true; + home = mkDefault "/home/${config.name}"; + homeMode = mkDefault "700"; + useDefaultShell = mkDefault true; + isSystemUser = mkDefault false; + }) + # If !mutableUsers, setting ‘initialPassword’ is equivalent to + # setting ‘password’ (and similarly for hashed passwords). + (mkIf (!cfg.mutableUsers && config.initialPassword != null) { + password = mkDefault config.initialPassword; + }) + (mkIf (!cfg.mutableUsers && config.initialHashedPassword != null) { + hashedPassword = mkDefault config.initialHashedPassword; + }) + (mkIf (config.isNormalUser && config.subUidRanges == [ ] && config.subGidRanges == [ ]) { + autoSubUidGidRange = mkDefault true; + }) + ]; + + }; + + groupOpts = + { name, config, ... }: + { + + options = { + + name = mkOption { + type = types.passwdEntry types.str; + description = '' + The name of the group. If undefined, the name of the attribute set + will be used. + ''; + }; + + gid = mkOption { + type = with types; nullOr int; + default = null; + description = '' + The group GID. If the GID is null, a free GID is picked on + activation. + ''; + }; + + members = mkOption { + type = with types; listOf (passwdEntry str); + default = [ ]; + description = '' + The user names of the group members, added to the + `/etc/group` file. + ''; + }; + + }; + + config = { + name = mkDefault name; + + members = mapAttrsToList (n: u: u.name) ( + filterAttrs (n: u: elem config.name u.extraGroups) cfg.users + ); + }; + + }; + + subordinateUidRange = { + options = { + startUid = mkOption { + type = types.int; + description = '' + Start of the range of subordinate user ids that user is + allowed to use. + ''; + }; + count = mkOption { + type = types.int; + default = 1; + description = "Count of subordinate user ids"; + }; + }; + }; + + subordinateGidRange = { + options = { + startGid = mkOption { + type = types.int; + description = '' + Start of the range of subordinate group ids that user is + allowed to use. + ''; + }; + count = mkOption { + type = types.int; + default = 1; + description = "Count of subordinate group ids"; + }; + }; + }; + + idsAreUnique = + set: idAttr: + !(foldr + ( + name: + args@{ dup, acc }: + let + id = toString (getAttr idAttr (getAttr name set)); + exists = hasAttr id acc; + newAcc = + acc + // (listToAttrs [ + { + name = id; + value = true; + } + ]); + in + if dup then + args + else if exists then + trace "Duplicate ${idAttr} ${id}" { + dup = true; + acc = null; + } + else + { + dup = false; + acc = newAcc; + } + ) + { + dup = false; + acc = { }; + } + (attrNames set) + ).dup; + + uidsAreUnique = idsAreUnique (filterAttrs (n: u: u.uid != null) cfg.users) "uid"; + gidsAreUnique = idsAreUnique (filterAttrs (n: g: g.gid != null) cfg.groups) "gid"; + # sdInitrdUidsAreUnique = idsAreUnique (filterAttrs ( + # n: u: u.uid != null + # ) config.boot.initrd.systemd.users) "uid"; + # sdInitrdGidsAreUnique = idsAreUnique (filterAttrs ( + # n: g: g.gid != null + # ) config.boot.initrd.systemd.groups) "gid"; + groupNames = lib.mapAttrsToList (n: g: g.name) cfg.groups; + usersWithoutExistingGroup = lib.filterAttrs ( + n: u: u.group != "" && !lib.elem u.group groupNames + ) cfg.users; + usersWithNullShells = attrNames (filterAttrs (name: cfg: cfg.shell == null) cfg.users); + + spec = pkgs.writeText "users-groups.json" ( + builtins.toJSON { + inherit (cfg) mutableUsers; + users = mapAttrsToList (_: u: { + inherit (u) + name + uid + group + description + home + homeMode + createHome + isSystemUser + password + hashedPasswordFile + hashedPassword + autoSubUidGidRange + subUidRanges + subGidRanges + initialPassword + initialHashedPassword + expires + ; + shell = utils.toShellPath u.shell; + }) (filterAttrs (_: u: u.enable) cfg.users); + groups = attrValues cfg.groups; + } + ); + + systemShells = + let + shells = mapAttrsToList (_: u: u.shell) cfg.users; + in + filter types.shellPackage.check shells; + + lingeringUsers = map (u: u.name) (attrValues (flip filterAttrs cfg.users (n: u: u.linger))); +in +{ + imports = [ + (mkAliasOptionModuleMD [ "users" "extraUsers" ] [ "users" "users" ]) + (mkAliasOptionModuleMD [ "users" "extraGroups" ] [ "users" "groups" ]) + (mkRenamedOptionModule + [ "security" "initialRootPassword" ] + [ "users" "users" "root" "initialHashedPassword" ] + ) + ]; + + ###### interface + options = { + users.defaultUserShell = lib.mkOption { + description = '' + This option defines the default shell assigned to user + accounts. This can be either a full system path or a shell package. + + This must not be a store path, since the path is + used outside the store (in particular in /etc/passwd). + ''; + example = lib.literalExpression "pkgs.zsh"; + type = lib.types.either lib.types.path lib.types.shellPackage; + }; + + users.mutableUsers = mkOption { + type = types.bool; + default = true; + description = '' + If set to `true`, you are free to add new users and groups to the system + with the ordinary `useradd` and + `groupadd` commands. On system activation, the + existing contents of the `/etc/passwd` and + `/etc/group` files will be merged with the + contents generated from the `users.users` and + `users.groups` options. + The initial password for a user will be set + according to `users.users`, but existing passwords + will not be changed. + + ::: {.warning} + If set to `false`, the contents of the user and + group files will simply be replaced on system activation. This also + holds for the user passwords; all changed + passwords will be reset according to the + `users.users` configuration on activation. + ::: + ''; + }; + + users.enforceIdUniqueness = mkOption { + type = types.bool; + default = true; + description = '' + Whether to require that no two users/groups share the same uid/gid. + ''; + }; + + users.users = mkOption { + default = { }; + type = with types; attrsOf (submodule userOpts); + example = { + alice = { + uid = 1234; + description = "Alice Q. User"; + home = "/home/alice"; + createHome = true; + group = "users"; + extraGroups = [ "wheel" ]; + shell = "/bin/sh"; + }; + }; + description = '' + Additional user accounts to be created automatically by the system. + This can also be used to set options for root. + ''; + }; + + users.groups = mkOption { + default = { }; + example = { + students.gid = 1001; + hackers = { }; + }; + type = with types; attrsOf (submodule groupOpts); + description = '' + Additional groups to be created automatically by the system. + ''; + }; + + users.allowNoPasswordLogin = mkOption { + type = types.bool; + default = false; + description = '' + Disable checking that at least the `root` user or a user in the `wheel` group can log in using + a password or an SSH key. + + WARNING: enabling this can lock you out of your system. Enable this only if you know what are you doing. + ''; + }; + + # systemd initrd + # boot.initrd.systemd.users = mkOption { + # description = '' + # Users to include in initrd. + # ''; + # default = { }; + # type = types.attrsOf ( + # types.submodule ( + # { name, ... }: + # { + # options.uid = mkOption { + # type = types.int; + # description = '' + # ID of the user in initrd. + # ''; + # defaultText = literalExpression "config.users.users.\${name}.uid"; + # default = cfg.users.${name}.uid; + # }; + # options.group = mkOption { + # type = types.singleLineStr; + # description = '' + # Group the user belongs to in initrd. + # ''; + # defaultText = literalExpression "config.users.users.\${name}.group"; + # default = cfg.users.${name}.group; + # }; + # options.shell = mkOption { + # type = types.passwdEntry types.path; + # description = '' + # The path to the user's shell in initrd. + # ''; + # default = "${pkgs.shadow}/bin/nologin"; + # defaultText = literalExpression "\${pkgs.shadow}/bin/nologin"; + # }; + # } + # ) + # ); + # }; + # + # boot.initrd.systemd.groups = mkOption { + # description = '' + # Groups to include in initrd. + # ''; + # default = { }; + # type = types.attrsOf ( + # types.submodule ( + # { name, ... }: + # { + # options.gid = mkOption { + # type = types.int; + # description = '' + # ID of the group in initrd. + # ''; + # defaultText = literalExpression "config.users.groups.\${name}.gid"; + # default = cfg.groups.${name}.gid; + # }; + # } + # ) + # ); + # }; + }; + + ###### implementation + + config = + let + cryptSchemeIdPatternGroup = "(${lib.concatStringsSep "|" pkgs.libxcrypt.enabledCryptSchemeIds})"; + in + { + + users.defaultUserShell = lib.mkDefault pkgs.bashInteractive; + users.users = { + root = { + uid = ids.uids.root; + description = "System administrator"; + home = "/root"; + shell = mkDefault cfg.defaultUserShell; + group = "root"; + }; + nobody = { + uid = ids.uids.nobody; + isSystemUser = true; + description = "Unprivileged account (don't use!)"; + group = "nogroup"; + }; + }; + + users.groups = { + root.gid = ids.gids.root; + wheel.gid = ids.gids.wheel; + disk.gid = ids.gids.disk; + kmem.gid = ids.gids.kmem; + tty.gid = ids.gids.tty; + floppy.gid = ids.gids.floppy; + uucp.gid = ids.gids.uucp; + lp.gid = ids.gids.lp; + cdrom.gid = ids.gids.cdrom; + tape.gid = ids.gids.tape; + audio.gid = ids.gids.audio; + video.gid = ids.gids.video; + dialout.gid = ids.gids.dialout; + nogroup.gid = ids.gids.nogroup; + users.gid = ids.gids.users; + nixbld.gid = ids.gids.nixbld; + utmp.gid = ids.gids.utmp; + adm.gid = ids.gids.adm; + input.gid = ids.gids.input; + kvm.gid = ids.gids.kvm; + render.gid = ids.gids.render; + sgx.gid = ids.gids.sgx; + shadow.gid = ids.gids.shadow; + }; + + system.activationScripts.users = + if !config.systemd.sysusers.enable then + { + supportsDryActivation = true; + text = '' + install -m 0700 -d /root + install -m 0755 -d /home + + ${ + pkgs.perl.withPackages (p: [ + p.FileSlurp + p.JSON + ]) + }/bin/perl \ + -w ${./update-users-groups.pl} ${spec} + ''; + } + else + ""; # keep around for backwards compatibility + + systemd.services.linger-users = lib.mkIf ((length lingeringUsers) > 0) { + wantedBy = [ "multi-user.target" ]; + after = [ "systemd-logind.service" ]; + requires = [ "systemd-logind.service" ]; + + script = + let + lingerDir = "/var/lib/systemd/linger"; + lingeringUsersFile = builtins.toFile "lingering-users" ( + concatStrings (map (s: "${s}\n") (sort (a: b: a < b) lingeringUsers)) + ); # this sorting is important for `comm` to work correctly + in + '' + mkdir -vp ${lingerDir} + cd ${lingerDir} + for user in $(ls); do + if ! id "$user" >/dev/null; then + echo "Removing linger for missing user $user" + rm --force -- "$user" + fi + done + ls | sort | comm -3 -1 ${lingeringUsersFile} - | xargs -r ${pkgs.systemd}/bin/loginctl disable-linger + ls | sort | comm -3 -2 ${lingeringUsersFile} - | xargs -r ${pkgs.systemd}/bin/loginctl enable-linger + ''; + + serviceConfig.Type = "oneshot"; + }; + + # Warn about user accounts with deprecated password hashing schemes + # This does not work when the users and groups are created by + # systemd-sysusers because the users are created too late then. + system.activationScripts.hashes = + if !config.systemd.sysusers.enable then + { + deps = [ "users" ]; + text = '' + users=() + while IFS=: read -r user hash _; do + if [[ "$hash" = "$"* && ! "$hash" =~ ^\''$${cryptSchemeIdPatternGroup}\$ ]]; then + users+=("$user") + fi + done length usersWithNullShells == 0; + message = '' + users.mutableUsers = false has been set, + but found users that have their shell set to null. + If you wish to disable login, set their shell to pkgs.shadow (the default). + Misconfigured users: ${lib.concatStringsSep " " usersWithNullShells} + ''; + } + { + # If mutableUsers is false, to prevent users creating a + # configuration that locks them out of the system, ensure that + # there is at least one "privileged" account that has a + # password or an SSH authorized key. Privileged accounts are + # root and users in the wheel group. + # The check does not apply when users.allowNoPasswordLogin + # The check does not apply when users.mutableUsers + assertion = + !cfg.mutableUsers + -> !cfg.allowNoPasswordLogin + -> any id ( + mapAttrsToList ( + name: cfg: + (name == "root" || cfg.group == "wheel" || elem "wheel" cfg.extraGroups) + && ( + allowsLogin cfg.hashedPassword + || cfg.password != null + || cfg.hashedPasswordFile != null + || cfg.openssh.authorizedKeys.keys != [ ] + || cfg.openssh.authorizedKeys.keyFiles != [ ] + ) + ) cfg.users + ++ [ + config.security.googleOsLogin.enable + ] + ); + message = '' + Neither the root account nor any wheel user has a password or SSH authorized key. + You must set one to prevent being locked out of your system. + If you really want to be locked out of your system, set users.allowNoPasswordLogin = true; + However you are most probably better off by setting users.mutableUsers = true; and + manually running passwd root to set the root password. + ''; + } + ] + ++ flatten ( + flip mapAttrsToList cfg.users ( + name: user: + [ + ( + let + # Things fail in various ways with especially non-ascii usernames. + # This regex mirrors the one from shadow's is_valid_name: + # https://github.com/shadow-maint/shadow/blob/bee77ffc291dfed2a133496db465eaa55e2b0fec/lib/chkname.c#L68 + # though without the trailing $, because Samba 3 got its last release + # over 10 years ago and is not in Nixpkgs anymore, + # while later versions don't appear to require anything like that. + nameRegex = "[a-zA-Z0-9_.][a-zA-Z0-9_.-]*"; + in + { + assertion = builtins.match nameRegex user.name != null; + message = "The username \"${user.name}\" is not valid, it does not match the regex \"${nameRegex}\"."; + } + ) + { + assertion = (user.hashedPassword != null) -> (match ".*:.*" user.hashedPassword == null); + message = '' + The password hash of user "${user.name}" contains a ":" character. + This is invalid and would break the login system because the fields + of /etc/shadow (file where hashes are stored) are colon-separated. + Please check the value of option `users.users."${user.name}".hashedPassword`.''; + } + { + assertion = user.isNormalUser && user.uid != null -> user.uid >= 1000; + message = '' + A user cannot have a users.users.${user.name}.uid set below 1000 and set users.users.${user.name}.isNormalUser. + Either users.users.${user.name}.isSystemUser must be set to true instead of users.users.${user.name}.isNormalUser + or users.users.${user.name}.uid must be changed to 1000 or above. + ''; + } + { + assertion = + let + # we do an extra check on isNormalUser here, to not trigger this assertion when isNormalUser is set and uid to < 1000 + isEffectivelySystemUser = + user.isSystemUser || (user.uid != null && user.uid < 1000 && !user.isNormalUser); + in + xor isEffectivelySystemUser user.isNormalUser; + message = '' + Exactly one of users.users.${user.name}.isSystemUser and users.users.${user.name}.isNormalUser must be set. + ''; + } + { + assertion = user.group != ""; + message = '' + users.users.${user.name}.group is unset. This used to default to + nogroup, but this is unsafe. For example you can create a group + for this user with: + users.users.${user.name}.group = "${user.name}"; + users.groups.${user.name} = {}; + ''; + } + ] + ++ (map + (shell: { + assertion = + !user.ignoreShellProgramCheck + -> (user.shell == pkgs.${shell}) + -> (config.programs.${shell}.enable == true); + message = '' + users.users.${user.name}.shell is set to ${shell}, but + programs.${shell}.enable is not true. This will cause the ${shell} + shell to lack the basic nix directories in its PATH and might make + logging in as that user impossible. You can fix it with: + programs.${shell}.enable = true; + + If you know what you're doing and you are fine with the behavior, + set users.users.${user.name}.ignoreShellProgramCheck = true; + instead. + ''; + }) + [ + "fish" + "xonsh" + "zsh" + ] + ) + ) + ); + + warnings = + flip concatMap (attrValues cfg.users) ( + user: + let + passwordOptions = + [ + "hashedPassword" + "hashedPasswordFile" + "password" + ] + ++ optionals cfg.mutableUsers [ + # For immutable users, initialHashedPassword is set to hashedPassword, + # so using these options would always trigger the assertion. + "initialHashedPassword" + "initialPassword" + ]; + unambiguousPasswordConfiguration = + 1 >= length (filter (x: x != null) (map (flip getAttr user) passwordOptions)); + in + optional (!unambiguousPasswordConfiguration) '' + The user '${user.name}' has multiple of the options + `initialHashedPassword`, `hashedPassword`, `initialPassword`, `password` + & `hashedPasswordFile` set to a non-null value. + + ${multiplePasswordsWarning} + ${overrideOrderText cfg.mutableUsers} + The values of these options are: + ${concatMapStringsSep "\n" ( + value: "* users.users.\"${user.name}\".${value}: ${generators.toPretty { } user.${value}}" + ) passwordOptions} + '' + ) + ++ filter (x: x != null) ( + flip mapAttrsToList cfg.users ( + _: user: + # This regex matches a subset of the Modular Crypto Format (MCF)[1] + # informal standard. Since this depends largely on the OS or the + # specific implementation of crypt(3) we only support the (sane) + # schemes implemented by glibc and BSDs. In particular the original + # DES hash is excluded since, having no structure, it would validate + # common mistakes like typing the plaintext password. + # + # [1]: https://en.wikipedia.org/wiki/Crypt_(C) + let + sep = "\\$"; + base64 = "[a-zA-Z0-9./]+"; + id = cryptSchemeIdPatternGroup; + name = "[a-z0-9-]+"; + value = "[a-zA-Z0-9/+.-]+"; + options = "${name}(=${value})?(,${name}=${value})*"; + scheme = "${id}(${sep}${options})?"; + content = "${base64}${sep}${base64}(${sep}${base64})?"; + mcf = "^${sep}${scheme}${sep}${content}$"; + in + if + ( + allowsLogin user.hashedPassword + && user.hashedPassword != "" # login without password + && match mcf user.hashedPassword == null + ) + then + '' + The password hash of user "${user.name}" may be invalid. You must set a + valid hash or the user will be locked out of their account. Please + check the value of option `users.users."${user.name}".hashedPassword`.'' + else + null + ) + ++ flip mapAttrsToList cfg.users ( + name: user: + if user.passwordFile != null then + ''The option `users.users."${name}".passwordFile' has been renamed '' + + ''to `users.users."${name}".hashedPasswordFile'.'' + else + null + ) + ); + }; + +} diff --git a/testFlake/vm-tests.nix b/testFlake/vm-tests.nix index 7a49073e..b2f7bbb5 100644 --- a/testFlake/vm-tests.nix +++ b/testFlake/vm-tests.nix @@ -104,6 +104,7 @@ let nixpkgs.hostPlatform = system; services.nginx.enable = false; + services.userborn.enable = true; environment = { etc = { @@ -155,6 +156,11 @@ let ''; }; }; + + users.users.zimbatm = { + isNormalUser = true; + extraGroups = [ "wheel" ]; + }; }; } ) From e8b845896ab87ff64a10d30e5f0b9acda434f8d1 Mon Sep 17 00:00:00 2001 From: Julien Malka Date: Mon, 8 Sep 2025 20:35:25 +0200 Subject: [PATCH 03/16] Add userborn --- crates/system-manager-engine/src/activate.rs | 2 + .../src/activate/services.rs | 25 +- nix/modules/systemd.nix | 7 + .../upstream/nixpkgs/update-users-groups.pl | 382 ------------------ nix/modules/upstream/nixpkgs/users-groups.nix | 46 --- testFlake/vm-tests.nix | 5 + 6 files changed, 32 insertions(+), 435 deletions(-) delete mode 100644 nix/modules/upstream/nixpkgs/update-users-groups.pl diff --git a/crates/system-manager-engine/src/activate.rs b/crates/system-manager-engine/src/activate.rs index 644d3bff..9147b88f 100644 --- a/crates/system-manager-engine/src/activate.rs +++ b/crates/system-manager-engine/src/activate.rs @@ -81,6 +81,8 @@ pub fn activate(store_path: &StorePath, ephemeral: bool) -> Result<()> { match etc_files::activate(store_path, old_state.file_tree, ephemeral) { Ok(etc_tree) => { + log::info!("Restarting sysinit-reactivation.target..."); + services::restart_sysinit_reactivation_target()?; log::info!("Activating tmp files..."); let tmp_result = tmp_files::activate(&etc_tree); if let Err(e) = &tmp_result { diff --git a/crates/system-manager-engine/src/activate/services.rs b/crates/system-manager-engine/src/activate/services.rs index 94aabffd..a548de33 100644 --- a/crates/system-manager-engine/src/activate/services.rs +++ b/crates/system-manager-engine/src/activate/services.rs @@ -79,13 +79,6 @@ pub fn activate( ) .map_err(|e| ActivationError::with_partial_result(services.clone(), e))?; - // We added all new services and removed old ones, so let's reload the units - // to tell systemd about them. - log::info!("Reloading the systemd daemon..."); - service_manager - .daemon_reload() - .map_err(|e| ActivationError::with_partial_result(services.clone(), e))?; - wait_for_jobs( &service_manager, &job_monitor, @@ -308,3 +301,21 @@ impl From for String { value.id } } + +pub fn restart_sysinit_reactivation_target() -> anyhow::Result<()> { + let service_manager = systemd::ServiceManager::new_session()?; + let job_monitor = service_manager.monitor_jobs_init()?; + let timeout = Some(Duration::from_secs(30)); + + log::info!("Reloading the systemd daemon..."); + service_manager.daemon_reload()?; + + let jobs = for_each_unit( + |unit| service_manager.restart_unit(unit), + ["sysinit-reactivation.target"], + "restarting", + ); + + wait_for_jobs(&service_manager, &job_monitor, jobs, &timeout)?; + Ok(()) +} diff --git a/nix/modules/systemd.nix b/nix/modules/systemd.nix index 85f8e34b..feedc684 100644 --- a/nix/modules/systemd.nix +++ b/nix/modules/systemd.nix @@ -161,6 +161,13 @@ in wantedBy = [ "default.target" ]; }; + # This target only exists so that services ordered before sysinit.target + # are restarted in the correct order, notably BEFORE the other services, + # when switching configurations. + targets.sysinit-reactivation = { + description = "Reactivate sysinit units"; + }; + timers = lib.mapAttrs (name: service: { wantedBy = [ "timers.target" ]; timerConfig.OnCalendar = service.startAt; diff --git a/nix/modules/upstream/nixpkgs/update-users-groups.pl b/nix/modules/upstream/nixpkgs/update-users-groups.pl deleted file mode 100644 index 0d192ae0..00000000 --- a/nix/modules/upstream/nixpkgs/update-users-groups.pl +++ /dev/null @@ -1,382 +0,0 @@ -use strict; -use warnings; -use File::Path qw(make_path); -use File::Slurp; -use Getopt::Long; -use JSON; -use Time::Piece; - -# Keep track of deleted uids and gids. -my $uidMapFile = "/var/lib/nixos/uid-map"; -my $uidMap = -e $uidMapFile ? decode_json(read_file($uidMapFile)) : {}; - -my $gidMapFile = "/var/lib/nixos/gid-map"; -my $gidMap = -e $gidMapFile ? decode_json(read_file($gidMapFile)) : {}; - -my $is_dry = ($ENV{'NIXOS_ACTION'} // "") eq "dry-activate"; -GetOptions("dry-activate" => \$is_dry); -make_path("/var/lib/nixos", { mode => 0755 }) unless $is_dry; - -sub updateFile { - my ($path, $contents, $perms) = @_; - return if $is_dry; - write_file($path, { atomic => 1, binmode => ':utf8', perms => $perms // 0644 }, $contents) or die; -} - -# Converts an ISO date to number of days since 1970-01-01 -sub dateToDays { - my ($date) = @_; - my $time = Time::Piece->strptime($date, "%Y-%m-%d"); - return $time->epoch / 60 / 60 / 24; -} - -sub nscdInvalidate { - system("nscd", "--invalidate", $_[0]) unless $is_dry; -} - -sub hashPassword { - my ($password) = @_; - my $salt = ""; - my @chars = ('.', '/', 0..9, 'A'..'Z', 'a'..'z'); - $salt .= $chars[rand 64] for (1..8); - return crypt($password, '$6$' . $salt . '$'); -} - -sub dry_print { - if ($is_dry) { - print STDERR ("$_[1] $_[2]\n") - } else { - print STDERR ("$_[0] $_[2]\n") - } -} - - -# Functions for allocating free GIDs/UIDs. FIXME: respect ID ranges in -# /etc/login.defs. -sub allocId { - my ($used, $prevUsed, $idMin, $idMax, $delta, $getid) = @_; - my $id = $delta > 0 ? $idMin : $idMax; - while ($id >= $idMin && $id <= $idMax) { - if (!$used->{$id} && !$prevUsed->{$id} && !defined &$getid($id)) { - $used->{$id} = 1; - return $id; - } - $id += $delta; - } - die "$0: out of free UIDs or GIDs\n"; -} - -my (%gidsUsed, %uidsUsed, %gidsPrevUsed, %uidsPrevUsed); - -sub allocGid { - my ($name) = @_; - my $prevGid = $gidMap->{$name}; - if (defined $prevGid && !defined $gidsUsed{$prevGid}) { - dry_print("reviving", "would revive", "group '$name' with GID $prevGid"); - $gidsUsed{$prevGid} = 1; - return $prevGid; - } - return allocId(\%gidsUsed, \%gidsPrevUsed, 400, 999, -1, sub { my ($gid) = @_; getgrgid($gid) }); -} - -sub allocUid { - my ($name, $isSystemUser) = @_; - my ($min, $max, $delta) = $isSystemUser ? (400, 999, -1) : (1000, 29999, 1); - my $prevUid = $uidMap->{$name}; - if (defined $prevUid && $prevUid >= $min && $prevUid <= $max && !defined $uidsUsed{$prevUid}) { - dry_print("reviving", "would revive", "user '$name' with UID $prevUid"); - $uidsUsed{$prevUid} = 1; - return $prevUid; - } - return allocId(\%uidsUsed, \%uidsPrevUsed, $min, $max, $delta, sub { my ($uid) = @_; getpwuid($uid) }); -} - -# Read the declared users/groups -my $spec = decode_json(read_file($ARGV[0])); - -# Don't allocate UIDs/GIDs that are manually assigned. -foreach my $g (@{$spec->{groups}}) { - $gidsUsed{$g->{gid}} = 1 if defined $g->{gid}; -} - -foreach my $u (@{$spec->{users}}) { - $uidsUsed{$u->{uid}} = 1 if defined $u->{uid}; -} - -# Likewise for previously used but deleted UIDs/GIDs. -$uidsPrevUsed{$_} = 1 foreach values %{$uidMap}; -$gidsPrevUsed{$_} = 1 foreach values %{$gidMap}; - - -# Read the current /etc/group. -sub parseGroup { - chomp; - my @f = split(':', $_, -4); - my $gid = $f[2] eq "" ? undef : int($f[2]); - $gidsUsed{$gid} = 1 if defined $gid; - return ($f[0], { name => $f[0], password => $f[1], gid => $gid, members => $f[3] }); -} - -my %groupsCur = -f "/etc/group" ? map { parseGroup } read_file("/etc/group", { binmode => ":utf8" }) : (); - -# Read the current /etc/passwd. -sub parseUser { - chomp; - my @f = split(':', $_, -7); - my $uid = $f[2] eq "" ? undef : int($f[2]); - $uidsUsed{$uid} = 1 if defined $uid; - return ($f[0], { name => $f[0], fakePassword => $f[1], uid => $uid, - gid => $f[3], description => $f[4], home => $f[5], shell => $f[6] }); -} -my %usersCur = -f "/etc/passwd" ? map { parseUser } read_file("/etc/passwd", { binmode => ":utf8" }) : (); - -# Read the groups that were created declaratively (i.e. not by groups) -# in the past. These must be removed if they are no longer in the -# current spec. -my $declGroupsFile = "/var/lib/nixos/declarative-groups"; -my %declGroups; -$declGroups{$_} = 1 foreach split / /, -e $declGroupsFile ? read_file($declGroupsFile, { binmode => ":utf8" }) : ""; - -# Idem for the users. -my $declUsersFile = "/var/lib/nixos/declarative-users"; -my %declUsers; -$declUsers{$_} = 1 foreach split / /, -e $declUsersFile ? read_file($declUsersFile, { binmode => ":utf8" }) : ""; - - -# Generate a new /etc/group containing the declared groups. -my %groupsOut; -foreach my $g (@{$spec->{groups}}) { - my $name = $g->{name}; - my $existing = $groupsCur{$name}; - - my %members = map { ($_, 1) } @{$g->{members}}; - - if (defined $existing) { - $g->{gid} = $existing->{gid} if !defined $g->{gid}; - if ($g->{gid} != $existing->{gid}) { - dry_print("warning: not applying", "warning: would not apply", "GID change of group ‘$name’ ($existing->{gid} -> $g->{gid}) in /etc/group"); - $g->{gid} = $existing->{gid}; - } - $g->{password} = $existing->{password}; # do we want this? - if ($spec->{mutableUsers}) { - # Merge in non-declarative group members. - foreach my $uname (split /,/, $existing->{members} // "") { - $members{$uname} = 1 if !defined $declUsers{$uname}; - } - } - } else { - $g->{gid} = allocGid($name) if !defined $g->{gid}; - $g->{password} = "x"; - } - - $g->{members} = join ",", sort(keys(%members)); - $groupsOut{$name} = $g; - - $gidMap->{$name} = $g->{gid}; -} - -# Update the persistent list of declarative groups. -updateFile($declGroupsFile, join(" ", sort(keys %groupsOut))); - -# Merge in the existing /etc/group. -foreach my $name (keys %groupsCur) { - my $g = $groupsCur{$name}; - next if defined $groupsOut{$name}; - if (!$spec->{mutableUsers} || defined $declGroups{$name}) { - dry_print("removing group", "would remove group", "‘$name’"); - } else { - $groupsOut{$name} = $g; - } -} - - -# Rewrite /etc/group. FIXME: acquire lock. -my @lines = map { join(":", $_->{name}, $_->{password}, $_->{gid}, $_->{members}) . "\n" } - (sort { $a->{gid} <=> $b->{gid} } values(%groupsOut)); -updateFile($gidMapFile, to_json($gidMap, {canonical => 1})); -updateFile("/etc/group", \@lines); -nscdInvalidate("group"); - -# Generate a new /etc/passwd containing the declared users. -my %usersOut; -foreach my $u (@{$spec->{users}}) { - my $name = $u->{name}; - - # Resolve the gid of the user. - if ($u->{group} =~ /^[0-9]$/) { - $u->{gid} = $u->{group}; - } elsif (defined $groupsOut{$u->{group}}) { - $u->{gid} = $groupsOut{$u->{group}}->{gid} // die; - } else { - warn "warning: user ‘$name’ has unknown group ‘$u->{group}’\n"; - $u->{gid} = 65534; - } - - my $existing = $usersCur{$name}; - if (defined $existing) { - $u->{uid} = $existing->{uid} if !defined $u->{uid}; - if ($u->{uid} != $existing->{uid}) { - dry_print("warning: not applying", "warning: would not apply", "UID change of user ‘$name’ ($existing->{uid} -> $u->{uid}) in /etc/passwd"); - $u->{uid} = $existing->{uid}; - } - } else { - $u->{uid} = allocUid($name, $u->{isSystemUser}) if !defined $u->{uid}; - - if (!defined $u->{hashedPassword}) { - if (defined $u->{initialPassword}) { - $u->{hashedPassword} = hashPassword($u->{initialPassword}); - } elsif (defined $u->{initialHashedPassword}) { - $u->{hashedPassword} = $u->{initialHashedPassword}; - } - } - } - - # Ensure home directory incl. ownership and permissions. - if ($u->{createHome} and !$is_dry) { - make_path($u->{home}, { mode => 0755 }) if ! -e $u->{home}; - chown $u->{uid}, $u->{gid}, $u->{home}; - chmod oct($u->{homeMode}), $u->{home}; - } - - if (defined $u->{hashedPasswordFile}) { - if (-e $u->{hashedPasswordFile}) { - $u->{hashedPassword} = read_file($u->{hashedPasswordFile}); - chomp $u->{hashedPassword}; - } else { - warn "warning: password file ‘$u->{hashedPasswordFile}’ does not exist\n"; - } - } elsif (defined $u->{password}) { - $u->{hashedPassword} = hashPassword($u->{password}); - } - - if (!defined $u->{shell}) { - if (defined $existing) { - $u->{shell} = $existing->{shell}; - } else { - warn "warning: no declarative or previous shell for ‘$name’, setting shell to nologin\n"; - $u->{shell} = "/run/current-system/sw/bin/nologin"; - } - } - - $u->{fakePassword} = $existing->{fakePassword} // "x"; - $usersOut{$name} = $u; - - $uidMap->{$name} = $u->{uid}; -} - -# Update the persistent list of declarative users. -updateFile($declUsersFile, join(" ", sort(keys %usersOut))); - -# Merge in the existing /etc/passwd. -foreach my $name (keys %usersCur) { - my $u = $usersCur{$name}; - next if defined $usersOut{$name}; - if (!$spec->{mutableUsers} || defined $declUsers{$name}) { - dry_print("removing user", "would remove user", "‘$name’"); - } else { - $usersOut{$name} = $u; - } -} - -# Rewrite /etc/passwd. FIXME: acquire lock. -@lines = map { join(":", $_->{name}, $_->{fakePassword}, $_->{uid}, $_->{gid}, $_->{description}, $_->{home}, $_->{shell}) . "\n" } - (sort { $a->{uid} <=> $b->{uid} } (values %usersOut)); -updateFile($uidMapFile, to_json($uidMap, {canonical => 1})); -updateFile("/etc/passwd", \@lines); -nscdInvalidate("passwd"); - - -# Rewrite /etc/shadow to add new accounts or remove dead ones. -my @shadowNew; -my %shadowSeen; - -foreach my $line (-f "/etc/shadow" ? read_file("/etc/shadow", { binmode => ":utf8" }) : ()) { - chomp $line; - # struct name copied from `man 3 shadow` - my ($sp_namp, $sp_pwdp, $sp_lstch, $sp_min, $sp_max, $sp_warn, $sp_inact, $sp_expire, $sp_flag) = split(':', $line, -9); - my $u = $usersOut{$sp_namp};; - next if !defined $u; - $sp_pwdp = "!" if !$spec->{mutableUsers}; - $sp_pwdp = $u->{hashedPassword} if defined $u->{hashedPassword} && !$spec->{mutableUsers}; # FIXME - $sp_expire = dateToDays($u->{expires}) if defined $u->{expires}; - chomp $sp_pwdp; - push @shadowNew, join(":", $sp_namp, $sp_pwdp, $sp_lstch, $sp_min, $sp_max, $sp_warn, $sp_inact, $sp_expire, $sp_flag) . "\n"; - $shadowSeen{$sp_namp} = 1; -} - -foreach my $u (values %usersOut) { - next if defined $shadowSeen{$u->{name}}; - my $hashedPassword = "!"; - $hashedPassword = $u->{hashedPassword} if defined $u->{hashedPassword}; - my $expires = ""; - $expires = dateToDays($u->{expires}) if defined $u->{expires}; - # FIXME: set correct value for sp_lstchg. - push @shadowNew, join(":", $u->{name}, $hashedPassword, "1::::", $expires, "") . "\n"; -} - -updateFile("/etc/shadow", \@shadowNew, 0640); -{ - my $uid = getpwnam "root"; - my $gid = getgrnam "shadow"; - my $path = "/etc/shadow"; - (chown($uid, $gid, $path) || die "Failed to change ownership of $path: $!") unless $is_dry; -} - -# Rewrite /etc/subuid & /etc/subgid to include default container mappings - -my $subUidMapFile = "/var/lib/nixos/auto-subuid-map"; -my $subUidMap = -e $subUidMapFile ? decode_json(read_file($subUidMapFile)) : {}; - -my (%subUidsUsed, %subUidsPrevUsed); - -$subUidsPrevUsed{$_} = 1 foreach values %{$subUidMap}; - -sub allocSubUid { - my ($name, @rest) = @_; - - # TODO: No upper bounds? - my ($min, $max, $delta) = (100000, 100000 + 100 * 65536, 65536); - my $prevId = $subUidMap->{$name}; - if (defined $prevId && !defined $subUidsUsed{$prevId}) { - $subUidsUsed{$prevId} = 1; - return $prevId; - } - - return allocId(\%subUidsUsed, \%subUidsPrevUsed, $min, $max, $delta, sub { undef }); -} - -my @subGids; -my @subUids; -foreach my $u (values %usersOut) { - my $name = $u->{name}; - - foreach my $range (@{$u->{subUidRanges}}) { - my $value = join(":", ($name, $range->{startUid}, $range->{count})); - push @subUids, $value; - } - - foreach my $range (@{$u->{subGidRanges}}) { - my $value = join(":", ($name, $range->{startGid}, $range->{count})); - push @subGids, $value; - } - - if($u->{autoSubUidGidRange}) { - my $subordinate = allocSubUid($name); - if (defined $subUidMap->{$name} && $subUidMap->{$name} != $subordinate) { - print STDERR "warning: The subuids for '$name' changed, as they coincided with the subuids of a different user (see /etc/subuid). " - . "The range now starts with $subordinate instead of $subUidMap->{$name}. " - . "If the subuids were used (e.g. with rootless container managers like podman), please change the ownership of affected files accordingly. " - . "Alternatively, to keep the old overlapping ranges, add this to the system configuration: " - . "users.users.$name.subUidRanges = [{startUid = $subUidMap->{$name}; count = 65536;}]; " - . "users.users.$name.subGidRanges = [{startGid = $subUidMap->{$name}; count = 65536;}];\n"; - } - $subUidMap->{$name} = $subordinate; - my $value = join(":", ($name, $subordinate, 65536)); - push @subUids, $value; - push @subGids, $value; - } -} - -updateFile("/etc/subuid", join("\n", @subUids) . "\n"); -updateFile("/etc/subgid", join("\n", @subGids) . "\n"); -updateFile($subUidMapFile, to_json($subUidMap) . "\n"); diff --git a/nix/modules/upstream/nixpkgs/users-groups.nix b/nix/modules/upstream/nixpkgs/users-groups.nix index 7f6f051a..92da2ec9 100644 --- a/nix/modules/upstream/nixpkgs/users-groups.nix +++ b/nix/modules/upstream/nixpkgs/users-groups.nix @@ -877,26 +877,6 @@ in shadow.gid = ids.gids.shadow; }; - system.activationScripts.users = - if !config.systemd.sysusers.enable then - { - supportsDryActivation = true; - text = '' - install -m 0700 -d /root - install -m 0755 -d /home - - ${ - pkgs.perl.withPackages (p: [ - p.FileSlurp - p.JSON - ]) - }/bin/perl \ - -w ${./update-users-groups.pl} ${spec} - ''; - } - else - ""; # keep around for backwards compatibility - systemd.services.linger-users = lib.mkIf ((length lingeringUsers) > 0) { wantedBy = [ "multi-user.target" ]; after = [ "systemd-logind.service" ]; @@ -928,32 +908,6 @@ in # Warn about user accounts with deprecated password hashing schemes # This does not work when the users and groups are created by # systemd-sysusers because the users are created too late then. - system.activationScripts.hashes = - if !config.systemd.sysusers.enable then - { - deps = [ "users" ]; - text = '' - users=() - while IFS=: read -r user hash _; do - if [[ "$hash" = "$"* && ! "$hash" =~ ^\''$${cryptSchemeIdPatternGroup}\$ ]]; then - users+=("$user") - fi - done Date: Sun, 2 Nov 2025 14:30:08 +0100 Subject: [PATCH 04/16] add test for stateful users preservation This new test impurely add a new user to the system and verifies that the user is not garbage collected by userborn. --- testFlake/vm-tests.nix | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/testFlake/vm-tests.nix b/testFlake/vm-tests.nix index 835ff599..d1a1364d 100644 --- a/testFlake/vm-tests.nix +++ b/testFlake/vm-tests.nix @@ -207,7 +207,12 @@ forEachUbuntuImage "example" { assert uid == "5", f"uid was {uid}, expected 5" assert gid == "6", f"gid was {gid}, expected 6" + vm.succeed("useradd luj") + vm.succeed("echo test | passwd luj --stdin") + print(vm.succeed("cat /etc/passwd")) + passwd_out = vm.succeed("passwd -S luj | awk '{print $2}'") + assert "P" in passwd_out user = vm.succeed("stat -c %U /etc/with_ownership2").strip() group = vm.succeed("stat -c %G /etc/with_ownership2").strip() @@ -261,6 +266,8 @@ forEachUbuntuImage "example" { vm.succeed("id -u zimbatm") print(vm.succeed("cat /etc/passwd")) + passwd_out = vm.succeed("passwd -S luj | awk '{print $2}'") + assert "P" in passwd_out nix_trusted_users = vm.succeed("${hostPkgs.nix}/bin/nix config show trusted-users").strip() From 6279d01fef1c829cdb64175fca705d896cc9b311 Mon Sep 17 00:00:00 2001 From: Julien Malka Date: Sun, 2 Nov 2025 19:13:23 +0100 Subject: [PATCH 05/16] intro experimental userborn version --- flake.nix | 5 +- testFlake/flake.lock | 142 ++++++++++++++++++++++++++++++++++++++++- testFlake/flake.nix | 19 ++++-- testFlake/vm-tests.nix | 3 + 4 files changed, 160 insertions(+), 9 deletions(-) diff --git a/flake.nix b/flake.nix index 61a3f5df..84827131 100644 --- a/flake.nix +++ b/flake.nix @@ -9,7 +9,10 @@ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; outputs = - { self, nixpkgs }: + { + self, + nixpkgs, + }: let systems = [ "aarch64-linux" diff --git a/testFlake/flake.lock b/testFlake/flake.lock index 18a6193e..656ad248 100644 --- a/testFlake/flake.lock +++ b/testFlake/flake.lock @@ -1,5 +1,64 @@ { "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1747046372, + "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "userborn", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1756770412, + "narHash": "sha256-+uWLQZccFHwqpGqr2Yt5VsW/PbeJVTn9Dk6SHWhNRPw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "4524271976b625a4a605beefd893f270620fd751", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "userborn", + "pre-commit-hooks-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, "nix-vm-test": { "inputs": { "nixpkgs": [ @@ -36,6 +95,48 @@ "type": "github" } }, + "nixpkgs_2": { + "locked": { + "lastModified": 1757745802, + "narHash": "sha256-hLEO2TPj55KcUFUU1vgtHE9UEIOjRcH/4QbmfHNF820=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c23193b943c6c689d70ee98ce3128239ed9e32d1", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks-nix": { + "inputs": { + "flake-compat": [ + "userborn", + "flake-compat" + ], + "gitignore": "gitignore", + "nixpkgs": [ + "userborn", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1757588530, + "narHash": "sha256-tJ7A8mID3ct69n9WCvZ3PzIIl3rXTdptn/lZmqSS95U=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "b084b2c2b6bc23e83bbfe583b03664eb0b18c411", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, "root": { "inputs": { "nix-vm-test": "nix-vm-test", @@ -43,7 +144,8 @@ "system-manager", "nixpkgs" ], - "system-manager": "system-manager" + "system-manager": "system-manager", + "userborn": "userborn" } }, "system-manager": { @@ -59,6 +161,44 @@ "type": "path" }, "parent": [] + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "userborn": { + "inputs": { + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs_2", + "pre-commit-hooks-nix": "pre-commit-hooks-nix", + "systems": "systems" + }, + "locked": { + "lastModified": 1762107051, + "narHash": "sha256-8bvUPwdiUnqgBnNAuPJlbNFGProAIzlDzjiaqQugPJY=", + "owner": "JulienMalka", + "repo": "userborn", + "rev": "6e8f0d00e683049ac727b626552d5eba7f3471ff", + "type": "github" + }, + "original": { + "owner": "JulienMalka", + "ref": "stateful-users", + "repo": "userborn", + "type": "github" + } } }, "root": "root", diff --git a/testFlake/flake.nix b/testFlake/flake.nix index c631b947..2e291921 100644 --- a/testFlake/flake.nix +++ b/testFlake/flake.nix @@ -9,6 +9,7 @@ inputs = { system-manager.url = "path:.."; nixpkgs.follows = "system-manager/nixpkgs"; + userborn.url = "github:JulienMalka/userborn/stateful-users"; nix-vm-test = { url = "github:numtide/nix-vm-test"; inputs.nixpkgs.follows = "nixpkgs"; @@ -19,6 +20,7 @@ { self, system-manager, + userborn, nixpkgs, nix-vm-test, }: @@ -34,12 +36,15 @@ inherit nixpkgs; system = vmTestSystem; }; - vmChecks = import ./vm-tests.nix { - system = vmTestSystem; - inherit (nixpkgs) lib; - nix-vm-test = vmTestLib; - inherit system-manager; - }; + vmChecks = + system: + import ./vm-tests.nix { + system = vmTestSystem; + inherit (nixpkgs) lib; + nix-vm-test = vmTestLib; + inherit system-manager; + userborn = userborn.packages.${system}.default; + }; containerChecks = system: import ./container-tests.nix { @@ -53,7 +58,7 @@ checks = nixpkgs.lib.genAttrs testedSystems ( system: system-manager.checks.${system} - // nixpkgs.lib.optionalAttrs (system == vmTestSystem) vmChecks + // nixpkgs.lib.optionalAttrs (system == vmTestSystem) (vmChecks system) // (containerChecks system) ); }; diff --git a/testFlake/vm-tests.nix b/testFlake/vm-tests.nix index d1a1364d..f4943822 100644 --- a/testFlake/vm-tests.nix +++ b/testFlake/vm-tests.nix @@ -3,6 +3,7 @@ system-manager, system, nix-vm-test, + userborn, }: let @@ -105,6 +106,8 @@ let services.nginx.enable = false; services.userborn.enable = true; + services.userborn.package = userborn; + systemd.services.userborn.environment.USERBORN_STATEFUL = "1"; environment = { etc = { From 41fac3d48c8c027bd408520893b39ce1ca9b30e5 Mon Sep 17 00:00:00 2001 From: Julien Malka Date: Sun, 2 Nov 2025 19:20:16 +0100 Subject: [PATCH 06/16] fix passwd --stdin not available on old ubuntu versions --- testFlake/vm-tests.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testFlake/vm-tests.nix b/testFlake/vm-tests.nix index f4943822..ad5a4988 100644 --- a/testFlake/vm-tests.nix +++ b/testFlake/vm-tests.nix @@ -211,7 +211,7 @@ forEachUbuntuImage "example" { assert gid == "6", f"gid was {gid}, expected 6" vm.succeed("useradd luj") - vm.succeed("echo test | passwd luj --stdin") + vm.succeed("echo \"luj:test\" | chpasswd") print(vm.succeed("cat /etc/passwd")) passwd_out = vm.succeed("passwd -S luj | awk '{print $2}'") From 47a521f870948a48aaad3407f35a00639022e7f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Fri, 14 Nov 2025 15:20:32 +0100 Subject: [PATCH 07/16] chore: update compatibility with latest nixpkgs changes --- nix/modules/upstream/nixpkgs/activation-script.nix | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nix/modules/upstream/nixpkgs/activation-script.nix b/nix/modules/upstream/nixpkgs/activation-script.nix index 3679b35b..e6488a7f 100644 --- a/nix/modules/upstream/nixpkgs/activation-script.nix +++ b/nix/modules/upstream/nixpkgs/activation-script.nix @@ -268,4 +268,7 @@ in }; }; + config = { + system.activationScripts.users = ""; + }; } From 94b782acf6c732f1351bc79ec9f710d44ff05ca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Fri, 14 Nov 2025 15:20:32 +0100 Subject: [PATCH 08/16] chore: fix code formatting --- .../upstream/nixpkgs/activation-script.nix | 49 ++- nix/modules/upstream/nixpkgs/users-groups.nix | 314 +++++++++--------- 2 files changed, 180 insertions(+), 183 deletions(-) diff --git a/nix/modules/upstream/nixpkgs/activation-script.nix b/nix/modules/upstream/nixpkgs/activation-script.nix index e6488a7f..e32cd455 100644 --- a/nix/modules/upstream/nixpkgs/activation-script.nix +++ b/nix/modules/upstream/nixpkgs/activation-script.nix @@ -98,32 +98,31 @@ let withDry: with types; let - scriptOptions = - { - deps = mkOption { - type = types.listOf types.str; - default = [ ]; - description = "List of dependencies. The script will run after these."; - }; - text = mkOption { - type = types.lines; - description = "The content of the script."; - }; - } - // optionalAttrs withDry { - supportsDryActivation = mkOption { - type = types.bool; - default = false; - description = '' - Whether this activation script supports being dry-activated. - These activation scripts will also be executed on dry-activate - activations with the environment variable - `NIXOS_ACTION` being set to `dry-activate`. - it's important that these activation scripts don't - modify anything about the system when the variable is set. - ''; - }; + scriptOptions = { + deps = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "List of dependencies. The script will run after these."; + }; + text = mkOption { + type = types.lines; + description = "The content of the script."; }; + } + // optionalAttrs withDry { + supportsDryActivation = mkOption { + type = types.bool; + default = false; + description = '' + Whether this activation script supports being dry-activated. + These activation scripts will also be executed on dry-activate + activations with the environment variable + `NIXOS_ACTION` being set to `dry-activate`. + it's important that these activation scripts don't + modify anything about the system when the variable is set. + ''; + }; + }; in either str (submodule { options = scriptOptions; diff --git a/nix/modules/upstream/nixpkgs/users-groups.nix b/nix/modules/upstream/nixpkgs/users-groups.nix index 92da2ec9..a9b50c0d 100644 --- a/nix/modules/upstream/nixpkgs/users-groups.nix +++ b/nix/modules/upstream/nixpkgs/users-groups.nix @@ -994,176 +994,174 @@ in # }; # }; - assertions = - [ - { - assertion = !cfg.enforceIdUniqueness || (uidsAreUnique && gidsAreUnique); - message = "UIDs and GIDs must be unique!"; - } - # { - # assertion = !cfg.enforceIdUniqueness || (sdInitrdUidsAreUnique && sdInitrdGidsAreUnique); - # message = "systemd initrd UIDs and GIDs must be unique!"; - # } - { - assertion = usersWithoutExistingGroup == { }; - message = + assertions = [ + { + assertion = !cfg.enforceIdUniqueness || (uidsAreUnique && gidsAreUnique); + message = "UIDs and GIDs must be unique!"; + } + # { + # assertion = !cfg.enforceIdUniqueness || (sdInitrdUidsAreUnique && sdInitrdGidsAreUnique); + # message = "systemd initrd UIDs and GIDs must be unique!"; + # } + { + assertion = usersWithoutExistingGroup == { }; + message = + let + errUsers = lib.attrNames usersWithoutExistingGroup; + missingGroups = lib.unique (lib.mapAttrsToList (n: u: u.group) usersWithoutExistingGroup); + mkConfigHint = group: "users.groups.${group} = {};"; + in + '' + The following users have a primary group that is undefined: ${lib.concatStringsSep " " errUsers} + Hint: Add this to your NixOS configuration: + ${lib.concatStringsSep "\n " (map mkConfigHint missingGroups)} + ''; + } + { + assertion = !cfg.mutableUsers -> length usersWithNullShells == 0; + message = '' + users.mutableUsers = false has been set, + but found users that have their shell set to null. + If you wish to disable login, set their shell to pkgs.shadow (the default). + Misconfigured users: ${lib.concatStringsSep " " usersWithNullShells} + ''; + } + { + # If mutableUsers is false, to prevent users creating a + # configuration that locks them out of the system, ensure that + # there is at least one "privileged" account that has a + # password or an SSH authorized key. Privileged accounts are + # root and users in the wheel group. + # The check does not apply when users.allowNoPasswordLogin + # The check does not apply when users.mutableUsers + assertion = + !cfg.mutableUsers + -> !cfg.allowNoPasswordLogin + -> any id ( + mapAttrsToList ( + name: cfg: + (name == "root" || cfg.group == "wheel" || elem "wheel" cfg.extraGroups) + && ( + allowsLogin cfg.hashedPassword + || cfg.password != null + || cfg.hashedPasswordFile != null + || cfg.openssh.authorizedKeys.keys != [ ] + || cfg.openssh.authorizedKeys.keyFiles != [ ] + ) + ) cfg.users + ++ [ + config.security.googleOsLogin.enable + ] + ); + message = '' + Neither the root account nor any wheel user has a password or SSH authorized key. + You must set one to prevent being locked out of your system. + If you really want to be locked out of your system, set users.allowNoPasswordLogin = true; + However you are most probably better off by setting users.mutableUsers = true; and + manually running passwd root to set the root password. + ''; + } + ] + ++ flatten ( + flip mapAttrsToList cfg.users ( + name: user: + [ + ( let - errUsers = lib.attrNames usersWithoutExistingGroup; - missingGroups = lib.unique (lib.mapAttrsToList (n: u: u.group) usersWithoutExistingGroup); - mkConfigHint = group: "users.groups.${group} = {};"; + # Things fail in various ways with especially non-ascii usernames. + # This regex mirrors the one from shadow's is_valid_name: + # https://github.com/shadow-maint/shadow/blob/bee77ffc291dfed2a133496db465eaa55e2b0fec/lib/chkname.c#L68 + # though without the trailing $, because Samba 3 got its last release + # over 10 years ago and is not in Nixpkgs anymore, + # while later versions don't appear to require anything like that. + nameRegex = "[a-zA-Z0-9_.][a-zA-Z0-9_.-]*"; in - '' - The following users have a primary group that is undefined: ${lib.concatStringsSep " " errUsers} - Hint: Add this to your NixOS configuration: - ${lib.concatStringsSep "\n " (map mkConfigHint missingGroups)} + { + assertion = builtins.match nameRegex user.name != null; + message = "The username \"${user.name}\" is not valid, it does not match the regex \"${nameRegex}\"."; + } + ) + { + assertion = (user.hashedPassword != null) -> (match ".*:.*" user.hashedPassword == null); + message = '' + The password hash of user "${user.name}" contains a ":" character. + This is invalid and would break the login system because the fields + of /etc/shadow (file where hashes are stored) are colon-separated. + Please check the value of option `users.users."${user.name}".hashedPassword`.''; + } + { + assertion = user.isNormalUser && user.uid != null -> user.uid >= 1000; + message = '' + A user cannot have a users.users.${user.name}.uid set below 1000 and set users.users.${user.name}.isNormalUser. + Either users.users.${user.name}.isSystemUser must be set to true instead of users.users.${user.name}.isNormalUser + or users.users.${user.name}.uid must be changed to 1000 or above. ''; - } - { - assertion = !cfg.mutableUsers -> length usersWithNullShells == 0; - message = '' - users.mutableUsers = false has been set, - but found users that have their shell set to null. - If you wish to disable login, set their shell to pkgs.shadow (the default). - Misconfigured users: ${lib.concatStringsSep " " usersWithNullShells} - ''; - } - { - # If mutableUsers is false, to prevent users creating a - # configuration that locks them out of the system, ensure that - # there is at least one "privileged" account that has a - # password or an SSH authorized key. Privileged accounts are - # root and users in the wheel group. - # The check does not apply when users.allowNoPasswordLogin - # The check does not apply when users.mutableUsers - assertion = - !cfg.mutableUsers - -> !cfg.allowNoPasswordLogin - -> any id ( - mapAttrsToList ( - name: cfg: - (name == "root" || cfg.group == "wheel" || elem "wheel" cfg.extraGroups) - && ( - allowsLogin cfg.hashedPassword - || cfg.password != null - || cfg.hashedPasswordFile != null - || cfg.openssh.authorizedKeys.keys != [ ] - || cfg.openssh.authorizedKeys.keyFiles != [ ] - ) - ) cfg.users - ++ [ - config.security.googleOsLogin.enable - ] - ); - message = '' - Neither the root account nor any wheel user has a password or SSH authorized key. - You must set one to prevent being locked out of your system. - If you really want to be locked out of your system, set users.allowNoPasswordLogin = true; - However you are most probably better off by setting users.mutableUsers = true; and - manually running passwd root to set the root password. - ''; - } - ] - ++ flatten ( - flip mapAttrsToList cfg.users ( - name: user: - [ - ( + } + { + assertion = let - # Things fail in various ways with especially non-ascii usernames. - # This regex mirrors the one from shadow's is_valid_name: - # https://github.com/shadow-maint/shadow/blob/bee77ffc291dfed2a133496db465eaa55e2b0fec/lib/chkname.c#L68 - # though without the trailing $, because Samba 3 got its last release - # over 10 years ago and is not in Nixpkgs anymore, - # while later versions don't appear to require anything like that. - nameRegex = "[a-zA-Z0-9_.][a-zA-Z0-9_.-]*"; + # we do an extra check on isNormalUser here, to not trigger this assertion when isNormalUser is set and uid to < 1000 + isEffectivelySystemUser = + user.isSystemUser || (user.uid != null && user.uid < 1000 && !user.isNormalUser); in - { - assertion = builtins.match nameRegex user.name != null; - message = "The username \"${user.name}\" is not valid, it does not match the regex \"${nameRegex}\"."; - } - ) - { - assertion = (user.hashedPassword != null) -> (match ".*:.*" user.hashedPassword == null); - message = '' - The password hash of user "${user.name}" contains a ":" character. - This is invalid and would break the login system because the fields - of /etc/shadow (file where hashes are stored) are colon-separated. - Please check the value of option `users.users."${user.name}".hashedPassword`.''; - } - { - assertion = user.isNormalUser && user.uid != null -> user.uid >= 1000; - message = '' - A user cannot have a users.users.${user.name}.uid set below 1000 and set users.users.${user.name}.isNormalUser. - Either users.users.${user.name}.isSystemUser must be set to true instead of users.users.${user.name}.isNormalUser - or users.users.${user.name}.uid must be changed to 1000 or above. - ''; - } - { - assertion = - let - # we do an extra check on isNormalUser here, to not trigger this assertion when isNormalUser is set and uid to < 1000 - isEffectivelySystemUser = - user.isSystemUser || (user.uid != null && user.uid < 1000 && !user.isNormalUser); - in - xor isEffectivelySystemUser user.isNormalUser; - message = '' - Exactly one of users.users.${user.name}.isSystemUser and users.users.${user.name}.isNormalUser must be set. - ''; - } - { - assertion = user.group != ""; - message = '' - users.users.${user.name}.group is unset. This used to default to - nogroup, but this is unsafe. For example you can create a group - for this user with: - users.users.${user.name}.group = "${user.name}"; - users.groups.${user.name} = {}; - ''; - } + xor isEffectivelySystemUser user.isNormalUser; + message = '' + Exactly one of users.users.${user.name}.isSystemUser and users.users.${user.name}.isNormalUser must be set. + ''; + } + { + assertion = user.group != ""; + message = '' + users.users.${user.name}.group is unset. This used to default to + nogroup, but this is unsafe. For example you can create a group + for this user with: + users.users.${user.name}.group = "${user.name}"; + users.groups.${user.name} = {}; + ''; + } + ] + ++ (map + (shell: { + assertion = + !user.ignoreShellProgramCheck + -> (user.shell == pkgs.${shell}) + -> (config.programs.${shell}.enable == true); + message = '' + users.users.${user.name}.shell is set to ${shell}, but + programs.${shell}.enable is not true. This will cause the ${shell} + shell to lack the basic nix directories in its PATH and might make + logging in as that user impossible. You can fix it with: + programs.${shell}.enable = true; + + If you know what you're doing and you are fine with the behavior, + set users.users.${user.name}.ignoreShellProgramCheck = true; + instead. + ''; + }) + [ + "fish" + "xonsh" + "zsh" ] - ++ (map - (shell: { - assertion = - !user.ignoreShellProgramCheck - -> (user.shell == pkgs.${shell}) - -> (config.programs.${shell}.enable == true); - message = '' - users.users.${user.name}.shell is set to ${shell}, but - programs.${shell}.enable is not true. This will cause the ${shell} - shell to lack the basic nix directories in its PATH and might make - logging in as that user impossible. You can fix it with: - programs.${shell}.enable = true; - - If you know what you're doing and you are fine with the behavior, - set users.users.${user.name}.ignoreShellProgramCheck = true; - instead. - ''; - }) - [ - "fish" - "xonsh" - "zsh" - ] - ) ) - ); + ) + ); warnings = flip concatMap (attrValues cfg.users) ( user: let - passwordOptions = - [ - "hashedPassword" - "hashedPasswordFile" - "password" - ] - ++ optionals cfg.mutableUsers [ - # For immutable users, initialHashedPassword is set to hashedPassword, - # so using these options would always trigger the assertion. - "initialHashedPassword" - "initialPassword" - ]; + passwordOptions = [ + "hashedPassword" + "hashedPasswordFile" + "password" + ] + ++ optionals cfg.mutableUsers [ + # For immutable users, initialHashedPassword is set to hashedPassword, + # so using these options would always trigger the assertion. + "initialHashedPassword" + "initialPassword" + ]; unambiguousPasswordConfiguration = 1 >= length (filter (x: x != null) (map (flip getAttr user) passwordOptions)); in From 056da77a3a78643eee190365667e92afcb59746d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Mon, 19 Jan 2026 14:27:20 +0100 Subject: [PATCH 09/16] Revert "Enable system activation scripts" This reverts commit 461de1aac455189b4e4b3ee85e09d095746cdd0f. --- crates/system-manager-engine/src/activate.rs | 26 -- examples/example.nix | 4 - nix/modules/default.nix | 3 - .../upstream/nixpkgs/activation-script.nix | 273 ------------------ nix/modules/upstream/nixpkgs/default.nix | 1 - testFlake/vm-tests.nix | 9 - 6 files changed, 316 deletions(-) delete mode 100644 nix/modules/upstream/nixpkgs/activation-script.nix diff --git a/crates/system-manager-engine/src/activate.rs b/crates/system-manager-engine/src/activate.rs index 9147b88f..ab095453 100644 --- a/crates/system-manager-engine/src/activate.rs +++ b/crates/system-manager-engine/src/activate.rs @@ -108,19 +108,6 @@ pub fn activate(store_path: &StorePath, ephemeral: bool) -> Result<()> { }; final_state.write_to_file(state_file)?; - log::info!("Running system activation script..."); - match run_system_activation_script(store_path) { - Ok(status) if status.success() => { - log::info!("System activation script executed successfully."); - } - Ok(status) => { - log::error!("System activation script failed with status: {status}"); - } - Err(e) => { - log::error!("Error running system activation script: {e}"); - } - } - if let Err(e) = tmp_result { return Err(e.into()); } @@ -233,19 +220,6 @@ fn run_preactivation_assertions(store_path: &StorePath) -> Result Result { - let status = process::Command::new( - store_path - .store_path - .join("bin") - .join("systemActivationScript"), - ) - .stderr(process::Stdio::inherit()) - .stdout(process::Stdio::inherit()) - .status()?; - Ok(status) -} - fn get_state_file() -> Result { let state_file = Path::new(SYSTEM_MANAGER_STATE_DIR).join(STATE_FILE_NAME); DirBuilder::new() diff --git a/examples/example.nix b/examples/example.nix index 1ceed69b..f6fcf75b 100644 --- a/examples/example.nix +++ b/examples/example.nix @@ -100,9 +100,5 @@ mode = "0755"; }; }; - - system.activationScripts.test = '' - echo "This is a test activation script" - ''; }; } diff --git a/nix/modules/default.nix b/nix/modules/default.nix index 56f5fbeb..6b8518be 100644 --- a/nix/modules/default.nix +++ b/nix/modules/default.nix @@ -233,8 +233,6 @@ ${system-manager}/bin/system-manager-engine deactivate "$@" ''; - systemActivationScript = pkgs.writeShellScript "systemActivationScript" config.system.activationScripts.script; - preActivationAssertionScript = let mkAssertion = @@ -278,7 +276,6 @@ exit 0 fi ''; - }; # TODO: handle globbing diff --git a/nix/modules/upstream/nixpkgs/activation-script.nix b/nix/modules/upstream/nixpkgs/activation-script.nix deleted file mode 100644 index e32cd455..00000000 --- a/nix/modules/upstream/nixpkgs/activation-script.nix +++ /dev/null @@ -1,273 +0,0 @@ -# copied from modules/system/activation/activation-script.nix to avoid the dependency on systemd.user -{ - config, - lib, - pkgs, - nixosModulesPath, - ... -}: - -with lib; - -let - - addAttributeName = mapAttrs ( - a: v: - v - // { - text = '' - #### Activation script snippet ${a}: - _localstatus=0 - ${v.text} - - if (( _localstatus > 0 )); then - printf "Activation script snippet '%s' failed (%s)\n" "${a}" "$_localstatus" - fi - ''; - } - ); - - systemActivationScript = - set: onlyDry: - let - set' = mapAttrs ( - _: v: if isString v then (noDepEntry v) // { supportsDryActivation = false; } else v - ) set; - withHeadlines = addAttributeName set'; - # When building a dry activation script, this replaces all activation scripts - # that do not support dry mode with a comment that does nothing. Filtering these - # activation scripts out so they don't get generated into the dry activation script - # does not work because when an activation script that supports dry mode depends on - # an activation script that does not, the dependency cannot be resolved and the eval - # fails. - withDrySnippets = mapAttrs ( - a: v: - if onlyDry && !v.supportsDryActivation then - v - // { - text = "#### Activation script snippet ${a} does not support dry activation."; - } - else - v - ) withHeadlines; - in - '' - #!${pkgs.runtimeShell} - - source ${nixosModulesPath}/system/activation/lib/lib.sh - - systemConfig='@out@' - - export PATH=/empty - for i in ${toString path}; do - PATH=$PATH:$i/bin:$i/sbin - done - - _status=0 - trap "_status=1 _localstatus=\$?" ERR - - # Ensure a consistent umask. - umask 0022 - - ${textClosureMap id (withDrySnippets) (attrNames withDrySnippets)} - - '' - + optionalString (!onlyDry) '' - # Make this configuration the current configuration. - # The readlink is there to ensure that when $systemConfig = /system - # (which is a symlink to the store), /run/current-system is still - # used as a garbage collection root. - ln -sfn "$(readlink -f "$systemConfig")" /run/current-system - - exit $_status - ''; - - path = - with pkgs; - map getBin [ - coreutils - gnugrep - findutils - getent - stdenv.cc.libc # nscd in update-users-groups.pl - shadow - util-linux # needed for mount and mountpoint - ]; - - scriptType = - withDry: - with types; - let - scriptOptions = { - deps = mkOption { - type = types.listOf types.str; - default = [ ]; - description = "List of dependencies. The script will run after these."; - }; - text = mkOption { - type = types.lines; - description = "The content of the script."; - }; - } - // optionalAttrs withDry { - supportsDryActivation = mkOption { - type = types.bool; - default = false; - description = '' - Whether this activation script supports being dry-activated. - These activation scripts will also be executed on dry-activate - activations with the environment variable - `NIXOS_ACTION` being set to `dry-activate`. - it's important that these activation scripts don't - modify anything about the system when the variable is set. - ''; - }; - }; - in - either str (submodule { - options = scriptOptions; - }); - -in - -{ - - ###### interface - - options = { - - system.activationScripts = mkOption { - default = { }; - - example = literalExpression '' - { - stdio = { - # Run after /dev has been mounted - deps = [ "specialfs" ]; - text = - ''' - # Needed by some programs. - ln -sfn /proc/self/fd /dev/fd - ln -sfn /proc/self/fd/0 /dev/stdin - ln -sfn /proc/self/fd/1 /dev/stdout - ln -sfn /proc/self/fd/2 /dev/stderr - '''; - }; - } - ''; - - description = '' - A set of shell script fragments that are executed when a NixOS - system configuration is activated. Examples are updating - /etc, creating accounts, and so on. Since these are executed - every time you boot the system or run - {command}`nixos-rebuild`, it's important that they are - idempotent and fast. - ''; - - type = types.attrsOf (scriptType true); - apply = - set: - set - // { - script = systemActivationScript set false; - }; - }; - - system.dryActivationScript = mkOption { - description = "The shell script that is to be run when dry-activating a system."; - readOnly = true; - internal = true; - default = systemActivationScript (removeAttrs config.system.activationScripts [ "script" ]) true; - defaultText = literalMD "generated activation script"; - }; - - system.userActivationScripts = mkOption { - default = { }; - - example = literalExpression '' - { plasmaSetup = { - text = ''' - ''${pkgs.libsForQt5.kservice}/bin/kbuildsycoca5" - '''; - deps = []; - }; - } - ''; - - description = '' - A set of shell script fragments that are executed by a systemd user - service when a NixOS system configuration is activated. Examples are - rebuilding the .desktop file cache for showing applications in the menu. - Since these are executed every time you run - {command}`nixos-rebuild`, it's important that they are - idempotent and fast. - ''; - - type = with types; attrsOf (scriptType false); - - apply = set: { - script = '' - export PATH= - for i in ${toString path}; do - PATH=$PATH:$i/bin:$i/sbin - done - - _status=0 - trap "_status=1 _localstatus=\$?" ERR - - ${ - let - set' = mapAttrs (n: v: if isString v then noDepEntry v else v) set; - withHeadlines = addAttributeName set'; - in - textClosureMap id (withHeadlines) (attrNames withHeadlines) - } - - exit $_status - ''; - }; - - }; - - environment.usrbinenv = mkOption { - default = "${pkgs.coreutils}/bin/env"; - defaultText = literalExpression ''"''${pkgs.coreutils}/bin/env"''; - example = literalExpression ''"''${pkgs.busybox}/bin/env"''; - type = types.nullOr types.path; - visible = false; - description = '' - The {manpage}`env(1)` executable that is linked system-wide to - `/usr/bin/env`. - ''; - }; - - system.build.installBootLoader = mkOption { - internal = true; - default = pkgs.writeShellScript "no-bootloader" '' - echo 'Warning: do not know how to make this configuration bootable; please enable a boot loader.' 1>&2 - ''; - defaultText = lib.literalExpression '' - pkgs.writeShellScript "no-bootloader" ''' - echo 'Warning: do not know how to make this configuration bootable; please enable a boot loader.' 1>&2 - ''' - ''; - description = '' - A program that writes a bootloader installation script to the path passed in the first command line argument. - - See `pkgs/by-name/sw/switch-to-configuration-ng/src/src/main.rs`. - ''; - type = types.unique { - message = '' - Only one bootloader can be enabled at a time. This requirement has not - been checked until NixOS 22.05. Earlier versions defaulted to the last - definition. Change your configuration to enable only one bootloader. - ''; - } (types.either types.str types.package); - }; - - }; - config = { - system.activationScripts.users = ""; - }; -} diff --git a/nix/modules/upstream/nixpkgs/default.nix b/nix/modules/upstream/nixpkgs/default.nix index 04697701..74db2e0b 100644 --- a/nix/modules/upstream/nixpkgs/default.nix +++ b/nix/modules/upstream/nixpkgs/default.nix @@ -7,7 +7,6 @@ imports = [ ./nginx.nix ./nix.nix - ./activation-script.nix ./users-groups.nix ] ++ diff --git a/testFlake/vm-tests.nix b/testFlake/vm-tests.nix index ad5a4988..1363640d 100644 --- a/testFlake/vm-tests.nix +++ b/testFlake/vm-tests.nix @@ -152,14 +152,6 @@ let }; }; - system.activationScripts = { - "system-manager" = { - text = '' - touch /tmp/file-created-by-system-activation-script - ''; - }; - }; - users.users.zimbatm = { isNormalUser = true; extraGroups = [ "wheel" ]; @@ -264,7 +256,6 @@ forEachUbuntuImage "example" { vm.fail("test -f /etc/a/nested/example/foo3") vm.fail("test -f /etc/baz/bar/foo2") vm.succeed("test -f /etc/foo_new") - vm.succeed("test -f /tmp/file-created-by-system-activation-script") vm.succeed("id -u zimbatm") From 92dd7cddbbefe9b12196eb9ba87fd97c2c897a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Mon, 19 Jan 2026 14:31:28 +0100 Subject: [PATCH 10/16] fix(nixpkgs): add stub for system.activationScripts.users option Add a stub option for system.activationScripts.users to satisfy nixos/modules/services/system/userborn.nix dependency without importing the full activationScripts module. --- nix/modules/upstream/nixpkgs/default.nix | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/nix/modules/upstream/nixpkgs/default.nix b/nix/modules/upstream/nixpkgs/default.nix index 74db2e0b..f9197dcc 100644 --- a/nix/modules/upstream/nixpkgs/default.nix +++ b/nix/modules/upstream/nixpkgs/default.nix @@ -31,6 +31,14 @@ boot = lib.mkOption { type = lib.types.raw; }; + + # nixos/modules/services/system/userborn.nix still depends on activation scripts + # but just to verify that the "users" activation script is disabled. + # We try to avoid having to import the whole activationScripts module. + system.activationScripts.users = lib.mkOption { + type = lib.types.str; + default = ""; + }; }; } From 16de8dd05f1feedbe0b2dea06db93566f2db58f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Mon, 19 Jan 2026 16:05:51 +0100 Subject: [PATCH 11/16] docs: add users and groups example and documentation --- docs/site/examples/index.md | 4 ++ docs/site/examples/postgresql.md | 45 ++++++------- docs/site/examples/users.md | 107 +++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 22 deletions(-) create mode 100644 docs/site/examples/users.md diff --git a/docs/site/examples/index.md b/docs/site/examples/index.md index fe713d72..aa82460a 100644 --- a/docs/site/examples/index.md +++ b/docs/site/examples/index.md @@ -4,6 +4,10 @@ Complete, working examples demonstrating System Manager configurations for commo ## Available Examples +### [Users and Groups](users.md) + +Declaratively manage user accounts and groups, including normal users, system users for services, and group membership. + ### [Timer](timer.md) Create a simple systemd timer that runs every minute, demonstrating how to set up scheduled tasks. diff --git a/docs/site/examples/postgresql.md b/docs/site/examples/postgresql.md index 2a71b6b2..5772cb5f 100644 --- a/docs/site/examples/postgresql.md +++ b/docs/site/examples/postgresql.md @@ -2,26 +2,9 @@ This example shows how to install and configure PostgreSQL as a systemd service. -## Prerequisites - -System Manager is still in its early state, and doesn't yet have user management, which is a planned feature that will be here soon. As such, for now, before you run this, you'll need to manually create the postgres user. Additionally, go ahead and create two directories and grant the postgres user access to them: - -```sh -# Create postgres user and group -sudo groupadd -r postgres -sudo useradd -r -g postgres -d /var/lib/postgresql -s /bin/bash postgres - -# Create directories with proper permissions -sudo mkdir -p /var/lib/postgresql -sudo chown postgres:postgres /var/lib/postgresql - -sudo mkdir -p /run/postgresql -sudo chown postgres:postgres /run/postgresql -``` - ## Configuration -Here's the `.nix` file that installs PostgreSQL. +Here's the `.nix` file that installs PostgreSQL with declarative user management. ```nix { config, lib, pkgs, ... }: @@ -29,6 +12,22 @@ Here's the `.nix` file that installs PostgreSQL. config = { nixpkgs.hostPlatform = "x86_64-linux"; + # Create the postgres system user and group + users.users.postgres = { + isSystemUser = true; + group = "postgres"; + home = "/var/lib/postgresql"; + createHome = true; + description = "PostgreSQL server"; + }; + + users.groups.postgres = {}; + + # Create the runtime directory for PostgreSQL socket + systemd.tmpfiles.rules = [ + "d /run/postgresql 0755 postgres postgres -" + ]; + environment.systemPackages = with pkgs; [ postgresql_16 ]; @@ -103,13 +102,15 @@ Here's the `.nix` file that installs PostgreSQL. ## What this configuration does -1. **Installs PostgreSQL 16** as a system package -2. **Creates a systemd service** that: +1. **Creates the postgres user and group** declaratively via `users.users` and `users.groups` +2. **Creates the runtime directory** `/run/postgresql` via tmpfiles for the PostgreSQL socket +3. **Installs PostgreSQL 16** as a system package +4. **Creates a systemd service** that: - Runs as the `postgres` user - Initializes the database directory on first run - Starts PostgreSQL with the data directory at `/var/lib/postgresql/16` -3. **Creates an initialization service** that: +5. **Creates an initialization service** that: - Waits for PostgreSQL to be ready - Creates a database called `myapp` - - Creates a user called `myapp` + - Creates a database user called `myapp` - Grants appropriate privileges diff --git a/docs/site/examples/users.md b/docs/site/examples/users.md new file mode 100644 index 00000000..5568f526 --- /dev/null +++ b/docs/site/examples/users.md @@ -0,0 +1,107 @@ +# Users and groups + +This example demonstrates how to declaratively manage users and groups with System Manager. + +## Configuration + +### system.nix + +```nix +{ pkgs, ... }: +{ + nixpkgs.hostPlatform = "x86_64-linux"; + + # Create a normal user account + users.users.alice = { + isNormalUser = true; + description = "Alice User"; + extraGroups = [ "wheel" "docker" ]; + # Set an initial password (only applied on first creation if mutableUsers = true) + initialPassword = "changeme"; + }; + + # Create a system user for running services + users.users.myapp = { + isSystemUser = true; + group = "myapp"; + home = "/var/lib/myapp"; + createHome = true; + description = "My Application service account"; + }; + + # Create the group for the system user + users.groups.myapp = {}; + + # Create additional groups + users.groups.docker = {}; +} +``` + +## User types + +System Manager distinguishes between two types of users, and exactly one must be specified. + +*Normal users* are interactive accounts for people logging into the system. +Setting `isNormalUser = true` automatically configures sensible defaults: a home directory at `/home/`, membership in the `users` group, and the default shell. + +*System users* are non-interactive accounts for running services. +Setting `isSystemUser = true` creates an account with a UID below 1000 and no login shell by default. +System users require an explicit `group` setting. + +## Password options + +Several options control user passwords. +For systems where `users.mutableUsers = true` (the default), use `initialPassword` or `initialHashedPassword` to set a password only when the user is first created. +Users can then change their password with `passwd`. + +For immutable configurations where `users.mutableUsers = false`, use `hashedPassword` or `hashedPasswordFile` to enforce a specific password on every activation. + +Generate a hashed password with `mkpasswd`: + +```bash +mkpasswd -m sha-512 +``` + +## Advanced example + +This configuration shows additional options for user management: + +```nix +{ pkgs, ... }: +{ + nixpkgs.hostPlatform = "x86_64-linux"; + + # Disable mutable users for fully declarative management + # users.mutableUsers = false; + + users.users.bob = { + isNormalUser = true; + description = "Bob Developer"; + home = "/home/bob"; + shell = pkgs.zsh; + extraGroups = [ "wheel" "networkmanager" ]; + # Hashed password (use mkpasswd to generate) + hashedPassword = "$6$rounds=500000$example$hashedpasswordhere"; + # Or read from a file at activation time + # hashedPasswordFile = "/run/secrets/bob-password"; + }; + + users.users.postgres = { + isSystemUser = true; + group = "postgres"; + home = "/var/lib/postgresql"; + createHome = true; + description = "PostgreSQL server"; + }; + + users.groups.postgres = {}; +} +``` + +## What this configuration does + +1. Creates user accounts in `/etc/passwd` and `/etc/shadow` +2. Creates groups in `/etc/group` +3. Sets up home directories when `createHome = true` +4. Manages subordinate UID/GID ranges in `/etc/subuid` and `/etc/subgid` for container support +5. Preserves existing passwords and UIDs when `mutableUsers = true` From 916580c298828090a236c4c831476acb41551282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Mon, 19 Jan 2026 16:12:00 +0100 Subject: [PATCH 12/16] fix: use system-manager path for shell in /etc/passwd --- nix/lib.nix | 21 ++++++++++++++++++--- testFlake/vm-tests.nix | 5 +++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/nix/lib.nix b/nix/lib.nix index b0d43cb1..6ed6de32 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -56,9 +56,24 @@ let } // systemArgs ); - utils = import "${nixos}/lib/utils.nix" { - inherit lib config pkgs; - }; + utils = + let + nixosUtils = import "${nixos}/lib/utils.nix" { + inherit lib config pkgs; + }; + in + nixosUtils + // { + # Override toShellPath to use system-manager's path instead of NixOS's + toShellPath = + shell: + if lib.types.shellPackage.check shell then + "/run/system-manager/sw${shell.shellPath}" + else if lib.types.package.check shell then + throw "${shell} is not a shell package" + else + shell; + }; # Pass the wrapped system-manager binary down # TODO: Use nixpkgs version by default. system-manager = pkgs.callPackage ../package.nix { }; diff --git a/testFlake/vm-tests.nix b/testFlake/vm-tests.nix index 1363640d..446fb0e1 100644 --- a/testFlake/vm-tests.nix +++ b/testFlake/vm-tests.nix @@ -267,6 +267,11 @@ forEachUbuntuImage "example" { nix_trusted_users = vm.succeed("${hostPkgs.nix}/bin/nix config show trusted-users").strip() assert "zimbatm" in nix_trusted_users, f"Expected 'zimbatm' to be in trusted-users, got {nix_trusted_users}" + # Verify zimbatm user exists with correct shell path + zimbatm_entry = vm.succeed("grep '^zimbatm:' /etc/passwd").strip() + assert "/run/system-manager/sw/bin/bash" in zimbatm_entry, f"Expected shell to be /run/system-manager/sw/bin/bash, got: {zimbatm_entry}" + + # Re-activate the same profile to verify idempotency and no ERROR in output ${system-manager.lib.activateProfileSnippet { node = "vm"; From dc8b84a230433b3396b2c433507bc759b851e0b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Mon, 19 Jan 2026 18:57:19 +0100 Subject: [PATCH 13/16] feat: call userborn on deactivation to lock managed accounts Check that user created outside of userborn is not locked on deactivation, while userborn-managed users are locked. --- nix/modules/default.nix | 19 ++++++++++++++++--- testFlake/vm-tests.nix | 22 +++++++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/nix/modules/default.nix b/nix/modules/default.nix index 6b8518be..a579f089 100644 --- a/nix/modules/default.nix +++ b/nix/modules/default.nix @@ -229,9 +229,22 @@ "$@" ''; - deactivationScript = pkgs.writeShellScript "deactivate" '' - ${system-manager}/bin/system-manager-engine deactivate "$@" - ''; + deactivationScript = + let + # Lock any users that were managed by system-manager + minimalUserbornConfig = pkgs.writeText "userborn-deactivate.json" ( + builtins.toJSON { + users = [ ]; + groups = [ ]; + } + ); + in + pkgs.writeShellScript "deactivate" '' + echo "Locking previously managed user accounts..." + USERBORN_STATEFUL=1 ${lib.getExe config.services.userborn.package} ${minimalUserbornConfig} /etc || true + + ${system-manager}/bin/system-manager-engine deactivate "$@" + ''; preActivationAssertionScript = let diff --git a/testFlake/vm-tests.nix b/testFlake/vm-tests.nix index 446fb0e1..f98fe630 100644 --- a/testFlake/vm-tests.nix +++ b/testFlake/vm-tests.nix @@ -155,6 +155,7 @@ let users.users.zimbatm = { isNormalUser = true; extraGroups = [ "wheel" ]; + initialPassword = "test123"; }; }; } @@ -267,10 +268,16 @@ forEachUbuntuImage "example" { nix_trusted_users = vm.succeed("${hostPkgs.nix}/bin/nix config show trusted-users").strip() assert "zimbatm" in nix_trusted_users, f"Expected 'zimbatm' to be in trusted-users, got {nix_trusted_users}" + luj_entry = vm.succeed("grep '^luj:' /etc/passwd").strip() + assert luj_entry != "", "Expected user 'luj' to exist" + # Verify zimbatm user exists with correct shell path zimbatm_entry = vm.succeed("grep '^zimbatm:' /etc/passwd").strip() assert "/run/system-manager/sw/bin/bash" in zimbatm_entry, f"Expected shell to be /run/system-manager/sw/bin/bash, got: {zimbatm_entry}" + zimbatm_shadow_before = vm.succeed("grep '^zimbatm:' /etc/shadow").strip() + print(f"Shadow entry before deactivation: {zimbatm_shadow_before}") + assert not zimbatm_shadow_before.startswith("zimbatm:!*"), f"Expected unlocked account before deactivation, got: {zimbatm_shadow_before}" # Re-activate the same profile to verify idempotency and no ERROR in output ${system-manager.lib.activateProfileSnippet { @@ -286,7 +293,20 @@ forEachUbuntuImage "example" { }} vm.fail("systemctl status new-service.service") vm.fail("test -f /etc/foo_new") - #vm.fail("test -f /var/tmp/system-manager/foo1") + + # userborn never deletes users + zimbatm_entry = vm.succeed("grep '^zimbatm:' /etc/passwd").strip() + assert zimbatm_entry != "", f"Expected user 'zimbatm' to persist in /etc/passwd after deactivation, got empty" + + # userborn locks user in shadow (password = "!*") after deactivation + zimbatm_shadow = vm.succeed("grep '^zimbatm:' /etc/shadow").strip() + print(f"Shadow entry after deactivation: {zimbatm_shadow}") + assert zimbatm_shadow.startswith("zimbatm:!*"), f"Expected locked account (zimbatm:!*), got: {zimbatm_shadow}" + + # Stateful user 'luj' (not managed by userborn) should NOT be locked + luj_shadow = vm.succeed("grep '^luj:' /etc/shadow").strip() + print(f"Stateful user shadow after deactivation: {luj_shadow}") + assert not luj_shadow.startswith("luj:!*"), f"Stateful user 'luj' should NOT be locked after deactivation, got: {luj_shadow}" ''; } From bc22c2b7b05b35d221862a7be4c3a8ad5dc424d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Tue, 27 Jan 2026 09:49:23 +0100 Subject: [PATCH 14/16] feat: integrate userborn with activation/deactivation lifecycle mutable users handling has been merged into userborn with https://github.com/nikstur/userborn/pull/38 This commit update system-manager to use the new userborn features and properly sequence userborn execution during activation and deactivation. System manager ensure users exist before tmpfiles runs and managed accounts are locked on deactivation. Activation changes: - Restart userborn.service after daemon-reload but before tmpfiles - Use restart (not start) because userborn is a oneshot service with RemainAfterExit=true - start on an already-active service is a no-op Deactivation changes: - Move user locking logic from Nix shell script to Rust engine - Add users.rs module with lock_managed_users() that calls userborn with empty config to lock previously managed accounts - Set USERBORN_MUTABLE_USERS=true so only managed users are locked, not stateful users created outside userborn - Create top-level deactivate.rs module for cleaner API naming --- Cargo.lock | 88 +++++++++++ Cargo.toml | 1 + crates/system-manager-engine/Cargo.toml | 1 + crates/system-manager-engine/src/activate.rs | 53 ++----- .../src/activate/etc_files.rs | 7 - .../src/activate/services.rs | 31 ++++ .../src/activate/users.rs | 57 +++++++ .../system-manager-engine/src/deactivate.rs | 48 ++++++ crates/system-manager-engine/src/lib.rs | 1 + crates/system-manager-engine/src/main.rs | 2 +- flake.lock | 147 +++++++++++++++++- flake.nix | 4 +- nix/lib.nix | 2 + nix/modules/default.nix | 20 +-- nix/modules/upstream/nixpkgs/default.nix | 1 + nix/modules/upstream/nixpkgs/userborn.nix | 53 +++++++ nix/modules/upstream/nixpkgs/users-groups.nix | 4 +- testFlake/flake.nix | 3 - testFlake/vm-tests.nix | 15 +- 19 files changed, 456 insertions(+), 82 deletions(-) create mode 100644 crates/system-manager-engine/src/activate/users.rs create mode 100644 crates/system-manager-engine/src/deactivate.rs create mode 100644 nix/modules/upstream/nixpkgs/userborn.nix diff --git a/Cargo.lock b/Cargo.lock index 46f2fcfe..7af8e155 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,6 +180,34 @@ dependencies = [ "log", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "heck" version = "0.5.0" @@ -261,6 +289,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "log" version = "0.4.29" @@ -285,6 +319,12 @@ dependencies = [ "libc", ] +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -330,6 +370,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand_core" version = "0.6.4" @@ -395,6 +441,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "serde" version = "1.0.228" @@ -492,9 +551,23 @@ dependencies = [ "regex", "serde", "serde_json", + "tempfile", "thiserror", ] +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -539,6 +612,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -636,6 +718,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "zmij" version = "1.0.19" diff --git a/Cargo.toml b/Cargo.toml index 5bc7331c..79783879 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ regex = "1.11.1" rpassword = "7.3.1" serde = { version = "1.0.152", features = ["derive"] } serde_json = "1.0.91" +tempfile = "3" thiserror = "2.0.0" # Internal crates diff --git a/crates/system-manager-engine/Cargo.toml b/crates/system-manager-engine/Cargo.toml index 97bd24ff..8d689c12 100644 --- a/crates/system-manager-engine/Cargo.toml +++ b/crates/system-manager-engine/Cargo.toml @@ -26,4 +26,5 @@ nix.workspace = true regex.workspace = true serde.workspace = true serde_json.workspace = true +tempfile.workspace = true thiserror.workspace = true diff --git a/crates/system-manager-engine/src/activate.rs b/crates/system-manager-engine/src/activate.rs index ab095453..fa59c32d 100644 --- a/crates/system-manager-engine/src/activate.rs +++ b/crates/system-manager-engine/src/activate.rs @@ -1,6 +1,7 @@ -mod etc_files; -mod services; +pub(crate) mod etc_files; +pub(crate) mod services; mod tmp_files; +pub(crate) mod users; use anyhow::Result; use serde::{Deserialize, Serialize}; @@ -35,8 +36,8 @@ pub type ActivationResult = Result>; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct State { - file_tree: FileTree, - services: services::Services, + pub(crate) file_tree: FileTree, + pub(crate) services: services::Services, } impl State { @@ -83,6 +84,12 @@ pub fn activate(store_path: &StorePath, ephemeral: bool) -> Result<()> { Ok(etc_tree) => { log::info!("Restarting sysinit-reactivation.target..."); services::restart_sysinit_reactivation_target()?; + + // Restart userborn before tmpfiles so users exist when tmpfiles runs + if let Err(e) = services::restart_userborn_if_exists() { + log::error!("Error restarting userborn.service: {e}"); + } + log::info!("Activating tmp files..."); let tmp_result = tmp_files::activate(&etc_tree); if let Err(e) = &tmp_result { @@ -171,42 +178,6 @@ pub fn prepopulate(store_path: &StorePath, ephemeral: bool) -> Result<()> { Ok(()) } -pub fn deactivate() -> Result<()> { - log::info!("Deactivating system-manager"); - let state_file = &get_state_file()?; - let old_state = State::from_file(state_file)?; - log::debug!("{old_state:?}"); - - match etc_files::deactivate(old_state.file_tree) { - Ok(etc_tree) => { - log::info!("Deactivating systemd services..."); - match services::deactivate(old_state.services) { - Ok(services) => State { - file_tree: etc_tree, - services, - }, - Err(ActivationError::WithPartialResult { result, source }) => { - log::error!("Error during deactivation: {source:?}"); - State { - file_tree: etc_tree, - services: result, - } - } - } - } - Err(ActivationError::WithPartialResult { result, source }) => { - log::error!("Error during deactivation: {source:?}"); - State { - file_tree: result, - ..old_state - } - } - } - .write_to_file(state_file)?; - - Ok(()) -} - fn run_preactivation_assertions(store_path: &StorePath) -> Result { let status = process::Command::new( store_path @@ -220,7 +191,7 @@ fn run_preactivation_assertions(store_path: &StorePath) -> Result Result { +pub(crate) fn get_state_file() -> Result { let state_file = Path::new(SYSTEM_MANAGER_STATE_DIR).join(STATE_FILE_NAME); DirBuilder::new() .recursive(true) diff --git a/crates/system-manager-engine/src/activate/etc_files.rs b/crates/system-manager-engine/src/activate/etc_files.rs index 8794b00b..ece05a81 100644 --- a/crates/system-manager-engine/src/activate/etc_files.rs +++ b/crates/system-manager-engine/src/activate/etc_files.rs @@ -69,13 +69,6 @@ impl std::fmt::Display for EtcFilesConfig { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[allow(dead_code)] -struct CreatedEtcFile { - path: PathBuf, -} - fn read_config(store_path: &StorePath) -> anyhow::Result { log::info!("Reading etc file definitions..."); let file = fs::File::open( diff --git a/crates/system-manager-engine/src/activate/services.rs b/crates/system-manager-engine/src/activate/services.rs index a548de33..d111364d 100644 --- a/crates/system-manager-engine/src/activate/services.rs +++ b/crates/system-manager-engine/src/activate/services.rs @@ -319,3 +319,34 @@ pub fn restart_sysinit_reactivation_target() -> anyhow::Result<()> { wait_for_jobs(&service_manager, &job_monitor, jobs, &timeout)?; Ok(()) } + +/// This must be called after daemon-reload so systemd knows about the unit, +/// but before tmpfiles activation since tmpfiles may reference users that +/// userborn needs to create. +pub fn restart_userborn_if_exists() -> anyhow::Result<()> { + let service_manager = systemd::ServiceManager::new_session()?; + + // Check if userborn.service exists by listing units matching the pattern + let units = service_manager.list_units_by_patterns(&[], &["userborn.service"])?; + + if units.is_empty() { + log::debug!("userborn.service not found, skipping"); + return Ok(()); + } + + log::info!("Restarting userborn.service to create users before tmpfiles..."); + let job_monitor = service_manager.monitor_jobs_init()?; + let timeout = Some(Duration::from_secs(30)); + + // We use restart rather than start because userborn is a oneshot service + // with RemainAfterExit=true. + let jobs = for_each_unit( + |unit| service_manager.restart_unit(unit), + ["userborn.service"], + "restarting", + ); + + wait_for_jobs(&service_manager, &job_monitor, jobs, &timeout)?; + log::info!("userborn.service completed"); + Ok(()) +} diff --git a/crates/system-manager-engine/src/activate/users.rs b/crates/system-manager-engine/src/activate/users.rs new file mode 100644 index 00000000..1d3188ab --- /dev/null +++ b/crates/system-manager-engine/src/activate/users.rs @@ -0,0 +1,57 @@ +use anyhow::{Context, Result}; +use std::io::Write; +use std::process::{Command, Stdio}; +use tempfile::NamedTempFile; + +const USERBORN_PREVIOUS_CONFIG: &str = "/var/lib/userborn/previous-userborn.json"; + +/// Locks user accounts that were previously managed by userborn. +pub fn lock_managed_users() -> Result<()> { + if Command::new("which") + .arg("userborn") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| !s.success()) + .unwrap_or(true) + { + log::debug!("userborn not found in PATH, skipping user account locking"); + return Ok(()); + } + + log::info!("Locking previously managed user accounts..."); + + // Create a temporary file with an empty userborn config + let empty_config = serde_json::json!({ + "users": [], + "groups": [] + }); + + let mut temp_file = NamedTempFile::new().context("Failed to create temporary config file")?; + serde_json::to_writer(&mut temp_file, &empty_config) + .context("Failed to write empty userborn config")?; + temp_file.flush()?; + + let temp_path = temp_file.path(); + + let output = Command::new("userborn") + .arg(temp_path) + .arg("/etc") + .env("USERBORN_MUTABLE_USERS", "true") + .env("USERBORN_PREVIOUS_CONFIG", USERBORN_PREVIOUS_CONFIG) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .output() + .context("Failed to execute userborn")?; + + if !output.status.success() { + anyhow::bail!( + "userborn exited with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + + log::info!("Successfully locked managed user accounts"); + Ok(()) +} diff --git a/crates/system-manager-engine/src/deactivate.rs b/crates/system-manager-engine/src/deactivate.rs new file mode 100644 index 00000000..2fb4d9b5 --- /dev/null +++ b/crates/system-manager-engine/src/deactivate.rs @@ -0,0 +1,48 @@ +use anyhow::Result; + +use crate::activate::etc_files; +use crate::activate::services; +use crate::activate::users; +use crate::activate::{get_state_file, ActivationError, State}; + +/// Deactivates system-manager by locking managed users, removing etc files, +/// and stopping systemd services. +pub fn deactivate() -> Result<()> { + log::info!("Deactivating system-manager"); + let state_file = &get_state_file()?; + let old_state = State::from_file(state_file)?; + log::debug!("{old_state:?}"); + + if let Err(e) = users::lock_managed_users() { + log::error!("Error locking managed user accounts: {e}"); + } + + match etc_files::deactivate(old_state.file_tree) { + Ok(etc_tree) => { + log::info!("Deactivating systemd services..."); + match services::deactivate(old_state.services) { + Ok(services) => State { + file_tree: etc_tree, + services, + }, + Err(ActivationError::WithPartialResult { result, source }) => { + log::error!("Error during deactivation: {source:?}"); + State { + file_tree: etc_tree, + services: result, + } + } + } + } + Err(ActivationError::WithPartialResult { result, source }) => { + log::error!("Error during deactivation: {source:?}"); + State { + file_tree: result, + ..old_state + } + } + } + .write_to_file(state_file)?; + + Ok(()) +} diff --git a/crates/system-manager-engine/src/lib.rs b/crates/system-manager-engine/src/lib.rs index 7a4c491a..043d6a96 100644 --- a/crates/system-manager-engine/src/lib.rs +++ b/crates/system-manager-engine/src/lib.rs @@ -1,4 +1,5 @@ pub mod activate; +pub mod deactivate; pub mod register; mod systemd; diff --git a/crates/system-manager-engine/src/main.rs b/crates/system-manager-engine/src/main.rs index e823f1d6..14e95d9f 100644 --- a/crates/system-manager-engine/src/main.rs +++ b/crates/system-manager-engine/src/main.rs @@ -107,7 +107,7 @@ fn go(args: Args) -> Result<()> { let path = std::path::Path::new(PROFILE_DIR).join("system-manager"); log::info!("No store path provided, using {}", path.display()); } - system_manager_engine::activate::deactivate() + system_manager_engine::deactivate::deactivate() } Action::Prepopulate { diff --git a/flake.lock b/flake.lock index b8b992e7..9490c7e4 100644 --- a/flake.lock +++ b/flake.lock @@ -1,12 +1,87 @@ { "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "userborn", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768135262, + "narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "userborn", + "pre-commit-hooks-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, "nixpkgs": { "locked": { - "lastModified": 1769789167, - "narHash": "sha256-kKB3bqYJU5nzYeIROI82Ef9VtTbu4uA3YydSk/Bioa8=", + "lastModified": 1770019141, + "narHash": "sha256-VKS4ZLNx4PNrABoB0L8KUpc1fE7CLpQXQs985tGfaCU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "cb369ef2efd432b3cdf8622b0ffc0a97a02f3137", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1769170682, + "narHash": "sha256-oMmN1lVQU0F0W2k6OI3bgdzp2YOHWYUAw79qzDSjenU=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "62c8382960464ceb98ea593cb8321a2cf8f9e3e5", + "rev": "c5296fdd05cfa2c187990dd909864da9658df755", "type": "github" }, "original": { @@ -16,9 +91,73 @@ "type": "github" } }, + "pre-commit-hooks-nix": { + "inputs": { + "flake-compat": [ + "userborn", + "flake-compat" + ], + "gitignore": "gitignore", + "nixpkgs": [ + "userborn", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1769069492, + "narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, "root": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "userborn": "userborn" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "userborn": { + "inputs": { + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs_2", + "pre-commit-hooks-nix": "pre-commit-hooks-nix", + "systems": "systems" + }, + "locked": { + "lastModified": 1769448131, + "narHash": "sha256-chrBODauOVqbulddCXlB/DvQKjusBYrJAIElVJL3sf4=", + "owner": "nikstur", + "repo": "userborn", + "rev": "f6f051cd80b52db0ef1fa2d4548416c6dbce0b4a", + "type": "github" + }, + "original": { + "owner": "nikstur", + "repo": "userborn", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index 84827131..d6ad1102 100644 --- a/flake.nix +++ b/flake.nix @@ -7,11 +7,13 @@ }; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + inputs.userborn.url = "github:nikstur/userborn"; outputs = { self, nixpkgs, + userborn, }: let systems = [ @@ -30,7 +32,7 @@ ); in { - lib = (import ./nix/lib.nix { inherit nixpkgs; }) // { + lib = (import ./nix/lib.nix { inherit nixpkgs userborn; }) // { # Container test library for external projects containerTest = import ./lib/container-test-driver { inherit (nixpkgs) lib; }; }; diff --git a/nix/lib.nix b/nix/lib.nix index 6ed6de32..93d2b7d1 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -2,6 +2,7 @@ nixpkgs ? , lib ? import "${nixpkgs}/lib", nixos ? "${nixpkgs}/nixos", + userborn, }: let self = { @@ -77,6 +78,7 @@ let # Pass the wrapped system-manager binary down # TODO: Use nixpkgs version by default. system-manager = pkgs.callPackage ../package.nix { }; + userborn = userborn.packages.${config.nixpkgs.hostPlatform}.default; }; }; diff --git a/nix/modules/default.nix b/nix/modules/default.nix index a579f089..14ae26bf 100644 --- a/nix/modules/default.nix +++ b/nix/modules/default.nix @@ -229,22 +229,10 @@ "$@" ''; - deactivationScript = - let - # Lock any users that were managed by system-manager - minimalUserbornConfig = pkgs.writeText "userborn-deactivate.json" ( - builtins.toJSON { - users = [ ]; - groups = [ ]; - } - ); - in - pkgs.writeShellScript "deactivate" '' - echo "Locking previously managed user accounts..." - USERBORN_STATEFUL=1 ${lib.getExe config.services.userborn.package} ${minimalUserbornConfig} /etc || true - - ${system-manager}/bin/system-manager-engine deactivate "$@" - ''; + deactivationScript = pkgs.writeShellScript "deactivate" '' + export PATH="$PATH:${lib.makeBinPath [ config.services.userborn.package ]}" + ${system-manager}/bin/system-manager-engine deactivate "$@" + ''; preActivationAssertionScript = let diff --git a/nix/modules/upstream/nixpkgs/default.nix b/nix/modules/upstream/nixpkgs/default.nix index f9197dcc..14038b47 100644 --- a/nix/modules/upstream/nixpkgs/default.nix +++ b/nix/modules/upstream/nixpkgs/default.nix @@ -7,6 +7,7 @@ imports = [ ./nginx.nix ./nix.nix + ./userborn.nix ./users-groups.nix ] ++ diff --git a/nix/modules/upstream/nixpkgs/userborn.nix b/nix/modules/upstream/nixpkgs/userborn.nix new file mode 100644 index 00000000..4dc66976 --- /dev/null +++ b/nix/modules/upstream/nixpkgs/userborn.nix @@ -0,0 +1,53 @@ +{ + config, + pkgs, + lib, + utils, + userborn, + ... +}: +let + userbornConfig = { + groups = lib.mapAttrsToList (username: opts: { + inherit (opts) name gid members; + }) config.users.groups; + + users = lib.mapAttrsToList (username: opts: { + inherit (opts) + name + uid + group + description + home + password + hashedPassword + hashedPasswordFile + initialPassword + initialHashedPassword + ; + isNormal = opts.isNormalUser; + shell = utils.toShellPath opts.shell; + }) (lib.filterAttrs (_: u: u.enable) config.users.users); + }; + + previousConfigPath = "/var/lib/userborn/previous-userborn.json"; + userbornConfigJson = pkgs.writeText "userborn.json" (builtins.toJSON userbornConfig); +in +{ + services.userborn.enable = true; + services.userborn.package = userborn; + + # REMOVE when https://github.com/NixOS/nixpkgs/pull/483684 is merged + systemd.services.userborn = { + environment = { + USERBORN_MUTABLE_USERS = "true"; + USERBORN_PREVIOUS_CONFIG = previousConfigPath; + }; + serviceConfig = { + StateDirectory = "userborn"; + ExecStartPost = [ + "${pkgs.coreutils}/bin/ln -sf ${userbornConfigJson} ${previousConfigPath}" + ]; + }; + }; +} diff --git a/nix/modules/upstream/nixpkgs/users-groups.nix b/nix/modules/upstream/nixpkgs/users-groups.nix index a9b50c0d..b563e260 100644 --- a/nix/modules/upstream/nixpkgs/users-groups.nix +++ b/nix/modules/upstream/nixpkgs/users-groups.nix @@ -60,9 +60,9 @@ let "*" # password unset ]); - overrideOrderMutable = ''{option}`initialHashedPassword` -> {option}`initialPassword` -> {option}`hashedPassword` -> {option}`password` -> {option}`hashedPasswordFile`''; + overrideOrderMutable = "{option}`initialHashedPassword` -> {option}`initialPassword` -> {option}`hashedPassword` -> {option}`password` -> {option}`hashedPasswordFile`"; - overrideOrderImmutable = ''{option}`initialHashedPassword` -> {option}`hashedPassword` -> {option}`initialPassword` -> {option}`password` -> {option}`hashedPasswordFile`''; + overrideOrderImmutable = "{option}`initialHashedPassword` -> {option}`hashedPassword` -> {option}`initialPassword` -> {option}`password` -> {option}`hashedPasswordFile`"; overrideOrderText = isMutable: '' If the option {option}`users.mutableUsers` is diff --git a/testFlake/flake.nix b/testFlake/flake.nix index 2e291921..4b2a67a5 100644 --- a/testFlake/flake.nix +++ b/testFlake/flake.nix @@ -9,7 +9,6 @@ inputs = { system-manager.url = "path:.."; nixpkgs.follows = "system-manager/nixpkgs"; - userborn.url = "github:JulienMalka/userborn/stateful-users"; nix-vm-test = { url = "github:numtide/nix-vm-test"; inputs.nixpkgs.follows = "nixpkgs"; @@ -20,7 +19,6 @@ { self, system-manager, - userborn, nixpkgs, nix-vm-test, }: @@ -43,7 +41,6 @@ inherit (nixpkgs) lib; nix-vm-test = vmTestLib; inherit system-manager; - userborn = userborn.packages.${system}.default; }; containerChecks = system: diff --git a/testFlake/vm-tests.nix b/testFlake/vm-tests.nix index f98fe630..40def355 100644 --- a/testFlake/vm-tests.nix +++ b/testFlake/vm-tests.nix @@ -3,7 +3,6 @@ system-manager, system, nix-vm-test, - userborn, }: let @@ -105,9 +104,6 @@ let nixpkgs.hostPlatform = system; services.nginx.enable = false; - services.userborn.enable = true; - services.userborn.package = userborn; - systemd.services.userborn.environment.USERBORN_STATEFUL = "1"; environment = { etc = { @@ -208,7 +204,7 @@ forEachUbuntuImage "example" { print(vm.succeed("cat /etc/passwd")) passwd_out = vm.succeed("passwd -S luj | awk '{print $2}'") - assert "P" in passwd_out + assert "P" in passwd_out, f"Expected luj to be unlocked with 'P' status, got: {passwd_out}" user = vm.succeed("stat -c %U /etc/with_ownership2").strip() group = vm.succeed("stat -c %G /etc/with_ownership2").strip() @@ -232,6 +228,8 @@ forEachUbuntuImage "example" { node = "vm"; profile = newConfig; }} + print(vm.succeed("cat /tmp/output.log")) + vm.succeed("systemctl status new-service.service") vm.fail("systemctl status service-9.service") vm.fail("test -f /etc/a/nested/example/foo3") @@ -260,10 +258,13 @@ forEachUbuntuImage "example" { vm.succeed("id -u zimbatm") + print(vm.succeed("systemctl status userborn.service")) + print(vm.succeed("journalctl -u userborn.service")) + print(vm.succeed("cat /var/lib/userborn/previous-userborn.json")) + print(vm.succeed("cat /etc/passwd")) passwd_out = vm.succeed("passwd -S luj | awk '{print $2}'") - assert "P" in passwd_out - + assert "P" in passwd_out, f"Expected luj to be unlocked with 'P' status, got: {passwd_out}" nix_trusted_users = vm.succeed("${hostPkgs.nix}/bin/nix config show trusted-users").strip() assert "zimbatm" in nix_trusted_users, f"Expected 'zimbatm' to be in trusted-users, got {nix_trusted_users}" From 0e6f9f9f9284ccce838386a64275d95534109899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Mon, 19 Jan 2026 15:42:19 +0100 Subject: [PATCH 15/16] chore: pin userborn to 0.5.0 --- flake.lock | 33 +++++++--------------- flake.nix | 5 +++- testFlake/flake.lock | 67 +++++++++++++++++++------------------------- 3 files changed, 43 insertions(+), 62 deletions(-) diff --git a/flake.lock b/flake.lock index 9490c7e4..be2daba0 100644 --- a/flake.lock +++ b/flake.lock @@ -61,27 +61,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1770019141, - "narHash": "sha256-VKS4ZLNx4PNrABoB0L8KUpc1fE7CLpQXQs985tGfaCU=", + "lastModified": 1770115704, + "narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "cb369ef2efd432b3cdf8622b0ffc0a97a02f3137", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_2": { - "locked": { - "lastModified": 1769170682, - "narHash": "sha256-oMmN1lVQU0F0W2k6OI3bgdzp2YOHWYUAw79qzDSjenU=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "c5296fdd05cfa2c187990dd909864da9658df755", + "rev": "e6eae2ee2110f3d31110d5c222cd395303343b08", "type": "github" }, "original": { @@ -142,20 +126,23 @@ "inputs": { "flake-compat": "flake-compat", "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs_2", + "nixpkgs": [ + "nixpkgs" + ], "pre-commit-hooks-nix": "pre-commit-hooks-nix", "systems": "systems" }, "locked": { - "lastModified": 1769448131, - "narHash": "sha256-chrBODauOVqbulddCXlB/DvQKjusBYrJAIElVJL3sf4=", + "lastModified": 1769903681, + "narHash": "sha256-mXXakR75Iz6AFf/TYgIHE8SxOri2HyReYUYTT3lCEPA=", "owner": "nikstur", "repo": "userborn", - "rev": "f6f051cd80b52db0ef1fa2d4548416c6dbce0b4a", + "rev": "88666e2d8931c7252411498c5b82feb9a8a4d8d4", "type": "github" }, "original": { "owner": "nikstur", + "ref": "0.5.0", "repo": "userborn", "type": "github" } diff --git a/flake.nix b/flake.nix index d6ad1102..f0e9051f 100644 --- a/flake.nix +++ b/flake.nix @@ -7,7 +7,10 @@ }; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - inputs.userborn.url = "github:nikstur/userborn"; + inputs.userborn = { + url = "github:nikstur/userborn/0.5.0"; + inputs.nixpkgs.follows = "nixpkgs"; + }; outputs = { diff --git a/testFlake/flake.lock b/testFlake/flake.lock index 656ad248..439d58f5 100644 --- a/testFlake/flake.lock +++ b/testFlake/flake.lock @@ -3,11 +3,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1747046372, - "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", "owner": "edolstra", "repo": "flake-compat", - "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", "type": "github" }, "original": { @@ -19,16 +19,17 @@ "flake-parts": { "inputs": { "nixpkgs-lib": [ + "system-manager", "userborn", "nixpkgs" ] }, "locked": { - "lastModified": 1756770412, - "narHash": "sha256-+uWLQZccFHwqpGqr2Yt5VsW/PbeJVTn9Dk6SHWhNRPw=", + "lastModified": 1768135262, + "narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "4524271976b625a4a605beefd893f270620fd751", + "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac", "type": "github" }, "original": { @@ -40,6 +41,7 @@ "gitignore": { "inputs": { "nixpkgs": [ + "system-manager", "userborn", "pre-commit-hooks-nix", "nixpkgs" @@ -81,27 +83,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1769789167, - "narHash": "sha256-kKB3bqYJU5nzYeIROI82Ef9VtTbu4uA3YydSk/Bioa8=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "62c8382960464ceb98ea593cb8321a2cf8f9e3e5", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_2": { - "locked": { - "lastModified": 1757745802, - "narHash": "sha256-hLEO2TPj55KcUFUU1vgtHE9UEIOjRcH/4QbmfHNF820=", + "lastModified": 1770115704, + "narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c23193b943c6c689d70ee98ce3128239ed9e32d1", + "rev": "e6eae2ee2110f3d31110d5c222cd395303343b08", "type": "github" }, "original": { @@ -114,21 +100,23 @@ "pre-commit-hooks-nix": { "inputs": { "flake-compat": [ + "system-manager", "userborn", "flake-compat" ], "gitignore": "gitignore", "nixpkgs": [ + "system-manager", "userborn", "nixpkgs" ] }, "locked": { - "lastModified": 1757588530, - "narHash": "sha256-tJ7A8mID3ct69n9WCvZ3PzIIl3rXTdptn/lZmqSS95U=", + "lastModified": 1769069492, + "narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=", "owner": "cachix", "repo": "pre-commit-hooks.nix", - "rev": "b084b2c2b6bc23e83bbfe583b03664eb0b18c411", + "rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23", "type": "github" }, "original": { @@ -144,13 +132,13 @@ "system-manager", "nixpkgs" ], - "system-manager": "system-manager", - "userborn": "userborn" + "system-manager": "system-manager" } }, "system-manager": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "userborn": "userborn" }, "locked": { "path": "..", @@ -181,21 +169,24 @@ "inputs": { "flake-compat": "flake-compat", "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs_2", + "nixpkgs": [ + "system-manager", + "nixpkgs" + ], "pre-commit-hooks-nix": "pre-commit-hooks-nix", "systems": "systems" }, "locked": { - "lastModified": 1762107051, - "narHash": "sha256-8bvUPwdiUnqgBnNAuPJlbNFGProAIzlDzjiaqQugPJY=", - "owner": "JulienMalka", + "lastModified": 1769903681, + "narHash": "sha256-mXXakR75Iz6AFf/TYgIHE8SxOri2HyReYUYTT3lCEPA=", + "owner": "nikstur", "repo": "userborn", - "rev": "6e8f0d00e683049ac727b626552d5eba7f3471ff", + "rev": "88666e2d8931c7252411498c5b82feb9a8a4d8d4", "type": "github" }, "original": { - "owner": "JulienMalka", - "ref": "stateful-users", + "owner": "nikstur", + "ref": "0.5.0", "repo": "userborn", "type": "github" } From f4ad00a0210666c8194271ee20a9b349fdce2835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roche?= Date: Tue, 3 Feb 2026 17:24:05 +0100 Subject: [PATCH 16/16] fix: replace deprecated mkAliasOptionModuleMD with mkAliasOptionModule The MD variant is deprecated and will be removed in NixOS 26.05. --- nix/modules/upstream/nixpkgs/users-groups.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nix/modules/upstream/nixpkgs/users-groups.nix b/nix/modules/upstream/nixpkgs/users-groups.nix index b563e260..d96dfb86 100644 --- a/nix/modules/upstream/nixpkgs/users-groups.nix +++ b/nix/modules/upstream/nixpkgs/users-groups.nix @@ -30,7 +30,7 @@ let mapAttrs' mapAttrsToList match - mkAliasOptionModuleMD + mkAliasOptionModule mkDefault mkIf mkMerge @@ -666,8 +666,8 @@ let in { imports = [ - (mkAliasOptionModuleMD [ "users" "extraUsers" ] [ "users" "users" ]) - (mkAliasOptionModuleMD [ "users" "extraGroups" ] [ "users" "groups" ]) + (mkAliasOptionModule [ "users" "extraUsers" ] [ "users" "users" ]) + (mkAliasOptionModule [ "users" "extraGroups" ] [ "users" "groups" ]) (mkRenamedOptionModule [ "security" "initialRootPassword" ] [ "users" "users" "root" "initialHashedPassword" ]