SNTP client (RFC 4330): a pure 48-byte NTP packet codec plus a blocking UDP query that reads a public time server and computes the local clock offset and round-trip delay.
- Std has no NTP/SNTP support at all.
- Model after: RFC 4330 (SNTP for IPv4/IPv6); packet layout and the
T1–T4 offset/delay arithmetic mirror the design of
FObersteiner/ntp_client(src/ntp.zig, MIT) — a clean-room re-derivation, no code copied. - Platform: any — the wire codec is portable; the local send/receive
instants come from the libc-free
clock_gettime(REALTIME)errno form (RtlGetSystemTimePreciseon Windows), same as the siblingjwt/jobqueuemodules. Role: client (codec + UDP). Concurrency: reentrant (no shared state). - Deps:
std.Io.net(UDP datagram socket forquery).
Provenance: clean-room from RFC 4330 (SNTPv4) (and RFC 5905 for the timestamp
format) — original work of the zig-libs authors (MIT). The packet layout and
offset/delay design are modeled after FObersteiner/ntp_client (MIT,
Codeberg); no third-party code was copied.
- NTP timestamps are 64-bit fixed-point seconds since the NTP epoch
1900-01-01: the high 32 bits are whole seconds, the low 32 bits are the
fraction in units of 1/2^32 second (
Timestamp{ seconds, fraction }). Big-endian on the wire. - Unix time is
NTP − 2_208_988_800 s(ntp_unix_offset_s). Timestampconverts both ways:nanosSinceNtpEpoch/fromNanosSinceNtpEpoch(fraction ↔ ns viafrac * 1e9 >> 32andns << 32 / 1e9) andtoUnixNanos/fromUnixNanos. Offset/delay differences are computed in i128 nanoseconds so they stay exact and can be negative.root_delay/root_dispersionare kept as raw 16.16 "NTP short" fixed point;rootDelaySeconds/rootDispersionSecondsinterpret them.
const sntp = @import("sntp");
// Codec — transport-agnostic, no I/O:
var req: [sntp.packet_len]u8 = undefined;
sntp.encodeRequest(&req, sntp.nowTimestamp()); // client mode, VN 4, T1 set
var kiss: sntp.KissOfDeath = undefined;
const reply = sntp.decodeResponse(bytes, &kiss) catch |err| switch (err) {
error.KissOfDeath => switch (kiss.code) {
.rate => { /* back off: increase polling interval */ return err; },
.deny, .rstr => { /* stop sending to this server */ return err; },
else => return err,
},
else => return err,
};
// Offset & round-trip delay from the four timestamps (nanoseconds):
const sample: sntp.Sample = .{
.originate = t1, .receive = t2, .transmit = t3, .destination = t4,
};
const offset_ns = sample.offsetNanos(); // ((T2−T1)+(T3−T4))/2
const delay_ns = sample.roundtripDelayNanos(); // (T4−T1)−(T3−T2)
// One-shot UDP query (IPv4 or IPv6) over std.Io.net:
const server = try std.Io.net.IpAddress.parse("162.159.200.1", sntp.ntp_port);
const r = try sntp.query(io, server, .{ .timeout_ms = 3000 }, null); // null: ignore KoD detail
// r.reply (stratum, timestamps…), r.sample, r.offset_ns, r.roundtrip_nsdecodeResponse(bytes, kiss_out) returns distinct errors: InvalidLength (not 48 bytes, including a
UDP datagram the kernel had to truncate to fit), InvalidVersion (VN = 0), NotServerMode (mode ≠
4), KissOfDeath (stratum 0 — pass a non-null kiss_out: ?*KissOfDeath to get the parsed KissCode
and raw 4-byte reason from reference_id, per RFC 5905 §7.4), UnsynchronizedStratum (stratum ≥ 16
— RFC 5905: 16 is "unsynchronized", 17-255 reserved), UnsynchronizedLeap (Leap Indicator = 3, RFC
4330 §4's own "alarm condition, clock not synchronized" — the same verdict UnsynchronizedStratum
reaches by a different field), ReceiveTimestampUnset and TransmitTimestampUnset (the server
hasn't set its own clock yet). query forwards its own kiss_out: ?*KissOfDeath parameter the same
way, and adds OriginateMismatch (the reply didn't echo query's anti-spoof origin nonce),
EntropyUnavailable (couldn't source fresh entropy for that nonce — fails closed rather than using a
predictable one), and ClockUnavailable (the local clock couldn't be read for T1/T4).
Security note: this module is a single unauthenticated UDP exchange. query validates the reply
thoroughly (RFC 4330 §5's checks, including the peer address/port and an anti-spoof origin nonce) but
that is not authentication — a network attacker who can observe traffic, not just spoof it blindly,
can still forge a reply. Do not step a security-sensitive clock (TLS validity, TOTP, Kerberos, log
ordering) from a single query result on a hostile network; use NTS or authenticated NTP for that,
layered on top — this module doesn't provide it.
Known limitation — NTP era 0 (until 2036-02-07): timestamps are decoded as
a bare 32-bit seconds count with no era pivot, so arithmetic is only correct
inside the current NTP era; see SPEC.md for detail. Not fixed here — no
surveyed implementation has actually solved it either. One of this module's
own tests is a tripwire for the date, not just a comment: test "nowTimestamp is in a sane modern range" fails on its own two days before the rollover.
zig build test-sntp — offline, no live server: golden request bytes (the
LI|VN|Mode byte + T1 placement), packet encode/decode round-trip, a canned
server response (stratum/precision/timestamps/ref-id), the reject paths
(length, version 0, mode, Kiss-o'-Death with its parsed reason code, stratum
≥ 16, Leap Indicator 3, an unset Receive Timestamp, an unset Transmit
Timestamp), NTP↔Unix epoch conversion at a known instant, fraction↔nanosecond
round-trips, offset/delay against hand-computed T1..T4 (including a negative
offset), and query's receive-side guards (peer address/port match,
truncation, origin-nonce echo) exercised directly, without a socket. The live
query test is gated behind error.SkipZigTest.
- Full NTP (RFC 5905): the intersection/clustering/combining algorithms.
- Server side.
- NTP authentication (the optional MAC / extension fields; only the plain
48-byte packet is parsed — longer packets are rejected as
InvalidLength). - Leap-second handling beyond surfacing the
LeapIndicatorflag. - Multi-server sampling / racing and best-sample selection.
- NTP era 0 rollover (2036-02-07): documented as a known bound, not fixed.