{rwhod,fingerd}: add ignore-user lists

This commit is contained in:
2026-06-24 12:43:10 +09:00
parent b8b4d8dcc0
commit 4af04d7dd6
9 changed files with 288 additions and 28 deletions
+15 -4
View File
@@ -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<String, OwnedFd> =
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<IgnoreList>,
) -> 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<IgnoreList>,
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();
+1
View File
@@ -1,4 +1,5 @@
pub mod config;
pub mod fingerd;
pub mod ignore_list;
pub mod rwhod;
pub mod varlink_api;
+16 -1
View File
@@ -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<PathBuf>,
/// 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<Vec<SocketAddrV4>>,
}
#[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<PathBuf>,
}
+35 -6
View File
@@ -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<anyhow::Result<FingerResponseStructuredUserEntry>> {
(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<anyhow::Result<FingerResponseStructuredUserEntry>> {
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<Option<String>> {
fn get_local_user(
username: &str,
utmp_records: Option<Vec<UtmpxRecord>>,
_request_info: &FingerRequestInfo,
ignore_list: Option<&IgnoreList>,
) -> anyhow::Result<Option<FingerResponseStructuredUserEntry>> {
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");
}
+142
View File
@@ -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<IgnoreEntry>,
}
impl IgnoreList {
pub fn load_optional(path: Option<&Path>) -> anyhow::Result<Option<Self>> {
match path {
Some(path) => Self::load(path).map(Some),
None => Ok(None),
}
}
pub fn load(path: &Path) -> anyhow::Result<Self> {
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<Self> {
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::<u32>().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"));
}
}
+6 -2
View File
@@ -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<UdpSocket>,
interfaces: Vec<RwhodSendTarget>,
ignore_list: Option<IgnoreList>,
) -> 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);
+16 -5
View File
@@ -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<Utc>) -> anyhow::Result<Vec<WhodUserEntry>> {
pub fn generate_rwhod_user_entries(
now: DateTime<Utc>,
ignore_list: Option<&IgnoreList>,
) -> anyhow::Result<Vec<WhodUserEntry>> {
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<Utc>) -> anyhow::Result<Vec<Who
}
/// Generate a rwhod status update packet representing the current system state.
pub fn generate_rwhod_status_update() -> anyhow::Result<WhodStatusUpdate> {
pub fn generate_rwhod_status_update(
ignore_list: Option<&IgnoreList>,
) -> anyhow::Result<WhodStatusUpdate> {
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<WhodStatusUpdate> {
(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);
}
}
+22 -8
View File
@@ -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<IgnoreList>,
}
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<IgnoreList>,
) -> Self {
Self {
whod_status_store,
finger_ignore_list,
}
}
}
@@ -194,10 +202,15 @@ impl VarlinkRoowhoo2ClientServer {
Some(usernames) => usernames
.into_iter()
.flat_map::<Vec<_>, _>(|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<zlink::unix::Stream> for VarlinkRoowhoo2ClientServer {
pub async fn varlink_client_server_task(
socket: zlink::unix::Listener,
whod_status_store: RwhodStatusStore,
finger_ignore_list: Option<IgnoreList>,
) -> 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);