Skip to content

vpetrigo/sntpc

Repository files navigation

GitHub Actions Workflow Status docs.rs codecov

Simple Rust SNTP client

This crate provides a method for sending requests to NTP servers and process responses, extracting received timestamp.

Supported SNTP protocol versions:

Note: Broadcast mode (NTP mode 5) is not supported. This library only supports unicast client mode (mode 3 → mode 4).

Features

  • std - Standard library support (includes StdTimestampGen)
  • sync - Synchronous API in sync module
  • dispersion - Enables root dispersion estimation per RFC 5905 §9.2
  • utils - OS-specific utilities for system time sync
  • log / defmt - Debug logging

Return value: NtpResult

The get_time and sntp_process_response functions return NtpResult which includes:

Field Description
sec() / sec_fraction() Server-reported timestamp (seconds and fraction)
roundtrip() Round-trip delay in microseconds
offset() Estimated clock offset in microseconds
stratum() Server stratum level
precision() Server clock precision as log2(seconds)
leap_indicator() Leap indicator (0-3) from the server response
root_delay() Root delay in NTP short format (16.16 fixed-point)
root_dispersion() Root dispersion in NTP short format (16.16 fixed-point)
reference_id() Reference identifier (4-byte array in network byte order)
reference_timestamp() Reference timestamp in NTP timestamp format
poll() Server poll interval in log2 seconds
dispersion() Estimated dispersion in microseconds (requires dispersion feature)

NtpResult.seconds is u64 because NTP wire timestamps carry only 32 bits of seconds and roll over every 2^32 seconds. The normal get_time / sntp_process_response APIs are rollover-safe by default.

NTP era rollover and embedded cold start

NTP wire timestamps contain only 32 bits of seconds, so after the 2036 rollover the same wire value can represent multiple 2^32-second "eras". sntpc reconstructs the era by asking the timestamp generator for its best guess at the current Unix time (the pivot, timestamp_gen.timestamp_sec()) and picking whichever of the three eras closest to that pivot best matches the wire timestamp.

This is a nearest-era guess, not a verified result. If the pivot is within about half an era (±2^31 seconds, ~68 years) of the truth, reconstruction is correct. If the pivot is off by more than that, sntpc does not return an error — it silently reconstructs into the wrong era and returns a plausible-looking but incorrect timestamp. The only rejected input is a raw wire timestamp of exactly zero (Error::InvalidTimestamp); a stale or implausible pivot is never detected internally.

For embedded systems that may boot with an invalid RTC or an uptime-only clock, keep persistence and pivot policy in your own generator and/or application code. The snippet below is illustrative: load_last_successful_sync, validated_rtc_time, save_last_successful_sync, and the internal pivot field are all application-defined, not part of sntpc.

const FIRMWARE_BUILD_TIME: u64 = 1_735_689_600; // 2025-01-01, last resort floor

struct MyTimestampGen {
    pivot: u64, // seconds since UNIX_EPOCH; app-managed, not an sntpc concept
}

impl MyTimestampGen {
    fn new() -> Self {
        // Your own fallback chain: saved sync time, then a validated RTC
        // read, then the firmware build time as an absolute last resort.
        let pivot = load_last_successful_sync()
            .or_else(validated_rtc_time)
            .unwrap_or(FIRMWARE_BUILD_TIME);
        Self { pivot }
    }
}

impl NtpTimestampGenerator for MyTimestampGen {
    fn init(&mut self) { /* refresh self.pivot from your uptime/RTC source */ }
    fn timestamp_sec(&self) -> u64 { self.pivot }
    fn timestamp_subsec_micros(&self) -> u32 { 0 }
}

let context = NtpContext::new(MyTimestampGen::new());
let result = sntpc::get_time(addr, &socket, context).await?;

// Sanity-check the result before trusting or persisting it — see below.
if result.sec() >= FIRMWARE_BUILD_TIME {
    save_last_successful_sync(result.sec());
} else {
    // result.sec() before the firmware build time means the pivot was bad
    // enough to land reconstruction in a stale era; do not persist it.
    flag_suspect_sync(result.sec());
}

The crate intentionally does not manage flash/EEPROM/RTC persistence, and it does not validate the pivot's plausibility. If no RTC, saved time, or other external hint is available, the client cannot reliably infer the absolute NTP era from a single SNTP response — only your application can decide what "reasonable" means for its own deployment.

Sanity-checking sync results with no trusted RTC

If a device has no battery-backed RTC and no saved sync time (e.g. first boot, or storage was wiped), the only pivot available is the firmware build time. In that situation, after every successful sync:

  • Reject or flag results earlier than your build-time floor. Since the build time is a hard lower bound on "now", result.sec() < FIRMWARE_BUILD_TIME is proof the reconstruction landed in a stale era (the true wire timestamp was ambiguous and the nearest-era guess picked the wrong one). Treat such a result as untrusted: don't persist it as the new pivot, and consider retrying the sync or falling back to a different server.
  • Expect monotonicity within a boot session. Once you have one trusted sync result, later syncs in the same session should not regress to an earlier time by more than your expected offset/network jitter. A large backward jump between two syncs in the same session — with no era rollover in between — is a strong signal that the second sync's pivot (or response) should not be trusted, even if it's individually above the build-time floor.
  • Persist the sync result, not just the pivot you started with. Feeding each NtpTimestampGenerator re-init from the previous successful result.sec() (rather than from an unvalidated uptime counter) keeps the pivot self-correcting across reboots, shrinking the ±68-year ambiguity window down to whatever clock drift accumulated since the last sync.

