581 lines
17 KiB
Rust
581 lines
17 KiB
Rust
//! 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<i64>,
|
|
pub password_expiry_day: Option<i64>,
|
|
pub account_expiry_day: Option<i64>,
|
|
pub inactive_after_day: Option<i64>,
|
|
}
|
|
|
|
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<String>,
|
|
}
|
|
|
|
/// 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<i64>,
|
|
max_days: Option<i64>,
|
|
inactive_days: Option<i64>,
|
|
account_expiry: Option<i64>,
|
|
}
|
|
|
|
/// 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<u32>,
|
|
pub remaining: Option<u32>,
|
|
pub locked_out: bool,
|
|
}
|
|
|
|
/// Enumerates interactive accounts using NSS and deterministic ordering.
|
|
pub fn discover(config: &AccountsConfig) -> Vec<Identity> {
|
|
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<AccountRecord> {
|
|
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<AccountStatus, Error> {
|
|
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<ActiveSession> {
|
|
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<FaillockStatus, Error> {
|
|
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<u32>) -> 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<String> {
|
|
(!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<PathBuf> {
|
|
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::<u32>()
|
|
{
|
|
return parsed;
|
|
}
|
|
let mut fields = line.split_whitespace();
|
|
let Some(first) = fields.next() else {
|
|
continue;
|
|
};
|
|
if let Ok(parsed) = first.parse::<u32>() {
|
|
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<u32> {
|
|
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<ShadowEntry> {
|
|
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<i64> {
|
|
(!value.is_empty() && value != "-1")
|
|
.then(|| value.parse::<i64>().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<ShadowEntry>,
|
|
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<const N: usize>(field: &[libc::c_char; N]) -> Option<String> {
|
|
let length = field.iter().position(|byte| *byte == 0).unwrap_or(N);
|
|
if length == 0 {
|
|
return None;
|
|
}
|
|
let bytes: Vec<u8> = 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());
|
|
}
|
|
}
|