userborn: init at 0.1.0 (#332719)

This commit is contained in:
WilliButz 2024-08-30 12:22:54 +02:00 committed by GitHub
commit c169763c30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 703 additions and 3 deletions

View File

@ -100,6 +100,9 @@ modified using `usermod`. Unix groups can be managed using `groupadd`,
::: {.note}
This is experimental.
Please consider using [Userborn](#sec-userborn) over systemd-sysusers as it's
more feature complete.
:::
Instead of using a custom perl script to create users and groups, you can use
@ -112,3 +115,43 @@ systemd-sysusers:
```
The primary benefit of this is to remove a dependency on perl.
## Manage users and groups with `userborn` {#sec-userborn}
::: {.note}
This is experimental.
:::
Like systemd-sysusers, Userborn adoesn't depend on Perl but offers some more
advantages over systemd-sysusers:
1. It can create "normal" users (with a GID >= 1000).
2. It can update some information about users. Most notably it can update their
passwords.
3. It will warn when users use an insecure or unsupported password hashing
scheme.
Userborn is the recommended way to manage users if you don't want to rely on
the Perl script. It aims to eventually replace the Perl script by default.
You can enable Userborn via:
```nix
services.userborn.enable = true;
```
You can configure Userborn to store the password files
(`/etc/{group,passwd,shadow}`) outside of `/etc` and symlink them from this
location to `/etc`:
```nix
services.userborn.passwordFilesLocation = "/persistent/etc";
```
This is useful when you store `/etc` on a `tmpfs` or if `/etc` is immutable
(e.g. when using `system.etc.overlay.mutable = false;`). In the latter case the
original files are by default stored in `/var/lib/nixos`.
Userborn implements immutable users by re-mounting the password files
read-only. This means that unlike when using the Perl script, trying to add a
new user (e.g. via `useradd`) will fail right away.

View File

@ -41,6 +41,13 @@
- [Quickwit](https://quickwit.io), sub-second search & analytics engine on cloud storage. Available as [services.quickwit](options.html#opt-services.quickwit).
- [Userborn](https://github.com/nikstur/userborn), a service for declarative
user management. This can be used instead of the `update-users-groups.pl`
Perl script and instead of systemd-sysusers. To achieve a system without
Perl, this is the now recommended tool over systemd-sysusers because it can
alos create normal users and change passwords. Available as
[services.userborn](#opt-services.userborn.enable)
- [Flood](https://flood.js.org/), a beautiful WebUI for various torrent clients. Available as [services.flood](options.html#opt-services.flood).
- [Firefly-iii Data Importer](https://github.com/firefly-iii/data-importer), a data importer for Firefly-III. Available as [services.firefly-iii-data-importer](options.html#opt-services.firefly-iii-data-importer)

View File

@ -1348,6 +1348,7 @@
./services/system/systembus-notify.nix
./services/system/systemd-lock-handler.nix
./services/system/uptimed.nix
./services/system/userborn.nix
./services/system/zram-generator.nix
./services/torrent/deluge.nix
./services/torrent/flexget.nix

View File

@ -12,7 +12,7 @@
# Remove perl from activation
boot.initrd.systemd.enable = lib.mkDefault true;
system.etc.overlay.enable = lib.mkDefault true;
systemd.sysusers.enable = lib.mkDefault true;
services.userborn.enable = lib.mkDefault true;
# Random perl remnants
system.disableInstallerTools = lib.mkDefault true;

View File

@ -0,0 +1,183 @@
{
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;
}) config.users.users;
};
userbornConfigJson = pkgs.writeText "userborn.json" (builtins.toJSON userbornConfig);
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";
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"'';
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 an also serve other use cases, e.g. when `/etc` is on a `tmpfs`.
'';
};
};
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.passwordFilesLocation != "/etc");
message = "When `system.etc.overlay.mutable = false`, `services.userborn.passwordFilesLocation` cannot be set to `/etc`";
}
];
system.activationScripts.users = lib.mkForce "";
system.activationScripts.hashes = lib.mkForce "";
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 opts.home {
d = {
mode = opts.homeMode;
user = username;
inherit (opts) group;
};
}
) (lib.filterAttrs (_username: opts: opts.home != "/var/empty") userCfg.users);
services.userborn = {
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" ];
unitConfig = {
Description = "Manage Users and Groups";
DefaultDependencies = false;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
TimeoutSec = "90s";
ExecStart = "${lib.getExe cfg.package} ${userbornConfigJson} ${cfg.passwordFilesLocation}";
ExecStartPre = lib.mkMerge [
(lib.mkIf (!config.system.etc.overlay.mutable) [
"${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
))
];
# Make the source files read-only after userborn has finished.
ExecStartPost = lib.mkIf (!userCfg.mutableUsers) (
lib.map (
file:
"${pkgs.util-linux}/bin/mount --bind -o ro ${cfg.passwordFilesLocation}/${file} ${cfg.passwordFilesLocation}/${file}"
) passwordFiles
);
};
};
};
# 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
)
);
};
meta.maintainers = with lib.maintainers; [ nikstur ];
}

View File

@ -19,8 +19,8 @@
message = "`system.etc.overlay.enable` requires `boot.initrd.systemd.enable`";
}
{
assertion = (!config.system.etc.overlay.mutable) -> config.systemd.sysusers.enable;
message = "`system.etc.overlay.mutable = false` requires `systemd.sysusers.enable`";
assertion = (!config.system.etc.overlay.mutable) -> (config.systemd.sysusers.enable || config.services.userborn.enable);
message = "`!system.etc.overlay.mutable` requires `systemd.sysusers.enable` or `services.userborn.enable`";
}
{
assertion = lib.versionAtLeast config.boot.kernelPackages.kernel.version "6.6";

View File

@ -1060,6 +1060,11 @@ in {
uptime-kuma = handleTest ./uptime-kuma.nix {};
urn-timer = handleTest ./urn-timer.nix {};
usbguard = handleTest ./usbguard.nix {};
userborn = runTest ./userborn.nix;
userborn-mutable-users = runTest ./userborn-mutable-users.nix;
userborn-immutable-users = runTest ./userborn-immutable-users.nix;
userborn-mutable-etc = runTest ./userborn-mutable-etc.nix;
userborn-immutable-etc = runTest ./userborn-immutable-etc.nix;
user-activation-scripts = handleTest ./user-activation-scripts.nix {};
user-expiry = runTest ./user-expiry.nix;
user-home-mode = handleTest ./user-home-mode.nix {};

View File

@ -0,0 +1,70 @@
{ lib, ... }:
let
normaloHashedPassword = "$y$j9T$IEWqhKtWg.r.8fVkSEF56.$iKNxdMC6hOAQRp6eBtYvBk4c7BGpONXeZMqc8I/LM46";
common = {
services.userborn.enable = true;
boot.initrd.systemd.enable = true;
system.etc.overlay = {
enable = true;
mutable = false;
};
};
in
{
name = "userborn-immutable-etc";
meta.maintainers = with lib.maintainers; [ nikstur ];
nodes.machine =
{ config, ... }:
{
imports = [ common ];
users = {
users = {
normalo = {
isNormalUser = true;
hashedPassword = normaloHashedPassword;
};
};
};
specialisation.new-generation = {
inheritParentConfig = false;
configuration = {
nixpkgs = {
inherit (config.nixpkgs) hostPlatform;
};
imports = [ common ];
users.users = {
new-normalo = {
isNormalUser = true;
};
};
};
};
};
testScript = ''
machine.wait_for_unit("userborn.service")
with subtest("normalo user is created"):
assert "${normaloHashedPassword}" in machine.succeed("getent shadow normalo"), "normalo user password is not correct"
machine.succeed("/run/current-system/specialisation/new-generation/bin/switch-to-configuration switch")
with subtest("normalo user is disabled"):
print(machine.succeed("getent shadow normalo"))
assert "!*" in machine.succeed("getent shadow normalo"), "normalo user is not disabled"
with subtest("new-normalo user is created after switching to new generation"):
print(machine.succeed("getent passwd new-normalo"))
'';
}

View File

@ -0,0 +1,75 @@
{ lib, ... }:
let
normaloHashedPassword = "$y$j9T$IEWqhKtWg.r.8fVkSEF56.$iKNxdMC6hOAQRp6eBtYvBk4c7BGpONXeZMqc8I/LM46";
common = {
services.userborn.enable = true;
users.mutableUsers = false;
};
in
{
name = "userborn-immutable-users";
meta.maintainers = with lib.maintainers; [ nikstur ];
nodes.machine =
{ config, ... }:
{
imports = [ common ];
users = {
users = {
normalo = {
isNormalUser = true;
hashedPassword = normaloHashedPassword;
};
};
};
specialisation.new-generation = {
inheritParentConfig = false;
configuration = {
nixpkgs = {
inherit (config.nixpkgs) hostPlatform;
};
imports = [ common ];
users.users = {
new-normalo = {
isNormalUser = true;
};
};
};
};
};
testScript = ''
machine.wait_for_unit("userborn.service")
with subtest("normalo user is created"):
assert "${normaloHashedPassword}" in machine.succeed("getent shadow normalo"), "normalo user password is not correct"
with subtest("Fail to add new user manually"):
machine.fail("useradd manual-normalo")
with subtest("Fail to add delete user manually"):
machine.fail("userdel normalo")
machine.succeed("/run/current-system/specialisation/new-generation/bin/switch-to-configuration switch")
with subtest("normalo user is disabled"):
print(machine.succeed("getent shadow normalo"))
assert "!*" in machine.succeed("getent shadow normalo"), "normalo user is not disabled"
with subtest("new-normalo user is created after switching to new generation"):
print(machine.succeed("getent passwd new-normalo"))
with subtest("Still fail to add new user manually"):
machine.fail("useradd again-normalo")
'';
}

View File

@ -0,0 +1,70 @@
{ lib, ... }:
let
normaloHashedPassword = "$y$j9T$IEWqhKtWg.r.8fVkSEF56.$iKNxdMC6hOAQRp6eBtYvBk4c7BGpONXeZMqc8I/LM46";
common = {
services.userborn.enable = true;
boot.initrd.systemd.enable = true;
system.etc.overlay = {
enable = true;
mutable = true;
};
};
in
{
name = "userborn-mutable-etc";
meta.maintainers = with lib.maintainers; [ nikstur ];
nodes.machine =
{ config, ... }:
{
imports = [ common ];
users = {
users = {
normalo = {
isNormalUser = true;
hashedPassword = normaloHashedPassword;
};
};
};
specialisation.new-generation = {
inheritParentConfig = false;
configuration = {
nixpkgs = {
inherit (config.nixpkgs) hostPlatform;
};
imports = [ common ];
users.users = {
new-normalo = {
isNormalUser = true;
};
};
};
};
};
testScript = ''
machine.wait_for_unit("userborn.service")
with subtest("normalo user is created"):
assert "${normaloHashedPassword}" in machine.succeed("getent shadow normalo"), "normalo user password is not correct"
machine.succeed("/run/current-system/specialisation/new-generation/bin/switch-to-configuration switch")
with subtest("normalo user is disabled"):
print(machine.succeed("getent shadow normalo"))
assert "!*" in machine.succeed("getent shadow normalo"), "normalo user is not disabled"
with subtest("new-normalo user is created after switching to new generation"):
print(machine.succeed("getent passwd new-normalo"))
'';
}

View File

@ -0,0 +1,76 @@
{ lib, ... }:
let
normaloHashedPassword = "$y$j9T$IEWqhKtWg.r.8fVkSEF56.$iKNxdMC6hOAQRp6eBtYvBk4c7BGpONXeZMqc8I/LM46";
common = {
services.userborn.enable = true;
users.mutableUsers = true;
};
in
{
name = "userborn-mutable-users";
meta.maintainers = with lib.maintainers; [ nikstur ];
nodes.machine =
{ config, ... }:
{
imports = [ common ];
users = {
mutableUsers = true;
users = {
normalo = {
isNormalUser = true;
hashedPassword = normaloHashedPassword;
};
};
};
specialisation.new-generation = {
inheritParentConfig = false;
configuration = {
nixpkgs = {
inherit (config.nixpkgs) hostPlatform;
};
imports = [ common ];
users.users = {
new-normalo = {
isNormalUser = true;
};
};
};
};
};
testScript = ''
machine.wait_for_unit("userborn.service")
with subtest("normalo user is created"):
assert 1000 == int(machine.succeed("id --user normalo")), "normalo user doesn't have UID 1000"
assert "${normaloHashedPassword}" in machine.succeed("getent shadow normalo"), "normalo user password is not correct"
with subtest("Add new user manually"):
machine.succeed("useradd manual-normalo")
assert 1001 == int(machine.succeed("id --user manual-normalo")), "manual-normalo user doesn't have UID 1001"
with subtest("Delete manual--normalo user manually"):
machine.succeed("userdel manual-normalo")
machine.succeed("/run/current-system/specialisation/new-generation/bin/switch-to-configuration switch")
with subtest("normalo user is disabled"):
print(machine.succeed("getent shadow normalo"))
assert "!*" in machine.succeed("getent shadow normalo"), "normalo user is not disabled"
with subtest("new-normalo user is created after switching to new generation"):
print(machine.succeed("getent passwd new-normalo"))
assert 1001 == int(machine.succeed("id --user new-normalo")), "new-normalo user doesn't have UID 1001"
'';
}

127
nixos/tests/userborn.nix Normal file
View File

@ -0,0 +1,127 @@
{ lib, ... }:
let
# All passwords are "test"
rootHashedPasswordFile = "$y$j9T$6ueoTO5y7vvFsGvpQJEEa.$vubxgBiMnkTCtRtPD3hNiZHa7Nm1WsJeE9QomYqSRXB";
updatedRootHashedPassword = "$y$j9T$pBCO9N1FRF1rSl6V15n9n/$1JmRLEYPO7TRCx43cvLO19u59WA/oqTEhmSR4wrhzr.";
normaloPassword = "test";
updatedNormaloHashedPassword = "$y$j9T$IEWqhKtWg.r.8fVkSEF56.$iKNxdMC6hOAQRp6eBtYvBk4c7BGpONXeZMqc8I/LM46";
sysuserInitialHashedPassword = "$y$j9T$Kb6jGrk41hudTZpNjazf11$iw7fZXrewC6JxRaGPz7/gPXDZ.Z1VWsupvy81Hi1XiD";
updatedSysuserInitialHashedPassword = "$y$j9T$kUBVhgOdSjymSfwfRVja70$eqCwWzVsz0fI0Uc6JsdD2CYMCpfJcErqnIqva2JCi1D";
newNormaloHashedPassword = "$y$j9T$UFBMWbGjjVola0YE9YCcV/$jRSi5S6lzkcifbuqjMcyXLTwgOGm9BTQk/G/jYaxroC";
in
{
name = "userborn";
meta.maintainers = with lib.maintainers; [ nikstur ];
nodes.machine = {
services.userborn.enable = true;
# Read this password file at runtime from outside the Nix store.
environment.etc."rootpw.secret".text = rootHashedPasswordFile;
users = {
users = {
root = {
# Override the empty root password set by the test instrumentation.
hashedPasswordFile = lib.mkForce "/etc/rootpw.secret";
};
normalo = {
isNormalUser = true;
password = normaloPassword;
};
sysuser = {
isSystemUser = true;
group = "sysusers";
initialHashedPassword = sysuserInitialHashedPassword;
};
};
groups = {
sysusers = { };
};
};
specialisation.new-generation.configuration = {
users = {
users = {
root = {
# Forcing this to null simulates removing the config value in a new
# generation.
hashedPasswordFile = lib.mkOverride 9 null;
hashedPassword = updatedRootHashedPassword;
};
normalo = {
hashedPassword = updatedNormaloHashedPassword;
};
sysuser = {
initialHashedPassword = lib.mkForce updatedSysuserInitialHashedPassword;
};
new-normalo = {
isNormalUser = true;
hashedPassword = newNormaloHashedPassword;
};
};
groups = {
new-group = { };
};
};
};
};
testScript = ''
machine.wait_for_unit("userborn.service")
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("root user has correct password"):
print(machine.succeed("getent passwd root"))
assert "${rootHashedPasswordFile}" in machine.succeed("getent shadow root"), "root user password is not correct"
with subtest("normalo user is created"):
print(machine.succeed("getent passwd normalo"))
assert 1000 <= int(machine.succeed("id --user normalo")), "normalo user doesn't have a normal UID"
assert machine.succeed("stat -c '%U' /home/normalo") == "normalo\n"
with subtest("system user is created with correct password"):
print(machine.succeed("getent passwd sysuser"))
assert 1000 > int(machine.succeed("id --user sysuser")), "sysuser user doesn't have a system UID"
assert "${sysuserInitialHashedPassword}" in machine.succeed("getent shadow sysuser"), "system user password is not correct"
with subtest("sysusers group is created"):
print(machine.succeed("getent group sysusers"))
machine.succeed("/run/current-system/specialisation/new-generation/bin/switch-to-configuration switch")
with subtest("root user password is updated"):
print(machine.succeed("getent passwd root"))
assert "${updatedRootHashedPassword}" in machine.succeed("getent shadow root"), "root user password is not updated"
with subtest("normalo user password is updated"):
print(machine.succeed("getent passwd normalo"))
assert "${updatedNormaloHashedPassword}" in machine.succeed("getent shadow normalo"), "normalo user password is not updated"
with subtest("system user password is NOT updated"):
print(machine.succeed("getent passwd sysuser"))
assert "${sysuserInitialHashedPassword}" in machine.succeed("getent shadow sysuser"), "sysuser user password is not updated"
with subtest("new-normalo user is created after switching to new generation"):
print(machine.succeed("getent passwd new-normalo"))
assert 1000 <= int(machine.succeed("id --user new-normalo")), "new-normalo user doesn't have a normal UID"
assert machine.succeed("stat -c '%U' /home/new-normalo") == "new-normalo\n"
assert "${newNormaloHashedPassword}" in machine.succeed("getent shadow new-normalo"), "new-normalo user password is not correct"
with subtest("new-group group is created after switching to new generation"):
print(machine.succeed("getent group new-group"))
'';
}

View File

@ -0,0 +1,43 @@
{
lib,
rustPlatform,
fetchFromGitHub,
makeBinaryWrapper,
mkpasswd,
}:
rustPlatform.buildRustPackage rec {
pname = "userborn";
version = "0.1.0";
src = fetchFromGitHub {
owner = "nikstur";
repo = "userborn";
rev = version;
hash = "sha256-aptFDrL9RPPTu4wp2ee3LVaEruRdCWtLGIKdOgsR+/s=";
};
sourceRoot = "${src.name}/rust/userborn";
cargoHash = "sha256-m39AC26E0Pxu1E/ap2kSwr5uznJNgExf5QUrZ+zTNX0=";
nativeBuildInputs = [ makeBinaryWrapper ];
buildInputs = [ mkpasswd ];
nativeCheckInputs = [ mkpasswd ];
postInstall = ''
wrapProgram $out/bin/userborn --prefix PATH : ${lib.makeBinPath [ mkpasswd ]}
'';
stripAllList = [ "bin" ];
meta = with lib; {
homepage = "https://github.com/nikstur/userborn";
description = "Declaratively bear (manage) Linux users and groups";
license = licenses.mit;
maintainers = with lib.maintainers; [ nikstur ];
mainProgram = "userborn";
};
}