Files
nixpkgs/nixos/modules/services/system/userborn.nix
r-vdp 3d3e3dbfc4 nixos/userborn: import legacy /var/lib/nixos state on first run
ids that were allocated and later freed only live in
/var/lib/nixos/{uid,gid}-map. userborn allocates from /etc/passwd
alone, so without this a new user could get a previously-used uid and
inherit file ownership.

Service has a ConditionPathExists on the uid-map and a sentinel under
/var/lib/userborn, so it runs at most once and is a no-op on fresh
installs.
2026-08-13 12:16:35 +02:00

285 lines
9.1 KiB
Nix

{
utils,
config,
lib,
pkgs,
...
}:
let
cfg = config.services.userborn;
userCfg = config.users;
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);
};
userbornConfigJson = pkgs.writeText "userborn.json" (builtins.toJSON userbornConfig);
userbornStaticFiles =
pkgs.runCommand "static-userborn"
{
nativeBuildInputs = [ cfg.package ];
}
''
mkdir -p $out
userborn ${userbornConfigJson} $out
'';
previousConfigPath = "/var/lib/userborn/previous-userborn.json";
immutableEtc = config.system.etc.overlay.enable && !config.system.etc.overlay.mutable;
# The filenames created by userborn.
passwordFiles = [
"group"
"passwd"
"shadow"
];
in
{
options.services.userborn = {
enable = lib.mkEnableOption "userborn";
static = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to generate the password files at build time and store them directly
in the system closure, without requiring any services at boot time.
This is STRICTLY intended for embedded appliance images that only have system
users with manually managed static user IDs, and CANNOT be used with generation
updates.
WARNING: In this mode, you MUST statically manage user IDs yourself, carefully.
Beware, UID reuse is a serious security issue and it's your responsibility
to avoid it over the entire lifetime of the system.
'';
};
package = lib.mkPackageOption pkgs "userborn" { };
passwordFilesLocation = lib.mkOption {
type = lib.types.str;
default = if immutableEtc && !cfg.static then "/var/lib/nixos" else "/etc";
defaultText = lib.literalExpression ''if immutableEtc && !config.services.userborn.static then "/var/lib/nixos" else "/etc"'';
description = ''
The location of the original password files.
If this is not `/etc`, the files are symlinked from this location to `/etc`.
The primary motivation for this is an immutable `/etc`, where we cannot
write the files directly to `/etc`.
However this can also serve other use cases, e.g. when `/etc` is on a `tmpfs`.
'';
};
importLegacyState = lib.mkOption {
type = lib.types.bool;
default = !cfg.static;
defaultText = lib.literalExpression "!config.services.userborn.static";
description = ''
Whether to include one-shot migration services that import the state
left behind by the perl activation script (`update-users-groups.pl`),
so id and subid allocations survive the switch to userborn.
Disable this if you want to keep the migration tooling out of your
system closure.
'';
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = !(config.systemd.sysusers.enable && cfg.enable);
message = "You cannot use systemd-sysusers and Userborn at the same time";
}
{
assertion = config.system.activationScripts.users == "";
message = "system.activationScripts.users has to be empty to use userborn";
}
{
assertion = (immutableEtc && !cfg.static) -> (cfg.passwordFilesLocation != "/etc");
message = "When `system.etc.overlay.mutable = false` and `services.userborn.static = false`, `services.userborn.passwordFilesLocation` cannot be set to `/etc`";
}
{
assertion = !(cfg.static && config.system.switch.enable);
message = "You cannot use `services.userborn.static = true` with switchable configurations, it is ONLY indended for appliance images with fully static user IDs";
}
];
systemd = {
# Create home directories, do not create /var/empty even if that's a user's
# home.
tmpfiles.settings.home-directories =
lib.mapAttrs'
(
username: opts:
lib.nameValuePair (toString opts.home) {
d = {
mode = opts.homeMode;
user = opts.name;
inherit (opts) group;
};
}
)
(
lib.filterAttrs (
_username: opts: opts.enable && opts.createHome && opts.home != "/var/empty"
) userCfg.users
);
# One-shot import of update-users-groups.pl state. Runs before
# userborn so removed users' ids are reserved before allocation.
# Remove once the perl path has been gone for two releases.
services.userborn-import-legacy = lib.mkIf cfg.importLegacyState {
wantedBy = [ "sysinit.target" ];
requiredBy = [ "userborn.service" ];
before = [
"userborn.service"
"shutdown.target"
];
after = [ "systemd-remount-fs.service" ];
conflicts = [ "shutdown.target" ];
unitConfig = {
Description = "Import legacy update-users-groups.pl state for userborn";
DefaultDependencies = false;
ConditionPathExists = [
"/var/lib/nixos/uid-map"
"!/var/lib/userborn"
];
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${lib.getExe pkgs.userborn-import-legacy} --nogroup-gid ${toString config.ids.gids.nogroup} ${cfg.passwordFilesLocation}";
};
};
services.userborn = lib.mkIf (!cfg.static) {
wantedBy = [ "sysinit.target" ];
requiredBy = [ "sysinit-reactivation.target" ];
after = [
"systemd-remount-fs.service"
"systemd-tmpfiles-setup-dev-early.service"
];
before = [
"systemd-tmpfiles-setup-dev.service"
"sysinit.target"
"shutdown.target"
"sysinit-reactivation.target"
];
conflicts = [ "shutdown.target" ];
restartTriggers = [
userbornConfigJson
cfg.passwordFilesLocation
];
# This way we don't have to re-declare all the dependencies to other
# services again.
aliases = [ "systemd-sysusers.service" ];
environment = {
USERBORN_MUTABLE_USERS = lib.boolToString userCfg.mutableUsers;
USERBORN_PREVIOUS_CONFIG = lib.mkIf userCfg.mutableUsers previousConfigPath;
};
unitConfig = {
Description = "Manage Users and Groups";
DefaultDependencies = false;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
TimeoutSec = "90s";
StateDirectory = "userborn";
ExecStart = "${lib.getExe cfg.package} ${userbornConfigJson} ${cfg.passwordFilesLocation}";
ExecStartPre = lib.mkMerge [
(lib.mkIf (cfg.passwordFilesLocation != "/etc") [
"${pkgs.coreutils}/bin/mkdir -p ${cfg.passwordFilesLocation}"
])
# Make the source files writable before executing userborn.
(lib.mkIf (!userCfg.mutableUsers) (
lib.map (file: "-${pkgs.util-linux}/bin/umount ${cfg.passwordFilesLocation}/${file}") passwordFiles
))
];
ExecStartPost =
if userCfg.mutableUsers then
# Store the config somewhere for the next invocation
[
"${pkgs.coreutils}/bin/ln -sf ${userbornConfigJson} ${previousConfigPath}"
]
else
# Make the source files read-only after userborn has finished.
(lib.map (
file:
"${pkgs.util-linux}/bin/mount --bind -o ro ${cfg.passwordFilesLocation}/${file} ${cfg.passwordFilesLocation}/${file}"
) passwordFiles);
};
};
};
environment.etc = lib.mkMerge [
(lib.mkIf cfg.static (
# In static mode, statically drop the files into an immutable /etc.
lib.listToAttrs (
lib.map (
file:
lib.nameValuePair file {
source = "${userbornStaticFiles}/${file}";
mode = if file == "shadow" then "0000" else "0644";
}
) passwordFiles
)
))
(lib.mkIf (!cfg.static && cfg.passwordFilesLocation != "/etc") (
# Statically create the symlinks to passwordFilesLocation when they're not
# inside /etc because we will not be able to do it at runtime in case of a
# (non-static) immutable /etc!
lib.listToAttrs (
lib.map (
file:
lib.nameValuePair file {
source = "${cfg.passwordFilesLocation}/${file}";
mode = "direct-symlink";
}
) passwordFiles
)
))
];
};
meta.maintainers = with lib.maintainers; [ nikstur ];
}