Usage example

  • dependency for the app
[dependencies]
sntpc = { version = "0.11", features = ["sync"] }
  • application code. Based on a socket implementation used, you may want to use supplementary crates with UDP socket wrappers:

Example below uses sntpc-net-std crate:

[dependencies]
sntpc-net-std = "1"
use sntpc::{sync::get_time, NtpContext, StdTimestampGen};
use sntpc_net_std::UdpSocketWrapper;

use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
use std::thread;
use std::time::Duration;

#[allow(dead_code)]
const POOL_NTP_ADDR: &str = "pool.ntp.org:123";
#[allow(dead_code)]
const GOOGLE_NTP_ADDR: &str = "time.google.com:123";

fn main() {
    #[cfg(feature = "log")]
    if cfg!(debug_assertions) {
        simple_logger::init_with_level(log::Level::Trace).unwrap();
    } else {
        simple_logger::init_with_level(log::Level::Info).unwrap();
    }

    let socket =
        UdpSocket::bind("0.0.0.0:0").expect("Unable to crate UDP socket");
    socket
        .set_read_timeout(Some(Duration::from_secs(2)))
        .expect("Unable to set UDP socket read timeout");
    let socket = UdpSocketWrapper::new(socket);

    for addr in POOL_NTP_ADDR.to_socket_addrs().unwrap() {
        let ntp_context = NtpContext::new(StdTimestampGen::default());
        let result = get_time(addr, &socket, ntp_context);

        match result {
            Ok(time) => {
                assert_ne!(time.sec(), 0);
                let seconds = time.sec();
                let microseconds = u64::from(time.sec_fraction()) * 1_000_000
                    / u64::from(u32::MAX);
                println!("Got time from [{POOL_NTP_ADDR}] {addr}: {seconds}.{microseconds}");

                break;
            }
            Err(err) => println!("Err: {err:?}"),
        }

        thread::sleep(Duration::new(2, 0));
    }
}

You can find this example as well as other example projects in the example directory.

no_std support

There is an example available on how to use smoltcp stack and that should provide general idea on how to bootstrap no_std networking and timestamping tools for sntpc library usage

async support

Starting version 0.5 the default interface is async. If you want to use synchronous interface, read about sync feature below.

tokio example: examples/tokio

There is also no_std support with feature async, but it requires Rust >= 1.75-nightly version. The example can be found in separate repository.

sync support

sntpc crate is async by default, since most of the frameworks (I have seen) for embedded systems utilize asynchronous approach, e.g.:

If you need a fully synchronous interface, it is available in the sntpc::sync submodule and respective sync-feature enabled. In the case someone needs a synchronous socket support the currently async NtpUdpSocket trait can be implemented in a fully synchronous manner. This is an example for the std::net::UdpSocket that is available in the crate:

#[cfg(feature = "std")]
impl NtpUdpSocket for UdpSocket {
    async fn send_to(&self, buf: &[u8], addr: SocketAddr) -> Result<usize> {
        match self.send_to(buf, addr) {
            Ok(usize) => Ok(usize),
            Err(_) => Err(Error::Network),
        }
    }

    async fn recv_from(&self, buf: &mut [u8]) -> Result<(usize, SocketAddr)> {
        match self.recv_from(buf) {
            Ok((size, addr)) => Ok((size, addr)),
            Err(_) => Err(Error::Network),
        }
    }
}

As you can see, you may implement everything as synchronous, sntpc synchronous interface handles async-like stuff internally.

That approach also allows to avoid issues with maybe_async when the sync/async feature violates Cargo requirements:

That is, enabling a feature should not disable functionality, and it should usually be safe to enable any combination of features.

Small overhead introduced by creating an executor should be negligible.

Network Adapters

The sntpc uses a modular architecture:

Contribution

Contributions are always welcome! If you have an idea, it's best to float it by me before working on it to ensure no effort is wasted. If there's already an open issue for it, knock yourself out. See the contributing section for additional details

Thanks

  1. Frank A. Stevenson: for implementing stricter adherence to RFC4330 verification scheme
  2. Timothy Mertz: for fixing possible overflow in offset calculation
  3. HannesH: for fixing a typo in the README.md
  4. Richard Penney: for adding two indicators of the NTP server's accuracy into the NtpResult structure
  5. Vitali Pikulik: for adding async support
  6. tsingwong: for fixing invalid link in the README.md
  7. Robert Bastian: for fixing the overflow issue in the calculate_offset
  8. oleid: for bringing embassy socket support
  9. Damian Peckett: for adding defmt support and elaborating on embassy example
  10. icalder: for improving embassy-net support and adding missing defmt format support for some sntpc types
  11. Boris Faure: for allowing embassy-net-0.9 that works without code change
  12. Jaroslav Henner: for fixing the sntpc-net-embassy documentation on how to get microseconds from fractions of seconds properly
  13. SimonIT: for introducing the sntpc-time-embassy crate with embassy-time support and updating defmt version of the dependent crates

Really appreciate all your efforts! Please let me know if I forgot someone.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this codebase by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

About

Rust SNTP client to get a timestamp from NTP servers

Topics

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Contributing

Stars

73 stars

Watchers

2 watching

Forks

Sponsor this project

  •  

Packages

 
 
 

Contributors

Languages