1use 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
18const RECV_TIMEOUT: Duration = Duration::from_secs(5);
20
21const NTP_UNIX_OFFSET_SECS: u64 = 2_208_988_800;
23
24#[derive(Debug, Clone, Serialize)]
25pub struct ClockSync {
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub offset_ms: Option<f64>,
29 pub servers_responded: u8,
30 pub ntp_blocked: bool,
32 pub source: String,
34 pub assessment: String,
35 pub level: String,
36}
37
38pub async fn collect() -> Option<ClockSync> {
39 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 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
75async 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 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])?; let t3 = parse_ntp_timestamp(&buf[40..48])?; 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
117fn 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
131async 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
147fn httpdate_to_unix(s: &str) -> Option<f64> {
150 let parts: Vec<&str> = s.split_whitespace().collect();
151 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 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
200fn 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 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 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 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}