const https = require('https') const dayjs = require('dayjs') const utc = require('dayjs/plugin/utc') const timezone = require('dayjs/plugin/timezone') dayjs.extend(utc) dayjs.extend(timezone) const axios = require('axios') const { Prometheus } = require('../prometheus') const { debug, UP, DOWN, PENDING, flipStatus, TimeLogger } = require('../../src/util') const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, errorLog } = require('../util-server') const { R } = require('redbean-node') const { BeanModel } = require('redbean-node/dist/bean-model') const { Notification } = require('../notification') const { demoMode } = require('../config') const version = require('../../package.json').version const apicache = require('../modules/apicache') /** * status: * 0 = DOWN * 1 = UP * 2 = PENDING */ class Monitor extends BeanModel { /** * Return an object that ready to parse to JSON for public * Only show necessary data to public */ async toPublicJSON (showTags = false) { const obj = { id: this.id, name: this.name } if (showTags) { obj.tags = await this.getTags() } return obj } /** * Return an object that ready to parse to JSON */ async toJSON () { const notificationIDList = {} const list = await R.find('monitor_notification', ' monitor_id = ? ', [ this.id ]) for (const bean of list) { notificationIDList[bean.notification_id] = true } const tags = await this.getTags() return { id: this.id, name: this.name, url: this.url, method: this.method, body: this.body, headers: this.headers, basic_auth_user: this.basic_auth_user, basic_auth_pass: this.basic_auth_pass, hostname: this.hostname, port: this.port, maxretries: this.maxretries, weight: this.weight, active: this.active, type: this.type, interval: this.interval, retryInterval: this.retryInterval, keyword: this.keyword, ignoreTls: this.getIgnoreTls(), upsideDown: this.isUpsideDown(), 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, pushToken: this.pushToken, notificationIDList, tags: tags } } 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 = ?', [this.id]) } /** * Encode user and password to Base64 encoding * for HTTP "basic" auth, as per RFC-7617 * @returns {string} */ encodeBase64 (user, pass) { return Buffer.from(user + ':' + pass).toString('base64') } /** * Parse to boolean * @returns {boolean} */ getIgnoreTls () { return Boolean(this.ignoreTls) } /** * Parse to boolean * @returns {boolean} */ isUpsideDown () { return Boolean(this.upsideDown) } getAcceptedStatuscodes () { return JSON.parse(this.accepted_statuscodes_json) } start (io) { let previousBeat = null let retries = 0 const 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 if (!previousBeat) { previousBeat = await R.findOne('heartbeat', ' monitor_id = ? ORDER BY time DESC', [ this.id ]) } const isFirstBeat = !previousBeat const bean = R.dispense('heartbeat') bean.monitor_id = this.id bean.time = R.isoDateTime(dayjs.utc()) bean.status = DOWN if (this.isUpsideDown()) { bean.status = flipStatus(bean.status) } // Duration if (!isFirstBeat) { bean.duration = dayjs(bean.time).diff(dayjs(previousBeat.time), 'second') } else { bean.duration = 0 } try { if (this.type === 'http' || this.type === 'keyword') { // Do not do any queries/high loading things before the "bean.ping" const startTime = dayjs().valueOf() // HTTP basic auth let basicAuthHeader = {} if (this.basic_auth_user) { basicAuthHeader = { Authorization: 'Basic ' + this.encodeBase64(this.basic_auth_user, this.basic_auth_pass) } } debug(`[${this.name}] Prepare Options for axios`) const options = { url: this.url, method: (this.method || 'get').toLowerCase(), ...(this.body ? { data: JSON.parse(this.body) } : {}), timeout: this.interval * 1000 * 0.8, 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, ...(this.headers ? JSON.parse(this.headers) : {}), ...(basicAuthHeader) }, httpsAgent: new https.Agent({ maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) rejectUnauthorized: !this.getIgnoreTls() }), maxRedirects: this.maxredirects, validateStatus: (status) => { return checkStatusCode(status, this.getAcceptedStatuscodes()) } } debug(`[${this.name}] Axios Request`) const res = await axios.request(options) bean.msg = `${res.status} - ${res.statusText}` bean.ping = dayjs().valueOf() - startTime // Check certificate if https is used const certInfoStartTime = dayjs().valueOf() if (this.getUrl()?.protocol === 'https:') { debug(`[${this.name}] Check cert`) try { const tlsInfoObject = checkCertificate(res) tlsInfo = await this.updateTlsInfo(tlsInfoObject) if (!this.getIgnoreTls()) { debug(`[${this.name}] call sendCertNotification`) await this.sendCertNotification(tlsInfoObject) } } catch (e) { if (e.message !== 'No TLS certificate in response') { console.error(e.message) } } } if (process.env.TIMELOGGER === '1') { debug('Cert Info Query Time: ' + (dayjs().valueOf() - certInfoStartTime) + 'ms') } if (process.env.UPTIME_KUMA_LOG_RESPONSE_BODY_MONITOR_ID == this.id) { console.log(res.data) } if (this.type === 'http') { bean.status = UP } else { let data = res.data // Convert to string for object/array if (typeof data !== 'string') { data = JSON.stringify(data) } if (data.includes(this.keyword)) { bean.msg += ', keyword is found' bean.status = UP } else { throw new Error(bean.msg + ', but keyword is not found') } } } 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) bean.msg = '' bean.status = UP } else if (this.type === 'dns') { const startTime = dayjs().valueOf() let dnsMessage = '' const dnsRes = await dnsResolve(this.hostname, this.dns_resolve_server, 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') { dnsMessage += 'Records: ' dnsMessage += dnsRes.join(' | ') } else if (this.dns_resolve_type == 'CNAME' || this.dns_resolve_type == 'PTR') { 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) { 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 const time = R.isoDateTime(dayjs.utc().subtract(this.interval, 'second')) const heartbeatCount = await R.count('heartbeat', ' monitor_id = ? AND time > ? ', [ this.id, time ]) debug('heartbeatCount' + heartbeatCount + ' ' + time) if (heartbeatCount <= 0) { // Fix #922, since previous heartbeat could be inserted by api, it should get from database previousBeat = await Monitor.getPreviousHeartbeat(this.id) throw new Error('No heartbeat in the time window') } else { // No need to insert successful heartbeat for push type, so end here retries = 0 this.heartbeatInterval = setTimeout(beat, beatInterval * 1000) return } } 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') } const res = await axios.get(steamApiUrl, { timeout: this.interval * 1000 * 0.8, headers: { Accept: '*/*', 'User-Agent': 'Uptime-Kuma/' + version }, httpsAgent: new https.Agent({ maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) rejectUnauthorized: !this.getIgnoreTls() }), 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) } catch (_) { } } else { throw new Error('Server not found on Steam') } } else { bean.msg = 'Unknown Monitor Type' bean.status = PENDING } if (this.isUpsideDown()) { bean.status = flipStatus(bean.status) if (bean.status === DOWN) { throw new Error('Flip UP to DOWN') } } retries = 0 } catch (error) { 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 } } debug(`[${this.name}] Check isImportant`) const 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 debug(`[${this.name}] sendNotification`) await Monitor.sendNotification(isFirstBeat, this, bean) // Clear Status Page Cache debug(`[${this.name}] apicache clear`) apicache.clear() } else { bean.important = false } if (bean.status === UP) { console.info(`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 } console.warn(`Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Max retries: ${this.maxretries} | Retry: ${retries} | Retry Interval: ${beatInterval} seconds | Type: ${this.type}`) } else { console.warn(`Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type}`) } debug(`[${this.name}] Send to socket`) io.to(this.user_id).emit('heartbeat', bean.toJSON()) Monitor.sendStats(io, this.id, this.user_id) debug(`[${this.name}] Store`) await R.store(bean) debug(`[${this.name}] prometheus.update`) prometheus.update(bean, tlsInfo) previousBeat = bean if (!this.isStop) { debug(`[${this.name}] SetTimeout for next check.`) this.heartbeatInterval = setTimeout(safeBeat, beatInterval * 1000) } else { console.log(`[${this.name}] isStop = true, no next check.`) } } const safeBeat = async () => { try { await beat() } catch (e) { console.trace(e) errorLog(e, false) console.error('Please report to https://github.com/louislam/uptime-kuma/issues') if (!this.isStop) { console.log('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() } } stop () { clearTimeout(this.heartbeatInterval) this.isStop = true } /** * Helper Method: * returns URL object for further usage * returns null if url is invalid * @returns {null|URL} */ getUrl () { try { return new URL(this.url) } catch (_) { return null } } /** * Store TLS info to database * @param checkCertificateResult * @returns {Promise} */ async updateTlsInfo (checkCertificateResult) { let tls_info_bean = await R.findOne('monitor_tls_info', 'monitor_id = ?', [ this.id ]) if (tls_info_bean == null) { tls_info_bean = R.dispense('monitor_tls_info') tls_info_bean.monitor_id = this.id } else { // Clear sent history if the cert changed. try { const oldCertInfo = JSON.parse(tls_info_bean.info_json) const isValidObjects = oldCertInfo && oldCertInfo.certInfo && checkCertificateResult && checkCertificateResult.certInfo if (isValidObjects) { if (oldCertInfo.certInfo.fingerprint256 !== checkCertificateResult.certInfo.fingerprint256) { debug('Resetting sent_history') await R.exec("DELETE FROM notification_sent_history WHERE type = 'certificate' AND monitor_id = ?", [ this.id ]) } else { debug('No need to reset sent_history') debug(oldCertInfo.certInfo.fingerprint256) debug(checkCertificateResult.certInfo.fingerprint256) } } else { debug('Not valid object') } } catch (e) { } } tls_info_bean.info_json = JSON.stringify(checkCertificateResult) await R.store(tls_info_bean) return checkCertificateResult } static async sendStats (io, monitorID, userID) { const hasClients = getTotalClientInRoom(io, userID) > 0 if (hasClients) { await Monitor.sendAvgPing(24, io, monitorID, userID) await Monitor.sendUptime(24, io, monitorID, userID) await Monitor.sendUptime(24 * 30, io, monitorID, userID) await Monitor.sendCertInfo(io, monitorID, userID) } else { debug('No clients in the room, no need to send stats') } } /** * * @param duration : int Hours */ static async sendAvgPing (duration, io, monitorID, userID) { const timeLogger = new TimeLogger() const 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) } static async sendCertInfo (io, monitorID, userID) { const tls_info = await R.findOne('monitor_tls_info', 'monitor_id = ?', [ monitorID ]) if (tls_info != null) { io.to(userID).emit('certInfo', monitorID, tls_info.info_json) } } /** * Uptime with calculation * Calculation based on: * https://www.uptrends.com/support/kb/reporting/calculation-of-uptime-and-downtime * @param duration : int Hours */ static async calcUptime (duration, monitorID) { 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) const 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) 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`) const totalDuration = result.total_duration const 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 const status = parseInt(await R.getCell('SELECT `status` FROM heartbeat WHERE monitor_id = ?', [monitorID])) if (status === UP) { uptime = 1 } } return uptime } /** * Send Uptime * @param duration : int Hours */ static async sendUptime (duration, io, monitorID, userID) { const uptime = await this.calcUptime(duration, monitorID) io.to(userID).emit('uptime', monitorID, duration, uptime) } 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 const isImportant = isFirstBeat || (previousBeatStatus === UP && currentBeatStatus === DOWN) || (previousBeatStatus === DOWN && currentBeatStatus === UP) || (previousBeatStatus === PENDING && currentBeatStatus === DOWN) return isImportant } 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' } const msg = `[${monitor.name}] [${text}] ${bean.msg}` for (const notification of notificationList) { try { await Notification.send(JSON.parse(notification.config), msg, await monitor.toJSON(), bean.toJSON()) } catch (e) { console.error('Cannot send notification to ' + notification.name) console.log(e) } } } } static async getNotificationList (monitor) { const notificationList = await R.getAll('SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id ', [ monitor.id ]) return notificationList } async sendCertNotification (tlsInfoObject) { if (tlsInfoObject && tlsInfoObject.certInfo && tlsInfoObject.certInfo.daysRemaining) { const notificationList = await Monitor.getNotificationList(this) debug('call sendCertNotificationByTargetDays') await this.sendCertNotificationByTargetDays(tlsInfoObject.certInfo.daysRemaining, 21, notificationList) await this.sendCertNotificationByTargetDays(tlsInfoObject.certInfo.daysRemaining, 14, notificationList) await this.sendCertNotificationByTargetDays(tlsInfoObject.certInfo.daysRemaining, 7, notificationList) } } async sendCertNotificationByTargetDays (daysRemaining, targetDays, notificationList) { if (daysRemaining > targetDays) { debug(`No need to send cert notification. ${daysRemaining} > ${targetDays}`) return } if (notificationList.length > 0) { const 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) { debug('Sent already, no need to send again') return } let sent = false debug('Send certificate notification') for (const notification of notificationList) { try { debug('Sending to ' + notification.name) await Notification.send(JSON.parse(notification.config), `[${this.name}][${this.url}] Certificate will be expired in ${daysRemaining} days`) sent = true } catch (e) { console.error('Cannot send cert notification to ' + notification.name) console.error(e) } } if (sent) { await R.exec('INSERT INTO notification_sent_history (type, monitor_id, days) VALUES(?, ?, ?)', [ 'certificate', this.id, targetDays ]) } } else { debug('No notification, no need to send cert notification') } } static async getPreviousHeartbeat (monitorID) { return await R.getRow(` SELECT status, time FROM heartbeat WHERE id = (select MAX(id) from heartbeat where monitor_id = ?) `, [ monitorID ]) } } module.exports = Monitor