Skip to main content

roowho2_lib/proto/
rwhod_protocol.rs

1use std::array;
2
3use bytes::{Buf, BufMut, BytesMut};
4use chrono::{DateTime, Duration, Utc};
5use serde::{Deserialize, Serialize};
6
7/// Classic C struct for utmp data for a single user session.
8///
9/// This struct is used in the rwhod protocol by being interpreted as raw bytes to be sent over UDP.
10#[derive(Debug, Clone, PartialEq, Eq)]
11#[repr(C)]
12pub struct Outmp {
13    /// tty name
14    pub out_line: [u8; Self::MAX_TTY_NAME_LEN],
15    /// user id
16    pub out_name: [u8; Self::MAX_USER_ID_LEN],
17    /// time on
18    pub out_time: i32,
19}
20
21impl Outmp {
22    pub const MAX_TTY_NAME_LEN: usize = 8;
23    pub const MAX_USER_ID_LEN: usize = 8;
24}
25
26/// Classic C struct for a single user session.
27///
28/// This struct is used in the rwhod protocol by being interpreted as raw bytes to be sent over UDP.
29#[derive(Debug, Clone, PartialEq, Eq)]
30#[repr(C)]
31pub struct Whoent {
32    /// active tty info
33    pub we_utmp: Outmp,
34    /// tty idle time
35    pub we_idle: i32,
36}
37
38impl Whoent {
39    pub const SIZE: usize = std::mem::size_of::<Self>();
40
41    fn zeroed() -> Self {
42        Self {
43            we_utmp: Outmp {
44                out_line: [0u8; Outmp::MAX_TTY_NAME_LEN],
45                out_name: [0u8; Outmp::MAX_USER_ID_LEN],
46                out_time: 0,
47            },
48            we_idle: 0,
49        }
50    }
51
52    fn is_zeroed(&self) -> bool {
53        self.we_utmp.out_line.iter().all(|&b| b == 0)
54            && self.we_utmp.out_name.iter().all(|&b| b == 0)
55            && self.we_utmp.out_time == 0
56            && self.we_idle == 0
57    }
58}
59
60/// Classic C struct for a rwhod status update.
61///
62/// This struct is used in the rwhod protocol by being interpreted as raw bytes to be sent over UDP.
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[repr(C)]
65pub struct Whod {
66    /// protocol version
67    pub wd_vers: u8,
68    /// packet type, see below
69    pub wd_type: u8,
70    pub wd_pad: [u8; 2],
71    /// time stamp by sender
72    pub wd_sendtime: i32,
73    /// time stamp applied by receiver
74    pub wd_recvtime: i32,
75    /// host's name
76    pub wd_hostname: [u8; Self::MAX_HOSTNAME_LEN],
77    /// load average as in uptime
78    pub wd_loadav: [i32; 3],
79    /// time system booted
80    pub wd_boottime: i32,
81    pub wd_we: [Whoent; Self::MAX_WHOENTRIES],
82}
83
84impl Whod {
85    pub const HEADER_SIZE: usize = 1 + 1 + 2 + 4 + 4 + Self::MAX_HOSTNAME_LEN + 4 * 3 + 4;
86    pub const MAX_SIZE: usize = std::mem::size_of::<Self>();
87
88    pub const MAX_HOSTNAME_LEN: usize = 32;
89    pub const MAX_WHOENTRIES: usize = 1024 / std::mem::size_of::<Whoent>();
90
91    pub const WHODVERSION: u8 = 1;
92
93    // NOTE: there was probably meant to be more packet types, but only status is defined.
94    pub const WHODTYPE_STATUS: u8 = 1;
95
96    pub fn new(
97        sendtime: i32,
98        recvtime: i32,
99        hostname: [u8; Self::MAX_HOSTNAME_LEN],
100        loadav: [i32; 3],
101        boottime: i32,
102        whoentries: [Whoent; Self::MAX_WHOENTRIES],
103    ) -> Self {
104        debug_assert!(
105            whoentries
106                .iter()
107                .skip_while(|entry| !entry.is_zeroed())
108                .all(|entry| entry.is_zeroed())
109        );
110
111        Self {
112            wd_vers: Self::WHODVERSION,
113            wd_type: Self::WHODTYPE_STATUS,
114            wd_pad: [0u8; 2],
115            wd_sendtime: sendtime,
116            wd_recvtime: recvtime,
117            wd_hostname: hostname,
118            wd_loadav: loadav,
119            wd_boottime: boottime,
120            wd_we: whoentries,
121        }
122    }
123
124    pub fn to_bytes(&self) -> Vec<u8> {
125        let mut buf = BytesMut::with_capacity(Whod::MAX_SIZE);
126        buf.put_u8(self.wd_vers);
127        buf.put_u8(self.wd_type);
128        buf.put_slice(&self.wd_pad);
129        buf.put_i32(self.wd_sendtime);
130        buf.put_i32(self.wd_recvtime);
131        buf.put_slice(&self.wd_hostname);
132        buf.put_i32(self.wd_loadav[0]);
133        buf.put_i32(self.wd_loadav[1]);
134        buf.put_i32(self.wd_loadav[2]);
135        buf.put_i32(self.wd_boottime);
136
137        for whoent in self.wd_we.iter().take_while(|entry| !entry.is_zeroed()) {
138            buf.put_slice(&whoent.we_utmp.out_line);
139            buf.put_slice(&whoent.we_utmp.out_name);
140            buf.put_i32(whoent.we_utmp.out_time);
141            buf.put_i32(whoent.we_idle);
142        }
143
144        buf.to_vec()
145    }
146
147    pub fn from_bytes(input: &[u8]) -> anyhow::Result<Self> {
148        if input.len() < Self::HEADER_SIZE {
149            return Err(anyhow::anyhow!(
150                "Not enough bytes to parse packet header: {} < {}",
151                input.len(),
152                Self::HEADER_SIZE
153            ));
154        }
155
156        if input.len() > Self::MAX_SIZE {
157            return Err(anyhow::anyhow!(
158                "Too many bytes to parse packet: {} > {}",
159                input.len(),
160                Self::MAX_SIZE
161            ));
162        }
163
164        if !(input.len() - Self::HEADER_SIZE).is_multiple_of(Whoent::SIZE) {
165            return Err(anyhow::anyhow!(
166                "Invalid packet length: {} (not aligned with struct sizes, should be {} + N * {})",
167                input.len(),
168                Self::HEADER_SIZE,
169                Whoent::SIZE,
170            ));
171        }
172
173        let mut bytes = bytes::Bytes::copy_from_slice(input);
174
175        let wd_vers = bytes.get_u8();
176        if wd_vers != Self::WHODVERSION {
177            return Err(anyhow::anyhow!(
178                "Unsupported whod protocol version: {}",
179                wd_vers
180            ));
181        }
182
183        let wd_type = bytes.get_u8();
184        if wd_type != Self::WHODTYPE_STATUS {
185            return Err(anyhow::anyhow!("Unsupported whod packet type: {}", wd_type));
186        }
187
188        bytes.advance(2); // skip wd_pad
189
190        let wd_sendtime = bytes.get_i32();
191        let wd_recvtime = bytes.get_i32();
192        let mut wd_hostname = [0u8; Self::MAX_HOSTNAME_LEN];
193        bytes.copy_to_slice(&mut wd_hostname);
194        let wd_loadav = [bytes.get_i32(), bytes.get_i32(), bytes.get_i32()];
195        let wd_boottime = bytes.get_i32();
196
197        debug_assert!(bytes.remaining() + Self::HEADER_SIZE == input.len());
198
199        let mut wd_we = array::from_fn(|_| Whoent::zeroed());
200
201        for (byte_chunk, whoent) in bytes.chunks_exact(Whoent::SIZE).zip(wd_we.iter_mut()) {
202            let mut chunk_bytes = bytes::Bytes::copy_from_slice(byte_chunk);
203
204            let mut out_line = [0u8; Outmp::MAX_TTY_NAME_LEN];
205            chunk_bytes.copy_to_slice(&mut out_line);
206            let mut out_name = [0u8; Outmp::MAX_USER_ID_LEN];
207            chunk_bytes.copy_to_slice(&mut out_name);
208            let out_time = chunk_bytes.get_i32();
209
210            let we_utmp = Outmp {
211                out_line,
212                out_name,
213                out_time,
214            };
215            let we_idle = chunk_bytes.get_i32();
216
217            *whoent = Whoent { we_utmp, we_idle };
218        }
219
220        let result = Whod::new(
221            wd_sendtime,
222            wd_recvtime,
223            wd_hostname,
224            wd_loadav,
225            wd_boottime,
226            wd_we,
227        );
228
229        Ok(result)
230    }
231}
232
233// ------------------------------------------------
234
235/// Load average representation: (5 min, 10 min, 15 min)
236/// All values are multiplied by 100.
237pub type LoadAverage = (i32, i32, i32);
238
239/// High-level representation of a rwhod status update.
240///
241/// This struct is intended for easier use in Rust code, with proper types and dynamic arrays.
242/// It can be converted to and from the low-level [`Whod`] struct used for network transmission.
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct WhodStatusUpdate {
245    // NOTE: there is only one defined packet type, so we just omit it here
246    /// Timestamp by sender
247    pub sendtime: DateTime<Utc>,
248
249    /// Timestamp applied by receiver
250    pub recvtime: Option<DateTime<Utc>>,
251
252    /// Name of the host sending the status update (max 32 characters)
253    pub hostname: String,
254
255    /// load average over 5, 10, and 15 minutes multiplied by 100
256    pub load_average: LoadAverage,
257
258    /// Which time the system was booted
259    pub boot_time: DateTime<Utc>,
260
261    /// List of users currently logged in to the host (max 42 entries)
262    pub users: Vec<WhodUserEntry>,
263}
264
265impl WhodStatusUpdate {
266    pub fn new(
267        sendtime: DateTime<Utc>,
268        recvtime: Option<DateTime<Utc>>,
269        hostname: String,
270        load_average: LoadAverage,
271        boot_time: DateTime<Utc>,
272        users: Vec<WhodUserEntry>,
273    ) -> Self {
274        Self {
275            sendtime,
276            recvtime,
277            hostname,
278            load_average,
279            boot_time,
280            users,
281        }
282    }
283}
284
285/// High-level representation of a single user session in a rwhod status update.
286///
287/// This struct is intended for easier use in Rust code, with proper types.
288/// It can be converted to and from the low-level [`Whoent`] struct used for network transmission.
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct WhodUserEntry {
291    /// TTY name (max 8 characters)
292    pub tty: String,
293
294    /// User ID (max 8 characters)
295    pub user_id: String,
296
297    /// Time when the user logged in
298    pub login_time: DateTime<Utc>,
299
300    /// How long since the user last typed on the TTY
301    pub idle_time: Duration,
302}
303
304impl WhodUserEntry {
305    pub fn new(
306        tty: String,
307        user_id: String,
308        login_time: DateTime<Utc>,
309        idle_time: Duration,
310    ) -> Self {
311        Self {
312            tty,
313            user_id,
314            login_time,
315            idle_time,
316        }
317    }
318}
319
320impl TryFrom<Whoent> for WhodUserEntry {
321    type Error = String;
322
323    fn try_from(value: Whoent) -> Result<Self, Self::Error> {
324        let tty_end = value
325            .we_utmp
326            .out_line
327            .iter()
328            .position(|&c| c == 0)
329            .unwrap_or(value.we_utmp.out_line.len());
330        let tty = String::from_utf8(value.we_utmp.out_line[..tty_end].to_vec())
331            .map_err(|e| format!("Invalid UTF-8 in TTY name: {}", e))?;
332
333        let user_id_end = value
334            .we_utmp
335            .out_name
336            .iter()
337            .position(|&c| c == 0)
338            .unwrap_or(value.we_utmp.out_name.len());
339        let user_id = String::from_utf8(value.we_utmp.out_name[..user_id_end].to_vec())
340            .map_err(|e| format!("Invalid UTF-8 in user ID: {}", e))?;
341
342        let login_time = DateTime::from_timestamp_secs(value.we_utmp.out_time as i64).ok_or(
343            format!("Invalid login time timestamp: {}", value.we_utmp.out_time),
344        )?;
345
346        Ok(WhodUserEntry {
347            tty,
348            user_id,
349            login_time,
350            idle_time: Duration::seconds(value.we_idle as i64),
351        })
352    }
353}
354
355impl TryFrom<Whod> for WhodStatusUpdate {
356    type Error = String;
357
358    fn try_from(value: Whod) -> Result<Self, Self::Error> {
359        if value.wd_vers != Whod::WHODVERSION {
360            return Err(format!(
361                "Unsupported whod protocol version: {}",
362                value.wd_vers
363            ));
364        }
365
366        let sendtime = DateTime::from_timestamp_secs(value.wd_sendtime as i64).ok_or(format!(
367            "Invalid send time timestamp: {}",
368            value.wd_sendtime
369        ))?;
370
371        let recvtime = if value.wd_recvtime == 0 {
372            None
373        } else {
374            Some(
375                DateTime::from_timestamp_secs(value.wd_recvtime as i64).ok_or(format!(
376                    "Invalid receive time timestamp: {}",
377                    value.wd_recvtime
378                ))?,
379            )
380        };
381
382        let hostname_end = value
383            .wd_hostname
384            .iter()
385            .position(|&c| c == 0)
386            .unwrap_or(value.wd_hostname.len());
387        let hostname = String::from_utf8(value.wd_hostname[..hostname_end].to_vec())
388            .map_err(|e| format!("Invalid UTF-8 in hostname: {}", e))?;
389
390        let boot_time = DateTime::from_timestamp_secs(value.wd_boottime as i64).ok_or(format!(
391            "Invalid boot time timestamp: {}",
392            value.wd_boottime
393        ))?;
394
395        let users = value
396            .wd_we
397            .iter()
398            .take_while(|whoent| !whoent.is_zeroed())
399            .cloned()
400            .map(WhodUserEntry::try_from)
401            .collect::<Result<Vec<WhodUserEntry>, String>>()?;
402
403        Ok(WhodStatusUpdate {
404            sendtime,
405            recvtime,
406            hostname,
407            load_average: value.wd_loadav.into(),
408            boot_time,
409            users,
410        })
411    }
412}
413
414impl TryFrom<WhodUserEntry> for Whoent {
415    type Error = String;
416
417    fn try_from(value: WhodUserEntry) -> Result<Self, Self::Error> {
418        let mut out_line = [0u8; Outmp::MAX_TTY_NAME_LEN];
419        let tty_bytes = value.tty.as_bytes();
420        out_line[..tty_bytes.len().min(Outmp::MAX_TTY_NAME_LEN)].copy_from_slice(tty_bytes);
421
422        let mut out_name = [0u8; Outmp::MAX_USER_ID_LEN];
423        let user_id_bytes = value.user_id.as_bytes();
424        out_name[..user_id_bytes.len().min(Outmp::MAX_USER_ID_LEN)].copy_from_slice(user_id_bytes);
425
426        let out_time = value
427            .login_time
428            .timestamp()
429            .clamp(i32::MIN as i64, i32::MAX as i64) as i32;
430
431        let we_idle = value
432            .idle_time
433            .num_seconds()
434            .clamp(i32::MIN as i64, i32::MAX as i64) as i32;
435
436        Ok(Whoent {
437            we_utmp: Outmp {
438                out_line,
439                out_name,
440                out_time,
441            },
442            we_idle,
443        })
444    }
445}
446
447impl TryFrom<WhodStatusUpdate> for Whod {
448    type Error = String;
449
450    fn try_from(value: WhodStatusUpdate) -> Result<Self, Self::Error> {
451        let mut wd_hostname = [0u8; Whod::MAX_HOSTNAME_LEN];
452        let hostname_bytes = value.hostname.as_bytes();
453        wd_hostname[..hostname_bytes.len().min(Whod::MAX_HOSTNAME_LEN)]
454            .copy_from_slice(hostname_bytes);
455
456        let wd_sendtime = value
457            .sendtime
458            .timestamp()
459            .clamp(i32::MIN as i64, i32::MAX as i64) as i32;
460
461        let wd_recvtime = value.recvtime.map_or(0, |dt| {
462            dt.timestamp().clamp(i32::MIN as i64, i32::MAX as i64) as i32
463        });
464
465        let wd_boottime = value
466            .boot_time
467            .timestamp()
468            .clamp(i32::MIN as i64, i32::MAX as i64) as i32;
469
470        let wd_we = value
471            .users
472            .into_iter()
473            .map(Whoent::try_from)
474            .chain(std::iter::repeat(Ok(Whoent::zeroed())))
475            .take(Whod::MAX_WHOENTRIES)
476            .collect::<Result<Vec<Whoent>, String>>()?
477            .try_into()
478            .expect("Length mismatch, this should never happen");
479
480        Ok(Whod {
481            wd_vers: Whod::WHODVERSION,
482            wd_type: Whod::WHODTYPE_STATUS,
483            wd_pad: [0u8; 2],
484            wd_sendtime,
485            wd_recvtime,
486            wd_hostname,
487            wd_loadav: value.load_average.into(),
488            wd_boottime,
489            wd_we,
490        })
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use chrono::TimeZone;
498
499    #[test]
500    fn test_whod_serialization_roundtrip() {
501        let original_status = WhodStatusUpdate::new(
502            Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap(),
503            Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 5, 0).unwrap()),
504            "testhost".to_string(),
505            (25, 20, 18),
506            Utc.with_ymd_and_hms(2024, 5, 31, 8, 0, 0).unwrap(),
507            vec![
508                WhodUserEntry::new(
509                    "tty1".to_string(),
510                    "user1".to_string(),
511                    Utc.with_ymd_and_hms(2024, 6, 1, 10, 0, 0).unwrap(),
512                    Duration::minutes(5),
513                ),
514                WhodUserEntry::new(
515                    "tty2".to_string(),
516                    "user2".to_string(),
517                    Utc.with_ymd_and_hms(2024, 6, 1, 11, 0, 0).unwrap(),
518                    Duration::minutes(10),
519                ),
520            ],
521        );
522
523        let whod_struct =
524            Whod::try_from(original_status.clone()).expect("Conversion to Whod failed");
525        let bytes = whod_struct.to_bytes();
526        let parsed_whod = Whod::from_bytes(&bytes).expect("Parsing from bytes failed");
527        let final_status =
528            WhodStatusUpdate::try_from(parsed_whod).expect("Conversion from Whod failed");
529
530        assert_eq!(original_status, final_status);
531    }
532
533    #[test]
534    fn test_parser_invalid_bytes() {
535        // Too short
536        let short_bytes = vec![0u8; Whod::HEADER_SIZE - 1];
537        assert!(Whod::from_bytes(&short_bytes).is_err());
538
539        // Too long
540        let long_bytes = vec![0u8; Whod::MAX_SIZE + 1];
541        assert!(Whod::from_bytes(&long_bytes).is_err());
542
543        // Misaligned length
544        let misaligned_bytes = vec![0u8; Whod::HEADER_SIZE + 1];
545        assert!(Whod::from_bytes(&misaligned_bytes).is_err());
546
547        // Invalid version
548        let mut invalid_version_bytes = vec![0u8; Whod::HEADER_SIZE];
549        invalid_version_bytes[0] = 99; // invalid version
550        assert!(Whod::from_bytes(&invalid_version_bytes).is_err());
551
552        // Invalid packet type
553        let mut invalid_type_bytes = vec![0u8; Whod::HEADER_SIZE];
554        invalid_type_bytes[0] = Whod::WHODVERSION;
555        invalid_type_bytes[1] = 99; // invalid type
556        assert!(Whod::from_bytes(&invalid_type_bytes).is_err());
557    }
558}