Skip to main content

nd_300/diagnostics/
ntp.rs

1//! Clock synchronization check — deep diagnostic.
2//!
3//! A skewed system clock breaks TLS certificate validation in ways that look
4//! exactly like a broken network. This module measures the offset with a
5//! dependency-free SNTP exchange (48-byte mode-3 packet over UDP/123) against
6//! three public servers, taking the median of the responders — identical on
7//! every platform, no `w32tm`/`sntp`/`chronyd` needed. When UDP/123 is
8//! blocked (all servers silent — itself a finding), an HTTP `Date` header
9//! fallback distinguishes "clock fine, NTP blocked" from "clock skewed".
10
11use serde::Serialize;
12use std::time::Duration;
13
14use super::util;
15
16const NTP_SERVERS: &[&str] = &["pool.ntp.org", "time.cloudflare.com", "time.google.com"];
17
18/// Per-server receive budget.
19const RECV_TIMEOUT: Duration = Duration::from_secs(5);
20
21/// Seconds between the NTP era (1900-01-01) and the Unix epoch (1970-01-01).
22const NTP_UNIX_OFFSET_SECS: u64 = 2_208_988_800;
23
24#[derive(Debug, Clone, Serialize)]
25pub struct ClockSync {
26    /// System clock offset in milliseconds (positive = local clock ahead).
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub offset_ms: Option<f64>,
29    pub servers_responded: u8,
30    /// All SNTP exchanges timed out — UDP/123 is likely filtered.
31    pub ntp_blocked: bool,
32    /// "sntp" or "http-date".
33    pub source: String,
34    pub assessment: String,
35    pub level: String,
36}
37
38pub async fn collect() -> Option<ClockSync> {
39    // Query all servers concurrently; each exchange is individually bounded.
40    let offsets: Vec<f64> =
41        futures_util::future::join_all(NTP_SERVERS.iter().map(|server| sntp_offset_ms(server)))
42            .await
43            .into_iter()
44            .flatten()
45            .collect();
46
47    if !offsets.is_empty() {
48        let offset = median(&offsets);
49        let (assessment, level) = classify_offset(Some(offset), false);
50        return Some(ClockSync {
51            offset_ms: Some(offset),
52            servers_responded: offsets.len() as u8,
53            ntp_blocked: false,
54            source: "sntp".to_string(),
55            assessment,
56            level,
57        });
58    }
59
60    // UDP/123 blocked or all pools unreachable: coarse fallback via an HTTPS
61    // Date header (±1s granularity — enough to tell "fine" from "broken").
62    let http_offset = http_date_offset_ms().await;
63    let ntp_blocked = true;
64    let (assessment, level) = classify_offset(http_offset, ntp_blocked);
65    Some(ClockSync {
66        offset_ms: http_offset,
67        servers_responded: 0,
68        ntp_blocked,
69        source: "http-date".to_string(),
70        assessment,
71        level,
72    })
73}
74
75/// One SNTP exchange: offset = ((T2−T1)+(T3−T4))/2 in milliseconds.
76async fn sntp_offset_ms(server: &str) -> Option<f64> {
77    let addrs = util::lookup_host_timeout(format!("{}:123", server), util::RESOLVE).await?;
78    let addr = addrs.first()?;
79
80    let socket = tokio::net::UdpSocket::bind("0.0.0.0:0").await.ok()?;
81    socket.connect(addr).await.ok()?;
82
83    // Mode 3 (client), version 4.
84    let mut packet = [0u8; 48];
85    packet[0] = 0b00_100_011;
86
87    let t1 = unix_now_secs_f64();
88    socket.send(&packet).await.ok()?;
89
90    let mut buf = [0u8; 48];
91    let n = tokio::time::timeout(RECV_TIMEOUT, socket.recv(&mut buf))
92        .await
93        .ok()?
94        .ok()?;
95    let t4 = unix_now_secs_f64();
96    if n < 48 {
97        return None;
98    }
99
100    let t2 = parse_ntp_timestamp(&buf[32..40])?; // receive timestamp
101    let t3 = parse_ntp_timestamp(&buf[40..48])?; // transmit timestamp
102
103    // offset = ((T2 - T1) + (T3 - T4)) / 2; positive = server ahead of us,
104    // so the LOCAL clock offset is the negation.
105    let offset_secs = ((t2 - t1) + (t3 - t4)) / 2.0;
106    let local_offset_ms = -offset_secs * 1000.0;
107    local_offset_ms.is_finite().then_some(local_offset_ms)
108}
109
110fn unix_now_secs_f64() -> f64 {
111    std::time::SystemTime::now()
112        .duration_since(std::time::UNIX_EPOCH)
113        .map(|d| d.as_secs_f64())
114        .unwrap_or(0.0)
115}
116
117/// 64-bit NTP timestamp (32.32 fixed point, era 1900) → Unix seconds.
118fn parse_ntp_timestamp(bytes: &[u8]) -> Option<f64> {
119    if bytes.len() < 8 {
120        return None;
121    }
122    let secs = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as u64;
123    let frac = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as f64;
124    if secs == 0 {
125        return None;
126    }
127    let unix_secs = secs.checked_sub(NTP_UNIX_OFFSET_SECS)? as f64;
128    Some(unix_secs + frac / (u32::MAX as f64 + 1.0))
129}
130
131/// Coarse offset from an HTTPS response's Date header (±1s).
132async fn http_date_offset_ms() -> Option<f64> {
133    let client = reqwest::Client::builder()
134        .timeout(Duration::from_secs(5))
135        .build()
136        .ok()?;
137    let resp = client
138        .get("https://1.1.1.1/cdn-cgi/trace")
139        .send()
140        .await
141        .ok()?;
142    let date = resp.headers().get("date")?.to_str().ok()?;
143    let server_time = httpdate_to_unix(date)?;
144    Some((unix_now_secs_f64() - server_time) * 1000.0)
145}
146
147/// Parse an RFC 7231 HTTP date ("Tue, 10 Jun 2026 12:34:56 GMT") to Unix
148/// seconds. Hand-rolled to avoid a date-crate dependency.
149fn httpdate_to_unix(s: &str) -> Option<f64> {
150    let parts: Vec<&str> = s.split_whitespace().collect();
151    // ["Tue,", "10", "Jun", "2026", "12:34:56", "GMT"]
152    if parts.len() != 6 {
153        return None;
154    }
155    let day: i64 = parts[1].parse().ok()?;
156    let month = match parts[2] {
157        "Jan" => 1,
158        "Feb" => 2,
159        "Mar" => 3,
160        "Apr" => 4,
161        "May" => 5,
162        "Jun" => 6,
163        "Jul" => 7,
164        "Aug" => 8,
165        "Sep" => 9,
166        "Oct" => 10,
167        "Nov" => 11,
168        "Dec" => 12,
169        _ => return None,
170    };
171    let year: i64 = parts[3].parse().ok()?;
172    let hms: Vec<&str> = parts[4].split(':').collect();
173    if hms.len() != 3 {
174        return None;
175    }
176    let (h, m, sec): (i64, i64, i64) = (
177        hms[0].parse().ok()?,
178        hms[1].parse().ok()?,
179        hms[2].parse().ok()?,
180    );
181
182    // Days since Unix epoch via the standard civil-date algorithm.
183    let y = if month <= 2 { year - 1 } else { year };
184    let era = if y >= 0 { y } else { y - 399 } / 400;
185    let yoe = y - era * 400;
186    let mp = (month + 9) % 12;
187    let doy = (153 * mp + 2) / 5 + day - 1;
188    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
189    let days = era * 146_097 + doe - 719_468;
190
191    Some((days * 86_400 + h * 3_600 + m * 60 + sec) as f64)
192}
193
194fn median(values: &[f64]) -> f64 {
195    let mut sorted = values.to_vec();
196    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
197    sorted[sorted.len() / 2]
198}
199
200/// Classification — pure, unit-testable.
201fn classify_offset(offset_ms: Option<f64>, ntp_blocked: bool) -> (String, String) {
202    match offset_ms {
203        Some(ms) if ms.abs() > 30_000.0 => (
204            format!(
205                "Clock is off by {:.0}s — TLS certificate validation will fail; fix the system time",
206                ms / 1000.0
207            ),
208            "fail".to_string(),
209        ),
210        Some(ms) if ms.abs() > 1_000.0 => (
211            format!("Clock is off by {:.1}s — time sync is drifting", ms / 1000.0),
212            "warn".to_string(),
213        ),
214        Some(_) if ntp_blocked => (
215            "Clock is accurate, but NTP (UDP/123) appears blocked — drift won't self-correct"
216                .to_string(),
217            "warn".to_string(),
218        ),
219        Some(_) => ("Clock is synchronized".to_string(), "ok".to_string()),
220        None => (
221            "Clock offset could not be measured (NTP blocked, no HTTP fallback)".to_string(),
222            "warn".to_string(),
223        ),
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn ntp_timestamp_roundtrip() {
233        // 2026-01-01 00:00:00 UTC = Unix 1767225600.
234        let unix: u64 = 1_767_225_600;
235        let ntp_secs = (unix + NTP_UNIX_OFFSET_SECS) as u32;
236        let mut bytes = [0u8; 8];
237        bytes[..4].copy_from_slice(&ntp_secs.to_be_bytes());
238        // Half-second fraction.
239        bytes[4..].copy_from_slice(&(u32::MAX / 2 + 1).to_be_bytes());
240        let parsed = parse_ntp_timestamp(&bytes).unwrap();
241        assert!((parsed - (unix as f64 + 0.5)).abs() < 0.001, "got {parsed}");
242    }
243
244    #[test]
245    fn zero_timestamp_rejected() {
246        assert!(parse_ntp_timestamp(&[0u8; 8]).is_none());
247    }
248
249    #[test]
250    fn httpdate_parses_rfc7231() {
251        // Known value: 2026-06-10 12:00:00 UTC = 1781092800
252        // (2026-01-01 = 1767225600, +160 days, +12h).
253        let unix = httpdate_to_unix("Wed, 10 Jun 2026 12:00:00 GMT").unwrap();
254        assert_eq!(unix, 1_781_092_800.0);
255        assert!(httpdate_to_unix("garbage").is_none());
256    }
257
258    #[test]
259    fn classify_offset_thresholds() {
260        assert_eq!(classify_offset(Some(100.0), false).1, "ok");
261        assert_eq!(classify_offset(Some(5_000.0), false).1, "warn");
262        assert_eq!(classify_offset(Some(-5_000.0), false).1, "warn");
263        assert_eq!(classify_offset(Some(60_000.0), false).1, "fail");
264        assert_eq!(classify_offset(Some(-60_000.0), false).1, "fail");
265        assert_eq!(classify_offset(Some(100.0), true).1, "warn");
266        assert_eq!(classify_offset(None, true).1, "warn");
267    }
268
269    #[test]
270    fn median_of_offsets() {
271        assert_eq!(median(&[5.0, -3.0, 2.0]), 2.0);
272        assert_eq!(median(&[7.0]), 7.0);
273    }
274}