Merge branch 'master' into feature/add_prometheus_metrics

# Conflicts:
#	server/model/monitor.js
This commit is contained in:
LouisLam
2021-07-27 23:13:03 +08:00
30 changed files with 442 additions and 62 deletions

View File

@@ -8,7 +8,7 @@ class Database {
static templatePath = "./db/kuma.db"
static path = './data/kuma.db';
static latestVersion = 1;
static latestVersion = 3;
static noReject = true;
static async patch() {

View File

@@ -1,15 +1,23 @@
const Prometheus = require('prom-client');
const https = require('https');
const dayjs = require("dayjs");
const utc = require('dayjs/plugin/utc')
var timezone = require('dayjs/plugin/timezone')
dayjs.extend(utc)
dayjs.extend(timezone)
const axios = require("axios");
const {tcping, ping} = require("../util-server");
const {debug, UP, DOWN, PENDING} = require("../util");
const {tcping, ping, checkCertificate} = require("../util-server");
const {R} = require("redbean-node");
const {BeanModel} = require("redbean-node/dist/bean-model");
const {Notification} = require("../notification")
// Use Custom agent to disable session reuse
// https://github.com/nodejs/node/issues/3940
const customAgent = new https.Agent({
maxCachedSessions: 0
});
const commonLabels = [
'monitor_name',
'monitor_type',
@@ -18,24 +26,24 @@ const commonLabels = [
'monitor_port',
]
const monitor_response_time = new Prometheus.Gauge({
name: 'monitor_response_time',
help: 'Monitor Response Time (ms)',
labelNames: commonLabels
});
const monitor_status = new Prometheus.Gauge({
name: 'monitor_status',
help: 'Monitor Status (1 = UP, 0= DOWN)',
labelNames: commonLabels
});
/**
* status:
* 0 = DOWN
* 1 = UP
*/
class Monitor extends BeanModel {
async toJSON() {
let notificationIDList = {};
@@ -54,6 +62,7 @@ class Monitor extends BeanModel {
url: this.url,
hostname: this.hostname,
port: this.port,
maxretries: this.maxretries,
weight: this.weight,
active: this.active,
type: this.type,
@@ -65,6 +74,7 @@ class Monitor extends BeanModel {
start(io) {
let previousBeat = null;
let retries = 0;
const monitorLabelValues = {
monitor_name: this.name,
@@ -74,21 +84,23 @@ class Monitor extends BeanModel {
monitor_port: this.port
}
const beat = async () => {
if (! previousBeat) {
previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [
this.id
])
}
const isFirstBeat = !previousBeat;
let bean = R.dispense("heartbeat")
bean.monitor_id = this.id;
bean.time = R.isoDateTime(dayjs.utc());
bean.status = 0;
bean.status = DOWN;
// Duration
if (previousBeat) {
if (! isFirstBeat) {
bean.duration = dayjs(bean.time).diff(dayjs(previousBeat.time), 'second');
} else {
bean.duration = 0;
@@ -98,13 +110,27 @@ class Monitor extends BeanModel {
if (this.type === "http" || this.type === "keyword") {
let startTime = dayjs().valueOf();
let res = await axios.get(this.url, {
headers: { 'User-Agent':'Uptime-Kuma' }
})
headers: { "User-Agent": "Uptime-Kuma" },
httpsAgent: customAgent,
});
bean.msg = `${res.status} - ${res.statusText}`
bean.ping = dayjs().valueOf() - startTime;
// Check certificate if https is used
let certInfoStartTime = dayjs().valueOf();
if (this.getUrl()?.protocol === "https:") {
try {
await this.updateTlsInfo(checkCertificate(res));
} catch (e) {
console.error(e.message)
}
}
debug("Cert Info Query Time: " + (dayjs().valueOf() - certInfoStartTime) + "ms")
if (this.type === "http") {
bean.status = 1;
bean.status = UP;
} else {
let data = res.data;
@@ -116,7 +142,7 @@ class Monitor extends BeanModel {
if (data.includes(this.keyword)) {
bean.msg += ", keyword is found"
bean.status = 1;
bean.status = UP;
} else {
throw new Error(bean.msg + ", but keyword is not found")
}
@@ -127,30 +153,52 @@ class Monitor extends BeanModel {
} else if (this.type === "port") {
bean.ping = await tcping(this.hostname, this.port);
bean.msg = ""
bean.status = 1;
bean.status = UP;
} else if (this.type === "ping") {
bean.ping = await ping(this.hostname);
bean.msg = ""
bean.status = 1;
bean.status = UP;
}
retries = 0;
} catch (error) {
if ((this.maxretries > 0) && (retries < this.maxretries)) {
retries++;
bean.status = PENDING;
}
bean.msg = error.message;
}
// Mark as important if status changed
if (! previousBeat || previousBeat.status !== bean.status) {
// * ? -> 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
let isImportant = isFirstBeat ||
(previousBeat.status === UP && bean.status === DOWN) ||
(previousBeat.status === DOWN && bean.status === UP) ||
(previousBeat.status === PENDING && bean.status === DOWN);
// Mark as important if status changed, ignore pending pings,
// Don't notify if disrupted changes to up
if (isImportant) {
bean.important = true;
// Do not send if first beat is UP
if (previousBeat || bean.status !== 1) {
// Send only if the first beat is DOWN
if (!isFirstBeat || bean.status === DOWN) {
let notificationList = await R.getAll(`SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id `, [
this.id
])
let text;
if (bean.status === 1) {
if (bean.status === UP) {
text = "✅ Up"
} else {
text = "🔴 Down"
@@ -171,11 +219,12 @@ class Monitor extends BeanModel {
bean.important = false;
}
monitor_status.set(monitorLabelValues, bean.status)
if (bean.status === 1) {
if (bean.status === UP) {
console.info(`Monitor #${this.id} '${this.name}': Successful Response: ${bean.ping} ms | Interval: ${this.interval} seconds | Type: ${this.type}`)
} else if (bean.status === PENDING) {
console.warn(`Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Type: ${this.type}`)
} else {
console.warn(`Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Type: ${this.type}`)
}
@@ -198,10 +247,35 @@ class Monitor extends BeanModel {
clearInterval(this.heartbeatInterval)
}
// Helper Method:
// returns URL object for further usage
// returns null if url is invalid
getUrl() {
try {
return new URL(this.url);
} catch (_) {
return null;
}
}
// Store TLS info to database
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;
}
tls_info_bean.info_json = JSON.stringify(checkCertificateResult);
await R.store(tls_info_bean);
}
static async sendStats(io, monitorID, userID) {
Monitor.sendAvgPing(24, io, monitorID, userID);
Monitor.sendUptime(24, io, monitorID, userID);
Monitor.sendUptime(24 * 30, io, monitorID, userID);
Monitor.sendCertInfo(io, monitorID, userID);
}
/**
@@ -222,6 +296,15 @@ class Monitor extends BeanModel {
io.to(userID).emit("avgPing", monitorID, avgPing);
}
static async sendCertInfo(io, monitorID, userID) {
let 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:
@@ -270,7 +353,7 @@ class Monitor extends BeanModel {
}
total += value;
if (row.status === 0) {
if (row.status === 0 || row.status === 2) {
downtime += value;
}
}

View File

@@ -204,7 +204,7 @@ class Notification {
}
let data = {
"message": "<b>Uptime Kuma Alert</b>\n\n<b>Message</b>:" +msg + '\n<b>Time (UTC)</b>:' +time,
"message": "<b>Uptime Kuma Alert</b>\n\n<b>Message</b>:"+msg+ '\n<b>Time (UTC)</b>:' +heartbeatJSON["time"],
"user":notification.pushoveruserkey,
"token": notification.pushoverapptoken,
"sound": notification.pushoversounds,

View File

@@ -235,6 +235,7 @@ let needSetup = false;
bean.url = monitor.url
bean.interval = monitor.interval
bean.hostname = monitor.hostname;
bean.maxretries = monitor.maxretries;
bean.port = monitor.port;
bean.keyword = monitor.keyword;
@@ -544,12 +545,12 @@ async function afterLogin(socket, user) {
let monitorList = await sendMonitorList(socket)
for (let monitorID in monitorList) {
await sendHeartbeatList(socket, monitorID);
await sendImportantHeartbeatList(socket, monitorID);
await Monitor.sendStats(io, monitorID, user.id)
sendHeartbeatList(socket, monitorID);
sendImportantHeartbeatList(socket, monitorID);
Monitor.sendStats(io, monitorID, user.id)
}
await sendNotificationList(socket)
sendNotificationList(socket)
}
async function getMonitorJSONList(userID) {

View File

@@ -70,3 +70,52 @@ exports.getSettings = async function (type) {
return result;
}
// ssl-checker by @dyaa
// param: res - response object from axios
// return an object containing the certificate information
const getDaysBetween = (validFrom, validTo) =>
Math.round(Math.abs(+validFrom - +validTo) / 8.64e7);
const getDaysRemaining = (validFrom, validTo) => {
const daysRemaining = getDaysBetween(validFrom, validTo);
if (new Date(validTo).getTime() < new Date().getTime()) {
return -daysRemaining;
}
return daysRemaining;
};
exports.checkCertificate = function (res) {
const {
valid_from,
valid_to,
subjectaltname,
issuer,
fingerprint,
} = res.request.res.socket.getPeerCertificate(false);
if (!valid_from || !valid_to || !subjectaltname) {
throw { message: 'No TLS certificate in response' };
}
const valid = res.request.res.socket.authorized || false;
const validTo = new Date(valid_to);
const validFor = subjectaltname
.replace(/DNS:|IP Address:/g, "")
.split(", ");
const daysRemaining = getDaysRemaining(new Date(), validTo);
return {
valid,
validFor,
validTo,
daysRemaining,
issuer,
fingerprint,
};
}

View File

@@ -1,6 +1,10 @@
// Common JS cannot be used in frontend sadly
// sleep, ucfirst is duplicated in ../src/util-frontend.js
exports.DOWN = 0;
exports.UP = 1;
exports.PENDING = 2;
exports.sleep = function (ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
@@ -14,3 +18,9 @@ exports.ucfirst = function (str) {
return firstLetter.toUpperCase() + str.substr(1);
}
exports.debug = (msg) => {
if (process.env.NODE_ENV === "development") {
console.log(msg)
}
}