{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
+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);