ntpfence
v0.1.0
Published
sntp time client with clock offset, replacing the deprecated sntp package
Readme
ntpfence
ask an ntp server what time it is. zero deps.
npm i ntpfencewhy
the package npm still reaches for, sntp, does 1.4M downloads a week, is
formally deprecated, and its last release was november 2018. it pulls
four dependencies. nothing maintained replaced it at any scale.
an sntp request is a 48 byte udp packet. it did not need a successor with dependencies, it just needed one.
use
import { query, time, offset } from 'ntpfence'
const r = await query() // pool.ntp.org by default
r.offset // how far this machine's clock is off, in ms
r.delay // round trip time
r.time // the server's idea of now, unix ms
r.stratum // 1 is a primary reference, 2+ is downstream
r.referenceId // 'GOOG', or an address
await time() // a corrected Date, without touching the system clock
await offset() // just the millisecondsagainst real servers:
time.cloudflare.com offset 270ms | delay 192ms | stratum 3 | 10.136.8.6
time.google.com offset 454ms | delay 656ms | stratum 1 | GOOG
pool.ntp.org offset 258ms | delay 491ms | stratum 2 | 216.239.35.0more than one server
a single server can be unreachable, or wrong. best asks several at once and
keeps the reading with the shortest round trip, which is the least
distorted sample:
import { best } from 'ntpfence'
const r = await best() // pool.ntp.org, cloudflare, google
const r = await best(['time.cloudflare.com', 'ntp.example.internal'])it resolves as long as one server answers.
it does not believe just any packet
udp has no connection, so anything that reaches the socket could try to move your clock. a reply is checked before it counts:
| the reply | result |
|---|---|
| does not echo the exact timestamp we sent | BAD_REPLY — the important one |
| is not in server mode | BAD_REPLY |
| is stratum 0, a kiss of death | KISS_OF_DEATH |
| is stratum 16 or higher | NOT_SYNCED |
| says its own clock is unsynchronised | NOT_SYNCED |
| has an empty transmit timestamp | BAD_REPLY |
| is shorter than 48 bytes | BAD_REPLY |
the origin check is what matters most: the request's transmit timestamp acts as a nonce, and a reply that cannot echo it never saw the request.
errors are all TimeError with a code, so you can branch without matching
on messages. the socket is closed on every path, including timeouts.
clock, not system clock
nothing here sets your system time. that needs privileges and is the job of
ntpd or chronyd. this tells you the offset so you can apply it yourself —
which is usually what you want for signing deadlines, token expiry, or
refusing to run when a container's clock has drifted:
const { offset } = await query()
if (Math.abs(offset) > 30_000) throw new Error('clock drift over 30s, refusing to start')low level
import { encode, decode, offsets } from 'ntpfence'
decode(bytes) // every rfc 5905 header field
offsets(t1, t2, t3, t4) // the rfc 4330 offset and delay mathsnotes
era 0 of the ntp epoch rolls over in 2036. timestamps past it are handled, so this keeps working.
node:dgram is the only import.
correctness
27 tests. the wire format is checked field by field against handcrafted packets, the offset maths against the worked example in rfc 4330, and the client against a real udp server — including a forged reply carrying someone else's origin timestamp, which must be ignored rather than trusted.
live tests against cloudflare, google and pool.ntp.org run with
NTPFENCE_LIVE=1, and assert the three independently agree with each other.
license
MIT
