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:
Henri Cook
2026-07-17 01:37:46 +01:00
committed by GitHub
parent 937bfa38af
commit 73f199f4bd
8 changed files with 575 additions and 5 deletions
@@ -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");
});
};
+3
View File
@@ -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,
+202
View File
@@ -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,
};
+3
View File
@@ -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;
+2
View File
@@ -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");
+7
View File
@@ -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",
+80 -5
View File
@@ -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
+263
View File
@@ -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");
}
);
});