feat: ship Rust login manager and GitLab release pipeline
Replace the legacy Go implementation as the production path, quarantine the reference tree, add deterministic release packaging and version metadata, and run verification through GitLab CI.
This commit is contained in:
237
crates/latchd-core/src/accounts.rs
Normal file
237
crates/latchd-core/src/accounts.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
//! 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},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::AccountsConfig,
|
||||
error::{Error, ResultExt},
|
||||
};
|
||||
|
||||
/// An account that can be offered by the login screen.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Identity {
|
||||
/// Canonical username.
|
||||
pub username: String,
|
||||
/// Numeric user ID.
|
||||
pub uid: u32,
|
||||
/// Primary group ID.
|
||||
pub gid: u32,
|
||||
/// Home directory.
|
||||
pub home: PathBuf,
|
||||
/// Login shell.
|
||||
pub shell: PathBuf,
|
||||
}
|
||||
|
||||
/// A non-authoritative account lockout summary.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct FaillockStatus {
|
||||
/// Whether the faillock helper returned a usable result.
|
||||
pub available: bool,
|
||||
/// Number of observed failed attempts.
|
||||
pub failures: u32,
|
||||
/// Configured failure threshold, when discoverable.
|
||||
pub max_tries: Option<u32>,
|
||||
/// Remaining attempts before the advisory threshold.
|
||||
pub remaining: Option<u32>,
|
||||
/// Whether the advisory threshold has been reached.
|
||||
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
|
||||
}
|
||||
|
||||
/// 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 failures = parse_failure_count(&output.stdout);
|
||||
let max_tries = read_deny(&config.faillock_config);
|
||||
let remaining = max_tries.map(|limit| limit.saturating_sub(failures));
|
||||
Ok(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 {
|
||||
String::from_utf8_lossy(bytes)
|
||||
.lines()
|
||||
.filter_map(|line| line.split_whitespace().next())
|
||||
.find_map(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
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_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_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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user