//! NSS account discovery and advisory faillock status. use std::{ ffi::CStr, os::unix::fs::MetadataExt, path::{Path, PathBuf}, process::{Command, Stdio}, thread, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use crate::{ config::AccountsConfig, error::{Error, ResultExt}, }; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Identity { pub username: String, pub uid: u32, pub gid: u32, pub home: PathBuf, pub shell: PathBuf, } /// Effective account state determined from the login shell and shadow aging data. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccountState { Active, Locked, PasswordExpired, AccountExpired, AccountInactive, NoLoginShell, /// Shadow data was not available or could not be parsed. Unknown, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AccountStatus { pub state: AccountState, pub password_changed_day: Option, pub password_expiry_day: Option, pub account_expiry_day: Option, pub inactive_after_day: Option, } impl AccountStatus { pub const fn is_login_allowed(self) -> bool { matches!(self.state, AccountState::Active) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct AccountRecord { pub identity: Identity, pub status: AccountStatus, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ActiveSession { pub username: String, pub terminal: String, pub pid: libc::pid_t, pub host: Option, } /// Runtime session-management backend detected on this host. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionBackend { SystemdLogind, Utmpx, } #[derive(Debug, Clone, PartialEq, Eq)] struct ShadowEntry { hash: String, last_change: Option, max_days: Option, inactive_days: Option, account_expiry: Option, } /// A non-authoritative account lockout summary. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct FaillockStatus { pub available: bool, pub failures: u32, pub max_tries: Option, pub remaining: Option, pub locked_out: bool, } /// Enumerates interactive accounts using NSS and deterministic ordering. pub fn discover(config: &AccountsConfig) -> Vec { let mut accounts = Vec::new(); unsafe { libc::setpwent(); loop { let entry = libc::getpwent(); if entry.is_null() { break; } let passwd = &*entry; let Some(username) = c_string(passwd.pw_name) else { continue; }; let Some(home) = c_path(passwd.pw_dir) else { continue; }; let Some(shell) = c_path(passwd.pw_shell) else { continue; }; if !eligible(&username, passwd.pw_uid, &home, &shell, config) { continue; } accounts.push(Identity { username, uid: passwd.pw_uid, gid: passwd.pw_gid, home, shell, }); } libc::endpwent(); } accounts.sort_by(|left, right| left.username.cmp(&right.username)); accounts } /// Discovers identities and attaches account-aging state when the shadow /// database is readable. A missing or inaccessible shadow file yields /// [`AccountState::Unknown`] rather than hiding the account. pub fn discover_with_status(config: &AccountsConfig) -> Vec { discover(config) .into_iter() .map(|identity| { let status = inspect_account(&identity, config).unwrap_or(AccountStatus { state: AccountState::Unknown, password_changed_day: None, password_expiry_day: None, account_expiry_day: None, inactive_after_day: None, }); AccountRecord { identity, status } }) .collect() } /// Inspects password and account aging fields without returning password /// hashes. The configured shadow path must be root-owned and non-writable. pub fn inspect_account( identity: &Identity, config: &AccountsConfig, ) -> Result { if !trusted_shadow(&config.shadow_path) { return Err(Error::Config("shadow_path is not a trusted file".into())); } let content = std::fs::read_to_string(&config.shadow_path) .io_context(format!("read {}", config.shadow_path.display()))?; let entry = content.lines().find_map(|line| { let (username, _) = line.split_once(':')?; (username == identity.username) .then(|| parse_shadow_entry(line)) .flatten() }); let day = unix_day(); Ok(account_status_from_shadow(identity, entry, day)) } /// Returns all active UTMPX user-process records in deterministic order. pub fn active_sessions() -> Vec { let mut sessions = Vec::new(); unsafe { libc::setutxent(); loop { let entry = libc::getutxent(); if entry.is_null() { break; } let record = &*entry; if record.ut_type != libc::USER_PROCESS { continue; } let Some(username) = utmp_field(&record.ut_user) else { continue; }; let Some(terminal) = utmp_field(&record.ut_line) else { continue; }; sessions.push(ActiveSession { username, terminal, pid: record.ut_pid, host: utmp_field(&record.ut_host), }); } libc::endutxent(); } sessions.sort_by(|left, right| { left.username .cmp(&right.username) .then_with(|| left.terminal.cmp(&right.terminal)) .then_with(|| left.pid.cmp(&right.pid)) }); sessions } /// Detects whether systemd-logind has initialized its runtime seat state. pub fn session_backend() -> SessionBackend { if logind_markers_present( Path::new("/run/systemd/system").is_dir(), Path::new("/run/systemd/seats").is_dir(), ) { SessionBackend::SystemdLogind } else { SessionBackend::Utmpx } } /// Runs the configured helper and parses its stable numeric output. pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result { let helper = &config.faillock_path; if !helper.is_absolute() { return Err(Error::Config("faillock_path must be absolute".into())); } if !trusted_helper(helper) { return Ok(FaillockStatus::default()); } let mut child = Command::new(helper) .args(["--user", username, "--legacy-output"]) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .io_context("start faillock query")?; let deadline = Instant::now() + Duration::from_millis(750); loop { if child .try_wait() .io_context("poll faillock query")? .is_some() { break; } if Instant::now() >= deadline { let _ = child.kill(); let _ = child.wait(); return Ok(FaillockStatus::default()); } thread::sleep(Duration::from_millis(10)); } let output = child.wait_with_output().io_context("read faillock query")?; if !output.status.success() { return Ok(FaillockStatus::default()); } let max_tries = read_deny(&config.faillock_config); Ok(faillock_status_from_output(&output.stdout, max_tries)) } /// Parses the legacy count format and current rows with usernames or timestamps. /// Malformed output reports zero failures and does not control authentication. pub fn faillock_status_from_output(output: &[u8], max_tries: Option) -> FaillockStatus { let failures = parse_failure_count(output); let remaining = max_tries.map(|limit| limit.saturating_sub(failures)); FaillockStatus { available: true, failures, max_tries, remaining, locked_out: remaining.is_some_and(|value| value == 0), } } fn eligible(username: &str, uid: u32, home: &Path, shell: &Path, config: &AccountsConfig) -> bool { if uid < config.minimum_uid || config.excluded_users.iter().any(|value| value == username) { return false; } if shell == Path::new("/bin/false") || shell == Path::new("/usr/sbin/nologin") || shell == Path::new("/sbin/nologin") || !shell.is_absolute() { return false; } let Ok(metadata) = std::fs::symlink_metadata(home) else { return false; }; metadata.is_dir() && (metadata.uid() == uid || metadata.uid() == 0) && metadata.mode() & 0o022 == 0 } fn c_string(pointer: *const libc::c_char) -> Option { (!pointer.is_null()) .then(|| { unsafe { CStr::from_ptr(pointer) } .to_str() .ok() .map(str::to_owned) }) .flatten() } fn c_path(pointer: *const libc::c_char) -> Option { c_string(pointer) .filter(|value| !value.is_empty()) .map(PathBuf::from) } fn parse_failure_count(bytes: &[u8]) -> u32 { let mut rows = 0u32; for line in String::from_utf8_lossy(bytes).lines() { let line = line.trim(); if line.is_empty() { continue; } if let Some((_, count)) = line.split_once(':') && let Some(value) = count.split_whitespace().next() && let Ok(parsed) = value.parse::() { return parsed; } let mut fields = line.split_whitespace(); let Some(first) = fields.next() else { continue; }; if let Ok(parsed) = first.parse::() { return parsed; } if is_date(first) && fields.next().is_some_and(is_time) { rows = rows.saturating_add(1); } } rows } fn is_date(value: &str) -> bool { let mut fields = value.split('-'); fields .next() .is_some_and(|year| year.len() == 4 && year.chars().all(|c| c.is_ascii_digit())) && fields .next() .is_some_and(|month| month.len() == 2 && month.chars().all(|c| c.is_ascii_digit())) && fields .next() .is_some_and(|day| day.len() == 2 && day.chars().all(|c| c.is_ascii_digit())) && fields.next().is_none() } fn is_time(value: &str) -> bool { let mut fields = value.split(':'); fields.all(|part| part.len() == 2 && part.chars().all(|c| c.is_ascii_digit())) } fn read_deny(path: &Path) -> Option { if !trusted_helper(path) { return None; } let contents = std::fs::read_to_string(path).ok()?; contents.lines().find_map(|line| { let (key, value) = line.split_once('=')?; (key.trim() == "deny") .then(|| value.trim().parse().ok()) .flatten() }) } fn trusted_shadow(path: &Path) -> bool { let Ok(metadata) = std::fs::symlink_metadata(path) else { return false; }; metadata.is_file() && metadata.uid() == 0 && metadata.mode() & 0o022 == 0 } fn parse_shadow_entry(line: &str) -> Option { let mut fields = line.split(':'); let username = fields.next()?; if username.is_empty() { return None; } let hash = fields.next()?.to_owned(); let last_change = parse_shadow_day(fields.next()?); let _minimum_days = parse_shadow_day(fields.next()?); let max_days = parse_shadow_day(fields.next()?); let _warning_days = parse_shadow_day(fields.next()?); let inactive_days = parse_shadow_day(fields.next()?); let account_expiry = parse_shadow_day(fields.next()?); let _reserved = fields.next()?; Some(ShadowEntry { hash, last_change, max_days, inactive_days, account_expiry, }) } fn parse_shadow_day(value: &str) -> Option { (!value.is_empty() && value != "-1") .then(|| value.parse::().ok()) .flatten() .filter(|day| *day >= 0) } fn shadow_hash_is_locked(hash: &str) -> bool { hash.is_empty() || hash.starts_with('!') || hash.starts_with('*') } fn account_status_from_shadow( identity: &Identity, entry: Option, today: i64, ) -> AccountStatus { let Some(entry) = entry else { return AccountStatus { state: AccountState::Unknown, password_changed_day: None, password_expiry_day: None, account_expiry_day: None, inactive_after_day: None, }; }; let password_expiry_day = entry .last_change .zip(entry.max_days) .and_then(|(changed, max)| changed.checked_add(max)); let inactive_after_day = password_expiry_day .zip(entry.inactive_days) .and_then(|(expiry, inactive)| expiry.checked_add(inactive)); let state = if !identity.shell.is_absolute() || identity.shell == Path::new("/bin/false") || identity.shell == Path::new("/usr/sbin/nologin") || identity.shell == Path::new("/sbin/nologin") { AccountState::NoLoginShell } else if entry.account_expiry.is_some_and(|expiry| expiry <= today) { AccountState::AccountExpired } else if shadow_hash_is_locked(&entry.hash) { AccountState::Locked } else if entry.last_change == Some(0) || password_expiry_day.is_some_and(|expiry| expiry <= today) { AccountState::PasswordExpired } else if inactive_after_day.is_some_and(|expiry| expiry <= today) { AccountState::AccountInactive } else { AccountState::Active }; AccountStatus { state, password_changed_day: entry.last_change, password_expiry_day, account_expiry_day: entry.account_expiry, inactive_after_day, } } fn unix_day() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| (duration.as_secs() / 86_400) as i64) .unwrap_or_default() } fn utmp_field(field: &[libc::c_char; N]) -> Option { let length = field.iter().position(|byte| *byte == 0).unwrap_or(N); if length == 0 { return None; } let bytes: Vec = field[..length].iter().map(|byte| *byte as u8).collect(); String::from_utf8(bytes) .ok() .filter(|value| !value.is_empty()) } fn logind_markers_present(system_dir: bool, seats_dir: bool) -> bool { system_dir && seats_dir } fn trusted_helper(path: &Path) -> bool { let Ok(metadata) = std::fs::symlink_metadata(path) else { return false; }; metadata.is_file() && metadata.uid() == 0 && metadata.mode() & 0o022 == 0 && (metadata.mode() & 0o111 != 0 || path.ends_with("faillock.conf")) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_faillock_rows_with_username_and_timestamp_prefixes() { assert_eq!(parse_failure_count(b"alice: 4\n"), 4); assert_eq!( parse_failure_count(b"2026-01-01 12:00:00 TTY 1\n2026-01-02 12:00:00 TTY 1\n"), 2 ); } #[test] fn parses_shadow_aging_and_lock_markers_without_exposing_hashes() { let entry = parse_shadow_entry("alice:$6$redacted:0:0:30:7:14:20000:").expect("entry"); assert_eq!(entry.last_change, Some(0)); assert_eq!(entry.max_days, Some(30)); assert_eq!(entry.inactive_days, Some(14)); assert_eq!(entry.account_expiry, Some(20_000)); assert!(shadow_hash_is_locked("!$6$hash")); assert!(!shadow_hash_is_locked("$6$hash")); } #[test] fn account_state_prioritises_non_login_shell_and_expiry() { let identity = Identity { username: "alice".into(), uid: 1000, gid: 1000, home: "/home/alice".into(), shell: "/usr/sbin/nologin".into(), }; let status = account_status_from_shadow( &identity, Some(ShadowEntry { hash: "*".into(), last_change: Some(1), max_days: Some(1), inactive_days: Some(1), account_expiry: Some(1), }), 20_000, ); assert_eq!(status.state, AccountState::NoLoginShell); } #[test] fn detects_systemd_logind_only_when_runtime_markers_exist() { assert!(logind_markers_present(true, true)); assert!(!logind_markers_present(true, false)); assert!(!logind_markers_present(false, true)); } #[test] fn parses_legacy_failure_count() { assert_eq!(parse_failure_count(b" 4 2026-01-01 source\n"), 4); assert_eq!(parse_failure_count(b""), 0); } #[test] fn filters_non_login_shells_and_untrusted_homes() { let config = AccountsConfig::default(); assert!(!eligible( "service", 1000, Path::new("/tmp"), Path::new("/bin/false"), &config, )); assert!(!eligible( "service", 1000, Path::new("/definitely/missing"), Path::new("/bin/sh"), &config, )); } #[cfg(unix)] #[test] fn unavailable_faillock_helper_is_advisory() { let config = AccountsConfig { faillock_path: PathBuf::from("/bin/false"), ..AccountsConfig::default() }; let status = faillock_status("alice", &config).expect("false helper should be handled"); assert_eq!(status, FaillockStatus::default()); } }