NTP Check

Boot-time clock-drift verification against an external NTP / NTS-KE reference. The audit chain's monotonicCounter orders events deterministically even when the wall clock jumps, but recordedAt is the human-readable timestamp auditors rely on — a clock silently off by hours (container with no RTC sync, NTP daemon stopped) makes the audit trail misleading without ever surfacing as an error.

What this does: sends a single SNTPv4 query over UDP/123 (RFC 5905) to one or more configured servers, computes drift as serverTransmit - localMidpoint (round-trip-corrected), returns the drift in milliseconds. Falls through a server list in order; the first success wins.

What this does NOT do: continuous synchronization (the host OS's NTP daemon does that), authenticated NTP / NTS / autokey (the external reference is trust-on-first-query), or median-of-N server reconciliation (single-shot only).

Policy thresholds at boot — wired into b.db.init:

drift |x| < warnMs (5 min default) → info, continue drift |x| in [warnMs, fatalMs) → warning, continue drift |x| >= fatalMs (1 hr default) → refuse to boot (BLAMEJS_NTP_STRICT=0 downgrades to a log line) NTP unreachable → warning, continue (network may not allow UDP/123 outbound; BLAMEJS_NTP_REQUIRE_REACHABLE=1 refuses the boot instead)

b.ntpCheck.monitor runs the same check on a recurring interval after boot and emits system.ntp.checked / system.ntp.drift_warn / system.ntp.drift_fatal / system.ntp.unreachable audit events plus an ntp.drift_ms observability gauge — so silent clock drift mid-flight surfaces in the same evidence stream as boot drift.

b.ntpCheck.setThresholds(opts) #

stable0.7.30
{
  warnMs:  300000,    // ms; absolute drift at-or-above this logs warn
  fatalMs: 3600000,   // ms; absolute drift at-or-above this refuses boot
}

Override the warn / fatal drift thresholds applied by bootCheck and monitor. Validates that both values are non-negative finite numbers and that warnMs <= fatalMs (a fatal floor below the warning threshold would mean every warning is also fatal — likely a typo). Throws TypeError on bad shapes and RangeError on the ordering invariant.

b.ntpCheck.setThresholds({
  warnMs:  60000,
  fatalMs: 600000,
});
var t = b.ntpCheck.getThresholds();
// → { warnMs: 60000, fatalMs: 600000 }

b.ntpCheck.getThresholds() #

stable0.7.30

Read the currently-effective warn / fatal drift thresholds. Returns a fresh object so mutating the result doesn't accidentally rewrite framework state.

var t = b.ntpCheck.getThresholds();
// → { warnMs: 300000, fatalMs: 3600000 }

b.ntpCheck.querySingle(server, opts) #

stable0.0.7
{
  port:      123,    // UDP port (almost always 123)
  timeoutMs: 3000,   // single-query timeout
}

Send one SNTPv4 query to a named server over UDP/123 and resolve with { driftMs, serverTimeMs, server } (round-trip-corrected drift). Rejects with { code, message } where code is one of ntp/timeout (no reply within timeoutMs), ntp/refused (DNS / connection error), ntp/bad-reply (packet too short), or ntp/unsynchronized (Stratum-16 peer with zero transmit timestamp). IPv4 / IPv6 socket family is selected from the host literal so an fd00::... server doesn't fail with EINVAL.

A port outside 1-65535 throws ntp/bad-port synchronously, before the socket opens, rather than rejecting like the codes above.

// requires: outbound NTP (udp/123) to the server named below
b.ntpCheck.querySingle("time.cloudflare.com", { timeoutMs: 2000 })
  .then(function (r) { console.log("drift", r.driftMs, "ms"); })
  .catch(function (e) { console.error("ntp", e.code, e.message); });

b.ntpCheck.checkDrift(opts) #

stable0.0.7
{
    servers:    ["time.cloudflare.com", "pool.ntp.org"],
    ntsServers: ["time.cloudflare.com", "nts.example:4460", "[2001:db8::1]:4460"],   // array; host, host:port or [v6]:port — tried first, authenticated (RFC 8915)
    requireNts: false,                     // boolean; no unauthenticated fallback when true
    port:      123,
    timeoutMs: 3000,
    driftWarnMs:  2000,                    // accepted and ignored here; carried so one config object
    driftFatalMs: 30000,                   //   passes to b.ntpCheck.monitor, which reads them
    intervalMs:   900000,                  //   (checkDrift measures drift; it does not judge or
    audit:        true,                    //    schedule it, and the caller deciding that is
    onDrift:      function (result) {},    //    typically bootCheck or monitor)

  Opts are validated against a schema. A field present with the wrong type is
  refused rather than read as absent: a string `requireNts` would otherwise
  drop the authenticated reading, a non-array `ntsServers` would attempt no
  authenticated query at all, and a string `servers` has a length and indexes,
  so it was walked CHARACTER BY CHARACTER — the configured server never
  contacted, the result an ordinary unreachable warning. A key the schema does
  not declare is refused too, so a typo cannot be silently consumed;
  `bootCheck` and `monitor` forward their own opts through here and those are
  allowed, validated where they are read.
}

Walk a server list in order; resolve with the first successful drift measurement ({ driftMs, serverTimeMs, server }). When every server in the list fails, resolves with { driftMs: null, error } so the caller — typically bootCheck — can decide whether unreachable NTP is fatal or a soft warning.

The result says which kind of answer it is: authenticated: true when it came from an NTS server (RFC 8915), false from plain SNTP. Named NTS servers are tried first; requireNts makes a failed authenticated query the answer rather than a reason to fall back to an unauthenticated one.

// requires: outbound NTP (udp/123) to the servers named below
var result = await b.ntpCheck.checkDrift({
  servers: ["time.cloudflare.com", "pool.ntp.org"],
});
// → { driftMs: 12, serverTimeMs: 1714694400000,
//     server: "time.cloudflare.com", authenticated: false }

b.ntpCheck.bootCheck(opts) #

stable0.0.7
{
  servers:      ["time.cloudflare.com", "pool.ntp.org"],
  port:         123,
  timeoutMs:    3000,
  driftWarnMs:  300000,    // override registered warn threshold
  driftFatalMs: 3600000,   // override registered fatal threshold
  ntsServers:   ["time.cloudflare.com:4460"],  // NTS-KE endpoints tried BEFORE the plain `servers`,
                           // each "host", "host:port" or "[v6]:port"; a malformed entry raises
                           // ntp/bad-endpoint. A reading from one of these carries
                           // authenticated: true
  requireNts:   false,     // with it true, the plain `servers` are not consulted at all, so if
                           // every NTS endpoint fails the result is { driftMs: null,
                           // authenticated: false } carrying the last error rather than an
                           // unauthenticated reading
  intervalMs:   900000,    // accepted and ignored here; carried so one config object passes to
  audit:        true,      //   b.ntpCheck.monitor, which reads them. bootCheck takes a single
  onDrift:      function (result) {},  // reading at startup and does not schedule or hook
}

Boot-time clock-drift check that integrates with the framework's logging policy. Resolves with { ok, severity, driftMs, server, authenticated, message } where severity is info / warning / fatal and authenticated says whether the reading came from an authenticated (RFC 8915) server or a plain SNTP reply anyone on the path could have written. The framework's b.db.init calls this. Drift past the fatal threshold refuses the boot by default, and BLAMEJS_NTP_STRICT=0 downgrades that refusal to a log line.

Opts are forwarded to checkDrift, so ntsServers and requireNts apply here as well, and they are validated against a schema: a field present with the wrong type is refused rather than ignored. That includes the two threshold overrides, which were read with a typeof test and silently dropped when they were not numbers — the boot then ran against the registered threshold rather than the one passed. Zero remains valid on both: it disables that threshold.

An unreachable server is a different question and answers severity: "warning" with ok: true, because a network that does not allow outbound UDP/123 is ordinary and refusing every such host by default would be a boot failure nobody asked for. An operator who does want it refused sets BLAMEJS_NTP_REQUIRE_REACHABLE=1, which bootCheck reads: the same unanswered query then answers ok: false with severity: "fatal", and b.db.init turns that into db/ntp-unreachable. Its own refusals are ntp/bad-opts for an option of the wrong type or a name it does not accept, and ntp/bad-port for a port outside 1-65535. It is the only setting that turns an unanswered query into a refusal, and BLAMEJS_NTP_STRICT has no bearing on it. Either way the result carries unreachableReason, the reason no reading was obtained.

// requires: outbound NTP (udp/123) to the servers named below
var result = await b.ntpCheck.bootCheck({
  servers:      ["time.cloudflare.com"],
  driftWarnMs:  60000,
  driftFatalMs: 600000,
});
// → { ok: true, severity: "info", driftMs: 12,
//     server: "time.cloudflare.com", authenticated: false,
//     message: "clock drift +12ms from time.cloudflare.com" }

b.ntpCheck.monitor(opts) #

stable0.7.30
{
  intervalMs:   900000,                            // tick cadence
  servers:      ["time.cloudflare.com", "pool.ntp.org"],
  driftWarnMs:  2000,
  driftFatalMs: 30000,
  audit:        true,                              // emit audit events
  onDrift:      function (result) {},              // operator hook
  port:         123,                               // UDP port for the plain `servers`; a value
                          // outside 1-65535 raises ntp/bad-port
  timeoutMs:    3000,                              // per-query ceiling, applied to an NTS endpoint
                          // and a plain server alike
  ntsServers:   ["time.cloudflare.com:4460"],      // NTS-KE endpoints tried BEFORE the plain
                          // `servers`, each "host", "host:port" or "[v6]:port"; a malformed entry
                          // raises ntp/bad-endpoint. A reading from one carries authenticated: true
  requireNts:   false,                             // with it true, the plain `servers` are not
                          // consulted at all, so if every NTS endpoint fails the tick reports
                          // { driftMs: null, authenticated: false } with the last error rather
                          // than an unauthenticated reading
}

Periodic drift monitor — runs bootCheck on a recurring interval and emits audit + observability events on threshold crossings. Returns a handle with .stop() for graceful shutdown. Audit emissions: system.ntp.checked on every tick, system.ntp.drift_warn and system.ntp.drift_fatal on threshold crossings, system.ntp.unreachable when every server in the list failed. Observability gauge ntp.drift_ms rides every successful check. The optional onDrift hook fires only when severity is warning or fatal, so operators can page on drift without inspecting every healthy tick.

// requires: outbound NTP (udp/123) to the servers named below
var mon = b.ntpCheck.monitor({
  intervalMs:   900000,
  servers:      ["time.cloudflare.com", "pool.ntp.org"],
  driftWarnMs:  2000,
  driftFatalMs: 30000,
  onDrift: function (r) { console.warn("ntp drift", r.driftMs); },
});
await mon.stop();

Last updated 2026-10-12T14:21:56.879Z by seeder.