"use strict"; import * as dgram from "dgram"; import { NtpPacket, NtpPacketParser } from "ntp-packet-parser"; import { NtpTimeResult } from "./NtpTimeResult"; import { RecursivePartial } from "./RecursivePartial"; let singleton: NtpTimeSync | undefined; let lastPoll: number | undefined; let lastResult: | undefined | { offset: number; precision: number; }; export interface NtpTimeSyncConstructorOptions { servers: string[]; sampleCount: number; replyTimeout: number; ntpDefaults: { port: number; version: number; tolerance: number; minPoll: number; maxPoll: number; maxDispersion: number; minDispersion: number; maxDistance: number; maxStratum: number; precision: number; referenceDate: Date; }; } export interface NtpTimeSyncOptions extends Omit { servers: ReadonlyArray<{ host: string; port: number; }>; } export const NtpTimeSyncDefaultOptions = { // 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"), }, }; interface NtpReceivedPacket extends Partial { destinationTimestamp: Date; } interface SampleData { data: NtpReceivedPacket; offset: number; delay: number; dispersion: number; } export class NtpTimeSync { private options: NtpTimeSyncOptions; private samples: SampleData[] = []; constructor(options: RecursivePartial = {}) { const serverConfig = options.servers || NtpTimeSyncDefaultOptions.servers; const mergedConfig = this.recursiveResolveOptions( options, NtpTimeSyncDefaultOptions ) as NtpTimeSyncConstructorOptions; this.options = { ...mergedConfig, servers: serverConfig.map((server) => { return { host: server.split(":", 2)[0], port: Number(server.split(":", 2)[1]) || mergedConfig.ntpDefaults.port, }; }), }; } private recursiveResolveOptions( options: { [key: string]: any }, defaults: { [key: string]: any } ): { [key: string]: any } { const mergedConfig: [string, any][] = Object.entries(defaults).map(([key, value]) => { // option was not defined in input if (!(key in options)) { return [key, value]; } // option is invalid if (!(key in defaults)) { throw new Error(`Invalid option: ${key}`); } if (Array.isArray(options[key])) { return [key, options[key]]; } if (NtpTimeSync.isPlainObject(options[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 private static isPlainObject(v: any): boolean { return !!v && typeof v === "object" && (v.__proto__ === null || v.__proto__ === Object.prototype); } /** * Returns a singleton */ static getInstance(options: RecursivePartial = {}): NtpTimeSync { if (!singleton) { singleton = new NtpTimeSync(options); } return singleton; } private async collectSamples(numSamples: number) { let ntpResults: NtpReceivedPacket[] = []; let retry = 0; do { let timePromises: Promise[] = []; this.options.servers.forEach((server) => { timePromises.push( this.getNetworkTime(server.host, server.port).then((data) => { this.acceptResponse(data); return data; }) ); }); // 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); }); if (ntpResults.length === 0) { 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: SampleData[] = []; ntpResults.forEach((data) => { const offsetSign = data.transmitTimestamp.getTime() > data.destinationTimestamp.getTime() ? 1 : -1; const offset = ((Math.abs(data.receiveTimestamp.getTime() - data.originTimestamp.getTime()) + Math.abs(data.transmitTimestamp.getTime() - data.destinationTimestamp.getTime())) / 2) * offsetSign; const delay = Math.max( data.destinationTimestamp.getTime() - data.originTimestamp.getTime() - (data.receiveTimestamp.getTime() - data.transmitTimestamp.getTime()), Math.pow(2, this.options.ntpDefaults.precision) ); const dispersion = Math.pow(2, data.precision) + Math.pow(2, this.options.ntpDefaults.precision) + this.options.ntpDefaults.tolerance * (data.destinationTimestamp.getTime() - data.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): Promise { if (!force && lastPoll && Date.now() - lastPoll < Math.pow(2, this.options.ntpDefaults.minPoll) * 1000) { let date = new Date(); date.setUTCMilliseconds(date.getUTCMilliseconds() + lastResult.offset); return { now: date, offset: lastResult.offset, precision: 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)); lastResult = { offset: offset, precision: precision, }; 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; } private static pad(string: string, length: number, char = "0", side: "left" | "right" = "left") { if (side === "left") { return char.repeat(length).substring(0, length - string.length) + string; } return string + char.repeat(length).substring(0, length - string.length); } /** * @param {Integer} leapIndicator, defaults to 3 (unsynchronized) * @param {Integer} ntpVersion, defaults to `options.ntpDefaults.version` * @param {Integer} mode, defaults to 3 (client) * @return {Buffer} */ private createPacket(leapIndicator = 3, ntpVersion: number = null, mode = 3): Buffer { ntpVersion = ntpVersion || this.options.ntpDefaults.version; // generate NTP packet let ntpData = new Array(48).fill(0); ntpData[0] = // Leap indicator (= 3, unsynchronized) NtpTimeSync.pad((leapIndicator >>> 0).toString(2), 2) + // NTP version (= 4) NtpTimeSync.pad((ntpVersion >>> 0).toString(2), 3) + // client mode (= 3) NtpTimeSync.pad((mode >>> 0).toString(2), 3); ntpData[0] = parseInt(ntpData[0], 2); // origin timestamp const baseTime = new Date().getTime() - this.options.ntpDefaults.referenceDate.getTime(); const seconds = baseTime / 1000; let ntpTimestamp = (seconds * Math.pow(2, 32)).toString(2); ntpTimestamp = NtpTimeSync.pad(ntpTimestamp, 64); // origin timestamp ntpData[24] = parseInt(ntpTimestamp.substr(0, 8), 2); ntpData[25] = parseInt(ntpTimestamp.substr(8, 8), 2); ntpData[26] = parseInt(ntpTimestamp.substr(16, 8), 2); ntpData[27] = parseInt(ntpTimestamp.substr(24, 8), 2); ntpData[28] = parseInt(ntpTimestamp.substr(32, 8), 2); ntpData[29] = parseInt(ntpTimestamp.substr(40, 8), 2); ntpData[30] = parseInt(ntpTimestamp.substr(48, 8), 2); ntpData[31] = parseInt(ntpTimestamp.substr(56, 8), 2); // transmit timestamp ntpData[40] = parseInt(ntpTimestamp.substr(0, 8), 2); ntpData[41] = parseInt(ntpTimestamp.substr(8, 8), 2); ntpData[42] = parseInt(ntpTimestamp.substr(16, 8), 2); ntpData[43] = parseInt(ntpTimestamp.substr(24, 8), 2); ntpData[44] = parseInt(ntpTimestamp.substr(32, 8), 2); ntpData[45] = parseInt(ntpTimestamp.substr(40, 8), 2); ntpData[46] = parseInt(ntpTimestamp.substr(48, 8), 2); ntpData[47] = parseInt(ntpTimestamp.substr(56, 8), 2); return Buffer.from(ntpData); } private static cleanup(client: dgram.Socket) { try { client.close(); } catch (e) { // ignore, as we just want to cleanup } } getNetworkTime(server: string, port = 123): Promise { return new Promise((resolve, reject) => { const client = dgram.createSocket("udp4"); let hasFinished = false; const errorCallback = (err: Error) => { if (timeoutHandler) { clearTimeout(timeoutHandler); timeoutHandler = null; } if (hasFinished) { return; } NtpTimeSync.cleanup(client); hasFinished = true; reject(err); }; client.on("error", (err) => errorCallback); // setup timeout let timeoutHandler = setTimeout(() => { errorCallback(new Error("Timeout waiting for NTP response.")); }, this.options.replyTimeout); client.send(this.createPacket(), port, server, (err) => { if (hasFinished) { return; } if (err) { errorCallback(err); return; } client.once("message", function (msg) { if (hasFinished) { return; } clearTimeout(timeoutHandler); timeoutHandler = null; client.close(); const result: NtpReceivedPacket = { ...NtpPacketParser.parse(msg), destinationTimestamp: new Date(), }; hasFinished = true; resolve(result); }); }); }); } /** * Test if response is acceptable for synchronization */ private acceptResponse(data: Partial) { /* * Format error */ if (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 >= this.options.ntpDefaults.maxStratum) { throw new Error("Stratum error: Remote clock is unsynchronized"); } /* * Verify valid root distance. */ 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.getTime() > new Date().getTime()) { throw new Error("Format error: Origin timestamp is from the future"); } } /** * Average for a list of numbers */ private static avg(values: number[]): number { const sum = values.reduce(function (sum, value) { return sum + value; }, 0); return sum / values.length; } /** * Standard deviation for a list of numbers */ private static stdDev(values: number[]): number { const avg = this.avg(values); const squareDiffs = values.map(function (value) { const diff = value - avg; return diff * diff; }); return Math.sqrt(this.avg(squareDiffs)); } }