feat: add NTP monitor type for network time protocol monitoring

Adds a new monitor type that queries NTP servers via UDP, parses the
response per RFC 5905, and checks stratum, time offset, and root
dispersion against configurable thresholds. Includes knex migration,
full backend test suite, and frontend UI in the Specific Monitor Type
category.

Resolves #5028
pull/7214/head
Henri Cook 2 months ago
parent 42e8b8fbbb
commit 2f7ae74847

@ -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");
});
};

@ -201,6 +201,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,184 @@
const { MonitorType } = require("./monitor-type");
const { UP } = require("../../src/util");
const dayjs = require("dayjs");
const dgram = require("dgram");
/**
* 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) => {
const client = dgram.createSocket(hostname.includes(":") ? "udp6" : "udp4");
const ntpPacket = this.createNTPPacket();
const NTP_EPOCH_OFFSET_MS = 2208988800000;
const t1 = Date.now() + NTP_EPOCH_OFFSET_MS;
const timeoutHandle = setTimeout(() => {
client.close();
reject(new Error("NTP request timed out"));
}, timeout);
client.on("error", (err) => {
clearTimeout(timeoutHandle);
client.close();
reject(new Error(`UDP socket error: ${err.message}`));
});
client.on("message", (msg) => {
clearTimeout(timeoutHandle);
const t4 = Date.now() + NTP_EPOCH_OFFSET_MS;
try {
const result = this.parseNTPResponse(msg, t1, t4);
client.close();
resolve(result);
} catch (err) {
client.close();
reject(err);
}
});
client.send(ntpPacket, 0, ntpPacket.length, port, hostname, (err) => {
if (err) {
clearTimeout(timeoutHandle);
client.close();
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,
};

@ -931,6 +931,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;

@ -132,6 +132,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;
@ -584,4 +585,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");

@ -1528,5 +1528,12 @@
"teltonikaModem": "Modem Id",
"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."
"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."
}

@ -81,6 +81,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
@ -457,7 +458,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' ||
@ -470,7 +471,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"
>
@ -671,7 +673,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' ||
@ -682,6 +684,7 @@
monitor.type === 'smtp' ||
monitor.type === 'snmp' ||
monitor.type === 'sip-options' ||
monitor.type === 'ntp' ||
(monitor.type === 'globalping' &&
monitor.subtype === 'ping' &&
monitor.protocol === 'TCP')
@ -822,6 +825,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">
@ -2936,6 +2996,9 @@ const monitorDefaults = {
rabbitmqPassword: "",
conditions: [],
system_service_name: "",
ntpStratumThreshold: 5,
ntpTimeOffsetThreshold: 1000,
ntpRootDispersionThreshold: 500,
};
export default {
@ -3384,14 +3447,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 {

@ -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");
}
);
});
Loading…
Cancel
Save