Kuma/server/model/monitor.js
2022-03-22 21:14:33 +07:00

776 lines
25 KiB
JavaScript

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')
const moment = require('moment')
/**
* 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<object>}
*/
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
let message
if (bean.status === UP) {
text = '✅ Up'
const heartbeat = await Monitor.getPreviousHeartbeatByStatus(monitor, DOWN)
message = `${text}: ${monitor.name} ( ${monitor.url} ).`
if (heartbeat) {
const diff = moment(bean.time).diff(moment(heartbeat.time))
const duration = moment.utc(diff).format('HH:mm:ss.SSS')
message += ` It was down for ${duration}.`
}
} else {
text = '🔴 Down'
message = `${text}: ${monitor.name} ( ${monitor.url} ). Reason: ${bean.msg}.`
}
for (const notification of notificationList) {
try {
await Notification.send(JSON.parse(notification.config), message, 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
])
}
static async getPreviousHeartbeatByStatus (monitorID, status = 0) {
return await R.getRow(`
SELECT status, time FROM heartbeat
WHERE id = (select MAX(id) from heartbeat where monitor_id = ? and status = ?)
`, [
monitorID, status
])
}
}
module.exports = Monitor