diff --git a/Cargo.lock b/Cargo.lock index 0256a84e..b6fcc2e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + [[package]] name = "autocfg" version = "1.5.0" @@ -95,6 +101,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -174,6 +186,12 @@ dependencies = [ "syn", ] +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "either" version = "1.15.0" @@ -190,6 +208,12 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.2" @@ -206,6 +230,24 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + +[[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 = "getset" version = "0.1.6" @@ -313,6 +355,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[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.28" @@ -358,6 +406,43 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.0", +] + +[[package]] +name = "mockall" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "nix" version = "0.24.3" @@ -369,6 +454,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -453,6 +550,32 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8d0eef3571242013a0d5dc84861c3ae4a652e56e12adf8bdc26ff5f8cb34c94" +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -524,7 +647,7 @@ dependencies = [ "flate2", "hex", "procfs-core", - "rustix", + "rustix 0.38.44", ] [[package]] @@ -549,7 +672,7 @@ dependencies = [ "derive_more", "glob", "mach2", - "nix", + "nix 0.24.3", "num_cpus", "once_cell", "platforms", @@ -566,6 +689,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 = "regex" version = "1.11.2" @@ -610,10 +739,23 @@ dependencies = [ "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.0", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -684,12 +826,32 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "swss-common" version = "0.1.0" @@ -700,6 +862,7 @@ dependencies = [ "lazy_static", "libc", "serde", + "tokio", "tracing-subscriber", ] @@ -739,6 +902,25 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.2", + "windows-sys 0.61.0", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" @@ -768,6 +950,32 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.0", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing" version = "0.1.41" @@ -843,12 +1051,46 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "userd_rs" +version = "1.0.0" +dependencies = [ + "lazy_static", + "libc", + "mockall", + "nix 0.29.0", + "serde", + "serde_json", + "swss-common", + "syslog-tracing", + "tempfile", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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 = "wasm-bindgen" version = "0.2.103" @@ -1075,7 +1317,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1084,7 +1326,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.4", ] [[package]] @@ -1102,14 +1353,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" +dependencies = [ + "windows-link 0.2.0", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -1127,44 +1395,98 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" diff --git a/Cargo.toml b/Cargo.toml index 8087a051..d814c2c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = '2' members = [ "crates/procdockerstatsd-rs", + "crates/userd-rs", ] exclude = [] @@ -23,7 +24,7 @@ unused_import_braces = 'warn' [workspace.dependencies] # System info and SONiC database sysinfo = "0.35.0" -swss-common = { git = "https://github.com/sonic-net/sonic-swss-common", branch = "master" } +swss-common = { git = "https://github.com/sonic-net/sonic-swss-common", branch = "master", features = ["async"] } # Regex and pattern matching regex = "1.10" @@ -50,4 +51,20 @@ syslog-tracing = "0.3" # Serialization serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" \ No newline at end of file +serde_json = "1.0" + +# Error handling +thiserror = "1.0" + +# Unix/POSIX system calls +nix = { version = "0.29", features = ["user", "fs"] } + +# Async runtime and signal handling +tokio = { version = "1", features = ["rt-multi-thread", "signal", "macros", "time"] } + +# Testing +mockall = "0.13" +tempfile = "3.10" + +# Static initialization +lazy_static = "1.4" \ No newline at end of file diff --git a/crates/userd-rs/Cargo.toml b/crates/userd-rs/Cargo.toml new file mode 100644 index 00000000..bf1e700f --- /dev/null +++ b/crates/userd-rs/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "userd_rs" +version.workspace = true +edition.workspace = true +description = "SONiC User Management Daemon in Rust" +license.workspace = true +authors.workspace = true + +[dependencies] +swss-common.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +syslog-tracing.workspace = true +libc.workspace = true +thiserror.workspace = true +nix.workspace = true +tokio.workspace = true +tempfile.workspace = true +lazy_static.workspace = true + +[dev-dependencies] +mockall.workspace = true + +[[bin]] +name = "userd" +path = "src/main.rs" + +[lib] +name = "userd_rs" +path = "src/lib.rs" + diff --git a/crates/userd-rs/src/commands.rs b/crates/userd-rs/src/commands.rs new file mode 100644 index 00000000..b9e8093c --- /dev/null +++ b/crates/userd-rs/src/commands.rs @@ -0,0 +1,567 @@ +//! System command wrappers +//! +//! This module provides wrapper functions for system commands used in user management. +//! Following the pattern from sonic-swss/cfgmgr/shellcmd.h for command path definitions, +//! these wrappers ensure consistent error handling and logging across the codebase. + +use tracing::{debug, error, info}; + +use crate::error::UserdResult; +use crate::system::CommandExecutor; +use crate::types::*; + +/// Helper macro to convert string literals to Vec for command execution +#[macro_export] +macro_rules! cmd { + ($($arg:expr),+ $(,)?) => { + vec![$($arg.to_string()),+] + }; +} + +/// Result type for gpasswd operations that may partially succeed +#[derive(Debug, PartialEq)] +pub enum GpasswdResult { + /// User was successfully removed from the group + Removed, + /// User was not a member of the group (not an error) + NotMember, +} + +/// Run `groups` command to get the groups a user belongs to +pub fn run_groups(cmd_executor: &C, username: &str) -> Vec { + let output = cmd_executor.execute(cmd![CMD_GROUPS, username]); + match output { + Ok(result) if result.success() => { + let stdout = result.stdout_str(); + // Format: "username : group1 group2 group3" + if let Some(pos) = stdout.find(':') { + return stdout[pos + 1..] + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + } + Vec::new() + } + _ => Vec::new(), + } +} + +/// Run `gpasswd -d` to remove a user from a group +pub fn run_gpasswd_remove( + cmd_executor: &C, + username: &str, + group: &str, +) -> UserdResult { + let result = cmd_executor.execute(cmd![CMD_GPASSWD, "-d", username, group])?; + if result.success() { + debug!("Removed user {} from group {}", username, group); + Ok(GpasswdResult::Removed) + } else { + let stderr = result.stderr_str(); + // "not a member" is not an error - user wasn't in the group + if stderr.contains("not a member") { + debug!("User {} was not a member of group {}", username, group); + Ok(GpasswdResult::NotMember) + } else { + Err(result.to_error()) + } + } +} + +/// Run `groupadd` to create a new system group +pub fn run_groupadd(cmd_executor: &C, group: &str) -> UserdResult { + let result = cmd_executor.execute(cmd![CMD_GROUPADD, group])?; + if result.success() { + info!("Created group {}", group); + Ok(true) + } else { + let stderr = result.stderr_str(); + // Group already exists is not necessarily an error + if stderr.contains("already exists") { + debug!("Group {} already exists", group); + Ok(true) + } else { + error!("Failed to create group {}: {}", group, stderr); + Ok(false) + } + } +} + +/// Run `useradd` to create a new user +pub fn run_useradd( + cmd_executor: &C, + username: &str, + home_dir: &str, + shell: &str, +) -> UserdResult<()> { + let result = cmd_executor.execute(cmd![ + CMD_USERADD, + "-d", home_dir, + "-m", + "-s", shell, + username + ])?; + if result.success() { + debug!("Created user {} with home {} and shell {}", username, home_dir, shell); + Ok(()) + } else { + Err(result.to_error()) + } +} + +/// Run `userdel -r` to delete a user and their home directory +pub fn run_userdel(cmd_executor: &C, username: &str) -> UserdResult<()> { + let result = cmd_executor.execute(cmd![CMD_USERDEL, "-r", username])?; + if result.success() { + debug!("Deleted user {}", username); + Ok(()) + } else { + let stderr = result.stderr_str(); + // Check if the ONLY error is a benign mail spool warning + // userdel outputs "mail spool (/path) not found" when there's no mail spool (not an error) + // We need to ensure this is the ONLY error - if there are other errors, we should fail + let is_only_mail_spool_error = stderr.contains("mail spool") + && stderr.contains("not found") + && !stderr.lines().any(|line| { + let line = line.trim(); + !line.is_empty() + && !line.contains("mail spool") + && line.starts_with("userdel:") + }); + + if is_only_mail_spool_error { + debug!("Deleted user {} (mail spool warning ignored)", username); + Ok(()) + } else { + Err(result.to_error()) + } + } +} + +/// Run `usermod -p` to set a user's password hash +pub fn run_usermod_password( + cmd_executor: &C, + username: &str, + password_hash: &str, +) -> UserdResult<()> { + // Index 2 is the password_hash argument which should be masked in logs + let result = cmd_executor.execute_masked( + cmd![CMD_USERMOD, "-p", password_hash, username], + [2].into_iter().collect(), + )?; + if result.success() { + debug!("Set password for user {}", username); + Ok(()) + } else { + Err(result.to_error()) + } +} + +/// Run `usermod -s` to set a user's login shell +pub fn run_usermod_shell( + cmd_executor: &C, + username: &str, + shell: &str, +) -> UserdResult<()> { + let result = cmd_executor.execute(cmd![CMD_USERMOD, "-s", shell, username])?; + if result.success() { + debug!("Set shell for user {} to {}", username, shell); + Ok(()) + } else { + Err(result.to_error()) + } +} + +/// Run `usermod -a -G` to add a user to a group (supplementary group) +pub fn run_usermod_add_group( + cmd_executor: &C, + username: &str, + group: &str, +) -> UserdResult<()> { + let result = cmd_executor.execute(cmd![CMD_USERMOD, "-a", "-G", group, username])?; + if result.success() { + debug!("Added user {} to group {}", username, group); + Ok(()) + } else { + Err(result.to_error()) + } +} + +/// Run `pkill` to send a signal to all processes owned by a user +/// +/// # Arguments +/// * `cmd_executor` - Command executor to use +/// * `username` - Username whose processes to signal +/// * `signal` - Signal to send (e.g., "TERM", "KILL") +/// +/// # Returns +/// * `Ok(true)` if processes were found and signaled +/// * `Ok(false)` if no processes were found (exit code 1) +/// * `Err` if the command failed to execute (exit code 2 or 3) +pub fn run_pkill( + cmd_executor: &C, + username: &str, + signal: &str, +) -> UserdResult { + let result = cmd_executor.execute(cmd![CMD_PKILL, &format!("-{}", signal), "-u", username])?; + + // pkill returns: + // 0 - one or more processes matched and were signaled + // 1 - no processes matched + // 2 - syntax error + // 3 - fatal error + match result.code() { + Some(0) => { + debug!("Sent SIG{} to processes of user {}", signal, username); + Ok(true) + } + Some(1) => { + debug!("No processes found for user {} (already terminated)", username); + Ok(false) + } + _ => { + Err(result.to_error()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::UserdError; + use crate::system::{CommandResult, MockCommandExecutor}; + use std::os::unix::process::ExitStatusExt; + use std::process::ExitStatus; + + // Helper to create CommandResult for tests + fn mock_result(status: ExitStatus, stdout: Vec, stderr: Vec) -> CommandResult { + CommandResult { + status, + stdout, + stderr, + command_str: "test command".to_string(), + } + } + + // Helper to create CommandResult with custom command string + fn mock_result_with_cmd( + status: ExitStatus, + stdout: Vec, + stderr: Vec, + command_str: String, + ) -> CommandResult { + CommandResult { + status, + stdout, + stderr, + command_str, + } + } + + #[test] + fn test_run_pkill_success() { + let mut mock_cmd = MockCommandExecutor::new(); + + // pkill returns 0 (success - processes were signaled) + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(0), + Vec::new(), + Vec::new(), + )) + }); + + let result = run_pkill(&mock_cmd, "testuser", "TERM"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), true); // Processes were found and signaled + } + + #[test] + fn test_run_pkill_no_processes_matched() { + let mut mock_cmd = MockCommandExecutor::new(); + + // pkill returns 1 (no processes matched - this is OK) + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(256), // Exit code 1 + Vec::new(), + Vec::new(), + )) + }); + + let result = run_pkill(&mock_cmd, "testuser", "KILL"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), false); // No processes found + } + + #[test] + fn test_run_pkill_syntax_error() { + let mut mock_cmd = MockCommandExecutor::new(); + + // pkill returns 2 (syntax error) + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(512), // Exit code 2 + Vec::new(), + b"pkill: invalid option".to_vec(), + )) + }); + + let result = run_pkill(&mock_cmd, "testuser", "TERM"); + assert!(result.is_err()); + match result.unwrap_err() { + UserdError::CommandError { command: _, message } => { + assert!(message.contains("invalid option")); + } + _ => panic!("Expected CommandError"), + } + } + + #[test] + fn test_run_pkill_fatal_error() { + let mut mock_cmd = MockCommandExecutor::new(); + + // pkill returns 3 (fatal error) + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(768), // Exit code 3 + Vec::new(), + b"pkill: fatal error".to_vec(), + )) + }); + + let result = run_pkill(&mock_cmd, "testuser", "KILL"); + assert!(result.is_err()); + match result.unwrap_err() { + UserdError::CommandError { command: _, message } => { + assert!(message.contains("fatal error")); + } + _ => panic!("Expected CommandError"), + } + } + + #[test] + fn test_password_masking_in_error_messages() { + let mut mock_cmd = MockCommandExecutor::new(); + + // Simulate usermod -p failure with masked password in command string + mock_cmd.expect_execute_masked().returning(|_, _| { + Ok(mock_result_with_cmd( + ExitStatus::from_raw(256), // Exit code 1 + Vec::new(), + b"usermod: user does not exist".to_vec(), + "/usr/sbin/usermod -p *** testuser".to_string(), // Password is masked as *** + )) + }); + + let result = run_usermod_password(&mock_cmd, "testuser", "$6$secret$hash"); + + // Verify the error occurred + assert!(result.is_err()); + + // Verify the error message contains masked password, not the actual password + match result.unwrap_err() { + UserdError::CommandError { command, message } => { + // Command should contain *** not the actual password + assert!(command.contains("***"), "Command should mask password with ***"); + assert!(!command.contains("$6$secret$hash"), "Command should NOT contain actual password"); + assert!(command.contains("usermod"), "Command should contain usermod"); + assert!(command.contains("testuser"), "Command should contain username"); + assert!(message.contains("user does not exist"), "Message should contain error"); + } + _ => panic!("Expected CommandError"), + } + } + + // ========== Tests for run_groups() ========== + + #[test] + fn test_run_groups_success() { + let mut mock_cmd = MockCommandExecutor::new(); + + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(0), + b"testuser : sudo docker admin\n".to_vec(), + Vec::new(), + )) + }); + + let groups = run_groups(&mock_cmd, "testuser"); + + assert_eq!(groups.len(), 3); + assert_eq!(groups[0], "sudo"); + assert_eq!(groups[1], "docker"); + assert_eq!(groups[2], "admin"); + } + + #[test] + fn test_run_groups_single_group() { + let mut mock_cmd = MockCommandExecutor::new(); + + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(0), + b"testuser : users\n".to_vec(), + Vec::new(), + )) + }); + + let groups = run_groups(&mock_cmd, "testuser"); + + assert_eq!(groups.len(), 1); + assert_eq!(groups[0], "users"); + } + + #[test] + fn test_run_groups_no_colon() { + let mut mock_cmd = MockCommandExecutor::new(); + + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(0), + b"invalid output\n".to_vec(), + Vec::new(), + )) + }); + + let groups = run_groups(&mock_cmd, "testuser"); + + assert_eq!(groups.len(), 0); + } + + #[test] + fn test_run_groups_command_fails() { + let mut mock_cmd = MockCommandExecutor::new(); + + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(256), // Exit code 1 + Vec::new(), + b"groups: user not found".to_vec(), + )) + }); + + let groups = run_groups(&mock_cmd, "testuser"); + + assert_eq!(groups.len(), 0); + } + + // ========== Tests for run_gpasswd_remove() ========== + + #[test] + fn test_run_gpasswd_remove_success() { + let mut mock_cmd = MockCommandExecutor::new(); + + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(0), + Vec::new(), + Vec::new(), + )) + }); + + let result = run_gpasswd_remove(&mock_cmd, "testuser", "sudo"); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), GpasswdResult::Removed); + } + + #[test] + fn test_run_gpasswd_remove_not_a_member() { + let mut mock_cmd = MockCommandExecutor::new(); + + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(256), // Exit code 1 + Vec::new(), + b"gpasswd: user 'testuser' is not a member of 'sudo'\n".to_vec(), + )) + }); + + let result = run_gpasswd_remove(&mock_cmd, "testuser", "sudo"); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), GpasswdResult::NotMember); + } + + #[test] + fn test_run_gpasswd_remove_other_error() { + let mut mock_cmd = MockCommandExecutor::new(); + + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(256), // Exit code 1 + Vec::new(), + b"gpasswd: group 'sudo' does not exist\n".to_vec(), + )) + }); + + let result = run_gpasswd_remove(&mock_cmd, "testuser", "sudo"); + + assert!(result.is_err()); + match result.unwrap_err() { + UserdError::CommandError { message, .. } => { + assert!(message.contains("does not exist")); + } + _ => panic!("Expected CommandError"), + } + } + + // ========== Tests for run_groupadd() ========== + + #[test] + fn test_run_groupadd_success() { + let mut mock_cmd = MockCommandExecutor::new(); + + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(0), + Vec::new(), + Vec::new(), + )) + }); + + let result = run_groupadd(&mock_cmd, "testgroup"); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), true); + } + + #[test] + fn test_run_groupadd_already_exists() { + let mut mock_cmd = MockCommandExecutor::new(); + + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(256), // Exit code 1 + Vec::new(), + b"groupadd: group 'testgroup' already exists\n".to_vec(), + )) + }); + + let result = run_groupadd(&mock_cmd, "testgroup"); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), true); + } + + #[test] + fn test_run_groupadd_other_error() { + let mut mock_cmd = MockCommandExecutor::new(); + + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(256), // Exit code 1 + Vec::new(), + b"groupadd: permission denied\n".to_vec(), + )) + }); + + let result = run_groupadd(&mock_cmd, "testgroup"); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), false); + } +} + diff --git a/crates/userd-rs/src/error.rs b/crates/userd-rs/src/error.rs new file mode 100644 index 00000000..a88fd180 --- /dev/null +++ b/crates/userd-rs/src/error.rs @@ -0,0 +1,54 @@ +//! Error types for userd daemon + +use thiserror::Error; + +/// Errors that can occur in the userd daemon +#[derive(Error, Debug)] +pub enum UserdError { + /// Failed to execute a system command + #[error("Failed to execute command '{command}': {message}")] + CommandError { command: String, message: String }, + + /// User not found in the system + #[error("User not found: {0}")] + UserNotFound(String), + + /// Invalid role specified + #[error("Invalid role: {0}")] + InvalidRole(String), + + /// Failed to read or write a file + #[error("File I/O error for '{path}': {message}")] + FileError { path: String, message: String }, + + /// Database error from swss-common + #[error("Database error: {0}")] + DatabaseError(String), + + /// Configuration error + #[error("Configuration error: {0}")] + ConfigError(String), + + /// Permission denied + #[error("Permission denied: {0}")] + PermissionDenied(String), + + /// Invalid SSH key format + #[error("Invalid SSH key format")] + InvalidSshKey, + + /// System call error (from nix) + #[error("System call error: {0}")] + SystemError(#[from] nix::errno::Errno), + + /// I/O error + #[error("I/O error: {0}")] + IoError(#[from] std::io::Error), + + /// JSON parsing error + #[error("JSON parsing error: {0}")] + JsonError(#[from] serde_json::Error), +} + +/// Result type alias for userd operations +pub type UserdResult = Result; diff --git a/crates/userd-rs/src/lib.rs b/crates/userd-rs/src/lib.rs new file mode 100644 index 00000000..02942d4c --- /dev/null +++ b/crates/userd-rs/src/lib.rs @@ -0,0 +1,42 @@ +//! SONiC User Management Daemon (userd) in Rust +//! +//! This crate provides a daemon for managing local users on SONiC devices. +//! It monitors CONFIG_DB for user configuration changes and synchronizes +//! the system state accordingly. +//! +//! # Features +//! +//! - User creation, modification, and deletion +//! - Role-based group assignment (administrator, operator) +//! - SSH key management +//! - PAM faillock security policies +//! - Consistency checking at startup +//! +//! # Architecture +//! +//! The daemon uses trait-based dependency injection for testability: +//! - `CommandExecutor`: For executing system commands +//! - `SystemFunctions`: For system calls (getpwnam, chown, etc.) +//! - `commands`: Wrapper functions for system commands (useradd, userdel, etc.) + +pub mod commands; +pub mod error; +pub mod system; +pub mod templates; +pub mod types; +pub mod user_manager; + +pub use commands::{ + run_gpasswd_remove, run_groupadd, run_groups, run_pkill, run_useradd, run_userdel, + run_usermod_add_group, run_usermod_password, run_usermod_shell, GpasswdResult, +}; +pub use error::{UserdError, UserdResult}; +pub use system::{ + CommandExecutor, CommandResult, DefaultCommandExecutor, DefaultSystemFunctions, SystemFunctions, +}; + +// Re-export nix types that are part of our public API +pub use nix::unistd::{Gid, Group, Uid, User}; +pub use templates::render_pam_faillock; +pub use types::{SecurityPolicy, UserInfo}; +pub use user_manager::UserManager; diff --git a/crates/userd-rs/src/main.rs b/crates/userd-rs/src/main.rs new file mode 100644 index 00000000..ca0e9e56 --- /dev/null +++ b/crates/userd-rs/src/main.rs @@ -0,0 +1,299 @@ +//! SONiC User Management Daemon (userd) entry point +//! +//! Monitors CONFIG_DB tables and manages local users accordingly. + +use std::collections::HashMap; +use std::ffi::CString; +use std::time::{Duration, Instant}; + +use lazy_static::lazy_static; +use std::sync::Mutex; +use swss_common::{link_to_swsscommon_logger, DbConnector, KeyOperation, LoggerConfigChangeHandler, SubscriberStateTable}; +use syslog_tracing; +use tokio::signal::unix::{signal, SignalKind}; +use tracing::{error, info, warn}; +use tracing_subscriber::{filter, prelude::*, reload, Layer, Registry}; + +use userd_rs::system::{DefaultCommandExecutor, DefaultSystemFunctions}; +use userd_rs::types::*; +use userd_rs::user_manager::UserManager; + +/// Program name used for logging configuration +const PROGRAM_NAME: &str = "userd"; + +/// Default log level for release builds +#[cfg(not(debug_assertions))] +const DEFAULT_LOG_LEVEL: filter::LevelFilter = filter::LevelFilter::INFO; + +/// Default log level for debug builds +#[cfg(debug_assertions)] +const DEFAULT_LOG_LEVEL: filter::LevelFilter = filter::LevelFilter::DEBUG; + +lazy_static! { + /// Global handle to the reload layer for dynamic log level changes + static ref LOG_LEVEL_HANDLE: Mutex>> = Mutex::new(None); +} + +/// Handler for CONFIG_DB Logger table changes +struct LoggerConfigHandler; + +impl LoggerConfigChangeHandler for LoggerConfigHandler { + fn on_log_level_change(&mut self, level: &str) { + let new_level = match level.to_uppercase().as_str() { + "EMERG" | "ALERT" | "CRIT" => filter::LevelFilter::ERROR, + "ERROR" => filter::LevelFilter::ERROR, + "WARNING" | "WARN" => filter::LevelFilter::WARN, + "NOTICE" | "INFO" => filter::LevelFilter::INFO, + "DEBUG" => filter::LevelFilter::DEBUG, + _ => filter::LevelFilter::INFO, + }; + + if let Ok(guard) = LOG_LEVEL_HANDLE.lock() { + if let Some(ref handle) = *guard { + if let Err(e) = handle.modify(|f| *f = new_level) { + eprintln!("Failed to update log level: {}", e); + } else { + info!("Log level changed to: {} (mapped to {:?})", level, new_level); + } + } + } + } + + fn on_log_output_change(&mut self, output: &str) { + // Rust doesn't support dynamically changing log output + // We only support syslog output + if output.to_uppercase() != "SYSLOG" { + info!( + "Log output change to unsupported destination {}. Setting ignored", + output + ); + } + } +} + +/// Initialize logging with support for: +/// 1. RUST_LOG or USERD_LOG_LEVEL environment variables (highest priority) +/// 2. CONFIG_DB Logger table for dynamic log level changes (if env not set) +fn init_logging() -> Result<(), Box> { + let identity = CString::new(PROGRAM_NAME).map_err(|e| format!("invalid identity string: {}", e))?; + let syslog = syslog_tracing::Syslog::new( + identity, + syslog_tracing::Options::LOG_PID, + syslog_tracing::Facility::Daemon, + ) + .ok_or("failed to initialize syslog")?; + + // Check if log level is set via environment variable + let env_log_level = std::env::var("RUST_LOG") + .or_else(|_| std::env::var("USERD_LOG_LEVEL")); + + let log_env_set = env_log_level.is_ok(); + + if log_env_set { + // Use EnvFilter for environment variable based logging + let filter = tracing_subscriber::filter::EnvFilter::try_from_default_env() + .or_else(|_| tracing_subscriber::filter::EnvFilter::try_new( + env_log_level.unwrap_or_else(|_| "info".to_string()) + )) + .unwrap_or_else(|_| tracing_subscriber::filter::EnvFilter::new("info")); + + let syslog_layer = tracing_subscriber::fmt::layer() + .with_writer(syslog) + .with_ansi(false) + .with_target(false) + .with_level(false) + .without_time(); + + tracing_subscriber::registry() + .with(filter.and_then(syslog_layer)) + .init(); + } else { + // Use CONFIG_DB Logger table with reload support for dynamic log level changes + let (level_layer, level_reload_handle) = reload::Layer::new(DEFAULT_LOG_LEVEL); + + // Store the handle globally for the LoggerConfigHandler to use + { + let mut guard = LOG_LEVEL_HANDLE.lock().map_err(|e| format!("Failed to lock: {}", e))?; + *guard = Some(level_reload_handle); + } + + let syslog_layer = tracing_subscriber::fmt::layer() + .with_writer(syslog) + .with_ansi(false) + .with_target(false) + .with_level(false) + .without_time(); + + tracing_subscriber::registry() + .with(level_layer.and_then(syslog_layer)) + .init(); + + // Link to CONFIG_DB Logger table for dynamic log level changes + let handler = LoggerConfigHandler; + if let Err(e) = link_to_swsscommon_logger(PROGRAM_NAME, handler) { + // Don't fail if we can't link to swsscommon logger, just log a warning + eprintln!("Warning: Unable to link to CONFIG_DB Logger table: {}. Using default log level.", e); + } + } + + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize logging with environment variable and CONFIG_DB support + init_logging()?; + + info!("Starting userd daemon"); + + // Check root privileges + if unsafe { libc::getuid() } != 0 { + error!("Must be root to run this daemon"); + std::process::exit(1); + } + + // Setup signal handlers + let mut sigterm = signal(SignalKind::terminate())?; + let mut sigint = signal(SignalKind::interrupt())?; + + // Create system abstractions + let cmd_executor = DefaultCommandExecutor; + let sys_funcs = DefaultSystemFunctions; + + // Create UserManager + let mut user_manager = UserManager::new(cmd_executor, sys_funcs); + + // Create subscriber tables for monitoring + // Each SubscriberStateTable needs its own DbConnector since it takes ownership + let user_db = DbConnector::new_named("CONFIG_DB", false, 0) + .map_err(|e| format!("Failed to connect to CONFIG_DB for user subscription: {}", e))?; + let mut user_table = SubscriberStateTable::new(user_db, LOCAL_USER_TABLE, None, None) + .map_err(|e| format!("Failed to create LOCAL_USER subscriber: {}", e))?; + + let policy_db = DbConnector::new_named("CONFIG_DB", false, 0) + .map_err(|e| format!("Failed to connect to CONFIG_DB for policy subscription: {}", e))?; + let mut policy_table = SubscriberStateTable::new(policy_db, LOCAL_ROLE_SECURITY_POLICY_TABLE, None, None) + .map_err(|e| format!("Failed to create SECURITY_POLICY subscriber: {}", e))?; + + let metadata_db = DbConnector::new_named("CONFIG_DB", false, 0) + .map_err(|e| format!("Failed to connect to CONFIG_DB for metadata subscription: {}", e))?; + let mut metadata_table = SubscriberStateTable::new(metadata_db, DEVICE_METADATA_TABLE, None, None) + .map_err(|e| format!("Failed to create DEVICE_METADATA subscriber: {}", e))?; + + // Initial drain from all SubscriberStateTables + let timer1 = process_entries(&mut user_table, LOCAL_USER_TABLE, |key, data| { + user_manager.handle_user_change(key, data) + }); + let timer2 = process_entries(&mut policy_table, LOCAL_ROLE_SECURITY_POLICY_TABLE, |key, data| { + user_manager.handle_policy_change(key, data) + }); + let timer3 = process_entries(&mut metadata_table, DEVICE_METADATA_TABLE, |key, data| { + user_manager.handle_metadata_change(key, data) + }); + user_manager.set_initial_read_done(); + + // Timer for pending deletion processing + // Start with the earliest timer from initial processing, or 60 seconds + let mut next_deletion_check = [timer1, timer2, timer3] + .iter() + .filter_map(|&t| t) + .min() + .unwrap_or_else(|| Instant::now() + Duration::from_secs(60)); + + // Main event loop - uses async I/O to wait for events efficiently + loop { + tokio::select! { + _ = sigterm.recv() => { + info!("Received SIGTERM, shutting down"); + break; + } + _ = sigint.recv() => { + info!("Received SIGINT, shutting down"); + break; + } + _ = tokio::time::sleep_until(tokio::time::Instant::from_std(next_deletion_check)) => { + next_deletion_check = user_manager.process_pending_deletions(); + } + result = user_table.read_data_async() => { + if let Err(e) = result { + warn!("Failed to read from {}: {}", LOCAL_USER_TABLE, e); + continue; + } + if let Some(timer) = process_entries(&mut user_table, LOCAL_USER_TABLE, |key, data| { + user_manager.handle_user_change(key, data) + }) { + // Update timer if a pending deletion was added + next_deletion_check = next_deletion_check.min(timer); + } + } + result = policy_table.read_data_async() => { + if let Err(e) = result { + warn!("Failed to read from {}: {}", LOCAL_ROLE_SECURITY_POLICY_TABLE, e); + continue; + } + let _ = process_entries(&mut policy_table, LOCAL_ROLE_SECURITY_POLICY_TABLE, |key, data| { + user_manager.handle_policy_change(key, data) + }); + } + result = metadata_table.read_data_async() => { + if let Err(e) = result { + warn!("Failed to read from {}: {}", DEVICE_METADATA_TABLE, e); + continue; + } + let _ = process_entries(&mut metadata_table, DEVICE_METADATA_TABLE, |key, data| { + user_manager.handle_metadata_change(key, data) + }); + } + } + } + + info!("userd daemon shutdown complete"); + Ok(()) +} + +/// Extract entry data from a SubscriberStateTable entry +fn extract_entry_data(entry: &swss_common::KeyOpFieldValues) -> HashMap { + if entry.operation == KeyOperation::Del { + HashMap::new() + } else { + entry + .field_values + .iter() + .map(|(k, v)| (k.clone(), v.to_string_lossy().into_owned())) + .collect() + } +} + +/// Process entries from a SubscriberStateTable using the provided handler +/// Returns the minimum timer value from all handlers, if any +fn process_entries(table: &mut SubscriberStateTable, table_name: &str, mut handler: F) -> Option +where + F: FnMut(&str, &HashMap) -> userd_rs::UserdResult>, +{ + let mut min_timer: Option = None; + + match table.pops() { + Ok(entries) => { + for entry in entries { + let data = extract_entry_data(&entry); + match handler(&entry.key, &data) { + Ok(Some(timer)) => { + min_timer = Some(match min_timer { + Some(current) => current.min(timer), + None => timer, + }); + } + Ok(None) => {} + Err(e) => { + error!("Failed to handle {} change for {}: {}", table_name, entry.key, e); + } + } + } + } + Err(e) => { + warn!("Failed to pop entries from {}: {}", table_name, e); + } + } + + min_timer +} diff --git a/crates/userd-rs/src/system.rs b/crates/userd-rs/src/system.rs new file mode 100644 index 00000000..80566f24 --- /dev/null +++ b/crates/userd-rs/src/system.rs @@ -0,0 +1,195 @@ +//! System abstraction layer for dependency injection +//! +//! Provides traits for system operations to enable testing + +use std::collections::HashSet; +use std::path::Path; +use std::process::{Command, ExitStatus}; + +use nix::unistd::{Gid, Group, Uid, User}; +use tracing::{debug, error}; + +use crate::error::{UserdError, UserdResult}; + +#[cfg(test)] +use mockall::automock; + +/// Result of executing a command, including the output and the command string +pub struct CommandResult { + pub status: ExitStatus, + pub stdout: Vec, + pub stderr: Vec, + pub command_str: String, +} + +impl CommandResult { + /// Check if the command succeeded (exit code 0) + pub fn success(&self) -> bool { + self.status.success() + } + + /// Get the exit code of the command + pub fn code(&self) -> Option { + self.status.code() + } + + /// Get stderr as a UTF-8 string (lossy conversion) + pub fn stderr_str(&self) -> String { + String::from_utf8_lossy(&self.stderr).to_string() + } + + /// Get stdout as a UTF-8 string (lossy conversion) + pub fn stdout_str(&self) -> String { + String::from_utf8_lossy(&self.stdout).to_string() + } + + /// Convert a failed command result to a CommandError + pub fn to_error(self) -> UserdError { + let message = String::from_utf8_lossy(&self.stderr).to_string(); + UserdError::CommandError { + command: self.command_str, + message, + } + } +} + +/// Trait for executing system commands +#[cfg_attr(test, automock)] +pub trait CommandExecutor: Send + Sync { + /// Execute a command and return the result with command string + fn execute(&self, cmd: Vec) -> UserdResult; + + /// Execute a command with masked arguments (for logging sensitive data) + fn execute_masked(&self, cmd: Vec, mask_indices: HashSet) -> UserdResult; +} + +/// Trait for system functions (file operations, user lookups) +#[cfg_attr(test, automock)] +pub trait SystemFunctions: Send + Sync { + /// Get password entry for a user by name + fn getpwnam(&self, name: &str) -> Option; + + /// Get group entry for a group by name + fn getgrnam(&self, name: &str) -> Option; + + /// Change file ownership + fn chown(&self, path: &Path, uid: Uid, gid: Gid) -> UserdResult<()>; + + /// Change file permissions + fn chmod(&self, path: &Path, mode: u32) -> UserdResult<()>; + + /// Check file access + fn access(&self, path: &Path, mode: i32) -> bool; + + /// Remove a file + fn unlink(&self, path: &Path) -> UserdResult<()>; +} + +pub struct DefaultCommandExecutor; + +impl CommandExecutor for DefaultCommandExecutor { + fn execute(&self, cmd: Vec) -> UserdResult { + self.execute_masked(cmd, HashSet::new()) + } + + fn execute_masked(&self, cmd: Vec, mask_indices: HashSet) -> UserdResult { + if cmd.is_empty() { + return Err(UserdError::CommandError { + command: String::new(), + message: "Empty command".to_string(), + }); + } + + // Build command string for logging with sensitive arguments masked + let command_str: String = cmd + .iter() + .enumerate() + .map(|(i, arg)| { + if mask_indices.contains(&i) { + "***".to_string() + } else { + arg.clone() + } + }) + .collect::>() + .join(" "); + + debug!("Executing command: {}", command_str); + + let output = Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| { + UserdError::CommandError { + command: command_str.clone(), + message: e.to_string(), + } + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + error!( + "Command failed with status {:?}: {} - {}", + output.status.code(), + command_str, + stderr + ); + } + + Ok(CommandResult { + status: output.status, + stdout: output.stdout, + stderr: output.stderr, + command_str, + }) + } +} + +pub struct DefaultSystemFunctions; + +impl SystemFunctions for DefaultSystemFunctions { + fn getpwnam(&self, name: &str) -> Option { + match nix::unistd::User::from_name(name) { + Ok(user) => user, + Err(e) => { + error!("getpwnam failed for '{}': {}", name, e); + None + } + } + } + + fn getgrnam(&self, name: &str) -> Option { + match nix::unistd::Group::from_name(name) { + Ok(group) => group, + Err(e) => { + error!("getgrnam failed for '{}': {}", name, e); + None + } + } + } + + fn chown(&self, path: &Path, uid: Uid, gid: Gid) -> UserdResult<()> { + nix::unistd::chown(path, Some(uid), Some(gid))?; + Ok(()) + } + + fn chmod(&self, path: &Path, mode: u32) -> UserdResult<()> { + use nix::sys::stat::Mode; + let mode = Mode::from_bits_truncate(mode); + nix::sys::stat::fchmodat( + None, + path, + mode, + nix::sys::stat::FchmodatFlags::FollowSymlink, + )?; + Ok(()) + } + + fn access(&self, path: &Path, mode: i32) -> bool { + use nix::unistd::AccessFlags; + let flags = AccessFlags::from_bits_truncate(mode); + nix::unistd::access(path, flags).is_ok() + } + + fn unlink(&self, path: &Path) -> UserdResult<()> { + std::fs::remove_file(path)?; + Ok(()) + } +} diff --git a/crates/userd-rs/src/templates.rs b/crates/userd-rs/src/templates.rs new file mode 100644 index 00000000..ee468dc2 --- /dev/null +++ b/crates/userd-rs/src/templates.rs @@ -0,0 +1,491 @@ +//! Template rendering for userd configuration files +//! +//! Uses j2cli for Jinja2 template rendering + +use std::fs; +use std::io::Write; +use std::process::Command; + +use serde_json::json; +use tempfile::NamedTempFile; +use tracing::{debug, error, info}; + +use crate::error::{UserdError, UserdResult}; +use crate::types::{ + SecurityPolicy, CMD_CHMOD, CMD_J2CLI, PAM_AUTH_FAILLOCK_AUTHFAIL_CONF, + PAM_AUTH_FAILLOCK_AUTHFAIL_TEMPLATE, PAM_AUTH_FAILLOCK_CONF, PAM_AUTH_FAILLOCK_TEMPLATE, + PAM_FAILLOCK_CONF, PAM_FAILLOCK_TEMPLATE, +}; + +/// Build JSON template data for PAM faillock configuration +/// +/// Creates the security_policies JSON structure expected by the faillock.conf.j2 template. +fn build_faillock_template_data( + policies: &std::collections::HashMap, +) -> serde_json::Value { + let mut policy_data = serde_json::Map::new(); + + for policy in policies.values() { + let mut role_data = serde_json::Map::new(); + role_data.insert( + "max_login_attempts".to_string(), + json!(policy.max_login_attempts), + ); + policy_data.insert(policy.role.clone(), serde_json::Value::Object(role_data)); + } + + json!({ + "security_policies": policy_data + }) +} + +/// Build JSON template data for PAM auth faillock configuration +/// +/// Creates the template data with faillock_enabled flag and deny values for each role. +/// Uses defaults of 5 for missing roles or when max_login_attempts is 0. +fn build_auth_faillock_template_data( + policies: &std::collections::HashMap, +) -> serde_json::Value { + let faillock_enabled = !policies.is_empty(); + + // Extract deny values for each role, with defaults + let administrator_deny = policies + .get("administrator") + .map(|p| p.max_login_attempts) + .filter(|&v| v > 0) + .unwrap_or(5); + + let operator_deny = policies + .get("operator") + .map(|p| p.max_login_attempts) + .filter(|&v| v > 0) + .unwrap_or(5); + + json!({ + "faillock_enabled": faillock_enabled, + "administrator_deny": administrator_deny, + "operator_deny": operator_deny + }) +} + +/// Render PAM faillock configuration from security policies +/// +/// Creates a JSON file with security policies and renders the faillock.conf.j2 +/// template using j2cli. +pub fn render_pam_faillock( + policies: &std::collections::HashMap, +) -> UserdResult<()> { + let template_data = build_faillock_template_data(policies); + + let temp_file = write_json_to_temp_file(&template_data, "security policies JSON")?; + let rendered_content = render_template_with_j2(PAM_FAILLOCK_TEMPLATE, &temp_file)?; + write_file_with_permissions(PAM_FAILLOCK_CONF, &rendered_content, 0o644)?; + + info!("Updated PAM faillock configuration using template"); + + render_pam_auth_faillock(policies)?; + + Ok(()) +} + +/// Render PAM auth faillock configuration for common-auth integration +/// +/// This generates /etc/pam.d/common-auth-faillock which is included via +/// substack from common-auth-sonic. When faillock is enabled, it adds +/// the pam_faillock.so preauth module with role-based deny values. +/// +/// The PAM configuration: +/// 1. Skips faillock for non-managed users (not in local_mgd group) +/// 2. Applies administrator policy (deny=N) for users in sudo group +/// 3. Applies operator policy (deny=N) for users not in sudo group +pub fn render_pam_auth_faillock( + policies: &std::collections::HashMap, +) -> UserdResult<()> { + let template_data = build_auth_faillock_template_data(policies); + + let faillock_enabled = template_data["faillock_enabled"].as_bool().unwrap_or(false); + let administrator_deny = template_data["administrator_deny"].as_u64().unwrap_or(5); + let operator_deny = template_data["operator_deny"].as_u64().unwrap_or(5); + + let temp_file = write_json_to_temp_file(&template_data, "PAM auth faillock JSON")?; + let rendered_content = render_template_with_j2(PAM_AUTH_FAILLOCK_TEMPLATE, &temp_file)?; + write_file_with_permissions(PAM_AUTH_FAILLOCK_CONF, &rendered_content, 0o644)?; + + info!( + "Updated PAM auth faillock preauth configuration (enabled={}, admin_deny={}, operator_deny={})", + faillock_enabled, administrator_deny, operator_deny + ); + + render_pam_auth_faillock_authfail(&template_data)?; + + Ok(()) +} + +/// Render PAM auth faillock authfail configuration +/// +/// This generates /etc/pam.d/common-auth-faillock-authfail which is included via +/// substack from common-auth-sonic AFTER authentication modules. When authentication +/// fails, this records the failed attempt in the faillock database. +fn render_pam_auth_faillock_authfail(template_data: &serde_json::Value) -> UserdResult<()> { + let temp_file = write_json_to_temp_file(template_data, "PAM auth faillock authfail JSON")?; + let rendered_content = render_template_with_j2(PAM_AUTH_FAILLOCK_AUTHFAIL_TEMPLATE, &temp_file)?; + write_file_with_permissions(PAM_AUTH_FAILLOCK_AUTHFAIL_CONF, &rendered_content, 0o644)?; + + info!("Updated PAM auth faillock authfail configuration"); + Ok(()) +} + +/// Write JSON data to a temporary file +/// +/// Creates a secure temporary file with "userd_" prefix and writes the JSON data to it. +/// The file is automatically cleaned up when the NamedTempFile is dropped. +/// +/// Returns the NamedTempFile which must be kept alive while the file is needed. +fn write_json_to_temp_file( + template_data: &serde_json::Value, + description: &str, +) -> UserdResult { + let mut temp_file = NamedTempFile::with_prefix("userd_").map_err(|e| UserdError::FileError { + path: "/tmp".to_string(), + message: format!("Failed to create temp file: {}", e), + })?; + + let json_str = serde_json::to_string(template_data)?; + temp_file + .write_all(json_str.as_bytes()) + .map_err(|e| UserdError::FileError { + path: temp_file.path().display().to_string(), + message: e.to_string(), + })?; + + debug!("Wrote {} to {}", description, temp_file.path().display()); + + Ok(temp_file) +} + +/// Render a Jinja2 template using j2cli with JSON data +/// +/// Takes a template file path and a temporary JSON data file, renders the template, +/// and returns the rendered content as a String. +fn render_template_with_j2( + template_path: &str, + json_file: &NamedTempFile, +) -> UserdResult { + let temp_path = json_file.path(); + let output = Command::new(CMD_J2CLI) + .arg("--format=json") + .arg(template_path) + .arg(temp_path) + .output() + .map_err(|e| UserdError::CommandError { + command: format!( + "{} --format=json {} {}", + CMD_J2CLI, + template_path, + temp_path.display() + ), + message: e.to_string(), + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + error!("j2 template rendering failed for {}: {}", template_path, stderr); + return Err(UserdError::CommandError { + command: CMD_J2CLI.to_string(), + message: format!("Template rendering failed for {}: {}", template_path, stderr), + }); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +/// Write content to a file and set its permissions +/// +/// Writes the content to the specified path and sets the file permissions using chmod. +fn write_file_with_permissions( + path: &str, + content: &str, + mode: u32, +) -> UserdResult<()> { + fs::write(path, content.as_bytes()).map_err(|e| UserdError::FileError { + path: path.to_string(), + message: e.to_string(), + })?; + + set_file_permissions(path, mode)?; + + Ok(()) +} + +/// Set file permissions using chmod command +fn set_file_permissions(path: &str, mode: u32) -> UserdResult<()> { + let mode_str = format!("{:o}", mode); + let output = Command::new(CMD_CHMOD) + .arg(&mode_str) + .arg(path) + .output() + .map_err(|e| UserdError::CommandError { + command: format!("{} {} {}", CMD_CHMOD, mode_str, path), + message: e.to_string(), + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(UserdError::CommandError { + command: format!("{} {} {}", CMD_CHMOD, mode_str, path), + message: stderr.to_string(), + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + // ========== Tests for build_faillock_template_data() ========== + + #[test] + fn test_faillock_json_with_single_policy() { + let mut policies = HashMap::new(); + policies.insert( + "administrator".to_string(), + SecurityPolicy { + role: "administrator".to_string(), + max_login_attempts: 3, + }, + ); + + let template_data = build_faillock_template_data(&policies); + + assert_eq!( + template_data["security_policies"]["administrator"]["max_login_attempts"], + 3 + ); + } + + #[test] + fn test_faillock_json_with_multiple_policies() { + let mut policies = HashMap::new(); + policies.insert( + "administrator".to_string(), + SecurityPolicy { + role: "administrator".to_string(), + max_login_attempts: 3, + }, + ); + policies.insert( + "operator".to_string(), + SecurityPolicy { + role: "operator".to_string(), + max_login_attempts: 5, + }, + ); + + let template_data = build_faillock_template_data(&policies); + + assert_eq!( + template_data["security_policies"]["administrator"]["max_login_attempts"], + 3 + ); + assert_eq!( + template_data["security_policies"]["operator"]["max_login_attempts"], + 5 + ); + } + + #[test] + fn test_faillock_json_with_empty_policies() { + let policies = HashMap::new(); + + let template_data = build_faillock_template_data(&policies); + + assert!(template_data["security_policies"].is_object()); + assert_eq!( + template_data["security_policies"] + .as_object() + .unwrap() + .len(), + 0 + ); + } + + // ========== Tests for build_auth_faillock_template_data() ========== + + #[test] + fn test_auth_faillock_json_enabled_when_policies_exist() { + let mut policies = HashMap::new(); + policies.insert( + "administrator".to_string(), + SecurityPolicy { + role: "administrator".to_string(), + max_login_attempts: 3, + }, + ); + + let template_data = build_auth_faillock_template_data(&policies); + + assert_eq!(template_data["faillock_enabled"], true); + } + + #[test] + fn test_auth_faillock_json_disabled_when_policies_empty() { + let policies = HashMap::new(); + + let template_data = build_auth_faillock_template_data(&policies); + + assert_eq!(template_data["faillock_enabled"], false); + } + + #[test] + fn test_auth_faillock_json_uses_default_deny_when_missing() { + let policies = HashMap::new(); + + let template_data = build_auth_faillock_template_data(&policies); + + // Should use default of 5 when roles are missing + assert_eq!(template_data["administrator_deny"], 5); + assert_eq!(template_data["operator_deny"], 5); + } + + #[test] + fn test_auth_faillock_json_uses_default_deny_when_zero() { + let mut policies = HashMap::new(); + policies.insert( + "administrator".to_string(), + SecurityPolicy { + role: "administrator".to_string(), + max_login_attempts: 0, // Disabled + }, + ); + policies.insert( + "operator".to_string(), + SecurityPolicy { + role: "operator".to_string(), + max_login_attempts: 0, // Disabled + }, + ); + + let template_data = build_auth_faillock_template_data(&policies); + + // Should use default of 5 when max_login_attempts is 0 + assert_eq!(template_data["administrator_deny"], 5); + assert_eq!(template_data["operator_deny"], 5); + } + + #[test] + fn test_auth_faillock_json_uses_custom_deny_values() { + let mut policies = HashMap::new(); + policies.insert( + "administrator".to_string(), + SecurityPolicy { + role: "administrator".to_string(), + max_login_attempts: 3, + }, + ); + policies.insert( + "operator".to_string(), + SecurityPolicy { + role: "operator".to_string(), + max_login_attempts: 7, + }, + ); + + let template_data = build_auth_faillock_template_data(&policies); + + assert_eq!(template_data["administrator_deny"], 3); + assert_eq!(template_data["operator_deny"], 7); + } + + #[test] + fn test_auth_faillock_json_mixed_zero_and_custom() { + let mut policies = HashMap::new(); + policies.insert( + "administrator".to_string(), + SecurityPolicy { + role: "administrator".to_string(), + max_login_attempts: 0, // Disabled - should use default + }, + ); + policies.insert( + "operator".to_string(), + SecurityPolicy { + role: "operator".to_string(), + max_login_attempts: 10, // Custom value + }, + ); + + let template_data = build_auth_faillock_template_data(&policies); + + assert_eq!(template_data["administrator_deny"], 5); // Default + assert_eq!(template_data["operator_deny"], 10); // Custom + } + + // ========== Tests for write_json_to_temp_file() ========== + + #[test] + fn test_write_json_to_temp_file_creates_file() { + let data = json!({"test": "value"}); + + let temp_file = write_json_to_temp_file(&data, "test data").unwrap(); + + // Verify file exists + assert!(temp_file.path().exists()); + } + + #[test] + fn test_write_json_to_temp_file_contains_valid_json() { + let data = json!({ + "key1": "value1", + "key2": 42, + "key3": true + }); + + let temp_file = write_json_to_temp_file(&data, "test data").unwrap(); + + // Read the file content + let content = fs::read_to_string(temp_file.path()).unwrap(); + + // Verify it contains the expected JSON + assert!(content.contains("\"key1\"")); + assert!(content.contains("\"value1\"")); + assert!(content.contains("\"key2\"")); + assert!(content.contains("42")); + assert!(content.contains("\"key3\"")); + assert!(content.contains("true")); + + // Verify it's valid JSON by parsing it back + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(parsed["key1"], "value1"); + assert_eq!(parsed["key2"], 42); + assert_eq!(parsed["key3"], true); + } + + #[test] + fn test_write_json_to_temp_file_cleanup_on_drop() { + let data = json!({"test": "value"}); + let path; + + { + let temp_file = write_json_to_temp_file(&data, "test data").unwrap(); + path = temp_file.path().to_path_buf(); + assert!(path.exists()); + } // temp_file is dropped here + + // Verify the file was cleaned up + assert!(!path.exists()); + } + + #[test] + fn test_write_json_to_temp_file_has_userd_prefix() { + let data = json!({"test": "value"}); + + let temp_file = write_json_to_temp_file(&data, "test data").unwrap(); + + // Verify the filename has the "userd_" prefix + let filename = temp_file.path().file_name().unwrap().to_str().unwrap(); + assert!(filename.starts_with("userd_")); + } +} diff --git a/crates/userd-rs/src/types.rs b/crates/userd-rs/src/types.rs new file mode 100644 index 00000000..b078c375 --- /dev/null +++ b/crates/userd-rs/src/types.rs @@ -0,0 +1,168 @@ +//! Types and data structures for userd daemon + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::LazyLock; +use std::time::Instant; + +/// Constants for database tables +pub const LOCAL_USER_TABLE: &str = "LOCAL_USER"; +pub const LOCAL_ROLE_SECURITY_POLICY_TABLE: &str = "LOCAL_ROLE_SECURITY_POLICY"; +pub const DEVICE_METADATA_TABLE: &str = "DEVICE_METADATA"; +pub const DEVICE_METADATA_LOCALHOST_KEY: &str = "localhost"; +pub const LOCAL_USER_MANAGEMENT_FIELD: &str = "local_user_management"; + +/// PAM faillock configuration paths +pub const PAM_FAILLOCK_CONF: &str = "/etc/security/faillock.conf"; +pub const PAM_FAILLOCK_TEMPLATE: &str = "/usr/share/sonic/templates/faillock.conf.j2"; + +/// PAM auth faillock configuration (for common-auth integration) +pub const PAM_AUTH_FAILLOCK_CONF: &str = "/etc/pam.d/common-auth-faillock"; +pub const PAM_AUTH_FAILLOCK_TEMPLATE: &str = "/usr/share/sonic/templates/common-auth-faillock.j2"; + +/// PAM auth faillock authfail configuration (records failed attempts) +pub const PAM_AUTH_FAILLOCK_AUTHFAIL_CONF: &str = "/etc/pam.d/common-auth-faillock-authfail"; +pub const PAM_AUTH_FAILLOCK_AUTHFAIL_TEMPLATE: &str = + "/usr/share/sonic/templates/common-auth-faillock-authfail.j2"; + +/// Group for tracking users managed by userd +pub const MANAGED_USER_GROUP: &str = "local_mgd"; + +/// UID range for managed users +pub const MIN_USER_UID: u32 = 1000; +pub const MAX_USER_UID: u32 = 60000; + +/// Shell paths +pub const NOLOGIN_SHELL: &str = "/usr/sbin/nologin"; +pub const DEFAULT_SHELL: &str = "/bin/bash"; + +/// System command paths +/// Following the pattern from sonic-swss/cfgmgr/shellcmd.h +pub const CMD_USERADD: &str = "/usr/sbin/useradd"; +pub const CMD_USERDEL: &str = "/usr/sbin/userdel"; +pub const CMD_USERMOD: &str = "/usr/sbin/usermod"; +pub const CMD_GROUPADD: &str = "/usr/sbin/groupadd"; +pub const CMD_GPASSWD: &str = "/usr/bin/gpasswd"; +pub const CMD_GROUPS: &str = "/usr/bin/groups"; +pub const CMD_PKILL: &str = "/usr/bin/pkill"; +pub const CMD_J2CLI: &str = "/usr/bin/j2"; +pub const CMD_CHMOD: &str = "/usr/bin/chmod"; + +/// System users to exclude from management +pub static SYSTEM_USERS: LazyLock> = LazyLock::new(|| { + [ + "root", + "daemon", + "bin", + "sys", + "sync", + "games", + "man", + "lp", + "mail", + "news", + "uucp", + "proxy", + "www-data", + "backup", + "list", + "irc", + "gnats", + "nobody", + "_apt", + "systemd-network", + "systemd-resolve", + "messagebus", + "systemd-timesync", + "sshd", + "redis", + "ntp", + "frr", + "snmp", + ] + .into_iter() + .collect() +}); + +/// Role to group mappings +pub static ROLE_GROUPS: LazyLock>> = LazyLock::new(|| { + let mut m = HashMap::new(); + m.insert("administrator", vec!["sudo", "docker", "redis", "admin"]); + m.insert("operator", vec!["users"]); + m +}); + +/// Information about a user account +#[derive(Clone, Debug, Default)] +pub struct UserInfo { + /// Username + pub username: String, + /// User's role (administrator, operator) + pub role: String, + /// Hashed password (from /etc/shadow format) + pub password_hash: String, + /// SSH public keys + pub ssh_keys: Vec, + /// Whether the user account is enabled + pub enabled: bool, + /// User ID + pub uid: u32, + /// Group ID + pub gid: u32, + /// Home directory path + pub home_dir: PathBuf, + /// Login shell + pub shell: String, +} + +impl UserInfo { + /// Compare configuration fields (excluding identity fields like uid, gid, home_dir) + pub fn config_equals(&self, other: &UserInfo) -> bool { + self.role == other.role + && self.password_hash == other.password_hash + && self.ssh_keys == other.ssh_keys + && self.enabled == other.enabled + && self.shell == other.shell + } +} + +impl PartialEq for UserInfo { + fn eq(&self, other: &Self) -> bool { + self.config_equals(other) + } +} + +/// Security policy for a role +#[derive(Clone, Debug, Default)] +pub struct SecurityPolicy { + /// Role name + pub role: String, + /// Maximum login attempts before lockout + pub max_login_attempts: i32, +} + +/// State of a pending user deletion +#[derive(Debug, Clone, PartialEq)] +pub enum DeletionState { + /// Initial state - will attempt to send SIGTERM + Initial, + /// SIGTERM sent to user processes, waiting for grace period + TermSent, + /// SIGKILL sent to user processes, waiting for processes to die + KillSent, + /// Maximum retries exceeded, manual intervention required + Failed, +} + +/// Pending deletion entry for tracking failed user deletions +#[derive(Debug, Clone)] +pub struct PendingDeletion { + /// Username to delete + pub username: String, + /// Current state in the deletion state machine + pub state: DeletionState, + /// When to perform the next action + pub next_action_time: Instant, + /// Number of retry attempts + pub retry_count: u32, +} diff --git a/crates/userd-rs/src/user_manager.rs b/crates/userd-rs/src/user_manager.rs new file mode 100644 index 00000000..eb5e1aa0 --- /dev/null +++ b/crates/userd-rs/src/user_manager.rs @@ -0,0 +1,2461 @@ +//! User Manager - Core user management functionality +//! +//! Handles user creation, deletion, updates, and consistency checking. + +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use tracing::{debug, error, info, warn}; + +use crate::commands::{ + run_gpasswd_remove, run_groupadd, run_groups, run_useradd, run_userdel, run_usermod_add_group, + run_usermod_password, run_usermod_shell, GpasswdResult, +}; +use crate::error::{UserdError, UserdResult}; +use crate::system::{CommandExecutor, SystemFunctions}; +use crate::templates::render_pam_faillock; +use crate::types::*; + +/// User Manager - handles all user management operations +pub struct UserManager { + /// Command executor for running system commands + cmd_executor: C, + /// System functions for file/user operations + sys_funcs: S, + /// Cached users from CONFIG_DB + users: HashMap, + /// Cached security policies + security_policies: HashMap, + /// Whether local user management feature is enabled + feature_enabled: bool, + /// Whether initial sync done + initial_read_done: bool, + /// Pending user deletions (username -> PendingDeletion) + pending_deletions: HashMap, +} + +impl UserManager { + /// Create a new UserManager instance + pub fn new(cmd_executor: C, sys_funcs: S) -> Self { + UserManager { + cmd_executor, + sys_funcs, + users: HashMap::new(), + security_policies: HashMap::new(), + feature_enabled: false, + initial_read_done: false, + pending_deletions: HashMap::new(), + } + } + + /// Load configuration from CONFIG_DB + pub fn handle_feature_config(&mut self) -> UserdResult<()> { + if !self.is_feature_enabled() { + info!("Local user management feature is disabled"); + self.cleanup_managed_state(); + return Ok(()); + } + + info!( + "Cache has {} users and {} security policies", + self.users.len(), + self.security_policies.len() + ); + + // Update all cached users in the system + for (username, user) in &self.users.clone() { + let existing_users = self.get_existing_users(); + if existing_users.contains_key(username) { + self.update_user(username, user)?; + } else { + self.create_user(username, user)?; + } + } + + if let Err(e) = self.perform_consistency_check() { + error!("Failed initial consistency check: {}", e); + } + if let Err(e) = self.update_security_policies() { + error!("Failed to update security policies: {}", e); + } + Ok(()) + } + + /// Parse SSH keys from JSON string + fn parse_ssh_keys(&self, value: &str) -> Vec { + if value.is_empty() { + return Vec::new(); + } + + // SSH keys are stored as JSON array + match serde_json::from_str::>(value) { + Ok(keys) => keys.into_iter().filter(|k| !k.is_empty()).collect(), + Err(_) => { + // Try parsing as comma-separated string + value + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + } + } + } + + /// Check if feature is enabled + pub fn is_feature_enabled(&self) -> bool { + self.feature_enabled + } + + /// Get mutable reference to feature_enabled for config change handling + pub fn set_feature_enabled(&mut self, enabled: bool) { + self.feature_enabled = enabled; + } + + pub fn set_initial_read_done(&mut self) { + self.initial_read_done = true; + } + + /// Get existing system users + pub fn get_existing_users(&self) -> HashMap { + let mut users = HashMap::new(); + + // Parse /etc/passwd to get all users + let passwd_path = Path::new("/etc/passwd"); + let file = match File::open(passwd_path) { + Ok(f) => f, + Err(e) => { + warn!("Failed to open /etc/passwd: {} - cannot enumerate existing users", e); + return users; + } + }; + + let reader = BufReader::new(file); + for line in reader.lines().map_while(Result::ok) { + if let Some(user) = self.parse_passwd_line(&line) { + // Skip system users by name and users outside our UID range + if SYSTEM_USERS.contains(user.username.as_str()) { + continue; + } + if user.uid >= MIN_USER_UID && user.uid <= MAX_USER_UID { + users.insert(user.username.clone(), user); + } + } + } + + users + } + + /// Parse a line from /etc/passwd + fn parse_passwd_line(&self, line: &str) -> Option { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() < 7 { + return None; + } + + let username = parts[0].to_string(); + let uid: u32 = parts[2].parse().ok()?; + let gid: u32 = parts[3].parse().ok()?; + let home_dir = PathBuf::from(parts[5]); + let shell = parts[6].to_string(); + + // Get additional info from shadow file and groups + let password_hash = self.get_shadow_hash(&username).unwrap_or_default(); + let role = self.determine_user_role(&username); + let ssh_keys = self.read_user_ssh_keys(&username, &home_dir); + let enabled = shell != NOLOGIN_SHELL; + + Some(UserInfo { + username, + role, + password_hash, + ssh_keys, + enabled, + uid, + gid, + home_dir, + shell, + }) + } + + /// Get password hash from /etc/shadow + fn get_shadow_hash(&self, username: &str) -> Option { + let shadow_path = Path::new("/etc/shadow"); + let file = File::open(shadow_path).ok()?; + let reader = BufReader::new(file); + + for line in reader.lines().map_while(Result::ok) { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() >= 2 && parts[0] == username { + return Some(parts[1].to_string()); + } + } + + None + } + + /// Determine user role based on group membership + /// + /// Checks each role to see if user has ALL required groups for that role. + /// Returns empty string if no role matches. + fn determine_user_role(&self, username: &str) -> String { + let user_groups = self.get_user_groups(username); + let user_groups_set: HashSet<&str> = user_groups.iter().map(|s| s.as_str()).collect(); + + // Check each role to see if user has all required groups for that role + for (role, required_groups) in ROLE_GROUPS.iter() { + let has_all_groups = required_groups + .iter() + .all(|group| user_groups_set.contains(group)); + + if has_all_groups { + return role.to_string(); + } + } + + // If no role matches, return empty string + String::new() + } + + /// Check if user is in a specific group + pub fn is_user_in_group(&self, username: &str, group: &str) -> bool { + let user_groups = self.get_user_groups(username); + user_groups.contains(&group.to_string()) + } + + /// Get groups for a user + fn get_user_groups(&self, username: &str) -> Vec { + run_groups(&self.cmd_executor, username) + } + + /// Read SSH keys from user's authorized_keys file + fn read_user_ssh_keys(&self, _username: &str, home_dir: &Path) -> Vec { + let auth_keys_path = home_dir.join(".ssh/authorized_keys"); + if let Ok(content) = fs::read_to_string(&auth_keys_path) { + return content + .lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + .map(|l| l.to_string()) + .collect(); + } + Vec::new() + } + + /// Check if a user is managed by userd + pub fn is_user_managed(&self, username: &str) -> bool { + self.is_user_in_group(username, MANAGED_USER_GROUP) + } + + /// Create a new user + pub fn create_user(&mut self, username: &str, user_config: &UserInfo) -> UserdResult { + info!("Creating user: {}", username); + + // Check if user already exists + if self.sys_funcs.getpwnam(username).is_some() { + warn!("User {} already exists", username); + return Ok(false); + } + + // Validate role + if !user_config.role.is_empty() && !ROLE_GROUPS.contains_key(user_config.role.as_str()) { + error!("Invalid role '{}' for user {}", user_config.role, username); + return Err(UserdError::InvalidRole(user_config.role.clone())); + } + + // Create user with useradd - let the system assign UID automatically + // (avoids potential conflicts with RADIUS/TACACS user creation) + let home_dir = format!("/home/{}", username); + let shell = if user_config.enabled { + DEFAULT_SHELL + } else { + NOLOGIN_SHELL + }; + + run_useradd(&self.cmd_executor, username, &home_dir, shell)?; + + // User created successfully - now configure it + // If configuration fails, clean up the created user + if let Err(e) = self.setup_user_configuration(username, user_config) { + warn!( + "User configuration failed, cleaning up user {}: {}", + username, e + ); + let _ = self.delete_user(username); // Ignore timer return on cleanup + return Err(e); + } + + info!("Successfully created user: {}", username); + Ok(true) + } + + /// Setup user configuration after user creation + /// + /// Sets password, groups, SSH keys. + /// Returns error if critical setup fails. + fn setup_user_configuration(&self, username: &str, user_config: &UserInfo) -> UserdResult<()> { + self.set_user_password(username, &user_config.password_hash)?; + self.set_user_groups(username, &user_config.role)?; + + if !user_config.ssh_keys.is_empty() { + self.setup_ssh_keys(username, &user_config.ssh_keys)?; + } + + Ok(()) + } + + /// Delete a user + /// Returns Ok((deleted, next_timer)) where: + /// - deleted: true if user was deleted or queued for deletion + /// - next_timer: Some(Instant) if a pending deletion was added, None otherwise + pub fn delete_user(&mut self, username: &str) -> UserdResult<(bool, Option)> { + info!("Deleting user: {}", username); + + // Check if user exists + if self.sys_funcs.getpwnam(username).is_none() { + warn!("User {} does not exist", username); + return Ok((false, None)); + } + + // Try to remove user with userdel + match run_userdel(&self.cmd_executor, username) { + Ok(_) => { + info!("Successfully deleted user: {}", username); + Ok((true, None)) + } + Err(UserdError::CommandError { ref message, .. }) + if message.contains("currently used by process") => + { + // User has active processes - add to pending deletion queue + warn!( + "User {} has active processes. Adding to pending deletion queue.", + username + ); + let next_timer = self.add_pending_deletion(username); + Ok((true, Some(next_timer))) // Will be retried via pending deletion mechanism + } + Err(e) => Err(e), + } + } + + /// Add a user to the pending deletion queue + /// Returns the next action time for timer scheduling + fn add_pending_deletion(&mut self, username: &str) -> Instant { + use crate::types::{DeletionState, PendingDeletion}; + + let next_action_time = Instant::now() + Duration::from_secs(5); + let pending = PendingDeletion { + username: username.to_string(), + state: DeletionState::Initial, + next_action_time, + retry_count: 0, + }; + + self.pending_deletions.insert(username.to_string(), pending); + info!("Added user {} to pending deletion queue", username); + next_action_time + } + + /// Process pending user deletions - returns next check time + /// + /// This implements a state machine for gracefully deleting users with active processes: + /// 1. Initial → Send SIGTERM → wait 5 seconds → TermSent + /// 2. TermSent → Send SIGKILL → wait 2 seconds → KillSent + /// 3. KillSent → Retry userdel → Success? Remove from queue : Retry (max 3 times) + /// + /// Returns the next time this function should be called + pub fn process_pending_deletions(&mut self) -> Instant { + use crate::commands::run_pkill; + use crate::types::DeletionState; + + let now = Instant::now(); + let mut to_remove = Vec::new(); + + for (username, pending) in self.pending_deletions.iter_mut() { + // Skip if not time yet + if now < pending.next_action_time { + continue; + } + + match pending.state { + DeletionState::Initial => { + // Send SIGTERM to user processes + info!("Sending SIGTERM to processes of user {}", username); + match run_pkill(&self.cmd_executor, username, "TERM") { + Ok(true) => { + // Processes found and signaled, wait for them to terminate + pending.state = DeletionState::TermSent; + pending.next_action_time = now + Duration::from_secs(5); + } + Ok(false) => { + // No processes found, skip to retry immediately + info!("No processes found for user {}, retrying deletion immediately", username); + pending.state = DeletionState::KillSent; + pending.next_action_time = now; // Retry immediately + } + Err(e) => { + warn!("Failed to send SIGTERM to user {}: {}", username, e); + // Continue anyway, try SIGKILL next + pending.state = DeletionState::TermSent; + pending.next_action_time = now + Duration::from_secs(5); + } + } + } + + DeletionState::TermSent => { + // Send SIGKILL to user processes + info!("Sending SIGKILL to processes of user {}", username); + match run_pkill(&self.cmd_executor, username, "KILL") { + Ok(true) => { + // Processes found and signaled, wait for them to die + pending.state = DeletionState::KillSent; + pending.next_action_time = now + Duration::from_secs(2); + } + Ok(false) => { + // No processes found, retry deletion immediately + info!("No processes found for user {}, retrying deletion immediately", username); + pending.state = DeletionState::KillSent; + pending.next_action_time = now; // Retry immediately + } + Err(e) => { + warn!("Failed to send SIGKILL to user {}: {}", username, e); + // Continue anyway, try deletion + pending.state = DeletionState::KillSent; + pending.next_action_time = now + Duration::from_secs(2); + } + } + } + + DeletionState::KillSent => { + // Retry deletion + info!("Retrying deletion for user {}", username); + match run_userdel(&self.cmd_executor, username) { + Ok(_) => { + info!("Successfully deleted user {} after process termination", username); + to_remove.push(username.clone()); + } + Err(e) => { + pending.retry_count += 1; + if pending.retry_count >= 3 { + error!( + "Failed to delete user {} after {} retries: {}. Manual intervention required.", + username, pending.retry_count, e + ); + pending.state = DeletionState::Failed; + } else { + warn!("Retry {} failed for user {}: {}", pending.retry_count, username, e); + pending.state = DeletionState::Initial; + pending.next_action_time = now + Duration::from_secs(10); + } + } + } + } + + DeletionState::Failed => { + // Already logged error, nothing more to do + } + } + } + + // Remove successfully deleted users + for username in to_remove { + self.pending_deletions.remove(&username); + } + + // Calculate next check time + self.pending_deletions + .values() + .map(|p| p.next_action_time) + .min() + .unwrap_or_else(|| now + Duration::from_secs(60)) + } + + /// Unmanage a user (remove from managed group but keep account) + /// Returns Ok(true) if user was unmanaged, Ok(false) if user wasn't managed + pub fn unmanage_user(&self, username: &str) -> UserdResult { + // Remove user from managed group to indicate they're no longer managed + if self.is_user_managed(username) { + match run_gpasswd_remove(&self.cmd_executor, username, MANAGED_USER_GROUP)? { + GpasswdResult::Removed => { + info!( + "Removed user {} from managed group {}", + username, MANAGED_USER_GROUP + ); + info!( + "Successfully unmanaged user {} (user account preserved)", + username + ); + Ok(true) + } + GpasswdResult::NotMember => { + debug!("User {} was not in managed group", username); + Ok(false) + } + } + } else { + debug!("User {} is not in managed group", username); + Ok(false) + } + } + + /// Update an existing user + pub fn update_user(&self, username: &str, user_config: &UserInfo) -> UserdResult { + // Get current user info + let current_users = self.get_existing_users(); + let current_info = match current_users.get(username) { + Some(info) => info, + None => { + error!("User {} not found for update", username); + return Ok(false); + } + }; + + // Create expected UserInfo with correct shell based on enabled status + let expected_shell = if user_config.enabled { + DEFAULT_SHELL + } else { + NOLOGIN_SHELL + }; + + // Check and update password + if !user_config.password_hash.is_empty() + && current_info.password_hash != user_config.password_hash + { + self.set_user_password(username, &user_config.password_hash)?; + } + + // Check and update shell + if current_info.shell != expected_shell { + self.set_user_shell(username, user_config.enabled)?; + } + + // Always ensure user is in managed group and has correct role groups + // (even if role appears unchanged, the user might be missing from local_mgd) + if !user_config.role.is_empty() { + if current_info.role != user_config.role { + info!( + "Changing user {} role from '{}' to '{}'", + username, current_info.role, user_config.role + ); + } + self.set_user_groups(username, &user_config.role)?; + } + + // Check and update SSH keys + if current_info.ssh_keys != user_config.ssh_keys { + self.setup_ssh_keys(username, &user_config.ssh_keys)?; + } + + info!("Updated user {}", username); + Ok(true) + } + + /// Set user password hash directly in /etc/shadow + pub fn set_user_password(&self, username: &str, password_hash: &str) -> UserdResult<()> { + debug!("Setting password for user: {}", username); + run_usermod_password(&self.cmd_executor, username, password_hash) + } + + /// Set user shell (enable/disable login) + pub fn set_user_shell(&self, username: &str, enabled: bool) -> UserdResult<()> { + let shell = if enabled { + DEFAULT_SHELL + } else { + NOLOGIN_SHELL + }; + + debug!("Setting shell for user {}: {}", username, shell); + run_usermod_shell(&self.cmd_executor, username, shell) + } + + /// Set user groups based on role + /// + /// This function: + /// 1. Ensures the managed group exists + /// 2. Adds user to managed group (if not already) + /// 3. Removes user from groups of other roles + /// 4. Adds user to groups for the new role + pub fn set_user_groups(&self, username: &str, role: &str) -> UserdResult<()> { + // Ensure managed group exists + if !self.ensure_managed_group_exists()? { + error!( + "Failed to ensure managed group exists for user {}", + username + ); + return Err(UserdError::CommandError { + command: "ensure_managed_group_exists".to_string(), + message: "Failed to ensure managed group exists".to_string(), + }); + } + + // Get user's current groups ONCE (avoid repeated 'groups' command calls) + let current_groups: HashSet = self.get_user_groups(username).into_iter().collect(); + + // Always add user to managed group first (only if not already a member) + if !current_groups.contains(MANAGED_USER_GROUP) { + run_usermod_add_group(&self.cmd_executor, username, MANAGED_USER_GROUP)?; + debug!("Added user {} to managed group", username); + } else { + debug!("User {} already in managed group", username); + } + + let new_role_groups = match ROLE_GROUPS.get(role) { + Some(g) => g, + None => { + warn!("No groups defined for role {}", role); + return Ok(()); + } + }; + + // Get all role-based groups that this user should NOT be in + // (groups from other roles that are not in the new role) + let new_role_groups_set: HashSet<&str> = new_role_groups.iter().copied().collect(); + let mut groups_to_remove: Vec<&str> = Vec::new(); + + for (other_role, other_groups) in ROLE_GROUPS.iter() { + if *other_role != role { + for group in other_groups.iter() { + // Only remove if the group is not also part of the new role + // and user is currently in that group + if !new_role_groups_set.contains(group) && current_groups.contains(*group) { + groups_to_remove.push(group); + } + } + } + } + + // Remove user from groups they should no longer be in + for group in groups_to_remove { + match run_gpasswd_remove(&self.cmd_executor, username, group) { + Ok(GpasswdResult::Removed) => { + debug!("Removed user {} from group {}", username, group); + } + Ok(GpasswdResult::NotMember) => { + debug!("User {} was not in group {}", username, group); + } + Err(e) => { + warn!("Failed to remove user {} from group {}: {}", username, group, e); + } + } + } + + // Add user to role-specific groups + for group in new_role_groups.iter() { + if !current_groups.contains(*group) { + // Don't fail if group doesn't exist + let _ = run_usermod_add_group(&self.cmd_executor, username, group); + debug!("Added user {} to group {}", username, group); + } else { + debug!("User {} already in group {}", username, group); + } + } + + debug!("Updated user {} groups for role {}", username, role); + Ok(()) + } + + /// Ensure the managed user group exists + fn ensure_managed_group_exists(&self) -> UserdResult { + // Check if group already exists using getgrnam + if self.sys_funcs.getgrnam(MANAGED_USER_GROUP).is_some() { + debug!("Managed group {} already exists", MANAGED_USER_GROUP); + return Ok(true); + } + + // Create the managed group + run_groupadd(&self.cmd_executor, MANAGED_USER_GROUP) + } + + /// Validate SSH key format + fn is_valid_ssh_key(&self, key: &str) -> bool { + if key.is_empty() { + return false; + } + + // Check if it starts with a known SSH key type + if !key.starts_with("ssh-") + && !key.starts_with("ecdsa-") + && !key.contains("ed25519") + && !key.contains("rsa") + { + return false; + } + + // Check if it has at least 2 parts (type and key, comment is optional) + let part_count = key.split_whitespace().take(3).count(); + part_count >= 2 + } + + /// Setup SSH authorized keys for a user + pub fn setup_ssh_keys(&self, username: &str, ssh_keys: &[String]) -> UserdResult<()> { + // Filter and validate SSH keys + let valid_keys: Vec<&String> = ssh_keys + .iter() + .filter(|key| { + if self.is_valid_ssh_key(key) { + true + } else { + warn!("Skipping invalid SSH key for user {}", username); + false + } + }) + .collect(); + + if valid_keys.is_empty() && !ssh_keys.is_empty() { + warn!("No valid SSH keys for user {}", username); + } + + // Get user home directory + let passwd_entry = self + .sys_funcs + .getpwnam(username) + .ok_or_else(|| UserdError::UserNotFound(username.to_string()))?; + + let home_dir = passwd_entry.dir.clone(); + let ssh_dir = home_dir.join(".ssh"); + let auth_keys_path = ssh_dir.join("authorized_keys"); + + // Create .ssh directory if it doesn't exist + if !ssh_dir.exists() { + fs::create_dir_all(&ssh_dir).map_err(|e| UserdError::FileError { + path: ssh_dir.to_string_lossy().to_string(), + message: e.to_string(), + })?; + } + + // Set directory permissions (700) + self.sys_funcs.chmod(&ssh_dir, 0o700)?; + self.sys_funcs + .chown(&ssh_dir, passwd_entry.uid, passwd_entry.gid)?; + + // Write authorized_keys file atomically using temp file + rename + // This ensures the original file is preserved if write fails + let content = valid_keys + .iter() + .map(|k| k.as_str()) + .collect::>() + .join("\n"); + + let temp_path = auth_keys_path.with_extension("tmp"); + + // Write to temporary file + fs::write(&temp_path, format!("{}\n", content)).map_err(|e| UserdError::FileError { + path: temp_path.to_string_lossy().to_string(), + message: e.to_string(), + })?; + + // Set permissions and ownership on temp file before rename + self.sys_funcs.chmod(&temp_path, 0o600)?; + self.sys_funcs + .chown(&temp_path, passwd_entry.uid, passwd_entry.gid)?; + + // Atomic rename to final location + fs::rename(&temp_path, &auth_keys_path).map_err(|e| UserdError::FileError { + path: auth_keys_path.to_string_lossy().to_string(), + message: format!("Failed to rename temp file: {}", e), + })?; + + debug!("Setup {} SSH keys for user {}", valid_keys.len(), username); + Ok(()) + } + + /// Perform consistency check at startup + pub fn perform_consistency_check(&mut self) -> UserdResult<()> { + if !self.is_feature_enabled() { + info!("Feature disabled, skipping consistency check"); + return Ok(()); + } + + debug!("Performing startup consistency check..."); + + // Get existing system users + let system_users = self.get_existing_users(); + + // Get users that should exist according to CONFIG_DB + let config_users: HashSet = self.users.keys().cloned().collect(); + + // Ensure all CONFIG_DB users exist and are properly configured + for (username, user_config) in &self.users.clone() { + if !system_users.contains_key(username) { + info!("Creating missing user: {}", username); + self.create_user(username, user_config)?; + } else { + // Update existing user configuration + self.update_user(username, user_config)?; + } + } + + // Find managed users that exist in system but not in CONFIG_DB + // Get the members of the managed group ONCE (avoid repeated 'groups' command calls) + let managed_group_members: HashSet = + match self.sys_funcs.getgrnam(MANAGED_USER_GROUP) { + Some(group) => group.mem.into_iter().collect(), + None => HashSet::new(), + }; + + let mut unmanaged_users = Vec::new(); + for username in system_users.keys() { + // Skip if user is in CONFIG_DB + if config_users.contains(username) { + continue; + } + // Skip system users + if SYSTEM_USERS.contains(username.as_str()) { + continue; + } + // Only consider users that are managed by userd (in local_mgd group) + if managed_group_members.contains(username) { + unmanaged_users.push(username.clone()); + } + } + + // Remove unmanaged users that were previously managed by userd + for username in unmanaged_users { + info!("Removing previously managed user: {}", username); + let _ = self.delete_user(&username)?; // Ignore timer return during consistency check + } + + info!("Consistency check completed"); + Ok(()) + } + + /// Update security policies + pub fn update_security_policies(&self) -> UserdResult<()> { + if !self.is_feature_enabled() { + return Ok(()); + } + + render_pam_faillock(&self.security_policies)?; + info!("Security policies updated"); + Ok(()) + } + + /// Cleanup all managed state when feature is disabled + /// + /// Discovers managed users from the system (via local_mgd group membership) + /// rather than from DB, since the DB tables may have been cleared when the + /// feature was disabled. Also resets PAM config and clears internal state. + pub fn cleanup_managed_state(&mut self) { + info!("Cleaning up all managed users and policies"); + + // Get members of the managed user group from the system + let managed_users = match self.sys_funcs.getgrnam(MANAGED_USER_GROUP) { + Some(group) => group.mem, + None => { + // Group doesn't exist - no users to clean up + debug!("Managed group {} does not exist, no users to clean up", MANAGED_USER_GROUP); + vec![] + } + }; + + if managed_users.is_empty() { + debug!("No users in managed group"); + } else { + info!("Unmanaging {} users from system", managed_users.len()); + for username in &managed_users { + info!("Unmanaging user: {}", username); + if let Err(e) = self.unmanage_user(username) { + warn!("Failed to unmanage user {}: {}", username, e); + } + } + } + + // Reset PAM config to defaults (no policies) + if let Err(e) = render_pam_faillock(&HashMap::new()) { + warn!("Failed to reset PAM faillock config: {}", e); + } + + // Clear internal state + self.users.clear(); + self.security_policies.clear(); + + info!("Managed state cleanup complete"); + } + + /// Extract the user attributes + fn extract_user_attributes(&self, key: &str, data: &HashMap) -> UserInfo { + // User added or modified + let mut user = UserInfo { + username: key.to_string(), + ..Default::default() + }; + + for (field, value) in data { + match field.as_str() { + "role" => user.role = value.clone(), + "password_hash" => user.password_hash = value.clone(), + "enabled" => user.enabled = value == "true" || value == "True", + "ssh_keys" => { + user.ssh_keys = self.parse_ssh_keys(value); + } + _ => {} + } + } + user + } + + /// Handle user table change from CONFIG_DB (LOCAL_USER table) + /// Returns Some(Instant) if timer needs to be updated, None otherwise + pub fn handle_user_change(&mut self, key: &str, data: &HashMap) -> UserdResult> { + if !data.is_empty() { + // Cancel pending deletion if user is re-added + if let Some(pending) = self.pending_deletions.remove(key) { + warn!( + "Cancelling pending deletion for {} (state: {:?})", + key, pending.state + ); + } + + let user = self.extract_user_attributes(key, data); + if self.is_feature_enabled() { + let existing_users = self.get_existing_users(); + if existing_users.contains_key(key) { + self.update_user(key, &user)?; + } else { + self.create_user(key, &user)?; + } + } + self.users.insert(key.to_string(), user); + Ok(None) + } else { + let next_timer = if self.is_feature_enabled() { + let (_, timer) = self.delete_user(key)?; + timer + } else { + None + }; + self.users.remove(key); + Ok(next_timer) + } + } + + /// Extract the security policy attributes + fn extract_policy_attributes(&self, key: &str, data: &HashMap) -> SecurityPolicy { + let mut policy = SecurityPolicy { + role: key.to_string(), + ..Default::default() + }; + + for (field, value) in data { + if field == "max_login_attempts" { + policy.max_login_attempts = value.parse().unwrap_or(0); + } + } + policy + } + + /// Handle security policy table change from CONFIG_DB (LOCAL_ROLE_SECURITY_POLICY table) + pub fn handle_policy_change(&mut self, key: &str, data: &HashMap) -> UserdResult> { + if !data.is_empty() { + let policy = self.extract_policy_attributes(key, data); + self.security_policies.insert(key.to_string(), policy); + } else { + self.security_policies.remove(key); + } + + if self.is_feature_enabled() { + self.update_security_policies()?; + } + + Ok(None) + } + + /// Handle metadata table change from CONFIG_DB (DEVICE_METADATA table) + pub fn handle_metadata_change(&mut self, key: &str, data: &HashMap) -> UserdResult> { + if key != DEVICE_METADATA_LOCALHOST_KEY { + return Ok(None); + } + + let mut new_state = false; + for (field, value) in data { + if field == LOCAL_USER_MANAGEMENT_FIELD && value == "enabled" { + new_state = true; + break; + } + } + + if new_state != self.is_feature_enabled() || !self.initial_read_done { + self.set_feature_enabled(new_state); + self.handle_feature_config()?; + } + + Ok(None) + } + + /// Remove user from groups + pub fn remove_user_from_groups(&self, username: &str, groups: &[&str]) -> UserdResult<()> { + // Get user's current groups ONCE (avoid repeated 'groups' command calls) + let current_groups: HashSet = self.get_user_groups(username).into_iter().collect(); + + for group in groups { + if current_groups.contains(*group) { + match run_gpasswd_remove(&self.cmd_executor, username, group)? { + GpasswdResult::Removed => { + info!("Removed user {} from group {}", username, group); + } + GpasswdResult::NotMember => { + debug!("User {} was not a member of group {}", username, group); + } + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ============================================================================ + // USERINFO STRUCTURE TESTS + // ============================================================================ + + #[test] + fn test_user_info_equality() { + let user1 = UserInfo { + username: "testuser".to_string(), + role: "administrator".to_string(), + password_hash: "$6$salt$hash".to_string(), + ssh_keys: vec!["ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ user@host".to_string()], + enabled: true, + uid: 1000, + gid: 1000, + home_dir: PathBuf::from("/home/testuser"), + shell: "/bin/bash".to_string(), + }; + + let user2 = user1.clone(); + assert_eq!(user1, user2); + + // Change role + let mut user3 = user1.clone(); + user3.role = "operator".to_string(); + assert_ne!(user1, user3); + + // Change enabled status + let mut user4 = user1.clone(); + user4.enabled = false; + user4.shell = NOLOGIN_SHELL.to_string(); + assert_ne!(user1, user4); + } + + #[test] + fn test_user_info_config_equals() { + let user1 = UserInfo { + username: "user1".to_string(), + role: "operator".to_string(), + password_hash: "hash".to_string(), + ssh_keys: vec![], + enabled: true, + uid: 1000, + gid: 1000, + home_dir: PathBuf::from("/home/user1"), + shell: "/bin/bash".to_string(), + }; + + // Same config but different uid/gid + let user2 = UserInfo { + username: "user2".to_string(), + role: "operator".to_string(), + password_hash: "hash".to_string(), + ssh_keys: vec![], + enabled: true, + uid: 2000, + gid: 2000, + home_dir: PathBuf::from("/home/user2"), + shell: "/bin/bash".to_string(), + }; + + // config_equals should compare configuration fields, not identity fields + assert!(user1.config_equals(&user2)); + } + + // ============================================================================ + // CONSTANTS TESTS + // ============================================================================ + + #[test] + fn test_role_group_mappings() { + // Verify administrator role groups + let admin_groups = ROLE_GROUPS + .get("administrator") + .expect("admin role should exist"); + assert_eq!(admin_groups.len(), 4); + assert!(admin_groups.contains(&"sudo")); + assert!(admin_groups.contains(&"docker")); + assert!(admin_groups.contains(&"redis")); + assert!(admin_groups.contains(&"admin")); + + // Verify operator role groups + let operator_groups = ROLE_GROUPS + .get("operator") + .expect("operator role should exist"); + assert_eq!(operator_groups.len(), 1); + assert!(operator_groups.contains(&"users")); + } + + #[test] + fn test_system_users_list() { + // Verify some key system users are in the exclusion list + assert!(SYSTEM_USERS.contains("root")); + assert!(SYSTEM_USERS.contains("daemon")); + assert!(SYSTEM_USERS.contains("www-data")); + + // Verify regular user names are not in the system users list + assert!(!SYSTEM_USERS.contains("testuser")); + assert!(!SYSTEM_USERS.contains("admin")); + assert!(!SYSTEM_USERS.contains("operator")); + } + + // ============================================================================ + // SSH KEY PARSING TESTS (Testing parsing logic) + // ============================================================================ + + #[test] + fn test_ssh_key_parsing_json_array() { + // Test JSON array parsing + let json_str = r#"["ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ user1@host", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user2@host"]"#; + let keys: Vec = serde_json::from_str(json_str).unwrap(); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0], "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ user1@host"); + assert_eq!(keys[1], "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user2@host"); + } + + #[test] + fn test_ssh_key_parsing_empty_array() { + let json_str = "[]"; + let keys: Vec = serde_json::from_str(json_str).unwrap(); + assert_eq!(keys.len(), 0); + } + + #[test] + fn test_ssh_key_parsing_comma_separated() { + // Test comma-separated fallback + let keys_str = "ssh-rsa KEY1 user1,ssh-ed25519 KEY2 user2"; + let keys: Vec = keys_str + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0], "ssh-rsa KEY1 user1"); + assert_eq!(keys[1], "ssh-ed25519 KEY2 user2"); + } + + // ============================================================================ + // USERMANAGER INTEGRATION TESTS WITH MOCKS + // ============================================================================ + + use crate::system::{CommandResult, MockCommandExecutor, MockSystemFunctions}; + use nix::unistd::{Gid, Group, Uid, User}; + use std::ffi::CString; + use std::os::unix::process::ExitStatusExt; + use std::path::PathBuf; + use std::process::ExitStatus; + + /// Helper to create a successful CommandResult + fn success_output() -> CommandResult { + CommandResult { + status: ExitStatus::from_raw(0), + stdout: Vec::new(), + stderr: Vec::new(), + command_str: "test command".to_string(), + } + } + + /// Helper to create a failed CommandResult + fn failure_output(stderr: &str) -> CommandResult { + CommandResult { + status: ExitStatus::from_raw(256), // exit code 1 + stdout: Vec::new(), + stderr: stderr.as_bytes().to_vec(), + command_str: "test command".to_string(), + } + } + + /// Helper to create a CommandResult with custom output + fn mock_result(status: ExitStatus, stdout: Vec, stderr: Vec) -> CommandResult { + CommandResult { + status, + stdout, + stderr, + command_str: "test command".to_string(), + } + } + + /// Helper to create a test UserInfo + fn test_user_info(username: &str, role: &str, enabled: bool) -> UserInfo { + UserInfo { + username: username.to_string(), + role: role.to_string(), + password_hash: "$6$salt$testhash".to_string(), + ssh_keys: vec![], + enabled, + uid: 1001, + gid: 1001, + home_dir: PathBuf::from(format!("/home/{}", username)), + shell: if enabled { + DEFAULT_SHELL.to_string() + } else { + NOLOGIN_SHELL.to_string() + }, + } + } + + // ============================================================================ + // CREATE USER TESTS + // ============================================================================ + + #[test] + fn test_create_user_already_exists() { + let mut mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // User already exists + mock_sys.expect_getpwnam().returning(|name| { + Some(User { + name: name.to_string(), + passwd: CString::new("x").unwrap(), + uid: Uid::from_raw(1001), + gid: Gid::from_raw(1001), + gecos: CString::new("").unwrap(), + dir: PathBuf::from(format!("/home/{}", name)), + shell: PathBuf::from("/bin/bash"), + }) + }); + + // No commands should be executed + mock_cmd.expect_execute().times(0); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + let user_config = test_user_info("testuser", "operator", true); + let result = manager.create_user("testuser", &user_config); + + assert!(result.is_ok()); + assert!(!result.unwrap()); // Returns false when user already exists + } + + #[test] + fn test_create_user_invalid_role() { + let mut mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // User doesn't exist + mock_sys.expect_getpwnam().returning(|_| None); + + // No commands should be executed for invalid role + mock_cmd.expect_execute().times(0); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + let mut user_config = test_user_info("testuser", "invalid_role", true); + user_config.role = "invalid_role".to_string(); + let result = manager.create_user("testuser", &user_config); + + assert!(result.is_err()); + match result.unwrap_err() { + UserdError::InvalidRole(role) => assert_eq!(role, "invalid_role"), + _ => panic!("Expected InvalidRole error"), + } + } + + // ============================================================================ + // DELETE USER TESTS + // ============================================================================ + + #[test] + fn test_delete_user_not_exists() { + let mut mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // User doesn't exist + mock_sys.expect_getpwnam().returning(|_| None); + + // No commands should be executed + mock_cmd.expect_execute().times(0); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.delete_user("nonexistent"); + + assert!(result.is_ok()); + let (deleted, timer) = result.unwrap(); + assert!(!deleted); // Returns false when user doesn't exist + assert!(timer.is_none()); // No timer when user doesn't exist + } + + #[test] + fn test_delete_user_success() { + let mut mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // User exists + mock_sys.expect_getpwnam().returning(|name| { + Some(User { + name: name.to_string(), + passwd: CString::new("x").unwrap(), + uid: Uid::from_raw(1001), + gid: Gid::from_raw(1001), + gecos: CString::new("").unwrap(), + dir: PathBuf::from(format!("/home/{}", name)), + shell: PathBuf::from("/bin/bash"), + }) + }); + + // Expect userdel command + mock_cmd + .expect_execute() + .withf(|cmd| { + cmd.len() == 3 + && cmd[0] == CMD_USERDEL + && cmd[1] == "-r" + && cmd[2] == "testuser" + }) + .times(1) + .returning(|_| Ok(success_output())); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.delete_user("testuser"); + + assert!(result.is_ok()); + let (deleted, timer) = result.unwrap(); + assert!(deleted); // Returns true on successful deletion + assert!(timer.is_none()); // No timer when deletion succeeds immediately + } + + #[test] + fn test_delete_user_ignores_mail_spool_error() { + let mut mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // User exists + mock_sys.expect_getpwnam().returning(|name| { + Some(User { + name: name.to_string(), + passwd: CString::new("x").unwrap(), + uid: Uid::from_raw(1001), + gid: Gid::from_raw(1001), + gecos: CString::new("").unwrap(), + dir: PathBuf::from(format!("/home/{}", name)), + shell: PathBuf::from("/bin/bash"), + }) + }); + + // userdel returns error about mail spool (benign) + mock_cmd + .expect_execute() + .returning(|_| Ok(failure_output("userdel: user testuser mail spool not found"))); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.delete_user("testuser"); + + // Should succeed despite mail spool error + assert!(result.is_ok()); + let (deleted, timer) = result.unwrap(); + assert!(deleted); + assert!(timer.is_none()); + } + + #[test] + fn test_delete_user_fails_on_mail_spool_plus_other_errors() { + let mut mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // User exists + mock_sys.expect_getpwnam().returning(|name| { + Some(User { + name: name.to_string(), + passwd: CString::new("x").unwrap(), + uid: Uid::from_raw(1001), + gid: Gid::from_raw(1001), + gecos: CString::new("").unwrap(), + dir: PathBuf::from(format!("/home/{}", name)), + shell: PathBuf::from("/bin/bash"), + }) + }); + + // userdel returns mail spool error AND another error (should fail) + mock_cmd.expect_execute().returning(|_| { + Ok(failure_output( + "userdel: cannot remove mail spool /var/mail/testuser\nuserdel: error removing home directory /home/testuser: Permission denied" + )) + }); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.delete_user("testuser"); + + // Should fail because there's a real error in addition to mail spool + assert!(result.is_err()); + } + + // ============================================================================ + // SET USER PASSWORD TESTS + // ============================================================================ + + #[test] + fn test_set_user_password_success() { + let mut mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + // Expect usermod -p command with masked password + mock_cmd + .expect_execute_masked() + .withf(|cmd, mask_indices| { + cmd.len() == 4 + && cmd[0] == CMD_USERMOD + && cmd[1] == "-p" + && cmd[2] == "$6$salt$hash" + && cmd[3] == "testuser" + && mask_indices.contains(&2) // Password should be masked + }) + .times(1) + .returning(|_, _| Ok(success_output())); + + let manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.set_user_password("testuser", "$6$salt$hash"); + assert!(result.is_ok()); + } + + #[test] + fn test_set_user_password_failure() { + let mut mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + // usermod fails + mock_cmd + .expect_execute_masked() + .returning(|_, _| Ok(failure_output("usermod: user 'testuser' does not exist"))); + + let manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.set_user_password("testuser", "$6$salt$hash"); + assert!(result.is_err()); + } + + // ============================================================================ + // SET USER SHELL TESTS + // ============================================================================ + + #[test] + fn test_set_user_shell_enabled() { + let mut mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + // Expect usermod -s /bin/bash + mock_cmd + .expect_execute() + .withf(|cmd| { + cmd.len() == 4 + && cmd[0] == CMD_USERMOD + && cmd[1] == "-s" + && cmd[2] == DEFAULT_SHELL + && cmd[3] == "testuser" + }) + .times(1) + .returning(|_| Ok(success_output())); + + let manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.set_user_shell("testuser", true); + assert!(result.is_ok()); + } + + #[test] + fn test_set_user_shell_disabled() { + let mut mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + // Expect usermod -s NOLOGIN_SHELL + mock_cmd + .expect_execute() + .withf(|cmd| { + cmd.len() == 4 + && cmd[0] == CMD_USERMOD + && cmd[1] == "-s" + && cmd[2] == NOLOGIN_SHELL + && cmd[3] == "testuser" + }) + .times(1) + .returning(|_| Ok(success_output())); + + let manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.set_user_shell("testuser", false); + assert!(result.is_ok()); + } + + // ============================================================================ + // IS USER MANAGED TESTS + // ============================================================================ + + #[test] + fn test_is_user_managed_true() { + let mut mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + // Simulate `groups username` returning groups including local_mgd + // Format: "username : group1 group2 group3" + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(0), + "testuser : users docker local_mgd\n".as_bytes().to_vec(), + Vec::new(), + )) + }); + + let manager = UserManager::new(mock_cmd, mock_sys); + + assert!(manager.is_user_managed("testuser")); + } + + #[test] + fn test_is_user_managed_false() { + let mut mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + // Simulate `groups username` returning groups without local_mgd + // Format: "username : group1 group2 group3" + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(0), + "testuser : users docker\n".as_bytes().to_vec(), + Vec::new(), + )) + }); + + let manager = UserManager::new(mock_cmd, mock_sys); + + assert!(!manager.is_user_managed("testuser")); + } + + // ============================================================================ + // UNMANAGE USER TESTS + // ============================================================================ + + #[test] + fn test_unmanage_user_success() { + let mut mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + // First call: groups to check if managed (returns groups with local_mgd) + // Second call: gpasswd -d to remove from managed group + let call_count = std::cell::RefCell::new(0); + mock_cmd.expect_execute().returning(move |cmd| { + let mut count = call_count.borrow_mut(); + *count += 1; + if *count == 1 { + // First call: groups - format: "username : group1 group2" + Ok(mock_result( + ExitStatus::from_raw(0), + "testuser : local_mgd users\n".as_bytes().to_vec(), + Vec::new(), + )) + } else { + // Second call: gpasswd -d + assert_eq!(cmd[0], CMD_GPASSWD); + assert_eq!(cmd[1], "-d"); + assert_eq!(cmd[2], "testuser"); + assert_eq!(cmd[3], "local_mgd"); + Ok(success_output()) + } + }); + + let manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.unmanage_user("testuser"); + assert!(result.is_ok()); + assert!(result.unwrap()); + } + + #[test] + fn test_unmanage_user_not_managed() { + let mut mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + // User is not in managed group - groups command format: "username : group1 group2" + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(0), + "testuser : users docker\n".as_bytes().to_vec(), + Vec::new(), + )) + }); + + let manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.unmanage_user("testuser"); + assert!(result.is_ok()); + assert!(!result.unwrap()); // Returns false - user wasn't managed + } + + // ============================================================================ + // FEATURE ENABLED TESTS + // ============================================================================ + + #[test] + fn test_feature_enabled_default_false() { + let mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + let manager = UserManager::new(mock_cmd, mock_sys); + + assert!(!manager.is_feature_enabled()); + } + + #[test] + fn test_set_feature_enabled() { + let mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + manager.set_feature_enabled(true); + assert!(manager.is_feature_enabled()); + + manager.set_feature_enabled(false); + assert!(!manager.is_feature_enabled()); + } + + // ============================================================================ + // ENSURE MANAGED GROUP EXISTS TESTS + // ============================================================================ + + #[test] + fn test_ensure_managed_group_exists_already_exists() { + let mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // Group already exists + mock_sys.expect_getgrnam().returning(|name| { + Some(Group { + name: name.to_string(), + passwd: CString::new("x").unwrap(), + gid: Gid::from_raw(1000), + mem: vec![], + }) + }); + + let manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.ensure_managed_group_exists(); + assert!(result.is_ok()); + assert!(result.unwrap()); + } + + #[test] + fn test_ensure_managed_group_exists_creates_group() { + let mut mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // Group doesn't exist + mock_sys.expect_getgrnam().returning(|_| None); + + // Expect groupadd command + mock_cmd + .expect_execute() + .withf(|cmd| cmd.len() == 2 && cmd[0] == CMD_GROUPADD && cmd[1] == "local_mgd") + .times(1) + .returning(|_| Ok(success_output())); + + let manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.ensure_managed_group_exists(); + assert!(result.is_ok()); + assert!(result.unwrap()); + } + + // ============================================================================ + // REMOVE USER FROM GROUPS TESTS + // ============================================================================ + + #[test] + fn test_remove_user_from_groups() { + let mut mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + mock_cmd.expect_execute().returning(move |cmd| { + // groups command is used to check membership + // Format: "username : group1 group2 group3" + if cmd[0] == CMD_GROUPS { + Ok(mock_result( + ExitStatus::from_raw(0), + "testuser : sudo docker admin\n".as_bytes().to_vec(), + Vec::new(), + )) + } else if cmd[0] == CMD_GPASSWD { + // gpasswd -d to remove from group + Ok(success_output()) + } else { + panic!("Unexpected command: {:?}", cmd); + } + }); + + let manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.remove_user_from_groups("testuser", &["sudo", "docker"]); + assert!(result.is_ok()); + } + + // ============================================================================ + // HANDLE USER CHANGE TESTS + // ============================================================================ + + #[test] + fn test_handle_user_change_feature_disabled() { + let mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + // No commands should be executed when feature is disabled + let mut manager = UserManager::new(mock_cmd, mock_sys); + manager.set_feature_enabled(false); + + let mut data = HashMap::new(); + data.insert("role".to_string(), "administrator".to_string()); + data.insert("enabled".to_string(), "true".to_string()); + + let result = manager.handle_user_change("testuser", &data); + assert!(result.is_ok()); + // User SHOULD be cached even when feature is disabled (no system sync, but cached) + assert!(!manager.users.is_empty()); + assert!(manager.users.contains_key("testuser")); + } + + #[test] + fn test_handle_user_change_delete_when_disabled() { + let mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + // Delete (empty data) should also be ignored when feature is disabled + let mut manager = UserManager::new(mock_cmd, mock_sys); + manager.set_feature_enabled(false); + + let data = HashMap::new(); // Empty data = delete + let result = manager.handle_user_change("testuser", &data); + assert!(result.is_ok()); + } + + // ============================================================================ + // HANDLE POLICY CHANGE TESTS + // ============================================================================ + + #[test] + fn test_handle_policy_change_feature_disabled() { + let mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + // No commands should be executed when feature is disabled + let mut manager = UserManager::new(mock_cmd, mock_sys); + manager.set_feature_enabled(false); + + let mut data = HashMap::new(); + data.insert("max_login_attempts".to_string(), "5".to_string()); + + let result = manager.handle_policy_change("administrator", &data); + assert!(result.is_ok()); + // Policy SHOULD be cached even when feature is disabled (no system sync, but cached) + assert!(!manager.security_policies.is_empty()); + assert!(manager.security_policies.contains_key("administrator")); + } + + #[test] + fn test_handle_policy_change_delete_when_disabled() { + let mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + // Delete (empty data) should also be ignored when feature is disabled + let mut manager = UserManager::new(mock_cmd, mock_sys); + manager.set_feature_enabled(false); + + let data = HashMap::new(); // Empty data = delete + let result = manager.handle_policy_change("administrator", &data); + assert!(result.is_ok()); + } + + // ============================================================================ + // HANDLE METADATA CHANGE TESTS + // ============================================================================ + + #[test] + fn test_handle_metadata_change_ignores_non_localhost() { + let mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + // Non-localhost keys should be ignored + let mut data = HashMap::new(); + data.insert("local_user_management".to_string(), "enabled".to_string()); + + let result = manager.handle_metadata_change("not_localhost", &data); + assert!(result.is_ok()); + // Feature should remain disabled (default) + assert!(!manager.is_feature_enabled()); + } + + #[test] + fn test_handle_metadata_change_already_enabled() { + // Test that when feature is already enabled and initial read is done, + // no state change occurs and no side effects are triggered. + let mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + manager.set_feature_enabled(true); // Already enabled + manager.set_initial_read_done(); // Mark initial read as done + + let mut data = HashMap::new(); + data.insert("local_user_management".to_string(), "enabled".to_string()); + + // Should succeed because no state change needed (enabled → enabled, initial read done) + let result = manager.handle_metadata_change("localhost", &data); + assert!(result.is_ok()); + assert!(manager.is_feature_enabled()); + } + + #[test] + fn test_handle_metadata_change_parses_enabled_value() { + // Test that the "enabled" value is correctly parsed from metadata + // We test this indirectly through the non-localhost ignore test + // and the disable test. This test verifies only "enabled" value triggers enable. + let mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // cleanup_managed_state() calls getgrnam to discover managed users + mock_sys.expect_getgrnam().returning(|name| { + if name == MANAGED_USER_GROUP { + Some(Group { + name: name.to_string(), + passwd: CString::new("x").unwrap(), + gid: Gid::from_raw(1000), + mem: vec![], // No users to cleanup + }) + } else { + None + } + }); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + manager.set_feature_enabled(true); + + // "disabled" value should trigger transition (but won't fail because + // cleanup_managed_state gracefully handles j2 errors with warn!) + let mut data = HashMap::new(); + data.insert("local_user_management".to_string(), "disabled".to_string()); + + let result = manager.handle_metadata_change("localhost", &data); + assert!(result.is_ok()); + assert!(!manager.is_feature_enabled()); // Should be disabled now + + // Any other value (not "enabled") should also be treated as disabled + manager.set_feature_enabled(true); + data.insert("local_user_management".to_string(), "invalid".to_string()); + let result = manager.handle_metadata_change("localhost", &data); + assert!(result.is_ok()); + assert!(!manager.is_feature_enabled()); + } + + #[test] + fn test_handle_metadata_change_disable_triggers_cleanup() { + let mut mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // First enable the feature + mock_sys.expect_getgrnam().returning(|name| { + if name == MANAGED_USER_GROUP { + // Return group with one member for cleanup + Some(Group { + name: name.to_string(), + passwd: CString::new("x").unwrap(), + gid: Gid::from_raw(1000), + mem: vec!["testuser".to_string()], + }) + } else { + None + } + }); + + // Mock commands for cleanup (groups check, gpasswd) + mock_cmd.expect_execute().returning(|cmd| { + if cmd[0] == CMD_GROUPS { + Ok(mock_result( + ExitStatus::from_raw(0), + "testuser : local_mgd users\n".as_bytes().to_vec(), + Vec::new(), + )) + } else if cmd[0] == CMD_GPASSWD { + Ok(success_output()) + } else { + Ok(success_output()) + } + }); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + manager.set_feature_enabled(true); + + // Add a user and policy to internal state + manager.users.insert( + "testuser".to_string(), + test_user_info("testuser", "operator", true), + ); + manager.security_policies.insert( + "operator".to_string(), + SecurityPolicy { + role: "operator".to_string(), + max_login_attempts: 5, + }, + ); + + // Now disable + let mut data = HashMap::new(); + data.insert("local_user_management".to_string(), "disabled".to_string()); + + let result = manager.handle_metadata_change("localhost", &data); + assert!(result.is_ok()); + assert!(!manager.is_feature_enabled()); + // Internal state should be cleared + assert!(manager.users.is_empty()); + assert!(manager.security_policies.is_empty()); + } + + // ============================================================================ + // CLEANUP MANAGED STATE TESTS + // ============================================================================ + + #[test] + fn test_cleanup_managed_state_no_group() { + let mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // Managed group doesn't exist + mock_sys + .expect_getgrnam() + .returning(|_| None); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + // Add some internal state + manager.users.insert( + "testuser".to_string(), + test_user_info("testuser", "operator", true), + ); + manager.security_policies.insert( + "operator".to_string(), + SecurityPolicy { + role: "operator".to_string(), + max_login_attempts: 5, + }, + ); + + manager.cleanup_managed_state(); + + // Internal state should be cleared even if group doesn't exist + assert!(manager.users.is_empty()); + assert!(manager.security_policies.is_empty()); + } + + #[test] + fn test_cleanup_managed_state_with_users() { + let mut mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // Managed group exists with users + mock_sys.expect_getgrnam().returning(|name| { + if name == MANAGED_USER_GROUP { + Some(Group { + name: name.to_string(), + passwd: CString::new("x").unwrap(), + gid: Gid::from_raw(1000), + mem: vec!["user1".to_string(), "user2".to_string()], + }) + } else { + None + } + }); + + // Mock commands for unmanage_user calls + // Each unmanage_user calls: groups (check), gpasswd -d (remove) + mock_cmd.expect_execute().returning(|cmd| { + if cmd[0] == CMD_GROUPS { + Ok(mock_result( + ExitStatus::from_raw(0), + "user1 : local_mgd users\n".as_bytes().to_vec(), + Vec::new(), + )) + } else if cmd[0] == CMD_GPASSWD { + Ok(success_output()) + } else { + Ok(success_output()) + } + }); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + // Add internal state + manager.users.insert( + "user1".to_string(), + test_user_info("user1", "operator", true), + ); + manager.security_policies.insert( + "operator".to_string(), + SecurityPolicy { + role: "operator".to_string(), + max_login_attempts: 3, + }, + ); + + manager.cleanup_managed_state(); + + // Internal state should be cleared + assert!(manager.users.is_empty()); + assert!(manager.security_policies.is_empty()); + } + + #[test] + fn test_cleanup_managed_state_empty_group() { + let mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // Managed group exists but has no members + mock_sys.expect_getgrnam().returning(|name| { + Some(Group { + name: name.to_string(), + passwd: CString::new("x").unwrap(), + gid: Gid::from_raw(1000), + mem: vec![], + }) + }); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + // Add internal state + manager.security_policies.insert( + "administrator".to_string(), + SecurityPolicy { + role: "administrator".to_string(), + max_login_attempts: 5, + }, + ); + + manager.cleanup_managed_state(); + + // Internal state should be cleared + assert!(manager.users.is_empty()); + assert!(manager.security_policies.is_empty()); + } + + // ============================================================================ + // PENDING DELETION TESTS + // ============================================================================ + + #[test] + fn test_delete_user_with_active_processes_adds_to_pending_queue() { + let mut mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // User exists + mock_sys.expect_getpwnam().returning(|name| { + Some(User { + name: name.to_string(), + passwd: CString::new("x").unwrap(), + uid: Uid::from_raw(1001), + gid: Gid::from_raw(1001), + gecos: CString::new("").unwrap(), + dir: PathBuf::from(format!("/home/{}", name)), + shell: PathBuf::from("/bin/bash"), + }) + }); + + // userdel fails with "currently used by process" error + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(256), // Exit code 1 + Vec::new(), + b"userdel: user testuser is currently used by process 1234".to_vec(), + )) + }); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.delete_user("testuser"); + + // Should succeed (returns Ok(true) even though deletion is pending) + assert!(result.is_ok()); + let (deleted, timer) = result.unwrap(); + assert!(deleted); + assert!(timer.is_some()); // Timer should be set for pending deletion + + // User should be in pending deletion queue + assert_eq!(manager.pending_deletions.len(), 1); + assert!(manager.pending_deletions.contains_key("testuser")); + + let pending = manager.pending_deletions.get("testuser").unwrap(); + assert_eq!(pending.username, "testuser"); + assert_eq!(pending.state, crate::types::DeletionState::Initial); + assert_eq!(pending.retry_count, 0); + } + + #[test] + fn test_delete_user_other_errors_not_added_to_pending() { + let mut mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // User exists + mock_sys.expect_getpwnam().returning(|name| { + Some(User { + name: name.to_string(), + passwd: CString::new("x").unwrap(), + uid: Uid::from_raw(1001), + gid: Gid::from_raw(1001), + gecos: CString::new("").unwrap(), + dir: PathBuf::from(format!("/home/{}", name)), + shell: PathBuf::from("/bin/bash"), + }) + }); + + // userdel fails with different error (not "currently used by process") + mock_cmd.expect_execute().returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(256), // Exit code 1 + Vec::new(), + b"userdel: some other error".to_vec(), + )) + }); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + let result = manager.delete_user("testuser"); + + // Should fail with error + assert!(result.is_err()); + + // User should NOT be in pending deletion queue + assert_eq!(manager.pending_deletions.len(), 0); + } + + #[test] + fn test_handle_user_change_cancels_pending_deletion() { + let mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + manager.set_feature_enabled(false); // Disable feature to avoid actual system calls + + // Manually add a user to pending deletion queue + use crate::types::{DeletionState, PendingDeletion}; + use std::time::{Duration, Instant}; + + let pending = PendingDeletion { + username: "testuser".to_string(), + state: DeletionState::TermSent, + next_action_time: Instant::now() + Duration::from_secs(5), + retry_count: 1, + }; + manager.pending_deletions.insert("testuser".to_string(), pending); + + assert_eq!(manager.pending_deletions.len(), 1); + + // Re-add user to CONFIG_DB + let mut data = HashMap::new(); + data.insert("role".to_string(), "administrator".to_string()); + data.insert("enabled".to_string(), "true".to_string()); + + let result = manager.handle_user_change("testuser", &data); + assert!(result.is_ok()); + + // Pending deletion should be cancelled + assert_eq!(manager.pending_deletions.len(), 0); + assert!(!manager.pending_deletions.contains_key("testuser")); + } + + #[test] + fn test_process_pending_deletions_initial_to_term_sent() { + let mut mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + // pkill should be called with SIGTERM + mock_cmd + .expect_execute() + .withf(|cmd| { + cmd.len() >= 4 + && cmd[0].contains("pkill") + && cmd[1] == "-TERM" + && cmd[2] == "-u" + && cmd[3] == "testuser" + }) + .times(1) + .returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(0), + Vec::new(), + Vec::new(), + )) + }); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + // Add user in Initial state with action time in the past + use crate::types::{DeletionState, PendingDeletion}; + use std::time::{Duration, Instant}; + + let pending = PendingDeletion { + username: "testuser".to_string(), + state: DeletionState::Initial, + next_action_time: Instant::now() - Duration::from_secs(1), // In the past + retry_count: 0, + }; + manager.pending_deletions.insert("testuser".to_string(), pending); + + // Process pending deletions + let _next_check = manager.process_pending_deletions(); + + // State should transition to TermSent + let pending = manager.pending_deletions.get("testuser").unwrap(); + assert_eq!(pending.state, DeletionState::TermSent); + assert_eq!(pending.retry_count, 0); + } + + #[test] + fn test_process_pending_deletions_term_sent_to_kill_sent() { + let mut mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + // pkill should be called with SIGKILL + mock_cmd + .expect_execute() + .withf(|cmd| { + cmd.len() >= 4 + && cmd[0].contains("pkill") + && cmd[1] == "-KILL" + && cmd[2] == "-u" + && cmd[3] == "testuser" + }) + .times(1) + .returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(0), + Vec::new(), + Vec::new(), + )) + }); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + // Add user in TermSent state with action time in the past + use crate::types::{DeletionState, PendingDeletion}; + use std::time::{Duration, Instant}; + + let pending = PendingDeletion { + username: "testuser".to_string(), + state: DeletionState::TermSent, + next_action_time: Instant::now() - Duration::from_secs(1), // In the past + retry_count: 0, + }; + manager.pending_deletions.insert("testuser".to_string(), pending); + + // Process pending deletions + let _next_check = manager.process_pending_deletions(); + + // State should transition to KillSent + let pending = manager.pending_deletions.get("testuser").unwrap(); + assert_eq!(pending.state, DeletionState::KillSent); + assert_eq!(pending.retry_count, 0); + } + + #[test] + fn test_process_pending_deletions_kill_sent_success() { + let mut mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // User exists + mock_sys.expect_getpwnam().returning(|name| { + Some(User { + name: name.to_string(), + passwd: CString::new("x").unwrap(), + uid: Uid::from_raw(1001), + gid: Gid::from_raw(1001), + gecos: CString::new("").unwrap(), + dir: PathBuf::from(format!("/home/{}", name)), + shell: PathBuf::from("/bin/bash"), + }) + }); + + // userdel should succeed + mock_cmd + .expect_execute() + .withf(|cmd| cmd.len() >= 3 && cmd[0].contains("userdel") && cmd[2] == "testuser") + .times(1) + .returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(0), + Vec::new(), + Vec::new(), + )) + }); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + // Add user in KillSent state with action time in the past + use crate::types::{DeletionState, PendingDeletion}; + use std::time::{Duration, Instant}; + + let pending = PendingDeletion { + username: "testuser".to_string(), + state: DeletionState::KillSent, + next_action_time: Instant::now() - Duration::from_secs(1), // In the past + retry_count: 0, + }; + manager.pending_deletions.insert("testuser".to_string(), pending); + + // Process pending deletions + let _next_check = manager.process_pending_deletions(); + + // User should be removed from pending queue (successfully deleted) + assert_eq!(manager.pending_deletions.len(), 0); + assert!(!manager.pending_deletions.contains_key("testuser")); + } + + #[test] + fn test_process_pending_deletions_kill_sent_retry_on_failure() { + let mut mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // User exists + mock_sys.expect_getpwnam().returning(|name| { + Some(User { + name: name.to_string(), + passwd: CString::new("x").unwrap(), + uid: Uid::from_raw(1001), + gid: Gid::from_raw(1001), + gecos: CString::new("").unwrap(), + dir: PathBuf::from(format!("/home/{}", name)), + shell: PathBuf::from("/bin/bash"), + }) + }); + + // userdel fails (still has processes) + mock_cmd + .expect_execute() + .withf(|cmd| cmd.len() >= 3 && cmd[0].contains("userdel") && cmd[2] == "testuser") + .times(1) + .returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(256), // Exit code 1 + Vec::new(), + b"userdel: user testuser is currently used by process 1234".to_vec(), + )) + }); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + // Add user in KillSent state with action time in the past + use crate::types::{DeletionState, PendingDeletion}; + use std::time::{Duration, Instant}; + + let pending = PendingDeletion { + username: "testuser".to_string(), + state: DeletionState::KillSent, + next_action_time: Instant::now() - Duration::from_secs(1), // In the past + retry_count: 0, + }; + manager.pending_deletions.insert("testuser".to_string(), pending); + + // Process pending deletions + let _next_check = manager.process_pending_deletions(); + + // User should still be in pending queue, state reset to Initial, retry_count incremented + assert_eq!(manager.pending_deletions.len(), 1); + let pending = manager.pending_deletions.get("testuser").unwrap(); + assert_eq!(pending.state, DeletionState::Initial); + assert_eq!(pending.retry_count, 1); + } + + #[test] + fn test_process_pending_deletions_max_retries_reached() { + let mut mock_cmd = MockCommandExecutor::new(); + let mut mock_sys = MockSystemFunctions::new(); + + // User exists + mock_sys.expect_getpwnam().returning(|name| { + Some(User { + name: name.to_string(), + passwd: CString::new("x").unwrap(), + uid: Uid::from_raw(1001), + gid: Gid::from_raw(1001), + gecos: CString::new("").unwrap(), + dir: PathBuf::from(format!("/home/{}", name)), + shell: PathBuf::from("/bin/bash"), + }) + }); + + // userdel fails (still has processes) + mock_cmd + .expect_execute() + .withf(|cmd| cmd.len() >= 3 && cmd[0].contains("userdel") && cmd[2] == "testuser") + .times(1) + .returning(|_| { + Ok(mock_result( + ExitStatus::from_raw(256), // Exit code 1 + Vec::new(), + b"userdel: user testuser is currently used by process 1234".to_vec(), + )) + }); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + // Add user in KillSent state with retry_count = 2 (next failure will be 3rd retry) + use crate::types::{DeletionState, PendingDeletion}; + use std::time::{Duration, Instant}; + + let pending = PendingDeletion { + username: "testuser".to_string(), + state: DeletionState::KillSent, + next_action_time: Instant::now() - Duration::from_secs(1), // In the past + retry_count: 2, + }; + manager.pending_deletions.insert("testuser".to_string(), pending); + + // Process pending deletions + let _next_check = manager.process_pending_deletions(); + + // User should still be in pending queue but marked as Failed + assert_eq!(manager.pending_deletions.len(), 1); + let pending = manager.pending_deletions.get("testuser").unwrap(); + assert_eq!(pending.state, DeletionState::Failed); + assert_eq!(pending.retry_count, 3); + } + + #[test] + fn test_process_pending_deletions_skips_future_actions() { + let mock_cmd = MockCommandExecutor::new(); + let mock_sys = MockSystemFunctions::new(); + + let mut manager = UserManager::new(mock_cmd, mock_sys); + + // Add user in Initial state with action time in the future + use crate::types::{DeletionState, PendingDeletion}; + use std::time::{Duration, Instant}; + + let pending = PendingDeletion { + username: "testuser".to_string(), + state: DeletionState::Initial, + next_action_time: Instant::now() + Duration::from_secs(60), // In the future + retry_count: 0, + }; + manager.pending_deletions.insert("testuser".to_string(), pending.clone()); + + // Process pending deletions + let _next_check = manager.process_pending_deletions(); + + // State should NOT change (action time is in the future) + let pending_after = manager.pending_deletions.get("testuser").unwrap(); + assert_eq!(pending_after.state, DeletionState::Initial); + assert_eq!(pending_after.retry_count, 0); + } +} diff --git a/data/debian/rules b/data/debian/rules index f122c7c5..0a1cdf28 100755 --- a/data/debian/rules +++ b/data/debian/rules @@ -18,6 +18,7 @@ override_dh_installsystemd: dh_installsystemd --no-start --name=featured dh_installsystemd --no-start --name=aaastatsd dh_installsystemd --no-start --name=procdockerstatsd + dh_installsystemd --no-start --name=userd dh_installsystemd --no-start --name=determine-reboot-cause dh_installsystemd --no-start --name=process-reboot-cause dh_installsystemd --no-start --name=gnoi-shutdown diff --git a/data/debian/sonic-host-services-data.userd.service b/data/debian/sonic-host-services-data.userd.service new file mode 100644 index 00000000..9798e1d4 --- /dev/null +++ b/data/debian/sonic-host-services-data.userd.service @@ -0,0 +1,13 @@ +[Unit] +Description=User management daemon +Requires=config-setup.service +After=config-setup.service +BindsTo=sonic.target +After=sonic.target + +[Service] +Type=simple +ExecStart=/usr/bin/userd +Restart=always +RestartSec=5 + diff --git a/data/templates/common-auth-faillock-authfail.j2 b/data/templates/common-auth-faillock-authfail.j2 new file mode 100644 index 00000000..cbcc8fc7 --- /dev/null +++ b/data/templates/common-auth-faillock-authfail.j2 @@ -0,0 +1,28 @@ +# PAM faillock authfail configuration managed by userd +# Do not edit manually - changes will be overwritten +# +# This file is included via substack from common-auth-sonic AFTER authentication +# to record failed login attempts. +# +# Role-based faillock policy: +# - Only applies to managed users (members of local_mgd group) +# - Administrator role (sudo group): deny={{ administrator_deny }} +# - Operator role (not in sudo group): deny={{ operator_deny }} + +{% if faillock_enabled %} +# Skip authfail for non-managed users (not in local_mgd group) +# If user is NOT in local_mgd, skip the next 4 lines (all faillock authfail logic) +auth [success=4 default=ignore] pam_succeed_if.so user notingroup local_mgd quiet + +# For managed users, check if administrator (in sudo group) +# If user IS in sudo, skip 2 lines to administrator authfail +auth [success=2 default=ignore] pam_succeed_if.so user ingroup sudo quiet + +# OPERATOR path: record failed attempt with operator policy, then skip admin authfail +auth [default=die] pam_faillock.so authfail deny={{ operator_deny }} +auth [default=1] pam_permit.so + +# ADMINISTRATOR path: record failed attempt with administrator policy +auth [default=die] pam_faillock.so authfail deny={{ administrator_deny }} +{% endif %} + diff --git a/data/templates/common-auth-faillock.j2 b/data/templates/common-auth-faillock.j2 new file mode 100644 index 00000000..b57150bb --- /dev/null +++ b/data/templates/common-auth-faillock.j2 @@ -0,0 +1,26 @@ +# PAM faillock authentication configuration managed by userd +# Do not edit manually - changes will be overwritten +# +# This file is included via substack from common-auth-sonic +# +# Role-based faillock policy: +# - Only applies to managed users (members of local_mgd group) +# - Administrator role (sudo group): deny={{ administrator_deny }} +# - Operator role (not in sudo group): deny={{ operator_deny }} + +{% if faillock_enabled %} +# Skip all faillock for non-managed users (not in local_mgd group) +# If user is NOT in local_mgd, skip the next 4 lines (all faillock logic) +auth [success=4 default=ignore] pam_succeed_if.so user notingroup local_mgd quiet + +# For managed users, check if administrator (in sudo group) +# If user IS in sudo, skip 2 lines to administrator faillock +auth [success=2 default=ignore] pam_succeed_if.so user ingroup sudo quiet + +# OPERATOR path: apply operator faillock policy, then skip admin faillock +auth required pam_faillock.so preauth deny={{ operator_deny }} +auth [default=1] pam_permit.so + +# ADMINISTRATOR path: apply administrator faillock policy +auth required pam_faillock.so preauth deny={{ administrator_deny }} +{% endif %} diff --git a/data/templates/common-auth-sonic.j2 b/data/templates/common-auth-sonic.j2 index 18289736..ac0f3023 100644 --- a/data/templates/common-auth-sonic.j2 +++ b/data/templates/common-auth-sonic.j2 @@ -9,8 +9,11 @@ # # here are the per-package modules (the "Primary" block) +# Include faillock configuration if present (managed by userd) +auth substack common-auth-faillock + {% if auth['login'] == 'local' %} -auth [success=1 default=ignore] pam_unix.so nullok try_first_pass +auth [success=2 default=ignore] pam_unix.so nullok try_first_pass {% elif auth['login'] == 'local,tacacs+' %} auth [success=done new_authtok_reqd=done default=ignore{{ ' auth_err=die' if not auth['failthrough'] }}] pam_unix.so nullok try_first_pass @@ -19,14 +22,14 @@ auth [success=done new_authtok_reqd=done default=ignore{{ ' auth_err=die' if not {% endfor %} {% if servers | count %} {% set last_server = servers | last %} -auth [success=1 default=ignore] pam_tacplus.so server={{ last_server.ip }}:{{ last_server.tcp_port }} secret={{ last_server.passkey }} login={{ last_server.auth_type }} timeout={{ last_server.timeout }} {% if last_server.vrf %} vrf={{ last_server.vrf }} {% endif %} {{ 'source_ip=%s' % src_ip if src_ip }} try_first_pass +auth [success=2 default=ignore] pam_tacplus.so server={{ last_server.ip }}:{{ last_server.tcp_port }} secret={{ last_server.passkey }} login={{ last_server.auth_type }} timeout={{ last_server.timeout }} {% if last_server.vrf %} vrf={{ last_server.vrf }} {% endif %} {{ 'source_ip=%s' % src_ip if src_ip }} try_first_pass {% endif %} {% elif auth['login'] == 'tacacs+' or auth['login'] == 'tacacs+,local' %} {% for server in servers %} auth [success=done new_authtok_reqd=done default=ignore{{ ' auth_err=die' if not auth['failthrough'] }}] pam_tacplus.so server={{ server.ip }}:{{ server.tcp_port }} secret={{ server.passkey }} login={{ server.auth_type }} timeout={{ server.timeout }} {%if server.vrf %} vrf={{ server.vrf }} {% endif %} {{ 'source_ip=%s' % src_ip if src_ip }} try_first_pass {% endfor %} -auth [success=1 default=ignore] pam_unix.so nullok try_first_pass +auth [success=2 default=ignore] pam_unix.so nullok try_first_pass {% elif auth['login'] == 'local,radius' %} auth [success=done new_authtok_reqd=done default=ignore{{ ' auth_err=die maxtries=die' if not auth['failthrough'] }}] pam_unix.so nullok try_first_pass @@ -36,7 +39,7 @@ auth [success={{ (servers | count) - loop.index0 }} new_authtok_reqd=done defaul {% endfor %} auth requisite pam_deny.so # Cache MPL(Privilege) -auth [success=1 default=ignore] pam_exec.so /usr/sbin/cache_radius +auth [success=2 default=ignore] pam_exec.so /usr/sbin/cache_radius {% elif auth['login'] == 'radius,local' %} # root user can only be authenticated locally. Jump to local. @@ -53,7 +56,7 @@ auth [success={{ (servers | count) + 1 - loop.index0 }} new_authtok_reqd=done de auth [success=done new_authtok_reqd=done default=ignore{{ ' auth_err=die maxtries=die' if not auth['failthrough'] }}] pam_unix.so nullok try_first_pass auth requisite pam_deny.so # Cache MPL(Privilege) -auth [success=1 default=ignore] pam_exec.so /usr/sbin/cache_radius +auth [success=2 default=ignore] pam_exec.so /usr/sbin/cache_radius {% elif auth['login'] == 'radius' %} # root user can only be authenticated locally. Jump to local. @@ -64,24 +67,27 @@ auth [success={{ (servers | count) - loop.index0 }} new_authtok_reqd=done defaul {% endfor %} auth requisite pam_deny.so # Cache MPL(Privilege) -auth [success=2 default=ignore] pam_exec.so /usr/sbin/cache_radius +auth [success=3 default=ignore] pam_exec.so /usr/sbin/cache_radius # Local auth [success=done new_authtok_reqd=done default=ignore{{ ' auth_err=die maxtries=die' if not auth['failthrough'] }}] pam_unix.so nullok try_first_pass {% elif auth['login'] == 'ldap,local' %} -auth [success=2 default=ignore] pam_ldap.so minimum_uid=1000 try_first_pass -auth [success=1 default=ignore] pam_unix.so nullok try_first_pass -{% elif auth['login'] == 'local,ldap' %} +auth [success=3 default=ignore] pam_ldap.so minimum_uid=1000 try_first_pass auth [success=2 default=ignore] pam_unix.so nullok try_first_pass -auth [success=1 default=ignore] pam_ldap.so minimum_uid=1000 try_first_pass +{% elif auth['login'] == 'local,ldap' %} +auth [success=3 default=ignore] pam_unix.so nullok try_first_pass +auth [success=2 default=ignore] pam_ldap.so minimum_uid=1000 try_first_pass {% elif auth['login'] == 'ldap' %} -auth [success=1 default=ignore] pam_ldap.so minimum_uid=1000 try_first_pass +auth [success=2 default=ignore] pam_ldap.so minimum_uid=1000 try_first_pass {% else %} -auth [success=1 default=ignore] pam_unix.so nullok try_first_pass +auth [success=2 default=ignore] pam_unix.so nullok try_first_pass {% endif %} # +# Record failed login attempts for faillock (managed by userd) +auth substack common-auth-faillock-authfail + # here's the fallback if no module succeeds auth requisite pam_deny.so # prime the stack with a positive return value if there isn't one already; diff --git a/data/templates/faillock.conf.j2 b/data/templates/faillock.conf.j2 new file mode 100644 index 00000000..1c465565 --- /dev/null +++ b/data/templates/faillock.conf.j2 @@ -0,0 +1,16 @@ +# PAM faillock configuration managed by userd +# Do not edit manually - changes will be overwritten +# +# Note: deny= values are set per-role in /etc/pam.d/common-auth-faillock +# This file contains only global faillock settings. + +# Global settings +dir = /var/run/faillock +audit +silent +no_log_info + +# Timing settings +unlock_time = 900 +fail_interval = 900 +root_unlock_time = 900 diff --git a/debian/control b/debian/control index 690c71b1..42d2f49f 100644 --- a/debian/control +++ b/debian/control @@ -7,5 +7,8 @@ Standards-Version: 1.0.0 Package: sonic-host-services-rs Architecture: any -Depends: ${shlibs:Depends} -Description: Process and Docker statistics daemon in Rust \ No newline at end of file +Depends: ${shlibs:Depends}, ${misc:Depends}, sonic-host-services-data, libpam-modules-bin +Description: SONiC host services in Rust + Contains Rust implementations of SONiC host services: + - procdockerstatsd-rs: Process and Docker statistics daemon + - userd: User management daemon \ No newline at end of file diff --git a/debian/install b/debian/install index 305995cd..b0a4303f 100644 --- a/debian/install +++ b/debian/install @@ -1 +1,2 @@ -target/release/procdockerstatsd-rs usr/bin \ No newline at end of file +target/release/procdockerstatsd-rs usr/bin +target/release/userd usr/bin \ No newline at end of file diff --git a/debian/rules b/debian/rules index 937b2f1c..82668528 100755 --- a/debian/rules +++ b/debian/rules @@ -19,5 +19,4 @@ override_dh_auto_clean: cargo clean --release override_dh_auto_test: - # do nothing - : \ No newline at end of file + cargo test --release --all \ No newline at end of file diff --git a/tests/hostcfgd/hostcfgd_radius_test.py b/tests/hostcfgd/hostcfgd_radius_test.py index dfb21dc8..99ad213b 100644 --- a/tests/hostcfgd/hostcfgd_radius_test.py +++ b/tests/hostcfgd/hostcfgd_radius_test.py @@ -10,6 +10,7 @@ from unittest import TestCase, mock from tests.hostcfgd.test_radius_vectors import HOSTCFGD_TEST_RADIUS_VECTOR from tests.common.mock_configdb import MockConfigDb, MockDBConnector +from tests.common.mock_restart_waiter import MockRestartWaiter from sonic_py_common.general import getstatusoutput_noshell @@ -33,6 +34,7 @@ hostcfgd.ConfigDBConnector = MockConfigDb hostcfgd.DBConnector = MockDBConnector hostcfgd.Table = mock.Mock() +swsscommon.RestartWaiter = MockRestartWaiter class TestHostcfgdRADIUS(TestCase): """ diff --git a/tests/hostcfgd/hostcfgd_tacacs_test.py b/tests/hostcfgd/hostcfgd_tacacs_test.py index c07b961c..75db5749 100644 --- a/tests/hostcfgd/hostcfgd_tacacs_test.py +++ b/tests/hostcfgd/hostcfgd_tacacs_test.py @@ -11,6 +11,7 @@ from sonic_py_common.general import getstatusoutput_noshell from tests.hostcfgd.test_tacacs_vectors import HOSTCFGD_TEST_TACACS_VECTOR from tests.common.mock_configdb import MockConfigDb, MockDBConnector +from tests.common.mock_restart_waiter import MockRestartWaiter test_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) modules_path = os.path.dirname(test_path) @@ -32,6 +33,7 @@ hostcfgd.ConfigDBConnector = MockConfigDb hostcfgd.DBConnector = MockDBConnector hostcfgd.Table = mock.Mock() +swsscommon.RestartWaiter = MockRestartWaiter class TestHostcfgdTACACS(TestCase): """ @@ -129,6 +131,9 @@ def test_hostcfgd_tacacs(self, test_name, test_data): self.check_config(test_name, test_data, "config_db_local_and_tacacs") # test disable accounting self.check_config(test_name, test_data, "config_db_disable_accounting") + # test local,tacacs+ authentication (if config exists) + if "config_db_local_tacacs" in test_data: + self.check_config(test_name, test_data, "config_db_local_tacacs") @parameterized.expand(HOSTCFGD_TEST_TACACS_VECTOR) def test_hostcfgd_sshd_not_empty(self, test_name, test_data): diff --git a/tests/hostcfgd/sample_output/LDAP/common-auth-sonic b/tests/hostcfgd/sample_output/LDAP/common-auth-sonic index 848565cd..df76238e 100644 --- a/tests/hostcfgd/sample_output/LDAP/common-auth-sonic +++ b/tests/hostcfgd/sample_output/LDAP/common-auth-sonic @@ -9,9 +9,15 @@ # # here are the per-package modules (the "Primary" block) -auth [success=2 default=ignore] pam_ldap.so minimum_uid=1000 try_first_pass -auth [success=1 default=ignore] pam_unix.so nullok try_first_pass +# Include faillock configuration if present (managed by userd) +auth substack common-auth-faillock + +auth [success=3 default=ignore] pam_ldap.so minimum_uid=1000 try_first_pass +auth [success=2 default=ignore] pam_unix.so nullok try_first_pass # +# Record failed login attempts for faillock (managed by userd) +auth substack common-auth-faillock-authfail + # here's the fallback if no module succeeds auth requisite pam_deny.so # prime the stack with a positive return value if there isn't one already; diff --git a/tests/hostcfgd/sample_output/LOCAL/common-auth-sonic b/tests/hostcfgd/sample_output/LOCAL/common-auth-sonic index 87af4cc5..8d377108 100644 --- a/tests/hostcfgd/sample_output/LOCAL/common-auth-sonic +++ b/tests/hostcfgd/sample_output/LOCAL/common-auth-sonic @@ -9,9 +9,15 @@ # # here are the per-package modules (the "Primary" block) -auth [success=1 default=ignore] pam_unix.so nullok try_first_pass +# Include faillock configuration if present (managed by userd) +auth substack common-auth-faillock + +auth [success=2 default=ignore] pam_unix.so nullok try_first_pass # +# Record failed login attempts for faillock (managed by userd) +auth substack common-auth-faillock-authfail + # here's the fallback if no module succeeds auth requisite pam_deny.so # prime the stack with a positive return value if there isn't one already; diff --git a/tests/hostcfgd/sample_output/LOCAL_RADIUS/10.10.10.1_1645.conf b/tests/hostcfgd/sample_output/LOCAL_RADIUS/10.10.10.1_1645.conf new file mode 100644 index 00000000..ffdda7b4 --- /dev/null +++ b/tests/hostcfgd/sample_output/LOCAL_RADIUS/10.10.10.1_1645.conf @@ -0,0 +1,2 @@ +# server[:port] shared_secret timeout(s) source_ip vrf +[10.10.10.1]:1645 pass1 1 \ No newline at end of file diff --git a/tests/hostcfgd/sample_output/LOCAL_RADIUS/10.10.10.2_1645.conf b/tests/hostcfgd/sample_output/LOCAL_RADIUS/10.10.10.2_1645.conf new file mode 100644 index 00000000..3b680807 --- /dev/null +++ b/tests/hostcfgd/sample_output/LOCAL_RADIUS/10.10.10.2_1645.conf @@ -0,0 +1,2 @@ +# server[:port] shared_secret timeout(s) source_ip vrf +[10.10.10.2]:1645 pass2 2 \ No newline at end of file diff --git a/tests/hostcfgd/sample_output/LOCAL_RADIUS/common-auth-sonic b/tests/hostcfgd/sample_output/LOCAL_RADIUS/common-auth-sonic new file mode 100644 index 00000000..6857f7f8 --- /dev/null +++ b/tests/hostcfgd/sample_output/LOCAL_RADIUS/common-auth-sonic @@ -0,0 +1,33 @@ +#THIS IS AN AUTO-GENERATED FILE +# +# /etc/pam.d/common-auth- authentication settings common to all services +# This file is included from other service-specific PAM config files, +# and should contain a list of the authentication modules that define +# the central authentication scheme for use on the system +# (e.g., /etc/shadow, LDAP, Kerberos, etc.). The default is to use the +# traditional Unix authentication mechanisms. +# +# here are the per-package modules (the "Primary" block) + +# Include faillock configuration if present (managed by userd) +auth substack common-auth-faillock + +auth [success=done new_authtok_reqd=done default=ignore auth_err=die maxtries=die] pam_unix.so nullok try_first_pass +# For the RADIUS servers, on success jump to the cacheing the MPL(Privilege) +auth [success=2 new_authtok_reqd=done default=ignore auth_err=die] pam_radius_auth.so conf=/etc/pam_radius_auth.d/10.10.10.1_1645.conf privilege_level protocol=pap retry=1 nas_ip_address=10.10.10.10 debug try_first_pass require_message_authenticator +auth [success=1 new_authtok_reqd=done default=ignore auth_err=die] pam_radius_auth.so conf=/etc/pam_radius_auth.d/10.10.10.2_1645.conf privilege_level protocol=chap retry=2 nas_ip_address=10.10.10.10 debug try_first_pass require_message_authenticator +auth requisite pam_deny.so +# Cache MPL(Privilege) +auth [success=2 default=ignore] pam_exec.so /usr/sbin/cache_radius + +# +# Record failed login attempts for faillock (managed by userd) +auth substack common-auth-faillock-authfail + +# here's the fallback if no module succeeds +auth requisite pam_deny.so +# prime the stack with a positive return value if there isn't one already; +# this avoids us returning an error just because nothing sets a success code +# since the modules above will each just jump around +auth required pam_permit.so +# and here are more per-package modules (the "Additional" block) diff --git a/tests/hostcfgd/sample_output/LOCAL_RADIUS/login b/tests/hostcfgd/sample_output/LOCAL_RADIUS/login new file mode 100644 index 00000000..80ba6452 --- /dev/null +++ b/tests/hostcfgd/sample_output/LOCAL_RADIUS/login @@ -0,0 +1,116 @@ +# +# The PAM configuration file for the Shadow `login' service +# + +# Enforce a minimal delay in case of failure (in microseconds). +# (Replaces the `FAIL_DELAY' setting from login.defs) +# Note that other modules may require another minimal delay. (for example, +# to disable any delay, you should add the nodelay option to pam_unix) +auth optional pam_faildelay.so delay=3000000 + +# Outputs an issue file prior to each login prompt (Replaces the +# ISSUE_FILE option from login.defs). Uncomment for use +# auth required pam_issue.so issue=/etc/issue + +# Disallows root logins except on tty's listed in /etc/securetty +# (Replaces the `CONSOLE' setting from login.defs) +# +# With the default control of this module: +# [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] +# root will not be prompted for a password on insecure lines. +# if an invalid username is entered, a password is prompted (but login +# will eventually be rejected) +# +# You can change it to a "requisite" module if you think root may mis-type +# her login and should not be prompted for a password in that case. But +# this will leave the system as vulnerable to user enumeration attacks. +# +# You can change it to a "required" module if you think it permits to +# guess valid user names of your system (invalid user names are considered +# as possibly being root on insecure lines), but root passwords may be +# communicated over insecure lines. +auth [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] pam_securetty.so + +# Disallows other than root logins when /etc/nologin exists +# (Replaces the `NOLOGINS_FILE' option from login.defs) +auth requisite pam_nologin.so + +# SELinux needs to be the first session rule. This ensures that any +# lingering context has been cleared. Without this it is possible +# that a module could execute code in the wrong domain. +# When the module is present, "required" would be sufficient (When SELinux +# is disabled, this returns success.) +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close + +# Sets the loginuid process attribute +session required pam_loginuid.so + +# SELinux needs to intervene at login time to ensure that the process +# starts in the proper default security context. Only sessions which are +# intended to run in the user's context should be run after this. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open +# When the module is present, "required" would be sufficient (When SELinux +# is disabled, this returns success.) + +# This module parses environment configuration file(s) +# and also allows you to use an extended config +# file /etc/security/pam_env.conf. +# +# parsing /etc/environment needs "readenv=1" +session required pam_env.so readenv=1 +# locale variables are also kept into /etc/default/locale in etch +# reading this file *in addition to /etc/environment* does not hurt +session required pam_env.so readenv=1 envfile=/etc/default/locale + +# Standard Un*x authentication. +@include common-auth-sonic + +# This allows certain extra groups to be granted to a user +# based on things like time of day, tty, service, and user. +# Please edit /etc/security/group.conf to fit your needs +# (Replaces the `CONSOLE_GROUPS' option in login.defs) +auth optional pam_group.so + +# Uncomment and edit /etc/security/time.conf if you need to set +# time restraint on logins. +# (Replaces the `PORTTIME_CHECKS_ENAB' option from login.defs +# as well as /etc/porttime) +# account requisite pam_time.so + +# Uncomment and edit /etc/security/access.conf if you need to +# set access limits. +# (Replaces /etc/login.access file) +# account required pam_access.so + +# Sets up user limits according to /etc/security/limits.conf +# (Replaces the use of /etc/limits in old login) +session required pam_limits.so + +# Prints the last login info upon successful login +# (Replaces the `LASTLOG_ENAB' option from login.defs) +session optional pam_lastlog.so + +# Prints the message of the day upon successful login. +# (Replaces the `MOTD_FILE' option in login.defs) +# This includes a dynamically generated part from /run/motd.dynamic +# and a static (admin-editable) part from /etc/motd. +session optional pam_motd.so motd=/run/motd.dynamic +session optional pam_motd.so noupdate + +# Prints the status of the user's mailbox upon successful login +# (Replaces the `MAIL_CHECK_ENAB' option from login.defs). +# +# This also defines the MAIL environment variable +# However, userdel also needs MAIL_DIR and MAIL_FILE variables +# in /etc/login.defs to make sure that removing a user +# also removes the user's mail spool file. +# See comments in /etc/login.defs +session optional pam_mail.so standard + +# Create a new session keyring. +session optional pam_keyinit.so force revoke + +# Standard Un*x account and session +@include common-account +@include common-session +@include common-password diff --git a/tests/hostcfgd/sample_output/LOCAL_RADIUS/login.old b/tests/hostcfgd/sample_output/LOCAL_RADIUS/login.old new file mode 100644 index 00000000..07ff9540 --- /dev/null +++ b/tests/hostcfgd/sample_output/LOCAL_RADIUS/login.old @@ -0,0 +1,116 @@ +# +# The PAM configuration file for the Shadow `login' service +# + +# Enforce a minimal delay in case of failure (in microseconds). +# (Replaces the `FAIL_DELAY' setting from login.defs) +# Note that other modules may require another minimal delay. (for example, +# to disable any delay, you should add the nodelay option to pam_unix) +auth optional pam_faildelay.so delay=3000000 + +# Outputs an issue file prior to each login prompt (Replaces the +# ISSUE_FILE option from login.defs). Uncomment for use +# auth required pam_issue.so issue=/etc/issue + +# Disallows root logins except on tty's listed in /etc/securetty +# (Replaces the `CONSOLE' setting from login.defs) +# +# With the default control of this module: +# [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] +# root will not be prompted for a password on insecure lines. +# if an invalid username is entered, a password is prompted (but login +# will eventually be rejected) +# +# You can change it to a "requisite" module if you think root may mis-type +# her login and should not be prompted for a password in that case. But +# this will leave the system as vulnerable to user enumeration attacks. +# +# You can change it to a "required" module if you think it permits to +# guess valid user names of your system (invalid user names are considered +# as possibly being root on insecure lines), but root passwords may be +# communicated over insecure lines. +auth [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] pam_securetty.so + +# Disallows other than root logins when /etc/nologin exists +# (Replaces the `NOLOGINS_FILE' option from login.defs) +auth requisite pam_nologin.so + +# SELinux needs to be the first session rule. This ensures that any +# lingering context has been cleared. Without this it is possible +# that a module could execute code in the wrong domain. +# When the module is present, "required" would be sufficient (When SELinux +# is disabled, this returns success.) +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close + +# Sets the loginuid process attribute +session required pam_loginuid.so + +# SELinux needs to intervene at login time to ensure that the process +# starts in the proper default security context. Only sessions which are +# intended to run in the user's context should be run after this. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open +# When the module is present, "required" would be sufficient (When SELinux +# is disabled, this returns success.) + +# This module parses environment configuration file(s) +# and also allows you to use an extended config +# file /etc/security/pam_env.conf. +# +# parsing /etc/environment needs "readenv=1" +session required pam_env.so readenv=1 +# locale variables are also kept into /etc/default/locale in etch +# reading this file *in addition to /etc/environment* does not hurt +session required pam_env.so readenv=1 envfile=/etc/default/locale + +# Standard Un*x authentication. +@include common-auth + +# This allows certain extra groups to be granted to a user +# based on things like time of day, tty, service, and user. +# Please edit /etc/security/group.conf to fit your needs +# (Replaces the `CONSOLE_GROUPS' option in login.defs) +auth optional pam_group.so + +# Uncomment and edit /etc/security/time.conf if you need to set +# time restraint on logins. +# (Replaces the `PORTTIME_CHECKS_ENAB' option from login.defs +# as well as /etc/porttime) +# account requisite pam_time.so + +# Uncomment and edit /etc/security/access.conf if you need to +# set access limits. +# (Replaces /etc/login.access file) +# account required pam_access.so + +# Sets up user limits according to /etc/security/limits.conf +# (Replaces the use of /etc/limits in old login) +session required pam_limits.so + +# Prints the last login info upon successful login +# (Replaces the `LASTLOG_ENAB' option from login.defs) +session optional pam_lastlog.so + +# Prints the message of the day upon successful login. +# (Replaces the `MOTD_FILE' option in login.defs) +# This includes a dynamically generated part from /run/motd.dynamic +# and a static (admin-editable) part from /etc/motd. +session optional pam_motd.so motd=/run/motd.dynamic +session optional pam_motd.so noupdate + +# Prints the status of the user's mailbox upon successful login +# (Replaces the `MAIL_CHECK_ENAB' option from login.defs). +# +# This also defines the MAIL environment variable +# However, userdel also needs MAIL_DIR and MAIL_FILE variables +# in /etc/login.defs to make sure that removing a user +# also removes the user's mail spool file. +# See comments in /etc/login.defs +session optional pam_mail.so standard + +# Create a new session keyring. +session optional pam_keyinit.so force revoke + +# Standard Un*x account and session +@include common-account +@include common-session +@include common-password diff --git a/tests/hostcfgd/sample_output/LOCAL_RADIUS/radius_nss.conf b/tests/hostcfgd/sample_output/LOCAL_RADIUS/radius_nss.conf new file mode 100644 index 00000000..8c31db9f --- /dev/null +++ b/tests/hostcfgd/sample_output/LOCAL_RADIUS/radius_nss.conf @@ -0,0 +1,56 @@ +#THIS IS AN AUTO-GENERATED FILE +# Generated from: /usr/share/sonic/templates/radius_nss.conf.j2 +# RADIUS NSS Configuration File +# +# Debug: on|off|trace +# Default: off +# +# debug=on +debug=on + +# +# User Privilege: +# Default: +# user_priv=15;pw_info=remote_user_su;gid=1000;group=sudo,docker;shell=/usr/bin/sonic-launch-shell +# user_priv=1;pw_info=remote_user;gid=999;group=docker;shell=/usr/bin/sonic-launch-shell + +# Eg: +# user_priv=15;pw_info=remote_user_su;gid=1000;group=sudo,docker;shell=/usr/bin/sonic-launch-shell +# user_priv=7;pw_info=netops;gid=999;group=docker;shell=/usr/bin/sonic-launch-shell +# user_priv=1;pw_info=operator;gid=100;group=docker;shell=/usr/bin/sonic-launch-shell +# + +# many_to_one: +# y: Map RADIUS users to one local user per privilege. +# n: Create local user account on first successful authentication. +# Default: n +# + +# Eg: +# many_to_one=y +# + +# unconfirmed_disallow: +# y: Do not allow unconfirmed users (users created before authentication) +# n: Allow unconfirmed users. +# Default: n + +# Eg: +# unconfirmed_disallow=y +# + +# unconfirmed_ageout: +# : Wait time before purging unconfirmed users +# Default: 600 +# + +# Eg: +# unconfirmed_ageout=900 +# + +# unconfirmed_regexp: +# : The RE to match the command line of processes for which the +# creation of unconfirmed users are to be allowed. +# Default: (.*: \[priv\])|(.*: \[accepted\]) +# where: is the unconfirmed user. +# \ No newline at end of file diff --git a/tests/hostcfgd/sample_output/LOCAL_RADIUS/sshd b/tests/hostcfgd/sample_output/LOCAL_RADIUS/sshd new file mode 100644 index 00000000..c025af35 --- /dev/null +++ b/tests/hostcfgd/sample_output/LOCAL_RADIUS/sshd @@ -0,0 +1,55 @@ +# PAM configuration for the Secure Shell service + +# Standard Un*x authentication. +@include common-auth-sonic + +# Disallow non-root logins when /etc/nologin exists. +account required pam_nologin.so + +# Uncomment and edit /etc/security/access.conf if you need to set complex +# access limits that are hard to express in sshd_config. +# account required pam_access.so + +# Standard Un*x authorization. +@include common-account + +# SELinux needs to be the first session rule. This ensures that any +# lingering context has been cleared. Without this it is possible that a +# module could execute code in the wrong domain. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close + +# Set the loginuid process attribute. +session required pam_loginuid.so + +# Create a new session keyring. +session optional pam_keyinit.so force revoke + +# Standard Un*x session setup and teardown. +@include common-session + +# Print the message of the day upon successful login. +# This includes a dynamically generated part from /run/motd.dynamic +# and a static (admin-editable) part from /etc/motd. +session optional pam_motd.so motd=/run/motd.dynamic +session optional pam_motd.so noupdate + +# Print the status of the user's mailbox upon successful login. +session optional pam_mail.so standard noenv # [1] + +# Set up user limits from /etc/security/limits.conf. +session required pam_limits.so + +# Read environment variables from /etc/environment and +# /etc/security/pam_env.conf. +session required pam_env.so # [1] +# In Debian 4.0 (etch), locale-related environment variables were moved to +# /etc/default/locale, so read that as well. +session required pam_env.so user_readenv=1 envfile=/etc/default/locale + +# SELinux needs to intervene at login time to ensure that the process starts +# in the proper default security context. Only sessions which are intended +# to run in the user's context should be run after this. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open + +# Standard Un*x password updating. +@include common-password diff --git a/tests/hostcfgd/sample_output/LOCAL_RADIUS/sshd.old b/tests/hostcfgd/sample_output/LOCAL_RADIUS/sshd.old new file mode 100644 index 00000000..d70b384b --- /dev/null +++ b/tests/hostcfgd/sample_output/LOCAL_RADIUS/sshd.old @@ -0,0 +1,55 @@ +# PAM configuration for the Secure Shell service + +# Standard Un*x authentication. +@include common-auth + +# Disallow non-root logins when /etc/nologin exists. +account required pam_nologin.so + +# Uncomment and edit /etc/security/access.conf if you need to set complex +# access limits that are hard to express in sshd_config. +# account required pam_access.so + +# Standard Un*x authorization. +@include common-account + +# SELinux needs to be the first session rule. This ensures that any +# lingering context has been cleared. Without this it is possible that a +# module could execute code in the wrong domain. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close + +# Set the loginuid process attribute. +session required pam_loginuid.so + +# Create a new session keyring. +session optional pam_keyinit.so force revoke + +# Standard Un*x session setup and teardown. +@include common-session + +# Print the message of the day upon successful login. +# This includes a dynamically generated part from /run/motd.dynamic +# and a static (admin-editable) part from /etc/motd. +session optional pam_motd.so motd=/run/motd.dynamic +session optional pam_motd.so noupdate + +# Print the status of the user's mailbox upon successful login. +session optional pam_mail.so standard noenv # [1] + +# Set up user limits from /etc/security/limits.conf. +session required pam_limits.so + +# Read environment variables from /etc/environment and +# /etc/security/pam_env.conf. +session required pam_env.so # [1] +# In Debian 4.0 (etch), locale-related environment variables were moved to +# /etc/default/locale, so read that as well. +session required pam_env.so user_readenv=1 envfile=/etc/default/locale + +# SELinux needs to intervene at login time to ensure that the process starts +# in the proper default security context. Only sessions which are intended +# to run in the user's context should be run after this. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open + +# Standard Un*x password updating. +@include common-password diff --git a/tests/hostcfgd/sample_output/LOCAL_RADIUS/tacplus_nss.conf b/tests/hostcfgd/sample_output/LOCAL_RADIUS/tacplus_nss.conf new file mode 100644 index 00000000..eac82849 --- /dev/null +++ b/tests/hostcfgd/sample_output/LOCAL_RADIUS/tacplus_nss.conf @@ -0,0 +1,40 @@ +# Configuration for libnss-tacplus + +# debug - If you want to open debug log, set it on +# Default: off +# debug=on +debug=on + +# local_accounting - If you want to local accounting, set it +# Default: None +# local_accounting + +# tacacs_accounting - If you want to tacacs+ accounting, set it +# Default: None +# tacacs_accounting + +# local_authorization - If you want to local authorization, set it +# Default: None +# local_authorization +local_authorization + +# tacacs_authorization - If you want to tacacs+ authorization, set it +# Default: None +# tacacs_authorization + +# src_ip - set source address of TACACS+ protocol packets +# Default: None (auto source ip address) +# src_ip=2.2.2.2 + +# server - set ip address, tcp port, secret string and timeout for TACACS+ servers +# Default: None (no TACACS+ server) +# server=1.1.1.1:49,secret=test,timeout=3 + +# user_priv - set the map between TACACS+ user privilege and local user's passwd +# Default: +# user_priv=15;pw_info=remote_user_su;gid=1000;group=sudo,docker;shell=/bin/bash +# user_priv=1;pw_info=remote_user;gid=999;group=docker;shell=/bin/bash + +# many_to_one - create one local user for many TACACS+ users which has the same privilege +# Default: many_to_one=n +# many_to_one=y diff --git a/tests/hostcfgd/sample_output/RADIUS/common-auth-sonic b/tests/hostcfgd/sample_output/RADIUS/common-auth-sonic index 1e9c69a3..75cecd08 100644 --- a/tests/hostcfgd/sample_output/RADIUS/common-auth-sonic +++ b/tests/hostcfgd/sample_output/RADIUS/common-auth-sonic @@ -9,6 +9,9 @@ # # here are the per-package modules (the "Primary" block) +# Include faillock configuration if present (managed by userd) +auth substack common-auth-faillock + # root user can only be authenticated locally. Jump to local. auth [success=5 default=ignore] pam_succeed_if.so user = root # For the RADIUS servers, on success jump to the cache the MPL(Privilege) @@ -21,9 +24,12 @@ auth [success=2 new_authtok_reqd=done default=ignore auth_err=die] pam_radius_au auth [success=done new_authtok_reqd=done default=ignore auth_err=die maxtries=die] pam_unix.so nullok try_first_pass auth requisite pam_deny.so # Cache MPL(Privilege) -auth [success=1 default=ignore] pam_exec.so /usr/sbin/cache_radius +auth [success=2 default=ignore] pam_exec.so /usr/sbin/cache_radius # +# Record failed login attempts for faillock (managed by userd) +auth substack common-auth-faillock-authfail + # here's the fallback if no module succeeds auth requisite pam_deny.so # prime the stack with a positive return value if there isn't one already; diff --git a/tests/hostcfgd/sample_output/TACACS_config_db_disable_accounting/common-auth-sonic b/tests/hostcfgd/sample_output/TACACS_config_db_disable_accounting/common-auth-sonic index 87af4cc5..8d377108 100644 --- a/tests/hostcfgd/sample_output/TACACS_config_db_disable_accounting/common-auth-sonic +++ b/tests/hostcfgd/sample_output/TACACS_config_db_disable_accounting/common-auth-sonic @@ -9,9 +9,15 @@ # # here are the per-package modules (the "Primary" block) -auth [success=1 default=ignore] pam_unix.so nullok try_first_pass +# Include faillock configuration if present (managed by userd) +auth substack common-auth-faillock + +auth [success=2 default=ignore] pam_unix.so nullok try_first_pass # +# Record failed login attempts for faillock (managed by userd) +auth substack common-auth-faillock-authfail + # here's the fallback if no module succeeds auth requisite pam_deny.so # prime the stack with a positive return value if there isn't one already; diff --git a/tests/hostcfgd/sample_output/TACACS_config_db_local/common-auth-sonic b/tests/hostcfgd/sample_output/TACACS_config_db_local/common-auth-sonic index 87af4cc5..8d377108 100644 --- a/tests/hostcfgd/sample_output/TACACS_config_db_local/common-auth-sonic +++ b/tests/hostcfgd/sample_output/TACACS_config_db_local/common-auth-sonic @@ -9,9 +9,15 @@ # # here are the per-package modules (the "Primary" block) -auth [success=1 default=ignore] pam_unix.so nullok try_first_pass +# Include faillock configuration if present (managed by userd) +auth substack common-auth-faillock + +auth [success=2 default=ignore] pam_unix.so nullok try_first_pass # +# Record failed login attempts for faillock (managed by userd) +auth substack common-auth-faillock-authfail + # here's the fallback if no module succeeds auth requisite pam_deny.so # prime the stack with a positive return value if there isn't one already; diff --git a/tests/hostcfgd/sample_output/TACACS_config_db_local_and_tacacs/common-auth-sonic b/tests/hostcfgd/sample_output/TACACS_config_db_local_and_tacacs/common-auth-sonic index 87af4cc5..8d377108 100644 --- a/tests/hostcfgd/sample_output/TACACS_config_db_local_and_tacacs/common-auth-sonic +++ b/tests/hostcfgd/sample_output/TACACS_config_db_local_and_tacacs/common-auth-sonic @@ -9,9 +9,15 @@ # # here are the per-package modules (the "Primary" block) -auth [success=1 default=ignore] pam_unix.so nullok try_first_pass +# Include faillock configuration if present (managed by userd) +auth substack common-auth-faillock + +auth [success=2 default=ignore] pam_unix.so nullok try_first_pass # +# Record failed login attempts for faillock (managed by userd) +auth substack common-auth-faillock-authfail + # here's the fallback if no module succeeds auth requisite pam_deny.so # prime the stack with a positive return value if there isn't one already; diff --git a/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/common-auth-sonic b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/common-auth-sonic new file mode 100644 index 00000000..3f2e72fc --- /dev/null +++ b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/common-auth-sonic @@ -0,0 +1,29 @@ +#THIS IS AN AUTO-GENERATED FILE +# +# /etc/pam.d/common-auth- authentication settings common to all services +# This file is included from other service-specific PAM config files, +# and should contain a list of the authentication modules that define +# the central authentication scheme for use on the system +# (e.g., /etc/shadow, LDAP, Kerberos, etc.). The default is to use the +# traditional Unix authentication mechanisms. +# +# here are the per-package modules (the "Primary" block) + +# Include faillock configuration if present (managed by userd) +auth substack common-auth-faillock + +auth [success=done new_authtok_reqd=done default=ignore auth_err=die] pam_unix.so nullok try_first_pass +auth [success=done new_authtok_reqd=done default=ignore auth_err=die] pam_tacplus.so server=192.168.1.1:50 secret=dellsonic login=chap timeout=10 vrf=default try_first_pass +auth [success=2 default=ignore] pam_tacplus.so server=192.168.1.2:51 secret=dellsonic1 login=pap timeout=15 vrf=mgmt try_first_pass + +# +# Record failed login attempts for faillock (managed by userd) +auth substack common-auth-faillock-authfail + +# here's the fallback if no module succeeds +auth requisite pam_deny.so +# prime the stack with a positive return value if there isn't one already; +# this avoids us returning an error just because nothing sets a success code +# since the modules above will each just jump around +auth required pam_permit.so +# and here are more per-package modules (the "Additional" block) diff --git a/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/login b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/login new file mode 100644 index 00000000..80ba6452 --- /dev/null +++ b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/login @@ -0,0 +1,116 @@ +# +# The PAM configuration file for the Shadow `login' service +# + +# Enforce a minimal delay in case of failure (in microseconds). +# (Replaces the `FAIL_DELAY' setting from login.defs) +# Note that other modules may require another minimal delay. (for example, +# to disable any delay, you should add the nodelay option to pam_unix) +auth optional pam_faildelay.so delay=3000000 + +# Outputs an issue file prior to each login prompt (Replaces the +# ISSUE_FILE option from login.defs). Uncomment for use +# auth required pam_issue.so issue=/etc/issue + +# Disallows root logins except on tty's listed in /etc/securetty +# (Replaces the `CONSOLE' setting from login.defs) +# +# With the default control of this module: +# [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] +# root will not be prompted for a password on insecure lines. +# if an invalid username is entered, a password is prompted (but login +# will eventually be rejected) +# +# You can change it to a "requisite" module if you think root may mis-type +# her login and should not be prompted for a password in that case. But +# this will leave the system as vulnerable to user enumeration attacks. +# +# You can change it to a "required" module if you think it permits to +# guess valid user names of your system (invalid user names are considered +# as possibly being root on insecure lines), but root passwords may be +# communicated over insecure lines. +auth [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] pam_securetty.so + +# Disallows other than root logins when /etc/nologin exists +# (Replaces the `NOLOGINS_FILE' option from login.defs) +auth requisite pam_nologin.so + +# SELinux needs to be the first session rule. This ensures that any +# lingering context has been cleared. Without this it is possible +# that a module could execute code in the wrong domain. +# When the module is present, "required" would be sufficient (When SELinux +# is disabled, this returns success.) +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close + +# Sets the loginuid process attribute +session required pam_loginuid.so + +# SELinux needs to intervene at login time to ensure that the process +# starts in the proper default security context. Only sessions which are +# intended to run in the user's context should be run after this. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open +# When the module is present, "required" would be sufficient (When SELinux +# is disabled, this returns success.) + +# This module parses environment configuration file(s) +# and also allows you to use an extended config +# file /etc/security/pam_env.conf. +# +# parsing /etc/environment needs "readenv=1" +session required pam_env.so readenv=1 +# locale variables are also kept into /etc/default/locale in etch +# reading this file *in addition to /etc/environment* does not hurt +session required pam_env.so readenv=1 envfile=/etc/default/locale + +# Standard Un*x authentication. +@include common-auth-sonic + +# This allows certain extra groups to be granted to a user +# based on things like time of day, tty, service, and user. +# Please edit /etc/security/group.conf to fit your needs +# (Replaces the `CONSOLE_GROUPS' option in login.defs) +auth optional pam_group.so + +# Uncomment and edit /etc/security/time.conf if you need to set +# time restraint on logins. +# (Replaces the `PORTTIME_CHECKS_ENAB' option from login.defs +# as well as /etc/porttime) +# account requisite pam_time.so + +# Uncomment and edit /etc/security/access.conf if you need to +# set access limits. +# (Replaces /etc/login.access file) +# account required pam_access.so + +# Sets up user limits according to /etc/security/limits.conf +# (Replaces the use of /etc/limits in old login) +session required pam_limits.so + +# Prints the last login info upon successful login +# (Replaces the `LASTLOG_ENAB' option from login.defs) +session optional pam_lastlog.so + +# Prints the message of the day upon successful login. +# (Replaces the `MOTD_FILE' option in login.defs) +# This includes a dynamically generated part from /run/motd.dynamic +# and a static (admin-editable) part from /etc/motd. +session optional pam_motd.so motd=/run/motd.dynamic +session optional pam_motd.so noupdate + +# Prints the status of the user's mailbox upon successful login +# (Replaces the `MAIL_CHECK_ENAB' option from login.defs). +# +# This also defines the MAIL environment variable +# However, userdel also needs MAIL_DIR and MAIL_FILE variables +# in /etc/login.defs to make sure that removing a user +# also removes the user's mail spool file. +# See comments in /etc/login.defs +session optional pam_mail.so standard + +# Create a new session keyring. +session optional pam_keyinit.so force revoke + +# Standard Un*x account and session +@include common-account +@include common-session +@include common-password diff --git a/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/login.old b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/login.old new file mode 100644 index 00000000..07ff9540 --- /dev/null +++ b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/login.old @@ -0,0 +1,116 @@ +# +# The PAM configuration file for the Shadow `login' service +# + +# Enforce a minimal delay in case of failure (in microseconds). +# (Replaces the `FAIL_DELAY' setting from login.defs) +# Note that other modules may require another minimal delay. (for example, +# to disable any delay, you should add the nodelay option to pam_unix) +auth optional pam_faildelay.so delay=3000000 + +# Outputs an issue file prior to each login prompt (Replaces the +# ISSUE_FILE option from login.defs). Uncomment for use +# auth required pam_issue.so issue=/etc/issue + +# Disallows root logins except on tty's listed in /etc/securetty +# (Replaces the `CONSOLE' setting from login.defs) +# +# With the default control of this module: +# [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] +# root will not be prompted for a password on insecure lines. +# if an invalid username is entered, a password is prompted (but login +# will eventually be rejected) +# +# You can change it to a "requisite" module if you think root may mis-type +# her login and should not be prompted for a password in that case. But +# this will leave the system as vulnerable to user enumeration attacks. +# +# You can change it to a "required" module if you think it permits to +# guess valid user names of your system (invalid user names are considered +# as possibly being root on insecure lines), but root passwords may be +# communicated over insecure lines. +auth [success=ok new_authtok_reqd=ok ignore=ignore user_unknown=bad default=die] pam_securetty.so + +# Disallows other than root logins when /etc/nologin exists +# (Replaces the `NOLOGINS_FILE' option from login.defs) +auth requisite pam_nologin.so + +# SELinux needs to be the first session rule. This ensures that any +# lingering context has been cleared. Without this it is possible +# that a module could execute code in the wrong domain. +# When the module is present, "required" would be sufficient (When SELinux +# is disabled, this returns success.) +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close + +# Sets the loginuid process attribute +session required pam_loginuid.so + +# SELinux needs to intervene at login time to ensure that the process +# starts in the proper default security context. Only sessions which are +# intended to run in the user's context should be run after this. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open +# When the module is present, "required" would be sufficient (When SELinux +# is disabled, this returns success.) + +# This module parses environment configuration file(s) +# and also allows you to use an extended config +# file /etc/security/pam_env.conf. +# +# parsing /etc/environment needs "readenv=1" +session required pam_env.so readenv=1 +# locale variables are also kept into /etc/default/locale in etch +# reading this file *in addition to /etc/environment* does not hurt +session required pam_env.so readenv=1 envfile=/etc/default/locale + +# Standard Un*x authentication. +@include common-auth + +# This allows certain extra groups to be granted to a user +# based on things like time of day, tty, service, and user. +# Please edit /etc/security/group.conf to fit your needs +# (Replaces the `CONSOLE_GROUPS' option in login.defs) +auth optional pam_group.so + +# Uncomment and edit /etc/security/time.conf if you need to set +# time restraint on logins. +# (Replaces the `PORTTIME_CHECKS_ENAB' option from login.defs +# as well as /etc/porttime) +# account requisite pam_time.so + +# Uncomment and edit /etc/security/access.conf if you need to +# set access limits. +# (Replaces /etc/login.access file) +# account required pam_access.so + +# Sets up user limits according to /etc/security/limits.conf +# (Replaces the use of /etc/limits in old login) +session required pam_limits.so + +# Prints the last login info upon successful login +# (Replaces the `LASTLOG_ENAB' option from login.defs) +session optional pam_lastlog.so + +# Prints the message of the day upon successful login. +# (Replaces the `MOTD_FILE' option in login.defs) +# This includes a dynamically generated part from /run/motd.dynamic +# and a static (admin-editable) part from /etc/motd. +session optional pam_motd.so motd=/run/motd.dynamic +session optional pam_motd.so noupdate + +# Prints the status of the user's mailbox upon successful login +# (Replaces the `MAIL_CHECK_ENAB' option from login.defs). +# +# This also defines the MAIL environment variable +# However, userdel also needs MAIL_DIR and MAIL_FILE variables +# in /etc/login.defs to make sure that removing a user +# also removes the user's mail spool file. +# See comments in /etc/login.defs +session optional pam_mail.so standard + +# Create a new session keyring. +session optional pam_keyinit.so force revoke + +# Standard Un*x account and session +@include common-account +@include common-session +@include common-password diff --git a/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/radius_nss.conf b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/radius_nss.conf new file mode 100644 index 00000000..1567b6e6 --- /dev/null +++ b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/radius_nss.conf @@ -0,0 +1,55 @@ +#THIS IS AN AUTO-GENERATED FILE +# Generated from: /usr/share/sonic/templates/radius_nss.conf.j2 +# RADIUS NSS Configuration File +# +# Debug: on|off|trace +# Default: off +# +# debug=on + +# +# User Privilege: +# Default: +# user_priv=15;pw_info=remote_user_su;gid=1000;group=sudo,docker;shell=/usr/bin/sonic-launch-shell +# user_priv=1;pw_info=remote_user;gid=999;group=docker;shell=/usr/bin/sonic-launch-shell + +# Eg: +# user_priv=15;pw_info=remote_user_su;gid=1000;group=sudo,docker;shell=/usr/bin/sonic-launch-shell +# user_priv=7;pw_info=netops;gid=999;group=docker;shell=/usr/bin/sonic-launch-shell +# user_priv=1;pw_info=operator;gid=100;group=docker;shell=/usr/bin/sonic-launch-shell +# + +# many_to_one: +# y: Map RADIUS users to one local user per privilege. +# n: Create local user account on first successful authentication. +# Default: n +# + +# Eg: +# many_to_one=y +# + +# unconfirmed_disallow: +# y: Do not allow unconfirmed users (users created before authentication) +# n: Allow unconfirmed users. +# Default: n + +# Eg: +# unconfirmed_disallow=y +# + +# unconfirmed_ageout: +# : Wait time before purging unconfirmed users +# Default: 600 +# + +# Eg: +# unconfirmed_ageout=900 +# + +# unconfirmed_regexp: +# : The RE to match the command line of processes for which the +# creation of unconfirmed users are to be allowed. +# Default: (.*: \[priv\])|(.*: \[accepted\]) +# where: is the unconfirmed user. +# \ No newline at end of file diff --git a/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/sshd b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/sshd new file mode 100644 index 00000000..c025af35 --- /dev/null +++ b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/sshd @@ -0,0 +1,55 @@ +# PAM configuration for the Secure Shell service + +# Standard Un*x authentication. +@include common-auth-sonic + +# Disallow non-root logins when /etc/nologin exists. +account required pam_nologin.so + +# Uncomment and edit /etc/security/access.conf if you need to set complex +# access limits that are hard to express in sshd_config. +# account required pam_access.so + +# Standard Un*x authorization. +@include common-account + +# SELinux needs to be the first session rule. This ensures that any +# lingering context has been cleared. Without this it is possible that a +# module could execute code in the wrong domain. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close + +# Set the loginuid process attribute. +session required pam_loginuid.so + +# Create a new session keyring. +session optional pam_keyinit.so force revoke + +# Standard Un*x session setup and teardown. +@include common-session + +# Print the message of the day upon successful login. +# This includes a dynamically generated part from /run/motd.dynamic +# and a static (admin-editable) part from /etc/motd. +session optional pam_motd.so motd=/run/motd.dynamic +session optional pam_motd.so noupdate + +# Print the status of the user's mailbox upon successful login. +session optional pam_mail.so standard noenv # [1] + +# Set up user limits from /etc/security/limits.conf. +session required pam_limits.so + +# Read environment variables from /etc/environment and +# /etc/security/pam_env.conf. +session required pam_env.so # [1] +# In Debian 4.0 (etch), locale-related environment variables were moved to +# /etc/default/locale, so read that as well. +session required pam_env.so user_readenv=1 envfile=/etc/default/locale + +# SELinux needs to intervene at login time to ensure that the process starts +# in the proper default security context. Only sessions which are intended +# to run in the user's context should be run after this. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open + +# Standard Un*x password updating. +@include common-password diff --git a/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/sshd.old b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/sshd.old new file mode 100644 index 00000000..d70b384b --- /dev/null +++ b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/sshd.old @@ -0,0 +1,55 @@ +# PAM configuration for the Secure Shell service + +# Standard Un*x authentication. +@include common-auth + +# Disallow non-root logins when /etc/nologin exists. +account required pam_nologin.so + +# Uncomment and edit /etc/security/access.conf if you need to set complex +# access limits that are hard to express in sshd_config. +# account required pam_access.so + +# Standard Un*x authorization. +@include common-account + +# SELinux needs to be the first session rule. This ensures that any +# lingering context has been cleared. Without this it is possible that a +# module could execute code in the wrong domain. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close + +# Set the loginuid process attribute. +session required pam_loginuid.so + +# Create a new session keyring. +session optional pam_keyinit.so force revoke + +# Standard Un*x session setup and teardown. +@include common-session + +# Print the message of the day upon successful login. +# This includes a dynamically generated part from /run/motd.dynamic +# and a static (admin-editable) part from /etc/motd. +session optional pam_motd.so motd=/run/motd.dynamic +session optional pam_motd.so noupdate + +# Print the status of the user's mailbox upon successful login. +session optional pam_mail.so standard noenv # [1] + +# Set up user limits from /etc/security/limits.conf. +session required pam_limits.so + +# Read environment variables from /etc/environment and +# /etc/security/pam_env.conf. +session required pam_env.so # [1] +# In Debian 4.0 (etch), locale-related environment variables were moved to +# /etc/default/locale, so read that as well. +session required pam_env.so user_readenv=1 envfile=/etc/default/locale + +# SELinux needs to intervene at login time to ensure that the process starts +# in the proper default security context. Only sessions which are intended +# to run in the user's context should be run after this. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open + +# Standard Un*x password updating. +@include common-password diff --git a/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/sshd_empty.old b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/sshd_empty.old new file mode 100644 index 00000000..e69de29b diff --git a/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/sshd_not_empty.old b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/sshd_not_empty.old new file mode 100644 index 00000000..94fb85d3 --- /dev/null +++ b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/sshd_not_empty.old @@ -0,0 +1 @@ +#SSHD NOT EMPTY TEST diff --git a/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/tacplus_nss.conf b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/tacplus_nss.conf new file mode 100644 index 00000000..d24cab57 --- /dev/null +++ b/tests/hostcfgd/sample_output/TACACS_config_db_local_tacacs/tacplus_nss.conf @@ -0,0 +1,42 @@ +# Configuration for libnss-tacplus + +# debug - If you want to open debug log, set it on +# Default: off +# debug=on + +# local_accounting - If you want to local accounting, set it +# Default: None +# local_accounting +local_accounting + +# tacacs_accounting - If you want to tacacs+ accounting, set it +# Default: None +# tacacs_accounting + +# local_authorization - If you want to local authorization, set it +# Default: None +# local_authorization +local_authorization + +# tacacs_authorization - If you want to tacacs+ authorization, set it +# Default: None +# tacacs_authorization + +# src_ip - set source address of TACACS+ protocol packets +# Default: None (auto source ip address) +# src_ip=2.2.2.2 + +# server - set ip address, tcp port, secret string and timeout for TACACS+ servers +# Default: None (no TACACS+ server) +# server=1.1.1.1:49,secret=test,timeout=3 +server=192.168.1.1:50,secret=dellsonic,timeout=10,vrf=default +server=192.168.1.2:51,secret=dellsonic1,timeout=15,vrf=mgmt + +# user_priv - set the map between TACACS+ user privilege and local user's passwd +# Default: +# user_priv=15;pw_info=remote_user_su;gid=1000;group=sudo,docker;shell=/bin/bash +# user_priv=1;pw_info=remote_user;gid=999;group=docker;shell=/bin/bash + +# many_to_one - create one local user for many TACACS+ users which has the same privilege +# Default: many_to_one=n +# many_to_one=y diff --git a/tests/hostcfgd/sample_output/TACACS_config_db_tacacs/common-auth-sonic b/tests/hostcfgd/sample_output/TACACS_config_db_tacacs/common-auth-sonic index 87af4cc5..8d377108 100644 --- a/tests/hostcfgd/sample_output/TACACS_config_db_tacacs/common-auth-sonic +++ b/tests/hostcfgd/sample_output/TACACS_config_db_tacacs/common-auth-sonic @@ -9,9 +9,15 @@ # # here are the per-package modules (the "Primary" block) -auth [success=1 default=ignore] pam_unix.so nullok try_first_pass +# Include faillock configuration if present (managed by userd) +auth substack common-auth-faillock + +auth [success=2 default=ignore] pam_unix.so nullok try_first_pass # +# Record failed login attempts for faillock (managed by userd) +auth substack common-auth-faillock-authfail + # here's the fallback if no module succeeds auth requisite pam_deny.so # prime the stack with a positive return value if there isn't one already; diff --git a/tests/hostcfgd/test_radius_vectors.py b/tests/hostcfgd/test_radius_vectors.py index 5ae0ca0d..b473a6f5 100644 --- a/tests/hostcfgd/test_radius_vectors.py +++ b/tests/hostcfgd/test_radius_vectors.py @@ -231,4 +231,126 @@ ], }, ], + [ + "LOCAL_RADIUS", + { + "config_db": { + "MGMT_INTERFACE": { + "eth0|1.1.1.15/23": { + "gwaddr": "1.1.1.10" + }, + "eth0|2404::2/64": { + "gwaddr": "2404::1" + } + }, + "PORTCHANNEL_INTERFACE": { + "PortChannel0001|10.10.11.10/32": {} + }, + "DEVICE_METADATA": { + "localhost": { + "hostname": "radius", + } + }, + "FEATURE": { + "dhcp_relay": { + "auto_restart": "enabled", + "has_global_scope": "True", + "has_per_asic_scope": "False", + "delayed": "False", + "high_mem_alert": "disabled", + "set_owner": "kube", + "state": "enabled" + }, + }, + "KDUMP": { + "config": { + "enabled": "false", + "num_dumps": "3", + "memory": "0M-2G:256M,2G-4G:320M,4G-8G:384M,8G-16G:448M,16G-32G:768M,32G-:1G" + } + }, + "AAA": { + "authentication": { + "login": "local,radius", + "debug": "True", + } + }, + "RADIUS": { + "global": { + "nas_ip": "10.10.10.10", + "auth_port": "1645", + "auth_type": "mschapv2", + "retransmit": "2", + "timeout": "3", + "passkey": "pass", + } + }, + "RADIUS_SERVER": { + "10.10.10.1": { + "auth_type": "pap", + "retransmit": "1", + "timeout": "1", + "passkey": "pass1", + }, + "10.10.10.2": { + "auth_type": "chap", + "retransmit": "2", + "timeout": "2", + "passkey": "pass2", + }, + }, + }, + "expected_config_db": { + "DEVICE_METADATA": { + "localhost": { + "hostname": "radius", + } + }, + "FEATURE": { + "dhcp_relay": { + "auto_restart": "enabled", + "has_global_scope": "True", + "has_per_asic_scope": "False", + "delayed": "False", + "high_mem_alert": "disabled", + "set_owner": "kube", + "state": "enabled" + }, + }, + "AAA": { + "authentication": { + "login": "local,radius", + "debug": "True", + } + }, + "RADIUS": { + "global": { + "nas_ip": "10.10.10.10", + "auth_port": "1645", + "auth_type": "mschapv2", + "retransmit": "2", + "timeout": "3", + "passkey": "pass", + } + }, + "RADIUS_SERVER": { + "10.10.10.1": { + "auth_type": "pap", + "retransmit": "1", + "timeout": "1", + "passkey": "pass1", + }, + "10.10.10.2": { + "auth_type": "chap", + "retransmit": "2", + "timeout": "2", + "passkey": "pass2", + }, + }, + }, + "expected_subprocess_calls": [ + call(["service", "aaastatsd", "start"]), + ], + } + ], ] diff --git a/tests/hostcfgd/test_tacacs_vectors.py b/tests/hostcfgd/test_tacacs_vectors.py index a6938eb6..5124157a 100644 --- a/tests/hostcfgd/test_tacacs_vectors.py +++ b/tests/hostcfgd/test_tacacs_vectors.py @@ -222,10 +222,72 @@ "login": "local" }, "authorization": { - "login": "local" + "login": "local" }, "accounting": { - "login": "disable" + "login": "disable" + } + }, + "TACPLUS": { + "global": { + "auth_type": "chap", + "timeout": 5, + "passkey": "dellsonic", + "src_intf": "Ethernet0" + } + }, + "TACPLUS_SERVER": { + "192.168.1.1" : { + "priority": 5, + "tcp_port": 50, + "timeout": 10, + "auth_type": "chap", + "passkey": "dellsonic", + "vrf": "default" + }, + "192.168.1.2" : { + "priority": 2, + "tcp_port": 51, + "timeout": 15, + "auth_type": "pap", + "passkey": "dellsonic1", + "vrf": "mgmt" + } + }, + }, + "config_db_local_tacacs": { + "DEVICE_METADATA": { + "localhost": { + "hostname": "tacacs", + } + }, + "FEATURE": { + "dhcp_relay": { + "auto_restart": "enabled", + "has_global_scope": "True", + "has_per_asic_scope": "False", + "delayed": "False", + "high_mem_alert": "disabled", + "set_owner": "kube", + "state": "enabled" + }, + }, + "KDUMP": { + "config": { + "enabled": "false", + "num_dumps": "3", + "memory": "0M-2G:256M,2G-4G:320M,4G-8G:384M,8G-16G:448M,16G-32G:768M,32G-:1G" + } + }, + "AAA": { + "authentication": { + "login": "local,tacacs+" + }, + "authorization": { + "login": "local" + }, + "accounting": { + "login": "local" } }, "TACPLUS": {