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 3df263d6..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 { @@ -81,6 +82,14 @@ 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()?; + + // 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 { @@ -169,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 @@ -218,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 94aabffd..d111364d 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,52 @@ 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(()) +} + +/// 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/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` diff --git a/flake.lock b/flake.lock index b8b992e7..be2daba0 100644 --- a/flake.lock +++ b/flake.lock @@ -1,12 +1,71 @@ { "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": 1770115704, + "narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "62c8382960464ceb98ea593cb8321a2cf8f9e3e5", + "rev": "e6eae2ee2110f3d31110d5c222cd395303343b08", "type": "github" }, "original": { @@ -16,9 +75,76 @@ "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" + ], + "pre-commit-hooks-nix": "pre-commit-hooks-nix", + "systems": "systems" + }, + "locked": { + "lastModified": 1769903681, + "narHash": "sha256-mXXakR75Iz6AFf/TYgIHE8SxOri2HyReYUYTT3lCEPA=", + "owner": "nikstur", + "repo": "userborn", + "rev": "88666e2d8931c7252411498c5b82feb9a8a4d8d4", + "type": "github" + }, + "original": { + "owner": "nikstur", + "ref": "0.5.0", + "repo": "userborn", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index 61a3f5df..f0e9051f 100644 --- a/flake.nix +++ b/flake.nix @@ -7,9 +7,17 @@ }; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + inputs.userborn = { + url = "github:nikstur/userborn/0.5.0"; + inputs.nixpkgs.follows = "nixpkgs"; + }; outputs = - { self, nixpkgs }: + { + self, + nixpkgs, + userborn, + }: let systems = [ "aarch64-linux" @@ -27,7 +35,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 b0d43cb1..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 = { @@ -56,12 +57,28 @@ 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 { }; + userborn = userborn.packages.${config.nixpkgs.hostPlatform}.default; }; }; diff --git a/nix/modules/default.nix b/nix/modules/default.nix index f273d883..14ae26bf 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; @@ -230,6 +230,7 @@ ''; deactivationScript = pkgs.writeShellScript "deactivate" '' + export PATH="$PATH:${lib.makeBinPath [ config.services.userborn.package ]}" ${system-manager}/bin/system-manager-engine deactivate "$@" ''; 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..feedc684 100644 --- a/nix/modules/systemd.nix +++ b/nix/modules/systemd.nix @@ -140,14 +140,34 @@ 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" ]; }; + # 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/default.nix b/nix/modules/upstream/nixpkgs/default.nix index c5779e23..14038b47 100644 --- a/nix/modules/upstream/nixpkgs/default.nix +++ b/nix/modules/upstream/nixpkgs/default.nix @@ -7,16 +7,20 @@ imports = [ ./nginx.nix ./nix.nix + ./userborn.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 = @@ -28,6 +32,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 = ""; + }; }; } 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 new file mode 100644 index 00000000..d96dfb86 --- /dev/null +++ b/nix/modules/upstream/nixpkgs/users-groups.nix @@ -0,0 +1,1228 @@ +{ + 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 + mkAliasOptionModule + 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 = [ + (mkAliasOptionModule [ "users" "extraUsers" ] [ "users" "users" ]) + (mkAliasOptionModule [ "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; + }; + + 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. + + # Install all the user shells + environment.systemPackages = systemShells; + + environment.etc = mapAttrs' ( + _: + { packages, name, ... }: + { + name = "profiles/per-user/${name}"; + value.source = pkgs.buildEnv { + name = "user-environment"; + paths = packages; + inherit (config.environment) pathsToLink extraOutputsToInstall; + inherit (config.system.path) ignoreCollisions postBuild; + }; + } + ) (filterAttrs (_: u: u.packages != [ ]) cfg.users); + + # environment.profiles = [ + # "$HOME/.nix-profile" + # "\${XDG_STATE_HOME}/nix/profile" + # "$HOME/.local/state/nix/profile" + # "/etc/profiles/per-user/$USER" + # ]; + # + # systemd initrd + # boot.initrd.systemd = lib.mkIf config.boot.initrd.systemd.enable { + # contents = { + # "/etc/passwd".text = '' + # ${lib.concatStringsSep "\n" ( + # lib.mapAttrsToList ( + # n: + # { + # uid, + # group, + # shell, + # }: + # let + # g = config.boot.initrd.systemd.groups.${group}; + # in + # "${n}:x:${toString uid}:${toString g.gid}::/var/empty:${shell}" + # ) config.boot.initrd.systemd.users + # )} + # ''; + # "/etc/group".text = '' + # ${lib.concatStringsSep "\n" ( + # lib.mapAttrsToList (n: { gid }: "${n}:x:${toString gid}:") config.boot.initrd.systemd.groups + # )} + # ''; + # "/etc/shells".text = + # lib.concatStringsSep "\n" ( + # lib.unique (lib.mapAttrsToList (_: u: u.shell) config.boot.initrd.systemd.users) + # ) + # + "\n"; + # }; + # + # storePaths = [ "${pkgs.shadow}/bin/nologin" ]; + # + # users = { + # root = { + # shell = lib.mkDefault "/bin/bash"; + # }; + # nobody = { }; + # }; + # + # groups = { + # root = { }; + # nogroup = { }; + # systemd-journal = { }; + # tty = { }; + # dialout = { }; + # kmem = { }; + # input = { }; + # video = { }; + # render = { }; + # sgx = { }; + # audio = { }; + # video = { }; + # lp = { }; + # disk = { }; + # cdrom = { }; + # tape = { }; + # kvm = { }; + # }; + # }; + + 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 + # 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/flake.lock b/testFlake/flake.lock index 18a6193e..439d58f5 100644 --- a/testFlake/flake.lock +++ b/testFlake/flake.lock @@ -1,5 +1,66 @@ { "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": [ + "system-manager", + "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": [ + "system-manager", + "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": [ @@ -22,11 +83,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1769789167, - "narHash": "sha256-kKB3bqYJU5nzYeIROI82Ef9VtTbu4uA3YydSk/Bioa8=", + "lastModified": 1770115704, + "narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "62c8382960464ceb98ea593cb8321a2cf8f9e3e5", + "rev": "e6eae2ee2110f3d31110d5c222cd395303343b08", "type": "github" }, "original": { @@ -36,6 +97,34 @@ "type": "github" } }, + "pre-commit-hooks-nix": { + "inputs": { + "flake-compat": [ + "system-manager", + "userborn", + "flake-compat" + ], + "gitignore": "gitignore", + "nixpkgs": [ + "system-manager", + "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": { "nix-vm-test": "nix-vm-test", @@ -48,7 +137,8 @@ }, "system-manager": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "userborn": "userborn" }, "locked": { "path": "..", @@ -59,6 +149,47 @@ "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": [ + "system-manager", + "nixpkgs" + ], + "pre-commit-hooks-nix": "pre-commit-hooks-nix", + "systems": "systems" + }, + "locked": { + "lastModified": 1769903681, + "narHash": "sha256-mXXakR75Iz6AFf/TYgIHE8SxOri2HyReYUYTT3lCEPA=", + "owner": "nikstur", + "repo": "userborn", + "rev": "88666e2d8931c7252411498c5b82feb9a8a4d8d4", + "type": "github" + }, + "original": { + "owner": "nikstur", + "ref": "0.5.0", + "repo": "userborn", + "type": "github" + } } }, "root": "root", diff --git a/testFlake/flake.nix b/testFlake/flake.nix index c631b947..4b2a67a5 100644 --- a/testFlake/flake.nix +++ b/testFlake/flake.nix @@ -34,12 +34,14 @@ 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; + }; containerChecks = system: import ./container-tests.nix { @@ -53,7 +55,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 b14b6c0b..40def355 100644 --- a/testFlake/vm-tests.nix +++ b/testFlake/vm-tests.nix @@ -147,6 +147,12 @@ let trusted-users = [ "zimbatm" ]; }; }; + + users.users.zimbatm = { + isNormalUser = true; + extraGroups = [ "wheel" ]; + initialPassword = "test123"; + }; }; } ) @@ -193,7 +199,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 \"luj:test\" | chpasswd") + print(vm.succeed("cat /etc/passwd")) + passwd_out = vm.succeed("passwd -S luj | awk '{print $2}'") + 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() @@ -217,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") @@ -243,9 +256,30 @@ forEachUbuntuImage "example" { vm.fail("test -f /etc/baz/bar/foo2") vm.succeed("test -f /etc/foo_new") + 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, 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}" + 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 { node = "vm"; @@ -260,7 +294,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}" ''; }