ntp-time-sync
Version:
Fetches the current time from NTP servers and returns offset information
470 lines • 19.5 kB
JavaScript
;
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