use std::process::Command;
#[derive(Debug, Clone)]
pub struct FaillockStatus {
pub attempts_used: u32,
pub max_attempts: u32,
pub is_locked: bool,
pub lockout_duration_minutes: u32,
pub last_failed_timestamp: Option<std::time::SystemTime>,
}
#[derive(Debug, Clone)]
pub struct FaillockConfig {
pub deny: u32,
pub fail_interval: u32,
}
pub fn check_faillock_status(username: &str) -> Result<FaillockStatus, String> {
let config = parse_faillock_config().unwrap_or(FaillockConfig {
deny: 3,
fail_interval: 15,
});
let output = Command::new("faillock")
.args(["--user", username])
.output()
.map_err(|e| format!("Failed to execute faillock command: {e}"))?;
if !output.status.success() {
return Ok(FaillockStatus {
attempts_used: 0,
max_attempts: config.deny,
is_locked: false,
lockout_duration_minutes: config.fail_interval,
last_failed_timestamp: None,
});
}
let output_str = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = output_str.lines().collect();
let mut attempts_used = 0u32;
let mut in_user_section = false;
let mut seen_header = false;
let mut most_recent_timestamp: Option<std::time::SystemTime> = None;
for line in lines {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.ends_with(':') && trimmed.trim_end_matches(':') == username {
in_user_section = true;
seen_header = false; continue;
}
if in_user_section {
if !seen_header
&& trimmed.contains("When")
&& trimmed.contains("Type")
&& trimmed.contains("Source")
&& trimmed.contains("Valid")
{
seen_header = true;
continue;
}
if (trimmed.contains(" V") || trimmed.ends_with('V'))
&& trimmed.chars().take(4).all(|c| c.is_ascii_digit())
{
attempts_used += 1;
if trimmed.len() >= 19 {
let timestamp_str = &trimmed[0..19];
if let Ok(dt) =
chrono::NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S")
{
let local_dt = dt.and_local_timezone(chrono::Local);
let dt_utc = local_dt.single().map_or_else(
|| dt.and_utc(),
|dt_local| dt_local.with_timezone(&chrono::Utc),
);
let unix_timestamp = dt_utc.timestamp();
if unix_timestamp >= 0
&& let Some(st) = std::time::SystemTime::UNIX_EPOCH.checked_add(
std::time::Duration::from_secs(
u64::try_from(unix_timestamp).unwrap_or(0),
),
)
{
most_recent_timestamp = Some(st);
}
}
}
}
}
}
let should_be_locked = attempts_used >= config.deny;
let is_locked = if should_be_locked {
most_recent_timestamp.map_or(should_be_locked, |last_timestamp| {
let now = std::time::SystemTime::now();
now.duration_since(last_timestamp).map_or(true, |elapsed| {
let lockout_seconds = u64::from(config.fail_interval) * 60;
elapsed.as_secs() < lockout_seconds
})
})
} else {
false
};
Ok(FaillockStatus {
attempts_used,
max_attempts: config.deny,
is_locked,
lockout_duration_minutes: config.fail_interval,
last_failed_timestamp: most_recent_timestamp,
})
}
pub fn parse_faillock_config() -> Result<FaillockConfig, String> {
use std::fs;
let config_path = "/etc/security/faillock.conf";
let Ok(contents) = fs::read_to_string(config_path) else {
return Ok(FaillockConfig {
deny: 3,
fail_interval: 15,
});
};
let mut deny = 3u32; let mut fail_interval = 15u32;
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let line_without_comment = trimmed.split('#').next().unwrap_or("").trim();
if line_without_comment.starts_with("deny")
&& let Some(value_str) = line_without_comment.split('=').nth(1)
{
let value_trimmed = value_str.trim();
if let Ok(value) = value_trimmed.parse::<u32>() {
deny = value;
}
}
if line_without_comment.starts_with("fail_interval")
&& let Some(value_str) = line_without_comment.split('=').nth(1)
{
let value_trimmed = value_str.trim();
if let Ok(value) = value_trimmed.parse::<u32>() {
fail_interval = value;
}
}
}
Ok(FaillockConfig {
deny,
fail_interval,
})
}
#[must_use]
pub fn get_lockout_message_if_locked(
username: &str,
app: &crate::state::AppState,
) -> Option<String> {
if let Ok(status) = check_faillock_status(username)
&& status.is_locked
{
return Some(crate::i18n::t_fmt(
app,
"app.modals.alert.account_locked_with_time",
&[
&username as &dyn std::fmt::Display,
&status.lockout_duration_minutes,
],
));
}
None
}
#[must_use]
pub fn calculate_remaining_lockout_minutes(
last_timestamp: &std::time::SystemTime,
lockout_duration_minutes: u32,
) -> Option<u32> {
let now = std::time::SystemTime::now();
now.duration_since(*last_timestamp)
.map_or(Some(lockout_duration_minutes), |elapsed| {
let lockout_seconds = u64::from(lockout_duration_minutes) * 60;
if elapsed.as_secs() < lockout_seconds {
let remaining_seconds = lockout_seconds - elapsed.as_secs();
let remaining_minutes =
(remaining_seconds / 60) + u64::from(remaining_seconds % 60 > 0);
Some(u32::try_from(remaining_minutes.min(u64::from(u32::MAX))).unwrap_or(u32::MAX))
} else {
None }
})
}
#[must_use]
pub fn get_lockout_info(username: &str) -> (bool, Option<std::time::SystemTime>, Option<u32>) {
if let Ok(status) = check_faillock_status(username)
&& status.is_locked
{
if let Some(last_timestamp) = status.last_failed_timestamp {
let remaining = calculate_remaining_lockout_minutes(
&last_timestamp,
status.lockout_duration_minutes,
);
let lockout_until = last_timestamp
+ std::time::Duration::from_secs(u64::from(status.lockout_duration_minutes) * 60);
return (true, Some(lockout_until), remaining);
}
return (true, None, Some(0));
}
(false, None, None)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
#[test]
fn test_parse_faillock_config_defaults() {
let _config = parse_faillock_config();
}
#[test]
fn test_parse_faillock_config_custom_values() {
use std::env::temp_dir;
let temp_file = temp_dir().join("test_faillock.conf");
let content = "deny = 5\nfail_interval = 30\n";
if let Ok(mut file) = fs::File::create(&temp_file) {
let _ = file.write_all(content.as_bytes());
let _config = parse_faillock_config();
let _ = fs::remove_file(&temp_file);
}
}
#[test]
fn test_parse_faillock_config_with_comments() {
let _config = parse_faillock_config();
}
#[test]
fn test_check_faillock_status_handles_errors() {
let username = std::env::var("USER").unwrap_or_else(|_| "testuser".to_string());
let result = check_faillock_status(&username);
if let Ok(status) = result {
assert!(status.max_attempts > 0);
assert!(status.lockout_duration_minutes > 0);
} else {
}
}
#[test]
fn test_faillock_status_structure() {
let username = std::env::var("USER").unwrap_or_else(|_| "testuser".to_string());
if let Ok(status) = check_faillock_status(&username) {
let _ = status.attempts_used;
let _ = status.max_attempts;
let _ = status.is_locked;
let _ = status.lockout_duration_minutes;
}
}
}