From 2f7ae74847ba3d58bec7dafdb980fb2acabbf23f Mon Sep 17 00:00:00 2001 From: Henri Cook Date: Fri, 27 Mar 2026 07:49:27 +0000 Subject: [PATCH] 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 --- .../2026-03-27-0000-add-ntp-monitor.js | 15 + server/model/monitor.js | 3 + server/monitor-types/ntp.js | 184 ++++++++++++ server/server.js | 3 + server/uptime-kuma-server.js | 2 + src/lang/en.json | 9 +- src/pages/EditMonitor.vue | 83 +++++- test/backend-test/test-ntp.js | 263 ++++++++++++++++++ 8 files changed, 557 insertions(+), 5 deletions(-) create mode 100644 db/knex_migrations/2026-03-27-0000-add-ntp-monitor.js create mode 100644 server/monitor-types/ntp.js create mode 100644 test/backend-test/test-ntp.js diff --git a/db/knex_migrations/2026-03-27-0000-add-ntp-monitor.js b/db/knex_migrations/2026-03-27-0000-add-ntp-monitor.js new file mode 100644 index 000000000..81899dcca --- /dev/null +++ b/db/knex_migrations/2026-03-27-0000-add-ntp-monitor.js @@ -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"); + }); +}; diff --git a/server/model/monitor.js b/server/model/monitor.js index 2ad572e53..438e0561c 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -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, diff --git a/server/monitor-types/ntp.js b/server/monitor-types/ntp.js new file mode 100644 index 000000000..e90bb59e9 --- /dev/null +++ b/server/monitor-types/ntp.js @@ -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} 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, +}; diff --git a/server/server.js b/server/server.js index 5b3ea74d0..2167f58db 100644 --- a/server/server.js +++ b/server/server.js @@ -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; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index a1ee80485..3f2743777 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -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"); diff --git a/src/lang/en.json b/src/lang/en.json index ba138250e..072f9eb10 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -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." } diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index ac152a87d..5304df591 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -81,6 +81,7 @@ +