nixos/userborn: introduce config.services.userborn.static mode

For embedded appliance-style applications, we would like to avoid the
~100ms boot time hit that comes from running userborn or sysusers by
pre-baking the final password files directly into the system closure.

Turns out, userborn is trivial to run at build time instead of boot time,
so let's introduce a new "baked" static mode that directly places the files
into an immutable /etc.
This commit is contained in:
Val Packett
2026-01-13 01:56:09 -03:00
parent 3b3875c700
commit 133f4cd7da
3 changed files with 116 additions and 19 deletions
+58 -19
View File
@@ -35,6 +35,9 @@ let
};
userbornConfigJson = pkgs.writeText "userborn.json" (builtins.toJSON userbornConfig);
userbornStaticFiles =
pkgs.runCommand "static-userborn" { }
"mkdir -p $out; ${lib.getExe cfg.package} ${userbornConfigJson} $out";
immutableEtc = config.system.etc.overlay.enable && !config.system.etc.overlay.mutable;
# The filenames created by userborn.
@@ -51,12 +54,29 @@ in
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 then "/var/lib/nixos" else "/etc";
defaultText = lib.literalExpression ''if immutableEtc then "/var/lib/nixos" else "/etc"'';
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.
@@ -83,8 +103,12 @@ in
message = "system.activationScripts.users has to be empty to use userborn";
}
{
assertion = immutableEtc -> (cfg.passwordFilesLocation != "/etc");
message = "When `system.etc.overlay.mutable = false`, `services.userborn.passwordFilesLocation` cannot be set to `/etc`";
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";
}
];
@@ -110,7 +134,7 @@ in
) userCfg.users
);
services.userborn = {
services.userborn = lib.mkIf (!cfg.static) {
wantedBy = [ "sysinit.target" ];
requiredBy = [ "sysinit-reactivation.target" ];
after = [
@@ -166,20 +190,35 @@ in
};
};
# 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 an
# immutable /etc!
environment.etc = lib.mkIf (cfg.passwordFilesLocation != "/etc") (
lib.listToAttrs (
lib.map (
file:
lib.nameValuePair file {
source = "${cfg.passwordFilesLocation}/${file}";
mode = "direct-symlink";
}
) 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 ];
+1
View File
@@ -1647,6 +1647,7 @@ in
userborn-immutable-users = runTest ./userborn-immutable-users.nix;
userborn-mutable-etc = runTest ./userborn-mutable-etc.nix;
userborn-mutable-users = runTest ./userborn-mutable-users.nix;
userborn-static = runTest ./userborn-static.nix;
ustreamer = runTest ./ustreamer.nix;
uwsgi = runTest ./uwsgi.nix;
v2ray = runTest ./v2ray.nix;
+57
View File
@@ -0,0 +1,57 @@
{ lib, ... }:
let
sysuserPassword = "$y$j9T$3aiOV/8CADAK22OK2QT3/0$67OKd50Z4qTaZ8c/eRWHLIM.o3ujtC1.n9ysmJfv639";
common = {
services.userborn = {
enable = true;
static = true;
};
boot.initrd.systemd.enable = true;
networking.useNetworkd = true;
system.etc.overlay = {
enable = true;
mutable = false;
};
};
in
{
name = "userborn-static";
meta.maintainers = with lib.maintainers; [
mic92
valpackett
];
nodes.machine =
{ ... }:
{
imports = [ common ];
users.users.sysuser = {
uid = 1337;
isSystemUser = true;
group = "wheel";
home = "/var/empty";
initialHashedPassword = sysuserPassword;
};
};
testScript = ''
with subtest("Correct mode on the password files"):
assert machine.succeed("stat -c '%a' /etc/passwd") == "644\n"
assert machine.succeed("stat -c '%a' /etc/group") == "644\n"
assert machine.succeed("stat -c '%a' /etc/shadow") == "0\n"
with subtest("Check files"):
print(machine.succeed("grpck -r"))
print(machine.succeed("pwck -r"))
with subtest("sysuser user is created"):
print(machine.succeed("getent passwd sysuser"))
assert "${sysuserPassword}" in machine.succeed("getent shadow sysuser"), "sysuser user password is not correct"
'';
}