diff options
Diffstat (limited to 'server/model')
-rw-r--r-- | server/model/api_key.js | 76 | ||||
-rw-r--r-- | server/model/docker_host.js | 19 | ||||
-rw-r--r-- | server/model/group.js | 46 | ||||
-rw-r--r-- | server/model/heartbeat.js | 45 | ||||
-rw-r--r-- | server/model/incident.js | 23 | ||||
-rw-r--r-- | server/model/maintenance.js | 457 | ||||
-rw-r--r-- | server/model/monitor.js | 1740 | ||||
-rw-r--r-- | server/model/proxy.js | 25 | ||||
-rw-r--r-- | server/model/remote_browser.js | 17 | ||||
-rw-r--r-- | server/model/status_page.js | 491 | ||||
-rw-r--r-- | server/model/tag.js | 18 | ||||
-rw-r--r-- | server/model/user.js | 53 |
12 files changed, 3010 insertions, 0 deletions
diff --git a/server/model/api_key.js b/server/model/api_key.js new file mode 100644 index 0000000..96de984 --- /dev/null +++ b/server/model/api_key.js @@ -0,0 +1,76 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); +const { R } = require("redbean-node"); +const dayjs = require("dayjs"); + +class APIKey extends BeanModel { + /** + * Get the current status of this API key + * @returns {string} active, inactive or expired + */ + getStatus() { + let current = dayjs(); + let expiry = dayjs(this.expires); + if (expiry.diff(current) < 0) { + return "expired"; + } + + return this.active ? "active" : "inactive"; + } + + /** + * Returns an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + toJSON() { + return { + id: this.id, + key: this.key, + name: this.name, + userID: this.user_id, + createdDate: this.created_date, + active: this.active, + expires: this.expires, + status: this.getStatus(), + }; + } + + /** + * Returns an object that ready to parse to JSON with sensitive fields + * removed + * @returns {object} Object ready to parse + */ + toPublicJSON() { + return { + id: this.id, + name: this.name, + userID: this.user_id, + createdDate: this.created_date, + active: this.active, + expires: this.expires, + status: this.getStatus(), + }; + } + + /** + * Create a new API Key and store it in the database + * @param {object} key Object sent by client + * @param {int} userID ID of socket user + * @returns {Promise<bean>} API key + */ + static async save(key, userID) { + let bean; + bean = R.dispense("api_key"); + + bean.key = key.key; + bean.name = key.name; + bean.user_id = userID; + bean.active = key.active; + bean.expires = key.expires; + + await R.store(bean); + + return bean; + } +} + +module.exports = APIKey; diff --git a/server/model/docker_host.js b/server/model/docker_host.js new file mode 100644 index 0000000..ceb8f4a --- /dev/null +++ b/server/model/docker_host.js @@ -0,0 +1,19 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +class DockerHost extends BeanModel { + /** + * Returns an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + toJSON() { + return { + id: this.id, + userID: this.user_id, + dockerDaemon: this.docker_daemon, + dockerType: this.docker_type, + name: this.name, + }; + } +} + +module.exports = DockerHost; diff --git a/server/model/group.js b/server/model/group.js new file mode 100644 index 0000000..bd2c301 --- /dev/null +++ b/server/model/group.js @@ -0,0 +1,46 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); +const { R } = require("redbean-node"); + +class Group extends BeanModel { + + /** + * Return an object that ready to parse to JSON for public Only show + * necessary data to public + * @param {boolean} showTags Should the JSON include monitor tags + * @param {boolean} certExpiry Should JSON include info about + * certificate expiry? + * @returns {Promise<object>} Object ready to parse + */ + async toPublicJSON(showTags = false, certExpiry = false) { + let monitorBeanList = await this.getMonitorList(); + let monitorList = []; + + for (let bean of monitorBeanList) { + monitorList.push(await bean.toPublicJSON(showTags, certExpiry)); + } + + return { + id: this.id, + name: this.name, + weight: this.weight, + monitorList, + }; + } + + /** + * Get all monitors + * @returns {Promise<Bean[]>} List of monitors + */ + async getMonitorList() { + return R.convertToBeans("monitor", await R.getAll(` + SELECT monitor.*, monitor_group.send_url FROM monitor, monitor_group + WHERE monitor.id = monitor_group.monitor_id + AND group_id = ? + ORDER BY monitor_group.weight + `, [ + this.id, + ])); + } +} + +module.exports = Group; diff --git a/server/model/heartbeat.js b/server/model/heartbeat.js new file mode 100644 index 0000000..9e972a3 --- /dev/null +++ b/server/model/heartbeat.js @@ -0,0 +1,45 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +/** + * status: + * 0 = DOWN + * 1 = UP + * 2 = PENDING + * 3 = MAINTENANCE + */ +class Heartbeat extends BeanModel { + + /** + * Return an object that ready to parse to JSON for public + * Only show necessary data to public + * @returns {object} Object ready to parse + */ + toPublicJSON() { + return { + status: this.status, + time: this.time, + msg: "", // Hide for public + ping: this.ping, + }; + } + + /** + * Return an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + toJSON() { + return { + monitorID: this._monitorId, + status: this._status, + time: this._time, + msg: this._msg, + ping: this._ping, + important: this._important, + duration: this._duration, + retries: this._retries, + }; + } + +} + +module.exports = Heartbeat; diff --git a/server/model/incident.js b/server/model/incident.js new file mode 100644 index 0000000..c47dabb --- /dev/null +++ b/server/model/incident.js @@ -0,0 +1,23 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +class Incident extends BeanModel { + + /** + * Return an object that ready to parse to JSON for public + * Only show necessary data to public + * @returns {object} Object ready to parse + */ + toPublicJSON() { + return { + id: this.id, + style: this.style, + title: this.title, + content: this.content, + pin: this.pin, + createdDate: this.createdDate, + lastUpdatedDate: this.lastUpdatedDate, + }; + } +} + +module.exports = Incident; diff --git a/server/model/maintenance.js b/server/model/maintenance.js new file mode 100644 index 0000000..7111a18 --- /dev/null +++ b/server/model/maintenance.js @@ -0,0 +1,457 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); +const { parseTimeObject, parseTimeFromTimeObject, log } = require("../../src/util"); +const { R } = require("redbean-node"); +const dayjs = require("dayjs"); +const Cron = require("croner"); +const { UptimeKumaServer } = require("../uptime-kuma-server"); +const apicache = require("../modules/apicache"); + +class Maintenance extends BeanModel { + + /** + * Return an object that ready to parse to JSON for public + * Only show necessary data to public + * @returns {Promise<object>} Object ready to parse + */ + async toPublicJSON() { + + let dateRange = []; + if (this.start_date) { + dateRange.push(this.start_date); + } else { + dateRange.push(null); + } + + if (this.end_date) { + dateRange.push(this.end_date); + } + + let timeRange = []; + let startTime = parseTimeObject(this.start_time); + timeRange.push(startTime); + let endTime = parseTimeObject(this.end_time); + timeRange.push(endTime); + + let obj = { + id: this.id, + title: this.title, + description: this.description, + strategy: this.strategy, + intervalDay: this.interval_day, + active: !!this.active, + dateRange: dateRange, + timeRange: timeRange, + weekdays: (this.weekdays) ? JSON.parse(this.weekdays) : [], + daysOfMonth: (this.days_of_month) ? JSON.parse(this.days_of_month) : [], + timeslotList: [], + cron: this.cron, + duration: this.duration, + durationMinutes: parseInt(this.duration / 60), + timezone: await this.getTimezone(), // Only valid timezone + timezoneOption: this.timezone, // Mainly for dropdown menu, because there is a option "SAME_AS_SERVER" + timezoneOffset: await this.getTimezoneOffset(), + status: await this.getStatus(), + }; + + if (this.strategy === "manual") { + // Do nothing, no timeslots + } else if (this.strategy === "single") { + obj.timeslotList.push({ + startDate: this.start_date, + endDate: this.end_date, + }); + } else { + // Should be cron or recurring here + if (this.beanMeta.job) { + let runningTimeslot = this.getRunningTimeslot(); + + if (runningTimeslot) { + obj.timeslotList.push(runningTimeslot); + } + + let nextRunDate = this.beanMeta.job.nextRun(); + if (nextRunDate) { + let startDateDayjs = dayjs(nextRunDate); + + let startDate = startDateDayjs.toISOString(); + let endDate = startDateDayjs.add(this.duration, "second").toISOString(); + + obj.timeslotList.push({ + startDate, + endDate, + }); + } + } + } + + if (!Array.isArray(obj.weekdays)) { + obj.weekdays = []; + } + + if (!Array.isArray(obj.daysOfMonth)) { + obj.daysOfMonth = []; + } + + return obj; + } + + /** + * Return an object that ready to parse to JSON + * @param {string} timezone If not specified, the timeRange will be in UTC + * @returns {Promise<object>} Object ready to parse + */ + async toJSON(timezone = null) { + return this.toPublicJSON(timezone); + } + + /** + * Get a list of weekdays that the maintenance is active for + * Monday=1, Tuesday=2 etc. + * @returns {number[]} Array of active weekdays + */ + getDayOfWeekList() { + log.debug("timeslot", "List: " + this.weekdays); + return JSON.parse(this.weekdays).sort(function (a, b) { + return a - b; + }); + } + + /** + * Get a list of days in month that maintenance is active for + * @returns {number[]|string[]} Array of active days in month + */ + getDayOfMonthList() { + return JSON.parse(this.days_of_month).sort(function (a, b) { + return a - b; + }); + } + + /** + * Get the duration of maintenance in seconds + * @returns {number} Duration of maintenance + */ + calcDuration() { + let duration = dayjs.utc(this.end_time, "HH:mm").diff(dayjs.utc(this.start_time, "HH:mm"), "second"); + // Add 24hours if it is across day + if (duration < 0) { + duration += 24 * 3600; + } + return duration; + } + + /** + * Convert data from socket to bean + * @param {Bean} bean Bean to fill in + * @param {object} obj Data to fill bean with + * @returns {Promise<Bean>} Filled bean + */ + static async jsonToBean(bean, obj) { + if (obj.id) { + bean.id = obj.id; + } + + bean.title = obj.title; + bean.description = obj.description; + bean.strategy = obj.strategy; + bean.interval_day = obj.intervalDay; + bean.timezone = obj.timezoneOption; + bean.active = obj.active; + + if (obj.dateRange[0]) { + bean.start_date = obj.dateRange[0]; + } else { + bean.start_date = null; + } + + if (obj.dateRange[1]) { + bean.end_date = obj.dateRange[1]; + } else { + bean.end_date = null; + } + + if (bean.strategy === "cron") { + bean.duration = obj.durationMinutes * 60; + bean.cron = obj.cron; + this.validateCron(bean.cron); + } + + if (bean.strategy.startsWith("recurring-")) { + bean.start_time = parseTimeFromTimeObject(obj.timeRange[0]); + bean.end_time = parseTimeFromTimeObject(obj.timeRange[1]); + bean.weekdays = JSON.stringify(obj.weekdays); + bean.days_of_month = JSON.stringify(obj.daysOfMonth); + await bean.generateCron(); + this.validateCron(bean.cron); + } + return bean; + } + + /** + * Throw error if cron is invalid + * @param {string|Date} cron Pattern or date + * @returns {void} + */ + static validateCron(cron) { + let job = new Cron(cron, () => {}); + job.stop(); + } + + /** + * Run the cron + * @param {boolean} throwError Should an error be thrown on failure + * @returns {Promise<void>} + */ + async run(throwError = false) { + if (this.beanMeta.job) { + log.debug("maintenance", "Maintenance is already running, stop it first. id: " + this.id); + this.stop(); + } + + log.debug("maintenance", "Run maintenance id: " + this.id); + + // 1.21.2 migration + if (!this.cron) { + await this.generateCron(); + if (!this.timezone) { + this.timezone = "UTC"; + } + if (this.cron) { + await R.store(this); + } + } + + if (this.strategy === "manual") { + // Do nothing, because it is controlled by the user + } else if (this.strategy === "single") { + this.beanMeta.job = new Cron(this.start_date, { timezone: await this.getTimezone() }, () => { + log.info("maintenance", "Maintenance id: " + this.id + " is under maintenance now"); + UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); + apicache.clear(); + }); + } else if (this.cron != null) { + // Here should be cron or recurring + try { + this.beanMeta.status = "scheduled"; + + let startEvent = (customDuration = 0) => { + log.info("maintenance", "Maintenance id: " + this.id + " is under maintenance now"); + + this.beanMeta.status = "under-maintenance"; + clearTimeout(this.beanMeta.durationTimeout); + + let duration = this.inferDuration(customDuration); + + UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); + + this.beanMeta.durationTimeout = setTimeout(() => { + // End of maintenance for this timeslot + this.beanMeta.status = "scheduled"; + UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); + }, duration); + }; + + // Create Cron + if (this.strategy === "recurring-interval") { + // For recurring-interval, Croner needs to have interval and startAt + const startDate = dayjs(this.startDate); + const [ hour, minute ] = this.startTime.split(":"); + const startDateTime = startDate.hour(hour).minute(minute); + this.beanMeta.job = new Cron(this.cron, { + timezone: await this.getTimezone(), + interval: this.interval_day * 24 * 60 * 60, + startAt: startDateTime.toISOString(), + }, startEvent); + } else { + this.beanMeta.job = new Cron(this.cron, { + timezone: await this.getTimezone(), + }, startEvent); + } + + // Continue if the maintenance is still in the window + let runningTimeslot = this.getRunningTimeslot(); + let current = dayjs(); + + if (runningTimeslot) { + let duration = dayjs(runningTimeslot.endDate).diff(current, "second") * 1000; + log.debug("maintenance", "Maintenance id: " + this.id + " Remaining duration: " + duration + "ms"); + startEvent(duration); + } + + } catch (e) { + log.error("maintenance", "Error in maintenance id: " + this.id); + log.error("maintenance", "Cron: " + this.cron); + log.error("maintenance", e); + + if (throwError) { + throw e; + } + } + + } else { + log.error("maintenance", "Maintenance id: " + this.id + " has no cron"); + } + } + + /** + * Get timeslots where maintenance is running + * @returns {object|null} Maintenance time slot + */ + getRunningTimeslot() { + let start = dayjs(this.beanMeta.job.nextRun(dayjs().add(-this.duration, "second").toDate())); + let end = start.add(this.duration, "second"); + let current = dayjs(); + + if (current.isAfter(start) && current.isBefore(end)) { + return { + startDate: start.toISOString(), + endDate: end.toISOString(), + }; + } else { + return null; + } + } + + /** + * Calculate the maintenance duration + * @param {number} customDuration - The custom duration in milliseconds. + * @returns {number} The inferred duration in milliseconds. + */ + inferDuration(customDuration) { + // Check if duration is still in the window. If not, use the duration from the current time to the end of the window + if (customDuration > 0) { + return customDuration; + } else if (this.end_date) { + let d = dayjs(this.end_date).diff(dayjs(), "second"); + if (d < this.duration) { + return d * 1000; + } + } + return this.duration * 1000; + } + + /** + * Stop the maintenance + * @returns {void} + */ + stop() { + if (this.beanMeta.job) { + this.beanMeta.job.stop(); + delete this.beanMeta.job; + } + } + + /** + * Is this maintenance currently active + * @returns {Promise<boolean>} The maintenance is active? + */ + async isUnderMaintenance() { + return (await this.getStatus()) === "under-maintenance"; + } + + /** + * Get the timezone of the maintenance + * @returns {Promise<string>} timezone + */ + async getTimezone() { + if (!this.timezone || this.timezone === "SAME_AS_SERVER") { + return await UptimeKumaServer.getInstance().getTimezone(); + } + return this.timezone; + } + + /** + * Get offset for timezone + * @returns {Promise<string>} offset + */ + async getTimezoneOffset() { + return dayjs.tz(dayjs(), await this.getTimezone()).format("Z"); + } + + /** + * Get the current status of the maintenance + * @returns {Promise<string>} Current status + */ + async getStatus() { + if (!this.active) { + return "inactive"; + } + + if (this.strategy === "manual") { + return "under-maintenance"; + } + + // Check if the maintenance is started + if (this.start_date && dayjs().isBefore(dayjs.tz(this.start_date, await this.getTimezone()))) { + return "scheduled"; + } + + // Check if the maintenance is ended + if (this.end_date && dayjs().isAfter(dayjs.tz(this.end_date, await this.getTimezone()))) { + return "ended"; + } + + if (this.strategy === "single") { + return "under-maintenance"; + } + + if (!this.beanMeta.status) { + return "unknown"; + } + + return this.beanMeta.status; + } + + /** + * Generate Cron for recurring maintenance + * @returns {Promise<void>} + */ + async generateCron() { + log.info("maintenance", "Generate cron for maintenance id: " + this.id); + + if (this.strategy === "cron") { + // Do nothing for cron + } else if (!this.strategy.startsWith("recurring-")) { + this.cron = ""; + } else if (this.strategy === "recurring-interval") { + // For intervals, the pattern is calculated in the run function as the interval-option is set + this.cron = "* * * * *"; + this.duration = this.calcDuration(); + log.debug("maintenance", "Cron: " + this.cron); + log.debug("maintenance", "Duration: " + this.duration); + } else if (this.strategy === "recurring-weekday") { + let list = this.getDayOfWeekList(); + let array = this.start_time.split(":"); + let hour = parseInt(array[0]); + let minute = parseInt(array[1]); + this.cron = minute + " " + hour + " * * " + list.join(","); + this.duration = this.calcDuration(); + } else if (this.strategy === "recurring-day-of-month") { + let list = this.getDayOfMonthList(); + let array = this.start_time.split(":"); + let hour = parseInt(array[0]); + let minute = parseInt(array[1]); + + let dayList = []; + + for (let day of list) { + if (typeof day === "string" && day.startsWith("lastDay")) { + if (day === "lastDay1") { + dayList.push("L"); + } + // Unfortunately, lastDay2-4 is not supported by cron + } else { + dayList.push(day); + } + } + + // Remove duplicate + dayList = [ ...new Set(dayList) ]; + + this.cron = minute + " " + hour + " " + dayList.join(",") + " * *"; + this.duration = this.calcDuration(); + } + + } +} + +module.exports = Maintenance; diff --git a/server/model/monitor.js b/server/model/monitor.js new file mode 100644 index 0000000..9a30a66 --- /dev/null +++ b/server/model/monitor.js @@ -0,0 +1,1740 @@ +const dayjs = require("dayjs"); +const axios = require("axios"); +const { Prometheus } = require("../prometheus"); +const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, + SQL_DATETIME_FORMAT, evaluateJsonQuery +} = require("../../src/util"); +const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery, + redisPingAsync, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal +} = require("../util-server"); +const { R } = require("redbean-node"); +const { BeanModel } = require("redbean-node/dist/bean-model"); +const { Notification } = require("../notification"); +const { Proxy } = require("../proxy"); +const { demoMode } = require("../config"); +const version = require("../../package.json").version; +const apicache = require("../modules/apicache"); +const { UptimeKumaServer } = require("../uptime-kuma-server"); +const { DockerHost } = require("../docker"); +const Gamedig = require("gamedig"); +const jwt = require("jsonwebtoken"); +const crypto = require("crypto"); +const { UptimeCalculator } = require("../uptime-calculator"); +const { CookieJar } = require("tough-cookie"); +const { HttpsCookieAgent } = require("http-cookie-agent/http"); +const https = require("https"); +const http = require("http"); + +const rootCertificates = rootCertificatesFingerprints(); + +/** + * status: + * 0 = DOWN + * 1 = UP + * 2 = PENDING + * 3 = MAINTENANCE + */ +class Monitor extends BeanModel { + + /** + * 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 {Promise<object>} Object ready to parse + */ + async toPublicJSON(showTags = false, certExpiry = false) { + let obj = { + id: this.id, + name: this.name, + sendUrl: this.sendUrl, + type: this.type, + }; + + if (this.sendUrl) { + obj.url = this.url; + } + + if (showTags) { + obj.tags = await this.getTags(); + } + + if (certExpiry && (this.type === "http" || this.type === "keyword" || this.type === "json-query") && this.getURLProtocol() === "https:") { + const { certExpiryDaysRemaining, validCert } = await this.getCertExpiry(this.id); + obj.certExpiryDaysRemaining = certExpiryDaysRemaining; + obj.validCert = validCert; + } + + return obj; + } + + /** + * Return an object that ready to parse to JSON + * @param {object} preloadData to prevent n+1 problems, we query the data in a batch outside of this function + * @param {boolean} includeSensitiveData Include sensitive data in + * JSON + * @returns {object} Object ready to parse + */ + toJSON(preloadData = {}, includeSensitiveData = true) { + + let screenshot = null; + + if (this.type === "real-browser") { + screenshot = "/screenshots/" + jwt.sign(this.id, UptimeKumaServer.getInstance().jwtSecret) + ".png"; + } + + const path = preloadData.paths.get(this.id) || []; + const pathName = path.join(" / "); + + let data = { + id: this.id, + name: this.name, + description: this.description, + path, + pathName, + parent: this.parent, + childrenIDs: preloadData.childrenIDs.get(this.id) || [], + url: this.url, + method: this.method, + hostname: this.hostname, + port: this.port, + maxretries: this.maxretries, + weight: this.weight, + active: preloadData.activeStatus.get(this.id), + forceInactive: preloadData.forceInactive.get(this.id), + type: this.type, + timeout: this.timeout, + interval: this.interval, + retryInterval: this.retryInterval, + resendInterval: this.resendInterval, + keyword: this.keyword, + invertKeyword: this.isInvertKeyword(), + expiryNotification: this.isEnabledExpiryNotification(), + ignoreTls: this.getIgnoreTls(), + upsideDown: this.isUpsideDown(), + packetSize: this.packetSize, + maxredirects: this.maxredirects, + accepted_statuscodes: this.getAcceptedStatuscodes(), + dns_resolve_type: this.dns_resolve_type, + dns_resolve_server: this.dns_resolve_server, + dns_last_result: this.dns_last_result, + docker_container: this.docker_container, + docker_host: this.docker_host, + proxyId: this.proxy_id, + notificationIDList: preloadData.notifications.get(this.id) || {}, + tags: preloadData.tags.get(this.id) || [], + maintenance: preloadData.maintenanceStatus.get(this.id), + mqttTopic: this.mqttTopic, + mqttSuccessMessage: this.mqttSuccessMessage, + mqttCheckType: this.mqttCheckType, + databaseQuery: this.databaseQuery, + authMethod: this.authMethod, + grpcUrl: this.grpcUrl, + grpcProtobuf: this.grpcProtobuf, + grpcMethod: this.grpcMethod, + grpcServiceName: this.grpcServiceName, + grpcEnableTls: this.getGrpcEnableTls(), + radiusCalledStationId: this.radiusCalledStationId, + radiusCallingStationId: this.radiusCallingStationId, + game: this.game, + gamedigGivenPortOnly: this.getGameDigGivenPortOnly(), + httpBodyEncoding: this.httpBodyEncoding, + jsonPath: this.jsonPath, + expectedValue: this.expectedValue, + kafkaProducerTopic: this.kafkaProducerTopic, + kafkaProducerBrokers: JSON.parse(this.kafkaProducerBrokers), + kafkaProducerSsl: this.getKafkaProducerSsl(), + kafkaProducerAllowAutoTopicCreation: this.getKafkaProducerAllowAutoTopicCreation(), + kafkaProducerMessage: this.kafkaProducerMessage, + screenshot, + cacheBust: this.getCacheBust(), + remote_browser: this.remote_browser, + snmpOid: this.snmpOid, + jsonPathOperator: this.jsonPathOperator, + snmpVersion: this.snmpVersion, + rabbitmqNodes: JSON.parse(this.rabbitmqNodes), + conditions: JSON.parse(this.conditions), + }; + + if (includeSensitiveData) { + data = { + ...data, + headers: this.headers, + body: this.body, + grpcBody: this.grpcBody, + grpcMetadata: this.grpcMetadata, + basic_auth_user: this.basic_auth_user, + basic_auth_pass: this.basic_auth_pass, + oauth_client_id: this.oauth_client_id, + oauth_client_secret: this.oauth_client_secret, + oauth_token_url: this.oauth_token_url, + oauth_scopes: this.oauth_scopes, + oauth_auth_method: this.oauth_auth_method, + pushToken: this.pushToken, + databaseConnectionString: this.databaseConnectionString, + radiusUsername: this.radiusUsername, + radiusPassword: this.radiusPassword, + radiusSecret: this.radiusSecret, + mqttUsername: this.mqttUsername, + mqttPassword: this.mqttPassword, + authWorkstation: this.authWorkstation, + authDomain: this.authDomain, + tlsCa: this.tlsCa, + tlsCert: this.tlsCert, + tlsKey: this.tlsKey, + kafkaProducerSaslOptions: JSON.parse(this.kafkaProducerSaslOptions), + rabbitmqUsername: this.rabbitmqUsername, + rabbitmqPassword: this.rabbitmqPassword, + }; + } + + data.includeSensitiveData = includeSensitiveData; + return data; + } + + /** + * Get all tags applied to this monitor + * @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 ]); + } + + /** + * Gets certificate expiry for this monitor + * @param {number} monitorID ID of monitor to send + * @returns {Promise<LooseObject<any>>} Certificate expiry info for + * monitor + */ + async getCertExpiry(monitorID) { + let tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [ + monitorID, + ]); + let tlsInfo; + if (tlsInfoBean) { + tlsInfo = JSON.parse(tlsInfoBean?.info_json); + if (tlsInfo?.valid && tlsInfo?.certInfo?.daysRemaining) { + return { + certExpiryDaysRemaining: tlsInfo.certInfo.daysRemaining, + validCert: true + }; + } + } + return { + certExpiryDaysRemaining: "", + validCert: false + }; + } + + /** + * Encode user and password to Base64 encoding + * for HTTP "basic" auth, as per RFC-7617 + * @param {string|null} user - The username (nullable if not changed by a user) + * @param {string|null} pass - The password (nullable if not changed by a user) + * @returns {string} Encoded Base64 string + */ + encodeBase64(user, pass) { + return Buffer.from(`${user || ""}:${pass || ""}`).toString("base64"); + } + + /** + * Is the TLS expiry notification enabled? + * @returns {boolean} Enabled? + */ + isEnabledExpiryNotification() { + return Boolean(this.expiryNotification); + } + + /** + * Parse to boolean + * @returns {boolean} Should TLS errors be ignored? + */ + getIgnoreTls() { + return Boolean(this.ignoreTls); + } + + /** + * Parse to boolean + * @returns {boolean} Is the monitor in upside down mode? + */ + isUpsideDown() { + return Boolean(this.upsideDown); + } + + /** + * Parse to boolean + * @returns {boolean} Invert keyword match? + */ + isInvertKeyword() { + return Boolean(this.invertKeyword); + } + + /** + * Parse to boolean + * @returns {boolean} Enable TLS for gRPC? + */ + getGrpcEnableTls() { + return Boolean(this.grpcEnableTls); + } + + /** + * Parse to boolean + * @returns {boolean} if cachebusting is enabled + */ + getCacheBust() { + return Boolean(this.cacheBust); + } + + /** + * Get accepted status codes + * @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); + } + + /** + * Parse to boolean + * @returns {boolean} Kafka Producer Ssl enabled? + */ + getKafkaProducerSsl() { + return Boolean(this.kafkaProducerSsl); + } + + /** + * Parse to boolean + * @returns {boolean} Kafka Producer Allow Auto Topic Creation Enabled? + */ + getKafkaProducerAllowAutoTopicCreation() { + return Boolean(this.kafkaProducerAllowAutoTopicCreation); + } + + /** + * Start monitor + * @param {Server} io Socket server instance + * @returns {Promise<void>} + */ + async start(io) { + let previousBeat = null; + let retries = 0; + + this.prometheus = new Prometheus(this); + + const beat = async () => { + + let beatInterval = this.interval; + + if (! beatInterval) { + beatInterval = 1; + } + + if (demoMode) { + if (beatInterval < 20) { + console.log("beat interval too low, reset to 20s"); + beatInterval = 20; + } + } + + // Expose here for prometheus update + // undefined if not https + let tlsInfo = undefined; + + if (!previousBeat || this.type === "push") { + previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [ + this.id, + ]); + if (previousBeat) { + retries = previousBeat.retries; + } + } + + const isFirstBeat = !previousBeat; + + let bean = R.dispense("heartbeat"); + bean.monitor_id = this.id; + bean.time = R.isoDateTimeMillis(dayjs.utc()); + bean.status = DOWN; + bean.downCount = previousBeat?.downCount || 0; + + if (this.isUpsideDown()) { + bean.status = flipStatus(bean.status); + } + + // Runtime patch timeout if it is 0 + // See https://github.com/louislam/uptime-kuma/pull/3961#issuecomment-1804149144 + if (!this.timeout || this.timeout <= 0) { + this.timeout = this.interval * 1000 * 0.8; + } + + try { + if (await Monitor.isUnderMaintenance(this.id)) { + bean.msg = "Monitor under maintenance"; + bean.status = MAINTENANCE; + } else if (this.type === "group") { + const children = await Monitor.getChildren(this.id); + + if (children.length > 0) { + bean.status = UP; + bean.msg = "All children up and running"; + for (const child of children) { + if (!child.active) { + // Ignore inactive childs + continue; + } + const lastBeat = await Monitor.getPreviousHeartbeat(child.id); + + // Only change state if the monitor is in worse conditions then the ones before + // lastBeat.status could be null + if (!lastBeat) { + bean.status = PENDING; + } else if (bean.status === UP && (lastBeat.status === PENDING || lastBeat.status === DOWN)) { + bean.status = lastBeat.status; + } else if (bean.status === PENDING && lastBeat.status === DOWN) { + bean.status = lastBeat.status; + } + } + + if (bean.status !== UP) { + bean.msg = "Child inaccessible"; + } + } else { + // Set status pending if group is empty + bean.status = PENDING; + bean.msg = "Group empty"; + } + + } else if (this.type === "http" || this.type === "keyword" || this.type === "json-query") { + // Do not do any queries/high loading things before the "bean.ping" + let startTime = dayjs().valueOf(); + + // HTTP basic auth + let basicAuthHeader = {}; + if (this.auth_method === "basic") { + basicAuthHeader = { + "Authorization": "Basic " + this.encodeBase64(this.basic_auth_user, this.basic_auth_pass), + }; + } + + // OIDC: Basic client credential flow. + // Additional grants might be implemented in the future + let oauth2AuthHeader = {}; + if (this.auth_method === "oauth2-cc") { + try { + if (this.oauthAccessToken === undefined || new Date(this.oauthAccessToken.expires_at * 1000) <= new Date()) { + this.oauthAccessToken = await this.makeOidcTokenClientCredentialsRequest(); + } + oauth2AuthHeader = { + "Authorization": this.oauthAccessToken.token_type + " " + this.oauthAccessToken.access_token, + }; + } catch (e) { + throw new Error("The oauth config is invalid. " + e.message); + } + } + + const httpsAgentOptions = { + maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) + rejectUnauthorized: !this.getIgnoreTls(), + secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, + }; + + log.debug("monitor", `[${this.name}] Prepare Options for axios`); + + let contentType = null; + let bodyValue = null; + + if (this.body && (typeof this.body === "string" && this.body.trim().length > 0)) { + if (!this.httpBodyEncoding || this.httpBodyEncoding === "json") { + try { + bodyValue = JSON.parse(this.body); + contentType = "application/json"; + } 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"; + } + } + + // Axios Options + const options = { + url: this.url, + method: (this.method || "get").toLowerCase(), + 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", + ...(contentType ? { "Content-Type": contentType } : {}), + ...(basicAuthHeader), + ...(oauth2AuthHeader), + ...(this.headers ? JSON.parse(this.headers) : {}) + }, + maxRedirects: this.maxredirects, + validateStatus: (status) => { + return checkStatusCode(status, this.getAcceptedStatuscodes()); + }, + signal: axiosAbortSignal((this.timeout + 10) * 1000), + }; + + if (bodyValue) { + options.data = bodyValue; + } + + if (this.cacheBust) { + const randomFloatString = Math.random().toString(36); + const cacheBust = randomFloatString.substring(2); + options.params = { + uptime_kuma_cachebuster: cacheBust, + }; + } + + if (this.proxy_id) { + const proxy = await R.load("proxy", this.proxy_id); + + if (proxy && proxy.active) { + const { httpAgent, httpsAgent } = Proxy.createAgents(proxy, { + httpsAgentOptions: httpsAgentOptions, + }); + + options.proxy = false; + options.httpAgent = httpAgent; + options.httpsAgent = httpsAgent; + } + } + + if (!options.httpsAgent) { + let jar = new CookieJar(); + let httpsCookieAgentOptions = { + ...httpsAgentOptions, + cookies: { jar } + }; + options.httpsAgent = new HttpsCookieAgent(httpsCookieAgentOptions); + } + + if (this.auth_method === "mtls") { + if (this.tlsCert !== null && this.tlsCert !== "") { + options.httpsAgent.options.cert = Buffer.from(this.tlsCert); + } + if (this.tlsCa !== null && this.tlsCa !== "") { + options.httpsAgent.options.ca = Buffer.from(this.tlsCa); + } + if (this.tlsKey !== null && this.tlsKey !== "") { + options.httpsAgent.options.key = Buffer.from(this.tlsKey); + } + } + + let tlsInfo = {}; + // Store tlsInfo when secureConnect event is emitted + // The keylog event listener is a workaround to access the tlsSocket + options.httpsAgent.once("keylog", async (line, tlsSocket) => { + tlsSocket.once("secureConnect", async () => { + tlsInfo = checkCertificate(tlsSocket); + tlsInfo.valid = tlsSocket.authorized || false; + + await this.handleTlsInfo(tlsInfo); + }); + }); + + log.debug("monitor", `[${this.name}] Axios Options: ${JSON.stringify(options)}`); + log.debug("monitor", `[${this.name}] Axios Request`); + + // Make Request + let res = await this.makeAxiosRequest(options); + + bean.msg = `${res.status} - ${res.statusText}`; + bean.ping = dayjs().valueOf() - startTime; + + // fallback for if kelog event is not emitted, but we may still have tlsInfo, + // e.g. if the connection is made through a proxy + if (this.getUrl()?.protocol === "https:" && tlsInfo.valid === undefined) { + const tlsSocket = res.request.res.socket; + + if (tlsSocket) { + tlsInfo = checkCertificate(tlsSocket); + tlsInfo.valid = tlsSocket.authorized || false; + + await this.handleTlsInfo(tlsInfo); + } + } + + if (process.env.UPTIME_KUMA_LOG_RESPONSE_BODY_MONITOR_ID === this.id) { + log.info("monitor", res.data); + } + + if (this.type === "http") { + bean.status = UP; + } else if (this.type === "keyword") { + + let data = res.data; + + // Convert to string for object/array + if (typeof data !== "string") { + data = JSON.stringify(data); + } + + let keywordFound = data.includes(this.keyword); + if (keywordFound === !this.isInvertKeyword()) { + bean.msg += ", keyword " + (keywordFound ? "is" : "not") + " found"; + bean.status = UP; + } else { + data = data.replace(/<[^>]*>?|[\n\r]|\s+/gm, " ").trim(); + if (data.length > 50) { + data = data.substring(0, 47) + "..."; + } + throw new Error(bean.msg + ", but keyword is " + + (keywordFound ? "present" : "not") + " in [" + data + "]"); + } + + } else if (this.type === "json-query") { + let data = res.data; + + const { status, response } = await evaluateJsonQuery(data, this.jsonPath, this.jsonPathOperator, this.expectedValue); + + if (status) { + bean.status = UP; + bean.msg = `JSON query passes (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`; + } else { + throw new Error(`JSON query does not pass (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`); + } + + } + + } else if (this.type === "port") { + bean.ping = await tcping(this.hostname, this.port); + bean.msg = ""; + bean.status = UP; + + } else if (this.type === "ping") { + bean.ping = await ping(this.hostname, this.packetSize); + bean.msg = ""; + 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 + + if (previousBeat) { + const msSinceLastBeat = dayjs.utc().valueOf() - dayjs.utc(previousBeat.time).valueOf(); + + log.debug("monitor", `[${this.name}] msSinceLastBeat = ${msSinceLastBeat}`); + + // If the previous beat was down or pending we use the regular + // beatInterval/retryInterval in the setTimeout further below + if (previousBeat.status !== (this.isUpsideDown() ? DOWN : UP) || msSinceLastBeat > beatInterval * 1000 + bufferTime) { + bean.duration = Math.round(msSinceLastBeat / 1000); + throw new Error("No heartbeat in the time window"); + } else { + let timeout = beatInterval * 1000 - msSinceLastBeat; + if (timeout < 0) { + timeout = bufferTime; + } else { + timeout += bufferTime; + } + // No need to insert successful heartbeat for push type, so end here + retries = 0; + log.debug("monitor", `[${this.name}] timeout = ${timeout}`); + this.heartbeatInterval = setTimeout(safeBeat, timeout); + return; + } + } else { + bean.duration = beatInterval; + throw new Error("No heartbeat in the time window"); + } + + } else if (this.type === "steam") { + const steamApiUrl = "https://api.steampowered.com/IGameServersService/GetServerList/v1/"; + const steamAPIKey = await setting("steamAPIKey"); + const filter = `addr\\${this.hostname}:${this.port}`; + + if (!steamAPIKey) { + throw new Error("Steam API Key not found"); + } + + let res = await axios.get(steamApiUrl, { + timeout: this.timeout * 1000, + headers: { + "Accept": "*/*", + }, + httpsAgent: new https.Agent({ + maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) + rejectUnauthorized: !this.getIgnoreTls(), + secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, + }), + httpAgent: new http.Agent({ + maxCachedSessions: 0, + }), + maxRedirects: this.maxredirects, + validateStatus: (status) => { + return checkStatusCode(status, this.getAcceptedStatuscodes()); + }, + params: { + filter: filter, + key: steamAPIKey, + } + }); + + if (res.data.response && res.data.response.servers && res.data.response.servers.length > 0) { + bean.status = UP; + bean.msg = res.data.response.servers[0].name; + + try { + bean.ping = await ping(this.hostname, this.packetSize); + } catch (_) { } + } else { + throw new Error("Server not found on Steam"); + } + } else if (this.type === "gamedig") { + try { + const state = await Gamedig.query({ + type: this.game, + host: this.hostname, + port: this.port, + givenPortOnly: this.getGameDigGivenPortOnly(), + }); + + bean.msg = state.name; + bean.status = UP; + bean.ping = state.ping; + } catch (e) { + throw new Error(e.message); + } + } else if (this.type === "docker") { + log.debug("monitor", `[${this.name}] Prepare Options for Axios`); + + const options = { + url: `/containers/${this.docker_container}/json`, + timeout: this.interval * 1000 * 0.8, + headers: { + "Accept": "*/*", + }, + httpsAgent: new https.Agent({ + maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) + rejectUnauthorized: !this.getIgnoreTls(), + secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, + }), + httpAgent: new http.Agent({ + maxCachedSessions: 0, + }), + }; + + const dockerHost = await R.load("docker_host", this.docker_host); + + if (!dockerHost) { + throw new Error("Failed to load docker host config"); + } + + if (dockerHost._dockerType === "socket") { + options.socketPath = dockerHost._dockerDaemon; + } else if (dockerHost._dockerType === "tcp") { + options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon); + options.httpsAgent = new https.Agent( + DockerHost.getHttpsAgentOptions(dockerHost._dockerType, options.baseURL) + ); + } + + log.debug("monitor", `[${this.name}] Axios Request`); + let res = await axios.request(options); + + if (res.data.State.Running) { + if (res.data.State.Health && res.data.State.Health.Status !== "healthy") { + bean.status = PENDING; + bean.msg = res.data.State.Health.Status; + } else { + bean.status = UP; + bean.msg = res.data.State.Health ? res.data.State.Health.Status : res.data.State.Status; + } + } else { + throw Error("Container State is " + res.data.State.Status); + } + } else if (this.type === "sqlserver") { + let startTime = dayjs().valueOf(); + + await mssqlQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1"); + + bean.msg = ""; + bean.status = UP; + bean.ping = dayjs().valueOf() - startTime; + } else if (this.type === "grpc-keyword") { + let startTime = dayjs().valueOf(); + const options = { + grpcUrl: this.grpcUrl, + grpcProtobufData: this.grpcProtobuf, + grpcServiceName: this.grpcServiceName, + grpcEnableTls: this.grpcEnableTls, + grpcMethod: this.grpcMethod, + grpcBody: this.grpcBody, + }; + const response = await grpcQuery(options); + bean.ping = dayjs().valueOf() - startTime; + log.debug("monitor:", `gRPC response: ${JSON.stringify(response)}`); + let responseData = response.data; + if (responseData.length > 50) { + responseData = responseData.toString().substring(0, 47) + "..."; + } + if (response.code !== 1) { + bean.status = DOWN; + bean.msg = `Error in send gRPC ${response.code} ${response.errorMessage}`; + } else { + let keywordFound = response.data.toString().includes(this.keyword); + if (keywordFound === !this.isInvertKeyword()) { + bean.status = UP; + bean.msg = `${responseData}, keyword [${this.keyword}] ${keywordFound ? "is" : "not"} found`; + } else { + log.debug("monitor:", `GRPC response [${response.data}] + ", but keyword [${this.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${response.data} + "]"`); + bean.status = DOWN; + bean.msg = `, but keyword [${this.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${responseData} + "]`; + } + } + } else if (this.type === "postgres") { + let startTime = dayjs().valueOf(); + + await postgresQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1"); + + bean.msg = ""; + bean.status = UP; + bean.ping = dayjs().valueOf() - startTime; + } else if (this.type === "mysql") { + let startTime = dayjs().valueOf(); + + // Use `radius_password` as `password` field, since there are too many unnecessary fields + // TODO: rename `radius_password` to `password` later for general use + let mysqlPassword = this.radiusPassword; + + bean.msg = await mysqlQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1", mysqlPassword); + bean.status = UP; + bean.ping = dayjs().valueOf() - startTime; + } else if (this.type === "radius") { + let startTime = dayjs().valueOf(); + + // Handle monitors that were created before the + // update and as such don't have a value for + // this.port. + let port; + if (this.port == null) { + port = 1812; + } else { + port = this.port; + } + + const resp = await radius( + this.hostname, + this.radiusUsername, + this.radiusPassword, + this.radiusCalledStationId, + this.radiusCallingStationId, + this.radiusSecret, + port, + this.interval * 1000 * 0.4, + ); + + bean.msg = resp.code; + bean.status = UP; + bean.ping = dayjs().valueOf() - startTime; + } else if (this.type === "redis") { + let startTime = dayjs().valueOf(); + + bean.msg = await redisPingAsync(this.databaseConnectionString, !this.ignoreTls); + bean.status = UP; + bean.ping = dayjs().valueOf() - startTime; + + } else if (this.type in UptimeKumaServer.monitorTypeList) { + let startTime = dayjs().valueOf(); + const monitorType = UptimeKumaServer.monitorTypeList[this.type]; + await monitorType.check(this, bean, UptimeKumaServer.getInstance()); + if (!bean.ping) { + bean.ping = dayjs().valueOf() - startTime; + } + + } else if (this.type === "kafka-producer") { + let startTime = dayjs().valueOf(); + + bean.msg = await kafkaProducerAsync( + JSON.parse(this.kafkaProducerBrokers), + this.kafkaProducerTopic, + this.kafkaProducerMessage, + { + allowAutoTopicCreation: this.kafkaProducerAllowAutoTopicCreation, + ssl: this.kafkaProducerSsl, + clientId: `Uptime-Kuma/${version}`, + interval: this.interval, + }, + JSON.parse(this.kafkaProducerSaslOptions), + ); + bean.status = UP; + bean.ping = dayjs().valueOf() - startTime; + + } else { + throw new Error("Unknown Monitor Type"); + } + + if (this.isUpsideDown()) { + bean.status = flipStatus(bean.status); + + if (bean.status === DOWN) { + throw new Error("Flip UP to DOWN"); + } + } + + retries = 0; + + } catch (error) { + + if (error?.name === "CanceledError") { + bean.msg = `timeout by AbortSignal (${this.timeout}s)`; + } else { + bean.msg = error.message; + } + + // If UP come in here, it must be upside down mode + // Just reset the retries + if (this.isUpsideDown() && bean.status === UP) { + retries = 0; + + } else if ((this.maxretries > 0) && (retries < this.maxretries)) { + retries++; + bean.status = PENDING; + } else { + // Continue counting retries during DOWN + retries++; + } + } + + bean.retries = retries; + + log.debug("monitor", `[${this.name}] Check isImportant`); + let isImportant = Monitor.isImportantBeat(isFirstBeat, previousBeat?.status, bean.status); + + // Mark as important if status changed, ignore pending pings, + // Don't notify if disrupted changes to up + if (isImportant) { + bean.important = true; + + if (Monitor.isImportantForNotification(isFirstBeat, previousBeat?.status, bean.status)) { + log.debug("monitor", `[${this.name}] sendNotification`); + await Monitor.sendNotification(isFirstBeat, this, bean); + } else { + log.debug("monitor", `[${this.name}] will not sendNotification because it is (or was) under maintenance`); + } + + // Reset down count + bean.downCount = 0; + + // Clear Status Page Cache + log.debug("monitor", `[${this.name}] apicache clear`); + apicache.clear(); + + await UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); + + } else { + bean.important = false; + + if (bean.status === DOWN && this.resendInterval > 0) { + ++bean.downCount; + if (bean.downCount >= this.resendInterval) { + // Send notification again, because we are still DOWN + log.debug("monitor", `[${this.name}] sendNotification again: Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`); + await Monitor.sendNotification(isFirstBeat, this, bean); + + // Reset down count + bean.downCount = 0; + } + } + } + + if (bean.status === UP) { + log.debug("monitor", `Monitor #${this.id} '${this.name}': Successful Response: ${bean.ping} ms | Interval: ${beatInterval} seconds | Type: ${this.type}`); + } else if (bean.status === PENDING) { + if (this.retryInterval > 0) { + beatInterval = this.retryInterval; + } + log.warn("monitor", `Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Max retries: ${this.maxretries} | Retry: ${retries} | Retry Interval: ${beatInterval} seconds | Type: ${this.type}`); + } else if (bean.status === MAINTENANCE) { + log.warn("monitor", `Monitor #${this.id} '${this.name}': Under Maintenance | Type: ${this.type}`); + } else { + 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`); + 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); + + log.debug("monitor", `[${this.name}] prometheus.update`); + this.prometheus?.update(bean, tlsInfo); + + previousBeat = bean; + + if (! this.isStop) { + log.debug("monitor", `[${this.name}] SetTimeout for next check.`); + + 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); + } else { + log.info("monitor", `[${this.name}] isStop = true, no next check.`); + } + + }; + + /** + * Get a heartbeat and handle errors7 + * @returns {void} + */ + const safeBeat = async () => { + try { + await beat(); + } catch (e) { + console.trace(e); + UptimeKumaServer.errorLog(e, false); + log.error("monitor", "Please report to https://github.com/louislam/uptime-kuma/issues"); + + if (! this.isStop) { + log.info("monitor", "Try to restart the monitor"); + this.heartbeatInterval = setTimeout(safeBeat, this.interval * 1000); + } + } + }; + + // Delay Push Type + if (this.type === "push") { + setTimeout(() => { + safeBeat(); + }, this.interval * 1000); + } else { + safeBeat(); + } + } + + /** + * Make a request using axios + * @param {object} options Options for Axios + * @param {boolean} finalCall Should this be the final call i.e + * don't retry on failure + * @returns {object} Axios response + */ + async makeAxiosRequest(options, finalCall = false) { + try { + let res; + if (this.auth_method === "ntlm") { + options.httpsAgent.keepAlive = true; + + res = await httpNtlm(options, { + username: this.basic_auth_user, + password: this.basic_auth_pass, + domain: this.authDomain, + workstation: this.authWorkstation ? this.authWorkstation : undefined + }); + } else { + res = await axios.request(options); + } + + return res; + } catch (error) { + + /** + * Make a single attempt to obtain an new access token in the event that + * the recent api request failed for authentication purposes + */ + if (this.auth_method === "oauth2-cc" && error.response.status === 401 && !finalCall) { + this.oauthAccessToken = await this.makeOidcTokenClientCredentialsRequest(); + let oauth2AuthHeader = { + "Authorization": this.oauthAccessToken.token_type + " " + this.oauthAccessToken.access_token, + }; + options.headers = { ...(options.headers), + ...(oauth2AuthHeader) + }; + + return this.makeAxiosRequest(options, true); + } + + // Fix #2253 + // Read more: https://stackoverflow.com/questions/1759956/curl-error-18-transfer-closed-with-outstanding-read-data-remaining + if (!finalCall && typeof error.message === "string" && error.message.includes("maxContentLength size of -1 exceeded")) { + log.debug("monitor", "makeAxiosRequest with gzip"); + options.headers["Accept-Encoding"] = "gzip, deflate"; + return this.makeAxiosRequest(options, true); + } else { + if (typeof error.message === "string" && error.message.includes("maxContentLength size of -1 exceeded")) { + error.message = "response timeout: incomplete response within a interval"; + } + throw error; + } + } + } + + /** + * Stop monitor + * @returns {Promise<void>} + */ + async stop() { + clearTimeout(this.heartbeatInterval); + this.isStop = true; + + this.prometheus?.remove(); + } + + /** + * Get prometheus instance + * @returns {Prometheus|undefined} Current prometheus instance + */ + getPrometheus() { + return this.prometheus; + } + + /** + * Helper Method: + * returns URL object for further usage + * returns null if url is invalid + * @returns {(null|URL)} Monitor URL + */ + getUrl() { + try { + return new URL(this.url); + } catch (_) { + return null; + } + } + + /** + * Example: http: or https: + * @returns {(null|string)} URL's protocol + */ + getURLProtocol() { + const url = this.getUrl(); + if (url) { + return this.getUrl().protocol; + } else { + return null; + } + } + + /** + * Store TLS info to database + * @param {object} checkCertificateResult Certificate to update + * @returns {Promise<object>} Updated certificate + */ + async updateTlsInfo(checkCertificateResult) { + let tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [ + this.id, + ]); + + if (tlsInfoBean == null) { + tlsInfoBean = R.dispense("monitor_tls_info"); + tlsInfoBean.monitor_id = this.id; + } else { + + // Clear sent history if the cert changed. + try { + let oldCertInfo = JSON.parse(tlsInfoBean.info_json); + + let isValidObjects = oldCertInfo && oldCertInfo.certInfo && checkCertificateResult && checkCertificateResult.certInfo; + + if (isValidObjects) { + if (oldCertInfo.certInfo.fingerprint256 !== checkCertificateResult.certInfo.fingerprint256) { + log.debug("monitor", "Resetting sent_history"); + await R.exec("DELETE FROM notification_sent_history WHERE type = 'certificate' AND monitor_id = ?", [ + this.id + ]); + } else { + log.debug("monitor", "No need to reset sent_history"); + log.debug("monitor", oldCertInfo.certInfo.fingerprint256); + log.debug("monitor", checkCertificateResult.certInfo.fingerprint256); + } + } else { + log.debug("monitor", "Not valid object"); + } + } catch (e) { } + + } + + tlsInfoBean.info_json = JSON.stringify(checkCertificateResult); + await R.store(tlsInfoBean); + + return checkCertificateResult; + } + + /** + * Checks if the monitor is active based on itself and its parents + * @param {number} monitorID ID of monitor to send + * @param {boolean} active is active + * @returns {Promise<boolean>} Is the monitor active? + */ + static async isActive(monitorID, active) { + const parentActive = await Monitor.isParentActive(monitorID); + + return (active === 1) && parentActive; + } + + /** + * Send statistics to clients + * @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) { + // 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 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 = ?", [ + monitorID, + ]); + if (tlsInfo != null) { + io.to(userID).emit("certInfo", monitorID, tlsInfo.info_json); + } + } + + /** + * Has status of monitor changed since last beat? + * @param {boolean} isFirstBeat Is this the first beat of this monitor? + * @param {const} previousBeatStatus Status of the previous beat + * @param {const} currentBeatStatus Status of the current beat + * @returns {boolean} True if is an important beat else false + */ + static isImportantBeat(isFirstBeat, previousBeatStatus, currentBeatStatus) { + // * ? -> ANY STATUS = important [isFirstBeat] + // UP -> PENDING = not important + // * UP -> DOWN = important + // UP -> UP = not important + // PENDING -> PENDING = not important + // * PENDING -> DOWN = important + // PENDING -> UP = not important + // DOWN -> PENDING = this case not exists + // DOWN -> DOWN = not important + // * DOWN -> UP = important + // MAINTENANCE -> MAINTENANCE = not important + // * MAINTENANCE -> UP = important + // * MAINTENANCE -> DOWN = important + // * DOWN -> MAINTENANCE = important + // * UP -> MAINTENANCE = important + return isFirstBeat || + (previousBeatStatus === DOWN && currentBeatStatus === MAINTENANCE) || + (previousBeatStatus === UP && currentBeatStatus === MAINTENANCE) || + (previousBeatStatus === MAINTENANCE && currentBeatStatus === DOWN) || + (previousBeatStatus === MAINTENANCE && currentBeatStatus === UP) || + (previousBeatStatus === UP && currentBeatStatus === DOWN) || + (previousBeatStatus === DOWN && currentBeatStatus === UP) || + (previousBeatStatus === PENDING && currentBeatStatus === DOWN); + } + + /** + * Is this beat important for notifications? + * @param {boolean} isFirstBeat Is this the first beat of this monitor? + * @param {const} previousBeatStatus Status of the previous beat + * @param {const} currentBeatStatus Status of the current beat + * @returns {boolean} True if is an important beat else false + */ + static isImportantForNotification(isFirstBeat, previousBeatStatus, currentBeatStatus) { + // * ? -> ANY STATUS = important [isFirstBeat] + // UP -> PENDING = not important + // * UP -> DOWN = important + // UP -> UP = not important + // PENDING -> PENDING = not important + // * PENDING -> DOWN = important + // PENDING -> UP = not important + // DOWN -> PENDING = this case not exists + // DOWN -> DOWN = not important + // * DOWN -> UP = important + // MAINTENANCE -> MAINTENANCE = not important + // MAINTENANCE -> UP = not important + // * MAINTENANCE -> DOWN = important + // DOWN -> MAINTENANCE = not important + // UP -> MAINTENANCE = not important + return isFirstBeat || + (previousBeatStatus === MAINTENANCE && currentBeatStatus === DOWN) || + (previousBeatStatus === UP && currentBeatStatus === DOWN) || + (previousBeatStatus === DOWN && currentBeatStatus === UP) || + (previousBeatStatus === PENDING && currentBeatStatus === DOWN); + } + + /** + * Send a notification about a monitor + * @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) { + const notificationList = await Monitor.getNotificationList(monitor); + + let text; + if (bean.status === UP) { + text = "✅ Up"; + } else { + text = "🔴 Down"; + } + + let msg = `[${monitor.name}] [${text}] ${bean.msg}`; + + for (let notification of notificationList) { + try { + const heartbeatJSON = bean.toJSON(); + const monitorData = [{ id: monitor.id, + active: monitor.active + }]; + const preloadData = await Monitor.preparePreloadData(monitorData); + // Prevent if the msg is undefined, notifications such as Discord cannot send out. + if (!heartbeatJSON["msg"]) { + heartbeatJSON["msg"] = "N/A"; + } + + // Also provide the time in server timezone + heartbeatJSON["timezone"] = await UptimeKumaServer.getInstance().getTimezone(); + heartbeatJSON["timezoneOffset"] = UptimeKumaServer.getInstance().getTimezoneOffset(); + heartbeatJSON["localDateTime"] = dayjs.utc(heartbeatJSON["time"]).tz(heartbeatJSON["timezone"]).format(SQL_DATETIME_FORMAT); + + await Notification.send(JSON.parse(notification.config), msg, monitor.toJSON(preloadData, false), heartbeatJSON); + } catch (e) { + log.error("monitor", "Cannot send notification to " + notification.name); + log.error("monitor", e); + } + } + } + } + + /** + * Get list of notification providers for a given monitor + * @param {Monitor} monitor Monitor to get notification providers for + * @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 ", [ + monitor.id, + ]); + return notificationList; + } + + /** + * checks certificate chain for expiring certificates + * @param {object} tlsInfoObject Information about certificate + * @returns {void} + */ + async checkCertExpiryNotifications(tlsInfoObject) { + if (tlsInfoObject && tlsInfoObject.certInfo && tlsInfoObject.certInfo.daysRemaining) { + const notificationList = await Monitor.getNotificationList(this); + + if (! notificationList.length > 0) { + // fail fast. If no notification is set, all the following checks can be skipped. + log.debug("monitor", "No notification, no need to send cert notification"); + return; + } + + let notifyDays = await setting("tlsExpiryNotifyDays"); + if (notifyDays == null || !Array.isArray(notifyDays)) { + // Reset Default + await setSetting("tlsExpiryNotifyDays", [ 7, 14, 21 ], "general"); + notifyDays = [ 7, 14, 21 ]; + } + + if (Array.isArray(notifyDays)) { + for (const targetDays of notifyDays) { + let certInfo = tlsInfoObject.certInfo; + while (certInfo) { + let subjectCN = certInfo.subject["CN"]; + if (rootCertificates.has(certInfo.fingerprint256)) { + log.debug("monitor", `Known root cert: ${certInfo.certType} certificate "${subjectCN}" (${certInfo.daysRemaining} days valid) on ${targetDays} deadline.`); + break; + } else if (certInfo.daysRemaining > targetDays) { + log.debug("monitor", `No need to send cert notification for ${certInfo.certType} certificate "${subjectCN}" (${certInfo.daysRemaining} days valid) on ${targetDays} deadline.`); + } else { + log.debug("monitor", `call sendCertNotificationByTargetDays for ${targetDays} deadline on certificate ${subjectCN}.`); + await this.sendCertNotificationByTargetDays(subjectCN, certInfo.certType, certInfo.daysRemaining, targetDays, notificationList); + } + certInfo = certInfo.issuerCertificate; + } + } + } + } + } + + /** + * Send a certificate notification when certificate expires in less + * than target days + * @param {string} certCN Common Name attribute from the certificate subject + * @param {string} certType certificate type + * @param {number} daysRemaining Number of days remaining on certificate + * @param {number} targetDays Number of days to alert after + * @param {LooseObject<any>[]} notificationList List of notification providers + * @returns {Promise<void>} + */ + async sendCertNotificationByTargetDays(certCN, certType, daysRemaining, targetDays, notificationList) { + + let row = await R.getRow("SELECT * FROM notification_sent_history WHERE type = ? AND monitor_id = ? AND days <= ?", [ + "certificate", + this.id, + targetDays, + ]); + + // Sent already, no need to send again + if (row) { + log.debug("monitor", "Sent already, no need to send again"); + return; + } + + let sent = false; + log.debug("monitor", "Send certificate notification"); + + for (let notification of notificationList) { + try { + log.debug("monitor", "Sending to " + notification.name); + await Notification.send(JSON.parse(notification.config), `[${this.name}][${this.url}] ${certType} certificate ${certCN} will be expired in ${daysRemaining} days`); + sent = true; + } catch (e) { + log.error("monitor", "Cannot send cert notification to " + notification.name); + log.error("monitor", e); + } + } + + if (sent) { + await R.exec("INSERT INTO notification_sent_history (type, monitor_id, days) VALUES(?, ?, ?)", [ + "certificate", + this.id, + targetDays, + ]); + } + } + + /** + * Get the status of the previous heartbeat + * @param {number} monitorID ID of monitor to check + * @returns {Promise<LooseObject<any>>} Previous heartbeat + */ + static async getPreviousHeartbeat(monitorID) { + return await R.findOne("heartbeat", " id = (select MAX(id) from heartbeat where monitor_id = ?)", [ + monitorID + ]); + } + + /** + * Check if monitor is under maintenance + * @param {number} monitorID ID of monitor to check + * @returns {Promise<boolean>} Is the monitor under maintenance + */ + static async isUnderMaintenance(monitorID) { + const maintenanceIDList = await R.getCol(` + SELECT maintenance_id FROM monitor_maintenance + WHERE monitor_id = ? + `, [ monitorID ]); + + for (const maintenanceID of maintenanceIDList) { + const maintenance = await UptimeKumaServer.getInstance().getMaintenance(maintenanceID); + if (maintenance && await maintenance.isUnderMaintenance()) { + return true; + } + } + + const parent = await Monitor.getParent(monitorID); + if (parent != null) { + return await Monitor.isUnderMaintenance(parent.id); + } + + return false; + } + + /** + * 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`); + } + if (this.interval < MIN_INTERVAL_SECOND) { + throw new Error(`Interval cannot be less than ${MIN_INTERVAL_SECOND} seconds`); + } + } + + /** + * Gets monitor notification of multiple monitor + * @param {Array} monitorIDs IDs of monitor to get + * @returns {Promise<LooseObject<any>>} object + */ + static async getMonitorNotification(monitorIDs) { + return await R.getAll(` + SELECT monitor_notification.monitor_id, monitor_notification.notification_id + FROM monitor_notification + WHERE monitor_notification.monitor_id IN (${monitorIDs.map((_) => "?").join(",")}) + `, monitorIDs); + } + + /** + * Gets monitor tags of multiple monitor + * @param {Array} monitorIDs IDs of monitor to get + * @returns {Promise<LooseObject<any>>} object + */ + static async getMonitorTag(monitorIDs) { + return await R.getAll(` + SELECT monitor_tag.monitor_id, monitor_tag.tag_id, tag.name, tag.color + FROM monitor_tag + JOIN tag ON monitor_tag.tag_id = tag.id + WHERE monitor_tag.monitor_id IN (${monitorIDs.map((_) => "?").join(",")}) + `, monitorIDs); + } + + /** + * prepare preloaded data for efficient access + * @param {Array} monitorData IDs & active field of monitor to get + * @returns {Promise<LooseObject<any>>} object + */ + static async preparePreloadData(monitorData) { + + const notificationsMap = new Map(); + const tagsMap = new Map(); + const maintenanceStatusMap = new Map(); + const childrenIDsMap = new Map(); + const activeStatusMap = new Map(); + const forceInactiveMap = new Map(); + const pathsMap = new Map(); + + if (monitorData.length > 0) { + const monitorIDs = monitorData.map(monitor => monitor.id); + const notifications = await Monitor.getMonitorNotification(monitorIDs); + const tags = await Monitor.getMonitorTag(monitorIDs); + const maintenanceStatuses = await Promise.all(monitorData.map(monitor => Monitor.isUnderMaintenance(monitor.id))); + const childrenIDs = await Promise.all(monitorData.map(monitor => Monitor.getAllChildrenIDs(monitor.id))); + const activeStatuses = await Promise.all(monitorData.map(monitor => Monitor.isActive(monitor.id, monitor.active))); + const forceInactiveStatuses = await Promise.all(monitorData.map(monitor => Monitor.isParentActive(monitor.id))); + const paths = await Promise.all(monitorData.map(monitor => Monitor.getAllPath(monitor.id, monitor.name))); + + notifications.forEach(row => { + if (!notificationsMap.has(row.monitor_id)) { + notificationsMap.set(row.monitor_id, {}); + } + notificationsMap.get(row.monitor_id)[row.notification_id] = true; + }); + + tags.forEach(row => { + if (!tagsMap.has(row.monitor_id)) { + tagsMap.set(row.monitor_id, []); + } + tagsMap.get(row.monitor_id).push({ + tag_id: row.tag_id, + name: row.name, + color: row.color + }); + }); + + monitorData.forEach((monitor, index) => { + maintenanceStatusMap.set(monitor.id, maintenanceStatuses[index]); + }); + + monitorData.forEach((monitor, index) => { + childrenIDsMap.set(monitor.id, childrenIDs[index]); + }); + + monitorData.forEach((monitor, index) => { + activeStatusMap.set(monitor.id, activeStatuses[index]); + }); + + monitorData.forEach((monitor, index) => { + forceInactiveMap.set(monitor.id, !forceInactiveStatuses[index]); + }); + + monitorData.forEach((monitor, index) => { + pathsMap.set(monitor.id, paths[index]); + }); + } + + return { + notifications: notificationsMap, + tags: tagsMap, + maintenanceStatus: maintenanceStatusMap, + childrenIDs: childrenIDsMap, + activeStatus: activeStatusMap, + forceInactive: forceInactiveMap, + paths: pathsMap, + }; + } + + /** + * Gets Parent of the monitor + * @param {number} monitorID ID of monitor to get + * @returns {Promise<LooseObject<any>>} Parent + */ + static async getParent(monitorID) { + return await R.getRow(` + SELECT parent.* FROM monitor parent + LEFT JOIN monitor child + ON child.parent = parent.id + WHERE child.id = ? + `, [ + monitorID, + ]); + } + + /** + * Gets all Children of the monitor + * @param {number} monitorID ID of monitor to get + * @returns {Promise<LooseObject<any>>} Children + */ + static async getChildren(monitorID) { + return await R.getAll(` + SELECT * FROM monitor + WHERE parent = ? + `, [ + monitorID, + ]); + } + + /** + * Gets the full path + * @param {number} monitorID ID of the monitor to get + * @param {string} name of the monitor to get + * @returns {Promise<string[]>} Full path (includes groups and the name) of the monitor + */ + static async getAllPath(monitorID, name) { + const path = [ name ]; + + if (this.parent === null) { + return path; + } + + let parent = await Monitor.getParent(monitorID); + while (parent !== null) { + path.unshift(parent.name); + parent = await Monitor.getParent(parent.id); + } + + return path; + } + + /** + * Gets recursive all child ids + * @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); + + if (childs === null) { + return []; + } + + let childrenIDs = []; + + for (const child of childs) { + childrenIDs.push(child.id); + childrenIDs = childrenIDs.concat(await Monitor.getAllChildrenIDs(child.id)); + } + + return childrenIDs; + } + + /** + * Unlinks all children of the group monitor + * @param {number} groupID ID of group to remove children of + * @returns {Promise<void>} + */ + static async unlinkAllChildren(groupID) { + return await R.exec("UPDATE `monitor` SET parent = ? WHERE parent = ? ", [ + null, groupID + ]); + } + + /** + * 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); + + if (parent === null) { + return true; + } + + const parentActive = await Monitor.isParentActive(parent.id); + return parent.active && parentActive; + } + + /** + * Obtains a new Oidc Token + * @returns {Promise<object>} OAuthProvider client + */ + async makeOidcTokenClientCredentialsRequest() { + log.debug("monitor", `[${this.name}] The oauth access-token undefined or expired. Requesting a new token`); + const oAuthAccessToken = await getOidcTokenClientCredentials(this.oauth_token_url, this.oauth_client_id, this.oauth_client_secret, this.oauth_scopes, this.oauth_auth_method); + if (this.oauthAccessToken?.expires_at) { + log.debug("monitor", `[${this.name}] Obtained oauth access-token. Expires at ${new Date(this.oauthAccessToken?.expires_at * 1000)}`); + } else { + log.debug("monitor", `[${this.name}] Obtained oauth access-token. Time until expiry was not provided`); + } + + return oAuthAccessToken; + } + + /** + * Store TLS certificate information and check for expiry + * @param {object} tlsInfo Information about the TLS connection + * @returns {Promise<void>} + */ + async handleTlsInfo(tlsInfo) { + await this.updateTlsInfo(tlsInfo); + this.prometheus?.update(null, tlsInfo); + + if (!this.getIgnoreTls() && this.isEnabledExpiryNotification()) { + log.debug("monitor", `[${this.name}] call checkCertExpiryNotifications`); + await this.checkCertExpiryNotifications(tlsInfo); + } + } +} + +module.exports = Monitor; diff --git a/server/model/proxy.js b/server/model/proxy.js new file mode 100644 index 0000000..ec78403 --- /dev/null +++ b/server/model/proxy.js @@ -0,0 +1,25 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +class Proxy extends BeanModel { + /** + * Return an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + toJSON() { + return { + id: this._id, + userId: this._user_id, + protocol: this._protocol, + host: this._host, + port: this._port, + auth: !!this._auth, + username: this._username, + password: this._password, + active: !!this._active, + default: !!this._default, + createdDate: this._created_date, + }; + } +} + +module.exports = Proxy; diff --git a/server/model/remote_browser.js b/server/model/remote_browser.js new file mode 100644 index 0000000..49299ad --- /dev/null +++ b/server/model/remote_browser.js @@ -0,0 +1,17 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +class RemoteBrowser extends BeanModel { + /** + * Returns an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + toJSON() { + return { + id: this.id, + url: this.url, + name: this.name, + }; + } +} + +module.exports = RemoteBrowser; diff --git a/server/model/status_page.js b/server/model/status_page.js new file mode 100644 index 0000000..38f548e --- /dev/null +++ b/server/model/status_page.js @@ -0,0 +1,491 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); +const { R } = require("redbean-node"); +const cheerio = require("cheerio"); +const { UptimeKumaServer } = require("../uptime-kuma-server"); +const jsesc = require("jsesc"); +const googleAnalytics = require("../google-analytics"); +const { marked } = require("marked"); +const { Feed } = require("feed"); +const config = require("../config"); + +const { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE, DOWN } = require("../../src/util"); + +class StatusPage extends BeanModel { + + /** + * Like this: { "test-uptime.kuma.pet": "default" } + * @type {{}} + */ + static domainMappingList = { }; + + /** + * Handle responses to RSS pages + * @param {Response} response Response object + * @param {string} slug Status page slug + * @returns {Promise<void>} + */ + static async handleStatusPageRSSResponse(response, slug) { + let statusPage = await R.findOne("status_page", " slug = ? ", [ + slug + ]); + + if (statusPage) { + response.send(await StatusPage.renderRSS(statusPage, slug)); + } else { + response.status(404).send(UptimeKumaServer.getInstance().indexHTML); + } + } + + /** + * Handle responses to status page + * @param {Response} response Response object + * @param {string} indexHTML HTML to render + * @param {string} slug Status page slug + * @returns {Promise<void>} + */ + static async handleStatusPageResponse(response, indexHTML, slug) { + // Handle url with trailing slash (http://localhost:3001/status/) + // The slug comes from the route "/status/:slug". If the slug is empty, express converts it to "index.html" + if (slug === "index.html") { + slug = "default"; + } + + let statusPage = await R.findOne("status_page", " slug = ? ", [ + slug + ]); + + if (statusPage) { + response.send(await StatusPage.renderHTML(indexHTML, statusPage)); + } else { + response.status(404).send(UptimeKumaServer.getInstance().indexHTML); + } + } + + /** + * SSR for RSS feed + * @param {statusPage} statusPage object + * @param {slug} slug from router + * @returns {Promise<string>} the rendered html + */ + static async renderRSS(statusPage, slug) { + const { heartbeats, statusDescription } = await StatusPage.getRSSPageData(statusPage); + + let proto = config.isSSL ? "https" : "http"; + let host = `${proto}://${config.hostname || "localhost"}:${config.port}/status/${slug}`; + + const feed = new Feed({ + title: "uptime kuma rss feed", + description: `current status: ${statusDescription}`, + link: host, + language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes + updated: new Date(), // optional, default = today + }); + + heartbeats.forEach(heartbeat => { + feed.addItem({ + title: `${heartbeat.name} is down`, + description: `${heartbeat.name} has been down since ${heartbeat.time}`, + id: heartbeat.monitorID, + date: new Date(heartbeat.time), + }); + }); + + return feed.rss2(); + } + + /** + * SSR for status pages + * @param {string} indexHTML HTML page to render + * @param {StatusPage} statusPage Status page populate HTML with + * @returns {Promise<string>} the rendered html + */ + static async renderHTML(indexHTML, statusPage) { + const $ = cheerio.load(indexHTML); + + const description155 = marked(statusPage.description ?? "") + .replace(/<[^>]+>/gm, "") + .trim() + .substring(0, 155); + + $("title").text(statusPage.title); + $("meta[name=description]").attr("content", description155); + + if (statusPage.icon) { + $("link[rel=icon]") + .attr("href", statusPage.icon) + .removeAttr("type"); + + $("link[rel=apple-touch-icon]").remove(); + } + + const head = $("head"); + + if (statusPage.googleAnalyticsTagId) { + let escapedGoogleAnalyticsScript = googleAnalytics.getGoogleAnalyticsScript(statusPage.googleAnalyticsTagId); + head.append($(escapedGoogleAnalyticsScript)); + } + + // OG Meta Tags + let ogTitle = $("<meta property=\"og:title\" content=\"\" />").attr("content", statusPage.title); + head.append(ogTitle); + + let ogDescription = $("<meta property=\"og:description\" content=\"\" />").attr("content", description155); + head.append(ogDescription); + + // Preload data + // Add jsesc, fix https://github.com/louislam/uptime-kuma/issues/2186 + const escapedJSONObject = jsesc(await StatusPage.getStatusPageData(statusPage), { + "isScriptContext": true + }); + + const script = $(` + <script id="preload-data" data-json="{}"> + window.preloadData = ${escapedJSONObject}; + </script> + `); + + head.append(script); + + // manifest.json + $("link[rel=manifest]").attr("href", `/api/status-page/${statusPage.slug}/manifest.json`); + + return $.root().html(); + } + + /** + * @param {heartbeats} heartbeats from getRSSPageData + * @returns {number} status_page constant from util.ts + */ + static overallStatus(heartbeats) { + if (heartbeats.length === 0) { + return -1; + } + + let status = STATUS_PAGE_ALL_UP; + let hasUp = false; + + for (let beat of heartbeats) { + if (beat.status === MAINTENANCE) { + return STATUS_PAGE_MAINTENANCE; + } else if (beat.status === UP) { + hasUp = true; + } else { + status = STATUS_PAGE_PARTIAL_DOWN; + } + } + + if (! hasUp) { + status = STATUS_PAGE_ALL_DOWN; + } + + return status; + } + + /** + * @param {number} status from overallStatus + * @returns {string} description + */ + static getStatusDescription(status) { + if (status === -1) { + return "No Services"; + } + + if (status === STATUS_PAGE_ALL_UP) { + return "All Systems Operational"; + } + + if (status === STATUS_PAGE_PARTIAL_DOWN) { + return "Partially Degraded Service"; + } + + if (status === STATUS_PAGE_ALL_DOWN) { + return "Degraded Service"; + } + + // TODO: show the real maintenance information: title, description, time + if (status === MAINTENANCE) { + return "Under maintenance"; + } + + return "?"; + } + + /** + * Get all data required for RSS + * @param {StatusPage} statusPage Status page to get data for + * @returns {object} Status page data + */ + static async getRSSPageData(statusPage) { + // get all heartbeats that correspond to this statusPage + const config = await statusPage.toPublicJSON(); + + // Public Group List + const showTags = !!statusPage.show_tags; + + const list = await R.find("group", " public = 1 AND status_page_id = ? ORDER BY weight ", [ + statusPage.id + ]); + + let heartbeats = []; + + for (let groupBean of list) { + let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry); + for (const monitor of monitorGroup.monitorList) { + const heartbeat = await R.findOne("heartbeat", "monitor_id = ? ORDER BY time DESC", [ monitor.id ]); + if (heartbeat) { + heartbeats.push({ + ...monitor, + status: heartbeat.status, + time: heartbeat.time + }); + } + } + } + + // calculate RSS feed description + let status = StatusPage.overallStatus(heartbeats); + let statusDescription = StatusPage.getStatusDescription(status); + + // keep only DOWN heartbeats in the RSS feed + heartbeats = heartbeats.filter(heartbeat => heartbeat.status === DOWN); + + return { + heartbeats, + statusDescription + }; + } + + /** + * Get all status page data in one call + * @param {StatusPage} statusPage Status page to get data for + * @returns {object} Status page data + */ + static async getStatusPageData(statusPage) { + const config = await statusPage.toPublicJSON(); + + // Incident + let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [ + statusPage.id, + ]); + + if (incident) { + incident = incident.toPublicJSON(); + } + + let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id); + + // Public Group List + const publicGroupList = []; + const showTags = !!statusPage.show_tags; + + const list = await R.find("group", " public = 1 AND status_page_id = ? ORDER BY weight ", [ + statusPage.id + ]); + + for (let groupBean of list) { + let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry); + publicGroupList.push(monitorGroup); + } + + // Response + return { + config, + incident, + publicGroupList, + maintenanceList, + }; + } + + /** + * Loads domain mapping from DB + * Return object like this: { "test-uptime.kuma.pet": "default" } + * @returns {Promise<void>} + */ + static async loadDomainMappingList() { + StatusPage.domainMappingList = await R.getAssoc(` + SELECT domain, slug + FROM status_page, status_page_cname + WHERE status_page.id = status_page_cname.status_page_id + `); + } + + /** + * Send status page list to client + * @param {Server} io io Socket server instance + * @param {Socket} socket Socket.io instance + * @returns {Promise<Bean[]>} Status page list + */ + static async sendStatusPageList(io, socket) { + let result = {}; + + let list = await R.findAll("status_page", " ORDER BY title "); + + for (let item of list) { + result[item.id] = await item.toJSON(); + } + + io.to(socket.userID).emit("statusPageList", result); + return list; + } + + /** + * Update list of domain names + * @param {string[]} domainNameList List of status page domains + * @returns {Promise<void>} + */ + async updateDomainNameList(domainNameList) { + + if (!Array.isArray(domainNameList)) { + throw new Error("Invalid array"); + } + + let trx = await R.begin(); + + await trx.exec("DELETE FROM status_page_cname WHERE status_page_id = ?", [ + this.id, + ]); + + try { + for (let domain of domainNameList) { + if (typeof domain !== "string") { + throw new Error("Invalid domain"); + } + + if (domain.trim() === "") { + continue; + } + + // If the domain name is used in another status page, delete it + await trx.exec("DELETE FROM status_page_cname WHERE domain = ?", [ + domain, + ]); + + let mapping = trx.dispense("status_page_cname"); + mapping.status_page_id = this.id; + mapping.domain = domain; + await trx.store(mapping); + } + await trx.commit(); + } catch (error) { + await trx.rollback(); + throw error; + } + } + + /** + * Get list of domain names + * @returns {object[]} List of status page domains + */ + getDomainNameList() { + let domainList = []; + for (let domain in StatusPage.domainMappingList) { + let s = StatusPage.domainMappingList[domain]; + + if (this.slug === s) { + domainList.push(domain); + } + } + return domainList; + } + + /** + * Return an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + async toJSON() { + return { + id: this.id, + slug: this.slug, + title: this.title, + description: this.description, + icon: this.getIcon(), + theme: this.theme, + autoRefreshInterval: this.autoRefreshInterval, + published: !!this.published, + showTags: !!this.show_tags, + domainNameList: this.getDomainNameList(), + customCSS: this.custom_css, + footerText: this.footer_text, + showPoweredBy: !!this.show_powered_by, + googleAnalyticsId: this.google_analytics_tag_id, + showCertificateExpiry: !!this.show_certificate_expiry, + }; + } + + /** + * Return an object that ready to parse to JSON for public + * Only show necessary data to public + * @returns {object} Object ready to parse + */ + async toPublicJSON() { + return { + slug: this.slug, + title: this.title, + description: this.description, + icon: this.getIcon(), + autoRefreshInterval: this.autoRefreshInterval, + theme: this.theme, + published: !!this.published, + showTags: !!this.show_tags, + customCSS: this.custom_css, + footerText: this.footer_text, + showPoweredBy: !!this.show_powered_by, + googleAnalyticsId: this.google_analytics_tag_id, + showCertificateExpiry: !!this.show_certificate_expiry, + }; + } + + /** + * Convert slug to status page ID + * @param {string} slug Status page slug + * @returns {Promise<number>} ID of status page + */ + static async slugToID(slug) { + return await R.getCell("SELECT id FROM status_page WHERE slug = ? ", [ + slug + ]); + } + + /** + * Get path to the icon for the page + * @returns {string} Path + */ + getIcon() { + if (!this.icon) { + return "/icon.svg"; + } else { + return this.icon; + } + } + + /** + * Get list of maintenances + * @param {number} statusPageId ID of status page to get maintenance for + * @returns {object} Object representing maintenances sanitized for public + */ + static async getMaintenanceList(statusPageId) { + try { + const publicMaintenanceList = []; + + let maintenanceIDList = await R.getCol(` + SELECT DISTINCT maintenance_id + FROM maintenance_status_page + WHERE status_page_id = ? + `, [ statusPageId ]); + + for (const maintenanceID of maintenanceIDList) { + let maintenance = UptimeKumaServer.getInstance().getMaintenance(maintenanceID); + if (maintenance && await maintenance.isUnderMaintenance()) { + publicMaintenanceList.push(await maintenance.toPublicJSON()); + } + } + + return publicMaintenanceList; + + } catch (error) { + return []; + } + } +} + +module.exports = StatusPage; diff --git a/server/model/tag.js b/server/model/tag.js new file mode 100644 index 0000000..bc8a4db --- /dev/null +++ b/server/model/tag.js @@ -0,0 +1,18 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +class Tag extends BeanModel { + + /** + * Return an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + toJSON() { + return { + id: this._id, + name: this._name, + color: this._color, + }; + } +} + +module.exports = Tag; diff --git a/server/model/user.js b/server/model/user.js new file mode 100644 index 0000000..329402f --- /dev/null +++ b/server/model/user.js @@ -0,0 +1,53 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); +const passwordHash = require("../password-hash"); +const { R } = require("redbean-node"); +const jwt = require("jsonwebtoken"); +const { shake256, SHAKE256_LENGTH } = require("../util-server"); + +class User extends BeanModel { + /** + * Reset user password + * Fix #1510, as in the context reset-password.js, there is no auto model mapping. Call this static function instead. + * @param {number} userID ID of user to update + * @param {string} newPassword Users new password + * @returns {Promise<void>} + */ + static async resetPassword(userID, newPassword) { + await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [ + passwordHash.generate(newPassword), + userID + ]); + } + + /** + * Reset this users password + * @param {string} newPassword Users new password + * @returns {Promise<void>} + */ + async resetPassword(newPassword) { + const hashedPassword = passwordHash.generate(newPassword); + + await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [ + hashedPassword, + this.id + ]); + + this.password = hashedPassword; + } + + /** + * Create a new JWT for a user + * @param {User} user The User to create a JsonWebToken for + * @param {string} jwtSecret The key used to sign the JsonWebToken + * @returns {string} the JsonWebToken as a string + */ + static createJWT(user, jwtSecret) { + return jwt.sign({ + username: user.username, + h: shake256(user.password, SHAKE256_LENGTH), + }, jwtSecret); + } + +} + +module.exports = User; |