mirror of
https://github.com/louislam/uptime-kuma.git
synced 2026-08-07 09:14:58 +00:00
feat: add NTP monitor type for network time protocol monitoring (#7214)
Co-authored-by: Henri Cook <henri.cook@linklaters.com> Co-authored-by: Frank Elsinga <frank@elsinga.de>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.alterTable("monitor", function (table) {
|
||||
table.integer("ntp_stratum_threshold").defaultTo(5);
|
||||
table.integer("ntp_time_offset_threshold").defaultTo(1000);
|
||||
table.integer("ntp_root_dispersion_threshold").defaultTo(500);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.alterTable("monitor", function (table) {
|
||||
table.dropColumn("ntp_stratum_threshold");
|
||||
table.dropColumn("ntp_time_offset_threshold");
|
||||
table.dropColumn("ntp_root_dispersion_threshold");
|
||||
});
|
||||
};
|
||||
@@ -200,6 +200,9 @@ class Monitor extends BeanModel {
|
||||
smtpSecurity: this.smtpSecurity,
|
||||
rabbitmqNodes: JSON.parse(this.rabbitmqNodes),
|
||||
conditions: JSON.parse(this.conditions),
|
||||
ntpStratumThreshold: this.ntp_stratum_threshold,
|
||||
ntpTimeOffsetThreshold: this.ntp_time_offset_threshold,
|
||||
ntpRootDispersionThreshold: this.ntp_root_dispersion_threshold,
|
||||
ipFamily: this.ipFamily,
|
||||
expectedTlsAlert: this.expected_tls_alert,
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
const { MonitorType } = require("./monitor-type");
|
||||
const { UP } = require("../../src/util");
|
||||
const dayjs = require("dayjs");
|
||||
const dgram = require("dgram");
|
||||
const dns = require("dns");
|
||||
|
||||
/**
|
||||
* NTP Monitor Type
|
||||
* Monitors NTP servers for availability, time accuracy, and quality metrics
|
||||
*/
|
||||
class NTPMonitorType extends MonitorType {
|
||||
name = "ntp";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async check(monitor, heartbeat, _server) {
|
||||
const startTime = dayjs().valueOf();
|
||||
|
||||
if (!monitor.hostname) {
|
||||
throw new Error("Hostname is required");
|
||||
}
|
||||
|
||||
const port = monitor.port || 123;
|
||||
const timeout = (monitor.timeout || 10) * 1000;
|
||||
|
||||
const ntpResult = await this.queryNTP(monitor.hostname, port, timeout);
|
||||
|
||||
heartbeat.ping = dayjs().valueOf() - startTime;
|
||||
|
||||
const { stratum, offset, rootDispersion, refid, roundTripDelay } = ntpResult;
|
||||
|
||||
heartbeat.msg = `Stratum: ${stratum}, RefID: ${refid}, Offset: ${offset.toFixed(3)}ms, Delay: ${roundTripDelay.toFixed(3)}ms, Dispersion: ${rootDispersion.toFixed(3)}ms`;
|
||||
|
||||
if (stratum === 16) {
|
||||
throw new Error("NTP server is unsynchronized (stratum 16)");
|
||||
}
|
||||
|
||||
const stratumThreshold = monitor.ntp_stratum_threshold || 5;
|
||||
if (stratum >= stratumThreshold) {
|
||||
throw new Error(`Stratum ${stratum} meets or exceeds threshold ${stratumThreshold}`);
|
||||
}
|
||||
|
||||
const offsetThreshold = monitor.ntp_time_offset_threshold || 1000;
|
||||
if (Math.abs(offset) >= offsetThreshold) {
|
||||
throw new Error(`Time offset ${offset.toFixed(3)}ms exceeds threshold ${offsetThreshold}ms`);
|
||||
}
|
||||
|
||||
const dispersionThreshold = monitor.ntp_root_dispersion_threshold || 500;
|
||||
if (rootDispersion >= dispersionThreshold) {
|
||||
throw new Error(
|
||||
`Root dispersion ${rootDispersion.toFixed(3)}ms exceeds threshold ${dispersionThreshold}ms`
|
||||
);
|
||||
}
|
||||
|
||||
heartbeat.status = UP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query an NTP server via UDP
|
||||
* @param {string} hostname NTP server hostname or IP
|
||||
* @param {number} port NTP server port (usually 123)
|
||||
* @param {number} timeout Timeout in milliseconds
|
||||
* @returns {Promise<object>} Parsed NTP response data
|
||||
*/
|
||||
queryNTP(hostname, port, timeout) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let client = null;
|
||||
let settled = false;
|
||||
let timeoutHandle = null;
|
||||
|
||||
const finish = (fn, value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeoutHandle);
|
||||
if (client) {
|
||||
client.close();
|
||||
}
|
||||
fn(value);
|
||||
};
|
||||
|
||||
timeoutHandle = setTimeout(() => {
|
||||
finish(reject, new Error("NTP request timed out"));
|
||||
}, timeout);
|
||||
|
||||
dns.lookup(hostname, (dnsErr, address, family) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
if (dnsErr) {
|
||||
finish(reject, new Error(`DNS lookup failed for ${hostname}: ${dnsErr.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
client = dgram.createSocket(family === 6 ? "udp6" : "udp4");
|
||||
const ntpPacket = this.createNTPPacket();
|
||||
|
||||
const NTP_EPOCH_OFFSET_MS = 2208988800000;
|
||||
const t1 = Date.now() + NTP_EPOCH_OFFSET_MS;
|
||||
|
||||
client.on("error", (err) => {
|
||||
finish(reject, new Error(`UDP socket error: ${err.message}`));
|
||||
});
|
||||
|
||||
client.on("message", (msg) => {
|
||||
const t4 = Date.now() + NTP_EPOCH_OFFSET_MS;
|
||||
try {
|
||||
const result = this.parseNTPResponse(msg, t1, t4);
|
||||
finish(resolve, result);
|
||||
} catch (err) {
|
||||
finish(reject, err);
|
||||
}
|
||||
});
|
||||
|
||||
client.send(ntpPacket, 0, ntpPacket.length, port, address, (err) => {
|
||||
if (err) {
|
||||
finish(reject, new Error(`Failed to send NTP request: ${err.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an NTP version 3 client request packet (48 bytes)
|
||||
* Byte 0: LI=0 (no warning), VN=3 (NTPv3), Mode=3 (client) = 0x1B
|
||||
* @returns {Buffer} NTP request packet
|
||||
*/
|
||||
createNTPPacket() {
|
||||
const packet = Buffer.alloc(48);
|
||||
packet[0] = 0x1b;
|
||||
return packet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an NTP response packet and calculate offset/delay
|
||||
* @param {Buffer} msg NTP response packet (48+ bytes)
|
||||
* @param {number} t1 Client originate timestamp in ms since NTP epoch (1900)
|
||||
* @param {number} t4 Client receive timestamp in ms since NTP epoch (1900)
|
||||
* @returns {object} Parsed NTP data including stratum, offset, refid, rootDispersion, roundTripDelay
|
||||
* @throws {Error} If the packet is shorter than 48 bytes
|
||||
*/
|
||||
parseNTPResponse(msg, t1, t4) {
|
||||
if (msg.length < 48) {
|
||||
throw new Error(`Invalid NTP response: expected 48+ bytes, got ${msg.length}`);
|
||||
}
|
||||
|
||||
const leapIndicator = (msg[0] >> 6) & 0x03;
|
||||
const stratum = msg[1];
|
||||
|
||||
// Root dispersion: 32-bit unsigned fixed-point at offset 8, unit = seconds
|
||||
const rootDispersionRaw = msg.readUInt32BE(8);
|
||||
const rootDispersion = (rootDispersionRaw / 65536) * 1000;
|
||||
|
||||
// Reference ID: ASCII for stratum 0-1, IPv4 address for stratum 2+
|
||||
let refid;
|
||||
if (stratum <= 1) {
|
||||
refid = msg.toString("ascii", 12, 16).replace(/\0/g, "").trim();
|
||||
} else {
|
||||
refid = `${msg[12]}.${msg[13]}.${msg[14]}.${msg[15]}`;
|
||||
}
|
||||
|
||||
// Server receive timestamp (T2) at offset 32
|
||||
const t2 = this.readNTPTimestamp(msg, 32);
|
||||
// Server transmit timestamp (T3) at offset 40
|
||||
const t3 = this.readNTPTimestamp(msg, 40);
|
||||
|
||||
// RFC 5905 offset and delay calculations
|
||||
// offset = ((T2 - T1) + (T3 - T4)) / 2
|
||||
// delay = (T4 - T1) - (T3 - T2)
|
||||
const offset = (t2 - t1 + (t3 - t4)) / 2;
|
||||
const roundTripDelay = t4 - t1 - (t3 - t2);
|
||||
|
||||
return {
|
||||
leapIndicator,
|
||||
stratum,
|
||||
rootDispersion,
|
||||
refid,
|
||||
offset,
|
||||
roundTripDelay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a 64-bit NTP timestamp from a buffer and convert to milliseconds since NTP epoch
|
||||
* NTP timestamps are 32 bits of seconds + 32 bits of fractional seconds since 1900-01-01
|
||||
* @param {Buffer} buf Packet buffer
|
||||
* @param {number} offset Byte offset in the buffer
|
||||
* @returns {number} Timestamp in milliseconds since NTP epoch (1900)
|
||||
*/
|
||||
readNTPTimestamp(buf, offset) {
|
||||
const seconds = buf.readUInt32BE(offset);
|
||||
const fraction = buf.readUInt32BE(offset + 4);
|
||||
return seconds * 1000 + (fraction * 1000) / 0x100000000;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
NTPMonitorType,
|
||||
};
|
||||
@@ -951,6 +951,9 @@ let needSetup = false;
|
||||
bean.manual_status = monitor.manual_status;
|
||||
bean.system_service_name = monitor.system_service_name;
|
||||
bean.expected_tls_alert = monitor.expectedTlsAlert;
|
||||
bean.ntp_stratum_threshold = monitor.ntpStratumThreshold;
|
||||
bean.ntp_time_offset_threshold = monitor.ntpTimeOffsetThreshold;
|
||||
bean.ntp_root_dispersion_threshold = monitor.ntpRootDispersionThreshold;
|
||||
|
||||
// ping advanced options
|
||||
bean.ping_numeric = monitor.ping_numeric;
|
||||
|
||||
@@ -134,6 +134,7 @@ class UptimeKumaServer {
|
||||
UptimeKumaServer.monitorTypeList["sqlserver"] = new MssqlMonitorType();
|
||||
UptimeKumaServer.monitorTypeList["mysql"] = new MysqlMonitorType();
|
||||
UptimeKumaServer.monitorTypeList["oracledb"] = new OracleDbMonitorType();
|
||||
UptimeKumaServer.monitorTypeList["ntp"] = new NTPMonitorType();
|
||||
|
||||
// Allow all CORS origins (polling) in development
|
||||
let cors = undefined;
|
||||
@@ -588,4 +589,5 @@ const { SystemServiceMonitorType } = require("./monitor-types/system-service");
|
||||
const { MssqlMonitorType } = require("./monitor-types/mssql");
|
||||
const { MysqlMonitorType } = require("./monitor-types/mysql");
|
||||
const { OracleDbMonitorType } = require("./monitor-types/oracledb");
|
||||
const { NTPMonitorType } = require("./monitor-types/ntp");
|
||||
const Monitor = require("./model/monitor");
|
||||
|
||||
@@ -1566,6 +1566,13 @@
|
||||
"teltonikaModemHelptext": "The id of the SMS modem, must be in the format {0}. Refer to https://developers.teltonika-networks.com/reference/ for guidance.",
|
||||
"teltonikaPhoneNumber": "Phone number",
|
||||
"teltonikaPhoneNumberHelptext": "The number must be in the international format {0}, {1}. Only one number is allowed.",
|
||||
"ntpThresholdsTitle": "NTP Thresholds",
|
||||
"ntpStratumThreshold": "Stratum Threshold",
|
||||
"ntpStratumThresholdHelp": "Mark as down if stratum is at or above this value. Stratum indicates time source quality (1=GPS/atomic clock, 16=unsynchronized).",
|
||||
"ntpOffsetThreshold": "Time Offset Threshold (ms)",
|
||||
"ntpOffsetThresholdHelp": "Mark as down if time offset exceeds this value in milliseconds.",
|
||||
"ntpDispersionThreshold": "Root Dispersion Threshold (ms)",
|
||||
"ntpDispersionThresholdHelp": "Mark as down if root dispersion exceeds this value. Dispersion indicates the server's clock accuracy.",
|
||||
"VKTeams Bot Token Description": "You can find out how to get a bot token and other details at {0}.",
|
||||
"VKTeams Base Url Description": "VKTeams base API URL, may differ in On-Premise solution. Default: https://myteam.mail.ru",
|
||||
"VKTeams Chat Id Description": "For users, this is their email address. For groups and channels, get the ID from their settings and append {'@'}chat.agent",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
<option value="json-query">HTTP(s) - {{ $t("Json Query") }}</option>
|
||||
<option value="kafka-producer">Kafka Producer</option>
|
||||
<option value="mqtt">MQTT</option>
|
||||
<option value="ntp">NTP</option>
|
||||
<option value="rabbitmq">RabbitMQ</option>
|
||||
<option v-if="!$root.info.isContainer" value="sip-options">
|
||||
SIP Options Ping
|
||||
@@ -475,7 +476,7 @@
|
||||
</template>
|
||||
|
||||
<!-- Hostname -->
|
||||
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP / SMTP / SIP Options only -->
|
||||
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP / SMTP / SIP Options / NTP only -->
|
||||
<div
|
||||
v-if="
|
||||
monitor.type === 'port' ||
|
||||
@@ -488,7 +489,8 @@
|
||||
monitor.type === 'tailscale-ping' ||
|
||||
monitor.type === 'smtp' ||
|
||||
monitor.type === 'snmp' ||
|
||||
monitor.type === 'sip-options'
|
||||
monitor.type === 'sip-options' ||
|
||||
monitor.type === 'ntp'
|
||||
"
|
||||
class="my-3"
|
||||
>
|
||||
@@ -689,7 +691,7 @@
|
||||
</template>
|
||||
|
||||
<!-- Port -->
|
||||
<!-- For TCP Port / Steam / MQTT / Radius Type / SNMP / SIP Options -->
|
||||
<!-- For TCP Port / Steam / MQTT / Radius Type / SNMP / SIP Options / NTP -->
|
||||
<div
|
||||
v-if="
|
||||
monitor.type === 'port' ||
|
||||
@@ -700,6 +702,7 @@
|
||||
monitor.type === 'smtp' ||
|
||||
monitor.type === 'snmp' ||
|
||||
monitor.type === 'sip-options' ||
|
||||
monitor.type === 'ntp' ||
|
||||
(monitor.type === 'globalping' &&
|
||||
monitor.subtype === 'ping' &&
|
||||
monitor.protocol === 'TCP')
|
||||
@@ -855,6 +858,63 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- NTP Configuration -->
|
||||
<template v-if="monitor.type === 'ntp'">
|
||||
<h4 class="mt-4">{{ $t("ntpThresholdsTitle") }}</h4>
|
||||
|
||||
<div class="my-3">
|
||||
<label for="ntp-stratum-threshold" class="form-label">
|
||||
{{ $t("ntpStratumThreshold") }}
|
||||
</label>
|
||||
<input
|
||||
id="ntp-stratum-threshold"
|
||||
v-model.number="monitor.ntpStratumThreshold"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="1"
|
||||
max="15"
|
||||
placeholder="5"
|
||||
/>
|
||||
<div class="form-text">
|
||||
{{ $t("ntpStratumThresholdHelp") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<label for="ntp-offset-threshold" class="form-label">
|
||||
{{ $t("ntpOffsetThreshold") }}
|
||||
</label>
|
||||
<input
|
||||
id="ntp-offset-threshold"
|
||||
v-model.number="monitor.ntpTimeOffsetThreshold"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="1"
|
||||
placeholder="1000"
|
||||
/>
|
||||
<div class="form-text">
|
||||
{{ $t("ntpOffsetThresholdHelp") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<label for="ntp-dispersion-threshold" class="form-label">
|
||||
{{ $t("ntpDispersionThreshold") }}
|
||||
</label>
|
||||
<input
|
||||
id="ntp-dispersion-threshold"
|
||||
v-model.number="monitor.ntpRootDispersionThreshold"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="1"
|
||||
placeholder="500"
|
||||
/>
|
||||
<div class="form-text">
|
||||
{{ $t("ntpDispersionThresholdHelp") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Json Query -->
|
||||
<!-- For Json Query / SNMP -->
|
||||
<div v-if="monitor.type === 'json-query' || monitor.type === 'snmp'" class="my-3">
|
||||
@@ -3266,6 +3326,9 @@ const monitorDefaults = {
|
||||
rabbitmqPassword: "",
|
||||
conditions: [],
|
||||
system_service_name: "",
|
||||
ntpStratumThreshold: 5,
|
||||
ntpTimeOffsetThreshold: 1000,
|
||||
ntpRootDispersionThreshold: 500,
|
||||
};
|
||||
|
||||
export default {
|
||||
@@ -3721,14 +3784,26 @@ message HealthCheckResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// NTP servers may rate-limit frequent queries; default to 5 minutes
|
||||
if (this.monitor.type === "ntp" && (oldType || this.isAdd)) {
|
||||
this.monitor.interval = 300;
|
||||
}
|
||||
|
||||
// Set default port for DNS if not already defined
|
||||
if (!this.monitor.port || this.monitor.port === "53" || this.monitor.port === "1812") {
|
||||
if (
|
||||
!this.monitor.port ||
|
||||
this.monitor.port === "53" ||
|
||||
this.monitor.port === "1812" ||
|
||||
this.monitor.port === "123"
|
||||
) {
|
||||
if (this.monitor.type === "dns") {
|
||||
this.monitor.port = "53";
|
||||
} else if (this.monitor.type === "radius") {
|
||||
this.monitor.port = "1812";
|
||||
} else if (this.monitor.type === "snmp") {
|
||||
this.monitor.port = "161";
|
||||
} else if (this.monitor.type === "ntp") {
|
||||
this.monitor.port = "123";
|
||||
} else if (this.monitor.type === "globalping" && this.monitor.subtype === "ping") {
|
||||
this.monitor.port = "80";
|
||||
} else {
|
||||
@@ -4111,7 +4186,7 @@ message HealthCheckResponse {
|
||||
|
||||
// Validate hostname field input for various monitors
|
||||
if (
|
||||
["dns", "port", "ping", "steam", "gamedig", "radius", "tailscale-ping", "smtp", "snmp"].includes(
|
||||
["dns", "port", "ping", "steam", "gamedig", "radius", "tailscale-ping", "smtp", "snmp", "ntp"].includes(
|
||||
this.monitor.type
|
||||
) &&
|
||||
this.monitor.hostname
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
const { describe, test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { NTPMonitorType } = require("../../server/monitor-types/ntp");
|
||||
const { UP } = require("../../src/util");
|
||||
|
||||
describe("NTPMonitorType", () => {
|
||||
const ntp = new NTPMonitorType();
|
||||
|
||||
test("createNTPPacket() returns a 48-byte buffer with correct header", () => {
|
||||
const packet = ntp.createNTPPacket();
|
||||
assert.strictEqual(packet.length, 48);
|
||||
// LI=0, VN=3, Mode=3 => 0x1B
|
||||
assert.strictEqual(packet[0], 0x1b);
|
||||
// Rest should be zeros
|
||||
for (let i = 1; i < 48; i++) {
|
||||
assert.strictEqual(packet[i], 0, `byte ${i} should be zero`);
|
||||
}
|
||||
});
|
||||
|
||||
test("readNTPTimestamp() correctly converts NTP timestamp to milliseconds", () => {
|
||||
const buf = Buffer.alloc(8);
|
||||
// 1 second since NTP epoch
|
||||
buf.writeUInt32BE(1, 0);
|
||||
buf.writeUInt32BE(0, 4);
|
||||
assert.strictEqual(ntp.readNTPTimestamp(buf, 0), 1000);
|
||||
|
||||
// 0.5 seconds fractional
|
||||
buf.writeUInt32BE(0, 0);
|
||||
buf.writeUInt32BE(0x80000000, 4);
|
||||
assert.strictEqual(ntp.readNTPTimestamp(buf, 0), 500);
|
||||
});
|
||||
|
||||
test("parseNTPResponse() extracts correct fields from a valid packet", () => {
|
||||
// Construct a minimal valid NTP response packet
|
||||
const msg = Buffer.alloc(48);
|
||||
|
||||
// Byte 0: LI=0, VN=3, Mode=4 (server) => 00 011 100 = 0x1C
|
||||
msg[0] = 0x1c;
|
||||
// Stratum 2
|
||||
msg[1] = 2;
|
||||
|
||||
// Root dispersion at offset 8: 0.5 seconds = 0x00008000
|
||||
msg.writeUInt32BE(0x00008000, 8);
|
||||
|
||||
// Reference ID at offset 12: "GPS\0" for stratum 1 test not applicable here
|
||||
// For stratum 2, it's an IPv4 address
|
||||
msg[12] = 192;
|
||||
msg[13] = 168;
|
||||
msg[14] = 1;
|
||||
msg[15] = 1;
|
||||
|
||||
// Server receive timestamp (T2) at offset 32: 3912710400 seconds (approx 2024-01-01)
|
||||
msg.writeUInt32BE(3912710400, 32);
|
||||
msg.writeUInt32BE(0, 36);
|
||||
|
||||
// Server transmit timestamp (T3) at offset 40: same + 1ms
|
||||
msg.writeUInt32BE(3912710400, 40);
|
||||
msg.writeUInt32BE(4294967, 44); // ~1ms in fractional seconds
|
||||
|
||||
const t1 = 3912710400 * 1000; // Client originate in ms since NTP epoch
|
||||
const t4 = 3912710400 * 1000 + 50; // Client receive 50ms later
|
||||
|
||||
const result = ntp.parseNTPResponse(msg, t1, t4);
|
||||
|
||||
assert.strictEqual(result.stratum, 2);
|
||||
assert.strictEqual(result.leapIndicator, 0);
|
||||
assert.strictEqual(result.refid, "192.168.1.1");
|
||||
// Root dispersion: 0x8000 / 65536 * 1000 = 500ms
|
||||
assert.ok(
|
||||
Math.abs(result.rootDispersion - 500) < 0.1,
|
||||
`rootDispersion should be ~500ms, got ${result.rootDispersion}`
|
||||
);
|
||||
assert.strictEqual(typeof result.offset, "number");
|
||||
assert.strictEqual(typeof result.roundTripDelay, "number");
|
||||
});
|
||||
|
||||
test("parseNTPResponse() parses ASCII refid for stratum 1", () => {
|
||||
const msg = Buffer.alloc(48);
|
||||
msg[0] = 0x1c;
|
||||
msg[1] = 1; // Stratum 1
|
||||
msg.write("GPS\0", 12, "ascii");
|
||||
// Set timestamps to avoid NaN
|
||||
msg.writeUInt32BE(3912710400, 32);
|
||||
msg.writeUInt32BE(0, 36);
|
||||
msg.writeUInt32BE(3912710400, 40);
|
||||
msg.writeUInt32BE(0, 44);
|
||||
|
||||
const t1 = 3912710400 * 1000;
|
||||
const t4 = t1 + 10;
|
||||
|
||||
const result = ntp.parseNTPResponse(msg, t1, t4);
|
||||
assert.strictEqual(result.stratum, 1);
|
||||
assert.strictEqual(result.refid, "GPS");
|
||||
});
|
||||
|
||||
test("parseNTPResponse() rejects packets shorter than 48 bytes", () => {
|
||||
const short = Buffer.alloc(20);
|
||||
assert.throws(() => ntp.parseNTPResponse(short, 0, 0), /expected 48\+ bytes/);
|
||||
});
|
||||
|
||||
test("check() throws for stratum 16 (unsynchronized)", async () => {
|
||||
const monitor = {
|
||||
hostname: "localhost",
|
||||
port: 123,
|
||||
timeout: 5,
|
||||
ntp_stratum_threshold: 5,
|
||||
ntp_time_offset_threshold: 1000,
|
||||
ntp_root_dispersion_threshold: 500,
|
||||
};
|
||||
const heartbeat = {};
|
||||
|
||||
// Stub queryNTP to return stratum 16
|
||||
const originalQuery = ntp.queryNTP;
|
||||
ntp.queryNTP = async () => ({
|
||||
stratum: 16,
|
||||
offset: 0,
|
||||
rootDispersion: 10,
|
||||
refid: "INIT",
|
||||
roundTripDelay: 5,
|
||||
leapIndicator: 3,
|
||||
});
|
||||
|
||||
try {
|
||||
await assert.rejects(() => ntp.check(monitor, heartbeat, null), /unsynchronized.*stratum 16/);
|
||||
} finally {
|
||||
ntp.queryNTP = originalQuery;
|
||||
}
|
||||
});
|
||||
|
||||
test("check() throws when stratum exceeds threshold", async () => {
|
||||
const monitor = {
|
||||
hostname: "localhost",
|
||||
port: 123,
|
||||
timeout: 5,
|
||||
ntp_stratum_threshold: 2,
|
||||
ntp_time_offset_threshold: 1000,
|
||||
ntp_root_dispersion_threshold: 500,
|
||||
};
|
||||
const heartbeat = {};
|
||||
|
||||
const originalQuery = ntp.queryNTP;
|
||||
ntp.queryNTP = async () => ({
|
||||
stratum: 3,
|
||||
offset: 0.5,
|
||||
rootDispersion: 10,
|
||||
refid: "GPS",
|
||||
roundTripDelay: 5,
|
||||
leapIndicator: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
await assert.rejects(() => ntp.check(monitor, heartbeat, null), /Stratum 3 meets or exceeds threshold 2/);
|
||||
} finally {
|
||||
ntp.queryNTP = originalQuery;
|
||||
}
|
||||
});
|
||||
|
||||
test("check() throws when offset exceeds threshold", async () => {
|
||||
const monitor = {
|
||||
hostname: "localhost",
|
||||
port: 123,
|
||||
timeout: 5,
|
||||
ntp_stratum_threshold: 5,
|
||||
ntp_time_offset_threshold: 100,
|
||||
ntp_root_dispersion_threshold: 500,
|
||||
};
|
||||
const heartbeat = {};
|
||||
|
||||
const originalQuery = ntp.queryNTP;
|
||||
ntp.queryNTP = async () => ({
|
||||
stratum: 2,
|
||||
offset: -150.5,
|
||||
rootDispersion: 10,
|
||||
refid: "GPS",
|
||||
roundTripDelay: 5,
|
||||
leapIndicator: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
await assert.rejects(() => ntp.check(monitor, heartbeat, null), /Time offset.*exceeds threshold 100ms/);
|
||||
} finally {
|
||||
ntp.queryNTP = originalQuery;
|
||||
}
|
||||
});
|
||||
|
||||
test("check() throws when dispersion exceeds threshold", async () => {
|
||||
const monitor = {
|
||||
hostname: "localhost",
|
||||
port: 123,
|
||||
timeout: 5,
|
||||
ntp_stratum_threshold: 5,
|
||||
ntp_time_offset_threshold: 1000,
|
||||
ntp_root_dispersion_threshold: 50,
|
||||
};
|
||||
const heartbeat = {};
|
||||
|
||||
const originalQuery = ntp.queryNTP;
|
||||
ntp.queryNTP = async () => ({
|
||||
stratum: 2,
|
||||
offset: 1.5,
|
||||
rootDispersion: 100,
|
||||
refid: "GPS",
|
||||
roundTripDelay: 5,
|
||||
leapIndicator: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
await assert.rejects(() => ntp.check(monitor, heartbeat, null), /Root dispersion.*exceeds threshold 50ms/);
|
||||
} finally {
|
||||
ntp.queryNTP = originalQuery;
|
||||
}
|
||||
});
|
||||
|
||||
test("check() sets heartbeat UP when all thresholds pass", async () => {
|
||||
const monitor = {
|
||||
hostname: "localhost",
|
||||
port: 123,
|
||||
timeout: 5,
|
||||
ntp_stratum_threshold: 5,
|
||||
ntp_time_offset_threshold: 1000,
|
||||
ntp_root_dispersion_threshold: 500,
|
||||
};
|
||||
const heartbeat = {};
|
||||
|
||||
const originalQuery = ntp.queryNTP;
|
||||
ntp.queryNTP = async () => ({
|
||||
stratum: 2,
|
||||
offset: 1.5,
|
||||
rootDispersion: 10.2,
|
||||
refid: "GPS",
|
||||
roundTripDelay: 5.3,
|
||||
leapIndicator: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
await ntp.check(monitor, heartbeat, null);
|
||||
assert.strictEqual(heartbeat.status, UP);
|
||||
assert.match(heartbeat.msg, /Stratum: 2/);
|
||||
assert.match(heartbeat.msg, /RefID: GPS/);
|
||||
assert.match(heartbeat.msg, /Offset: 1\.500ms/);
|
||||
assert.match(heartbeat.msg, /Dispersion: 10\.200ms/);
|
||||
assert.strictEqual(typeof heartbeat.ping, "number");
|
||||
} finally {
|
||||
ntp.queryNTP = originalQuery;
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"queryNTP() can reach a public NTP server",
|
||||
{
|
||||
skip: !!process.env.CI,
|
||||
},
|
||||
async () => {
|
||||
const result = await ntp.queryNTP("time.google.com", 123, 10000);
|
||||
assert.strictEqual(typeof result.stratum, "number");
|
||||
assert.ok(result.stratum >= 1 && result.stratum <= 15, `stratum should be 1-15, got ${result.stratum}`);
|
||||
assert.strictEqual(typeof result.offset, "number");
|
||||
assert.strictEqual(typeof result.roundTripDelay, "number");
|
||||
assert.strictEqual(typeof result.rootDispersion, "number");
|
||||
assert.strictEqual(typeof result.refid, "string");
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user