Merge branch 'master' into 1.23.X-merge-to-2.X.X

# Conflicts:
#	docker/debian-base.dockerfile
#	package-lock.json
#	server/database.js
#	server/model/monitor.js
#	server/uptime-kuma-server.js
#	server/util-server.js
This commit is contained in:
Louis Lam 2023-11-13 21:15:51 +08:00
commit ace1fe00c2
316 changed files with 9487 additions and 4997 deletions

View file

@ -2,10 +2,10 @@ const https = require("https");
const dayjs = require("dayjs");
const axios = require("axios");
const { Prometheus } = require("../prometheus");
const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND,
const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND,
SQL_DATETIME_FORMAT, isDev, sleep, getRandomInt
} = require("../../src/util");
const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery,
const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery,
redisPingAsync, mongodbPing, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal
} = require("../util-server");
const { R } = require("redbean-node");
@ -18,10 +18,10 @@ const apicache = require("../modules/apicache");
const { UptimeKumaServer } = require("../uptime-kuma-server");
const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent");
const { DockerHost } = require("../docker");
const { UptimeCacheList } = require("../uptime-cache-list");
const Gamedig = require("gamedig");
const jsonata = require("jsonata");
const jwt = require("jsonwebtoken");
const { UptimeCalculator } = require("../uptime-calculator");
const rootCertificates = rootCertificatesFingerprints();
@ -35,9 +35,12 @@ const rootCertificates = rootCertificatesFingerprints();
class Monitor extends BeanModel {
/**
* Return an object that ready to parse to JSON for public
* Only show necessary data to public
* @returns {Object}
* Return an object that ready to parse to JSON for public Only show
* necessary data to public
* @param {boolean} showTags Include tags in JSON
* @param {boolean} certExpiry Include certificate expiry info in
* JSON
* @returns {object} Object ready to parse
*/
async toPublicJSON(showTags = false, certExpiry = false) {
let obj = {
@ -66,7 +69,9 @@ class Monitor extends BeanModel {
/**
* Return an object that ready to parse to JSON
* @returns {Object}
* @param {boolean} includeSensitiveData Include sensitive data in
* JSON
* @returns {object} Object ready to parse
*/
async toJSON(includeSensitiveData = true) {
@ -184,9 +189,9 @@ class Monitor extends BeanModel {
}
/**
* Checks if the monitor is active based on itself and its parents
* @returns {Promise<Boolean>}
*/
* Checks if the monitor is active based on itself and its parents
* @returns {Promise<boolean>} Is the monitor active?
*/
async isActive() {
const parentActive = await Monitor.isParentActive(this.id);
@ -195,7 +200,8 @@ class Monitor extends BeanModel {
/**
* Get all tags applied to this monitor
* @returns {Promise<LooseObject<any>[]>}
* @returns {Promise<LooseObject<any>[]>} List of tags on the
* monitor
*/
async getTags() {
return await R.getAll("SELECT mt.*, tag.name, tag.color FROM monitor_tag mt JOIN tag ON mt.tag_id = tag.id WHERE mt.monitor_id = ? ORDER BY tag.name", [ this.id ]);
@ -204,7 +210,8 @@ class Monitor extends BeanModel {
/**
* Gets certificate expiry for this monitor
* @param {number} monitorID ID of monitor to send
* @returns {Promise<LooseObject<any>>}
* @returns {Promise<LooseObject<any>>} Certificate expiry info for
* monitor
*/
async getCertExpiry(monitorID) {
let tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [
@ -229,7 +236,9 @@ class Monitor extends BeanModel {
/**
* Encode user and password to Base64 encoding
* for HTTP "basic" auth, as per RFC-7617
* @returns {string}
* @param {string} user Username to encode
* @param {string} pass Password to encode
* @returns {string} Encoded username:password
*/
encodeBase64(user, pass) {
return Buffer.from(user + ":" + pass).toString("base64");
@ -237,7 +246,7 @@ class Monitor extends BeanModel {
/**
* Is the TLS expiry notification enabled?
* @returns {boolean}
* @returns {boolean} Enabled?
*/
isEnabledExpiryNotification() {
return Boolean(this.expiryNotification);
@ -245,7 +254,7 @@ class Monitor extends BeanModel {
/**
* Parse to boolean
* @returns {boolean}
* @returns {boolean} Should TLS errors be ignored?
*/
getIgnoreTls() {
return Boolean(this.ignoreTls);
@ -253,7 +262,7 @@ class Monitor extends BeanModel {
/**
* Parse to boolean
* @returns {boolean}
* @returns {boolean} Is the monitor in upside down mode?
*/
isUpsideDown() {
return Boolean(this.upsideDown);
@ -261,7 +270,7 @@ class Monitor extends BeanModel {
/**
* Parse to boolean
* @returns {boolean}
* @returns {boolean} Invert keyword match?
*/
isInvertKeyword() {
return Boolean(this.invertKeyword);
@ -269,7 +278,7 @@ class Monitor extends BeanModel {
/**
* Parse to boolean
* @returns {boolean}
* @returns {boolean} Enable TLS for gRPC?
*/
getGrpcEnableTls() {
return Boolean(this.grpcEnableTls);
@ -277,12 +286,16 @@ class Monitor extends BeanModel {
/**
* Get accepted status codes
* @returns {Object}
* @returns {object} Accepted status codes
*/
getAcceptedStatuscodes() {
return JSON.parse(this.accepted_statuscodes_json);
}
/**
* Get if game dig should only use the port which was provided
* @returns {boolean} gamedig should only use the provided port
*/
getGameDigGivenPortOnly() {
return Boolean(this.gamedigGivenPortOnly);
}
@ -306,6 +319,7 @@ class Monitor extends BeanModel {
/**
* Start monitor
* @param {Server} io Socket server instance
* @returns {void}
*/
start(io) {
let previousBeat = null;
@ -360,13 +374,6 @@ class Monitor extends BeanModel {
bean.status = flipStatus(bean.status);
}
// Duration
if (!isFirstBeat) {
bean.duration = dayjs(bean.time).diff(dayjs(previousBeat.time), "second");
} else {
bean.duration = 0;
}
// Runtime patch timeout if it is 0
// See https://github.com/louislam/uptime-kuma/pull/3961#issuecomment-1804149144
if (this.timeout <= 0) {
@ -458,6 +465,9 @@ class Monitor extends BeanModel {
} catch (e) {
throw new Error("Your JSON body is invalid. " + e.message);
}
} else if (this.httpBodyEncoding === "form") {
bodyValue = this.body;
contentType = "application/x-www-form-urlencoded";
} else if (this.httpBodyEncoding === "xml") {
bodyValue = this.body;
contentType = "text/xml; charset=utf-8";
@ -471,7 +481,6 @@ class Monitor extends BeanModel {
timeout: this.timeout * 1000,
headers: {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"User-Agent": "Uptime-Kuma/" + version,
...(contentType ? { "Content-Type": contentType } : {}),
...(basicAuthHeader),
...(oauth2AuthHeader),
@ -609,46 +618,6 @@ class Monitor extends BeanModel {
bean.ping = await ping(this.hostname, this.packetSize);
bean.msg = "";
bean.status = UP;
} else if (this.type === "dns") {
let startTime = dayjs().valueOf();
let dnsMessage = "";
let dnsRes = await dnsResolve(this.hostname, this.dns_resolve_server, this.port, this.dns_resolve_type);
bean.ping = dayjs().valueOf() - startTime;
if (this.dns_resolve_type === "A" || this.dns_resolve_type === "AAAA" || this.dns_resolve_type === "TXT" || this.dns_resolve_type === "PTR") {
dnsMessage += "Records: ";
dnsMessage += dnsRes.join(" | ");
} else if (this.dns_resolve_type === "CNAME") {
dnsMessage += dnsRes[0];
} else if (this.dns_resolve_type === "CAA") {
dnsMessage += dnsRes[0].issue;
} else if (this.dns_resolve_type === "MX") {
dnsRes.forEach(record => {
dnsMessage += `Hostname: ${record.exchange} - Priority: ${record.priority} | `;
});
dnsMessage = dnsMessage.slice(0, -2);
} else if (this.dns_resolve_type === "NS") {
dnsMessage += "Servers: ";
dnsMessage += dnsRes.join(" | ");
} else if (this.dns_resolve_type === "SOA") {
dnsMessage += `NS-Name: ${dnsRes.nsname} | Hostmaster: ${dnsRes.hostmaster} | Serial: ${dnsRes.serial} | Refresh: ${dnsRes.refresh} | Retry: ${dnsRes.retry} | Expire: ${dnsRes.expire} | MinTTL: ${dnsRes.minttl}`;
} else if (this.dns_resolve_type === "SRV") {
dnsRes.forEach(record => {
dnsMessage += `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight} | `;
});
dnsMessage = dnsMessage.slice(0, -2);
}
if (this.dnsLastResult !== dnsMessage && dnsMessage !== undefined) {
R.exec("UPDATE `monitor` SET dns_last_result = ? WHERE id = ? ", [
dnsMessage,
this.id
]);
}
bean.msg = dnsMessage;
bean.status = UP;
} else if (this.type === "push") { // Type: Push
log.debug("monitor", `[${this.name}] Checking monitor at ${dayjs().format("YYYY-MM-DD HH:mm:ss.SSS")}`);
const bufferTime = 1000; // 1s buffer to accommodate clock differences
@ -692,7 +661,6 @@ class Monitor extends BeanModel {
timeout: this.timeout * 1000,
headers: {
"Accept": "*/*",
"User-Agent": "Uptime-Kuma/" + version,
},
httpsAgent: CacheableDnsHttpAgent.getHttpsAgent({
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
@ -744,7 +712,6 @@ class Monitor extends BeanModel {
timeout: this.interval * 1000 * 0.8,
headers: {
"Accept": "*/*",
"User-Agent": "Uptime-Kuma/" + version,
},
httpsAgent: CacheableDnsHttpAgent.getHttpsAgent({
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
@ -1000,11 +967,17 @@ class Monitor extends BeanModel {
log.warn("monitor", `Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type} | Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`);
}
// Calculate uptime
let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(this.id);
let endTimeDayjs = await uptimeCalculator.update(bean.status, parseFloat(bean.ping));
bean.end_time = R.isoDateTimeMillis(endTimeDayjs);
// Send to frontend
log.debug("monitor", `[${this.name}] Send to socket`);
UptimeCacheList.clearCache(this.id);
io.to(this.user_id).emit("heartbeat", bean.toJSON());
Monitor.sendStats(io, this.id, this.user_id);
// Store to database
log.debug("monitor", `[${this.name}] Store`);
await R.store(bean);
@ -1015,7 +988,15 @@ class Monitor extends BeanModel {
if (! this.isStop) {
log.debug("monitor", `[${this.name}] SetTimeout for next check.`);
this.heartbeatInterval = setTimeout(safeBeat, beatInterval * 1000);
let intervalRemainingMs = Math.max(
1,
beatInterval * 1000 - dayjs().diff(dayjs.utc(bean.time))
);
log.debug("monitor", `[${this.name}] Next heartbeat in: ${intervalRemainingMs}ms`);
this.heartbeatInterval = setTimeout(safeBeat, intervalRemainingMs);
this.lastScheduleBeatTime = dayjs();
} else {
log.info("monitor", `[${this.name}] isStop = true, no next check.`);
@ -1023,7 +1004,10 @@ class Monitor extends BeanModel {
};
/** Get a heartbeat and handle errors */
/**
* Get a heartbeat and handle errors7
* @returns {void}
*/
const safeBeat = async () => {
try {
this.lastStartBeatTime = dayjs();
@ -1056,10 +1040,10 @@ class Monitor extends BeanModel {
/**
* Make a request using axios
* @param {Object} options Options for Axios
* @param {object} options Options for Axios
* @param {boolean} finalCall Should this be the final call i.e
* don't retry on faliure
* @returns {Object} Axios response
* don't retry on failure
* @returns {object} Axios response
*/
async makeAxiosRequest(options, finalCall = false) {
try {
@ -1094,7 +1078,10 @@ class Monitor extends BeanModel {
}
}
/** Stop monitor */
/**
* Stop monitor
* @returns {void}
*/
stop() {
clearTimeout(this.heartbeatInterval);
this.isStop = true;
@ -1104,7 +1091,7 @@ class Monitor extends BeanModel {
/**
* Get prometheus instance
* @returns {Prometheus|undefined}
* @returns {Prometheus|undefined} Current prometheus instance
*/
getPrometheus() {
return this.prometheus;
@ -1114,7 +1101,7 @@ class Monitor extends BeanModel {
* Helper Method:
* returns URL object for further usage
* returns null if url is invalid
* @returns {(null|URL)}
* @returns {(null|URL)} Monitor URL
*/
getUrl() {
try {
@ -1126,7 +1113,7 @@ class Monitor extends BeanModel {
/**
* Example: http: or https:
* @returns {(null|string)}
* @returns {(null|string)} URL's protocol
*/
getURLProtocol() {
const url = this.getUrl();
@ -1139,8 +1126,8 @@ class Monitor extends BeanModel {
/**
* Store TLS info to database
* @param checkCertificateResult
* @returns {Promise<Object>}
* @param {object} checkCertificateResult Certificate to update
* @returns {Promise<object>} Updated certificate
*/
async updateTlsInfo(checkCertificateResult) {
let tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [
@ -1187,47 +1174,41 @@ class Monitor extends BeanModel {
* @param {Server} io Socket server instance
* @param {number} monitorID ID of monitor to send
* @param {number} userID ID of user to send to
* @returns {void}
*/
static async sendStats(io, monitorID, userID) {
const hasClients = getTotalClientInRoom(io, userID) > 0;
let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
if (hasClients) {
await Monitor.sendAvgPing(24, io, monitorID, userID);
await Monitor.sendUptime(24, io, monitorID, userID);
await Monitor.sendUptime(24 * 30, io, monitorID, userID);
// Send 24 hour average ping
let data24h = await uptimeCalculator.get24Hour();
io.to(userID).emit("avgPing", monitorID, (data24h.avgPing) ? Number(data24h.avgPing.toFixed(2)) : null);
// Send 24 hour uptime
io.to(userID).emit("uptime", monitorID, 24, data24h.uptime);
// Send 30 day uptime
let data30d = await uptimeCalculator.get30Day();
io.to(userID).emit("uptime", monitorID, 720, data30d.uptime);
// Send 1-year uptime
let data1y = await uptimeCalculator.get1Year();
io.to(userID).emit("uptime", monitorID, "1y", data1y.uptime);
// Send Cert Info
await Monitor.sendCertInfo(io, monitorID, userID);
} else {
log.debug("monitor", "No clients in the room, no need to send stats");
}
}
/**
* Send the average ping to user
* @param {number} duration Hours
*/
static async sendAvgPing(duration, io, monitorID, userID) {
const timeLogger = new TimeLogger();
let avgPing = parseInt(await R.getCell(`
SELECT AVG(ping)
FROM heartbeat
WHERE time > DATETIME('now', ? || ' hours')
AND ping IS NOT NULL
AND monitor_id = ? `, [
-duration,
monitorID,
]));
timeLogger.print(`[Monitor: ${monitorID}] avgPing`);
io.to(userID).emit("avgPing", monitorID, avgPing);
}
/**
* Send certificate information to client
* @param {Server} io Socket server instance
* @param {number} monitorID ID of monitor to send
* @param {number} userID ID of user to send to
* @returns {void}
*/
static async sendCertInfo(io, monitorID, userID) {
let tlsInfo = await R.findOne("monitor_tls_info", "monitor_id = ?", [
@ -1238,98 +1219,6 @@ class Monitor extends BeanModel {
}
}
/**
* Uptime with calculation
* Calculation based on:
* https://www.uptrends.com/support/kb/reporting/calculation-of-uptime-and-downtime
* @param {number} duration Hours
* @param {number} monitorID ID of monitor to calculate
*/
static async calcUptime(duration, monitorID, forceNoCache = false) {
if (!forceNoCache) {
let cachedUptime = UptimeCacheList.getUptime(monitorID, duration);
if (cachedUptime != null) {
return cachedUptime;
}
}
const timeLogger = new TimeLogger();
const startTime = R.isoDateTime(dayjs.utc().subtract(duration, "hour"));
// Handle if heartbeat duration longer than the target duration
// e.g. If the last beat's duration is bigger that the 24hrs window, it will use the duration between the (beat time - window margin) (THEN case in SQL)
let result = await R.getRow(`
SELECT
-- SUM all duration, also trim off the beat out of time window
SUM(
CASE
WHEN (JULIANDAY(\`time\`) - JULIANDAY(?)) * 86400 < duration
THEN (JULIANDAY(\`time\`) - JULIANDAY(?)) * 86400
ELSE duration
END
) AS total_duration,
-- SUM all uptime duration, also trim off the beat out of time window
SUM(
CASE
WHEN (status = 1 OR status = 3)
THEN
CASE
WHEN (JULIANDAY(\`time\`) - JULIANDAY(?)) * 86400 < duration
THEN (JULIANDAY(\`time\`) - JULIANDAY(?)) * 86400
ELSE duration
END
END
) AS uptime_duration
FROM heartbeat
WHERE time > ?
AND monitor_id = ?
`, [
startTime, startTime, startTime, startTime, startTime,
monitorID,
]);
timeLogger.print(`[Monitor: ${monitorID}][${duration}] sendUptime`);
let totalDuration = result.total_duration;
let uptimeDuration = result.uptime_duration;
let uptime = 0;
if (totalDuration > 0) {
uptime = uptimeDuration / totalDuration;
if (uptime < 0) {
uptime = 0;
}
} else {
// Handle new monitor with only one beat, because the beat's duration = 0
let status = parseInt(await R.getCell("SELECT `status` FROM heartbeat WHERE monitor_id = ?", [ monitorID ]));
if (status === UP) {
uptime = 1;
}
}
// Cache
UptimeCacheList.addUptime(monitorID, duration, uptime);
return uptime;
}
/**
* Send Uptime
* @param {number} duration Hours
* @param {Server} io Socket server instance
* @param {number} monitorID ID of monitor to send
* @param {number} userID ID of user to send to
*/
static async sendUptime(duration, io, monitorID, userID) {
const uptime = await this.calcUptime(duration, monitorID);
io.to(userID).emit("uptime", monitorID, duration, uptime);
}
/**
* Has status of monitor changed since last beat?
* @param {boolean} isFirstBeat Is this the first beat of this monitor?
@ -1398,6 +1287,7 @@ class Monitor extends BeanModel {
* @param {boolean} isFirstBeat Is this beat the first of this monitor?
* @param {Monitor} monitor The monitor to send a notificaton about
* @param {Bean} bean Status information about monitor
* @returns {void}
*/
static async sendNotification(isFirstBeat, monitor, bean) {
if (!isFirstBeat || bean.status === DOWN) {
@ -1438,7 +1328,7 @@ class Monitor extends BeanModel {
/**
* Get list of notification providers for a given monitor
* @param {Monitor} monitor Monitor to get notification providers for
* @returns {Promise<LooseObject<any>[]>}
* @returns {Promise<LooseObject<any>[]>} List of notifications
*/
static async getNotificationList(monitor) {
let notificationList = await R.getAll("SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id ", [
@ -1449,7 +1339,8 @@ class Monitor extends BeanModel {
/**
* checks certificate chain for expiring certificates
* @param {Object} tlsInfoObject Information about certificate
* @param {object} tlsInfoObject Information about certificate
* @returns {void}
*/
async checkCertExpiryNotifications(tlsInfoObject) {
if (tlsInfoObject && tlsInfoObject.certInfo && tlsInfoObject.certInfo.daysRemaining) {
@ -1539,7 +1430,7 @@ class Monitor extends BeanModel {
/**
* Get the status of the previous heartbeat
* @param {number} monitorID ID of monitor to check
* @returns {Promise<LooseObject<any>>}
* @returns {Promise<LooseObject<any>>} Previous heartbeat
*/
static async getPreviousHeartbeat(monitorID) {
return await R.getRow(`
@ -1553,7 +1444,7 @@ class Monitor extends BeanModel {
/**
* Check if monitor is under maintenance
* @param {number} monitorID ID of monitor to check
* @returns {Promise<boolean>}
* @returns {Promise<boolean>} Is the monitor under maintenance
*/
static async isUnderMaintenance(monitorID) {
const maintenanceIDList = await R.getCol(`
@ -1576,7 +1467,11 @@ class Monitor extends BeanModel {
return false;
}
/** Make sure monitor interval is between bounds */
/**
* Make sure monitor interval is between bounds
* @returns {void}
* @throws Interval is outside of range
*/
validate() {
if (this.interval > MAX_INTERVAL_SECOND) {
throw new Error(`Interval cannot be more than ${MAX_INTERVAL_SECOND} seconds`);
@ -1589,7 +1484,7 @@ class Monitor extends BeanModel {
/**
* Gets Parent of the monitor
* @param {number} monitorID ID of monitor to get
* @returns {Promise<LooseObject<any>>}
* @returns {Promise<LooseObject<any>>} Parent
*/
static async getParent(monitorID) {
return await R.getRow(`
@ -1605,7 +1500,7 @@ class Monitor extends BeanModel {
/**
* Gets all Children of the monitor
* @param {number} monitorID ID of monitor to get
* @returns {Promise<LooseObject<any>>}
* @returns {Promise<LooseObject<any>>} Children
*/
static async getChildren(monitorID) {
return await R.getAll(`
@ -1618,7 +1513,7 @@ class Monitor extends BeanModel {
/**
* Gets Full Path-Name (Groups and Name)
* @returns {Promise<String>}
* @returns {Promise<string>} Full path name of this monitor
*/
async getPathName() {
let path = this.name;
@ -1638,8 +1533,8 @@ class Monitor extends BeanModel {
/**
* Gets recursive all child ids
* @param {number} monitorID ID of the monitor to get
* @returns {Promise<Array>}
* @param {number} monitorID ID of the monitor to get
* @returns {Promise<Array>} IDs of all children
*/
static async getAllChildrenIDs(monitorID) {
const childs = await Monitor.getChildren(monitorID);
@ -1670,10 +1565,10 @@ class Monitor extends BeanModel {
}
/**
* Checks recursive if parent (ancestors) are active
* @param {number} monitorID ID of the monitor to get
* @returns {Promise<Boolean>}
*/
* Checks recursive if parent (ancestors) are active
* @param {number} monitorID ID of the monitor to get
* @returns {Promise<boolean>} Is the parent monitor active?
*/
static async isParentActive(monitorID) {
const parent = await Monitor.getParent(monitorID);