UNPKG

ntp-time-sync

Version:

Fetches the current time from NTP servers and returns offset information

470 lines 19.5 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.NtpTimeSync = exports.NtpTimeSyncDefaultOptions = void 0; const dgram = __importStar(require("node:dgram")); const ntp_packet_parser_1 = require("ntp-packet-parser"); // Recursively freeze an object tree so consumers cannot mutate shared defaults. // Arrays are frozen along with each of their entries. const deepFreeze = (value) => { if (value === null || typeof value !== "object" || Object.isFrozen(value)) { return value; } if (Array.isArray(value)) { for (const entry of value) { deepFreeze(entry); } } else { for (const key of Object.keys(value)) { deepFreeze(value[key]); } } return Object.freeze(value); }; exports.NtpTimeSyncDefaultOptions = deepFreeze({ // list of NTP time servers, optionally including a port (defaults to options.ntpDefaults.port = 123) servers: ["0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org", "3.pool.ntp.org"], // required amount of valid samples sampleCount: 8, // amount of time in milliseconds to wait for an NTP response replyTimeout: 3000, // defaults as of RFC5905 ntpDefaults: { port: 123, version: 4, tolerance: 15e-6, minPoll: 4, maxPoll: 17, maxDispersion: 16, minDispersion: 0.005, maxDistance: 1, maxStratum: 16, precision: -18, referenceDate: new Date("Jan 01 1900 GMT"), }, }); class NtpTimeSync { constructor(options = {}) { this.samples = []; const serverConfig = options.servers || exports.NtpTimeSyncDefaultOptions.servers; const mergedConfig = this.recursiveResolveOptions(options, exports.NtpTimeSyncDefaultOptions); this.options = { ...mergedConfig, servers: serverConfig .filter((server) => server !== undefined) .map((server) => NtpTimeSync.parseServer(server, mergedConfig.ntpDefaults.port)), }; } recursiveResolveOptions(options, defaults) { // Reject unknown options. The check has to iterate over the *input* keys // (not the defaults) to be meaningful: every key the caller supplies must // have a matching default, otherwise it is a typo or an unsupported option. for (const key of Object.keys(options)) { if (!(key in defaults)) { throw new Error(`Invalid option: ${key}`); } } const mergedConfig = Object.entries(defaults).map(([key, value]) => { // option was not overridden in input if (!(key in options)) { return [key, value]; } if (Array.isArray(options[key])) { return [key, options[key]]; } // only recurse when both sides are plain objects; otherwise the input // value replaces the default wholesale if (NtpTimeSync.isPlainObject(options[key]) && NtpTimeSync.isPlainObject(defaults[key])) { return [key, this.recursiveResolveOptions(options[key], defaults[key])]; } return [key, options[key]]; }); return Object.fromEntries(mergedConfig); } // @see https://quickref.me/check-if-a-value-is-a-plain-object.html static isPlainObject(v) { if (!v || typeof v !== "object") return false; const proto = Object.getPrototypeOf(v); return proto === null || proto === Object.prototype; } /** * Parse a server entry into a host/port pair. Supports: * - "host" / "1.2.3.4" → default port * - "host:123" / "1.2.3.4:123" → explicit port * - "[2001:db8::1]" / "[2001:db8::1]:123" → bracketed IPv6, optional port * - "2001:db8::1" / "::1" → bare IPv6 literal, default port * A bare (unbracketed) IPv6 literal cannot carry a port because the colons * are ambiguous, so the default port is always used for it. */ static parseServer(server, defaultPort) { const resolvePort = (raw) => { const port = Number(raw); return Number.isInteger(port) && port > 0 && port <= 65535 ? port : defaultPort; }; // Bracketed IPv6, e.g. "[::1]" or "[::1]:123" if (server.startsWith("[")) { const end = server.indexOf("]"); if (end !== -1) { const host = server.slice(1, end); const rest = server.slice(end + 1); // "" or ":<port>" return { host, port: rest.startsWith(":") ? resolvePort(rest.slice(1)) : defaultPort }; } } // Bare IPv6 literal (more than one colon, no brackets): no port possible. if (server.indexOf(":") !== server.lastIndexOf(":")) { return { host: server, port: defaultPort }; } // Hostname or IPv4, with an optional ":<port>". const idx = server.indexOf(":"); if (idx === -1) { return { host: server, port: defaultPort }; } return { host: server.slice(0, idx) || server, port: resolvePort(server.slice(idx + 1)) }; } /** * Returns a singleton */ static getInstance(options = {}) { if (!NtpTimeSync.singleton) { NtpTimeSync.singleton = new NtpTimeSync(options); } return NtpTimeSync.singleton; } async collectSamples(numSamples) { let ntpResults = []; let retry = 0; do { let timePromises = []; this.options.servers.forEach((server) => { timePromises.push(this.getNetworkTime(server.host, server.port).then((data) => { this.acceptResponse(data); return data; })); }); const prevResultCount = ntpResults.length; // wait for NTP responses to arrive ntpResults = ntpResults .concat(await Promise.all(timePromises.map((p) => p.catch((e) => e)))) .filter(function (result) { return !(result instanceof Error); }); // count a retry whenever a full round produced no new usable samples; // otherwise partial progress could loop forever against a slow server set if (ntpResults.length === prevResultCount) { retry++; } } while (ntpResults.length < numSamples && retry < 3); if (ntpResults.length === 0) { throw new Error("Connection error: Unable to get any NTP response after " + retry + " retries"); } // filter erroneous responses, use valid ones as samples let samples = []; ntpResults.forEach((data) => { const transmitTimestamp = data.transmitTimestamp; const receiveTimestamp = data.receiveTimestamp; const originTimestamp = data.originTimestamp; const precision = data.precision; // acceptResponse has already validated these fields; narrow for the type system if (transmitTimestamp === undefined || receiveTimestamp === undefined || originTimestamp === undefined || precision === undefined) { return; } // Clock offset per RFC 5905 §8: theta = ((T2 - T1) + (T3 - T4)) / 2 // T1 = originTimestamp (client transmit, echoed by server) // T2 = receiveTimestamp (server receive) // T3 = transmitTimestamp (server transmit) // T4 = destinationTimestamp (client receive) // The signed differences carry the correct direction on their own; the // previous abs()+heuristic-sign form produced the wrong result whenever // the inbound and outbound legs disagreed in sign. const offset = (receiveTimestamp.getTime() - originTimestamp.getTime() + (transmitTimestamp.getTime() - data.destinationTimestamp.getTime())) / 2; const delay = Math.max(data.destinationTimestamp.getTime() - originTimestamp.getTime() - (receiveTimestamp.getTime() - transmitTimestamp.getTime()), Math.pow(2, this.options.ntpDefaults.precision)); const dispersion = Math.pow(2, precision) + Math.pow(2, this.options.ntpDefaults.precision) + this.options.ntpDefaults.tolerance * (data.destinationTimestamp.getTime() - originTimestamp.getTime()); samples.push({ data: data, offset: offset, delay: delay, dispersion: dispersion, }); }); // sort samples by ascending delay samples.sort(function (a, b) { return a.delay - b.delay; }); // restrict to best n samples return samples.slice(0, numSamples); } /** * @param {boolean} force Force NTP update */ async getTime(force = false) { if (!force && this.lastPoll && this.lastResult && Date.now() - this.lastPoll < Math.pow(2, this.options.ntpDefaults.minPoll) * 1000) { let date = new Date(); date.setUTCMilliseconds(date.getUTCMilliseconds() + this.lastResult.offset); return { now: date, offset: this.lastResult.offset, precision: this.lastResult.precision, }; } // update time samples this.samples = await this.collectSamples(this.options.sampleCount); // calculate offset const offset = this.samples.reduce((acc, item) => { return acc + item.offset; }, 0) / this.samples.length; const precision = NtpTimeSync.stdDev(this.samples.map((sample) => sample.offset)); this.lastResult = { offset: offset, precision: precision, }; this.lastPoll = Date.now(); let date = new Date(); date.setUTCMilliseconds(date.getUTCMilliseconds() + offset); return { now: date, offset: offset, precision: precision, }; } /** * Will return the correct timestamp when function was called */ async now(force = false) { const now = new Date(); const result = await this.getTime(force); now.setUTCMilliseconds(now.getUTCMilliseconds() + result.offset); return now; } /** * @param {Integer} leapIndicator, defaults to 3 (unsynchronized) * @param {Integer} ntpVersion, defaults to `options.ntpDefaults.version` * @param {Integer} mode, defaults to 3 (client) * @return {Buffer} */ createPacket(leapIndicator = 3, ntpVersion = undefined, mode = 3) { ntpVersion = ntpVersion || this.options.ntpDefaults.version; const buf = Buffer.alloc(48); // Leap indicator (2 bits) | NTP version (3 bits) | mode (3 bits) buf[0] = ((leapIndicator & 0x3) << 6) | ((ntpVersion & 0x7) << 3) | (mode & 0x7); // origin timestamp: seconds since 1900 epoch in upper 32 bits, // fractional seconds (scaled by 2^32) in lower 32 bits const baseTimeMs = new Date().getTime() - this.options.ntpDefaults.referenceDate.getTime(); const seconds = Math.trunc(baseTimeMs / 1000); const fractional = Math.trunc(((baseTimeMs % 1000) / 1000) * 2 ** 32); const mask32 = BigInt("0xffffffff"); const shift32 = BigInt(32); const ntpTimestamp = ((BigInt(seconds) & mask32) << shift32) | (BigInt(fractional) & mask32); // origin timestamp buf.writeBigUInt64BE(ntpTimestamp, 24); // transmit timestamp buf.writeBigUInt64BE(ntpTimestamp, 40); return buf; } static cleanup(client) { try { // Drop all listeners first so late-arriving error/message events on an // already-abandoned socket cannot trigger resolve/reject a second time // or keep the event loop alive. client.removeAllListeners(); } catch (e) { // ignore, as we just want to cleanup } try { client.close(); } catch (e) { // ignore, as we just want to cleanup } } getNetworkTime(server, port = 123) { return new Promise((resolve, reject) => { const client = dgram.createSocket("udp4"); let hasFinished = false; const errorCallback = (err) => { if (timeoutHandler !== undefined) { clearTimeout(timeoutHandler); timeoutHandler = undefined; } if (hasFinished) { return; } NtpTimeSync.cleanup(client); hasFinished = true; reject(err); }; client.on("error", (err) => errorCallback(err)); // setup timeout let timeoutHandler = setTimeout(() => { errorCallback(new Error("Timeout waiting for NTP response.")); }, this.options.replyTimeout); // Register the message listener BEFORE sending the packet so we never // miss an unusually fast reply that arrives between send() completing // and the send callback firing. client.once("message", (msg) => { if (hasFinished) { return; } clearTimeout(timeoutHandler); timeoutHandler = undefined; client.close(); let parsed; try { parsed = ntp_packet_parser_1.NtpPacketParser.parse(msg); } catch (err) { hasFinished = true; reject(err); return; } const result = { ...parsed, destinationTimestamp: new Date(), }; hasFinished = true; resolve(result); }); try { client.send(this.createPacket(), port, server, (err) => { if (hasFinished) { return; } if (err) { errorCallback(err); return; } }); } catch (err) { // dgram.send can throw synchronously (e.g. when the packet cannot be // constructed or the socket is already in an unusable state) - make // sure we still tear the socket down and reject the pending promise. if (timeoutHandler !== undefined) { clearTimeout(timeoutHandler); timeoutHandler = undefined; } NtpTimeSync.cleanup(client); if (!hasFinished) { hasFinished = true; reject(err); } } }); } /** * Test if response is acceptable for synchronization */ acceptResponse(data) { /* * Format error */ if (data.version === undefined || data.version > this.options.ntpDefaults.version) { throw new Error("Format error: Expected version " + this.options.ntpDefaults.version + ", got " + data.version); } /* * A stratum error occurs if (1) the server has never been * synchronized, (2) the server stratum is invalid. */ if (data.leapIndicator === 3 || data.stratum === undefined || data.stratum >= this.options.ntpDefaults.maxStratum) { throw new Error("Stratum error: Remote clock is unsynchronized"); } /* * Verify valid root distance. */ if (data.rootDelay === undefined || data.rootDispersion === undefined) { throw new Error("Format error: Missing root delay or root dispersion"); } const rootDelay = (data.rootDelay.getTime() - this.options.ntpDefaults.referenceDate.getTime()) / 1000; const rootDispersion = (data.rootDispersion.getTime() - this.options.ntpDefaults.referenceDate.getTime()) / 1000; if (rootDelay / 2 + rootDispersion >= this.options.ntpDefaults.maxDispersion) { throw new Error("Distance error: Root distance too large"); } /* * Verify origin timestamp */ if (data.originTimestamp === undefined || data.originTimestamp.getTime() > new Date().getTime()) { throw new Error("Format error: Origin timestamp is from the future"); } /* * Verify remaining fields required for sample computation */ if (data.transmitTimestamp === undefined) { throw new Error("Format error: Missing transmit timestamp"); } if (data.receiveTimestamp === undefined) { throw new Error("Format error: Missing receive timestamp"); } if (data.precision === undefined) { throw new Error("Format error: Missing precision"); } } /** * Average for a list of numbers */ static avg(values) { const sum = values.reduce(function (sum, value) { return sum + value; }, 0); return sum / values.length; } /** * Standard deviation for a list of numbers */ static stdDev(values) { const avg = this.avg(values); const squareDiffs = values.map(function (value) { const diff = value - avg; return diff * diff; }); return Math.sqrt(this.avg(squareDiffs)); } } exports.NtpTimeSync = NtpTimeSync; //# sourceMappingURL=NtpTimeSync.js.map