Skip to main content

oxstack_net/
sntp.rs

1//! Simple Network Time Protocol (SNTPv4, RFC 4330) client messages.
2//!
3//! ```
4//! use oxstack_net::sntp;
5//! let request = sntp::request();
6//! // send `request` over UDP to port 123, receive `reply`, then:
7//! # let mut reply = [0u8; 48];
8//! # reply[0] = 0b00_100_100; reply[1] = 2; reply[40..44].copy_from_slice(&3_970_000_000u32.to_be_bytes());
9//! let t = sntp::parse_reply(&reply).unwrap();
10//! assert!(t.unix_seconds > 1_700_000_000);
11//! ```
12
13/// UDP port SNTP servers listen on.
14pub const PORT: u16 = 123;
15/// Size of an SNTP message without extensions.
16pub const PACKET_LEN: usize = 48;
17
18/// Seconds from the NTP epoch (1900-01-01) to the Unix epoch (1970-01-01).
19const NTP_TO_UNIX: u64 = 2_208_988_800;
20
21/// Leap indicator 0, version 4, mode 3 (client).
22const LI_VN_MODE_CLIENT: u8 = (4 << 3) | 3;
23const MODE_SERVER: u8 = 4;
24const MODE_BROADCAST: u8 = 5;
25
26/// A client request: just the header byte, everything else zero (RFC 4330
27/// §5 allows clients to leave the transmit timestamp zero).
28pub const fn request() -> [u8; PACKET_LEN] {
29    let mut p = [0u8; PACKET_LEN];
30    p[0] = LI_VN_MODE_CLIENT;
31    p
32}
33
34/// Time reported by a server.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36#[cfg_attr(feature = "defmt", derive(defmt::Format))]
37pub struct ServerTime {
38    /// Whole seconds since 1970-01-01 00:00:00 UTC.
39    pub unix_seconds: u64,
40    /// Sub-second part in microseconds.
41    pub micros: u32,
42    /// Server stratum (1 = directly attached to a reference clock).
43    pub stratum: u8,
44}
45
46/// Why a reply was rejected.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48#[cfg_attr(feature = "defmt", derive(defmt::Format))]
49pub enum Error {
50    /// Fewer than [`PACKET_LEN`] bytes.
51    TooShort,
52    /// Not a server (or broadcast) message.
53    NotAServerReply,
54    /// Stratum 0: a "Kiss-o'-Death" telling the client to back off. The
55    /// four-character code (e.g. `RATE`, `DENY`) is included.
56    KissOfDeath([u8; 4]),
57    /// Leap indicator 3: the server's clock is not synchronised.
58    Unsynchronised,
59    /// Transmit timestamp is zero.
60    NoTimestamp,
61}
62
63/// Validates a reply and extracts its transmit timestamp.
64///
65/// Network delay is not compensated: the result is the time the server
66/// sent the reply, typically tens of milliseconds behind true time.
67pub fn parse_reply(p: &[u8]) -> Result<ServerTime, Error> {
68    let p: &[u8; PACKET_LEN] = p
69        .get(..PACKET_LEN)
70        .and_then(|s| s.try_into().ok())
71        .ok_or(Error::TooShort)?;
72    let mode = p[0] & 0x07;
73    if mode != MODE_SERVER && mode != MODE_BROADCAST {
74        return Err(Error::NotAServerReply);
75    }
76    let stratum = p[1];
77    if stratum == 0 {
78        return Err(Error::KissOfDeath([p[12], p[13], p[14], p[15]]));
79    }
80    if p[0] >> 6 == 3 {
81        return Err(Error::Unsynchronised);
82    }
83    let secs = u32::from_be_bytes([p[40], p[41], p[42], p[43]]);
84    let frac = u32::from_be_bytes([p[44], p[45], p[46], p[47]]);
85    if secs == 0 && frac == 0 {
86        return Err(Error::NoTimestamp);
87    }
88    // NTP era 0 ends in 2036; timestamps below 1970 in era 0 belong to era 1.
89    let ntp_secs = if u64::from(secs) < NTP_TO_UNIX {
90        u64::from(secs) + (1 << 32)
91    } else {
92        u64::from(secs)
93    };
94    Ok(ServerTime {
95        unix_seconds: ntp_secs - NTP_TO_UNIX,
96        micros: ((u64::from(frac) * 1_000_000) >> 32) as u32,
97        stratum,
98    })
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    fn reply(secs: u32, frac: u32) -> [u8; PACKET_LEN] {
106        let mut p = [0u8; PACKET_LEN];
107        p[0] = (4 << 3) | MODE_SERVER;
108        p[1] = 2;
109        p[40..44].copy_from_slice(&secs.to_be_bytes());
110        p[44..48].copy_from_slice(&frac.to_be_bytes());
111        p
112    }
113
114    #[test]
115    fn request_header() {
116        let r = request();
117        assert_eq!(r[0], 0x23);
118        assert!(r[1..].iter().all(|b| *b == 0));
119    }
120
121    #[test]
122    fn converts_ntp_to_unix_in_both_eras() {
123        // 2026-01-01T00:00:00Z = Unix 1_767_225_600 = NTP 3_976_214_400.
124        let t = parse_reply(&reply(3_976_214_400, 1 << 31)).unwrap();
125        assert_eq!(t.unix_seconds, 1_767_225_600);
126        assert_eq!(t.micros, 500_000);
127        assert_eq!(t.stratum, 2);
128
129        // Era 1 (after the 2036 rollover): seconds = 10 → 2036-02-07T06:28:26Z.
130        let t = parse_reply(&reply(10, 0)).unwrap();
131        assert_eq!(t.unix_seconds, 2_085_978_506);
132    }
133
134    #[test]
135    fn rejects_bad_replies() {
136        assert_eq!(parse_reply(&[0; 10]), Err(Error::TooShort));
137
138        let mut kod = reply(3_976_214_400, 0);
139        kod[1] = 0;
140        kod[12..16].copy_from_slice(b"RATE");
141        assert_eq!(parse_reply(&kod), Err(Error::KissOfDeath(*b"RATE")));
142
143        let mut client = reply(3_976_214_400, 0);
144        client[0] = LI_VN_MODE_CLIENT;
145        assert_eq!(parse_reply(&client), Err(Error::NotAServerReply));
146
147        let mut unsync = reply(3_976_214_400, 0);
148        unsync[0] |= 0xC0;
149        assert_eq!(parse_reply(&unsync), Err(Error::Unsynchronised));
150
151        assert_eq!(parse_reply(&reply(0, 0)), Err(Error::NoTimestamp));
152    }
153}