mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-08-09 12:21:22 +08:00
Merge remote-tracking branch 'origin/master' into miles/invert-keyword
# Conflicts: # server/database.js
This commit is contained in:
@@ -2,6 +2,7 @@ const basicAuth = require("express-basic-auth");
|
||||
const passwordHash = require("./password-hash");
|
||||
const { R } = require("redbean-node");
|
||||
const { setting } = require("./util-server");
|
||||
const { log } = require("../src/util");
|
||||
const { loginRateLimiter, apiRateLimiter } = require("./rate-limiter");
|
||||
const { Settings } = require("./settings");
|
||||
const dayjs = require("dayjs");
|
||||
@@ -81,12 +82,16 @@ function apiAuthorizer(username, password, callback) {
|
||||
apiRateLimiter.pass(null, 0).then((pass) => {
|
||||
if (pass) {
|
||||
verifyAPIKey(password).then((valid) => {
|
||||
if (!valid) {
|
||||
log.warn("api-auth", "Failed API auth attempt: invalid API Key");
|
||||
}
|
||||
callback(null, valid);
|
||||
// Only allow a set number of api requests per minute
|
||||
// (currently set to 60)
|
||||
apiRateLimiter.removeTokens(1);
|
||||
});
|
||||
} else {
|
||||
log.warn("api-auth", "Failed API auth attempt: rate limit exceeded");
|
||||
callback(null, false);
|
||||
}
|
||||
});
|
||||
@@ -106,10 +111,12 @@ function userAuthorizer(username, password, callback) {
|
||||
callback(null, user != null);
|
||||
|
||||
if (user == null) {
|
||||
log.warn("basic-auth", "Failed basic auth attempt: invalid username/password");
|
||||
loginRateLimiter.removeTokens(1);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
log.warn("basic-auth", "Failed basic auth attempt: rate limit exceeded");
|
||||
callback(null, false);
|
||||
}
|
||||
});
|
||||
|
@@ -1,27 +1,33 @@
|
||||
const { setSetting, setting } = require("./util-server");
|
||||
const axios = require("axios");
|
||||
const compareVersions = require("compare-versions");
|
||||
const { log } = require("../src/util");
|
||||
|
||||
exports.version = require("../package.json").version;
|
||||
exports.latestVersion = null;
|
||||
|
||||
// How much time in ms to wait between update checks
|
||||
const UPDATE_CHECKER_INTERVAL_MS = 1000 * 60 * 60 * 48;
|
||||
const UPDATE_CHECKER_LATEST_VERSION_URL = "https://uptime.kuma.pet/version";
|
||||
|
||||
let interval;
|
||||
|
||||
/** Start 48 hour check interval */
|
||||
exports.startInterval = () => {
|
||||
let check = async () => {
|
||||
if (await setting("checkUpdate") === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("update-checker", "Retrieving latest versions");
|
||||
|
||||
try {
|
||||
const res = await axios.get("https://uptime.kuma.pet/version");
|
||||
const res = await axios.get(UPDATE_CHECKER_LATEST_VERSION_URL);
|
||||
|
||||
// For debug
|
||||
if (process.env.TEST_CHECK_VERSION === "1") {
|
||||
res.data.slow = "1000.0.0";
|
||||
}
|
||||
|
||||
if (await setting("checkUpdate") === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
let checkBeta = await setting("checkBeta");
|
||||
|
||||
if (checkBeta && res.data.beta) {
|
||||
@@ -35,12 +41,14 @@ exports.startInterval = () => {
|
||||
exports.latestVersion = res.data.slow;
|
||||
}
|
||||
|
||||
} catch (_) { }
|
||||
} catch (_) {
|
||||
log.info("update-checker", "Failed to check for new versions");
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
check();
|
||||
interval = setInterval(check, 3600 * 1000 * 48);
|
||||
interval = setInterval(check, UPDATE_CHECKER_INTERVAL_MS);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@@ -2,9 +2,7 @@ const fs = require("fs");
|
||||
const { R } = require("redbean-node");
|
||||
const { setSetting, setting } = require("./util-server");
|
||||
const { log, sleep } = require("../src/util");
|
||||
const dayjs = require("dayjs");
|
||||
const knex = require("knex");
|
||||
const { PluginsManager } = require("./plugins-manager");
|
||||
|
||||
/**
|
||||
* Database & App Data Folder
|
||||
@@ -23,6 +21,8 @@ class Database {
|
||||
*/
|
||||
static uploadDir;
|
||||
|
||||
static screenshotDir;
|
||||
|
||||
static path;
|
||||
|
||||
/**
|
||||
@@ -70,6 +70,7 @@ class Database {
|
||||
"patch-api-key-table.sql": true,
|
||||
"patch-monitor-tls.sql": true,
|
||||
"patch-maintenance-cron.sql": true,
|
||||
"patch-add-parent-monitor.sql": true,
|
||||
"patch-add-invert-keyword.sql": true,
|
||||
};
|
||||
|
||||
@@ -89,12 +90,6 @@ class Database {
|
||||
// Data Directory (must be end with "/")
|
||||
Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/";
|
||||
|
||||
// Plugin feature is working only if the dataDir = "./data";
|
||||
if (Database.dataDir !== "./data/") {
|
||||
log.warn("PLUGIN", "Warning: In order to enable plugin feature, you need to use the default data directory: ./data/");
|
||||
PluginsManager.disable = true;
|
||||
}
|
||||
|
||||
Database.path = Database.dataDir + "kuma.db";
|
||||
if (! fs.existsSync(Database.dataDir)) {
|
||||
fs.mkdirSync(Database.dataDir, { recursive: true });
|
||||
@@ -106,6 +101,12 @@ class Database {
|
||||
fs.mkdirSync(Database.uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create screenshot dir
|
||||
Database.screenshotDir = Database.dataDir + "screenshots/";
|
||||
if (! fs.existsSync(Database.screenshotDir)) {
|
||||
fs.mkdirSync(Database.screenshotDir, { recursive: true });
|
||||
}
|
||||
|
||||
log.info("db", `Data Dir: ${Database.dataDir}`);
|
||||
}
|
||||
|
||||
@@ -162,12 +163,12 @@ class Database {
|
||||
await R.exec("PRAGMA journal_mode = WAL");
|
||||
}
|
||||
await R.exec("PRAGMA cache_size = -12000");
|
||||
await R.exec("PRAGMA auto_vacuum = FULL");
|
||||
await R.exec("PRAGMA auto_vacuum = INCREMENTAL");
|
||||
|
||||
// This ensures that an operating system crash or power failure will not corrupt the database.
|
||||
// FULL synchronous is very safe, but it is also slower.
|
||||
// Read more: https://sqlite.org/pragma.html#pragma_synchronous
|
||||
await R.exec("PRAGMA synchronous = FULL");
|
||||
await R.exec("PRAGMA synchronous = NORMAL");
|
||||
|
||||
if (!noLog) {
|
||||
log.info("db", "SQLite config:");
|
||||
@@ -418,6 +419,9 @@ class Database {
|
||||
|
||||
log.info("db", "Closing the database");
|
||||
|
||||
// Flush WAL to main database
|
||||
await R.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
||||
|
||||
while (true) {
|
||||
Database.noReject = true;
|
||||
await R.close();
|
||||
|
@@ -1,24 +0,0 @@
|
||||
const childProcess = require("child_process");
|
||||
|
||||
class Git {
|
||||
|
||||
static clone(repoURL, cwd, targetDir = ".") {
|
||||
let result = childProcess.spawnSync("git", [
|
||||
"clone",
|
||||
repoURL,
|
||||
targetDir,
|
||||
], {
|
||||
cwd: cwd,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr.toString("utf-8"));
|
||||
} else {
|
||||
return result.stdout.toString("utf-8") + result.stderr.toString("utf-8");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Git,
|
||||
};
|
@@ -1,41 +1,44 @@
|
||||
const path = require("path");
|
||||
const Bree = require("bree");
|
||||
const { SHARE_ENV } = require("worker_threads");
|
||||
const { log } = require("../src/util");
|
||||
let bree;
|
||||
const { UptimeKumaServer } = require("./uptime-kuma-server");
|
||||
const { clearOldData } = require("./jobs/clear-old-data");
|
||||
const Cron = require("croner");
|
||||
|
||||
const jobs = [
|
||||
{
|
||||
name: "clear-old-data",
|
||||
interval: "at 03:14",
|
||||
interval: "14 03 * * *",
|
||||
jobFunc: clearOldData,
|
||||
croner: null,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Initialize background jobs
|
||||
* @param {Object} args Arguments to pass to workers
|
||||
* @returns {Bree}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const initBackgroundJobs = function (args) {
|
||||
bree = new Bree({
|
||||
root: path.resolve("server", "jobs"),
|
||||
jobs,
|
||||
worker: {
|
||||
env: SHARE_ENV,
|
||||
workerData: args,
|
||||
},
|
||||
workerMessageHandler: (message) => {
|
||||
log.info("jobs", message);
|
||||
}
|
||||
});
|
||||
const initBackgroundJobs = async function () {
|
||||
const timezone = await UptimeKumaServer.getInstance().getTimezone();
|
||||
|
||||
for (const job of jobs) {
|
||||
const cornerJob = new Cron(
|
||||
job.interval,
|
||||
{
|
||||
name: job.name,
|
||||
timezone,
|
||||
},
|
||||
job.jobFunc,
|
||||
);
|
||||
job.croner = cornerJob;
|
||||
}
|
||||
|
||||
bree.start();
|
||||
return bree;
|
||||
};
|
||||
|
||||
/** Stop all background jobs if running */
|
||||
const stopBackgroundJobs = function () {
|
||||
if (bree) {
|
||||
bree.stop();
|
||||
for (const job of jobs) {
|
||||
if (job.croner) {
|
||||
job.croner.stop();
|
||||
job.croner = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -1,12 +1,15 @@
|
||||
const { log, exit, connectDb } = require("./util-worker");
|
||||
const { R } = require("redbean-node");
|
||||
const { log } = require("../../src/util");
|
||||
const { setSetting, setting } = require("../util-server");
|
||||
|
||||
const DEFAULT_KEEP_PERIOD = 180;
|
||||
|
||||
(async () => {
|
||||
await connectDb();
|
||||
/**
|
||||
* Clears old data from the heartbeat table of the database.
|
||||
* @return {Promise<void>} A promise that resolves when the data has been cleared.
|
||||
*/
|
||||
|
||||
const clearOldData = async () => {
|
||||
let period = await setting("keepDataPeriodDays");
|
||||
|
||||
// Set Default Period
|
||||
@@ -20,16 +23,16 @@ const DEFAULT_KEEP_PERIOD = 180;
|
||||
try {
|
||||
parsedPeriod = parseInt(period);
|
||||
} catch (_) {
|
||||
log("Failed to parse setting, resetting to default..");
|
||||
log.warn("clearOldData", "Failed to parse setting, resetting to default..");
|
||||
await setSetting("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general");
|
||||
parsedPeriod = DEFAULT_KEEP_PERIOD;
|
||||
}
|
||||
|
||||
if (parsedPeriod < 1) {
|
||||
log(`Data deletion has been disabled as period is less than 1. Period is ${parsedPeriod} days.`);
|
||||
log.info("clearOldData", `Data deletion has been disabled as period is less than 1. Period is ${parsedPeriod} days.`);
|
||||
} else {
|
||||
|
||||
log(`Clearing Data older than ${parsedPeriod} days...`);
|
||||
log.debug("clearOldData", `Clearing Data older than ${parsedPeriod} days...`);
|
||||
|
||||
try {
|
||||
await R.exec(
|
||||
@@ -37,9 +40,11 @@ const DEFAULT_KEEP_PERIOD = 180;
|
||||
[ parsedPeriod ]
|
||||
);
|
||||
} catch (e) {
|
||||
log(`Failed to clear old data: ${e.message}`);
|
||||
log.error("clearOldData", `Failed to clear old data: ${e.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exit();
|
||||
})();
|
||||
module.exports = {
|
||||
clearOldData,
|
||||
};
|
||||
|
@@ -1,50 +0,0 @@
|
||||
const { parentPort, workerData } = require("worker_threads");
|
||||
const Database = require("../database");
|
||||
const path = require("path");
|
||||
|
||||
/**
|
||||
* Send message to parent process for logging
|
||||
* since worker_thread does not have access to stdout, this is used
|
||||
* instead of console.log()
|
||||
* @param {any} any The message to log
|
||||
*/
|
||||
const log = function (any) {
|
||||
if (parentPort) {
|
||||
parentPort.postMessage(any);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Exit the worker process
|
||||
* @param {number} error The status code to exit
|
||||
*/
|
||||
const exit = function (error) {
|
||||
if (error && error !== 0) {
|
||||
process.exit(error);
|
||||
} else {
|
||||
if (parentPort) {
|
||||
parentPort.postMessage("done");
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Connects to the database */
|
||||
const connectDb = async function () {
|
||||
const dbPath = path.join(
|
||||
process.env.DATA_DIR || workerData["data-dir"] || "./data/"
|
||||
);
|
||||
|
||||
Database.init({
|
||||
"data-dir": dbPath,
|
||||
});
|
||||
|
||||
await Database.connect();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
log,
|
||||
exit,
|
||||
connectDb,
|
||||
};
|
@@ -47,7 +47,8 @@ class Maintenance extends BeanModel {
|
||||
cron: this.cron,
|
||||
duration: this.duration,
|
||||
durationMinutes: parseInt(this.duration / 60),
|
||||
timezone: await this.getTimezone(),
|
||||
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(),
|
||||
};
|
||||
@@ -153,7 +154,7 @@ class Maintenance extends BeanModel {
|
||||
bean.description = obj.description;
|
||||
bean.strategy = obj.strategy;
|
||||
bean.interval_day = obj.intervalDay;
|
||||
bean.timezone = obj.timezone;
|
||||
bean.timezone = obj.timezoneOption;
|
||||
bean.active = obj.active;
|
||||
|
||||
if (obj.dateRange[0]) {
|
||||
@@ -290,7 +291,7 @@ class Maintenance extends BeanModel {
|
||||
}
|
||||
|
||||
getRunningTimeslot() {
|
||||
let start = dayjs(this.beanMeta.job.nextRun(dayjs().add(-this.duration, "second").format("YYYY-MM-DD HH:mm:ss")));
|
||||
let start = dayjs(this.beanMeta.job.nextRun(dayjs().add(-this.duration, "second").toDate()));
|
||||
let end = start.add(this.duration, "second");
|
||||
let current = dayjs();
|
||||
|
||||
@@ -316,7 +317,7 @@ class Maintenance extends BeanModel {
|
||||
}
|
||||
|
||||
async getTimezone() {
|
||||
if (!this.timezone) {
|
||||
if (!this.timezone || this.timezone === "SAME_AS_SERVER") {
|
||||
return await UptimeKumaServer.getInstance().getTimezone();
|
||||
}
|
||||
return this.timezone;
|
||||
|
@@ -2,7 +2,9 @@ const https = require("https");
|
||||
const dayjs = require("dayjs");
|
||||
const axios = require("axios");
|
||||
const { Prometheus } = require("../prometheus");
|
||||
const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND } = require("../../src/util");
|
||||
const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND,
|
||||
SQL_DATETIME_FORMAT
|
||||
} = require("../../src/util");
|
||||
const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery,
|
||||
redisPingAsync, mongodbPing,
|
||||
} = require("../util-server");
|
||||
@@ -18,6 +20,7 @@ const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent");
|
||||
const { DockerHost } = require("../docker");
|
||||
const { UptimeCacheList } = require("../uptime-cache-list");
|
||||
const Gamedig = require("gamedig");
|
||||
const jwt = require("jsonwebtoken");
|
||||
|
||||
/**
|
||||
* status:
|
||||
@@ -68,17 +71,27 @@ class Monitor extends BeanModel {
|
||||
|
||||
const tags = await this.getTags();
|
||||
|
||||
let screenshot = null;
|
||||
|
||||
if (this.type === "real-browser") {
|
||||
screenshot = "/screenshots/" + jwt.sign(this.id, UptimeKumaServer.getInstance().jwtSecret) + ".png";
|
||||
}
|
||||
|
||||
let data = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
pathName: await this.getPathName(),
|
||||
parent: this.parent,
|
||||
childrenIDs: await Monitor.getAllChildrenIDs(this.id),
|
||||
url: this.url,
|
||||
method: this.method,
|
||||
hostname: this.hostname,
|
||||
port: this.port,
|
||||
maxretries: this.maxretries,
|
||||
weight: this.weight,
|
||||
active: this.active,
|
||||
active: await this.isActive(),
|
||||
forceInactive: !await Monitor.isParentActive(this.id),
|
||||
type: this.type,
|
||||
interval: this.interval,
|
||||
retryInterval: this.retryInterval,
|
||||
@@ -112,7 +125,8 @@ class Monitor extends BeanModel {
|
||||
radiusCalledStationId: this.radiusCalledStationId,
|
||||
radiusCallingStationId: this.radiusCallingStationId,
|
||||
game: this.game,
|
||||
httpBodyEncoding: this.httpBodyEncoding
|
||||
httpBodyEncoding: this.httpBodyEncoding,
|
||||
screenshot,
|
||||
};
|
||||
|
||||
if (includeSensitiveData) {
|
||||
@@ -143,6 +157,16 @@ class Monitor extends BeanModel {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the monitor is active based on itself and its parents
|
||||
* @returns {Promise<Boolean>}
|
||||
*/
|
||||
async isActive() {
|
||||
const parentActive = await Monitor.isParentActive(this.id);
|
||||
|
||||
return this.active && parentActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags applied to this monitor
|
||||
* @returns {Promise<LooseObject<any>[]>}
|
||||
@@ -266,6 +290,36 @@ class Monitor extends BeanModel {
|
||||
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
|
||||
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") {
|
||||
// Do not do any queries/high loading things before the "bean.ping"
|
||||
let startTime = dayjs().valueOf();
|
||||
@@ -372,8 +426,8 @@ class Monitor extends BeanModel {
|
||||
tlsInfo = await this.updateTlsInfo(tlsInfoObject);
|
||||
|
||||
if (!this.getIgnoreTls() && this.isEnabledExpiryNotification()) {
|
||||
log.debug("monitor", `[${this.name}] call sendCertNotification`);
|
||||
await this.sendCertNotification(tlsInfoObject);
|
||||
log.debug("monitor", `[${this.name}] call checkCertExpiryNotifications`);
|
||||
await this.checkCertExpiryNotifications(tlsInfoObject);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
@@ -408,7 +462,7 @@ class Monitor extends BeanModel {
|
||||
bean.msg += ", keyword " + (keywordFound ? "is" : "not") + " found";
|
||||
bean.status = UP;
|
||||
} else {
|
||||
data = data.replace(/<[^>]*>?|[\n\r]|\s+/gm, " ");
|
||||
data = data.replace(/<[^>]*>?|[\n\r]|\s+/gm, " ").trim();
|
||||
if (data.length > 50) {
|
||||
data = data.substring(0, 47) + "...";
|
||||
}
|
||||
@@ -583,9 +637,15 @@ class Monitor extends BeanModel {
|
||||
|
||||
log.debug("monitor", `[${this.name}] Axios Request`);
|
||||
let res = await axios.request(options);
|
||||
|
||||
if (res.data.State.Running) {
|
||||
bean.status = UP;
|
||||
bean.msg = res.data.State.Status;
|
||||
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);
|
||||
}
|
||||
@@ -705,7 +765,7 @@ class Monitor extends BeanModel {
|
||||
} else if (this.type in UptimeKumaServer.monitorTypeList) {
|
||||
let startTime = dayjs().valueOf();
|
||||
const monitorType = UptimeKumaServer.monitorTypeList[this.type];
|
||||
await monitorType.check(this, bean);
|
||||
await monitorType.check(this, bean, UptimeKumaServer.getInstance());
|
||||
if (!bean.ping) {
|
||||
bean.ping = dayjs().valueOf() - startTime;
|
||||
}
|
||||
@@ -1187,12 +1247,18 @@ class Monitor extends BeanModel {
|
||||
|
||||
for (let notification of notificationList) {
|
||||
try {
|
||||
// Prevent if the msg is undefined, notifications such as Discord cannot send out.
|
||||
const heartbeatJSON = bean.toJSON();
|
||||
|
||||
// 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, await monitor.toJSON(false), heartbeatJSON);
|
||||
} catch (e) {
|
||||
log.error("monitor", "Cannot send notification to " + notification.name);
|
||||
@@ -1215,13 +1281,19 @@ class Monitor extends BeanModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification about a certificate
|
||||
* checks certificate chain for expiring certificates
|
||||
* @param {Object} tlsInfoObject Information about certificate
|
||||
*/
|
||||
async sendCertNotification(tlsInfoObject) {
|
||||
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
|
||||
@@ -1229,10 +1301,19 @@ class Monitor extends BeanModel {
|
||||
notifyDays = [ 7, 14, 21 ];
|
||||
}
|
||||
|
||||
if (notifyDays != null && Array.isArray(notifyDays)) {
|
||||
for (const day of notifyDays) {
|
||||
log.debug("monitor", "call sendCertNotificationByTargetDays", day);
|
||||
await this.sendCertNotificationByTargetDays(tlsInfoObject.certInfo.daysRemaining, day, notificationList);
|
||||
if (Array.isArray(notifyDays)) {
|
||||
for (const targetDays of notifyDays) {
|
||||
let certInfo = tlsInfoObject.certInfo;
|
||||
while (certInfo) {
|
||||
let subjectCN = certInfo.subject["CN"];
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1241,55 +1322,47 @@ class Monitor extends BeanModel {
|
||||
/**
|
||||
* Send a certificate notification when certificate expires in less
|
||||
* than target days
|
||||
* @param {number} daysRemaining Number of days remaining on certifcate
|
||||
* @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(daysRemaining, targetDays, notificationList) {
|
||||
async sendCertNotificationByTargetDays(certCN, certType, daysRemaining, targetDays, notificationList) {
|
||||
|
||||
if (daysRemaining > targetDays) {
|
||||
log.debug("monitor", `No need to send cert notification. ${daysRemaining} > ${targetDays}`);
|
||||
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;
|
||||
}
|
||||
|
||||
if (notificationList.length > 0) {
|
||||
let sent = false;
|
||||
log.debug("monitor", "Send certificate notification");
|
||||
|
||||
let row = await R.getRow("SELECT * FROM notification_sent_history WHERE type = ? AND monitor_id = ? AND days <= ?", [
|
||||
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,
|
||||
]);
|
||||
|
||||
// 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}] Certificate will expire 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,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
log.debug("monitor", "No notification, no need to send cert notification");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1325,6 +1398,11 @@ class Monitor extends BeanModel {
|
||||
}
|
||||
}
|
||||
|
||||
const parent = await Monitor.getParent(monitorID);
|
||||
if (parent != null) {
|
||||
return await Monitor.isUnderMaintenance(parent.id);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1337,6 +1415,105 @@ class Monitor extends BeanModel {
|
||||
throw new Error(`Interval cannot be less than ${MIN_INTERVAL_SECOND} seconds`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Parent of the monitor
|
||||
* @param {number} monitorID ID of monitor to get
|
||||
* @returns {Promise<LooseObject<any>>}
|
||||
*/
|
||||
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>>}
|
||||
*/
|
||||
static async getChildren(monitorID) {
|
||||
return await R.getAll(`
|
||||
SELECT * FROM monitor
|
||||
WHERE parent = ?
|
||||
`, [
|
||||
monitorID,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Full Path-Name (Groups and Name)
|
||||
* @returns {Promise<String>}
|
||||
*/
|
||||
async getPathName() {
|
||||
let path = this.name;
|
||||
|
||||
if (this.parent === null) {
|
||||
return path;
|
||||
}
|
||||
|
||||
let parent = await Monitor.getParent(this.id);
|
||||
while (parent !== null) {
|
||||
path = `${parent.name} / ${path}`;
|
||||
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>}
|
||||
*/
|
||||
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 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>}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Monitor;
|
||||
|
@@ -6,9 +6,10 @@ class MonitorType {
|
||||
*
|
||||
* @param {Monitor} monitor
|
||||
* @param {Heartbeat} heartbeat
|
||||
* @param {UptimeKumaServer} server
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async check(monitor, heartbeat) {
|
||||
async check(monitor, heartbeat, server) {
|
||||
throw new Error("You need to override check()");
|
||||
}
|
||||
|
||||
|
212
server/monitor-types/real-browser-monitor-type.js
Normal file
212
server/monitor-types/real-browser-monitor-type.js
Normal file
@@ -0,0 +1,212 @@
|
||||
const { MonitorType } = require("./monitor-type");
|
||||
const { chromium } = require("playwright-core");
|
||||
const { UP, log } = require("../../src/util");
|
||||
const { Settings } = require("../settings");
|
||||
const commandExistsSync = require("command-exists").sync;
|
||||
const childProcess = require("child_process");
|
||||
const path = require("path");
|
||||
const Database = require("../database");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const config = require("../config");
|
||||
|
||||
let browser = null;
|
||||
|
||||
let allowedList = [];
|
||||
let lastAutoDetectChromeExecutable = null;
|
||||
|
||||
if (process.platform === "win32") {
|
||||
allowedList.push(process.env.LOCALAPPDATA + "\\Google\\Chrome\\Application\\chrome.exe");
|
||||
allowedList.push(process.env.PROGRAMFILES + "\\Google\\Chrome\\Application\\chrome.exe");
|
||||
allowedList.push(process.env["ProgramFiles(x86)"] + "\\Google\\Chrome\\Application\\chrome.exe");
|
||||
|
||||
// Allow Chromium too
|
||||
allowedList.push(process.env.LOCALAPPDATA + "\\Chromium\\Application\\chrome.exe");
|
||||
allowedList.push(process.env.PROGRAMFILES + "\\Chromium\\Application\\chrome.exe");
|
||||
allowedList.push(process.env["ProgramFiles(x86)"] + "\\Chromium\\Application\\chrome.exe");
|
||||
|
||||
// For Loop A to Z
|
||||
for (let i = 65; i <= 90; i++) {
|
||||
let drive = String.fromCharCode(i);
|
||||
allowedList.push(drive + ":\\Program Files\\Google\\Chrome\\Application\\chrome.exe");
|
||||
allowedList.push(drive + ":\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe");
|
||||
}
|
||||
|
||||
} else if (process.platform === "linux") {
|
||||
allowedList = [
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"google-chrome",
|
||||
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/google-chrome",
|
||||
];
|
||||
} else if (process.platform === "darwin") {
|
||||
// TODO: Generated by GitHub Copilot, but not sure if it's correct
|
||||
allowedList = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
];
|
||||
}
|
||||
|
||||
log.debug("chrome", allowedList);
|
||||
|
||||
async function isAllowedChromeExecutable(executablePath) {
|
||||
console.log(config.args);
|
||||
if (config.args["allow-all-chrome-exec"] || process.env.UPTIME_KUMA_ALLOW_ALL_CHROME_EXEC === "1") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the executablePath is in the list of allowed executables
|
||||
return allowedList.includes(executablePath);
|
||||
}
|
||||
|
||||
async function getBrowser() {
|
||||
if (!browser) {
|
||||
let executablePath = await Settings.get("chromeExecutable");
|
||||
|
||||
executablePath = await prepareChromeExecutable(executablePath);
|
||||
|
||||
browser = await chromium.launch({
|
||||
//headless: false,
|
||||
executablePath,
|
||||
});
|
||||
}
|
||||
return browser;
|
||||
}
|
||||
|
||||
async function prepareChromeExecutable(executablePath) {
|
||||
// Special code for using the playwright_chromium
|
||||
if (typeof executablePath === "string" && executablePath.toLocaleLowerCase() === "#playwright_chromium") {
|
||||
// Set to undefined = use playwright_chromium
|
||||
executablePath = undefined;
|
||||
} else if (!executablePath) {
|
||||
if (process.env.UPTIME_KUMA_IS_CONTAINER) {
|
||||
executablePath = "/usr/bin/chromium";
|
||||
|
||||
// Install chromium in container via apt install
|
||||
if ( !commandExistsSync(executablePath)) {
|
||||
await new Promise((resolve, reject) => {
|
||||
log.info("Chromium", "Installing Chromium...");
|
||||
let child = childProcess.exec("apt update && apt --yes --no-install-recommends install chromium fonts-indic fonts-noto fonts-noto-cjk");
|
||||
|
||||
// On exit
|
||||
child.on("exit", (code) => {
|
||||
log.info("Chromium", "apt install chromium exited with code " + code);
|
||||
|
||||
if (code === 0) {
|
||||
log.info("Chromium", "Installed Chromium");
|
||||
let version = childProcess.execSync(executablePath + " --version").toString("utf8");
|
||||
log.info("Chromium", "Chromium version: " + version);
|
||||
resolve();
|
||||
} else if (code === 100) {
|
||||
reject(new Error("Installing Chromium, please wait..."));
|
||||
} else {
|
||||
reject(new Error("apt install chromium failed with code " + code));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
executablePath = findChrome(allowedList);
|
||||
}
|
||||
} else {
|
||||
// User specified a path
|
||||
// Check if the executablePath is in the list of allowed
|
||||
if (!await isAllowedChromeExecutable(executablePath)) {
|
||||
throw new Error("This Chromium executable path is not allowed by default. If you are sure this is safe, please add an environment variable UPTIME_KUMA_ALLOW_ALL_CHROME_EXEC=1 to allow it.");
|
||||
}
|
||||
}
|
||||
return executablePath;
|
||||
}
|
||||
|
||||
function findChrome(executables) {
|
||||
// Use the last working executable, so we don't have to search for it again
|
||||
if (lastAutoDetectChromeExecutable) {
|
||||
if (commandExistsSync(lastAutoDetectChromeExecutable)) {
|
||||
return lastAutoDetectChromeExecutable;
|
||||
}
|
||||
}
|
||||
|
||||
for (let executable of executables) {
|
||||
if (commandExistsSync(executable)) {
|
||||
lastAutoDetectChromeExecutable = executable;
|
||||
return executable;
|
||||
}
|
||||
}
|
||||
throw new Error("Chromium not found, please specify Chromium executable path in the settings page.");
|
||||
}
|
||||
|
||||
async function resetChrome() {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if the chrome executable is valid and return the version
|
||||
* @param executablePath
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function testChrome(executablePath) {
|
||||
try {
|
||||
executablePath = await prepareChromeExecutable(executablePath);
|
||||
|
||||
log.info("Chromium", "Testing Chromium executable: " + executablePath);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath,
|
||||
});
|
||||
const version = browser.version();
|
||||
await browser.close();
|
||||
return version;
|
||||
} catch (e) {
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: connect remote browser? https://playwright.dev/docs/api/class-browsertype#browser-type-connect
|
||||
*
|
||||
*/
|
||||
class RealBrowserMonitorType extends MonitorType {
|
||||
|
||||
name = "real-browser";
|
||||
|
||||
async check(monitor, heartbeat, server) {
|
||||
const browser = await getBrowser();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const res = await page.goto(monitor.url, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: monitor.interval * 1000 * 0.8,
|
||||
});
|
||||
|
||||
let filename = jwt.sign(monitor.id, server.jwtSecret) + ".png";
|
||||
|
||||
await page.screenshot({
|
||||
path: path.join(Database.screenshotDir, filename),
|
||||
});
|
||||
|
||||
await context.close();
|
||||
|
||||
if (res.status() >= 200 && res.status() < 400) {
|
||||
heartbeat.status = UP;
|
||||
heartbeat.msg = res.status();
|
||||
|
||||
const timing = res.request().timing();
|
||||
heartbeat.ping = timing.responseEnd;
|
||||
} else {
|
||||
throw new Error(res.status() + "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RealBrowserMonitorType,
|
||||
testChrome,
|
||||
resetChrome,
|
||||
};
|
@@ -15,7 +15,7 @@ class DingDing extends NotificationProvider {
|
||||
msgtype: "markdown",
|
||||
markdown: {
|
||||
title: `[${this.statusToString(heartbeatJSON["status"])}] ${monitorJSON["name"]}`,
|
||||
text: `## [${this.statusToString(heartbeatJSON["status"])}] ${monitorJSON["name"]} \n > ${heartbeatJSON["msg"]} \n > Time(UTC):${heartbeatJSON["time"]}`,
|
||||
text: `## [${this.statusToString(heartbeatJSON["status"])}] ${monitorJSON["name"]} \n> ${heartbeatJSON["msg"]}\n> Time (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`,
|
||||
}
|
||||
};
|
||||
if (this.sendToDingDing(notification, params)) {
|
||||
|
@@ -59,8 +59,8 @@ class Discord extends NotificationProvider {
|
||||
value: monitorJSON["type"] === "push" ? "Heartbeat" : address,
|
||||
},
|
||||
{
|
||||
name: "Time (UTC)",
|
||||
value: heartbeatJSON["time"],
|
||||
name: `Time (${heartbeatJSON["timezone"]})`,
|
||||
value: heartbeatJSON["localDateTime"],
|
||||
},
|
||||
{
|
||||
name: "Error",
|
||||
@@ -94,8 +94,8 @@ class Discord extends NotificationProvider {
|
||||
value: monitorJSON["type"] === "push" ? "Heartbeat" : address,
|
||||
},
|
||||
{
|
||||
name: "Time (UTC)",
|
||||
value: heartbeatJSON["time"],
|
||||
name: `Time (${heartbeatJSON["timezone"]})`,
|
||||
value: heartbeatJSON["localDateTime"],
|
||||
},
|
||||
{
|
||||
name: "Ping",
|
||||
|
@@ -35,8 +35,7 @@ class Feishu extends NotificationProvider {
|
||||
text:
|
||||
"[Down] " +
|
||||
heartbeatJSON["msg"] +
|
||||
"\nTime (UTC): " +
|
||||
heartbeatJSON["time"],
|
||||
`\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
|
||||
},
|
||||
],
|
||||
],
|
||||
@@ -62,8 +61,7 @@ class Feishu extends NotificationProvider {
|
||||
text:
|
||||
"[Up] " +
|
||||
heartbeatJSON["msg"] +
|
||||
"\nTime (UTC): " +
|
||||
heartbeatJSON["time"],
|
||||
`\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`,
|
||||
},
|
||||
],
|
||||
],
|
||||
|
@@ -11,7 +11,7 @@ class HomeAssistant extends NotificationProvider {
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${notification.homeAssistantUrl}/api/services/notify/${notificationService}`,
|
||||
`${notification.homeAssistantUrl.trim().replace(/\/*$/, "")}/api/services/notify/${notificationService}`,
|
||||
{
|
||||
title: "Uptime Kuma",
|
||||
message,
|
||||
|
@@ -33,7 +33,10 @@ class Line extends NotificationProvider {
|
||||
"messages": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "UptimeKuma Alert: [🔴 Down]\n" + "Name: " + monitorJSON["name"] + " \n" + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"]
|
||||
"text": "UptimeKuma Alert: [🔴 Down]\n" +
|
||||
"Name: " + monitorJSON["name"] + " \n" +
|
||||
heartbeatJSON["msg"] +
|
||||
`\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -44,7 +47,10 @@ class Line extends NotificationProvider {
|
||||
"messages": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "UptimeKuma Alert: [✅ Up]\n" + "Name: " + monitorJSON["name"] + " \n" + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"]
|
||||
"text": "UptimeKuma Alert: [✅ Up]\n" +
|
||||
"Name: " + monitorJSON["name"] + " \n" +
|
||||
heartbeatJSON["msg"] +
|
||||
`\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
|
||||
}
|
||||
]
|
||||
};
|
||||
|
@@ -24,12 +24,18 @@ class LineNotify extends NotificationProvider {
|
||||
await axios.post(lineAPIUrl, qs.stringify(testMessage), config);
|
||||
} else if (heartbeatJSON["status"] === DOWN) {
|
||||
let downMessage = {
|
||||
"message": "\n[🔴 Down]\n" + "Name: " + monitorJSON["name"] + " \n" + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"]
|
||||
"message": "\n[🔴 Down]\n" +
|
||||
"Name: " + monitorJSON["name"] + " \n" +
|
||||
heartbeatJSON["msg"] + "\n" +
|
||||
`Time (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
|
||||
};
|
||||
await axios.post(lineAPIUrl, qs.stringify(downMessage), config);
|
||||
} else if (heartbeatJSON["status"] === UP) {
|
||||
let upMessage = {
|
||||
"message": "\n[✅ Up]\n" + "Name: " + monitorJSON["name"] + " \n" + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"]
|
||||
"message": "\n[✅ Up]\n" +
|
||||
"Name: " + monitorJSON["name"] + " \n" +
|
||||
heartbeatJSON["msg"] + "\n" +
|
||||
`Time (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
|
||||
};
|
||||
await axios.post(lineAPIUrl, qs.stringify(upMessage), config);
|
||||
}
|
||||
|
@@ -28,7 +28,9 @@ class LunaSea extends NotificationProvider {
|
||||
if (heartbeatJSON["status"] === DOWN) {
|
||||
let downdata = {
|
||||
"title": "UptimeKuma Alert: " + monitorJSON["name"],
|
||||
"body": "[🔴 Down] " + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"],
|
||||
"body": "[🔴 Down] " +
|
||||
heartbeatJSON["msg"] +
|
||||
`\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
|
||||
};
|
||||
await axios.post(lunaseaurl, downdata);
|
||||
return okMsg;
|
||||
@@ -37,7 +39,9 @@ class LunaSea extends NotificationProvider {
|
||||
if (heartbeatJSON["status"] === UP) {
|
||||
let updata = {
|
||||
"title": "UptimeKuma Alert: " + monitorJSON["name"],
|
||||
"body": "[✅ Up] " + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"],
|
||||
"body": "[✅ Up] " +
|
||||
heartbeatJSON["msg"] +
|
||||
`\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
|
||||
};
|
||||
await axios.post(lunaseaurl, updata);
|
||||
return okMsg;
|
||||
|
@@ -10,7 +10,7 @@ class Mattermost extends NotificationProvider {
|
||||
let okMsg = "Sent Successfully.";
|
||||
try {
|
||||
const mattermostUserName = notification.mattermostusername || "Uptime Kuma";
|
||||
// If heartbeatJSON is null, assume we're testing.
|
||||
// If heartbeatJSON is null, assume non monitoring notification (Certificate warning) or testing.
|
||||
if (heartbeatJSON == null) {
|
||||
let mattermostTestData = {
|
||||
username: mattermostUserName,
|
||||
@@ -27,97 +27,79 @@ class Mattermost extends NotificationProvider {
|
||||
}
|
||||
|
||||
const mattermostIconEmoji = notification.mattermosticonemo;
|
||||
const mattermostIconUrl = notification.mattermosticonurl;
|
||||
let mattermostIconEmojiOnline = "";
|
||||
let mattermostIconEmojiOffline = "";
|
||||
|
||||
if (heartbeatJSON["status"] === DOWN) {
|
||||
let mattermostdowndata = {
|
||||
username: mattermostUserName,
|
||||
text: "Uptime Kuma Alert",
|
||||
channel: mattermostChannel,
|
||||
icon_emoji: mattermostIconEmoji,
|
||||
icon_url: mattermostIconUrl,
|
||||
attachments: [
|
||||
{
|
||||
fallback:
|
||||
"Your " +
|
||||
monitorJSON["name"] +
|
||||
" service went down.",
|
||||
color: "#FF0000",
|
||||
title:
|
||||
"❌ " +
|
||||
monitorJSON["name"] +
|
||||
" service went down. ❌",
|
||||
title_link: monitorJSON["url"],
|
||||
fields: [
|
||||
{
|
||||
short: true,
|
||||
title: "Service Name",
|
||||
value: monitorJSON["name"],
|
||||
},
|
||||
{
|
||||
short: true,
|
||||
title: "Time (UTC)",
|
||||
value: heartbeatJSON["time"],
|
||||
},
|
||||
{
|
||||
short: false,
|
||||
title: "Error",
|
||||
value: heartbeatJSON["msg"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
await axios.post(
|
||||
notification.mattermostWebhookUrl,
|
||||
mattermostdowndata
|
||||
);
|
||||
return okMsg;
|
||||
} else if (heartbeatJSON["status"] === UP) {
|
||||
let mattermostupdata = {
|
||||
username: mattermostUserName,
|
||||
text: "Uptime Kuma Alert",
|
||||
channel: mattermostChannel,
|
||||
icon_emoji: mattermostIconEmoji,
|
||||
icon_url: mattermostIconUrl,
|
||||
attachments: [
|
||||
{
|
||||
fallback:
|
||||
"Your " +
|
||||
monitorJSON["name"] +
|
||||
" service went up!",
|
||||
color: "#32CD32",
|
||||
title:
|
||||
"✅ " +
|
||||
monitorJSON["name"] +
|
||||
" service went up! ✅",
|
||||
title_link: monitorJSON["url"],
|
||||
fields: [
|
||||
{
|
||||
short: true,
|
||||
title: "Service Name",
|
||||
value: monitorJSON["name"],
|
||||
},
|
||||
{
|
||||
short: true,
|
||||
title: "Time (UTC)",
|
||||
value: heartbeatJSON["time"],
|
||||
},
|
||||
{
|
||||
short: false,
|
||||
title: "Ping",
|
||||
value: heartbeatJSON["ping"] + "ms",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
await axios.post(
|
||||
notification.mattermostWebhookUrl,
|
||||
mattermostupdata
|
||||
);
|
||||
return okMsg;
|
||||
if (mattermostIconEmoji && typeof mattermostIconEmoji === "string") {
|
||||
const emojiArray = mattermostIconEmoji.split(" ");
|
||||
if (emojiArray.length >= 2) {
|
||||
mattermostIconEmojiOnline = emojiArray[0];
|
||||
mattermostIconEmojiOffline = emojiArray[1];
|
||||
}
|
||||
}
|
||||
const mattermostIconUrl = notification.mattermosticonurl;
|
||||
let iconEmoji = mattermostIconEmoji;
|
||||
let statusField = {
|
||||
short: false,
|
||||
title: "Error",
|
||||
value: heartbeatJSON.msg,
|
||||
};
|
||||
let statusText = "unknown";
|
||||
let color = "#000000";
|
||||
if (heartbeatJSON.status === DOWN) {
|
||||
iconEmoji = mattermostIconEmojiOffline || mattermostIconEmoji;
|
||||
statusField = {
|
||||
short: false,
|
||||
title: "Error",
|
||||
value: heartbeatJSON.msg,
|
||||
};
|
||||
statusText = "down.";
|
||||
color = "#FF0000";
|
||||
} else if (heartbeatJSON.status === UP) {
|
||||
iconEmoji = mattermostIconEmojiOnline || mattermostIconEmoji;
|
||||
statusField = {
|
||||
short: false,
|
||||
title: "Ping",
|
||||
value: heartbeatJSON.ping + "ms",
|
||||
};
|
||||
statusText = "up!";
|
||||
color = "#32CD32";
|
||||
}
|
||||
|
||||
let mattermostdata = {
|
||||
username: monitorJSON.name + " " + mattermostUserName,
|
||||
channel: mattermostChannel,
|
||||
icon_emoji: iconEmoji,
|
||||
icon_url: mattermostIconUrl,
|
||||
attachments: [
|
||||
{
|
||||
fallback:
|
||||
"Your " +
|
||||
monitorJSON.name +
|
||||
" service went " +
|
||||
statusText,
|
||||
color: color,
|
||||
title:
|
||||
monitorJSON.name +
|
||||
" service went " +
|
||||
statusText,
|
||||
title_link: monitorJSON.url,
|
||||
fields: [
|
||||
statusField,
|
||||
{
|
||||
short: true,
|
||||
title: `Time (${heartbeatJSON["timezone"]})`,
|
||||
value: heartbeatJSON.localDateTime,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
await axios.post(
|
||||
notification.mattermostWebhookUrl,
|
||||
mattermostdata
|
||||
);
|
||||
return okMsg;
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
const { DOWN, UP } = require("../../src/util");
|
||||
|
||||
class Ntfy extends NotificationProvider {
|
||||
|
||||
@@ -9,16 +10,54 @@ class Ntfy extends NotificationProvider {
|
||||
let okMsg = "Sent Successfully.";
|
||||
try {
|
||||
let headers = {};
|
||||
if (notification.ntfyusername) {
|
||||
if (notification.ntfyAuthenticationMethod === "usernamePassword") {
|
||||
headers = {
|
||||
"Authorization": "Basic " + Buffer.from(notification.ntfyusername + ":" + notification.ntfypassword).toString("base64"),
|
||||
};
|
||||
} else if (notification.ntfyAuthenticationMethod === "accessToken") {
|
||||
headers = {
|
||||
"Authorization": "Bearer " + notification.ntfyaccesstoken,
|
||||
};
|
||||
}
|
||||
// If heartbeatJSON is null, assume non monitoring notification (Certificate warning) or testing.
|
||||
if (heartbeatJSON == null) {
|
||||
let ntfyTestData = {
|
||||
"topic": notification.ntfytopic,
|
||||
"title": (monitorJSON?.name || notification.ntfytopic) + " [Uptime-Kuma]",
|
||||
"message": msg,
|
||||
"priority": notification.ntfyPriority,
|
||||
"tags": [ "test_tube" ],
|
||||
};
|
||||
await axios.post(`${notification.ntfyserverurl}`, ntfyTestData, { headers: headers });
|
||||
return okMsg;
|
||||
}
|
||||
let tags = [];
|
||||
let status = "unknown";
|
||||
let priority = notification.ntfyPriority || 4;
|
||||
if ("status" in heartbeatJSON) {
|
||||
if (heartbeatJSON.status === DOWN) {
|
||||
tags = [ "red_circle" ];
|
||||
status = "Down";
|
||||
// if priority is not 5, increase priority for down alerts
|
||||
priority = priority === 5 ? priority : priority + 1;
|
||||
} else if (heartbeatJSON["status"] === UP) {
|
||||
tags = [ "green_circle" ];
|
||||
status = "Up";
|
||||
}
|
||||
}
|
||||
let data = {
|
||||
"topic": notification.ntfytopic,
|
||||
"message": msg,
|
||||
"priority": notification.ntfyPriority || 4,
|
||||
"title": "Uptime-Kuma",
|
||||
"message": heartbeatJSON.msg,
|
||||
"priority": priority,
|
||||
"title": monitorJSON.name + " " + status + " [Uptime-Kuma]",
|
||||
"tags": tags,
|
||||
"actions": [
|
||||
{
|
||||
"action": "view",
|
||||
"label": "Open " + monitorJSON.name,
|
||||
"url": monitorJSON.url,
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (notification.ntfyIcon) {
|
||||
|
@@ -15,7 +15,7 @@ class Opsgenie extends NotificationProvider {
|
||||
*/
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
let opsgenieAlertsUrl;
|
||||
let priority = (notification.opsgeniePriority == "") ? 3 : notification.opsgeniePriority;
|
||||
let priority = (!notification.opsgeniePriority) ? 3 : notification.opsgeniePriority;
|
||||
const textMsg = "Uptime Kuma Alert";
|
||||
|
||||
try {
|
||||
|
@@ -29,14 +29,18 @@ class Pushbullet extends NotificationProvider {
|
||||
let downData = {
|
||||
"type": "note",
|
||||
"title": "UptimeKuma Alert: " + monitorJSON["name"],
|
||||
"body": "[🔴 Down] " + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"],
|
||||
"body": "[🔴 Down] " +
|
||||
heartbeatJSON["msg"] +
|
||||
`\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`,
|
||||
};
|
||||
await axios.post(pushbulletUrl, downData, config);
|
||||
} else if (heartbeatJSON["status"] === UP) {
|
||||
let upData = {
|
||||
"type": "note",
|
||||
"title": "UptimeKuma Alert: " + monitorJSON["name"],
|
||||
"body": "[✅ Up] " + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"],
|
||||
"body": "[✅ Up] " +
|
||||
heartbeatJSON["msg"] +
|
||||
`\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`,
|
||||
};
|
||||
await axios.post(pushbulletUrl, upData, config);
|
||||
}
|
||||
|
@@ -24,13 +24,16 @@ class Pushover extends NotificationProvider {
|
||||
if (notification.pushoverdevice) {
|
||||
data.device = notification.pushoverdevice;
|
||||
}
|
||||
if (notification.pushoverttl) {
|
||||
data.ttl = notification.pushoverttl;
|
||||
}
|
||||
|
||||
try {
|
||||
if (heartbeatJSON == null) {
|
||||
await axios.post(pushoverlink, data);
|
||||
return okMsg;
|
||||
} else {
|
||||
data.message += "\n<b>Time (UTC)</b>:" + heartbeatJSON["time"];
|
||||
data.message += `\n<b>Time (${heartbeatJSON["timezone"]})</b>:${heartbeatJSON["localDateTime"]}`;
|
||||
await axios.post(pushoverlink, data);
|
||||
return okMsg;
|
||||
}
|
||||
|
@@ -22,8 +22,6 @@ class RocketChat extends NotificationProvider {
|
||||
return okMsg;
|
||||
}
|
||||
|
||||
const time = heartbeatJSON["time"];
|
||||
|
||||
let data = {
|
||||
"text": "Uptime Kuma Alert",
|
||||
"channel": notification.rocketchannel,
|
||||
@@ -31,7 +29,7 @@ class RocketChat extends NotificationProvider {
|
||||
"icon_emoji": notification.rocketiconemo,
|
||||
"attachments": [
|
||||
{
|
||||
"title": "Uptime Kuma Alert *Time (UTC)*\n" + time,
|
||||
"title": `Uptime Kuma Alert *Time (${heartbeatJSON["timezone"]})*\n${heartbeatJSON["localDateTime"]}`,
|
||||
"text": "*Message*\n" + msg,
|
||||
}
|
||||
]
|
||||
|
@@ -39,7 +39,6 @@ class Slack extends NotificationProvider {
|
||||
return okMsg;
|
||||
}
|
||||
|
||||
const time = heartbeatJSON["time"];
|
||||
const textMsg = "Uptime Kuma Alert";
|
||||
let data = {
|
||||
"text": `${textMsg}\n${msg}`,
|
||||
@@ -65,7 +64,7 @@ class Slack extends NotificationProvider {
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Time (UTC)*\n" + time,
|
||||
"text": `*Time (${heartbeatJSON["timezone"]})*\n${heartbeatJSON["localDateTime"]}`,
|
||||
}],
|
||||
}
|
||||
],
|
||||
|
@@ -91,7 +91,7 @@ class SMTP extends NotificationProvider {
|
||||
|
||||
let bodyTextContent = msg;
|
||||
if (heartbeatJSON) {
|
||||
bodyTextContent = `${msg}\nTime (UTC): ${heartbeatJSON["time"]}`;
|
||||
bodyTextContent = `${msg}\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`;
|
||||
}
|
||||
|
||||
// send mail with defined transport object
|
||||
|
@@ -25,8 +25,11 @@ class Telegram extends NotificationProvider {
|
||||
return okMsg;
|
||||
|
||||
} catch (error) {
|
||||
let msg = (error.response.data.description) ? error.response.data.description : "Error without description";
|
||||
throw new Error(msg);
|
||||
if (error.response && error.response.data && error.response.data.description) {
|
||||
throw new Error(error.response.data.description);
|
||||
} else {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,13 +0,0 @@
|
||||
class Plugin {
|
||||
async load() {
|
||||
|
||||
}
|
||||
|
||||
async unload() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Plugin,
|
||||
};
|
@@ -1,256 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const { log } = require("../src/util");
|
||||
const path = require("path");
|
||||
const axios = require("axios");
|
||||
const { Git } = require("./git");
|
||||
const childProcess = require("child_process");
|
||||
|
||||
class PluginsManager {
|
||||
|
||||
static disable = false;
|
||||
|
||||
/**
|
||||
* Plugin List
|
||||
* @type {PluginWrapper[]}
|
||||
*/
|
||||
pluginList = [];
|
||||
|
||||
/**
|
||||
* Plugins Dir
|
||||
*/
|
||||
pluginsDir;
|
||||
|
||||
server;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {UptimeKumaServer} server
|
||||
*/
|
||||
constructor(server) {
|
||||
this.server = server;
|
||||
|
||||
if (!PluginsManager.disable) {
|
||||
this.pluginsDir = "./data/plugins/";
|
||||
|
||||
if (! fs.existsSync(this.pluginsDir)) {
|
||||
fs.mkdirSync(this.pluginsDir, { recursive: true });
|
||||
}
|
||||
|
||||
log.debug("plugin", "Scanning plugin directory");
|
||||
let list = fs.readdirSync(this.pluginsDir);
|
||||
|
||||
this.pluginList = [];
|
||||
for (let item of list) {
|
||||
this.loadPlugin(item);
|
||||
}
|
||||
|
||||
} else {
|
||||
log.warn("PLUGIN", "Skip scanning plugin directory");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a Plugin
|
||||
*/
|
||||
async loadPlugin(name) {
|
||||
log.info("plugin", "Load " + name);
|
||||
let plugin = new PluginWrapper(this.server, this.pluginsDir + name);
|
||||
|
||||
try {
|
||||
await plugin.load();
|
||||
this.pluginList.push(plugin);
|
||||
} catch (e) {
|
||||
log.error("plugin", "Failed to load plugin: " + this.pluginsDir + name);
|
||||
log.error("plugin", "Reason: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a Plugin
|
||||
* @param {string} repoURL Git repo url
|
||||
* @param {string} name Directory name, also known as plugin unique name
|
||||
*/
|
||||
downloadPlugin(repoURL, name) {
|
||||
if (fs.existsSync(this.pluginsDir + name)) {
|
||||
log.info("plugin", "Plugin folder already exists? Removing...");
|
||||
fs.rmSync(this.pluginsDir + name, {
|
||||
recursive: true
|
||||
});
|
||||
}
|
||||
log.info("plugin", "Installing plugin: " + name + " " + repoURL);
|
||||
let result = Git.clone(repoURL, this.pluginsDir, name);
|
||||
log.info("plugin", "Install result: " + result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a plugin
|
||||
* @param {string} name
|
||||
*/
|
||||
async removePlugin(name) {
|
||||
log.info("plugin", "Removing plugin: " + name);
|
||||
for (let plugin of this.pluginList) {
|
||||
if (plugin.info.name === name) {
|
||||
await plugin.unload();
|
||||
|
||||
// Delete the plugin directory
|
||||
fs.rmSync(this.pluginsDir + name, {
|
||||
recursive: true
|
||||
});
|
||||
|
||||
this.pluginList.splice(this.pluginList.indexOf(plugin), 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
log.warn("plugin", "Plugin not found: " + name);
|
||||
throw new Error("Plugin not found: " + name);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Update a plugin
|
||||
* Only available for plugins which were downloaded from the official list
|
||||
* @param pluginID
|
||||
*/
|
||||
updatePlugin(pluginID) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the plugin list from server + local installed plugin list
|
||||
* Item will be merged if the `name` is the same.
|
||||
* @returns {Promise<[]>}
|
||||
*/
|
||||
async fetchPluginList() {
|
||||
let remotePluginList;
|
||||
try {
|
||||
const res = await axios.get("https://uptime.kuma.pet/c/plugins.json");
|
||||
remotePluginList = res.data.pluginList;
|
||||
} catch (e) {
|
||||
log.error("plugin", "Failed to fetch plugin list: " + e.message);
|
||||
remotePluginList = [];
|
||||
}
|
||||
|
||||
for (let plugin of this.pluginList) {
|
||||
let find = false;
|
||||
// Try to merge
|
||||
for (let remotePlugin of remotePluginList) {
|
||||
if (remotePlugin.name === plugin.info.name) {
|
||||
find = true;
|
||||
remotePlugin.installed = true;
|
||||
remotePlugin.name = plugin.info.name;
|
||||
remotePlugin.fullName = plugin.info.fullName;
|
||||
remotePlugin.description = plugin.info.description;
|
||||
remotePlugin.version = plugin.info.version;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Local plugin
|
||||
if (!find) {
|
||||
plugin.info.local = true;
|
||||
remotePluginList.push(plugin.info);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort Installed first, then sort by name
|
||||
return remotePluginList.sort((a, b) => {
|
||||
if (a.installed === b.installed) {
|
||||
if (a.fullName < b.fullName) {
|
||||
return -1;
|
||||
}
|
||||
if (a.fullName > b.fullName) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
} else if (a.installed) {
|
||||
return -1;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class PluginWrapper {
|
||||
|
||||
server = undefined;
|
||||
pluginDir = undefined;
|
||||
|
||||
/**
|
||||
* Must be an `new-able` class.
|
||||
* @type {function}
|
||||
*/
|
||||
pluginClass = undefined;
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {Plugin}
|
||||
*/
|
||||
object = undefined;
|
||||
info = {};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {UptimeKumaServer} server
|
||||
* @param {string} pluginDir
|
||||
*/
|
||||
constructor(server, pluginDir) {
|
||||
this.server = server;
|
||||
this.pluginDir = pluginDir;
|
||||
}
|
||||
|
||||
async load() {
|
||||
let indexFile = this.pluginDir + "/index.js";
|
||||
let packageJSON = this.pluginDir + "/package.json";
|
||||
|
||||
log.info("plugin", "Installing dependencies");
|
||||
|
||||
if (fs.existsSync(indexFile)) {
|
||||
// Install dependencies
|
||||
let result = childProcess.spawnSync("npm", [ "install" ], {
|
||||
cwd: this.pluginDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT_BROWSERS_PATH: "../../browsers", // Special handling for read-browser-monitor
|
||||
}
|
||||
});
|
||||
|
||||
if (result.stdout) {
|
||||
log.info("plugin", "Install dependencies result: " + result.stdout.toString("utf-8"));
|
||||
} else {
|
||||
log.warn("plugin", "Install dependencies result: no output");
|
||||
}
|
||||
|
||||
this.pluginClass = require(path.join(process.cwd(), indexFile));
|
||||
|
||||
let pluginClassType = typeof this.pluginClass;
|
||||
|
||||
if (pluginClassType === "function") {
|
||||
this.object = new this.pluginClass(this.server);
|
||||
await this.object.load();
|
||||
} else {
|
||||
throw new Error("Invalid plugin, it does not export a class");
|
||||
}
|
||||
|
||||
if (fs.existsSync(packageJSON)) {
|
||||
this.info = require(path.join(process.cwd(), packageJSON));
|
||||
} else {
|
||||
this.info.fullName = this.pluginDir;
|
||||
this.info.name = "[unknown]";
|
||||
this.info.version = "[unknown-version]";
|
||||
}
|
||||
|
||||
this.info.installed = true;
|
||||
log.info("plugin", `${this.info.fullName} v${this.info.version} loaded`);
|
||||
}
|
||||
}
|
||||
|
||||
async unload() {
|
||||
await this.object.unload();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PluginsManager,
|
||||
PluginWrapper
|
||||
};
|
@@ -28,7 +28,7 @@ const monitorResponseTime = new PrometheusClient.Gauge({
|
||||
|
||||
const monitorStatus = new PrometheusClient.Gauge({
|
||||
name: "monitor_status",
|
||||
help: "Monitor Status (1 = UP, 0= DOWN)",
|
||||
help: "Monitor Status (1 = UP, 0= DOWN, 2= PENDING, 3= MAINTENANCE)",
|
||||
labelNames: commonLabels
|
||||
});
|
||||
|
||||
|
@@ -19,6 +19,11 @@ const nodeVersion = parseInt(process.versions.node.split(".")[0]);
|
||||
const requiredVersion = 14;
|
||||
console.log(`Your Node.js version: ${nodeVersion}`);
|
||||
|
||||
// See more: https://github.com/louislam/uptime-kuma/issues/3138
|
||||
if (nodeVersion >= 20) {
|
||||
console.warn("\x1b[31m%s\x1b[0m", "Warning: Uptime Kuma is currently not stable on Node.js >= 20, please use Node.js 18.");
|
||||
}
|
||||
|
||||
if (nodeVersion < requiredVersion) {
|
||||
console.error(`Error: Your Node.js version is not supported, please upgrade to Node.js >= ${requiredVersion}.`);
|
||||
process.exit(-1);
|
||||
@@ -142,8 +147,8 @@ const { apiKeySocketHandler } = require("./socket-handlers/api-key-socket-handle
|
||||
const { generalSocketHandler } = require("./socket-handlers/general-socket-handler");
|
||||
const { Settings } = require("./settings");
|
||||
const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
|
||||
const { pluginsHandler } = require("./socket-handlers/plugins-handler");
|
||||
const apicache = require("./modules/apicache");
|
||||
const { resetChrome } = require("./monitor-types/real-browser-monitor-type");
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
@@ -156,12 +161,6 @@ app.use(function (req, res, next) {
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Use for decode the auth object
|
||||
* @type {null}
|
||||
*/
|
||||
let jwtSecret = null;
|
||||
|
||||
/**
|
||||
* Show Setup Page
|
||||
* @type {boolean}
|
||||
@@ -172,7 +171,6 @@ let needSetup = false;
|
||||
Database.init(args);
|
||||
await initDatabase(testMode);
|
||||
await server.initAfterDatabaseReady();
|
||||
server.loadPlugins();
|
||||
server.entryPage = await Settings.get("entryPage");
|
||||
await StatusPage.loadDomainMappingList();
|
||||
|
||||
@@ -281,7 +279,7 @@ let needSetup = false;
|
||||
log.info("auth", `Login by token. IP=${clientIP}`);
|
||||
|
||||
try {
|
||||
let decoded = jwt.verify(token, jwtSecret);
|
||||
let decoded = jwt.verify(token, server.jwtSecret);
|
||||
|
||||
log.info("auth", "Username from JWT: " + decoded.username);
|
||||
|
||||
@@ -352,7 +350,7 @@ let needSetup = false;
|
||||
ok: true,
|
||||
token: jwt.sign({
|
||||
username: data.username,
|
||||
}, jwtSecret),
|
||||
}, server.jwtSecret),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -382,7 +380,7 @@ let needSetup = false;
|
||||
ok: true,
|
||||
token: jwt.sign({
|
||||
username: data.username,
|
||||
}, jwtSecret),
|
||||
}, server.jwtSecret),
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -671,6 +669,7 @@ let needSetup = false;
|
||||
// Edit a monitor
|
||||
socket.on("editMonitor", async (monitor, callback) => {
|
||||
try {
|
||||
let removeGroupChildren = false;
|
||||
checkLogin(socket);
|
||||
|
||||
let bean = await R.findOne("monitor", " id = ? ", [ monitor.id ]);
|
||||
@@ -679,8 +678,22 @@ let needSetup = false;
|
||||
throw new Error("Permission denied.");
|
||||
}
|
||||
|
||||
// Check if Parent is Descendant (would cause endless loop)
|
||||
if (monitor.parent !== null) {
|
||||
const childIDs = await Monitor.getAllChildrenIDs(monitor.id);
|
||||
if (childIDs.includes(monitor.parent)) {
|
||||
throw new Error("Invalid Monitor Group");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove children if monitor type has changed (from group to non-group)
|
||||
if (bean.type === "group" && monitor.type !== bean.type) {
|
||||
removeGroupChildren = true;
|
||||
}
|
||||
|
||||
bean.name = monitor.name;
|
||||
bean.description = monitor.description;
|
||||
bean.parent = monitor.parent;
|
||||
bean.type = monitor.type;
|
||||
bean.url = monitor.url;
|
||||
bean.method = monitor.method;
|
||||
@@ -739,9 +752,13 @@ let needSetup = false;
|
||||
|
||||
await R.store(bean);
|
||||
|
||||
if (removeGroupChildren) {
|
||||
await Monitor.unlinkAllChildren(monitor.id);
|
||||
}
|
||||
|
||||
await updateMonitorNotification(bean.id, monitor.notificationIDList);
|
||||
|
||||
if (bean.active) {
|
||||
if (bean.isActive()) {
|
||||
await restartMonitor(socket.userID, bean.id);
|
||||
}
|
||||
|
||||
@@ -884,6 +901,8 @@ let needSetup = false;
|
||||
delete server.monitorList[monitorID];
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [
|
||||
monitorID,
|
||||
socket.userID,
|
||||
@@ -892,6 +911,10 @@ let needSetup = false;
|
||||
// Fix #2880
|
||||
apicache.clear();
|
||||
|
||||
const endTime = Date.now();
|
||||
|
||||
log.info("DB", `Delete Monitor completed in : ${endTime - startTime} ms`);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Deleted Successfully.",
|
||||
@@ -1135,6 +1158,8 @@ let needSetup = false;
|
||||
await doubleCheckPassword(socket, currentPassword);
|
||||
}
|
||||
|
||||
const previousChromeExecutable = await Settings.get("chromeExecutable");
|
||||
|
||||
await setSettings("general", data);
|
||||
server.entryPage = data.entryPage;
|
||||
|
||||
@@ -1145,6 +1170,12 @@ let needSetup = false;
|
||||
await server.setTimezone(data.serverTimezone);
|
||||
}
|
||||
|
||||
// If Chrome Executable is changed, need to reset the browser
|
||||
if (previousChromeExecutable !== data.chromeExecutable) {
|
||||
log.info("settings", "Chrome executable is changed. Resetting Chrome...");
|
||||
await resetChrome();
|
||||
}
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Saved"
|
||||
@@ -1353,7 +1384,7 @@ let needSetup = false;
|
||||
accepted_statuscodes: monitorListData[i].accepted_statuscodes,
|
||||
dns_resolve_type: monitorListData[i].dns_resolve_type,
|
||||
dns_resolve_server: monitorListData[i].dns_resolve_server,
|
||||
notificationIDList: {},
|
||||
notificationIDList: monitorListData[i].notificationIDList,
|
||||
proxy_id: monitorListData[i].proxy_id || null,
|
||||
};
|
||||
|
||||
@@ -1515,7 +1546,6 @@ let needSetup = false;
|
||||
maintenanceSocketHandler(socket);
|
||||
apiKeySocketHandler(socket);
|
||||
generalSocketHandler(socket, server);
|
||||
pluginsHandler(socket, server);
|
||||
|
||||
log.debug("server", "added all socket handlers");
|
||||
|
||||
@@ -1559,7 +1589,7 @@ let needSetup = false;
|
||||
}
|
||||
});
|
||||
|
||||
initBackgroundJobs(args);
|
||||
await initBackgroundJobs();
|
||||
|
||||
// Start cloudflared at the end if configured
|
||||
await cloudflaredAutoStart(cloudflaredToken);
|
||||
@@ -1685,7 +1715,7 @@ async function initDatabase(testMode = false) {
|
||||
needSetup = true;
|
||||
}
|
||||
|
||||
jwtSecret = jwtSecretBean.value;
|
||||
server.jwtSecret = jwtSecretBean.value;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -3,6 +3,7 @@ const { Settings } = require("../settings");
|
||||
const { sendInfo } = require("../client");
|
||||
const { checkLogin } = require("../util-server");
|
||||
const GameResolver = require("gamedig/lib/GameResolver");
|
||||
const { testChrome } = require("../monitor-types/real-browser-monitor-type");
|
||||
|
||||
let gameResolver = new GameResolver();
|
||||
let gameList = null;
|
||||
@@ -47,4 +48,18 @@ module.exports.generalSocketHandler = (socket, server) => {
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("testChrome", (executable, callback) => {
|
||||
// Just noticed that await call could block the whole socket.io server!!! Use pure promise instead.
|
||||
testChrome(executable).then((version) => {
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Found Chromium/Chrome. Version: " + version,
|
||||
});
|
||||
}).catch((e) => {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@@ -186,7 +186,7 @@ module.exports.maintenanceSocketHandler = (socket) => {
|
||||
|
||||
log.debug("maintenance", `Get Monitors for Maintenance: ${maintenanceID} User ID: ${socket.userID}`);
|
||||
|
||||
let monitors = await R.getAll("SELECT monitor.id, monitor.name FROM monitor_maintenance mm JOIN monitor ON mm.monitor_id = monitor.id WHERE mm.maintenance_id = ? ", [
|
||||
let monitors = await R.getAll("SELECT monitor.id FROM monitor_maintenance mm JOIN monitor ON mm.monitor_id = monitor.id WHERE mm.maintenance_id = ? ", [
|
||||
maintenanceID,
|
||||
]);
|
||||
|
||||
|
@@ -1,69 +0,0 @@
|
||||
const { checkLogin } = require("../util-server");
|
||||
const { PluginsManager } = require("../plugins-manager");
|
||||
const { log } = require("../../src/util.js");
|
||||
|
||||
/**
|
||||
* Handlers for plugins
|
||||
* @param {Socket} socket Socket.io instance
|
||||
* @param {UptimeKumaServer} server
|
||||
*/
|
||||
module.exports.pluginsHandler = (socket, server) => {
|
||||
|
||||
const pluginManager = server.getPluginManager();
|
||||
|
||||
// Get Plugin List
|
||||
socket.on("getPluginList", async (callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
log.debug("plugin", "PluginManager.disable: " + PluginsManager.disable);
|
||||
|
||||
if (PluginsManager.disable) {
|
||||
throw new Error("Plugin Disabled: In order to enable plugin feature, you need to use the default data directory: ./data/");
|
||||
}
|
||||
|
||||
let pluginList = await pluginManager.fetchPluginList();
|
||||
callback({
|
||||
ok: true,
|
||||
pluginList,
|
||||
});
|
||||
} catch (error) {
|
||||
log.warn("plugin", "Error: " + error.message);
|
||||
callback({
|
||||
ok: false,
|
||||
msg: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("installPlugin", async (repoURL, name, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
pluginManager.downloadPlugin(repoURL, name);
|
||||
await pluginManager.loadPlugin(name);
|
||||
callback({
|
||||
ok: true,
|
||||
});
|
||||
} catch (error) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("uninstallPlugin", async (name, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
await pluginManager.removePlugin(name);
|
||||
callback({
|
||||
ok: true,
|
||||
});
|
||||
} catch (error) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
@@ -10,7 +10,6 @@ const util = require("util");
|
||||
const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
|
||||
const { Settings } = require("./settings");
|
||||
const dayjs = require("dayjs");
|
||||
const { PluginsManager } = require("./plugins-manager");
|
||||
// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`
|
||||
|
||||
/**
|
||||
@@ -47,12 +46,6 @@ class UptimeKumaServer {
|
||||
*/
|
||||
indexHTML = "";
|
||||
|
||||
/**
|
||||
* Plugins Manager
|
||||
* @type {PluginsManager}
|
||||
*/
|
||||
pluginsManager = null;
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {{}}
|
||||
@@ -61,6 +54,12 @@ class UptimeKumaServer {
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Use for decode the auth object
|
||||
* @type {null}
|
||||
*/
|
||||
jwtSecret = null;
|
||||
|
||||
static getInstance(args) {
|
||||
if (UptimeKumaServer.instance == null) {
|
||||
UptimeKumaServer.instance = new UptimeKumaServer(args);
|
||||
@@ -98,11 +97,17 @@ class UptimeKumaServer {
|
||||
}
|
||||
}
|
||||
|
||||
// Set Monitor Types
|
||||
UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType();
|
||||
|
||||
this.io = new Server(this.httpServer);
|
||||
}
|
||||
|
||||
/** Initialise app after the database has been set up */
|
||||
async initAfterDatabaseReady() {
|
||||
// Static
|
||||
this.app.use("/screenshots", express.static(Database.screenshotDir));
|
||||
|
||||
await CacheableDnsHttpAgent.update();
|
||||
|
||||
process.env.TZ = await this.getTimezone();
|
||||
@@ -289,46 +294,6 @@ class UptimeKumaServer {
|
||||
async stop() {
|
||||
|
||||
}
|
||||
|
||||
loadPlugins() {
|
||||
this.pluginsManager = new PluginsManager(this);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {PluginsManager}
|
||||
*/
|
||||
getPluginManager() {
|
||||
return this.pluginsManager;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {MonitorType} monitorType
|
||||
*/
|
||||
addMonitorType(monitorType) {
|
||||
if (monitorType instanceof MonitorType && monitorType.name) {
|
||||
if (monitorType.name in UptimeKumaServer.monitorTypeList) {
|
||||
log.error("", "Conflict Monitor Type name");
|
||||
}
|
||||
UptimeKumaServer.monitorTypeList[monitorType.name] = monitorType;
|
||||
} else {
|
||||
log.error("", "Invalid Monitor Type: " + monitorType.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {MonitorType} monitorType
|
||||
*/
|
||||
removeMonitorType(monitorType) {
|
||||
if (UptimeKumaServer.monitorTypeList[monitorType.name] === monitorType) {
|
||||
delete UptimeKumaServer.monitorTypeList[monitorType.name];
|
||||
} else {
|
||||
log.error("", "Remove MonitorType failed: " + monitorType.name);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -337,3 +302,4 @@ module.exports = {
|
||||
|
||||
// Must be at the end
|
||||
const { MonitorType } = require("./monitor-types/monitor-type");
|
||||
const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type");
|
||||
|
@@ -342,7 +342,12 @@ exports.mysqlQuery = function (connectionString, query) {
|
||||
resolve("No Error, but the result is not an array. Type: " + typeof res);
|
||||
}
|
||||
}
|
||||
connection.destroy();
|
||||
|
||||
try {
|
||||
connection.end();
|
||||
} catch (_) {
|
||||
connection.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -408,12 +413,18 @@ exports.radius = function (
|
||||
exports.redisPingAsync = function (dsn) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = redis.createClient({
|
||||
url: dsn,
|
||||
url: dsn
|
||||
});
|
||||
client.on("error", (err) => {
|
||||
if (client.isOpen) {
|
||||
client.disconnect();
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
client.connect().then(() => {
|
||||
if (!client.isOpen) {
|
||||
client.emit("error", new Error("connection isn't open"));
|
||||
}
|
||||
client.ping().then((res, err) => {
|
||||
if (client.isOpen) {
|
||||
client.disconnect();
|
||||
@@ -423,7 +434,7 @@ exports.redisPingAsync = function (dsn) {
|
||||
} else {
|
||||
resolve(res);
|
||||
}
|
||||
});
|
||||
}).catch(error => reject(error));
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -519,12 +530,16 @@ const parseCertificateInfo = function (info) {
|
||||
|
||||
// Move up the chain until loop is encountered
|
||||
if (link.issuerCertificate == null) {
|
||||
link.certType = (i === 0) ? "self-signed" : "root CA";
|
||||
break;
|
||||
} else if (link.issuerCertificate.fingerprint in existingList) {
|
||||
// a root CA certificate is typically "signed by itself" (=> "self signed certificate") and thus the "issuerCertificate" is a reference to itself.
|
||||
log.debug("cert", `[Last] ${link.issuerCertificate.fingerprint}`);
|
||||
link.certType = (i === 0) ? "self-signed" : "root CA";
|
||||
link.issuerCertificate = null;
|
||||
break;
|
||||
} else {
|
||||
link.certType = (i === 0) ? "server" : "intermediate CA";
|
||||
link = link.issuerCertificate;
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user