From 4af04d7dd644f0c818c50902816bdc0246a1ff57 Mon Sep 17 00:00:00 2001 From: h7x4 Date: Wed, 24 Jun 2026 12:43:10 +0900 Subject: [PATCH] {rwhod,fingerd}: add ignore-user lists --- nix/module.nix | 37 ++++++- src/bin/roowhod.rs | 19 +++- src/server.rs | 1 + src/server/config.rs | 17 ++- src/server/fingerd/local_user_info.rs | 41 ++++++-- src/server/ignore_list.rs | 142 ++++++++++++++++++++++++++ src/server/rwhod/packet_sender.rs | 8 +- src/server/rwhod/rwhod_status.rs | 21 +++- src/server/varlink_api.rs | 30 ++++-- 9 files changed, 288 insertions(+), 28 deletions(-) create mode 100644 src/server/ignore_list.rs diff --git a/nix/module.nix b/nix/module.nix index 1093c05..2f27ce8 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -22,6 +22,20 @@ in { default = true; }; + ignore_list_path = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = lib.literalExpression '' + pkgs.writeText "rwhod-ignore-list" ''' + # Ignore the following users from rwhod + user:user1 + user:user2 + uid:1001 + ''' + ''; + description = "Path to the ignore list for users that should be hidden from rwhod."; + }; + # TODO: allow configuring socket config }; fingerd = { @@ -29,6 +43,20 @@ in { default = true; }; + ignore_list_path = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = lib.literalExpression '' + pkgs.writeText "rwhod-ignore-list" ''' + # Ignore the following users from rwhod + user:user1 + user:user2 + uid:1001 + ''' + ''; + description = "Path to the ignore list for users that should be hidden from fingerd."; + }; + # TODO: allow configuring socket config }; }; @@ -115,7 +143,7 @@ in { RuntimeDirectory = "roowho2/root-mnt"; RuntimeDirectoryMode = "0700"; RootDirectory = "/run/roowho2/root-mnt"; - BindReadOnlyPaths = [ + BindReadOnlyPaths = lib.filter (x: x != null) ([ builtins.storeDir "/etc" @@ -130,7 +158,12 @@ in { # NOTE: finger needs access to stat tty devices "/dev" - ]; + + ] ++ lib.optionals cfg.settings.rwhod.enable [ + cfg.settings.rwhod.ignore_list_path + ] ++ lib.optionals cfg.settings.fingerd.enable [ + cfg.settings.fingerd.ignore_list_path + ]); UMask = "0077"; }; diff --git a/src/bin/roowhod.rs b/src/bin/roowhod.rs index dbec2e3..bfa6772 100644 --- a/src/bin/roowhod.rs +++ b/src/bin/roowhod.rs @@ -14,6 +14,7 @@ use tracing_subscriber::layer::SubscriberExt; use roowho2_lib::server::{ config::{DEFAULT_CONFIG_PATH, LogLevel}, + ignore_list::IgnoreList, rwhod::{RwhodStatusStore, rwhod_packet_receiver_task, rwhod_packet_sender_task}, varlink_api::varlink_client_server_task, }; @@ -59,6 +60,9 @@ async fn main() -> anyhow::Result<()> { tracing::subscriber::set_global_default(subscriber) .context("Failed to set global default tracing subscriber")?; + let rwhod_ignore_list = IgnoreList::load_optional(config.rwhod.ignore_list_path.as_deref())?; + let finger_ignore_list = IgnoreList::load_optional(config.fingerd.ignore_list_path.as_deref())?; + let fd_map: HashMap = HashMap::from_iter(sd_notify::listen_fds_with_names()?.map(|(fd_num, name)| { ( @@ -96,7 +100,11 @@ async fn main() -> anyhow::Result<()> { }) .context("RWHOD server is enabled, but socket fd not provided by systemd")??; - join_set.spawn(rwhod_server(socket, whod_status_store.clone())); + join_set.spawn(rwhod_server( + socket, + whod_status_store.clone(), + rwhod_ignore_list.clone(), + )); } else { tracing::debug!("RWHOD server is disabled in configuration"); } @@ -108,6 +116,7 @@ async fn main() -> anyhow::Result<()> { .try_clone() .context("Failed to clone RWHOD client-server socket fd")?, whod_status_store.clone(), + finger_ignore_list, client_server_token, )); @@ -127,12 +136,12 @@ async fn ctrl_c_handler() -> anyhow::Result<()> { async fn rwhod_server( socket: UdpSocket, whod_status_store: RwhodStatusStore, + ignore_list: Option, ) -> anyhow::Result<()> { let socket = Arc::new(socket); let interfaces = roowho2_lib::server::rwhod::determine_relevant_interfaces()?; - let sender_task = rwhod_packet_sender_task(socket.clone(), interfaces); - + let sender_task = rwhod_packet_sender_task(socket.clone(), interfaces, ignore_list); let receiver_task = rwhod_packet_receiver_task(socket.clone(), whod_status_store); tokio::select! { @@ -146,6 +155,7 @@ async fn rwhod_server( async fn client_server( socket_fd: OwnedFd, whod_status_store: RwhodStatusStore, + finger_ignore_list: Option, startup_token: CancellationToken, ) -> anyhow::Result<()> { // SAFETY: see above @@ -153,7 +163,8 @@ async fn client_server( unsafe { std::os::unix::net::UnixListener::from_raw_fd(socket_fd.as_raw_fd()) }; std_socket.set_nonblocking(true)?; let zlink_listener = zlink::unix::Listener::try_from(OwnedFd::from(std_socket))?; - let client_server_task = varlink_client_server_task(zlink_listener, whod_status_store); + let client_server_task = + varlink_client_server_task(zlink_listener, whod_status_store, finger_ignore_list); startup_token.cancel(); diff --git a/src/server.rs b/src/server.rs index 6cb4339..7b58c8f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,4 +1,5 @@ pub mod config; pub mod fingerd; +pub mod ignore_list; pub mod rwhod; pub mod varlink_api; diff --git a/src/server/config.rs b/src/server/config.rs index 3953702..ddcf890 100644 --- a/src/server/config.rs +++ b/src/server/config.rs @@ -1,4 +1,4 @@ -use std::net::SocketAddrV4; +use std::{net::SocketAddrV4, path::PathBuf}; use serde::{Deserialize, Serialize}; @@ -14,6 +14,9 @@ pub struct Config { /// Configuration for the rwhod server. pub rwhod: RwhodConfig, + /// Configuration for the fingerd server. + pub fingerd: FingerdConfig, + /// Path to the Unix domain socket for client-server communication. /// /// If left as `None`, the server expects to be served a file descriptor to the socket named 'client'. @@ -33,6 +36,9 @@ pub struct RwhodConfig { /// Enable or disable the rwhod server functionality. pub enable: bool, + /// Path to the ignore list for users that should be hidden from rwhod. + pub ignore_list_path: Option, + /// Network interfaces to listen on (e.g., ["eth0", "wlan0"]). /// /// If left as `None`, the server will automatically determine relevant interfaces. @@ -45,3 +51,12 @@ pub struct RwhodConfig { /// If left as `None`, the server will automatically determine broadcast addresses for the selected interfaces. pub broadcast_addresses: Option>, } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FingerdConfig { + /// Enable or disable the fingerd server functionality. + pub enable: bool, + + /// Path to the ignore list for users that should be hidden from fingerd. + pub ignore_list_path: Option, +} diff --git a/src/server/fingerd/local_user_info.rs b/src/server/fingerd/local_user_info.rs index 4e362b6..4b8f8cf 100644 --- a/src/server/fingerd/local_user_info.rs +++ b/src/server/fingerd/local_user_info.rs @@ -12,14 +12,18 @@ use uucore::utmpx::{Utmpx, UtmpxRecord}; use crate::{ proto::finger_protocol::{FingerResponseStructuredUserEntry, FingerResponseUserSession}, - server::fingerd::{FingerRequestInfo, local_email}, + server::{ + fingerd::{FingerRequestInfo, local_email}, + ignore_list::IgnoreList, + }, }; /// 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, + request_info: &FingerRequestInfo, + ignore_list: Option<&IgnoreList>, ) -> Vec> { (unsafe { all_users() }) .filter_map(|user| { @@ -51,10 +55,14 @@ pub fn search_for_user( ) .to_string(); + if ignore_list.is_some_and(|ignore_list| ignore_list.ignores_uid(user.uid.as_raw())) { + return None; + } + 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) { + match get_local_user(&username, None, request_info, ignore_list) { Ok(Some(user_entry)) => Some(Ok(user_entry)), Ok(None) => None, // User exists but has .nofinger, skip Err(err) => Some(Err(err)), @@ -68,13 +76,19 @@ pub fn search_for_user( /// Retrieve information about all users currently logged in, based on utmpx records. pub fn finger_utmp_users( - _request_info: &FingerRequestInfo, + request_info: &FingerRequestInfo, + ignore_list: Option<&IgnoreList>, ) -> 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(|(username, _)| { + !ignore_list.is_some_and(|ignore_list| ignore_list.ignores_username(username)) + }) + .map(|(username, records)| { + get_local_user(&username, Some(records), request_info, ignore_list) + }) .filter_map(|result| match result { Ok(Some(user_entry)) => Some(Ok(user_entry)), Ok(None) => None, // User has .nofinger, skip @@ -113,6 +127,8 @@ fn read_file_content_if_exists(path: &Path) -> anyhow::Result> { fn get_local_user( username: &str, utmp_records: Option>, + _request_info: &FingerRequestInfo, + ignore_list: Option<&IgnoreList>, ) -> anyhow::Result> { tracing::trace!( "Retrieving local user information for username: {}", @@ -131,6 +147,10 @@ fn get_local_user( } }; + if ignore_list.is_some_and(|ignore_list| ignore_list.ignores_uid(user_entry.uid.as_raw())) { + return Ok(None); + } + let nofinger_path = user_entry.dir.join(".nofinger"); if nofinger_path.exists() { return Ok(None); @@ -249,7 +269,16 @@ mod tests { #[test] fn test_finger_root() { - let user_entry = get_local_user("root", None).unwrap().unwrap(); + let user_entry = get_local_user( + "root", + None, + &FingerRequestInfo::Long { + prevent_files: false, + }, + None, + ) + .unwrap() + .unwrap(); assert_eq!(user_entry.username, "root"); } diff --git a/src/server/ignore_list.rs b/src/server/ignore_list.rs new file mode 100644 index 0000000..36ceb92 --- /dev/null +++ b/src/server/ignore_list.rs @@ -0,0 +1,142 @@ +use std::{collections::HashSet, path::Path}; + +use anyhow::Context; +use nix::unistd::{Uid, User}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum IgnoreEntry { + Uid(u32), + User(String), +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct IgnoreList { + entries: HashSet, +} + +impl IgnoreList { + pub fn load_optional(path: Option<&Path>) -> anyhow::Result> { + match path { + Some(path) => Self::load(path).map(Some), + None => Ok(None), + } + } + + pub fn load(path: &Path) -> anyhow::Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read ignore list {}", path.display()))?; + Self::parse(&content) + } + + pub fn parse(content: &str) -> anyhow::Result { + let mut entries = HashSet::new(); + + for (idx, raw_line) in content.lines().enumerate() { + let line = raw_line.split('#').next().unwrap_or("").trim(); + if line.is_empty() { + continue; + } + + let entry = if let Some(uid) = line.strip_prefix("uid:") { + let uid = uid.trim().parse::().with_context(|| { + format!("Invalid uid on ignore list line {}: {}", idx + 1, raw_line) + })?; + IgnoreEntry::Uid(uid) + } else if let Some(user) = line.strip_prefix("user:") { + let user = user.trim(); + if user.is_empty() { + anyhow::bail!("Invalid user on ignore list line {}: {}", idx + 1, raw_line); + } + IgnoreEntry::User(user.to_string()) + } else { + anyhow::bail!( + "Invalid ignore list entry on line {}: {}", + idx + 1, + raw_line + ); + }; + + entries.insert(entry); + } + + Ok(Self { entries }) + } + + pub fn ignores_username(&self, username: &str) -> bool { + if self + .entries + .contains(&IgnoreEntry::User(username.to_string())) + { + return true; + } + + match User::from_name(username) { + Ok(Some(user)) => self.entries.contains(&IgnoreEntry::Uid(user.uid.as_raw())), + Ok(None) => false, + Err(err) => { + tracing::warn!( + "Failed to resolve username '{}' for ignore list lookup: {}", + username, + err + ); + false + } + } + } + + pub fn ignores_uid(&self, uid: u32) -> bool { + if self.entries.contains(&IgnoreEntry::Uid(uid)) { + return true; + } + + match User::from_uid(Uid::from_raw(uid)) { + Ok(Some(user)) => self.entries.contains(&IgnoreEntry::User(user.name)), + Ok(None) => false, + Err(err) => { + tracing::warn!( + "Failed to resolve uid {} for ignore list lookup: {}", + uid, + err + ); + false + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ignore_list() { + let list = IgnoreList::parse( + &["uid:1000", "user:alice", " user:bob "].join("\n"), // "\n# comment\nuid:1000\nuser:alice # trailing comment\n\nuser:bob\n", + ) + .unwrap(); + + assert!(list.ignores_uid(1000)); + assert!(list.ignores_username("alice")); + assert!(list.ignores_username("bob")); + } + + #[test] + fn test_parse_ignore_list_with_comments() { + let list = IgnoreList::parse( + &[ + "# This is a comment", + "uid:1000", + "user:alice # trailing comment", + "", + "user:bob", + ] + .join("\n"), + ) + .unwrap(); + + assert!(list.ignores_uid(1000)); + assert!(list.ignores_username("alice")); + assert!(list.ignores_username("bob")); + } +} diff --git a/src/server/rwhod/packet_sender.rs b/src/server/rwhod/packet_sender.rs index 67614e5..ca100a6 100644 --- a/src/server/rwhod/packet_sender.rs +++ b/src/server/rwhod/packet_sender.rs @@ -9,7 +9,10 @@ use tokio::{ time::{Duration as TokioDuration, interval}, }; -use crate::{proto::Whod, server::rwhod::rwhod_status::generate_rwhod_status_update}; +use crate::{ + proto::Whod, + server::{ignore_list::IgnoreList, rwhod::rwhod_status::generate_rwhod_status_update}, +}; /// Default port for rwhod communication. pub const RWHOD_BROADCAST_PORT: u16 = 513; @@ -100,13 +103,14 @@ pub async fn send_rwhod_packet_to_interface( pub async fn rwhod_packet_sender_task( socket: Arc, interfaces: Vec, + ignore_list: Option, ) -> anyhow::Result<()> { let mut interval = interval(TokioDuration::from_secs(60)); loop { interval.tick().await; - let status_update = generate_rwhod_status_update()?; + let status_update = generate_rwhod_status_update(ignore_list.as_ref())?; tracing::debug!("Generated rwhod packet: {:?}", status_update); diff --git a/src/server/rwhod/rwhod_status.rs b/src/server/rwhod/rwhod_status.rs index 840a116..00ac102 100644 --- a/src/server/rwhod/rwhod_status.rs +++ b/src/server/rwhod/rwhod_status.rs @@ -7,12 +7,21 @@ use nix::{ }; use uucore::utmpx::Utmpx; -use crate::proto::{WhodStatusUpdate, WhodUserEntry}; +use crate::{ + proto::{WhodStatusUpdate, WhodUserEntry}, + server::ignore_list::IgnoreList, +}; /// Reads utmp entries to determine currently logged-in users. -pub fn generate_rwhod_user_entries(now: DateTime) -> anyhow::Result> { +pub fn generate_rwhod_user_entries( + now: DateTime, + ignore_list: Option<&IgnoreList>, +) -> anyhow::Result> { Utmpx::iter_all_records() .filter(|entry| entry.is_user_process()) + .filter(|entry| { + !ignore_list.is_some_and(|ignore_list| ignore_list.ignores_username(&entry.user())) + }) .map(|entry| { let login_time = entry .login_time() @@ -44,7 +53,9 @@ pub fn generate_rwhod_user_entries(now: DateTime) -> anyhow::Result anyhow::Result { +pub fn generate_rwhod_status_update( + ignore_list: Option<&IgnoreList>, +) -> anyhow::Result { let sysinfo = sysinfo().unwrap(); let load_average = sysinfo.load_average(); let uptime = sysinfo.uptime(); @@ -61,7 +72,7 @@ pub fn generate_rwhod_status_update() -> anyhow::Result { (load_average.2 * 100.0).abs() as i32, ), now - uptime, - generate_rwhod_user_entries(now)?, + generate_rwhod_user_entries(now, ignore_list)?, ); Ok(result) @@ -73,7 +84,7 @@ mod tests { #[test] fn test_generate_rwhod_status_update() { - let status_update = generate_rwhod_status_update().unwrap(); + let status_update = generate_rwhod_status_update(None).unwrap(); println!("{:?}", status_update); } } diff --git a/src/server/varlink_api.rs b/src/server/varlink_api.rs index d336478..8da7cec 100644 --- a/src/server/varlink_api.rs +++ b/src/server/varlink_api.rs @@ -10,6 +10,7 @@ use crate::{ proto::{WhodStatusUpdate, WhodUserEntry, finger_protocol::FingerResponseUserEntry}, server::{ fingerd::{self, FingerRequestInfo, FingerRequestNetworking, finger_utmp_users}, + ignore_list::IgnoreList, rwhod::RwhodStatusStore, }, }; @@ -135,11 +136,18 @@ pub enum VarlinkReplyError { #[derive(Debug, Clone)] pub struct VarlinkRoowhoo2ClientServer { whod_status_store: RwhodStatusStore, + finger_ignore_list: Option, } impl VarlinkRoowhoo2ClientServer { - pub fn new(whod_status_store: RwhodStatusStore) -> Self { - Self { whod_status_store } + pub fn new( + whod_status_store: RwhodStatusStore, + finger_ignore_list: Option, + ) -> Self { + Self { + whod_status_store, + finger_ignore_list, + } } } @@ -194,10 +202,15 @@ impl VarlinkRoowhoo2ClientServer { Some(usernames) => usernames .into_iter() .flat_map::, _>(|username| { - fingerd::search_for_user(&username, match_fullnames, &request_info) - .into_iter() - .map(|res| (username.clone(), res)) - .collect() + fingerd::search_for_user( + &username, + match_fullnames, + &request_info, + self.finger_ignore_list.as_ref(), + ) + .into_iter() + .map(|res| (username.clone(), res)) + .collect() }) .dedup_by(|a, b| match (&a.1, &b.1) { (Ok(user_a), Ok(user_b)) => user_a.username == user_b.username, @@ -217,7 +230,7 @@ impl VarlinkRoowhoo2ClientServer { .map(Box::new) .map(FingerResponseUserEntry::Structured) .collect(), - None => finger_utmp_users(&request_info) + None => finger_utmp_users(&request_info, self.finger_ignore_list.as_ref()) .into_iter() .filter_map(|res| match res { Ok(user_info) => Some(user_info), @@ -346,8 +359,9 @@ impl zlink::Service for VarlinkRoowhoo2ClientServer { pub async fn varlink_client_server_task( socket: zlink::unix::Listener, whod_status_store: RwhodStatusStore, + finger_ignore_list: Option, ) -> anyhow::Result<()> { - let service = VarlinkRoowhoo2ClientServer::new(whod_status_store); + let service = VarlinkRoowhoo2ClientServer::new(whod_status_store, finger_ignore_list); let server = zlink::Server::new(socket, service);