diff --git a/Cargo.lock b/Cargo.lock index 50d87e7..098d33e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -501,6 +501,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "intl-memoizer" version = "0.5.3" @@ -838,6 +847,7 @@ dependencies = [ "clap", "clap_complete", "futures-util", + "indoc", "nix 0.31.1", "sd-notify", "serde", diff --git a/Cargo.toml b/Cargo.toml index 68a8b15..3ec65bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,3 +82,6 @@ inherits = "release" strip = true lto = true codegen-units = 1 + +[dev-dependencies] +indoc = "2.0.7" diff --git a/src/lib.rs b/src/lib.rs index fb103b8..69cc830 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ #![feature(iter_map_windows)] +#![feature(gethostname)] pub mod proto; pub mod server; diff --git a/src/proto/finger_protocol.rs b/src/proto/finger_protocol.rs index dcb34de..5ddefef 100644 --- a/src/proto/finger_protocol.rs +++ b/src/proto/finger_protocol.rs @@ -39,7 +39,7 @@ impl FingerRequest { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct FingerResponse(String); impl FingerResponse { @@ -111,12 +111,6 @@ impl FingerResponse { } } -impl Default for FingerResponse { - fn default() -> Self { - Self(String::new()) - } -} - impl From for FingerResponse { fn from(s: String) -> Self { Self::new(s) diff --git a/src/server.rs b/src/server.rs index 929be62..6cb4339 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,4 +1,4 @@ pub mod config; +pub mod fingerd; pub mod rwhod; pub mod varlink_api; -pub mod fingerd; diff --git a/src/server/fingerd.rs b/src/server/fingerd.rs index dcf6d47..5b93fd0 100644 --- a/src/server/fingerd.rs +++ b/src/server/fingerd.rs @@ -1,9 +1,11 @@ use std::{ - net::{SocketAddr, ToSocketAddrs}, + net::{SocketAddr, ToSocketAddrs, hostname}, path::{Path, PathBuf}, }; -use chrono::{DateTime, Duration, TimeDelta, Timelike, Utc}; +use chrono::{ + DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeDelta, Timelike, Utc, Weekday, +}; use nix::sys::stat::stat; use serde::{Deserialize, Serialize}; use tokio::{ @@ -21,6 +23,7 @@ pub struct FingerUserEntry { pub home_dir: PathBuf, pub shell: PathBuf, pub sessions: Vec, + pub pgp_key: Option, pub forward_status: Option, pub mail_status: Option, pub plan: Option, @@ -33,6 +36,7 @@ impl FingerUserEntry { home_dir: PathBuf, shell: PathBuf, sessions: Vec, + pgp_key: Option, forward_status: Option, mail_status: Option, plan: Option, @@ -43,6 +47,7 @@ impl FingerUserEntry { home_dir, shell, sessions, + pgp_key, forward_status, mail_status, plan, @@ -84,7 +89,7 @@ impl FingerUserEntry { .nth(1) .and_then(|s| s.split("Shell:").next()) .map(|s| s.trim()) - .and_then(|s| Some(PathBuf::from(s))) + .map(PathBuf::from) .ok_or_else(|| { anyhow::anyhow!( "Failed to parse home directory from finger response for user {}", @@ -96,7 +101,7 @@ impl FingerUserEntry { .split("Shell:") .nth(1) .map(|s| s.trim()) - .and_then(|s| Some(PathBuf::from(s))) + .map(PathBuf::from) .ok_or_else(|| { anyhow::anyhow!( "Failed to parse shell from finger response for user {}", @@ -120,7 +125,7 @@ impl FingerUserEntry { // TODO: parse forward_status, mail_status, plan from remaining lines Ok(Self::new( - username, full_name, home_dir, shell, sessions, None, None, None, + username, full_name, home_dir, shell, sessions, None, None, None, None, )) } } @@ -143,52 +148,160 @@ impl FingerUserSession { } } + fn parse_login_time(time: &str, _timezone: &str) -> anyhow::Result> { + let now = Utc::now(); + let mut parts = time.split_whitespace(); + + let weekday = match parts.next() { + Some("Mon") => Weekday::Mon, + Some("Tue") => Weekday::Tue, + Some("Wed") => Weekday::Wed, + Some("Thu") => Weekday::Thu, + Some("Fri") => Weekday::Fri, + Some("Sat") => Weekday::Sat, + Some("Sun") => Weekday::Sun, + _ => anyhow::bail!("Invalid weekday in login time: {}", time), + }; + + let month = match parts.next() { + Some("Jan") => 1, + Some("Feb") => 2, + Some("Mar") => 3, + Some("Apr") => 4, + Some("May") => 5, + Some("Jun") => 6, + Some("Jul") => 7, + Some("Aug") => 8, + Some("Sep") => 9, + Some("Oct") => 10, + Some("Nov") => 11, + Some("Dec") => 12, + _ => anyhow::bail!("Invalid month in login time: {}", time), + }; + + let day: u32 = parts + .next() + .and_then(|d| d.parse().ok()) + .ok_or_else(|| anyhow::anyhow!("Invalid day in login time: {}", time))?; + + let time_part = parts + .next() + .ok_or_else(|| anyhow::anyhow!("Missing time in login time: {}", time))?; + + let clock = NaiveTime::parse_from_str(time_part, "%H:%M").map_err(|e| { + anyhow::anyhow!( + "Failed to parse time component in login time: {}: {}", + time, + e + ) + })?; + + const MAX_YEARS_BACK: i32 = 10; + + for offset in 0..=MAX_YEARS_BACK { + let year = now.year() - offset; + + let date = match NaiveDate::from_ymd_opt(year, month, day) { + Some(d) => d, + None => continue, + }; + + if date.weekday() != weekday { + continue; + } + + let dt = date.and_time(clock); + + if dt <= now.naive_utc() { + // TODO: apply timezone if we are able to parse it. + // if not, try to get the local timezone offset. + // if not, assume UTC. + + return Ok(DateTime::::from_utc(dt, Utc)); + } + } + + Err(anyhow::anyhow!( + "Could not infer year for login time {} within {} years", + time, + MAX_YEARS_BACK + )) + } + + fn parse_idle_time(str: &str) -> anyhow::Result { + // Parse idle time from finger response format. + // This has four cases: " ", "MMMMM", "HH:MM", "DDd" + if str.trim().is_empty() { + Ok(Duration::zero()) + } else if str.contains(':') { + let parts: Vec<&str> = str.split(':').collect(); + if parts.len() != 2 { + return Err(anyhow::anyhow!("Invalid idle time format: {}", str)); + } + let hours: i64 = parts[0].parse().map_err(|e| { + anyhow::anyhow!("Failed to parse hours from idle time {}: {}", str, e) + })?; + let minutes: i64 = parts[1].parse().map_err(|e| { + anyhow::anyhow!("Failed to parse minutes from idle time {}: {}", str, e) + })?; + Ok(Duration::hours(hours) + Duration::minutes(minutes)) + } else if str.ends_with('d') { + let days_str = str.trim_end_matches('d'); + let days: i64 = days_str.parse().map_err(|e| { + anyhow::anyhow!("Failed to parse days from idle time {}: {}", str, e) + })?; + Ok(Duration::days(days)) + } else { + let minutes: i64 = str.parse().map_err(|e| { + anyhow::anyhow!("Failed to parse minutes from idle time {}: {}", str, e) + })?; + Ok(Duration::minutes(minutes)) + } + } + /// Try parsing a user session from the text format used by the classic finger implementations. pub fn try_from_finger_response_line(line: &str) -> anyhow::Result { let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 6 { - return Err(anyhow::anyhow!( - "Unexpected finger session line format: {}", - line - )); - } - let tty = parts[2].to_string(); + debug_assert!(parts[0] == "On"); + debug_assert!(parts[1] == "since"); - let login_time_str = format!("{} {} {} {}", parts[4], parts[5], parts[6], parts[7]); - let login_time = DateTime::parse_from_str(&login_time_str, "%a %b %e %H:%M (%Z)") - .map_err(|e| { - anyhow::anyhow!( - "Failed to parse login time from finger session line: {}: {}", - line, - e - ) + let login_time_parts = parts + .iter() + .take_while(|&&s| s != "on") + .skip(2) + .cloned() + .collect::>(); + + let login_time = Self::parse_login_time( + &login_time_parts[..login_time_parts.len() - 1].join(" "), + login_time_parts[login_time_parts.len() - 1], + )?; + + let tty = parts + .iter() + .skip_while(|&&s| s != "on") + .nth(1) + .ok_or_else(|| anyhow::anyhow!("Failed to find tty in finger session line: {line}"))? + .trim_end_matches(',') + .to_string(); + + let idle_time_str = parts + .iter() + .skip_while(|&&s| s != "idle") + .nth(1) + .ok_or_else(|| { + anyhow::anyhow!("Failed to find idle time in finger session line: {line}") })? - .with_timezone(&Utc); + .trim_end_matches(','); + let idle_time = Self::parse_idle_time(idle_time_str)?; - // - - // let idle_time = if let Some(idle_index) = parts.iter().position(|&s| s == "idle") - // && idle_index + 1 < parts.len() - // { - // let idle_str = parts[idle_index + 1]; - // let idle_parts: Vec<&str> = idle_str.split(':').collect(); - // if idle_parts.len() == 2 { - // let hours: i64 = idle_parts[0].parse().unwrap_or(0); - // let minutes: i64 = idle_parts[1].parse().unwrap_or(0); - // Duration::hours(hours) + Duration::minutes(minutes) - // } else { - // Duration::zero() - // } - // } else { - // Duration::zero() - // }; - - let host = if parts.len() > 7 { - parts[7..].join(" ") - } else { - "".to_string() - }; + let host = parts + .iter() + .skip_while(|&&s| s != "from") + .nth(1) + .unwrap_or(&"") + .to_string(); Ok(Self { tty, @@ -220,11 +333,13 @@ pub fn get_local_user(username: &str) -> anyhow::Result> let home_dir = user_entry.dir.clone(); let shell = user_entry.shell; + let hostname = hostname()?.to_str().unwrap_or("localhost").to_string(); + let now = Utc::now().with_nanosecond(0).unwrap_or(Utc::now()); let sessions: Vec = Utmpx::iter_all_records() .filter(|entry| entry.user() == username) .filter(|entry| entry.is_user_process()) - .map(|entry| { + .filter_map(|entry| { let login_time = entry .login_time() .checked_to_utc() @@ -247,10 +362,9 @@ pub fn get_local_user(username: &str) -> anyhow::Result> entry.tty_device(), login_time, idle_time, - String::new(), // Host information is not available from local utmpx + hostname.clone(), )) }) - .flatten() .collect(); let forward_path = user_entry.dir.join(".forward"); @@ -269,7 +383,7 @@ pub fn get_local_user(username: &str) -> anyhow::Result> }; Ok(Some(FingerUserEntry::new( - username, full_name, home_dir, shell, sessions, forward, plan, None, + username, full_name, home_dir, shell, sessions, None, forward, plan, None, ))) } @@ -311,15 +425,41 @@ async fn get_remote_user(username: &str, host: &str) -> anyhow::Result