Files
roowho2/src/server/fingerd.rs
T
oysteikt e741dfd3c1
Build and test / check (push) Successful in 1m33s
Build and test / build (push) Successful in 1m42s
Build and test / test (push) Successful in 1m48s
Build and test / docs (push) Successful in 3m32s
fingerd: don't nest utmp entry requests
2026-04-28 21:08:28 +09:00

282 lines
9.0 KiB
Rust

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 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 {
Any,
IPv4Only,
IPv6Only,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum FingerRequestInfo {
ShortHost { restrict_gecos: bool },
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<anyhow::Result<FingerResponseUserEntry>> {
(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<anyhow::Result<FingerResponseUserEntry>> {
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<Option<String>> {
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<Vec<UtmpxRecord>>,
) -> anyhow::Result<Option<FingerResponseUserEntry>> {
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::<Vec<_>>(),
};
// 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<FingerResponseUserSession> = utmpx_records
.into_iter()
.filter_map(|entry| {
let login_time = entry
.login_time()
.checked_to_utc()
.and_then(|t| DateTime::<Utc>::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::<Utc>::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<Option<RawFingerResponse>> {
// let addr = format!("{}:79", host);
// let socket_addrs: Vec<SocketAddr> = 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
}