use core::net::Ipv4Addr;
use core::time::Duration as StdDuration;
use embassy_net::dns::DnsQueryType;
use embassy_net::udp::{PacketMetadata, UdpSocket};
use embassy_net::{IpAddress, Stack};
use embassy_time::{Duration, Instant, Timer, with_timeout};
use crate::{clock, logln};
#[derive(Debug, Clone, Copy)]
pub struct NtpConfig<'a> {
pub server: &'a str,
pub retry_interval: StdDuration,
pub resync_interval: StdDuration,
}
impl NtpConfig<'static> {
pub const DEFAULT: Self = NtpConfig {
server: "pool.ntp.org",
retry_interval: StdDuration::from_secs(30),
resync_interval: StdDuration::from_secs(6 * 60 * 60),
};
}
impl Default for NtpConfig<'_> {
fn default() -> Self {
NtpConfig::DEFAULT
}
}
const NTP_PORT: u16 = 123;
const PACKET_LEN: usize = 48;
const REPLY_MAX: usize = 256;
const QUEUE_DEPTH: usize = 2;
const NTP_UNIX_OFFSET: u64 = 2_208_988_800;
const DNS_TIMEOUT: Duration = Duration::from_secs(10);
const REPLY_TIMEOUT: Duration = Duration::from_secs(10);
const FIRST_RETRY: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Error {
Dns,
DnsTimeout,
NoAddress,
Send,
Timeout {
ignored: usize,
},
}
impl core::fmt::Display for Error {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Error::Dns => f.write_str("name did not resolve"),
Error::DnsTimeout => f.write_str("name did not resolve in time"),
Error::NoAddress => f.write_str("resolved to no address"),
Error::Send => f.write_str("request could not be sent"),
Error::Timeout { ignored: 0 } => f.write_str("no reply"),
Error::Timeout { ignored } => write!(f, "{ignored} replies, none usable"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Refusal {
Short(usize),
NotAServerReply(u8),
Unsynchronized,
KissOfDeath([u8; 4]),
Stratum(u8),
NotOurRequest,
NoTimestamp,
Before1970,
}
impl core::fmt::Display for Refusal {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Refusal::Short(len) => write!(f, "{len} bytes, shorter than a packet"),
Refusal::NotAServerReply(mode) => write!(f, "mode {mode}, not a server reply"),
Refusal::Unsynchronized => f.write_str("server says its clock is unsynchronized"),
Refusal::KissOfDeath(code) => {
f.write_str("kiss of death ")?;
for &byte in code {
if byte.is_ascii_graphic() {
write!(f, "{}", byte as char)?;
} else {
write!(f, "\\x{byte:02x}")?;
}
}
Ok(())
}
Refusal::Stratum(stratum) => write!(f, "stratum {stratum}, unsynchronized"),
Refusal::NotOurRequest => f.write_str("not an answer to this request"),
Refusal::NoTimestamp => f.write_str("no transmit timestamp"),
Refusal::Before1970 => f.write_str("transmit timestamp before 1970"),
}
}
}
pub async fn run(stack: Stack<'_>, config: NtpConfig<'_>) -> ! {
let retry_max = to_embassy(config.retry_interval);
let resync = to_embassy(config.resync_interval);
let mut retry = FIRST_RETRY.min(retry_max);
loop {
stack.wait_config_up().await;
let result = match resolve(stack, config.server).await {
Ok(address) => sync(stack, address).await.map_err(|e| (e, Some(address))),
Err(e) => Err((e, None)),
};
let wait = match result {
Ok(()) => {
retry = FIRST_RETRY.min(retry_max);
resync
}
Err((e, address)) => {
let wait = retry;
retry = next_retry(retry, retry_max);
logln!(
"sntp: {} -- {e}; retrying in {} s",
Server(config.server, address),
wait.as_secs()
);
wait
}
};
Timer::after(wait).await;
}
}
fn next_retry(retry: Duration, max: Duration) -> Duration {
retry.checked_mul(2).unwrap_or(Duration::MAX).min(max)
}
fn to_embassy(duration: StdDuration) -> Duration {
Duration::try_from(duration).unwrap_or(Duration::MAX)
}
struct Server<'a>(&'a str, Option<Ipv4Addr>);
impl core::fmt::Display for Server<'_> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self.1 {
Some(address) if self.0.parse::<Ipv4Addr>().is_err() => {
write!(f, "{} ({address})", self.0)
}
_ => f.write_str(self.0),
}
}
}
async fn sync(stack: Stack<'_>, address: Ipv4Addr) -> Result<(), Error> {
let mut rx_meta = [PacketMetadata::EMPTY; QUEUE_DEPTH];
let mut rx_buffer = [0u8; REPLY_MAX * QUEUE_DEPTH];
let mut tx_meta = [PacketMetadata::EMPTY; QUEUE_DEPTH];
let mut tx_buffer = [0u8; PACKET_LEN * QUEUE_DEPTH];
let mut socket = UdpSocket::new(
stack,
&mut rx_meta,
&mut rx_buffer,
&mut tx_meta,
&mut tx_buffer,
);
socket.bind(0).map_err(|_| Error::Send)?;
let sent_at = Instant::now();
let nonce = sent_at.as_micros().to_be_bytes();
socket
.send_to(&request(nonce), (IpAddress::Ipv4(address), NTP_PORT))
.await
.map_err(|_| Error::Send)?;
let deadline = sent_at + REPLY_TIMEOUT;
let mut reply = [0u8; REPLY_MAX];
let mut ignored = 0;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.as_ticks() == 0 {
return Err(Error::Timeout { ignored });
}
let Ok(Ok((len, meta))) = with_timeout(remaining, socket.recv_from(&mut reply)).await
else {
return Err(Error::Timeout { ignored });
};
if meta.endpoint.addr != IpAddress::Ipv4(address) {
ignored += 1;
logln!(
"sntp: ignored a datagram from {}, not {address}",
meta.endpoint
);
continue;
}
let server_millis = match parse(&reply[..len], nonce) {
Ok(millis) => millis,
Err(refusal) => {
ignored += 1;
logln!("sntp: ignored a reply from {address}: {refusal}");
continue;
}
};
let round_trip = Instant::now()
.saturating_duration_since(sent_at)
.as_millis();
let unix_millis = corrected(server_millis, round_trip);
let previous = clock::now_unix_millis();
clock::set(unix_millis);
let time = clock::DateTime::from_unix(unix_millis / 1_000);
match previous {
Some(previous) => logln!(
"sntp: {time} from {address} (round trip {round_trip} ms, {} ms correction)",
unix_millis as i64 - previous as i64
),
None => logln!("sntp: {time} from {address} (round trip {round_trip} ms)"),
}
return Ok(());
}
}
async fn resolve(stack: Stack<'_>, server: &str) -> Result<Ipv4Addr, Error> {
if let Ok(address) = server.parse::<Ipv4Addr>() {
return Ok(address);
}
let addresses = with_timeout(DNS_TIMEOUT, stack.dns_query(server, DnsQueryType::A))
.await
.map_err(|_| Error::DnsTimeout)?
.map_err(|_| Error::Dns)?;
match addresses.first() {
Some(IpAddress::Ipv4(address)) => Ok(*address),
None => Err(Error::NoAddress),
}
}
fn request(nonce: [u8; 8]) -> [u8; PACKET_LEN] {
let mut packet = [0u8; PACKET_LEN];
packet[0] = 0b00_100_011;
packet[40..48].copy_from_slice(&nonce);
packet
}
fn corrected(server_millis: u64, round_trip_millis: u64) -> u64 {
server_millis + round_trip_millis / 2
}
fn parse(reply: &[u8], nonce: [u8; 8]) -> Result<u64, Refusal> {
if reply.len() < PACKET_LEN {
return Err(Refusal::Short(reply.len()));
}
let word =
|at: usize| u32::from_be_bytes([reply[at], reply[at + 1], reply[at + 2], reply[at + 3]]);
let leap = reply[0] >> 6;
let mode = reply[0] & 0b111;
let stratum = reply[1];
if mode != 4 {
return Err(Refusal::NotAServerReply(mode));
}
if stratum == 0 {
return Err(Refusal::KissOfDeath(word(12).to_be_bytes()));
}
if leap == 3 {
return Err(Refusal::Unsynchronized);
}
if stratum > 15 {
return Err(Refusal::Stratum(stratum));
}
if reply[24..32] != nonce {
return Err(Refusal::NotOurRequest);
}
let (seconds, fraction) = (word(40), word(44));
if seconds == 0 && fraction == 0 {
return Err(Refusal::NoTimestamp);
}
to_unix_millis(seconds, fraction).ok_or(Refusal::Before1970)
}
fn to_unix_millis(seconds: u32, fraction: u32) -> Option<u64> {
let unix_seconds = if seconds & 0x8000_0000 != 0 {
u64::from(seconds).checked_sub(NTP_UNIX_OFFSET)?
} else {
u64::from(seconds) + (1 << 32) - NTP_UNIX_OFFSET
};
let millis = (u64::from(fraction) * 1_000) >> 32;
Some(unix_seconds * 1_000 + millis)
}
#[cfg(test)]
mod tests {
use super::*;
const NONCE: [u8; 8] = [1, 2, 3, 4, 5, 6, 7, 8];
fn reply(seconds: u32, fraction: u32) -> [u8; PACKET_LEN] {
let mut packet = [0u8; PACKET_LEN];
packet[0] = 0b00_100_100;
packet[1] = 2;
packet[24..32].copy_from_slice(&NONCE);
packet[40..44].copy_from_slice(&seconds.to_be_bytes());
packet[44..48].copy_from_slice(&fraction.to_be_bytes());
packet
}
const SEPT_2026: u32 = (1_790_000_000 + NTP_UNIX_OFFSET) as u32;
#[test]
fn a_request_is_a_v4_client_packet_carrying_the_nonce() {
let packet = request(NONCE);
assert_eq!(packet[0] >> 6, 0, "leap");
assert_eq!((packet[0] >> 3) & 0b111, 4, "version");
assert_eq!(packet[0] & 0b111, 3, "mode");
assert_eq!(packet[40..48], NONCE);
assert!(packet[1..40].iter().all(|&b| b == 0));
}
#[test]
fn a_good_reply_gives_the_transmit_time() {
assert_eq!(parse(&reply(SEPT_2026, 0), NONCE), Ok(1_790_000_000_000));
assert_eq!(
parse(&reply(SEPT_2026, 0x8000_0000), NONCE),
Ok(1_790_000_000_500)
);
}
#[test]
fn extension_fields_after_the_packet_are_ignored() {
let mut long = [0u8; PACKET_LEN + 20];
long[..PACKET_LEN].copy_from_slice(&reply(SEPT_2026, 0));
assert_eq!(parse(&long, NONCE), Ok(1_790_000_000_000));
}
#[test]
fn replies_that_are_not_answers_are_refused_and_say_why() {
let good = reply(SEPT_2026, 0);
assert_eq!(
parse(&good[..PACKET_LEN - 1], NONCE),
Err(Refusal::Short(PACKET_LEN - 1))
);
assert_eq!(parse(&good, [0; 8]), Err(Refusal::NotOurRequest));
let mut client = good;
client[0] = 0b00_100_011;
assert_eq!(parse(&client, NONCE), Err(Refusal::NotAServerReply(3)));
let mut unsynchronized = good;
unsynchronized[0] |= 0b11 << 6;
assert_eq!(parse(&unsynchronized, NONCE), Err(Refusal::Unsynchronized));
let mut stratum_16 = good;
stratum_16[1] = 16;
assert_eq!(parse(&stratum_16, NONCE), Err(Refusal::Stratum(16)));
assert_eq!(parse(&reply(0, 0), NONCE), Err(Refusal::NoTimestamp));
}
#[test]
fn a_kiss_of_death_reports_its_code() {
use alloc::format;
let mut kiss = reply(SEPT_2026, 0);
kiss[0] |= 0b11 << 6;
kiss[1] = 0;
kiss[12..16].copy_from_slice(b"RATE");
let refusal = parse(&kiss, NONCE).unwrap_err();
assert_eq!(refusal, Refusal::KissOfDeath(*b"RATE"));
assert_eq!(format!("{refusal}"), "kiss of death RATE");
assert_eq!(
format!("{}", Refusal::KissOfDeath([b'X', 0, b'\n', b'Y'])),
"kiss of death X\\x00\\x0aY"
);
}
#[test]
fn a_timeout_says_whether_anything_arrived() {
use alloc::format;
assert_eq!(format!("{}", Error::Timeout { ignored: 0 }), "no reply");
assert_eq!(
format!("{}", Error::Timeout { ignored: 2 }),
"2 replies, none usable"
);
}
#[test]
fn the_2036_era_rollover() {
assert_eq!(to_unix_millis(u32::MAX, 0), Some(2_085_978_495_000));
assert_eq!(to_unix_millis(0, 0), Some(2_085_978_496_000));
assert_eq!(to_unix_millis(NTP_UNIX_OFFSET as u32, 0), Some(0));
}
#[test]
fn before_1970_is_refused_not_underflowed() {
assert_eq!(to_unix_millis(0x8000_0000, 0), None);
assert_eq!(to_unix_millis(NTP_UNIX_OFFSET as u32 - 1, 0), None);
assert_eq!(
parse(&reply(0x8000_0000, 0), NONCE),
Err(Refusal::Before1970)
);
}
#[test]
fn a_failure_names_the_address_asked() {
use alloc::format;
let resolved = Some(Ipv4Addr::new(203, 0, 113, 7));
assert_eq!(
format!("{}", Server("pool.ntp.org", resolved)),
"pool.ntp.org (203.0.113.7)"
);
assert_eq!(format!("{}", Server("pool.ntp.org", None)), "pool.ntp.org");
assert_eq!(
format!("{}", Server("203.0.113.7", resolved)),
"203.0.113.7"
);
}
#[test]
fn half_the_round_trip_is_added() {
assert_eq!(corrected(1_000, 40), 1_020);
assert_eq!(corrected(1_000, 0), 1_000);
}
#[test]
fn the_retry_doubles_up_to_the_cap() {
let max = Duration::from_secs(30);
let mut retry = FIRST_RETRY;
let mut waits = [0; 7];
for wait in &mut waits {
*wait = retry.as_secs();
retry = next_retry(retry, max);
}
assert_eq!(waits, [1, 2, 4, 8, 16, 30, 30]);
assert_eq!(next_retry(Duration::MAX, Duration::MAX), Duration::MAX);
}
#[test]
fn defaults() {
let config = NtpConfig::default();
assert_eq!(config.server, "pool.ntp.org");
assert_eq!(config.retry_interval, StdDuration::from_secs(30));
assert_eq!(config.resync_interval, StdDuration::from_secs(21_600));
assert_eq!(to_embassy(StdDuration::MAX), Duration::MAX);
}
}