From b9b5fa5735ac6749cfe489b6aa5278bca0227439 Mon Sep 17 00:00:00 2001 From: h7x4 Date: Wed, 29 Apr 2026 04:37:54 +0900 Subject: [PATCH] fingerd: add basic mailbox parsing functionality --- nix/module.nix | 6 + src/server/fingerd.rs | 270 +------------------------ src/server/fingerd/local_email.rs | 91 +++++++++ src/server/fingerd/local_user_info.rs | 243 ++++++++++++++++++++++ src/server/fingerd/remote_user_info.rs | 33 +++ 5 files changed, 378 insertions(+), 265 deletions(-) create mode 100644 src/server/fingerd/local_email.rs create mode 100644 src/server/fingerd/local_user_info.rs create mode 100644 src/server/fingerd/remote_user_info.rs diff --git a/nix/module.nix b/nix/module.nix index 1437999..45ff95c 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -117,9 +117,15 @@ in { BindReadOnlyPaths = [ builtins.storeDir "/etc" + # NOTE: need logind socket for utmp entries "/run/systemd" + "/home" + + # NOTE: finger might need access to mail directories + "-/var/spool" + "-/var/mail" ]; UMask = "0077"; diff --git a/src/server/fingerd.rs b/src/server/fingerd.rs index 4d3a688..e0caf5d 100644 --- a/src/server/fingerd.rs +++ b/src/server/fingerd.rs @@ -1,17 +1,10 @@ -use std::{ - net::hostname, - os::unix::fs::{MetadataExt, PermissionsExt}, - path::Path, -}; +mod local_email; +mod local_user_info; +mod remote_user_info; + +pub use local_user_info::{finger_utmp_users, search_for_user}; -use chrono::{DateTime, Duration, Timelike, Utc}; -use itertools::Itertools; -use nix::sys::stat::stat; use serde::{Deserialize, Serialize}; -use users::all_users; -use uucore::utmpx::{Utmpx, UtmpxRecord}; - -use crate::proto::finger_protocol::{FingerResponseUserEntry, FingerResponseUserSession}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum FingerRequestNetworking { @@ -26,256 +19,3 @@ pub enum FingerRequestInfo { ShortOffice { restrict_gecos: bool }, Long { prevent_files: bool }, } - -/// Search for users whose username or full name contains the search string. -pub fn search_for_user( - search_string: &str, - match_fullnames: bool, - _request_info: &FingerRequestInfo, -) -> Vec> { - (unsafe { all_users() }) - .filter_map(|user| { - let user = match nix::unistd::User::from_uid(user.uid().into()) { - Ok(Some(user)) => user, - Ok(None) => return None, // User disappeared, skip - Err(e) => { - return Some(Err(anyhow::anyhow!( - "Failed to get user entry for UID {}: {}", - user.uid(), - e - ))); - } - }; - - let username = user.name; - let full_name = String::from_utf8_lossy( - user.gecos - .as_bytes() - .split(|&b| b == b',') - .next() - .unwrap_or(&[]), - ) - .to_string(); - - let matches_username = username.contains(search_string); - let matches_fullname = match_fullnames && full_name.contains(search_string); - if matches_username || matches_fullname { - match get_local_user(&username, None) { - Ok(Some(user_entry)) => Some(Ok(user_entry)), - Ok(None) => None, // User exists but has .nofinger, skip - Err(err) => Some(Err(err)), - } - } else { - None - } - }) - .collect() -} - -/// Retrieve information about all users currently logged in, based on utmpx records. -pub fn finger_utmp_users( - _request_info: &FingerRequestInfo, -) -> Vec> { - Utmpx::iter_all_records() - .filter(|entry| entry.is_user_process()) - .into_group_map_by(|entry| entry.user()) - .into_iter() - .map(|(username, records)| get_local_user(&username, Some(records))) - .filter_map(|result| match result { - Ok(Some(user_entry)) => Some(Ok(user_entry)), - Ok(None) => None, // User has .nofinger, skip - Err(err) => Some(Err(err)), - }) - .collect() -} - -/// Helper function to read the content of a file if it exists and is readable, -/// returning None if the file does not exist or is not readable. -fn read_file_content_if_exists(path: &Path) -> anyhow::Result> { - let file_is_readable = path.exists() - && path.is_file() - && (((path.metadata()?.permissions().mode() & 0o400 != 0 - && nix::unistd::geteuid().as_raw() == path.metadata()?.uid()) - || (path.metadata()?.permissions().mode() & 0o040 != 0 - && nix::unistd::getegid().as_raw() == path.metadata()?.gid()) - || (path.metadata()?.permissions().mode() & 0o004 != 0)) - || caps::has_cap( - None, - caps::CapSet::Effective, - caps::Capability::CAP_DAC_READ_SEARCH, - )?) - && path.metadata()?.len() > 0; - - if file_is_readable { - Ok(Some(std::fs::read_to_string(path)?.trim().to_string())) - } else { - Ok(None) - } -} - -/// Retrieve local user information for the given username. -/// -/// Returns None if the user does not exist. -fn get_local_user( - username: &str, - utmp_records: Option>, -) -> anyhow::Result> { - tracing::trace!( - "Retrieving local user information for username: {}", - username - ); - let username = username.to_string(); - let user_entry = match nix::unistd::User::from_name(&username) { - Ok(Some(user)) => user, - Ok(None) => return Ok(None), - Err(err) => { - return Err(anyhow::anyhow!( - "Failed to get user entry for {}: {}", - username, - err - )); - } - }; - - let nofinger_path = user_entry.dir.join(".nofinger"); - if nofinger_path.exists() { - return Ok(None); - } - - let full_name = user_entry.name; - let home_dir = user_entry.dir.clone(); - let shell = user_entry.shell; - - let gecos_fields: Vec<&str> = full_name.split(',').collect(); - - let office = gecos_fields.get(1).map(|s| s.to_string()); - let office_phone = gecos_fields.get(2).map(|s| s.to_string()); - let home_phone = gecos_fields.get(3).map(|s| s.to_string()); - - let hostname = hostname()?.to_str().unwrap_or("localhost").to_string(); - - let utmpx_records = match utmp_records { - Some(records) => records, - None => Utmpx::iter_all_records() - .filter(|entry| entry.user() == username) - .filter(|entry| entry.is_user_process()) - .collect::>(), - }; - - // TODO: Don't use utmp entries for this, read from lastlog instead - let user_never_logged_in = utmpx_records.is_empty(); - - let now = Utc::now().with_nanosecond(0).unwrap_or(Utc::now()); - let sessions: Vec = utmpx_records - .into_iter() - .filter_map(|entry| { - let login_time = entry - .login_time() - .checked_to_utc() - .and_then(|t| DateTime::::from_timestamp_secs(t.unix_timestamp()))?; - - let tty_device_stat = stat(&Path::new("/dev").join(entry.tty_device())).ok(); - - let idle_time = tty_device_stat - .and_then(|st| { - let last_active = DateTime::::from_timestamp_secs(st.st_atime)?; - Some((now - last_active).max(Duration::zero())) - }) - .unwrap_or(Duration::zero()); - - // Check if the write permission for "others" is set - let messages_on = tty_device_stat - .map(|st| st.st_mode & 0o002 != 0) - .unwrap_or(false); - - debug_assert!( - idle_time.num_seconds() >= 0, - "Idle time should never be negative" - ); - - Some(FingerResponseUserSession::new( - entry.tty_device(), - login_time, - idle_time, - hostname.clone(), - messages_on, - )) - }) - .collect(); - - let forward_path = user_entry.dir.join(".forward"); - let forward = read_file_content_if_exists(&forward_path)?; - - let pgpkey_path = user_entry.dir.join(".pgpkey"); - let pgpkey = read_file_content_if_exists(&pgpkey_path)?; - - let project_path = user_entry.dir.join(".project"); - let project = read_file_content_if_exists(&project_path)?; - - let plan_path = user_entry.dir.join(".plan"); - let plan = read_file_content_if_exists(&plan_path)?; - - Ok(Some(FingerResponseUserEntry::new( - username, - full_name, - home_dir, - shell, - office, - office_phone, - home_phone, - user_never_logged_in, - sessions, - forward, - None, - pgpkey, - project, - plan, - ))) -} - -// /// Retrieve remote user information for the given username on the specified host. -// /// -// /// Returns None if the user does not exist or no information is available. -// async fn get_remote_user(username: &str, host: &str) -> anyhow::Result> { -// let addr = format!("{}:79", host); -// let socket_addrs: Vec = addr.to_socket_addrs()?.collect(); - -// if socket_addrs.is_empty() { -// return Err(anyhow::anyhow!( -// "Could not resolve address for host {}", -// host -// )); -// } - -// let socket_addr = socket_addrs[0]; - -// let mut stream = TcpStream::connect(socket_addr).await?; - -// let request = FingerRequest::new(false, username.to_string()); -// let request_bytes = request.to_bytes(); -// stream.write_all(&request_bytes).await?; - -// let mut response_bytes = Vec::new(); -// stream.read_to_end(&mut response_bytes).await?; - -// let response = RawFingerResponse::from_bytes(&response_bytes); - -// if response.is_empty() { -// Ok(None) -// } else { -// Ok(Some(response)) -// } -// } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_finger_root() { - let user_entry = get_local_user("root", None).unwrap().unwrap(); - assert_eq!(user_entry.username, "root"); - } - - // TODO: test serialization roundtrip -} diff --git a/src/server/fingerd/local_email.rs b/src/server/fingerd/local_email.rs new file mode 100644 index 0000000..f911483 --- /dev/null +++ b/src/server/fingerd/local_email.rs @@ -0,0 +1,91 @@ +use chrono::{TimeZone, Utc}; +use std::path::{Path, PathBuf}; + +use crate::proto::finger_protocol::MailStatus; + +pub fn detect_new_mail_for_user( + username: &str, + homedir: &Path, +) -> anyhow::Result> { + let mail_storage_paths: Vec = vec![ + homedir.join("Mail"), + homedir.join("Mailbox"), + homedir.join("Maildir"), + PathBuf::from("/var/mail").join(username), + PathBuf::from("/var/spool/mail").join(username), + PathBuf::from("/var/maildir").join(username), + PathBuf::from("/var/spool/maildir").join(username), + ]; + + for path in mail_storage_paths { + tracing::debug!("Checking for mail at path: {}", path.display()); + if path.is_dir() && path.join("new").is_dir() && path.join("cur").is_dir() { + tracing::debug!("Detected maildir structure at path: {}", path.display()); + if let Ok(status) = detect_new_mail_by_maildir(&path) { + return Ok(Some(status)); + } + } else if path.is_file() { + tracing::debug!("Detected mailbox file at path: {}", path.display()); + if let Ok(status) = detect_new_mail_by_mailbox(&path) { + return Ok(Some(status)); + } + } + } + + Ok(None) +} + +fn detect_new_mail_by_mailbox(mailbox_path: &Path) -> anyhow::Result { + let stat = nix::sys::stat::stat(mailbox_path)?; + + let mail_recv = stat.st_mtime; + let mail_read = stat.st_atime; + + if mail_recv > mail_read { + Ok(MailStatus::NewMailReceived { + received_time: Utc + .timestamp_opt(mail_recv, 0) + .single() + .unwrap_or_else(Default::default), + unread_since: Utc + .timestamp_opt(mail_read, 0) + .single() + .unwrap_or_else(Default::default), + }) + } else { + Ok(MailStatus::MailLastRead( + Utc.timestamp_opt(mail_read, 0) + .single() + .unwrap_or_else(Default::default), + )) + } +} + +fn detect_new_mail_by_maildir(maildir_path: &Path) -> anyhow::Result { + let new_mail_path = maildir_path.join("new"); + let cur_mail_path = maildir_path.join("cur"); + + let mail_recv = nix::sys::stat::stat(&new_mail_path)?.st_mtime; + let mail_read = nix::sys::stat::stat(&cur_mail_path)?.st_mtime; + + if new_mail_path.read_dir()?.next().is_none() && cur_mail_path.read_dir()?.next().is_none() { + Ok(MailStatus::NoMail) + } else if mail_recv > mail_read { + Ok(MailStatus::NewMailReceived { + received_time: Utc + .timestamp_opt(mail_recv, 0) + .single() + .unwrap_or_else(Default::default), + unread_since: Utc + .timestamp_opt(mail_read, 0) + .single() + .unwrap_or_else(Default::default), + }) + } else { + Ok(MailStatus::MailLastRead( + Utc.timestamp_opt(mail_read, 0) + .single() + .unwrap_or_else(Default::default), + )) + } +} diff --git a/src/server/fingerd/local_user_info.rs b/src/server/fingerd/local_user_info.rs new file mode 100644 index 0000000..2aeaf0e --- /dev/null +++ b/src/server/fingerd/local_user_info.rs @@ -0,0 +1,243 @@ +use std::{ + net::hostname, + os::unix::fs::{MetadataExt, PermissionsExt}, + path::Path, +}; + +use chrono::{DateTime, Duration, Timelike, Utc}; +use itertools::Itertools; +use nix::sys::stat::stat; +use users::all_users; +use uucore::utmpx::{Utmpx, UtmpxRecord}; + +use crate::{ + proto::finger_protocol::{FingerResponseUserEntry, FingerResponseUserSession}, + server::fingerd::{FingerRequestInfo, local_email}, +}; + +/// Search for users whose username or full name contains the search string. +pub fn search_for_user( + search_string: &str, + match_fullnames: bool, + _request_info: &FingerRequestInfo, +) -> Vec> { + (unsafe { all_users() }) + .filter_map(|user| { + let user = match nix::unistd::User::from_uid(user.uid().into()) { + Ok(Some(user)) => user, + Ok(None) => { + tracing::warn!( + "User with UID {} exists but could not retrieve user entry", + user.uid() + ); + return None; + } + Err(e) => { + return Some(Err(anyhow::anyhow!( + "Failed to get user entry for UID {}: {}", + user.uid(), + e + ))); + } + }; + + let username = user.name; + let full_name = String::from_utf8_lossy( + user.gecos + .as_bytes() + .split(|&b| b == b',') + .next() + .unwrap_or(&[]), + ) + .to_string(); + + let matches_username = username.contains(search_string); + let matches_fullname = match_fullnames && full_name.contains(search_string); + if matches_username || matches_fullname { + match get_local_user(&username, None) { + Ok(Some(user_entry)) => Some(Ok(user_entry)), + Ok(None) => None, // User exists but has .nofinger, skip + Err(err) => Some(Err(err)), + } + } else { + None + } + }) + .collect() +} + +/// Retrieve information about all users currently logged in, based on utmpx records. +pub fn finger_utmp_users( + _request_info: &FingerRequestInfo, +) -> Vec> { + Utmpx::iter_all_records() + .filter(|entry| entry.is_user_process()) + .into_group_map_by(|entry| entry.user()) + .into_iter() + .map(|(username, records)| get_local_user(&username, Some(records))) + .filter_map(|result| match result { + Ok(Some(user_entry)) => Some(Ok(user_entry)), + Ok(None) => None, // User has .nofinger, skip + Err(err) => Some(Err(err)), + }) + .collect() +} + +/// Helper function to read the content of a file if it exists and is readable, +/// returning None if the file does not exist or is not readable. +fn read_file_content_if_exists(path: &Path) -> anyhow::Result> { + let file_is_readable = path.exists() + && path.is_file() + && (((path.metadata()?.permissions().mode() & 0o400 != 0 + && nix::unistd::geteuid().as_raw() == path.metadata()?.uid()) + || (path.metadata()?.permissions().mode() & 0o040 != 0 + && nix::unistd::getegid().as_raw() == path.metadata()?.gid()) + || (path.metadata()?.permissions().mode() & 0o004 != 0)) + || caps::has_cap( + None, + caps::CapSet::Effective, + caps::Capability::CAP_DAC_READ_SEARCH, + )?) + && path.metadata()?.len() > 0; + + if file_is_readable { + Ok(Some(std::fs::read_to_string(path)?.trim().to_string())) + } else { + Ok(None) + } +} + +/// Retrieve local user information for the given username. +/// +/// Returns None if the user does not exist. +fn get_local_user( + username: &str, + utmp_records: Option>, +) -> anyhow::Result> { + tracing::trace!( + "Retrieving local user information for username: {}", + username + ); + let username = username.to_string(); + let user_entry = match nix::unistd::User::from_name(&username) { + Ok(Some(user)) => user, + Ok(None) => return Ok(None), + Err(err) => { + return Err(anyhow::anyhow!( + "Failed to get user entry for {}: {}", + username, + err + )); + } + }; + + let nofinger_path = user_entry.dir.join(".nofinger"); + if nofinger_path.exists() { + return Ok(None); + } + + let full_name = user_entry.name; + let home_dir = user_entry.dir.clone(); + let shell = user_entry.shell; + + let gecos_fields: Vec<&str> = full_name.split(',').collect(); + + let office = gecos_fields.get(1).map(|s| s.to_string()); + let office_phone = gecos_fields.get(2).map(|s| s.to_string()); + let home_phone = gecos_fields.get(3).map(|s| s.to_string()); + + let hostname = hostname()?.to_str().unwrap_or("localhost").to_string(); + + let utmpx_records = match utmp_records { + Some(records) => records, + None => Utmpx::iter_all_records() + .filter(|entry| entry.user() == username) + .filter(|entry| entry.is_user_process()) + .collect::>(), + }; + + // TODO: Don't use utmp entries for this, read from lastlog instead + let user_never_logged_in = utmpx_records.is_empty(); + + let now = Utc::now().with_nanosecond(0).unwrap_or(Utc::now()); + let sessions: Vec = utmpx_records + .into_iter() + .filter_map(|entry| { + let login_time = entry + .login_time() + .checked_to_utc() + .and_then(|t| DateTime::::from_timestamp_secs(t.unix_timestamp()))?; + + let tty_device_stat = stat(&Path::new("/dev").join(entry.tty_device())).ok(); + + let idle_time = tty_device_stat + .and_then(|st| { + let last_active = DateTime::::from_timestamp_secs(st.st_atime)?; + Some((now - last_active).max(Duration::zero())) + }) + .unwrap_or(Duration::zero()); + + // Check if the write permission for "others" is set + let messages_on = tty_device_stat + .map(|st| st.st_mode & 0o002 != 0) + .unwrap_or(false); + + debug_assert!( + idle_time.num_seconds() >= 0, + "Idle time should never be negative" + ); + + Some(FingerResponseUserSession::new( + entry.tty_device(), + login_time, + idle_time, + hostname.clone(), + messages_on, + )) + }) + .collect(); + + let email_status = local_email::detect_new_mail_for_user(&username, &home_dir)?; + + let forward_path = user_entry.dir.join(".forward"); + let forward = read_file_content_if_exists(&forward_path)?; + + let pgpkey_path = user_entry.dir.join(".pgpkey"); + let pgpkey = read_file_content_if_exists(&pgpkey_path)?; + + let project_path = user_entry.dir.join(".project"); + let project = read_file_content_if_exists(&project_path)?; + + let plan_path = user_entry.dir.join(".plan"); + let plan = read_file_content_if_exists(&plan_path)?; + + Ok(Some(FingerResponseUserEntry::new( + username, + full_name, + home_dir, + shell, + office, + office_phone, + home_phone, + user_never_logged_in, + sessions, + forward, + email_status, + pgpkey, + project, + plan, + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_finger_root() { + let user_entry = get_local_user("root", None).unwrap().unwrap(); + assert_eq!(user_entry.username, "root"); + } + + // TODO: test serialization roundtrip +} diff --git a/src/server/fingerd/remote_user_info.rs b/src/server/fingerd/remote_user_info.rs new file mode 100644 index 0000000..4907543 --- /dev/null +++ b/src/server/fingerd/remote_user_info.rs @@ -0,0 +1,33 @@ +// /// Retrieve remote user information for the given username on the specified host. +// /// +// /// Returns None if the user does not exist or no information is available. +// async fn get_remote_user(username: &str, host: &str) -> anyhow::Result> { +// let addr = format!("{}:79", host); +// let socket_addrs: Vec = addr.to_socket_addrs()?.collect(); + +// if socket_addrs.is_empty() { +// return Err(anyhow::anyhow!( +// "Could not resolve address for host {}", +// host +// )); +// } + +// let socket_addr = socket_addrs[0]; + +// let mut stream = TcpStream::connect(socket_addr).await?; + +// let request = FingerRequest::new(false, username.to_string()); +// let request_bytes = request.to_bytes(); +// stream.write_all(&request_bytes).await?; + +// let mut response_bytes = Vec::new(); +// stream.read_to_end(&mut response_bytes).await?; + +// let response = RawFingerResponse::from_bytes(&response_bytes); + +// if response.is_empty() { +// Ok(None) +// } else { +// Ok(Some(response)) +// } +// }