use serde::Serialize;
use std::time::Duration;
use super::util;
const NTP_SERVERS: &[&str] = &["pool.ntp.org", "time.cloudflare.com", "time.google.com"];
const RECV_TIMEOUT: Duration = Duration::from_secs(5);
const NTP_UNIX_OFFSET_SECS: u64 = 2_208_988_800;
#[derive(Debug, Clone, Serialize)]
pub struct ClockSync {
#[serde(skip_serializing_if = "Option::is_none")]
pub offset_ms: Option<f64>,
pub servers_responded: u8,
pub ntp_blocked: bool,
pub source: String,
pub assessment: String,
pub level: String,
}
pub async fn collect() -> Option<ClockSync> {
let offsets: Vec<f64> =
futures_util::future::join_all(NTP_SERVERS.iter().map(|server| sntp_offset_ms(server)))
.await
.into_iter()
.flatten()
.collect();
if !offsets.is_empty() {
let offset = median(&offsets);
let (assessment, level) = classify_offset(Some(offset), false);
return Some(ClockSync {
offset_ms: Some(offset),
servers_responded: offsets.len() as u8,
ntp_blocked: false,
source: "sntp".to_string(),
assessment,
level,
});
}
let http_offset = http_date_offset_ms().await;
let ntp_blocked = true;
let (assessment, level) = classify_offset(http_offset, ntp_blocked);
Some(ClockSync {
offset_ms: http_offset,
servers_responded: 0,
ntp_blocked,
source: "http-date".to_string(),
assessment,
level,
})
}
async fn sntp_offset_ms(server: &str) -> Option<f64> {
let addrs = util::lookup_host_timeout(format!("{}:123", server), util::RESOLVE).await?;
let addr = addrs.first()?;
let socket = tokio::net::UdpSocket::bind("0.0.0.0:0").await.ok()?;
socket.connect(addr).await.ok()?;
let mut packet = [0u8; 48];
packet[0] = 0b00_100_011;
let t1 = unix_now_secs_f64();
socket.send(&packet).await.ok()?;
let mut buf = [0u8; 48];
let n = tokio::time::timeout(RECV_TIMEOUT, socket.recv(&mut buf))
.await
.ok()?
.ok()?;
let t4 = unix_now_secs_f64();
if n < 48 {
return None;
}
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;
let local_offset_ms = -offset_secs * 1000.0;
local_offset_ms.is_finite().then_some(local_offset_ms)
}
fn unix_now_secs_f64() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
fn parse_ntp_timestamp(bytes: &[u8]) -> Option<f64> {
if bytes.len() < 8 {
return None;
}
let secs = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as u64;
let frac = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as f64;
if secs == 0 {
return None;
}
let unix_secs = secs.checked_sub(NTP_UNIX_OFFSET_SECS)? as f64;
Some(unix_secs + frac / (u32::MAX as f64 + 1.0))
}
async fn http_date_offset_ms() -> Option<f64> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.ok()?;
let resp = client
.get("https://1.1.1.1/cdn-cgi/trace")
.send()
.await
.ok()?;
let date = resp.headers().get("date")?.to_str().ok()?;
let server_time = httpdate_to_unix(date)?;
Some((unix_now_secs_f64() - server_time) * 1000.0)
}
fn httpdate_to_unix(s: &str) -> Option<f64> {
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.len() != 6 {
return None;
}
let day: i64 = parts[1].parse().ok()?;
let month = match parts[2] {
"Jan" => 1,
"Feb" => 2,
"Mar" => 3,
"Apr" => 4,
"May" => 5,
"Jun" => 6,
"Jul" => 7,
"Aug" => 8,
"Sep" => 9,
"Oct" => 10,
"Nov" => 11,
"Dec" => 12,
_ => return None,
};
let year: i64 = parts[3].parse().ok()?;
let hms: Vec<&str> = parts[4].split(':').collect();
if hms.len() != 3 {
return None;
}
let (h, m, sec): (i64, i64, i64) = (
hms[0].parse().ok()?,
hms[1].parse().ok()?,
hms[2].parse().ok()?,
);
let y = if month <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (month + 9) % 12;
let doy = (153 * mp + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some((days * 86_400 + h * 3_600 + m * 60 + sec) as f64)
}
fn median(values: &[f64]) -> f64 {
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
sorted[sorted.len() / 2]
}
fn classify_offset(offset_ms: Option<f64>, ntp_blocked: bool) -> (String, String) {
match offset_ms {
Some(ms) if ms.abs() > 30_000.0 => (
format!(
"Clock is off by {:.0}s — TLS certificate validation will fail; fix the system time",
ms / 1000.0
),
"fail".to_string(),
),
Some(ms) if ms.abs() > 1_000.0 => (
format!("Clock is off by {:.1}s — time sync is drifting", ms / 1000.0),
"warn".to_string(),
),
Some(_) if ntp_blocked => (
"Clock is accurate, but NTP (UDP/123) appears blocked — drift won't self-correct"
.to_string(),
"warn".to_string(),
),
Some(_) => ("Clock is synchronized".to_string(), "ok".to_string()),
None => (
"Clock offset could not be measured (NTP blocked, no HTTP fallback)".to_string(),
"warn".to_string(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ntp_timestamp_roundtrip() {
let unix: u64 = 1_767_225_600;
let ntp_secs = (unix + NTP_UNIX_OFFSET_SECS) as u32;
let mut bytes = [0u8; 8];
bytes[..4].copy_from_slice(&ntp_secs.to_be_bytes());
bytes[4..].copy_from_slice(&(u32::MAX / 2 + 1).to_be_bytes());
let parsed = parse_ntp_timestamp(&bytes).unwrap();
assert!((parsed - (unix as f64 + 0.5)).abs() < 0.001, "got {parsed}");
}
#[test]
fn zero_timestamp_rejected() {
assert!(parse_ntp_timestamp(&[0u8; 8]).is_none());
}
#[test]
fn httpdate_parses_rfc7231() {
let unix = httpdate_to_unix("Wed, 10 Jun 2026 12:00:00 GMT").unwrap();
assert_eq!(unix, 1_781_092_800.0);
assert!(httpdate_to_unix("garbage").is_none());
}
#[test]
fn classify_offset_thresholds() {
assert_eq!(classify_offset(Some(100.0), false).1, "ok");
assert_eq!(classify_offset(Some(5_000.0), false).1, "warn");
assert_eq!(classify_offset(Some(-5_000.0), false).1, "warn");
assert_eq!(classify_offset(Some(60_000.0), false).1, "fail");
assert_eq!(classify_offset(Some(-60_000.0), false).1, "fail");
assert_eq!(classify_offset(Some(100.0), true).1, "warn");
assert_eq!(classify_offset(None, true).1, "warn");
}
#[test]
fn median_of_offsets() {
assert_eq!(median(&[5.0, -3.0, 2.0]), 2.0);
assert_eq!(median(&[7.0]), 7.0);
}
}