Skip to main content

netget/server/inetd/
wire.rs

1//! Wire helpers for the classic inetd services: Echo (RFC 862), Discard (RFC 863),
2//! Chargen (RFC 864), QOTD (RFC 865), Daytime (RFC 867) and Time (RFC 868).
3use anyhow::{bail, ensure, Result};
4use serde_json::{json, Value};
5use std::time::Duration;
6
7/// One TCP read, and the largest UDP datagram accepted.
8pub const READ_CHUNK: usize = 8192;
9/// RFC 865: a quote should be limited to 512 characters.
10pub const MAX_QUOTE_CHARS: usize = 512;
11/// Daytime text a handler may supply.
12pub const MAX_DAYTIME_CHARS: usize = 256;
13/// RFC 864: a UDP chargen reply is between 0 and 512 characters.
14pub const MAX_CHARGEN_UDP: usize = 512;
15/// Upper bound on a chargen line.
16pub const MAX_CHARGEN_LINE: usize = 512;
17/// Seconds between 1900-01-01 and 1970-01-01 (RFC 868).
18pub const SECONDS_1900_TO_1970: u64 = 2_208_988_800;
19/// Write and per-reply deadline.
20pub const IO_TIMEOUT: Duration = Duration::from_secs(30);
21/// Bytes a client reads from a stream service (Daytime, QOTD, Time) before giving up.
22pub const MAX_STREAM_REPLY: usize = 64 * 1024;
23
24/// The RFC 864 character set: the 95 printable ASCII characters, space first.
25pub fn default_charset() -> String {
26    (0x20u8..=0x7e).map(char::from).collect()
27}
28
29/// Line `n` of the rotating chargen pattern: `width` characters starting `n` into the set.
30pub fn chargen_line(charset: &[u8], width: usize, n: usize) -> Vec<u8> {
31    let mut line = Vec::with_capacity(width + 2);
32    for i in 0..width {
33        line.push(charset[(n + i) % charset.len()]);
34    }
35    line.extend_from_slice(b"\r\n");
36    line
37}
38
39/// Whether text follows the rotating pattern of `charset` (any starting offset, any width).
40pub fn conforms_to_chargen(text: &str, charset: &str) -> bool {
41    let set = charset.as_bytes();
42    let lines: Vec<&str> = text.split("\r\n").filter(|l| !l.is_empty()).collect();
43    if lines.len() < 2 {
44        return !lines.is_empty() && lines[0].bytes().all(|b| set.contains(&b));
45    }
46    let start = |line: &str| {
47        line.bytes()
48            .next()
49            .and_then(|b| set.iter().position(|c| *c == b))
50    };
51    lines
52        .windows(2)
53        .all(|pair| match (start(pair[0]), start(pair[1])) {
54            (Some(a), Some(b)) => b == (a + 1) % set.len(),
55            _ => false,
56        })
57        && lines.iter().all(|line| {
58            let first = start(line).unwrap_or(0);
59            line.bytes()
60                .enumerate()
61                .all(|(i, b)| set[(first + i) % set.len()] == b)
62        })
63}
64
65/// Validate a handler's chargen character set: printable ASCII, 1 to 95 characters.
66pub fn charset(value: Option<&Value>) -> Result<String> {
67    match value {
68        None | Some(Value::Null) => Ok(default_charset()),
69        Some(Value::String(s)) => {
70            ensure!(
71                !s.is_empty() && s.len() <= 95,
72                "charset must hold 1 to 95 characters"
73            );
74            ensure!(
75                s.bytes().all(|b| (0x20..=0x7e).contains(&b)),
76                "charset must be printable ASCII"
77            );
78            Ok(s.clone())
79        }
80        _ => bail!("charset must be a string"),
81    }
82}
83
84/// RFC 867 suggests "Weekday, Month Day, Year HH:MM:SS-ZONE".
85pub fn daytime_now() -> String {
86    chrono::Utc::now()
87        .format("%A, %B %-d, %Y %H:%M:%S-UTC")
88        .to_string()
89}
90
91/// RFC 868 time: seconds since 1900 as a 32-bit big-endian number (wrapping in 2036).
92pub fn time_bytes(unix_seconds: i64) -> [u8; 4] {
93    let seconds = (unix_seconds as i128 + SECONDS_1900_TO_1970 as i128).rem_euclid(1 << 32) as u32;
94    seconds.to_be_bytes()
95}
96
97/// Unix seconds from a handler's `time_reply`: `unix_seconds`, `iso8601`, or now.
98pub fn requested_time(answer: &Value) -> Result<i64> {
99    if let Some(seconds) = answer.get("unix_seconds").filter(|v| !v.is_null()) {
100        return seconds
101            .as_i64()
102            .ok_or_else(|| anyhow::anyhow!("unix_seconds must be an integer"));
103    }
104    if let Some(iso) = answer.get("iso8601").filter(|v| !v.is_null()) {
105        let iso = iso
106            .as_str()
107            .ok_or_else(|| anyhow::anyhow!("iso8601 must be a string"))?;
108        return Ok(chrono::DateTime::parse_from_rfc3339(iso)
109            .map_err(|e| anyhow::anyhow!("iso8601 is not RFC 3339: {e}"))?
110            .timestamp());
111    }
112    Ok(chrono::Utc::now().timestamp())
113}
114
115/// Describe a received RFC 868 value for a handler.
116pub fn describe_time(bytes: [u8; 4]) -> Value {
117    let since_1900 = u32::from_be_bytes(bytes) as i64;
118    let unix = since_1900 - SECONDS_1900_TO_1970 as i64;
119    let iso = chrono::DateTime::from_timestamp(unix, 0).map(|t| t.to_rfc3339());
120    json!({"seconds_since_1900": since_1900, "unix_seconds": unix, "iso8601": iso})
121}
122
123/// Data for an event: text when it is printable UTF-8, hex otherwise, with the encoding named.
124pub fn encode(bytes: &[u8]) -> (String, &'static str) {
125    match std::str::from_utf8(bytes) {
126        Ok(text)
127            // Text when its only control characters are line breaks and tabs.
128            if !crate::utils::sanitize::has_controls(&text.replace(['\n', '\r', '\t'], "")) =>
129        {
130            (text.to_string(), "utf8")
131        }
132        _ => (hex::encode(bytes), "hex"),
133    }
134}
135
136/// Bytes from an action's `data` and `encoding` (`utf8`, the default, or `hex`).
137pub fn decode(data: &str, encoding: Option<&str>) -> Result<Vec<u8>> {
138    match encoding.unwrap_or("utf8") {
139        "utf8" => Ok(data.as_bytes().to_vec()),
140        "hex" => hex::decode(data).map_err(|e| anyhow::anyhow!("data is not valid hex: {e}")),
141        other => bail!("encoding must be utf8 or hex, not {other}"),
142    }
143}
144
145/// Handler text for a reply line: no control characters, at most `limit` characters.
146pub fn line(text: &str, limit: usize) -> String {
147    crate::utils::sanitize::strip_controls(text)
148        .chars()
149        .take(limit)
150        .collect()
151}