diff --git a/Cargo.lock b/Cargo.lock index 773742f..0ba2fc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,12 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + [[package]] name = "cc" version = "1.2.51" @@ -556,6 +562,7 @@ name = "roowho2" version = "0.1.0" dependencies = [ "anyhow", + "bytes", "chrono", "clap", "nix", @@ -753,9 +760,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "type-map" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 84f7492..adec9f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,10 +15,11 @@ autolib = false [dependencies] anyhow = "1.0.100" +bytes = "1.11.0" chrono = { version = "0.4.42", features = ["serde"] } clap = { version = "4.5.53", features = ["derive"] } nix = { version = "0.30.1", features = ["hostname", "net"] } -tokio = { version = "1.49.0", features = ["net", "rt-multi-thread"] } +tokio = { version = "1.49.0", features = ["macros", "net", "rt-multi-thread"] } # onc-rpc = "0.3.2" # sd-notify = "0.4.5" # serde = { version = "1.0.228", features = ["derive"] } diff --git a/src/bin/roowhod.rs b/src/bin/roowhod.rs index 0b8f907..bac400c 100644 --- a/src/bin/roowhod.rs +++ b/src/bin/roowhod.rs @@ -1,10 +1,41 @@ -fn main() { - println!( - "{:#?}", - roowho2_lib::server::rwhod::generate_rwhod_status_update(), - ); - println!( - "{:#?}", - roowho2_lib::server::rwhod::determine_relevant_interfaces(), - ); +use std::net::SocketAddrV4; + +use anyhow::Context; +use chrono::Timelike; +use roowho2_lib::proto::{Whod, WhodStatusUpdate}; + +const RWHOD_BROADCAST_PORT: u16 = 513; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let addr = SocketAddrV4::new(std::net::Ipv4Addr::UNSPECIFIED, RWHOD_BROADCAST_PORT); + let socket = tokio::net::UdpSocket::bind(addr).await?; + socket.set_broadcast(true)?; + + let mut buf = [0u8; Whod::MAX_SIZE]; + loop { + let (len, src) = socket.recv_from(&mut buf).await?; + if len < Whod::HEADER_SIZE { + eprintln!( + "Received too short packet from {src}: {len} bytes (needs to be at least {} bytes)", + Whod::HEADER_SIZE + ); + continue; + } + let result: WhodStatusUpdate = Whod::from_bytes(&buf[..len]) + .context("Failed to parse whod packet")? + .try_into() + .map(|mut status_update: WhodStatusUpdate| { + let timestamp = chrono::Utc::now() + .with_nanosecond(0) + .unwrap_or(chrono::Utc::now()); + status_update.recvtime = Some(timestamp); + status_update + }) + .map_err(|e| anyhow::anyhow!("Invalid whod packet: {}", e))?; + + println!("Received whod packet from {src}:\n{result:#?}"); + + buf = [0u8; Whod::MAX_SIZE]; + } } diff --git a/src/proto.rs b/src/proto.rs index ecc6999..973c695 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -3,7 +3,4 @@ pub mod rusers_protocol; pub mod rwhod_protocol; pub mod write_protocol; -pub use finger_protocol::*; -pub use rusers_protocol::*; pub use rwhod_protocol::*; -pub use write_protocol::*; diff --git a/src/proto/finger_protocol.rs b/src/proto/finger_protocol.rs index e69de29..8b13789 100644 --- a/src/proto/finger_protocol.rs +++ b/src/proto/finger_protocol.rs @@ -0,0 +1 @@ + diff --git a/src/proto/rusers_protocol.rs b/src/proto/rusers_protocol.rs index e69de29..8b13789 100644 --- a/src/proto/rusers_protocol.rs +++ b/src/proto/rusers_protocol.rs @@ -0,0 +1 @@ + diff --git a/src/proto/rwhod_protocol.rs b/src/proto/rwhod_protocol.rs index 93bceba..343043e 100644 --- a/src/proto/rwhod_protocol.rs +++ b/src/proto/rwhod_protocol.rs @@ -1,5 +1,11 @@ use std::array; +use bytes::{Buf, BufMut, BytesMut}; +use chrono::Duration; + +/// Classic C struct for utmp data for a single user session. +/// +/// This struct is used in the rwhod protocol by being interpreted as raw bytes to be sent over UDP. #[derive(Debug, Clone)] #[repr(C)] pub struct Outmp { @@ -16,6 +22,9 @@ impl Outmp { pub const MAX_USER_ID_LEN: usize = 8; } +/// Classic C struct for a single user session. +/// +/// This struct is used in the rwhod protocol by being interpreted as raw bytes to be sent over UDP. #[derive(Debug, Clone)] #[repr(C)] pub struct Whoent { @@ -25,6 +34,31 @@ pub struct Whoent { pub we_idle: i32, } +impl Whoent { + pub const SIZE: usize = std::mem::size_of::(); + + fn zeroed() -> Self { + Self { + we_utmp: Outmp { + out_line: [0u8; Outmp::MAX_TTY_NAME_LEN], + out_name: [0u8; Outmp::MAX_USER_ID_LEN], + out_time: 0, + }, + we_idle: 0, + } + } + + fn is_zeroed(&self) -> bool { + self.we_utmp.out_line.iter().all(|&b| b == 0) + && self.we_utmp.out_name.iter().all(|&b| b == 0) + && self.we_utmp.out_time == 0 + && self.we_idle == 0 + } +} + +/// Classic C struct for a rwhod status update. +/// +/// This struct is used in the rwhod protocol by being interpreted as raw bytes to be sent over UDP. #[derive(Debug, Clone)] #[repr(C)] pub struct Whod { @@ -47,7 +81,9 @@ pub struct Whod { } impl Whod { - pub const SIZE: usize = std::mem::size_of::(); + pub const HEADER_SIZE: usize = 1 + 1 + 2 + 4 + 4 + Self::MAX_HOSTNAME_LEN + 4 * 3 + 4; + pub const MAX_SIZE: usize = std::mem::size_of::(); + pub const MAX_HOSTNAME_LEN: usize = 32; pub const MAX_WHOENTRIES: usize = 1024 / std::mem::size_of::(); @@ -56,42 +92,170 @@ impl Whod { // NOTE: there was probably meant to be more packet types, but only status is defined. pub const WHODTYPE_STATUS: u8 = 1; - pub fn to_bytes(&self) -> [u8; std::mem::size_of::()] { - unsafe { std::mem::transmute_copy(self) } + pub fn new( + sendtime: i32, + recvtime: i32, + hostname: [u8; Self::MAX_HOSTNAME_LEN], + loadav: [i32; 3], + boottime: i32, + whoentries: [Whoent; Self::MAX_WHOENTRIES], + ) -> Self { + debug_assert!( + whoentries + .iter() + .skip_while(|entry| !entry.is_zeroed()) + .all(|entry| entry.is_zeroed()) + ); + + Self { + wd_vers: Self::WHODVERSION, + wd_type: Self::WHODTYPE_STATUS, + wd_pad: [0u8; 2], + wd_sendtime: sendtime, + wd_recvtime: recvtime, + wd_hostname: hostname, + wd_loadav: loadav, + wd_boottime: boottime, + wd_we: whoentries, + } } - // TODO: we should probably make a safer parser. - pub fn from_bytes(bytes: &[u8; std::mem::size_of::()]) -> Self { - unsafe { std::mem::transmute_copy(bytes) } + pub fn to_bytes(&self) -> [u8; Whod::MAX_SIZE] { + let mut buf = BytesMut::with_capacity(Whod::MAX_SIZE); + buf.put_u8(self.wd_vers); + buf.put_u8(self.wd_type); + buf.put_slice(&self.wd_pad); + buf.put_i32(self.wd_sendtime); + buf.put_i32(self.wd_recvtime); + buf.put_slice(&self.wd_hostname); + buf.put_i32(self.wd_loadav[0]); + buf.put_i32(self.wd_loadav[1]); + buf.put_i32(self.wd_loadav[2]); + buf.put_i32(self.wd_boottime); + + for whoent in &self.wd_we { + buf.put_slice(&whoent.we_utmp.out_line); + buf.put_slice(&whoent.we_utmp.out_name); + buf.put_i32(whoent.we_utmp.out_time); + buf.put_i32(whoent.we_idle); + } + + // SAFETY: this should never happen, Whod::MAX_SIZE is computed from the struct size + buf + .to_vec() + .try_into() + .expect("Buffer length mismatch, this should never happen") + } + + pub fn from_bytes(input: &[u8]) -> anyhow::Result { + if input.len() < Self::HEADER_SIZE { + return Err(anyhow::anyhow!( + "Not enough bytes to parse packet header: {} < {}", + input.len(), + Self::HEADER_SIZE + )); + } + + if input.len() > Self::MAX_SIZE { + return Err(anyhow::anyhow!( + "Too many bytes to parse packet: {} > {}", + input.len(), + Self::MAX_SIZE + )); + } + + if !(input.len() - Self::HEADER_SIZE).is_multiple_of(Whoent::SIZE) { + return Err(anyhow::anyhow!( + "Invalid packet length: {} (not aligned with struct sizes, should be {} + N * {})", + input.len(), + Self::HEADER_SIZE, + Whoent::SIZE, + )); + } + + let mut bytes = bytes::Bytes::copy_from_slice(input); + + let wd_vers = bytes.get_u8(); + debug_assert!(wd_vers == Self::WHODVERSION); + + let wd_type = bytes.get_u8(); + debug_assert!(wd_type == Self::WHODTYPE_STATUS); + + bytes.advance(2); // skip wd_pad + + let wd_sendtime = bytes.get_i32(); + let wd_recvtime = bytes.get_i32(); + let mut wd_hostname = [0u8; Self::MAX_HOSTNAME_LEN]; + bytes.copy_to_slice(&mut wd_hostname); + let wd_loadav = [bytes.get_i32(), bytes.get_i32(), bytes.get_i32()]; + let wd_boottime = bytes.get_i32(); + + debug_assert!(bytes.remaining() + Self::HEADER_SIZE == input.len()); + + let mut wd_we = array::from_fn(|_| Whoent::zeroed()); + + for (byte_chunk, whoent) in bytes.chunks_exact(Whoent::SIZE).zip(wd_we.iter_mut()) { + let mut chunk_bytes = bytes::Bytes::copy_from_slice(byte_chunk); + + let mut out_line = [0u8; Outmp::MAX_TTY_NAME_LEN]; + chunk_bytes.copy_to_slice(&mut out_line); + let mut out_name = [0u8; Outmp::MAX_USER_ID_LEN]; + chunk_bytes.copy_to_slice(&mut out_name); + let out_time = chunk_bytes.get_i32(); + + let we_utmp = Outmp { + out_line, + out_name, + out_time, + }; + let we_idle = chunk_bytes.get_i32(); + + *whoent = Whoent { we_utmp, we_idle }; + } + + let result = Whod::new( + wd_sendtime, + wd_recvtime, + wd_hostname, + wd_loadav, + wd_boottime, + wd_we, + ); + + Ok(result) } } // ------------------------------------------------ +/// High-level representation of a rwhod status update. +/// +/// This struct is intended for easier use in Rust code, with proper types and dynamic arrays. +/// It can be converted to and from the low-level [`Whod`] struct used for network transmission. #[derive(Debug, Clone)] pub struct WhodStatusUpdate { // NOTE: there is only one defined packet type, so we just omit it here /// Timestamp by sender - sendtime: chrono::DateTime, + pub sendtime: chrono::DateTime, /// Timestamp applied by receiver - recvtime: Option>, + pub recvtime: Option>, /// Name of the host sending the status update (max 32 characters) - hostname: String, + pub hostname: String, /// load average over 5 minutes multiplied by 100 - load_average_5_min: i32, + pub load_average_5_min: i32, /// load average over 10 minutes multiplied by 100 - load_average_10_min: i32, + pub load_average_10_min: i32, /// load average over 15 minutes multiplied by 100 - load_average_15_min: i32, + pub load_average_15_min: i32, /// Which time the system was booted - boot_time: chrono::DateTime, + pub boot_time: chrono::DateTime, /// List of users currently logged in to the host (max 42 entries) - users: Vec, + pub users: Vec, } impl WhodStatusUpdate { @@ -118,19 +282,23 @@ impl WhodStatusUpdate { } } +/// High-level representation of a single user session in a rwhod status update. +/// +/// This struct is intended for easier use in Rust code, with proper types. +/// It can be converted to and from the low-level [`Whoent`] struct used for network transmission. #[derive(Debug, Clone)] pub struct WhodUserEntry { /// TTY name (max 8 characters) - tty: String, + pub tty: String, /// User ID (max 8 characters) - user_id: String, + pub user_id: String, /// Time when the user logged in - login_time: chrono::DateTime, + pub login_time: chrono::DateTime, - /// Idle time in seconds - idle_time_seconds: i32, + /// How long since the user last typed on the TTY + pub idle_time: Duration, } impl WhodUserEntry { @@ -138,13 +306,13 @@ impl WhodUserEntry { tty: String, user_id: String, login_time: chrono::DateTime, - idle_time_seconds: i32, + idle_time: Duration, ) -> Self { Self { tty, user_id, login_time, - idle_time_seconds, + idle_time, } } } @@ -181,7 +349,7 @@ impl TryFrom for WhodUserEntry { tty, user_id, login_time, - idle_time_seconds: value.we_idle, + idle_time: Duration::seconds(value.we_idle as i64), }) } } @@ -224,11 +392,13 @@ impl TryFrom for WhodStatusUpdate { format!("Invalid boot time timestamp: {}", value.wd_boottime), )?; - let mut users = Vec::new(); - for whoent in &value.wd_we { - let user_entry = WhodUserEntry::try_from(whoent.clone())?; - users.push(user_entry); - } + let users = value + .wd_we + .iter() + .take_while(|whoent| !whoent.is_zeroed()) + .cloned() + .map(WhodUserEntry::try_from) + .collect::, String>>()?; Ok(WhodStatusUpdate { sendtime, @@ -243,91 +413,89 @@ impl TryFrom for WhodStatusUpdate { } } -// TODO: support less strict conversions with truncation - impl TryFrom for Whoent { type Error = String; fn try_from(value: WhodUserEntry) -> Result { - let mut out_line = [0u8; 8]; + let mut out_line = [0u8; Outmp::MAX_TTY_NAME_LEN]; let tty_bytes = value.tty.as_bytes(); - if tty_bytes.len() > out_line.len() { - return Err(format!( - "TTY name too long: {} (max {})", - value.tty, - out_line.len() - )); - } - out_line[..tty_bytes.len()].copy_from_slice(tty_bytes); + out_line[..tty_bytes.len().min(Outmp::MAX_TTY_NAME_LEN)].copy_from_slice(tty_bytes); - let mut out_name = [0u8; 8]; + let mut out_name = [0u8; Outmp::MAX_USER_ID_LEN]; let user_id_bytes = value.user_id.as_bytes(); - if user_id_bytes.len() > out_name.len() { - return Err(format!( - "User ID too long: {} (max {})", - value.user_id, - out_name.len() - )); - } - out_name[..user_id_bytes.len()].copy_from_slice(user_id_bytes); + out_name[..user_id_bytes.len().min(Outmp::MAX_USER_ID_LEN)].copy_from_slice(user_id_bytes); + + let out_time = value + .login_time + .timestamp() + .max(i32::MAX as i64) + .min(i32::MIN as i64) as i32; + + let we_idle = value + .idle_time + .num_seconds() + .max(i32::MAX as i64) + .min(i32::MIN as i64) as i32; Ok(Whoent { we_utmp: Outmp { out_line, out_name, - out_time: value.login_time.timestamp() as i32, + out_time, }, - we_idle: value.idle_time_seconds, + we_idle, }) } } -// TODO: support less strict conversions with truncation - impl TryFrom for Whod { type Error = String; fn try_from(value: WhodStatusUpdate) -> Result { let mut wd_hostname = [0u8; Whod::MAX_HOSTNAME_LEN]; let hostname_bytes = value.hostname.as_bytes(); - if hostname_bytes.len() > wd_hostname.len() { - return Err(format!( - "Hostname too long: {} (max {})", - value.hostname, - wd_hostname.len() - )); - } - wd_hostname[..hostname_bytes.len()].copy_from_slice(hostname_bytes); + wd_hostname[..hostname_bytes.len().min(Whod::MAX_HOSTNAME_LEN)] + .copy_from_slice(hostname_bytes); - let mut wd_we = array::from_fn(|_| Whoent { - we_utmp: Outmp { - out_line: [0u8; Outmp::MAX_TTY_NAME_LEN], - out_name: [0u8; Outmp::MAX_USER_ID_LEN], - out_time: 0, - }, - we_idle: 0, + let wd_sendtime = value + .sendtime + .timestamp() + .max(i32::MAX as i64) + .min(i32::MIN as i64) as i32; + + let wd_recvtime = value.recvtime.map_or(0, |dt| { + dt.timestamp().max(i32::MAX as i64).min(i32::MIN as i64) as i32 }); - for (i, user_entry) in value.users.into_iter().enumerate() { - if i >= Whod::MAX_WHOENTRIES { - break; - } - wd_we[i] = Whoent::try_from(user_entry)?; - } + let wd_boottime = value + .boot_time + .timestamp() + .max(i32::MAX as i64) + .min(i32::MIN as i64) as i32; + + let wd_we = value + .users + .into_iter() + .map(Whoent::try_from) + .chain(std::iter::repeat(Ok(Whoent::zeroed()))) + .take(Whod::MAX_WHOENTRIES) + .collect::, String>>()? + .try_into() + .expect("Length mismatch, this should never happen"); Ok(Whod { wd_vers: Whod::WHODVERSION, wd_type: Whod::WHODTYPE_STATUS, wd_pad: [0u8; 2], - wd_sendtime: value.sendtime.timestamp() as i32, - wd_recvtime: value.recvtime.map_or(0, |dt| dt.timestamp() as i32), + wd_sendtime, + wd_recvtime, wd_hostname, wd_loadav: [ value.load_average_5_min, value.load_average_10_min, value.load_average_15_min, ], - wd_boottime: value.boot_time.timestamp() as i32, + wd_boottime, wd_we, }) } diff --git a/src/proto/write_protocol.rs b/src/proto/write_protocol.rs index e69de29..8b13789 100644 --- a/src/proto/write_protocol.rs +++ b/src/proto/write_protocol.rs @@ -0,0 +1 @@ + diff --git a/src/server/rwhod.rs b/src/server/rwhod.rs index 58f9683..e1c32d7 100644 --- a/src/server/rwhod.rs +++ b/src/server/rwhod.rs @@ -1,5 +1,6 @@ use std::{collections::HashSet, net::IpAddr, path::Path}; +use chrono::{Duration, Timelike}; use nix::{ifaddrs::getifaddrs, net::if_::InterfaceFlags, sys::stat::stat}; use uucore::utmpx::Utmpx; @@ -19,8 +20,15 @@ pub fn generate_rwhod_user_entries() -> anyhow::Result> { .ok_or_else(|| anyhow::anyhow!("Failed to convert login time to UTC"))?; let idle_time = stat(&Path::new("/dev").join(entry.tty_device())) - .map(|st| (chrono::Utc::now().timestamp() - st.st_atime) as i32) - .unwrap_or(0); + .ok() + .and_then(|st| { + let last_active = + chrono::DateTime::::from_timestamp_secs(st.st_atime)?; + let now = chrono::Utc::now().with_nanosecond(0)?; + + Some(now - last_active) + }) + .unwrap_or(Duration::zero()); Ok(WhodUserEntry::new( entry.tty_device(), @@ -90,8 +98,8 @@ pub fn determine_relevant_interfaces() -> anyhow::Result> { match neighbor_addr { Some(addr) => addr .as_sockaddr_in() - .map(|sa| IpAddr::V4(sa.ip().into())) - .or_else(|| addr.as_sockaddr_in6().map(|sa| IpAddr::V6(sa.ip().into()))) + .map(|sa| IpAddr::V4(sa.ip())) + .or_else(|| addr.as_sockaddr_in6().map(|sa| IpAddr::V6(sa.ip()))) .map(|ip_addr| RwhodSendTarget { name: iface.interface_name, addr: ip_addr,