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 @@ +