diff --git a/crates/nibrunnerd/src/install/jailer_identities.rs b/crates/nibrunnerd/src/install/jailer_identities.rs new file mode 100644 index 00000000..c4955a79 --- /dev/null +++ b/crates/nibrunnerd/src/install/jailer_identities.rs @@ -0,0 +1,232 @@ +use super::InstallError; + +const ACCOUNT: &str = "nibrunner-jailer"; +const RESERVATION_SIZE: u32 = 65_536; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Identities { + pub(crate) uid_base: u32, + pub(crate) gid_base: u32, +} + +struct Reservation { + owner: String, + start: u32, + end: u32, +} + +fn ask(arguments: &[&str]) -> Result { + let output = std::process::Command::new("getent") + .args(arguments) + .output() + .map_err(|error| format!("getent could not read the jailer identities: {error}"))?; + if !output.status.success() { + return Err(format!( + "the jailer {} could not be read; run nibrunnerd install", + arguments[0] + )); + } + String::from_utf8(output.stdout).map_err(|_| "the account database is not UTF-8".into()) +} + +fn reservations(text: &str) -> Result, String> { + text.lines() + .filter(|line| !line.trim().is_empty() && !line.starts_with('#')) + .map(|line| { + let fields: Vec<_> = line.split(':').collect(); + let invalid = || "a subordinate identity reservation is malformed".to_string(); + if fields.len() != 3 || fields[0].is_empty() { + return Err(invalid()); + } + let start: u32 = fields[1].parse().map_err(|_| invalid())?; + let count: u32 = fields[2].parse().map_err(|_| invalid())?; + let end = start.checked_add(count).ok_or_else(invalid)?; + if start == 0 || count == 0 { + return Err(invalid()); + } + Ok(Reservation { + owner: fields[0].to_owned(), + start, + end, + }) + }) + .collect() +} + +fn reserved_base(text: &str, accounts: &str, uid: u32, max_apps: u32) -> Result { + let ranges = reservations(text)?; + let numeric_owner = uid.to_string(); + let mut own = ranges + .iter() + .filter(|range| range.owner == ACCOUNT || range.owner == numeric_owner); + let selected = own.next().ok_or_else(|| { + format!("{ACCOUNT} has no subordinate identity reservation; run nibrunnerd install") + })?; + if own.next().is_some() { + return Err(format!( + "{ACCOUNT} must have exactly one subordinate identity reservation" + )); + } + if max_apps == 0 || selected.end - selected.start < max_apps { + return Err(format!( + "the jailer identity reservation does not fit {max_apps} app slots" + )); + } + for range in &ranges { + if !std::ptr::eq(range, selected) && range.start < selected.end && selected.start < range.end { + return Err(format!( + "the jailer identity reservation overlaps {}", + range.owner + )); + } + } + for line in accounts.lines().filter(|line| !line.is_empty()) { + let fields: Vec<_> = line.split(':').collect(); + let id: u32 = fields + .get(2) + .and_then(|field| field.parse().ok()) + .ok_or_else(|| "an account database entry is malformed".to_string())?; + if (selected.start..selected.end).contains(&id) { + return Err(format!( + "the jailer identity reservation contains the {} account", + fields[0] + )); + } + } + Ok(selected.start) +} + +fn account_uid(passwd: &str, shadow: &str) -> Result { + let fields: Vec<_> = passwd.trim_end().split(':').collect(); + let password: Vec<_> = shadow.trim_end().split(':').collect(); + if fields.len() != 7 + || fields[0] != ACCOUNT + || !matches!(fields[6], "/usr/sbin/nologin" | "/sbin/nologin") + || password.len() != 9 + || password[0] != ACCOUNT + || !password[1].starts_with(['!', '*']) + { + return Err(format!("{ACCOUNT} must be a locked account with a nologin shell")); + } + let uid: u32 = fields[2] + .parse() + .map_err(|_| format!("{ACCOUNT} has an invalid UID"))?; + let gid: u32 = fields[3] + .parse() + .map_err(|_| format!("{ACCOUNT} has an invalid GID"))?; + if uid == 0 || gid == 0 || uid == u32::MAX || gid == u32::MAX { + return Err(format!("{ACCOUNT} must have a non-root UID and GID")); + } + Ok(uid) +} + +pub(crate) fn read(max_apps: u32) -> Result { + read_identities(max_apps).map_err(InstallError::Refused) +} + +fn read_identities(max_apps: u32) -> Result { + let uid = account_uid(&ask(&["passwd", ACCOUNT])?, &ask(&["shadow", ACCOUNT])?)?; + let base = |file: &str, database: &str| { + let text = std::fs::read_to_string(file) + .map_err(|error| format!("{file} could not be read: {error}; run nibrunnerd install"))?; + reserved_base(&text, &ask(&[database])?, uid, max_apps).map_err(|error| format!("{file}: {error}")) + }; + Ok(Identities { + uid_base: base("/etc/subuid", "passwd")?, + gid_base: base("/etc/subgid", "group")?, + }) +} + +pub(crate) fn ensure(max_apps: u32) -> Result { + reserve_identities(max_apps).map_err(InstallError::Refused) +} + +fn reserve_identities(max_apps: u32) -> Result { + if super::service_user::ids(ACCOUNT).is_some() { + read_identities(max_apps)?; + return Ok(false); + } + if max_apps == 0 || max_apps > RESERVATION_SIZE { + return Err("the requested app slots do not fit the jailer identity reservation".into()); + } + // useradd holds the subordinate-ID database locks and avoids other users' reservations. + let output = std::process::Command::new("useradd") + .args([ + "--system", + "--user-group", + "--no-create-home", + "--shell", + "/usr/sbin/nologin", + "--home-dir", + "/nonexistent", + "--add-subids-for-system", + "--key", + &format!("SUB_UID_COUNT={RESERVATION_SIZE}"), + "--key", + &format!("SUB_GID_COUNT={RESERVATION_SIZE}"), + ACCOUNT, + ]) + .output() + .map_err(|error| format!("the jailer identity reservation could not be created: {error}"))?; + if !output.status.success() { + return Err(format!( + "the jailer identity reservation could not be created: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + read_identities(max_apps)?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_reserved_range_is_stable_and_fits_every_slot() { + let text = "container:100000:65536\nnibrunner-jailer:165536:65536\n"; + let accounts = "root:x:0:0:root:/root:/bin/sh\n"; + assert_eq!(reserved_base(text, accounts, 999, 1000).unwrap(), 165536); + assert_eq!(reserved_base(text, accounts, 999, 65536).unwrap(), 165536); + assert!(reserved_base(text, accounts, 999, 65537).is_err()); + } + + #[test] + fn a_reservation_may_name_its_owner_by_numeric_uid() { + assert_eq!(reserved_base("999:200000:8", "", 999, 8).unwrap(), 200000); + } + + #[test] + fn overlapping_container_ranges_and_host_accounts_are_refused() { + assert!(reserved_base("nibrunner-jailer:200000:8\ncontainer:200004:8", "", 999, 8).is_err()); + for accounts in ["user:x:200004:1000:User:/home/user:/bin/sh", "group:x:200004:"] { + assert!(reserved_base("nibrunner-jailer:200000:8", accounts, 999, 8).is_err()); + } + assert!(reserved_base("nibrunner-jailer:200000:8\ncontainer:199999:9", "", 999, 8).is_err()); + assert!(reserved_base("nibrunner-jailer:200000:8\ncontainer:200008:8", "", 999, 8).is_ok()); + } + + #[test] + fn missing_ambiguous_and_invalid_reservations_are_refused() { + for text in [ + "", + "nibrunner-jailer:1:8\n999:20:8", + "nibrunner-jailer:0:8", + "nibrunner-jailer:4294967290:8", + "nibrunner-jailer:200000:0", + "nibrunner-jailer:bad:8", + ] { + assert!(reserved_base(text, "", 999, 8).is_err(), "{text}"); + } + } + + #[test] + fn the_reservation_account_is_non_root_locked_and_cannot_log_in() { + let passwd = "nibrunner-jailer:x:999:999::/nonexistent:/usr/sbin/nologin\n"; + let shadow = "nibrunner-jailer:!:20000::::::\n"; + assert_eq!(account_uid(passwd, shadow).unwrap(), 999); + assert!(account_uid(&passwd.replace(":999:", ":0:"), shadow).is_err()); + assert!(account_uid(&passwd.replace("/usr/sbin/nologin", "/bin/sh"), shadow).is_err()); + assert!(account_uid(passwd, &shadow.replace(":!:", ":password:")).is_err()); + } +} diff --git a/crates/nibrunnerd/src/install/mod.rs b/crates/nibrunnerd/src/install/mod.rs index 05f21d3b..dc6c780d 100644 --- a/crates/nibrunnerd/src/install/mod.rs +++ b/crates/nibrunnerd/src/install/mod.rs @@ -9,6 +9,7 @@ //! asks systemd for it. pub mod guest_image; +pub(crate) mod jailer_identities; pub mod kernel; pub mod max_apps; pub mod prerequisites; @@ -132,6 +133,14 @@ pub async fn run( })?; } + if jailer_identities::ensure(config.max_apps)? { + let identities = jailer_identities::read(config.max_apps)?; + laid.did(format!( + "jailer identity ranges reserved from UID {} and GID {}", + identities.uid_base, identities.gid_base + )); + } + if let Some(settings) = config.volumes.zerofs() { if let service_user::Made::Created = service_user::ensure(service_user::ZEROFS_USER)? { laid.did(format!("{} account created", service_user::ZEROFS_USER)); diff --git a/crates/nibrunnerd/src/install/prerequisites.rs b/crates/nibrunnerd/src/install/prerequisites.rs index 691bb206..d0986d18 100644 --- a/crates/nibrunnerd/src/install/prerequisites.rs +++ b/crates/nibrunnerd/src/install/prerequisites.rs @@ -34,6 +34,8 @@ pub fn check(config: &HostConfig) -> Vec { for tool in ["nft", "mke2fs"] { checks.push(binary(tool, "install the nftables and e2fsprogs packages")); } + checks.push(binary("useradd", "install the passwd package")); + checks.push(binary("getent", "install the libc-bin package")); // What sets this host's kernel settings, which `install` does rather than asks for. checks.push(binary("modprobe", "install the kmod package")); @@ -50,11 +52,6 @@ pub fn check(config: &HostConfig) -> Vec { "fusermount3", "install the nbd-client, e2fsprogs and fuse3 packages", ), - // ZeroFS does not run as root, and this is what creates the account it does run as. - ( - "useradd", - "install the passwd package, or create the zerofs account by hand", - ), ] { checks.push(binary(tool, remedy)); }