diff options
author | Daniel Baumann <daniel@debian.org> | 2024-11-26 09:28:28 +0100 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-11-26 12:25:58 +0100 |
commit | a1882b67c41fe9901a0cd8059b5cc78a5beadec0 (patch) | |
tree | 2a24507c67aa99a15416707b2f7e645142230ed8 /server | |
parent | Initial commit. (diff) | |
download | uptime-kuma-a1882b67c41fe9901a0cd8059b5cc78a5beadec0.tar.xz uptime-kuma-a1882b67c41fe9901a0cd8059b5cc78a5beadec0.zip |
Adding upstream version 2.0.0~beta.0+dfsg.upstream/2.0.0_beta.0+dfsgupstream
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'server')
144 files changed, 20826 insertions, 0 deletions
diff --git a/server/2fa.js b/server/2fa.js new file mode 100644 index 0000000..c7076da --- /dev/null +++ b/server/2fa.js @@ -0,0 +1,18 @@ +const { R } = require("redbean-node"); + +class TwoFA { + + /** + * Disable 2FA for specified user + * @param {number} userID ID of user to disable + * @returns {Promise<void>} + */ + static async disable2FA(userID) { + return await R.exec("UPDATE `user` SET twofa_status = 0 WHERE id = ? ", [ + userID, + ]); + } + +} + +module.exports = TwoFA; diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..597cf3d --- /dev/null +++ b/server/auth.js @@ -0,0 +1,179 @@ +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"); + +/** + * Login to web app + * @param {string} username Username to login with + * @param {string} password Password to login with + * @returns {Promise<(Bean|null)>} User or null if login failed + */ +exports.login = async function (username, password) { + if (typeof username !== "string" || typeof password !== "string") { + return null; + } + + let user = await R.findOne("user", " username = ? AND active = 1 ", [ + username, + ]); + + if (user && passwordHash.verify(password, user.password)) { + // Upgrade the hash to bcrypt + if (passwordHash.needRehash(user.password)) { + await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [ + passwordHash.generate(password), + user.id, + ]); + } + return user; + } + + return null; +}; + +/** + * Validate a provided API key + * @param {string} key API key to verify + * @returns {boolean} API is ok? + */ +async function verifyAPIKey(key) { + if (typeof key !== "string") { + return false; + } + + // uk prefix + key ID is before _ + let index = key.substring(2, key.indexOf("_")); + let clear = key.substring(key.indexOf("_") + 1, key.length); + + let hash = await R.findOne("api_key", " id=? ", [ index ]); + + if (hash === null) { + return false; + } + + let current = dayjs(); + let expiry = dayjs(hash.expires); + if (expiry.diff(current) < 0 || !hash.active) { + return false; + } + + return hash && passwordHash.verify(clear, hash.key); +} + +/** + * Callback for basic auth authorizers + * @callback authCallback + * @param {any} err Any error encountered + * @param {boolean} authorized Is the client authorized? + */ + +/** + * Custom authorizer for express-basic-auth + * @param {string} username Username to login with + * @param {string} password Password to login with + * @param {authCallback} callback Callback to handle login result + * @returns {void} + */ +function apiAuthorizer(username, password, callback) { + // API Rate Limit + 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); + } + }); +} + +/** + * Custom authorizer for express-basic-auth + * @param {string} username Username to login with + * @param {string} password Password to login with + * @param {authCallback} callback Callback to handle login result + * @returns {void} + */ +function userAuthorizer(username, password, callback) { + // Login Rate Limit + loginRateLimiter.pass(null, 0).then((pass) => { + if (pass) { + exports.login(username, password).then((user) => { + 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); + } + }); +} + +/** + * Use basic auth if auth is not disabled + * @param {express.Request} req Express request object + * @param {express.Response} res Express response object + * @param {express.NextFunction} next Next handler in chain + * @returns {Promise<void>} + */ +exports.basicAuth = async function (req, res, next) { + const middleware = basicAuth({ + authorizer: userAuthorizer, + authorizeAsync: true, + challenge: true, + }); + + const disabledAuth = await setting("disableAuth"); + + if (!disabledAuth) { + middleware(req, res, next); + } else { + next(); + } +}; + +/** + * Use use API Key if API keys enabled, else use basic auth + * @param {express.Request} req Express request object + * @param {express.Response} res Express response object + * @param {express.NextFunction} next Next handler in chain + * @returns {Promise<void>} + */ +exports.apiAuth = async function (req, res, next) { + if (!await Settings.get("disableAuth")) { + let usingAPIKeys = await Settings.get("apiKeysEnabled"); + let middleware; + if (usingAPIKeys) { + middleware = basicAuth({ + authorizer: apiAuthorizer, + authorizeAsync: true, + challenge: true, + }); + } else { + middleware = basicAuth({ + authorizer: userAuthorizer, + authorizeAsync: true, + challenge: true, + }); + } + middleware(req, res, next); + } else { + next(); + } +}; diff --git a/server/check-version.js b/server/check-version.js new file mode 100644 index 0000000..c6d5cfb --- /dev/null +++ b/server/check-version.js @@ -0,0 +1,69 @@ +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; + +exports.startInterval = () => { + let check = async () => { + if (await setting("checkUpdate") === false) { + return; + } + + log.debug("update-checker", "Retrieving latest versions"); + + try { + 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"; + } + + let checkBeta = await setting("checkBeta"); + + if (checkBeta && res.data.beta) { + if (compareVersions.compare(res.data.beta, res.data.slow, ">")) { + exports.latestVersion = res.data.beta; + return; + } + } + + if (res.data.slow) { + exports.latestVersion = res.data.slow; + } + + } catch (_) { + log.info("update-checker", "Failed to check for new versions"); + } + + }; + + check(); + interval = setInterval(check, UPDATE_CHECKER_INTERVAL_MS); +}; + +/** + * Enable the check update feature + * @param {boolean} value Should the check update feature be enabled? + * @returns {Promise<void>} + */ +exports.enableCheckUpdate = async (value) => { + await setSetting("checkUpdate", value); + + clearInterval(interval); + + if (value) { + exports.startInterval(); + } +}; + +exports.socket = null; diff --git a/server/client.js b/server/client.js new file mode 100644 index 0000000..72f0a4e --- /dev/null +++ b/server/client.js @@ -0,0 +1,252 @@ +/* + * For Client Socket + */ +const { TimeLogger } = require("../src/util"); +const { R } = require("redbean-node"); +const { UptimeKumaServer } = require("./uptime-kuma-server"); +const server = UptimeKumaServer.getInstance(); +const io = server.io; +const { setting } = require("./util-server"); +const checkVersion = require("./check-version"); +const Database = require("./database"); + +/** + * Send list of notification providers to client + * @param {Socket} socket Socket.io socket instance + * @returns {Promise<Bean[]>} List of notifications + */ +async function sendNotificationList(socket) { + const timeLogger = new TimeLogger(); + + let result = []; + let list = await R.find("notification", " user_id = ? ", [ + socket.userID, + ]); + + for (let bean of list) { + let notificationObject = bean.export(); + notificationObject.isDefault = (notificationObject.isDefault === 1); + notificationObject.active = (notificationObject.active === 1); + result.push(notificationObject); + } + + io.to(socket.userID).emit("notificationList", result); + + timeLogger.print("Send Notification List"); + + return list; +} + +/** + * Send Heartbeat History list to socket + * @param {Socket} socket Socket.io instance + * @param {number} monitorID ID of monitor to send heartbeat history + * @param {boolean} toUser True = send to all browsers with the same user id, False = send to the current browser only + * @param {boolean} overwrite Overwrite client-side's heartbeat list + * @returns {Promise<void>} + */ +async function sendHeartbeatList(socket, monitorID, toUser = false, overwrite = false) { + let list = await R.getAll(` + SELECT * FROM heartbeat + WHERE monitor_id = ? + ORDER BY time DESC + LIMIT 100 + `, [ + monitorID, + ]); + + let result = list.reverse(); + + if (toUser) { + io.to(socket.userID).emit("heartbeatList", monitorID, result, overwrite); + } else { + socket.emit("heartbeatList", monitorID, result, overwrite); + } +} + +/** + * Important Heart beat list (aka event list) + * @param {Socket} socket Socket.io instance + * @param {number} monitorID ID of monitor to send heartbeat history + * @param {boolean} toUser True = send to all browsers with the same user id, False = send to the current browser only + * @param {boolean} overwrite Overwrite client-side's heartbeat list + * @returns {Promise<void>} + */ +async function sendImportantHeartbeatList(socket, monitorID, toUser = false, overwrite = false) { + const timeLogger = new TimeLogger(); + + let list = await R.find("heartbeat", ` + monitor_id = ? + AND important = 1 + ORDER BY time DESC + LIMIT 500 + `, [ + monitorID, + ]); + + timeLogger.print(`[Monitor: ${monitorID}] sendImportantHeartbeatList`); + + if (toUser) { + io.to(socket.userID).emit("importantHeartbeatList", monitorID, list, overwrite); + } else { + socket.emit("importantHeartbeatList", monitorID, list, overwrite); + } + +} + +/** + * Emit proxy list to client + * @param {Socket} socket Socket.io socket instance + * @returns {Promise<Bean[]>} List of proxies + */ +async function sendProxyList(socket) { + const timeLogger = new TimeLogger(); + + const list = await R.find("proxy", " user_id = ? ", [ socket.userID ]); + io.to(socket.userID).emit("proxyList", list.map(bean => bean.export())); + + timeLogger.print("Send Proxy List"); + + return list; +} + +/** + * Emit API key list to client + * @param {Socket} socket Socket.io socket instance + * @returns {Promise<void>} + */ +async function sendAPIKeyList(socket) { + const timeLogger = new TimeLogger(); + + let result = []; + const list = await R.find( + "api_key", + "user_id=?", + [ socket.userID ], + ); + + for (let bean of list) { + result.push(bean.toPublicJSON()); + } + + io.to(socket.userID).emit("apiKeyList", result); + timeLogger.print("Sent API Key List"); + + return list; +} + +/** + * Emits the version information to the client. + * @param {Socket} socket Socket.io socket instance + * @param {boolean} hideVersion Should we hide the version information in the response? + * @returns {Promise<void>} + */ +async function sendInfo(socket, hideVersion = false) { + let version; + let latestVersion; + let isContainer; + let dbType; + + if (!hideVersion) { + version = checkVersion.version; + latestVersion = checkVersion.latestVersion; + isContainer = (process.env.UPTIME_KUMA_IS_CONTAINER === "1"); + dbType = Database.dbConfig.type; + } + + socket.emit("info", { + version, + latestVersion, + isContainer, + dbType, + primaryBaseURL: await setting("primaryBaseURL"), + serverTimezone: await server.getTimezone(), + serverTimezoneOffset: server.getTimezoneOffset(), + }); +} + +/** + * Send list of docker hosts to client + * @param {Socket} socket Socket.io socket instance + * @returns {Promise<Bean[]>} List of docker hosts + */ +async function sendDockerHostList(socket) { + const timeLogger = new TimeLogger(); + + let result = []; + let list = await R.find("docker_host", " user_id = ? ", [ + socket.userID, + ]); + + for (let bean of list) { + result.push(bean.toJSON()); + } + + io.to(socket.userID).emit("dockerHostList", result); + + timeLogger.print("Send Docker Host List"); + + return list; +} + +/** + * Send list of docker hosts to client + * @param {Socket} socket Socket.io socket instance + * @returns {Promise<Bean[]>} List of docker hosts + */ +async function sendRemoteBrowserList(socket) { + const timeLogger = new TimeLogger(); + + let result = []; + let list = await R.find("remote_browser", " user_id = ? ", [ + socket.userID, + ]); + + for (let bean of list) { + result.push(bean.toJSON()); + } + + io.to(socket.userID).emit("remoteBrowserList", result); + + timeLogger.print("Send Remote Browser List"); + + return list; +} + +/** + * Send list of monitor types to client + * @param {Socket} socket Socket.io socket instance + * @returns {Promise<void>} + */ +async function sendMonitorTypeList(socket) { + const result = Object.entries(UptimeKumaServer.monitorTypeList).map(([ key, type ]) => { + return [ key, { + supportsConditions: type.supportsConditions, + conditionVariables: type.conditionVariables.map(v => { + return { + id: v.id, + operators: v.operators.map(o => { + return { + id: o.id, + caption: o.caption, + }; + }), + }; + }), + }]; + }); + + io.to(socket.userID).emit("monitorTypeList", Object.fromEntries(result)); +} + +module.exports = { + sendNotificationList, + sendImportantHeartbeatList, + sendHeartbeatList, + sendProxyList, + sendAPIKeyList, + sendInfo, + sendDockerHostList, + sendRemoteBrowserList, + sendMonitorTypeList, +}; diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..515b904 --- /dev/null +++ b/server/config.js @@ -0,0 +1,46 @@ +const isFreeBSD = /^freebsd/.test(process.platform); + +// Interop with browser +const args = (typeof process !== "undefined") ? require("args-parser")(process.argv) : {}; + +// If host is omitted, the server will accept connections on the unspecified IPv6 address (::) when IPv6 is available and the unspecified IPv4 address (0.0.0.0) otherwise. +// Dual-stack support for (::) +// Also read HOST if not FreeBSD, as HOST is a system environment variable in FreeBSD +let hostEnv = isFreeBSD ? null : process.env.HOST; +const hostname = args.host || process.env.UPTIME_KUMA_HOST || hostEnv; + +const port = [ args.port, process.env.UPTIME_KUMA_PORT, process.env.PORT, 3001 ] + .map(portValue => parseInt(portValue)) + .find(portValue => !isNaN(portValue)); + +const sslKey = args["ssl-key"] || process.env.UPTIME_KUMA_SSL_KEY || process.env.SSL_KEY || undefined; +const sslCert = args["ssl-cert"] || process.env.UPTIME_KUMA_SSL_CERT || process.env.SSL_CERT || undefined; +const sslKeyPassphrase = args["ssl-key-passphrase"] || process.env.UPTIME_KUMA_SSL_KEY_PASSPHRASE || process.env.SSL_KEY_PASSPHRASE || undefined; + +const isSSL = sslKey && sslCert; + +/** + * Get the local WebSocket URL + * @returns {string} The local WebSocket URL + */ +function getLocalWebSocketURL() { + const protocol = isSSL ? "wss" : "ws"; + const host = hostname || "localhost"; + return `${protocol}://${host}:${port}`; +} + +const localWebSocketURL = getLocalWebSocketURL(); + +const demoMode = args["demo"] || false; + +module.exports = { + args, + hostname, + port, + sslKey, + sslCert, + sslKeyPassphrase, + isSSL, + localWebSocketURL, + demoMode, +}; diff --git a/server/database.js b/server/database.js new file mode 100644 index 0000000..3927d6d --- /dev/null +++ b/server/database.js @@ -0,0 +1,912 @@ +const fs = require("fs"); +const { R } = require("redbean-node"); +const { setSetting, setting } = require("./util-server"); +const { log, sleep } = require("../src/util"); +const knex = require("knex"); +const path = require("path"); +const { EmbeddedMariaDB } = require("./embedded-mariadb"); +const mysql = require("mysql2/promise"); +const { Settings } = require("./settings"); +const { UptimeCalculator } = require("./uptime-calculator"); +const dayjs = require("dayjs"); +const { SimpleMigrationServer } = require("./utils/simple-migration-server"); +const KumaColumnCompiler = require("./utils/knex/lib/dialects/mysql2/schema/mysql2-columncompiler"); + +/** + * Database & App Data Folder + */ +class Database { + + /** + * Boostrap database for SQLite + * @type {string} + */ + static templatePath = "./db/kuma.db"; + + /** + * Data Dir (Default: ./data) + * @type {string} + */ + static dataDir; + + /** + * User Upload Dir (Default: ./data/upload) + * @type {string} + */ + static uploadDir; + + /** + * Chrome Screenshot Dir (Default: ./data/screenshots) + * @type {string} + */ + static screenshotDir; + + /** + * SQLite file path (Default: ./data/kuma.db) + * @type {string} + */ + static sqlitePath; + + /** + * For storing Docker TLS certs (Default: ./data/docker-tls) + * @type {string} + */ + static dockerTLSDir; + + /** + * @type {boolean} + */ + static patched = false; + + /** + * SQLite only + * Add patch filename in key + * Values: + * true: Add it regardless of order + * false: Do nothing + * { parents: []}: Need parents before add it + * @deprecated + */ + static patchList = { + "patch-setting-value-type.sql": true, + "patch-improve-performance.sql": true, + "patch-2fa.sql": true, + "patch-add-retry-interval-monitor.sql": true, + "patch-incident-table.sql": true, + "patch-group-table.sql": true, + "patch-monitor-push_token.sql": true, + "patch-http-monitor-method-body-and-headers.sql": true, + "patch-2fa-invalidate-used-token.sql": true, + "patch-notification_sent_history.sql": true, + "patch-monitor-basic-auth.sql": true, + "patch-add-docker-columns.sql": true, + "patch-status-page.sql": true, + "patch-proxy.sql": true, + "patch-monitor-expiry-notification.sql": true, + "patch-status-page-footer-css.sql": true, + "patch-added-mqtt-monitor.sql": true, + "patch-add-clickable-status-page-link.sql": true, + "patch-add-sqlserver-monitor.sql": true, + "patch-add-other-auth.sql": { parents: [ "patch-monitor-basic-auth.sql" ] }, + "patch-grpc-monitor.sql": true, + "patch-add-radius-monitor.sql": true, + "patch-monitor-add-resend-interval.sql": true, + "patch-ping-packet-size.sql": true, + "patch-maintenance-table2.sql": true, + "patch-add-gamedig-monitor.sql": true, + "patch-add-google-analytics-status-page-tag.sql": true, + "patch-http-body-encoding.sql": true, + "patch-add-description-monitor.sql": true, + "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, + "patch-added-json-query.sql": true, + "patch-added-kafka-producer.sql": true, + "patch-add-certificate-expiry-status-page.sql": true, + "patch-monitor-oauth-cc.sql": true, + "patch-add-timeout-monitor.sql": true, + "patch-add-gamedig-given-port.sql": true, + "patch-notification-config.sql": true, + "patch-fix-kafka-producer-booleans.sql": true, + "patch-timeout.sql": true, + "patch-monitor-tls-info-add-fk.sql": true, // The last file so far converted to a knex migration file + }; + + /** + * The final version should be 10 after merged tag feature + * @deprecated Use patchList for any new feature + */ + static latestVersion = 10; + + static noReject = true; + + static dbConfig = {}; + + static knexMigrationsPath = "./db/knex_migrations"; + + /** + * Initialize the data directory + * @param {object} args Arguments to initialize DB with + * @returns {void} + */ + static initDataDir(args) { + // Data Directory (must be end with "/") + Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/"; + + Database.sqlitePath = path.join(Database.dataDir, "kuma.db"); + if (! fs.existsSync(Database.dataDir)) { + fs.mkdirSync(Database.dataDir, { recursive: true }); + } + + Database.uploadDir = path.join(Database.dataDir, "upload/"); + + if (! fs.existsSync(Database.uploadDir)) { + fs.mkdirSync(Database.uploadDir, { recursive: true }); + } + + // Create screenshot dir + Database.screenshotDir = path.join(Database.dataDir, "screenshots/"); + if (! fs.existsSync(Database.screenshotDir)) { + fs.mkdirSync(Database.screenshotDir, { recursive: true }); + } + + Database.dockerTLSDir = path.join(Database.dataDir, "docker-tls/"); + if (! fs.existsSync(Database.dockerTLSDir)) { + fs.mkdirSync(Database.dockerTLSDir, { recursive: true }); + } + + log.info("server", `Data Dir: ${Database.dataDir}`); + } + + /** + * Read the database config + * @throws {Error} If the config is invalid + * @typedef {string|undefined} envString + * @returns {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} Database config + */ + static readDBConfig() { + let dbConfig; + + let dbConfigString = fs.readFileSync(path.join(Database.dataDir, "db-config.json")).toString("utf-8"); + dbConfig = JSON.parse(dbConfigString); + + if (typeof dbConfig !== "object") { + throw new Error("Invalid db-config.json, it must be an object"); + } + + if (typeof dbConfig.type !== "string") { + throw new Error("Invalid db-config.json, type must be a string"); + } + return dbConfig; + } + + /** + * @typedef {string|undefined} envString + * @param {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} dbConfig the database configuration that should be written + * @returns {void} + */ + static writeDBConfig(dbConfig) { + fs.writeFileSync(path.join(Database.dataDir, "db-config.json"), JSON.stringify(dbConfig, null, 4)); + } + + /** + * Connect to the database + * @param {boolean} testMode Should the connection be started in test mode? + * @param {boolean} autoloadModels Should models be automatically loaded? + * @param {boolean} noLog Should logs not be output? + * @returns {Promise<void>} + */ + static async connect(testMode = false, autoloadModels = true, noLog = false) { + // Patch "mysql2" knex client + // Workaround: Tried extending the ColumnCompiler class, but it didn't work for unknown reasons, so I override the function via prototype + const { getDialectByNameOrAlias } = require("knex/lib/dialects"); + const mysql2 = getDialectByNameOrAlias("mysql2"); + mysql2.prototype.columnCompiler = function () { + return new KumaColumnCompiler(this, ...arguments); + }; + + const acquireConnectionTimeout = 120 * 1000; + let dbConfig; + try { + dbConfig = this.readDBConfig(); + Database.dbConfig = dbConfig; + } catch (err) { + log.warn("db", err.message); + dbConfig = { + type: "sqlite", + }; + } + + let config = {}; + + let mariadbPoolConfig = { + min: 0, + max: 10, + idleTimeoutMillis: 30000, + }; + + log.info("db", `Database Type: ${dbConfig.type}`); + + if (dbConfig.type === "sqlite") { + + if (! fs.existsSync(Database.sqlitePath)) { + log.info("server", "Copying Database"); + fs.copyFileSync(Database.templatePath, Database.sqlitePath); + } + + const Dialect = require("knex/lib/dialects/sqlite3/index.js"); + Dialect.prototype._driver = () => require("@louislam/sqlite3"); + + config = { + client: Dialect, + connection: { + filename: Database.sqlitePath, + acquireConnectionTimeout: acquireConnectionTimeout, + }, + useNullAsDefault: true, + pool: { + min: 1, + max: 1, + idleTimeoutMillis: 120 * 1000, + propagateCreateError: false, + acquireTimeoutMillis: acquireConnectionTimeout, + } + }; + } else if (dbConfig.type === "mariadb") { + if (!/^\w+$/.test(dbConfig.dbName)) { + throw Error("Invalid database name. A database name can only consist of letters, numbers and underscores"); + } + + const connection = await mysql.createConnection({ + host: dbConfig.hostname, + port: dbConfig.port, + user: dbConfig.username, + password: dbConfig.password, + }); + + await connection.execute("CREATE DATABASE IF NOT EXISTS " + dbConfig.dbName + " CHARACTER SET utf8mb4"); + connection.end(); + + config = { + client: "mysql2", + connection: { + host: dbConfig.hostname, + port: dbConfig.port, + user: dbConfig.username, + password: dbConfig.password, + database: dbConfig.dbName, + timezone: "Z", + typeCast: function (field, next) { + if (field.type === "DATETIME") { + // Do not perform timezone conversion + return field.string(); + } + return next(); + }, + }, + pool: mariadbPoolConfig, + }; + } else if (dbConfig.type === "embedded-mariadb") { + let embeddedMariaDB = EmbeddedMariaDB.getInstance(); + await embeddedMariaDB.start(); + log.info("mariadb", "Embedded MariaDB started"); + config = { + client: "mysql2", + connection: { + socketPath: embeddedMariaDB.socketPath, + user: "node", + database: "kuma", + timezone: "Z", + typeCast: function (field, next) { + if (field.type === "DATETIME") { + // Do not perform timezone conversion + return field.string(); + } + return next(); + }, + }, + pool: mariadbPoolConfig, + }; + } else { + throw new Error("Unknown Database type: " + dbConfig.type); + } + + // Set to utf8mb4 for MariaDB + if (dbConfig.type.endsWith("mariadb")) { + config.pool = { + afterCreate(conn, done) { + conn.query("SET CHARACTER SET utf8mb4;", (err) => done(err, conn)); + }, + }; + } + + const knexInstance = knex(config); + + R.setup(knexInstance); + + if (process.env.SQL_LOG === "1") { + R.debug(true); + } + + // Auto map the model to a bean object + R.freeze(true); + + if (autoloadModels) { + await R.autoloadModels("./server/model"); + } + + if (dbConfig.type === "sqlite") { + await this.initSQLite(testMode, noLog); + } else if (dbConfig.type.endsWith("mariadb")) { + await this.initMariaDB(); + } + } + + /** + @param {boolean} testMode Should the connection be started in test mode? + @param {boolean} noLog Should logs not be output? + @returns {Promise<void>} + */ + static async initSQLite(testMode, noLog) { + await R.exec("PRAGMA foreign_keys = ON"); + if (testMode) { + // Change to MEMORY + await R.exec("PRAGMA journal_mode = MEMORY"); + } else { + // Change to WAL + await R.exec("PRAGMA journal_mode = WAL"); + } + await R.exec("PRAGMA cache_size = -12000"); + 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 = NORMAL"); + + if (!noLog) { + log.debug("db", "SQLite config:"); + log.debug("db", await R.getAll("PRAGMA journal_mode")); + log.debug("db", await R.getAll("PRAGMA cache_size")); + log.debug("db", "SQLite Version: " + await R.getCell("SELECT sqlite_version()")); + } + } + + /** + * Initialize MariaDB + * @returns {Promise<void>} + */ + static async initMariaDB() { + log.debug("db", "Checking if MariaDB database exists..."); + + let hasTable = await R.hasTable("docker_host"); + if (!hasTable) { + const { createTables } = require("../db/knex_init_db"); + await createTables(); + } else { + log.debug("db", "MariaDB database already exists"); + } + } + + /** + * Patch the database + * @param {number} port Start the migration server for aggregate tables on this port if provided + * @param {string} hostname Start the migration server for aggregate tables on this hostname if provided + * @returns {Promise<void>} + */ + static async patch(port = undefined, hostname = undefined) { + // Still need to keep this for old versions of Uptime Kuma + if (Database.dbConfig.type === "sqlite") { + await this.patchSqlite(); + } + + // Using knex migrations + // https://knexjs.org/guide/migrations.html + // https://gist.github.com/NigelEarle/70db130cc040cc2868555b29a0278261 + try { + // Disable foreign key check for SQLite + // Known issue of knex: https://github.com/drizzle-team/drizzle-orm/issues/1813 + if (Database.dbConfig.type === "sqlite") { + await R.exec("PRAGMA foreign_keys = OFF"); + } + + await R.knex.migrate.latest({ + directory: Database.knexMigrationsPath, + }); + + // Enable foreign key check for SQLite + if (Database.dbConfig.type === "sqlite") { + await R.exec("PRAGMA foreign_keys = ON"); + } + + await this.migrateAggregateTable(port, hostname); + + } catch (e) { + // Allow missing patch files for downgrade or testing pr. + if (e.message.includes("the following files are missing:")) { + log.warn("db", e.message); + log.warn("db", "Database migration failed, you may be downgrading Uptime Kuma."); + } else { + log.error("db", "Database migration failed"); + throw e; + } + } + } + + /** + * TODO + * @returns {Promise<void>} + */ + static async rollbackLatestPatch() { + + } + + /** + * Patch the database for SQLite + * @returns {Promise<void>} + * @deprecated + */ + static async patchSqlite() { + let version = parseInt(await setting("database_version")); + + if (! version) { + version = 0; + } + + if (version !== this.latestVersion) { + log.info("db", "Your database version: " + version); + log.info("db", "Latest database version: " + this.latestVersion); + } + + if (version === this.latestVersion) { + log.debug("db", "Database patch not needed"); + } else if (version > this.latestVersion) { + log.warn("db", "Warning: Database version is newer than expected"); + } else { + log.info("db", "Database patch is needed"); + + // Try catch anything here + try { + for (let i = version + 1; i <= this.latestVersion; i++) { + const sqlFile = `./db/old_migrations/patch${i}.sql`; + log.info("db", `Patching ${sqlFile}`); + await Database.importSQLFile(sqlFile); + log.info("db", `Patched ${sqlFile}`); + await setSetting("database_version", i); + } + } catch (ex) { + await Database.close(); + + log.error("db", ex); + log.error("db", "Start Uptime-Kuma failed due to issue patching the database"); + log.error("db", "Please submit a bug report if you still encounter the problem after restart: https://github.com/louislam/uptime-kuma/issues"); + + process.exit(1); + } + } + + await this.patchSqlite2(); + await this.migrateNewStatusPage(); + } + + /** + * Patch DB using new process + * Call it from patch() only + * @deprecated + * @private + * @returns {Promise<void>} + */ + static async patchSqlite2() { + log.debug("db", "Database Patch 2.0 Process"); + let databasePatchedFiles = await setting("databasePatchedFiles"); + + if (! databasePatchedFiles) { + databasePatchedFiles = {}; + } + + log.debug("db", "Patched files:"); + log.debug("db", databasePatchedFiles); + + try { + for (let sqlFilename in this.patchList) { + await this.patch2Recursion(sqlFilename, databasePatchedFiles); + } + + if (this.patched) { + log.info("db", "Database Patched Successfully"); + } + + } catch (ex) { + await Database.close(); + + log.error("db", ex); + log.error("db", "Start Uptime-Kuma failed due to issue patching the database"); + log.error("db", "Please submit the bug report if you still encounter the problem after restart: https://github.com/louislam/uptime-kuma/issues"); + + process.exit(1); + } + + await setSetting("databasePatchedFiles", databasePatchedFiles); + } + + /** + * SQlite only + * Migrate status page value in setting to "status_page" table + * @returns {Promise<void>} + */ + static async migrateNewStatusPage() { + + // Fix 1.13.0 empty slug bug + await R.exec("UPDATE status_page SET slug = 'empty-slug-recover' WHERE TRIM(slug) = ''"); + + let title = await setting("title"); + + if (title) { + console.log("Migrating Status Page"); + + let statusPageCheck = await R.findOne("status_page", " slug = 'default' "); + + if (statusPageCheck !== null) { + console.log("Migrating Status Page - Skip, default slug record is already existing"); + return; + } + + let statusPage = R.dispense("status_page"); + statusPage.slug = "default"; + statusPage.title = title; + statusPage.description = await setting("description"); + statusPage.icon = await setting("icon"); + statusPage.theme = await setting("statusPageTheme"); + statusPage.published = !!await setting("statusPagePublished"); + statusPage.search_engine_index = !!await setting("searchEngineIndex"); + statusPage.show_tags = !!await setting("statusPageTags"); + statusPage.password = null; + + if (!statusPage.title) { + statusPage.title = "My Status Page"; + } + + if (!statusPage.icon) { + statusPage.icon = ""; + } + + if (!statusPage.theme) { + statusPage.theme = "light"; + } + + let id = await R.store(statusPage); + + await R.exec("UPDATE incident SET status_page_id = ? WHERE status_page_id IS NULL", [ + id + ]); + + await R.exec("UPDATE [group] SET status_page_id = ? WHERE status_page_id IS NULL", [ + id + ]); + + await R.exec("DELETE FROM setting WHERE type = 'statusPage'"); + + // Migrate Entry Page if it is status page + let entryPage = await setting("entryPage"); + + if (entryPage === "statusPage") { + await setSetting("entryPage", "statusPage-default", "general"); + } + + console.log("Migrating Status Page - Done"); + } + + } + + /** + * Patch database using new patching process + * Used it patch2() only + * @private + * @param {string} sqlFilename Name of SQL file to load + * @param {object} databasePatchedFiles Patch status of database files + * @returns {Promise<void>} + */ + static async patch2Recursion(sqlFilename, databasePatchedFiles) { + let value = this.patchList[sqlFilename]; + + if (! value) { + log.info("db", sqlFilename + " skip"); + return; + } + + // Check if patched + if (! databasePatchedFiles[sqlFilename]) { + log.info("db", sqlFilename + " is not patched"); + + if (value.parents) { + log.info("db", sqlFilename + " need parents"); + for (let parentSQLFilename of value.parents) { + await this.patch2Recursion(parentSQLFilename, databasePatchedFiles); + } + } + + log.info("db", sqlFilename + " is patching"); + this.patched = true; + await this.importSQLFile("./db/old_migrations/" + sqlFilename); + databasePatchedFiles[sqlFilename] = true; + log.info("db", sqlFilename + " was patched successfully"); + + } else { + log.debug("db", sqlFilename + " is already patched, skip"); + } + } + + /** + * Load an SQL file and execute it + * @param {string} filename Filename of SQL file to import + * @returns {Promise<void>} + */ + static async importSQLFile(filename) { + // Sadly, multi sql statements is not supported by many sqlite libraries, I have to implement it myself + await R.getCell("SELECT 1"); + + let text = fs.readFileSync(filename).toString(); + + // Remove all comments (--) + let lines = text.split("\n"); + lines = lines.filter((line) => { + return ! line.startsWith("--"); + }); + + // Split statements by semicolon + // Filter out empty line + text = lines.join("\n"); + + let statements = text.split(";") + .map((statement) => { + return statement.trim(); + }) + .filter((statement) => { + return statement !== ""; + }); + + for (let statement of statements) { + await R.exec(statement); + } + } + + /** + * Special handle, because tarn.js throw a promise reject that cannot be caught + * @returns {Promise<void>} + */ + static async close() { + const listener = (reason, p) => { + Database.noReject = false; + }; + process.addListener("unhandledRejection", listener); + + log.info("db", "Closing the database"); + + // Flush WAL to main database + if (Database.dbConfig.type === "sqlite") { + await R.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + } + + while (true) { + Database.noReject = true; + await R.close(); + await sleep(2000); + + if (Database.noReject) { + break; + } else { + log.info("db", "Waiting to close the database"); + } + } + log.info("db", "Database closed"); + + process.removeListener("unhandledRejection", listener); + } + + /** + * Get the size of the database (SQLite only) + * @returns {number} Size of database + */ + static getSize() { + if (Database.dbConfig.type === "sqlite") { + log.debug("db", "Database.getSize()"); + let stats = fs.statSync(Database.sqlitePath); + log.debug("db", stats); + return stats.size; + } + return 0; + } + + /** + * Shrink the database + * @returns {Promise<void>} + */ + static async shrink() { + if (Database.dbConfig.type === "sqlite") { + await R.exec("VACUUM"); + } + } + + /** + * @returns {string} Get the SQL for the current time plus a number of hours + */ + static sqlHourOffset() { + if (Database.dbConfig.type === "sqlite") { + return "DATETIME('now', ? || ' hours')"; + } else { + return "DATE_ADD(NOW(), INTERVAL ? HOUR)"; + } + } + + /** + * Migrate the old data in the heartbeat table to the new format (stat_daily, stat_hourly, stat_minutely) + * It should be run once while upgrading V1 to V2 + * + * Normally, it should be in transaction, but UptimeCalculator wasn't designed to be in transaction before that. + * I don't want to heavily modify the UptimeCalculator, so it is not in transaction. + * Run `npm run reset-migrate-aggregate-table-state` to reset, in case the migration is interrupted. + * @param {number} port Start the migration server on this port if provided + * @param {string} hostname Start the migration server on this hostname if provided + * @returns {Promise<void>} + */ + static async migrateAggregateTable(port, hostname = undefined) { + log.debug("db", "Enter Migrate Aggregate Table function"); + + // Add a setting for 2.0.0-dev users to skip this migration + if (process.env.SET_MIGRATE_AGGREGATE_TABLE_TO_TRUE === "1") { + log.warn("db", "SET_MIGRATE_AGGREGATE_TABLE_TO_TRUE is set to 1, skipping aggregate table migration forever (for 2.0.0-dev users)"); + await Settings.set("migrateAggregateTableState", "migrated"); + } + + let migrateState = await Settings.get("migrateAggregateTableState"); + + // Skip if already migrated + // If it is migrating, it possibly means the migration was interrupted, or the migration is in progress + if (migrateState === "migrated") { + log.debug("db", "Migrated aggregate table already, skip"); + return; + } else if (migrateState === "migrating") { + log.warn("db", "Aggregate table migration is already in progress, or it was interrupted"); + throw new Error("Aggregate table migration is already in progress"); + } + + /** + * Start migration server for displaying the migration status + * @type {SimpleMigrationServer} + */ + let migrationServer; + let msg; + + if (port) { + migrationServer = new SimpleMigrationServer(); + await migrationServer.start(port, hostname); + } + + log.info("db", "Migrating Aggregate Table"); + + log.info("db", "Getting list of unique monitors"); + + // Get a list of unique monitors from the heartbeat table, using raw sql + let monitors = await R.getAll(` + SELECT DISTINCT monitor_id + FROM heartbeat + ORDER BY monitor_id ASC + `); + + // Stop if stat_* tables are not empty + for (let table of [ "stat_minutely", "stat_hourly", "stat_daily" ]) { + let countResult = await R.getRow(`SELECT COUNT(*) AS count FROM ${table}`); + let count = countResult.count; + if (count > 0) { + log.warn("db", `Aggregate table ${table} is not empty, migration will not be started (Maybe you were using 2.0.0-dev?)`); + await migrationServer?.stop(); + return; + } + } + + await Settings.set("migrateAggregateTableState", "migrating"); + + let progressPercent = 0; + let part = 100 / monitors.length; + let i = 1; + for (let monitor of monitors) { + // Get a list of unique dates from the heartbeat table, using raw sql + let dates = await R.getAll(` + SELECT DISTINCT DATE(time) AS date + FROM heartbeat + WHERE monitor_id = ? + ORDER BY date ASC + `, [ + monitor.monitor_id + ]); + + for (let date of dates) { + // New Uptime Calculator + let calculator = new UptimeCalculator(); + calculator.monitorID = monitor.monitor_id; + calculator.setMigrationMode(true); + + // Get all the heartbeats for this monitor and date + let heartbeats = await R.getAll(` + SELECT status, ping, time + FROM heartbeat + WHERE monitor_id = ? + AND DATE(time) = ? + ORDER BY time ASC + `, [ monitor.monitor_id, date.date ]); + + if (heartbeats.length > 0) { + msg = `[DON'T STOP] Migrating monitor data ${monitor.monitor_id} - ${date.date} [${progressPercent.toFixed(2)}%][${i}/${monitors.length}]`; + log.info("db", msg); + migrationServer?.update(msg); + } + + for (let heartbeat of heartbeats) { + await calculator.update(heartbeat.status, parseFloat(heartbeat.ping), dayjs(heartbeat.time)); + } + + progressPercent += (Math.round(part / dates.length * 100) / 100); + + // Lazy to fix the floating point issue, it is acceptable since it is just a progress bar + if (progressPercent > 100) { + progressPercent = 100; + } + } + + i++; + } + + msg = "Clearing non-important heartbeats"; + log.info("db", msg); + migrationServer?.update(msg); + + await Database.clearHeartbeatData(true); + await Settings.set("migrateAggregateTableState", "migrated"); + await migrationServer?.stop(); + + if (monitors.length > 0) { + log.info("db", "Aggregate Table Migration Completed"); + } else { + log.info("db", "No data to migrate"); + } + } + + /** + * Remove all non-important heartbeats from heartbeat table, keep last 24-hour or {KEEP_LAST_ROWS} rows for each monitor + * @param {boolean} detailedLog Log detailed information + * @returns {Promise<void>} + */ + static async clearHeartbeatData(detailedLog = false) { + let monitors = await R.getAll("SELECT id FROM monitor"); + const sqlHourOffset = Database.sqlHourOffset(); + + for (let monitor of monitors) { + if (detailedLog) { + log.info("db", "Deleting non-important heartbeats for monitor " + monitor.id); + } + await R.exec(` + DELETE FROM heartbeat + WHERE monitor_id = ? + AND important = 0 + AND time < ${sqlHourOffset} + AND id NOT IN ( + SELECT id + FROM heartbeat + WHERE monitor_id = ? + ORDER BY time DESC + LIMIT ? + ) + `, [ + monitor.id, + -24, + monitor.id, + 100, + ]); + } + } + +} + +module.exports = Database; diff --git a/server/docker.js b/server/docker.js new file mode 100644 index 0000000..ee6051d --- /dev/null +++ b/server/docker.js @@ -0,0 +1,179 @@ +const axios = require("axios"); +const { R } = require("redbean-node"); +const https = require("https"); +const fs = require("fs"); +const path = require("path"); +const Database = require("./database"); +const { axiosAbortSignal } = require("./util-server"); + +class DockerHost { + + static CertificateFileNameCA = "ca.pem"; + static CertificateFileNameCert = "cert.pem"; + static CertificateFileNameKey = "key.pem"; + + /** + * Save a docker host + * @param {object} dockerHost Docker host to save + * @param {?number} dockerHostID ID of the docker host to update + * @param {number} userID ID of the user who adds the docker host + * @returns {Promise<Bean>} Updated docker host + */ + static async save(dockerHost, dockerHostID, userID) { + let bean; + + if (dockerHostID) { + bean = await R.findOne("docker_host", " id = ? AND user_id = ? ", [ dockerHostID, userID ]); + + if (!bean) { + throw new Error("docker host not found"); + } + + } else { + bean = R.dispense("docker_host"); + } + + bean.user_id = userID; + bean.docker_daemon = dockerHost.dockerDaemon; + bean.docker_type = dockerHost.dockerType; + bean.name = dockerHost.name; + + await R.store(bean); + + return bean; + } + + /** + * Delete a Docker host + * @param {number} dockerHostID ID of the Docker host to delete + * @param {number} userID ID of the user who created the Docker host + * @returns {Promise<void>} + */ + static async delete(dockerHostID, userID) { + let bean = await R.findOne("docker_host", " id = ? AND user_id = ? ", [ dockerHostID, userID ]); + + if (!bean) { + throw new Error("docker host not found"); + } + + // Delete removed proxy from monitors if exists + await R.exec("UPDATE monitor SET docker_host = null WHERE docker_host = ?", [ dockerHostID ]); + + await R.trash(bean); + } + + /** + * Fetches the amount of containers on the Docker host + * @param {object} dockerHost Docker host to check for + * @returns {Promise<number>} Total amount of containers on the host + */ + static async testDockerHost(dockerHost) { + const options = { + url: "/containers/json?all=true", + timeout: 5000, + headers: { + "Accept": "*/*", + }, + signal: axiosAbortSignal(6000), + }; + + if (dockerHost.dockerType === "socket") { + options.socketPath = dockerHost.dockerDaemon; + } else if (dockerHost.dockerType === "tcp") { + options.baseURL = DockerHost.patchDockerURL(dockerHost.dockerDaemon); + options.httpsAgent = new https.Agent(DockerHost.getHttpsAgentOptions(dockerHost.dockerType, options.baseURL)); + } + + try { + let res = await axios.request(options); + + if (Array.isArray(res.data)) { + + if (res.data.length > 1) { + + if ("ImageID" in res.data[0]) { + return res.data.length; + } else { + throw new Error("Invalid Docker response, is it Docker really a daemon?"); + } + + } else { + return res.data.length; + } + + } else { + throw new Error("Invalid Docker response, is it Docker really a daemon?"); + } + } catch (e) { + if (e.code === "ECONNABORTED" || e.name === "CanceledError") { + throw new Error("Connection to Docker daemon timed out."); + } else { + throw e; + } + } + } + + /** + * Since axios 0.27.X, it does not accept `tcp://` protocol. + * Change it to `http://` on the fly in order to fix it. (https://github.com/louislam/uptime-kuma/issues/2165) + * @param {any} url URL to fix + * @returns {any} URL with tcp:// replaced by http:// + */ + static patchDockerURL(url) { + if (typeof url === "string") { + // Replace the first occurrence only with g + return url.replace(/tcp:\/\//g, "http://"); + } + return url; + } + + /** + * Returns HTTPS agent options with client side TLS parameters if certificate files + * for the given host are available under a predefined directory path. + * + * The base path where certificates are looked for can be set with the + * 'DOCKER_TLS_DIR_PATH' environmental variable or defaults to 'data/docker-tls/'. + * + * If a directory in this path exists with a name matching the FQDN of the docker host + * (e.g. the FQDN of 'https://example.com:2376' is 'example.com' so the directory + * 'data/docker-tls/example.com/' would be searched for certificate files), + * then 'ca.pem', 'key.pem' and 'cert.pem' files are included in the agent options. + * File names can also be overridden via 'DOCKER_TLS_FILE_NAME_(CA|KEY|CERT)'. + * @param {string} dockerType i.e. "tcp" or "socket" + * @param {string} url The docker host URL rewritten to https:// + * @returns {object} HTTP agent options + */ + static getHttpsAgentOptions(dockerType, url) { + let baseOptions = { + maxCachedSessions: 0, + rejectUnauthorized: true + }; + let certOptions = {}; + + let dirName = (new URL(url)).hostname; + + let caPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameCA); + let certPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameCert); + let keyPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameKey); + + if (dockerType === "tcp" && fs.existsSync(caPath) && fs.existsSync(certPath) && fs.existsSync(keyPath)) { + let ca = fs.readFileSync(caPath); + let key = fs.readFileSync(keyPath); + let cert = fs.readFileSync(certPath); + certOptions = { + ca, + key, + cert + }; + } + + return { + ...baseOptions, + ...certOptions + }; + } +} + +module.exports = { + DockerHost, +}; diff --git a/server/embedded-mariadb.js b/server/embedded-mariadb.js new file mode 100644 index 0000000..8aa7134 --- /dev/null +++ b/server/embedded-mariadb.js @@ -0,0 +1,176 @@ +const { log } = require("../src/util"); +const childProcess = require("child_process"); +const fs = require("fs"); +const mysql = require("mysql2"); + +/** + * It is only used inside the docker container + */ +class EmbeddedMariaDB { + + static instance = null; + + exec = "mariadbd"; + + mariadbDataDir = "/app/data/mariadb"; + + runDir = "/app/data/run/mariadb"; + + socketPath = this.runDir + "/mysqld.sock"; + + /** + * @type {ChildProcessWithoutNullStreams} + * @private + */ + childProcess = null; + running = false; + + started = false; + + /** + * @returns {EmbeddedMariaDB} The singleton instance + */ + static getInstance() { + if (!EmbeddedMariaDB.instance) { + EmbeddedMariaDB.instance = new EmbeddedMariaDB(); + } + return EmbeddedMariaDB.instance; + } + + /** + * @returns {boolean} If the singleton instance is created + */ + static hasInstance() { + return !!EmbeddedMariaDB.instance; + } + + /** + * Start the embedded MariaDB + * @returns {Promise<void>|void} A promise that resolves when the MariaDB is started or void if it is already started + */ + start() { + if (this.childProcess) { + log.info("mariadb", "Already started"); + return; + } + + this.initDB(); + + this.running = true; + log.info("mariadb", "Starting Embedded MariaDB"); + this.childProcess = childProcess.spawn(this.exec, [ + "--user=node", + "--datadir=" + this.mariadbDataDir, + `--socket=${this.socketPath}`, + `--pid-file=${this.runDir}/mysqld.pid`, + ]); + + this.childProcess.on("close", (code) => { + this.running = false; + this.childProcess = null; + this.started = false; + log.info("mariadb", "Stopped Embedded MariaDB: " + code); + + if (code !== 0) { + log.info("mariadb", "Try to restart Embedded MariaDB as it is not stopped by user"); + this.start(); + } + }); + + this.childProcess.on("error", (err) => { + if (err.code === "ENOENT") { + log.error("mariadb", `Embedded MariaDB: ${this.exec} is not found`); + } else { + log.error("mariadb", err); + } + }); + + let handler = (data) => { + log.debug("mariadb", data.toString("utf-8")); + if (data.toString("utf-8").includes("ready for connections")) { + this.initDBAfterStarted(); + } + }; + + this.childProcess.stdout.on("data", handler); + this.childProcess.stderr.on("data", handler); + + return new Promise((resolve) => { + let interval = setInterval(() => { + if (this.started) { + clearInterval(interval); + resolve(); + } else { + log.info("mariadb", "Waiting for Embedded MariaDB to start..."); + } + }, 1000); + }); + } + + /** + * Stop all the child processes + * @returns {void} + */ + stop() { + if (this.childProcess) { + this.childProcess.kill("SIGINT"); + this.childProcess = null; + } + } + + /** + * Install MariaDB if it is not installed and make sure the `runDir` directory exists + * @returns {void} + */ + initDB() { + if (!fs.existsSync(this.mariadbDataDir)) { + log.info("mariadb", `Embedded MariaDB: ${this.mariadbDataDir} is not found, create one now.`); + fs.mkdirSync(this.mariadbDataDir, { + recursive: true, + }); + + let result = childProcess.spawnSync("mysql_install_db", [ + "--user=node", + "--ldata=" + this.mariadbDataDir, + ]); + + if (result.status !== 0) { + let error = result.stderr.toString("utf-8"); + log.error("mariadb", error); + return; + } else { + log.info("mariadb", "Embedded MariaDB: mysql_install_db done:" + result.stdout.toString("utf-8")); + } + } + + if (!fs.existsSync(this.runDir)) { + log.info("mariadb", `Embedded MariaDB: ${this.runDir} is not found, create one now.`); + fs.mkdirSync(this.runDir, { + recursive: true, + }); + } + + } + + /** + * Initialise the "kuma" database in mariadb if it does not exist + * @returns {Promise<void>} + */ + async initDBAfterStarted() { + const connection = mysql.createConnection({ + socketPath: this.socketPath, + user: "node", + }); + + let result = await connection.execute("CREATE DATABASE IF NOT EXISTS `kuma`"); + log.debug("mariadb", "CREATE DATABASE: " + JSON.stringify(result)); + + log.info("mariadb", "Embedded MariaDB is ready for connections"); + this.started = true; + } + +} + +module.exports = { + EmbeddedMariaDB, +}; diff --git a/server/google-analytics.js b/server/google-analytics.js new file mode 100644 index 0000000..57ae7b7 --- /dev/null +++ b/server/google-analytics.js @@ -0,0 +1,28 @@ +const jsesc = require("jsesc"); +const { escape } = require("html-escaper"); + +/** + * Returns a string that represents the javascript that is required to insert the Google Analytics scripts + * into a webpage. + * @param {string} tagId Google UA/G/AW/DC Property ID to use with the Google Analytics script. + * @returns {string} HTML script tags to inject into page + */ +function getGoogleAnalyticsScript(tagId) { + let escapedTagIdJS = jsesc(tagId, { isScriptContext: true }); + + if (escapedTagIdJS) { + escapedTagIdJS = escapedTagIdJS.trim(); + } + + // Escape the tag ID for use in an HTML attribute. + let escapedTagIdHTMLAttribute = escape(tagId); + + return ` + <script async src="https://www.googletagmanager.com/gtag/js?id=${escapedTagIdHTMLAttribute}"></script> + <script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date());gtag('config', '${escapedTagIdJS}'); </script> + `; +} + +module.exports = { + getGoogleAnalyticsScript, +}; diff --git a/server/image-data-uri.js b/server/image-data-uri.js new file mode 100644 index 0000000..77dd233 --- /dev/null +++ b/server/image-data-uri.js @@ -0,0 +1,79 @@ +/* + From https://github.com/DiegoZoracKy/image-data-uri/blob/master/lib/image-data-uri.js + Modified with 0 dependencies + */ +let fs = require("fs"); +const { log } = require("../src/util"); + +let ImageDataURI = (() => { + + /** + * Decode the data:image/ URI + * @param {string} dataURI data:image/ URI to decode + * @returns {?object} An object with properties "imageType" and "dataBase64". + * The former is the image type, e.g., "png", and the latter is a base64 + * encoded string of the image's binary data. If it fails to parse, returns + * null instead of an object. + */ + function decode(dataURI) { + if (!/data:image\//.test(dataURI)) { + log.error("image-data-uri", "It seems that it is not an Image Data URI. Couldn't match \"data:image/\""); + return null; + } + + let regExMatches = dataURI.match("data:(image/.*);base64,(.*)"); + return { + imageType: regExMatches[1], + dataBase64: regExMatches[2], + dataBuffer: new Buffer(regExMatches[2], "base64") + }; + } + + /** + * Endcode an image into data:image/ URI + * @param {(Buffer|string)} data Data to encode + * @param {string} mediaType Media type of data + * @returns {(string|null)} A string representing the base64-encoded + * version of the given Buffer object or null if an error occurred. + */ + function encode(data, mediaType) { + if (!data || !mediaType) { + log.error("image-data-uri", "Missing some of the required params: data, mediaType"); + return null; + } + + mediaType = (/\//.test(mediaType)) ? mediaType : "image/" + mediaType; + let dataBase64 = (Buffer.isBuffer(data)) ? data.toString("base64") : new Buffer(data).toString("base64"); + let dataImgBase64 = "data:" + mediaType + ";base64," + dataBase64; + + return dataImgBase64; + } + + /** + * Write data URI to file + * @param {string} dataURI data:image/ URI + * @param {string} filePath Path to write file to + * @returns {Promise<string|void>} Write file error + */ + function outputFile(dataURI, filePath) { + filePath = filePath || "./"; + return new Promise((resolve, reject) => { + let imageDecoded = decode(dataURI); + + fs.writeFile(filePath, imageDecoded.dataBuffer, err => { + if (err) { + return reject("ImageDataURI :: Error :: " + JSON.stringify(err, null, 4)); + } + resolve(filePath); + }); + }); + } + + return { + decode: decode, + encode: encode, + outputFile: outputFile, + }; +})(); + +module.exports = ImageDataURI; diff --git a/server/jobs.js b/server/jobs.js new file mode 100644 index 0000000..0838731 --- /dev/null +++ b/server/jobs.js @@ -0,0 +1,58 @@ +const { UptimeKumaServer } = require("./uptime-kuma-server"); +const { clearOldData } = require("./jobs/clear-old-data"); +const { incrementalVacuum } = require("./jobs/incremental-vacuum"); +const Cron = require("croner"); + +const jobs = [ + { + name: "clear-old-data", + interval: "14 03 * * *", + jobFunc: clearOldData, + croner: null, + }, + { + name: "incremental-vacuum", + interval: "*/5 * * * *", + jobFunc: incrementalVacuum, + croner: null, + } +]; + +/** + * Initialize background jobs + * @returns {Promise<void>} + */ +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; + } + +}; + +/** + * Stop all background jobs if running + * @returns {void} + */ +const stopBackgroundJobs = function () { + for (const job of jobs) { + if (job.croner) { + job.croner.stop(); + job.croner = null; + } + } +}; + +module.exports = { + initBackgroundJobs, + stopBackgroundJobs +}; diff --git a/server/jobs/clear-old-data.js b/server/jobs/clear-old-data.js new file mode 100644 index 0000000..cfd65a8 --- /dev/null +++ b/server/jobs/clear-old-data.js @@ -0,0 +1,65 @@ +const { R } = require("redbean-node"); +const { log } = require("../../src/util"); +const Database = require("../database"); +const { Settings } = require("../settings"); +const dayjs = require("dayjs"); + +const DEFAULT_KEEP_PERIOD = 365; + +/** + * Clears old data from the heartbeat table and the stat_daily of the database. + * @returns {Promise<void>} A promise that resolves when the data has been cleared. + */ +const clearOldData = async () => { + await Database.clearHeartbeatData(); + let period = await Settings.get("keepDataPeriodDays"); + + // Set Default Period + if (period == null) { + await Settings.set("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general"); + period = DEFAULT_KEEP_PERIOD; + } + + // Try parse setting + let parsedPeriod; + try { + parsedPeriod = parseInt(period); + } catch (_) { + log.warn("clearOldData", "Failed to parse setting, resetting to default.."); + await Settings.set("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general"); + parsedPeriod = DEFAULT_KEEP_PERIOD; + } + + if (parsedPeriod < 1) { + log.info("clearOldData", `Data deletion has been disabled as period is less than 1. Period is ${parsedPeriod} days.`); + } else { + log.debug("clearOldData", `Clearing Data older than ${parsedPeriod} days...`); + const sqlHourOffset = Database.sqlHourOffset(); + + try { + // Heartbeat + await R.exec("DELETE FROM heartbeat WHERE time < " + sqlHourOffset, [ + parsedPeriod * -24, + ]); + + let timestamp = dayjs().subtract(parsedPeriod, "day").utc().startOf("day").unix(); + + // stat_daily + await R.exec("DELETE FROM stat_daily WHERE timestamp < ? ", [ + timestamp, + ]); + + if (Database.dbConfig.type === "sqlite") { + await R.exec("PRAGMA optimize;"); + } + } catch (e) { + log.error("clearOldData", `Failed to clear old data: ${e.message}`); + } + } + + log.debug("clearOldData", "Data cleared."); +}; + +module.exports = { + clearOldData, +}; diff --git a/server/jobs/incremental-vacuum.js b/server/jobs/incremental-vacuum.js new file mode 100644 index 0000000..f0fa78a --- /dev/null +++ b/server/jobs/incremental-vacuum.js @@ -0,0 +1,27 @@ +const { R } = require("redbean-node"); +const { log } = require("../../src/util"); +const Database = require("../database"); + +/** + * Run incremental_vacuum and checkpoint the WAL. + * @returns {Promise<void>} A promise that resolves when the process is finished. + */ + +const incrementalVacuum = async () => { + try { + if (Database.dbConfig.type !== "sqlite") { + log.debug("incrementalVacuum", "Skipping incremental_vacuum, not using SQLite."); + return; + } + + log.debug("incrementalVacuum", "Running incremental_vacuum and wal_checkpoint(PASSIVE)..."); + await R.exec("PRAGMA incremental_vacuum(200)"); + await R.exec("PRAGMA wal_checkpoint(PASSIVE)"); + } catch (e) { + log.error("incrementalVacuum", `Failed: ${e.message}`); + } +}; + +module.exports = { + incrementalVacuum, +}; diff --git a/server/model/api_key.js b/server/model/api_key.js new file mode 100644 index 0000000..96de984 --- /dev/null +++ b/server/model/api_key.js @@ -0,0 +1,76 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); +const { R } = require("redbean-node"); +const dayjs = require("dayjs"); + +class APIKey extends BeanModel { + /** + * Get the current status of this API key + * @returns {string} active, inactive or expired + */ + getStatus() { + let current = dayjs(); + let expiry = dayjs(this.expires); + if (expiry.diff(current) < 0) { + return "expired"; + } + + return this.active ? "active" : "inactive"; + } + + /** + * Returns an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + toJSON() { + return { + id: this.id, + key: this.key, + name: this.name, + userID: this.user_id, + createdDate: this.created_date, + active: this.active, + expires: this.expires, + status: this.getStatus(), + }; + } + + /** + * Returns an object that ready to parse to JSON with sensitive fields + * removed + * @returns {object} Object ready to parse + */ + toPublicJSON() { + return { + id: this.id, + name: this.name, + userID: this.user_id, + createdDate: this.created_date, + active: this.active, + expires: this.expires, + status: this.getStatus(), + }; + } + + /** + * Create a new API Key and store it in the database + * @param {object} key Object sent by client + * @param {int} userID ID of socket user + * @returns {Promise<bean>} API key + */ + static async save(key, userID) { + let bean; + bean = R.dispense("api_key"); + + bean.key = key.key; + bean.name = key.name; + bean.user_id = userID; + bean.active = key.active; + bean.expires = key.expires; + + await R.store(bean); + + return bean; + } +} + +module.exports = APIKey; diff --git a/server/model/docker_host.js b/server/model/docker_host.js new file mode 100644 index 0000000..ceb8f4a --- /dev/null +++ b/server/model/docker_host.js @@ -0,0 +1,19 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +class DockerHost extends BeanModel { + /** + * Returns an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + toJSON() { + return { + id: this.id, + userID: this.user_id, + dockerDaemon: this.docker_daemon, + dockerType: this.docker_type, + name: this.name, + }; + } +} + +module.exports = DockerHost; diff --git a/server/model/group.js b/server/model/group.js new file mode 100644 index 0000000..bd2c301 --- /dev/null +++ b/server/model/group.js @@ -0,0 +1,46 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); +const { R } = require("redbean-node"); + +class Group extends BeanModel { + + /** + * Return an object that ready to parse to JSON for public Only show + * necessary data to public + * @param {boolean} showTags Should the JSON include monitor tags + * @param {boolean} certExpiry Should JSON include info about + * certificate expiry? + * @returns {Promise<object>} Object ready to parse + */ + async toPublicJSON(showTags = false, certExpiry = false) { + let monitorBeanList = await this.getMonitorList(); + let monitorList = []; + + for (let bean of monitorBeanList) { + monitorList.push(await bean.toPublicJSON(showTags, certExpiry)); + } + + return { + id: this.id, + name: this.name, + weight: this.weight, + monitorList, + }; + } + + /** + * Get all monitors + * @returns {Promise<Bean[]>} List of monitors + */ + async getMonitorList() { + return R.convertToBeans("monitor", await R.getAll(` + SELECT monitor.*, monitor_group.send_url FROM monitor, monitor_group + WHERE monitor.id = monitor_group.monitor_id + AND group_id = ? + ORDER BY monitor_group.weight + `, [ + this.id, + ])); + } +} + +module.exports = Group; diff --git a/server/model/heartbeat.js b/server/model/heartbeat.js new file mode 100644 index 0000000..9e972a3 --- /dev/null +++ b/server/model/heartbeat.js @@ -0,0 +1,45 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +/** + * status: + * 0 = DOWN + * 1 = UP + * 2 = PENDING + * 3 = MAINTENANCE + */ +class Heartbeat extends BeanModel { + + /** + * Return an object that ready to parse to JSON for public + * Only show necessary data to public + * @returns {object} Object ready to parse + */ + toPublicJSON() { + return { + status: this.status, + time: this.time, + msg: "", // Hide for public + ping: this.ping, + }; + } + + /** + * Return an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + toJSON() { + return { + monitorID: this._monitorId, + status: this._status, + time: this._time, + msg: this._msg, + ping: this._ping, + important: this._important, + duration: this._duration, + retries: this._retries, + }; + } + +} + +module.exports = Heartbeat; diff --git a/server/model/incident.js b/server/model/incident.js new file mode 100644 index 0000000..c47dabb --- /dev/null +++ b/server/model/incident.js @@ -0,0 +1,23 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +class Incident extends BeanModel { + + /** + * Return an object that ready to parse to JSON for public + * Only show necessary data to public + * @returns {object} Object ready to parse + */ + toPublicJSON() { + return { + id: this.id, + style: this.style, + title: this.title, + content: this.content, + pin: this.pin, + createdDate: this.createdDate, + lastUpdatedDate: this.lastUpdatedDate, + }; + } +} + +module.exports = Incident; diff --git a/server/model/maintenance.js b/server/model/maintenance.js new file mode 100644 index 0000000..7111a18 --- /dev/null +++ b/server/model/maintenance.js @@ -0,0 +1,457 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); +const { parseTimeObject, parseTimeFromTimeObject, log } = require("../../src/util"); +const { R } = require("redbean-node"); +const dayjs = require("dayjs"); +const Cron = require("croner"); +const { UptimeKumaServer } = require("../uptime-kuma-server"); +const apicache = require("../modules/apicache"); + +class Maintenance extends BeanModel { + + /** + * Return an object that ready to parse to JSON for public + * Only show necessary data to public + * @returns {Promise<object>} Object ready to parse + */ + async toPublicJSON() { + + let dateRange = []; + if (this.start_date) { + dateRange.push(this.start_date); + } else { + dateRange.push(null); + } + + if (this.end_date) { + dateRange.push(this.end_date); + } + + let timeRange = []; + let startTime = parseTimeObject(this.start_time); + timeRange.push(startTime); + let endTime = parseTimeObject(this.end_time); + timeRange.push(endTime); + + let obj = { + id: this.id, + title: this.title, + description: this.description, + strategy: this.strategy, + intervalDay: this.interval_day, + active: !!this.active, + dateRange: dateRange, + timeRange: timeRange, + weekdays: (this.weekdays) ? JSON.parse(this.weekdays) : [], + daysOfMonth: (this.days_of_month) ? JSON.parse(this.days_of_month) : [], + timeslotList: [], + cron: this.cron, + duration: this.duration, + durationMinutes: parseInt(this.duration / 60), + timezone: await this.getTimezone(), // Only valid timezone + timezoneOption: this.timezone, // Mainly for dropdown menu, because there is a option "SAME_AS_SERVER" + timezoneOffset: await this.getTimezoneOffset(), + status: await this.getStatus(), + }; + + if (this.strategy === "manual") { + // Do nothing, no timeslots + } else if (this.strategy === "single") { + obj.timeslotList.push({ + startDate: this.start_date, + endDate: this.end_date, + }); + } else { + // Should be cron or recurring here + if (this.beanMeta.job) { + let runningTimeslot = this.getRunningTimeslot(); + + if (runningTimeslot) { + obj.timeslotList.push(runningTimeslot); + } + + let nextRunDate = this.beanMeta.job.nextRun(); + if (nextRunDate) { + let startDateDayjs = dayjs(nextRunDate); + + let startDate = startDateDayjs.toISOString(); + let endDate = startDateDayjs.add(this.duration, "second").toISOString(); + + obj.timeslotList.push({ + startDate, + endDate, + }); + } + } + } + + if (!Array.isArray(obj.weekdays)) { + obj.weekdays = []; + } + + if (!Array.isArray(obj.daysOfMonth)) { + obj.daysOfMonth = []; + } + + return obj; + } + + /** + * Return an object that ready to parse to JSON + * @param {string} timezone If not specified, the timeRange will be in UTC + * @returns {Promise<object>} Object ready to parse + */ + async toJSON(timezone = null) { + return this.toPublicJSON(timezone); + } + + /** + * Get a list of weekdays that the maintenance is active for + * Monday=1, Tuesday=2 etc. + * @returns {number[]} Array of active weekdays + */ + getDayOfWeekList() { + log.debug("timeslot", "List: " + this.weekdays); + return JSON.parse(this.weekdays).sort(function (a, b) { + return a - b; + }); + } + + /** + * Get a list of days in month that maintenance is active for + * @returns {number[]|string[]} Array of active days in month + */ + getDayOfMonthList() { + return JSON.parse(this.days_of_month).sort(function (a, b) { + return a - b; + }); + } + + /** + * Get the duration of maintenance in seconds + * @returns {number} Duration of maintenance + */ + calcDuration() { + let duration = dayjs.utc(this.end_time, "HH:mm").diff(dayjs.utc(this.start_time, "HH:mm"), "second"); + // Add 24hours if it is across day + if (duration < 0) { + duration += 24 * 3600; + } + return duration; + } + + /** + * Convert data from socket to bean + * @param {Bean} bean Bean to fill in + * @param {object} obj Data to fill bean with + * @returns {Promise<Bean>} Filled bean + */ + static async jsonToBean(bean, obj) { + if (obj.id) { + bean.id = obj.id; + } + + bean.title = obj.title; + bean.description = obj.description; + bean.strategy = obj.strategy; + bean.interval_day = obj.intervalDay; + bean.timezone = obj.timezoneOption; + bean.active = obj.active; + + if (obj.dateRange[0]) { + bean.start_date = obj.dateRange[0]; + } else { + bean.start_date = null; + } + + if (obj.dateRange[1]) { + bean.end_date = obj.dateRange[1]; + } else { + bean.end_date = null; + } + + if (bean.strategy === "cron") { + bean.duration = obj.durationMinutes * 60; + bean.cron = obj.cron; + this.validateCron(bean.cron); + } + + if (bean.strategy.startsWith("recurring-")) { + bean.start_time = parseTimeFromTimeObject(obj.timeRange[0]); + bean.end_time = parseTimeFromTimeObject(obj.timeRange[1]); + bean.weekdays = JSON.stringify(obj.weekdays); + bean.days_of_month = JSON.stringify(obj.daysOfMonth); + await bean.generateCron(); + this.validateCron(bean.cron); + } + return bean; + } + + /** + * Throw error if cron is invalid + * @param {string|Date} cron Pattern or date + * @returns {void} + */ + static validateCron(cron) { + let job = new Cron(cron, () => {}); + job.stop(); + } + + /** + * Run the cron + * @param {boolean} throwError Should an error be thrown on failure + * @returns {Promise<void>} + */ + async run(throwError = false) { + if (this.beanMeta.job) { + log.debug("maintenance", "Maintenance is already running, stop it first. id: " + this.id); + this.stop(); + } + + log.debug("maintenance", "Run maintenance id: " + this.id); + + // 1.21.2 migration + if (!this.cron) { + await this.generateCron(); + if (!this.timezone) { + this.timezone = "UTC"; + } + if (this.cron) { + await R.store(this); + } + } + + if (this.strategy === "manual") { + // Do nothing, because it is controlled by the user + } else if (this.strategy === "single") { + this.beanMeta.job = new Cron(this.start_date, { timezone: await this.getTimezone() }, () => { + log.info("maintenance", "Maintenance id: " + this.id + " is under maintenance now"); + UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); + apicache.clear(); + }); + } else if (this.cron != null) { + // Here should be cron or recurring + try { + this.beanMeta.status = "scheduled"; + + let startEvent = (customDuration = 0) => { + log.info("maintenance", "Maintenance id: " + this.id + " is under maintenance now"); + + this.beanMeta.status = "under-maintenance"; + clearTimeout(this.beanMeta.durationTimeout); + + let duration = this.inferDuration(customDuration); + + UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); + + this.beanMeta.durationTimeout = setTimeout(() => { + // End of maintenance for this timeslot + this.beanMeta.status = "scheduled"; + UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); + }, duration); + }; + + // Create Cron + if (this.strategy === "recurring-interval") { + // For recurring-interval, Croner needs to have interval and startAt + const startDate = dayjs(this.startDate); + const [ hour, minute ] = this.startTime.split(":"); + const startDateTime = startDate.hour(hour).minute(minute); + this.beanMeta.job = new Cron(this.cron, { + timezone: await this.getTimezone(), + interval: this.interval_day * 24 * 60 * 60, + startAt: startDateTime.toISOString(), + }, startEvent); + } else { + this.beanMeta.job = new Cron(this.cron, { + timezone: await this.getTimezone(), + }, startEvent); + } + + // Continue if the maintenance is still in the window + let runningTimeslot = this.getRunningTimeslot(); + let current = dayjs(); + + if (runningTimeslot) { + let duration = dayjs(runningTimeslot.endDate).diff(current, "second") * 1000; + log.debug("maintenance", "Maintenance id: " + this.id + " Remaining duration: " + duration + "ms"); + startEvent(duration); + } + + } catch (e) { + log.error("maintenance", "Error in maintenance id: " + this.id); + log.error("maintenance", "Cron: " + this.cron); + log.error("maintenance", e); + + if (throwError) { + throw e; + } + } + + } else { + log.error("maintenance", "Maintenance id: " + this.id + " has no cron"); + } + } + + /** + * Get timeslots where maintenance is running + * @returns {object|null} Maintenance time slot + */ + getRunningTimeslot() { + let start = dayjs(this.beanMeta.job.nextRun(dayjs().add(-this.duration, "second").toDate())); + let end = start.add(this.duration, "second"); + let current = dayjs(); + + if (current.isAfter(start) && current.isBefore(end)) { + return { + startDate: start.toISOString(), + endDate: end.toISOString(), + }; + } else { + return null; + } + } + + /** + * Calculate the maintenance duration + * @param {number} customDuration - The custom duration in milliseconds. + * @returns {number} The inferred duration in milliseconds. + */ + inferDuration(customDuration) { + // Check if duration is still in the window. If not, use the duration from the current time to the end of the window + if (customDuration > 0) { + return customDuration; + } else if (this.end_date) { + let d = dayjs(this.end_date).diff(dayjs(), "second"); + if (d < this.duration) { + return d * 1000; + } + } + return this.duration * 1000; + } + + /** + * Stop the maintenance + * @returns {void} + */ + stop() { + if (this.beanMeta.job) { + this.beanMeta.job.stop(); + delete this.beanMeta.job; + } + } + + /** + * Is this maintenance currently active + * @returns {Promise<boolean>} The maintenance is active? + */ + async isUnderMaintenance() { + return (await this.getStatus()) === "under-maintenance"; + } + + /** + * Get the timezone of the maintenance + * @returns {Promise<string>} timezone + */ + async getTimezone() { + if (!this.timezone || this.timezone === "SAME_AS_SERVER") { + return await UptimeKumaServer.getInstance().getTimezone(); + } + return this.timezone; + } + + /** + * Get offset for timezone + * @returns {Promise<string>} offset + */ + async getTimezoneOffset() { + return dayjs.tz(dayjs(), await this.getTimezone()).format("Z"); + } + + /** + * Get the current status of the maintenance + * @returns {Promise<string>} Current status + */ + async getStatus() { + if (!this.active) { + return "inactive"; + } + + if (this.strategy === "manual") { + return "under-maintenance"; + } + + // Check if the maintenance is started + if (this.start_date && dayjs().isBefore(dayjs.tz(this.start_date, await this.getTimezone()))) { + return "scheduled"; + } + + // Check if the maintenance is ended + if (this.end_date && dayjs().isAfter(dayjs.tz(this.end_date, await this.getTimezone()))) { + return "ended"; + } + + if (this.strategy === "single") { + return "under-maintenance"; + } + + if (!this.beanMeta.status) { + return "unknown"; + } + + return this.beanMeta.status; + } + + /** + * Generate Cron for recurring maintenance + * @returns {Promise<void>} + */ + async generateCron() { + log.info("maintenance", "Generate cron for maintenance id: " + this.id); + + if (this.strategy === "cron") { + // Do nothing for cron + } else if (!this.strategy.startsWith("recurring-")) { + this.cron = ""; + } else if (this.strategy === "recurring-interval") { + // For intervals, the pattern is calculated in the run function as the interval-option is set + this.cron = "* * * * *"; + this.duration = this.calcDuration(); + log.debug("maintenance", "Cron: " + this.cron); + log.debug("maintenance", "Duration: " + this.duration); + } else if (this.strategy === "recurring-weekday") { + let list = this.getDayOfWeekList(); + let array = this.start_time.split(":"); + let hour = parseInt(array[0]); + let minute = parseInt(array[1]); + this.cron = minute + " " + hour + " * * " + list.join(","); + this.duration = this.calcDuration(); + } else if (this.strategy === "recurring-day-of-month") { + let list = this.getDayOfMonthList(); + let array = this.start_time.split(":"); + let hour = parseInt(array[0]); + let minute = parseInt(array[1]); + + let dayList = []; + + for (let day of list) { + if (typeof day === "string" && day.startsWith("lastDay")) { + if (day === "lastDay1") { + dayList.push("L"); + } + // Unfortunately, lastDay2-4 is not supported by cron + } else { + dayList.push(day); + } + } + + // Remove duplicate + dayList = [ ...new Set(dayList) ]; + + this.cron = minute + " " + hour + " " + dayList.join(",") + " * *"; + this.duration = this.calcDuration(); + } + + } +} + +module.exports = Maintenance; diff --git a/server/model/monitor.js b/server/model/monitor.js new file mode 100644 index 0000000..9a30a66 --- /dev/null +++ b/server/model/monitor.js @@ -0,0 +1,1740 @@ +const dayjs = require("dayjs"); +const axios = require("axios"); +const { Prometheus } = require("../prometheus"); +const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, + SQL_DATETIME_FORMAT, evaluateJsonQuery +} = require("../../src/util"); +const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery, + redisPingAsync, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal +} = require("../util-server"); +const { R } = require("redbean-node"); +const { BeanModel } = require("redbean-node/dist/bean-model"); +const { Notification } = require("../notification"); +const { Proxy } = require("../proxy"); +const { demoMode } = require("../config"); +const version = require("../../package.json").version; +const apicache = require("../modules/apicache"); +const { UptimeKumaServer } = require("../uptime-kuma-server"); +const { DockerHost } = require("../docker"); +const Gamedig = require("gamedig"); +const jwt = require("jsonwebtoken"); +const crypto = require("crypto"); +const { UptimeCalculator } = require("../uptime-calculator"); +const { CookieJar } = require("tough-cookie"); +const { HttpsCookieAgent } = require("http-cookie-agent/http"); +const https = require("https"); +const http = require("http"); + +const rootCertificates = rootCertificatesFingerprints(); + +/** + * status: + * 0 = DOWN + * 1 = UP + * 2 = PENDING + * 3 = MAINTENANCE + */ +class Monitor extends BeanModel { + + /** + * Return an object that ready to parse to JSON for public Only show + * necessary data to public + * @param {boolean} showTags Include tags in JSON + * @param {boolean} certExpiry Include certificate expiry info in + * JSON + * @returns {Promise<object>} Object ready to parse + */ + async toPublicJSON(showTags = false, certExpiry = false) { + let obj = { + id: this.id, + name: this.name, + sendUrl: this.sendUrl, + type: this.type, + }; + + if (this.sendUrl) { + obj.url = this.url; + } + + if (showTags) { + obj.tags = await this.getTags(); + } + + if (certExpiry && (this.type === "http" || this.type === "keyword" || this.type === "json-query") && this.getURLProtocol() === "https:") { + const { certExpiryDaysRemaining, validCert } = await this.getCertExpiry(this.id); + obj.certExpiryDaysRemaining = certExpiryDaysRemaining; + obj.validCert = validCert; + } + + return obj; + } + + /** + * Return an object that ready to parse to JSON + * @param {object} preloadData to prevent n+1 problems, we query the data in a batch outside of this function + * @param {boolean} includeSensitiveData Include sensitive data in + * JSON + * @returns {object} Object ready to parse + */ + toJSON(preloadData = {}, includeSensitiveData = true) { + + let screenshot = null; + + if (this.type === "real-browser") { + screenshot = "/screenshots/" + jwt.sign(this.id, UptimeKumaServer.getInstance().jwtSecret) + ".png"; + } + + const path = preloadData.paths.get(this.id) || []; + const pathName = path.join(" / "); + + let data = { + id: this.id, + name: this.name, + description: this.description, + path, + pathName, + parent: this.parent, + childrenIDs: preloadData.childrenIDs.get(this.id) || [], + url: this.url, + method: this.method, + hostname: this.hostname, + port: this.port, + maxretries: this.maxretries, + weight: this.weight, + active: preloadData.activeStatus.get(this.id), + forceInactive: preloadData.forceInactive.get(this.id), + type: this.type, + timeout: this.timeout, + interval: this.interval, + retryInterval: this.retryInterval, + resendInterval: this.resendInterval, + keyword: this.keyword, + invertKeyword: this.isInvertKeyword(), + expiryNotification: this.isEnabledExpiryNotification(), + ignoreTls: this.getIgnoreTls(), + upsideDown: this.isUpsideDown(), + packetSize: this.packetSize, + maxredirects: this.maxredirects, + accepted_statuscodes: this.getAcceptedStatuscodes(), + dns_resolve_type: this.dns_resolve_type, + dns_resolve_server: this.dns_resolve_server, + dns_last_result: this.dns_last_result, + docker_container: this.docker_container, + docker_host: this.docker_host, + proxyId: this.proxy_id, + notificationIDList: preloadData.notifications.get(this.id) || {}, + tags: preloadData.tags.get(this.id) || [], + maintenance: preloadData.maintenanceStatus.get(this.id), + mqttTopic: this.mqttTopic, + mqttSuccessMessage: this.mqttSuccessMessage, + mqttCheckType: this.mqttCheckType, + databaseQuery: this.databaseQuery, + authMethod: this.authMethod, + grpcUrl: this.grpcUrl, + grpcProtobuf: this.grpcProtobuf, + grpcMethod: this.grpcMethod, + grpcServiceName: this.grpcServiceName, + grpcEnableTls: this.getGrpcEnableTls(), + radiusCalledStationId: this.radiusCalledStationId, + radiusCallingStationId: this.radiusCallingStationId, + game: this.game, + gamedigGivenPortOnly: this.getGameDigGivenPortOnly(), + httpBodyEncoding: this.httpBodyEncoding, + jsonPath: this.jsonPath, + expectedValue: this.expectedValue, + kafkaProducerTopic: this.kafkaProducerTopic, + kafkaProducerBrokers: JSON.parse(this.kafkaProducerBrokers), + kafkaProducerSsl: this.getKafkaProducerSsl(), + kafkaProducerAllowAutoTopicCreation: this.getKafkaProducerAllowAutoTopicCreation(), + kafkaProducerMessage: this.kafkaProducerMessage, + screenshot, + cacheBust: this.getCacheBust(), + remote_browser: this.remote_browser, + snmpOid: this.snmpOid, + jsonPathOperator: this.jsonPathOperator, + snmpVersion: this.snmpVersion, + rabbitmqNodes: JSON.parse(this.rabbitmqNodes), + conditions: JSON.parse(this.conditions), + }; + + if (includeSensitiveData) { + data = { + ...data, + headers: this.headers, + body: this.body, + grpcBody: this.grpcBody, + grpcMetadata: this.grpcMetadata, + basic_auth_user: this.basic_auth_user, + basic_auth_pass: this.basic_auth_pass, + oauth_client_id: this.oauth_client_id, + oauth_client_secret: this.oauth_client_secret, + oauth_token_url: this.oauth_token_url, + oauth_scopes: this.oauth_scopes, + oauth_auth_method: this.oauth_auth_method, + pushToken: this.pushToken, + databaseConnectionString: this.databaseConnectionString, + radiusUsername: this.radiusUsername, + radiusPassword: this.radiusPassword, + radiusSecret: this.radiusSecret, + mqttUsername: this.mqttUsername, + mqttPassword: this.mqttPassword, + authWorkstation: this.authWorkstation, + authDomain: this.authDomain, + tlsCa: this.tlsCa, + tlsCert: this.tlsCert, + tlsKey: this.tlsKey, + kafkaProducerSaslOptions: JSON.parse(this.kafkaProducerSaslOptions), + rabbitmqUsername: this.rabbitmqUsername, + rabbitmqPassword: this.rabbitmqPassword, + }; + } + + data.includeSensitiveData = includeSensitiveData; + return data; + } + + /** + * Get all tags applied to this monitor + * @returns {Promise<LooseObject<any>[]>} List of tags on the + * monitor + */ + async getTags() { + return await R.getAll("SELECT mt.*, tag.name, tag.color FROM monitor_tag mt JOIN tag ON mt.tag_id = tag.id WHERE mt.monitor_id = ? ORDER BY tag.name", [ this.id ]); + } + + /** + * Gets certificate expiry for this monitor + * @param {number} monitorID ID of monitor to send + * @returns {Promise<LooseObject<any>>} Certificate expiry info for + * monitor + */ + async getCertExpiry(monitorID) { + let tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [ + monitorID, + ]); + let tlsInfo; + if (tlsInfoBean) { + tlsInfo = JSON.parse(tlsInfoBean?.info_json); + if (tlsInfo?.valid && tlsInfo?.certInfo?.daysRemaining) { + return { + certExpiryDaysRemaining: tlsInfo.certInfo.daysRemaining, + validCert: true + }; + } + } + return { + certExpiryDaysRemaining: "", + validCert: false + }; + } + + /** + * Encode user and password to Base64 encoding + * for HTTP "basic" auth, as per RFC-7617 + * @param {string|null} user - The username (nullable if not changed by a user) + * @param {string|null} pass - The password (nullable if not changed by a user) + * @returns {string} Encoded Base64 string + */ + encodeBase64(user, pass) { + return Buffer.from(`${user || ""}:${pass || ""}`).toString("base64"); + } + + /** + * Is the TLS expiry notification enabled? + * @returns {boolean} Enabled? + */ + isEnabledExpiryNotification() { + return Boolean(this.expiryNotification); + } + + /** + * Parse to boolean + * @returns {boolean} Should TLS errors be ignored? + */ + getIgnoreTls() { + return Boolean(this.ignoreTls); + } + + /** + * Parse to boolean + * @returns {boolean} Is the monitor in upside down mode? + */ + isUpsideDown() { + return Boolean(this.upsideDown); + } + + /** + * Parse to boolean + * @returns {boolean} Invert keyword match? + */ + isInvertKeyword() { + return Boolean(this.invertKeyword); + } + + /** + * Parse to boolean + * @returns {boolean} Enable TLS for gRPC? + */ + getGrpcEnableTls() { + return Boolean(this.grpcEnableTls); + } + + /** + * Parse to boolean + * @returns {boolean} if cachebusting is enabled + */ + getCacheBust() { + return Boolean(this.cacheBust); + } + + /** + * Get accepted status codes + * @returns {object} Accepted status codes + */ + getAcceptedStatuscodes() { + return JSON.parse(this.accepted_statuscodes_json); + } + + /** + * Get if game dig should only use the port which was provided + * @returns {boolean} gamedig should only use the provided port + */ + getGameDigGivenPortOnly() { + return Boolean(this.gamedigGivenPortOnly); + } + + /** + * Parse to boolean + * @returns {boolean} Kafka Producer Ssl enabled? + */ + getKafkaProducerSsl() { + return Boolean(this.kafkaProducerSsl); + } + + /** + * Parse to boolean + * @returns {boolean} Kafka Producer Allow Auto Topic Creation Enabled? + */ + getKafkaProducerAllowAutoTopicCreation() { + return Boolean(this.kafkaProducerAllowAutoTopicCreation); + } + + /** + * Start monitor + * @param {Server} io Socket server instance + * @returns {Promise<void>} + */ + async start(io) { + let previousBeat = null; + let retries = 0; + + this.prometheus = new Prometheus(this); + + const beat = async () => { + + let beatInterval = this.interval; + + if (! beatInterval) { + beatInterval = 1; + } + + if (demoMode) { + if (beatInterval < 20) { + console.log("beat interval too low, reset to 20s"); + beatInterval = 20; + } + } + + // Expose here for prometheus update + // undefined if not https + let tlsInfo = undefined; + + if (!previousBeat || this.type === "push") { + previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [ + this.id, + ]); + if (previousBeat) { + retries = previousBeat.retries; + } + } + + const isFirstBeat = !previousBeat; + + let bean = R.dispense("heartbeat"); + bean.monitor_id = this.id; + bean.time = R.isoDateTimeMillis(dayjs.utc()); + bean.status = DOWN; + bean.downCount = previousBeat?.downCount || 0; + + if (this.isUpsideDown()) { + bean.status = flipStatus(bean.status); + } + + // Runtime patch timeout if it is 0 + // See https://github.com/louislam/uptime-kuma/pull/3961#issuecomment-1804149144 + if (!this.timeout || this.timeout <= 0) { + this.timeout = this.interval * 1000 * 0.8; + } + + try { + if (await Monitor.isUnderMaintenance(this.id)) { + bean.msg = "Monitor under maintenance"; + bean.status = MAINTENANCE; + } else if (this.type === "group") { + const children = await Monitor.getChildren(this.id); + + if (children.length > 0) { + bean.status = UP; + bean.msg = "All children up and running"; + for (const child of children) { + if (!child.active) { + // Ignore inactive childs + continue; + } + const lastBeat = await Monitor.getPreviousHeartbeat(child.id); + + // Only change state if the monitor is in worse conditions then the ones before + // lastBeat.status could be null + if (!lastBeat) { + bean.status = PENDING; + } else if (bean.status === UP && (lastBeat.status === PENDING || lastBeat.status === DOWN)) { + bean.status = lastBeat.status; + } else if (bean.status === PENDING && lastBeat.status === DOWN) { + bean.status = lastBeat.status; + } + } + + if (bean.status !== UP) { + bean.msg = "Child inaccessible"; + } + } else { + // Set status pending if group is empty + bean.status = PENDING; + bean.msg = "Group empty"; + } + + } else if (this.type === "http" || this.type === "keyword" || this.type === "json-query") { + // Do not do any queries/high loading things before the "bean.ping" + let startTime = dayjs().valueOf(); + + // HTTP basic auth + let basicAuthHeader = {}; + if (this.auth_method === "basic") { + basicAuthHeader = { + "Authorization": "Basic " + this.encodeBase64(this.basic_auth_user, this.basic_auth_pass), + }; + } + + // OIDC: Basic client credential flow. + // Additional grants might be implemented in the future + let oauth2AuthHeader = {}; + if (this.auth_method === "oauth2-cc") { + try { + if (this.oauthAccessToken === undefined || new Date(this.oauthAccessToken.expires_at * 1000) <= new Date()) { + this.oauthAccessToken = await this.makeOidcTokenClientCredentialsRequest(); + } + oauth2AuthHeader = { + "Authorization": this.oauthAccessToken.token_type + " " + this.oauthAccessToken.access_token, + }; + } catch (e) { + throw new Error("The oauth config is invalid. " + e.message); + } + } + + const httpsAgentOptions = { + maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) + rejectUnauthorized: !this.getIgnoreTls(), + secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, + }; + + log.debug("monitor", `[${this.name}] Prepare Options for axios`); + + let contentType = null; + let bodyValue = null; + + if (this.body && (typeof this.body === "string" && this.body.trim().length > 0)) { + if (!this.httpBodyEncoding || this.httpBodyEncoding === "json") { + try { + bodyValue = JSON.parse(this.body); + contentType = "application/json"; + } catch (e) { + throw new Error("Your JSON body is invalid. " + e.message); + } + } else if (this.httpBodyEncoding === "form") { + bodyValue = this.body; + contentType = "application/x-www-form-urlencoded"; + } else if (this.httpBodyEncoding === "xml") { + bodyValue = this.body; + contentType = "text/xml; charset=utf-8"; + } + } + + // Axios Options + const options = { + url: this.url, + method: (this.method || "get").toLowerCase(), + timeout: this.timeout * 1000, + headers: { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", + ...(contentType ? { "Content-Type": contentType } : {}), + ...(basicAuthHeader), + ...(oauth2AuthHeader), + ...(this.headers ? JSON.parse(this.headers) : {}) + }, + maxRedirects: this.maxredirects, + validateStatus: (status) => { + return checkStatusCode(status, this.getAcceptedStatuscodes()); + }, + signal: axiosAbortSignal((this.timeout + 10) * 1000), + }; + + if (bodyValue) { + options.data = bodyValue; + } + + if (this.cacheBust) { + const randomFloatString = Math.random().toString(36); + const cacheBust = randomFloatString.substring(2); + options.params = { + uptime_kuma_cachebuster: cacheBust, + }; + } + + if (this.proxy_id) { + const proxy = await R.load("proxy", this.proxy_id); + + if (proxy && proxy.active) { + const { httpAgent, httpsAgent } = Proxy.createAgents(proxy, { + httpsAgentOptions: httpsAgentOptions, + }); + + options.proxy = false; + options.httpAgent = httpAgent; + options.httpsAgent = httpsAgent; + } + } + + if (!options.httpsAgent) { + let jar = new CookieJar(); + let httpsCookieAgentOptions = { + ...httpsAgentOptions, + cookies: { jar } + }; + options.httpsAgent = new HttpsCookieAgent(httpsCookieAgentOptions); + } + + if (this.auth_method === "mtls") { + if (this.tlsCert !== null && this.tlsCert !== "") { + options.httpsAgent.options.cert = Buffer.from(this.tlsCert); + } + if (this.tlsCa !== null && this.tlsCa !== "") { + options.httpsAgent.options.ca = Buffer.from(this.tlsCa); + } + if (this.tlsKey !== null && this.tlsKey !== "") { + options.httpsAgent.options.key = Buffer.from(this.tlsKey); + } + } + + let tlsInfo = {}; + // Store tlsInfo when secureConnect event is emitted + // The keylog event listener is a workaround to access the tlsSocket + options.httpsAgent.once("keylog", async (line, tlsSocket) => { + tlsSocket.once("secureConnect", async () => { + tlsInfo = checkCertificate(tlsSocket); + tlsInfo.valid = tlsSocket.authorized || false; + + await this.handleTlsInfo(tlsInfo); + }); + }); + + log.debug("monitor", `[${this.name}] Axios Options: ${JSON.stringify(options)}`); + log.debug("monitor", `[${this.name}] Axios Request`); + + // Make Request + let res = await this.makeAxiosRequest(options); + + bean.msg = `${res.status} - ${res.statusText}`; + bean.ping = dayjs().valueOf() - startTime; + + // fallback for if kelog event is not emitted, but we may still have tlsInfo, + // e.g. if the connection is made through a proxy + if (this.getUrl()?.protocol === "https:" && tlsInfo.valid === undefined) { + const tlsSocket = res.request.res.socket; + + if (tlsSocket) { + tlsInfo = checkCertificate(tlsSocket); + tlsInfo.valid = tlsSocket.authorized || false; + + await this.handleTlsInfo(tlsInfo); + } + } + + if (process.env.UPTIME_KUMA_LOG_RESPONSE_BODY_MONITOR_ID === this.id) { + log.info("monitor", res.data); + } + + if (this.type === "http") { + bean.status = UP; + } else if (this.type === "keyword") { + + let data = res.data; + + // Convert to string for object/array + if (typeof data !== "string") { + data = JSON.stringify(data); + } + + let keywordFound = data.includes(this.keyword); + if (keywordFound === !this.isInvertKeyword()) { + bean.msg += ", keyword " + (keywordFound ? "is" : "not") + " found"; + bean.status = UP; + } else { + data = data.replace(/<[^>]*>?|[\n\r]|\s+/gm, " ").trim(); + if (data.length > 50) { + data = data.substring(0, 47) + "..."; + } + throw new Error(bean.msg + ", but keyword is " + + (keywordFound ? "present" : "not") + " in [" + data + "]"); + } + + } else if (this.type === "json-query") { + let data = res.data; + + const { status, response } = await evaluateJsonQuery(data, this.jsonPath, this.jsonPathOperator, this.expectedValue); + + if (status) { + bean.status = UP; + bean.msg = `JSON query passes (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`; + } else { + throw new Error(`JSON query does not pass (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`); + } + + } + + } else if (this.type === "port") { + bean.ping = await tcping(this.hostname, this.port); + bean.msg = ""; + bean.status = UP; + + } else if (this.type === "ping") { + bean.ping = await ping(this.hostname, this.packetSize); + bean.msg = ""; + bean.status = UP; + } else if (this.type === "push") { // Type: Push + log.debug("monitor", `[${this.name}] Checking monitor at ${dayjs().format("YYYY-MM-DD HH:mm:ss.SSS")}`); + const bufferTime = 1000; // 1s buffer to accommodate clock differences + + if (previousBeat) { + const msSinceLastBeat = dayjs.utc().valueOf() - dayjs.utc(previousBeat.time).valueOf(); + + log.debug("monitor", `[${this.name}] msSinceLastBeat = ${msSinceLastBeat}`); + + // If the previous beat was down or pending we use the regular + // beatInterval/retryInterval in the setTimeout further below + if (previousBeat.status !== (this.isUpsideDown() ? DOWN : UP) || msSinceLastBeat > beatInterval * 1000 + bufferTime) { + bean.duration = Math.round(msSinceLastBeat / 1000); + throw new Error("No heartbeat in the time window"); + } else { + let timeout = beatInterval * 1000 - msSinceLastBeat; + if (timeout < 0) { + timeout = bufferTime; + } else { + timeout += bufferTime; + } + // No need to insert successful heartbeat for push type, so end here + retries = 0; + log.debug("monitor", `[${this.name}] timeout = ${timeout}`); + this.heartbeatInterval = setTimeout(safeBeat, timeout); + return; + } + } else { + bean.duration = beatInterval; + throw new Error("No heartbeat in the time window"); + } + + } else if (this.type === "steam") { + const steamApiUrl = "https://api.steampowered.com/IGameServersService/GetServerList/v1/"; + const steamAPIKey = await setting("steamAPIKey"); + const filter = `addr\\${this.hostname}:${this.port}`; + + if (!steamAPIKey) { + throw new Error("Steam API Key not found"); + } + + let res = await axios.get(steamApiUrl, { + timeout: this.timeout * 1000, + headers: { + "Accept": "*/*", + }, + httpsAgent: new https.Agent({ + maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) + rejectUnauthorized: !this.getIgnoreTls(), + secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, + }), + httpAgent: new http.Agent({ + maxCachedSessions: 0, + }), + maxRedirects: this.maxredirects, + validateStatus: (status) => { + return checkStatusCode(status, this.getAcceptedStatuscodes()); + }, + params: { + filter: filter, + key: steamAPIKey, + } + }); + + if (res.data.response && res.data.response.servers && res.data.response.servers.length > 0) { + bean.status = UP; + bean.msg = res.data.response.servers[0].name; + + try { + bean.ping = await ping(this.hostname, this.packetSize); + } catch (_) { } + } else { + throw new Error("Server not found on Steam"); + } + } else if (this.type === "gamedig") { + try { + const state = await Gamedig.query({ + type: this.game, + host: this.hostname, + port: this.port, + givenPortOnly: this.getGameDigGivenPortOnly(), + }); + + bean.msg = state.name; + bean.status = UP; + bean.ping = state.ping; + } catch (e) { + throw new Error(e.message); + } + } else if (this.type === "docker") { + log.debug("monitor", `[${this.name}] Prepare Options for Axios`); + + const options = { + url: `/containers/${this.docker_container}/json`, + timeout: this.interval * 1000 * 0.8, + headers: { + "Accept": "*/*", + }, + httpsAgent: new https.Agent({ + maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) + rejectUnauthorized: !this.getIgnoreTls(), + secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, + }), + httpAgent: new http.Agent({ + maxCachedSessions: 0, + }), + }; + + const dockerHost = await R.load("docker_host", this.docker_host); + + if (!dockerHost) { + throw new Error("Failed to load docker host config"); + } + + if (dockerHost._dockerType === "socket") { + options.socketPath = dockerHost._dockerDaemon; + } else if (dockerHost._dockerType === "tcp") { + options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon); + options.httpsAgent = new https.Agent( + DockerHost.getHttpsAgentOptions(dockerHost._dockerType, options.baseURL) + ); + } + + log.debug("monitor", `[${this.name}] Axios Request`); + let res = await axios.request(options); + + if (res.data.State.Running) { + if (res.data.State.Health && res.data.State.Health.Status !== "healthy") { + bean.status = PENDING; + bean.msg = res.data.State.Health.Status; + } else { + bean.status = UP; + bean.msg = res.data.State.Health ? res.data.State.Health.Status : res.data.State.Status; + } + } else { + throw Error("Container State is " + res.data.State.Status); + } + } else if (this.type === "sqlserver") { + let startTime = dayjs().valueOf(); + + await mssqlQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1"); + + bean.msg = ""; + bean.status = UP; + bean.ping = dayjs().valueOf() - startTime; + } else if (this.type === "grpc-keyword") { + let startTime = dayjs().valueOf(); + const options = { + grpcUrl: this.grpcUrl, + grpcProtobufData: this.grpcProtobuf, + grpcServiceName: this.grpcServiceName, + grpcEnableTls: this.grpcEnableTls, + grpcMethod: this.grpcMethod, + grpcBody: this.grpcBody, + }; + const response = await grpcQuery(options); + bean.ping = dayjs().valueOf() - startTime; + log.debug("monitor:", `gRPC response: ${JSON.stringify(response)}`); + let responseData = response.data; + if (responseData.length > 50) { + responseData = responseData.toString().substring(0, 47) + "..."; + } + if (response.code !== 1) { + bean.status = DOWN; + bean.msg = `Error in send gRPC ${response.code} ${response.errorMessage}`; + } else { + let keywordFound = response.data.toString().includes(this.keyword); + if (keywordFound === !this.isInvertKeyword()) { + bean.status = UP; + bean.msg = `${responseData}, keyword [${this.keyword}] ${keywordFound ? "is" : "not"} found`; + } else { + log.debug("monitor:", `GRPC response [${response.data}] + ", but keyword [${this.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${response.data} + "]"`); + bean.status = DOWN; + bean.msg = `, but keyword [${this.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${responseData} + "]`; + } + } + } else if (this.type === "postgres") { + let startTime = dayjs().valueOf(); + + await postgresQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1"); + + bean.msg = ""; + bean.status = UP; + bean.ping = dayjs().valueOf() - startTime; + } else if (this.type === "mysql") { + let startTime = dayjs().valueOf(); + + // Use `radius_password` as `password` field, since there are too many unnecessary fields + // TODO: rename `radius_password` to `password` later for general use + let mysqlPassword = this.radiusPassword; + + bean.msg = await mysqlQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1", mysqlPassword); + bean.status = UP; + bean.ping = dayjs().valueOf() - startTime; + } else if (this.type === "radius") { + let startTime = dayjs().valueOf(); + + // Handle monitors that were created before the + // update and as such don't have a value for + // this.port. + let port; + if (this.port == null) { + port = 1812; + } else { + port = this.port; + } + + const resp = await radius( + this.hostname, + this.radiusUsername, + this.radiusPassword, + this.radiusCalledStationId, + this.radiusCallingStationId, + this.radiusSecret, + port, + this.interval * 1000 * 0.4, + ); + + bean.msg = resp.code; + bean.status = UP; + bean.ping = dayjs().valueOf() - startTime; + } else if (this.type === "redis") { + let startTime = dayjs().valueOf(); + + bean.msg = await redisPingAsync(this.databaseConnectionString, !this.ignoreTls); + bean.status = UP; + bean.ping = dayjs().valueOf() - startTime; + + } else if (this.type in UptimeKumaServer.monitorTypeList) { + let startTime = dayjs().valueOf(); + const monitorType = UptimeKumaServer.monitorTypeList[this.type]; + await monitorType.check(this, bean, UptimeKumaServer.getInstance()); + if (!bean.ping) { + bean.ping = dayjs().valueOf() - startTime; + } + + } else if (this.type === "kafka-producer") { + let startTime = dayjs().valueOf(); + + bean.msg = await kafkaProducerAsync( + JSON.parse(this.kafkaProducerBrokers), + this.kafkaProducerTopic, + this.kafkaProducerMessage, + { + allowAutoTopicCreation: this.kafkaProducerAllowAutoTopicCreation, + ssl: this.kafkaProducerSsl, + clientId: `Uptime-Kuma/${version}`, + interval: this.interval, + }, + JSON.parse(this.kafkaProducerSaslOptions), + ); + bean.status = UP; + bean.ping = dayjs().valueOf() - startTime; + + } else { + throw new Error("Unknown Monitor Type"); + } + + if (this.isUpsideDown()) { + bean.status = flipStatus(bean.status); + + if (bean.status === DOWN) { + throw new Error("Flip UP to DOWN"); + } + } + + retries = 0; + + } catch (error) { + + if (error?.name === "CanceledError") { + bean.msg = `timeout by AbortSignal (${this.timeout}s)`; + } else { + bean.msg = error.message; + } + + // If UP come in here, it must be upside down mode + // Just reset the retries + if (this.isUpsideDown() && bean.status === UP) { + retries = 0; + + } else if ((this.maxretries > 0) && (retries < this.maxretries)) { + retries++; + bean.status = PENDING; + } else { + // Continue counting retries during DOWN + retries++; + } + } + + bean.retries = retries; + + log.debug("monitor", `[${this.name}] Check isImportant`); + let isImportant = Monitor.isImportantBeat(isFirstBeat, previousBeat?.status, bean.status); + + // Mark as important if status changed, ignore pending pings, + // Don't notify if disrupted changes to up + if (isImportant) { + bean.important = true; + + if (Monitor.isImportantForNotification(isFirstBeat, previousBeat?.status, bean.status)) { + log.debug("monitor", `[${this.name}] sendNotification`); + await Monitor.sendNotification(isFirstBeat, this, bean); + } else { + log.debug("monitor", `[${this.name}] will not sendNotification because it is (or was) under maintenance`); + } + + // Reset down count + bean.downCount = 0; + + // Clear Status Page Cache + log.debug("monitor", `[${this.name}] apicache clear`); + apicache.clear(); + + await UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); + + } else { + bean.important = false; + + if (bean.status === DOWN && this.resendInterval > 0) { + ++bean.downCount; + if (bean.downCount >= this.resendInterval) { + // Send notification again, because we are still DOWN + log.debug("monitor", `[${this.name}] sendNotification again: Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`); + await Monitor.sendNotification(isFirstBeat, this, bean); + + // Reset down count + bean.downCount = 0; + } + } + } + + if (bean.status === UP) { + log.debug("monitor", `Monitor #${this.id} '${this.name}': Successful Response: ${bean.ping} ms | Interval: ${beatInterval} seconds | Type: ${this.type}`); + } else if (bean.status === PENDING) { + if (this.retryInterval > 0) { + beatInterval = this.retryInterval; + } + log.warn("monitor", `Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Max retries: ${this.maxretries} | Retry: ${retries} | Retry Interval: ${beatInterval} seconds | Type: ${this.type}`); + } else if (bean.status === MAINTENANCE) { + log.warn("monitor", `Monitor #${this.id} '${this.name}': Under Maintenance | Type: ${this.type}`); + } else { + log.warn("monitor", `Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type} | Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`); + } + + // Calculate uptime + let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(this.id); + let endTimeDayjs = await uptimeCalculator.update(bean.status, parseFloat(bean.ping)); + bean.end_time = R.isoDateTimeMillis(endTimeDayjs); + + // Send to frontend + log.debug("monitor", `[${this.name}] Send to socket`); + io.to(this.user_id).emit("heartbeat", bean.toJSON()); + Monitor.sendStats(io, this.id, this.user_id); + + // Store to database + log.debug("monitor", `[${this.name}] Store`); + await R.store(bean); + + log.debug("monitor", `[${this.name}] prometheus.update`); + this.prometheus?.update(bean, tlsInfo); + + previousBeat = bean; + + if (! this.isStop) { + log.debug("monitor", `[${this.name}] SetTimeout for next check.`); + + let intervalRemainingMs = Math.max( + 1, + beatInterval * 1000 - dayjs().diff(dayjs.utc(bean.time)) + ); + + log.debug("monitor", `[${this.name}] Next heartbeat in: ${intervalRemainingMs}ms`); + + this.heartbeatInterval = setTimeout(safeBeat, intervalRemainingMs); + } else { + log.info("monitor", `[${this.name}] isStop = true, no next check.`); + } + + }; + + /** + * Get a heartbeat and handle errors7 + * @returns {void} + */ + const safeBeat = async () => { + try { + await beat(); + } catch (e) { + console.trace(e); + UptimeKumaServer.errorLog(e, false); + log.error("monitor", "Please report to https://github.com/louislam/uptime-kuma/issues"); + + if (! this.isStop) { + log.info("monitor", "Try to restart the monitor"); + this.heartbeatInterval = setTimeout(safeBeat, this.interval * 1000); + } + } + }; + + // Delay Push Type + if (this.type === "push") { + setTimeout(() => { + safeBeat(); + }, this.interval * 1000); + } else { + safeBeat(); + } + } + + /** + * Make a request using axios + * @param {object} options Options for Axios + * @param {boolean} finalCall Should this be the final call i.e + * don't retry on failure + * @returns {object} Axios response + */ + async makeAxiosRequest(options, finalCall = false) { + try { + let res; + if (this.auth_method === "ntlm") { + options.httpsAgent.keepAlive = true; + + res = await httpNtlm(options, { + username: this.basic_auth_user, + password: this.basic_auth_pass, + domain: this.authDomain, + workstation: this.authWorkstation ? this.authWorkstation : undefined + }); + } else { + res = await axios.request(options); + } + + return res; + } catch (error) { + + /** + * Make a single attempt to obtain an new access token in the event that + * the recent api request failed for authentication purposes + */ + if (this.auth_method === "oauth2-cc" && error.response.status === 401 && !finalCall) { + this.oauthAccessToken = await this.makeOidcTokenClientCredentialsRequest(); + let oauth2AuthHeader = { + "Authorization": this.oauthAccessToken.token_type + " " + this.oauthAccessToken.access_token, + }; + options.headers = { ...(options.headers), + ...(oauth2AuthHeader) + }; + + return this.makeAxiosRequest(options, true); + } + + // Fix #2253 + // Read more: https://stackoverflow.com/questions/1759956/curl-error-18-transfer-closed-with-outstanding-read-data-remaining + if (!finalCall && typeof error.message === "string" && error.message.includes("maxContentLength size of -1 exceeded")) { + log.debug("monitor", "makeAxiosRequest with gzip"); + options.headers["Accept-Encoding"] = "gzip, deflate"; + return this.makeAxiosRequest(options, true); + } else { + if (typeof error.message === "string" && error.message.includes("maxContentLength size of -1 exceeded")) { + error.message = "response timeout: incomplete response within a interval"; + } + throw error; + } + } + } + + /** + * Stop monitor + * @returns {Promise<void>} + */ + async stop() { + clearTimeout(this.heartbeatInterval); + this.isStop = true; + + this.prometheus?.remove(); + } + + /** + * Get prometheus instance + * @returns {Prometheus|undefined} Current prometheus instance + */ + getPrometheus() { + return this.prometheus; + } + + /** + * Helper Method: + * returns URL object for further usage + * returns null if url is invalid + * @returns {(null|URL)} Monitor URL + */ + getUrl() { + try { + return new URL(this.url); + } catch (_) { + return null; + } + } + + /** + * Example: http: or https: + * @returns {(null|string)} URL's protocol + */ + getURLProtocol() { + const url = this.getUrl(); + if (url) { + return this.getUrl().protocol; + } else { + return null; + } + } + + /** + * Store TLS info to database + * @param {object} checkCertificateResult Certificate to update + * @returns {Promise<object>} Updated certificate + */ + async updateTlsInfo(checkCertificateResult) { + let tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [ + this.id, + ]); + + if (tlsInfoBean == null) { + tlsInfoBean = R.dispense("monitor_tls_info"); + tlsInfoBean.monitor_id = this.id; + } else { + + // Clear sent history if the cert changed. + try { + let oldCertInfo = JSON.parse(tlsInfoBean.info_json); + + let isValidObjects = oldCertInfo && oldCertInfo.certInfo && checkCertificateResult && checkCertificateResult.certInfo; + + if (isValidObjects) { + if (oldCertInfo.certInfo.fingerprint256 !== checkCertificateResult.certInfo.fingerprint256) { + log.debug("monitor", "Resetting sent_history"); + await R.exec("DELETE FROM notification_sent_history WHERE type = 'certificate' AND monitor_id = ?", [ + this.id + ]); + } else { + log.debug("monitor", "No need to reset sent_history"); + log.debug("monitor", oldCertInfo.certInfo.fingerprint256); + log.debug("monitor", checkCertificateResult.certInfo.fingerprint256); + } + } else { + log.debug("monitor", "Not valid object"); + } + } catch (e) { } + + } + + tlsInfoBean.info_json = JSON.stringify(checkCertificateResult); + await R.store(tlsInfoBean); + + return checkCertificateResult; + } + + /** + * Checks if the monitor is active based on itself and its parents + * @param {number} monitorID ID of monitor to send + * @param {boolean} active is active + * @returns {Promise<boolean>} Is the monitor active? + */ + static async isActive(monitorID, active) { + const parentActive = await Monitor.isParentActive(monitorID); + + return (active === 1) && parentActive; + } + + /** + * Send statistics to clients + * @param {Server} io Socket server instance + * @param {number} monitorID ID of monitor to send + * @param {number} userID ID of user to send to + * @returns {void} + */ + static async sendStats(io, monitorID, userID) { + const hasClients = getTotalClientInRoom(io, userID) > 0; + let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID); + + if (hasClients) { + // Send 24 hour average ping + let data24h = await uptimeCalculator.get24Hour(); + io.to(userID).emit("avgPing", monitorID, (data24h.avgPing) ? Number(data24h.avgPing.toFixed(2)) : null); + + // Send 24 hour uptime + io.to(userID).emit("uptime", monitorID, 24, data24h.uptime); + + // Send 30 day uptime + let data30d = await uptimeCalculator.get30Day(); + io.to(userID).emit("uptime", monitorID, 720, data30d.uptime); + + // Send 1-year uptime + let data1y = await uptimeCalculator.get1Year(); + io.to(userID).emit("uptime", monitorID, "1y", data1y.uptime); + + // Send Cert Info + await Monitor.sendCertInfo(io, monitorID, userID); + } else { + log.debug("monitor", "No clients in the room, no need to send stats"); + } + } + + /** + * Send certificate information to client + * @param {Server} io Socket server instance + * @param {number} monitorID ID of monitor to send + * @param {number} userID ID of user to send to + * @returns {void} + */ + static async sendCertInfo(io, monitorID, userID) { + let tlsInfo = await R.findOne("monitor_tls_info", "monitor_id = ?", [ + monitorID, + ]); + if (tlsInfo != null) { + io.to(userID).emit("certInfo", monitorID, tlsInfo.info_json); + } + } + + /** + * Has status of monitor changed since last beat? + * @param {boolean} isFirstBeat Is this the first beat of this monitor? + * @param {const} previousBeatStatus Status of the previous beat + * @param {const} currentBeatStatus Status of the current beat + * @returns {boolean} True if is an important beat else false + */ + static isImportantBeat(isFirstBeat, previousBeatStatus, currentBeatStatus) { + // * ? -> ANY STATUS = important [isFirstBeat] + // UP -> PENDING = not important + // * UP -> DOWN = important + // UP -> UP = not important + // PENDING -> PENDING = not important + // * PENDING -> DOWN = important + // PENDING -> UP = not important + // DOWN -> PENDING = this case not exists + // DOWN -> DOWN = not important + // * DOWN -> UP = important + // MAINTENANCE -> MAINTENANCE = not important + // * MAINTENANCE -> UP = important + // * MAINTENANCE -> DOWN = important + // * DOWN -> MAINTENANCE = important + // * UP -> MAINTENANCE = important + return isFirstBeat || + (previousBeatStatus === DOWN && currentBeatStatus === MAINTENANCE) || + (previousBeatStatus === UP && currentBeatStatus === MAINTENANCE) || + (previousBeatStatus === MAINTENANCE && currentBeatStatus === DOWN) || + (previousBeatStatus === MAINTENANCE && currentBeatStatus === UP) || + (previousBeatStatus === UP && currentBeatStatus === DOWN) || + (previousBeatStatus === DOWN && currentBeatStatus === UP) || + (previousBeatStatus === PENDING && currentBeatStatus === DOWN); + } + + /** + * Is this beat important for notifications? + * @param {boolean} isFirstBeat Is this the first beat of this monitor? + * @param {const} previousBeatStatus Status of the previous beat + * @param {const} currentBeatStatus Status of the current beat + * @returns {boolean} True if is an important beat else false + */ + static isImportantForNotification(isFirstBeat, previousBeatStatus, currentBeatStatus) { + // * ? -> ANY STATUS = important [isFirstBeat] + // UP -> PENDING = not important + // * UP -> DOWN = important + // UP -> UP = not important + // PENDING -> PENDING = not important + // * PENDING -> DOWN = important + // PENDING -> UP = not important + // DOWN -> PENDING = this case not exists + // DOWN -> DOWN = not important + // * DOWN -> UP = important + // MAINTENANCE -> MAINTENANCE = not important + // MAINTENANCE -> UP = not important + // * MAINTENANCE -> DOWN = important + // DOWN -> MAINTENANCE = not important + // UP -> MAINTENANCE = not important + return isFirstBeat || + (previousBeatStatus === MAINTENANCE && currentBeatStatus === DOWN) || + (previousBeatStatus === UP && currentBeatStatus === DOWN) || + (previousBeatStatus === DOWN && currentBeatStatus === UP) || + (previousBeatStatus === PENDING && currentBeatStatus === DOWN); + } + + /** + * Send a notification about a monitor + * @param {boolean} isFirstBeat Is this beat the first of this monitor? + * @param {Monitor} monitor The monitor to send a notificaton about + * @param {Bean} bean Status information about monitor + * @returns {void} + */ + static async sendNotification(isFirstBeat, monitor, bean) { + if (!isFirstBeat || bean.status === DOWN) { + const notificationList = await Monitor.getNotificationList(monitor); + + let text; + if (bean.status === UP) { + text = "✅ Up"; + } else { + text = "🔴 Down"; + } + + let msg = `[${monitor.name}] [${text}] ${bean.msg}`; + + for (let notification of notificationList) { + try { + const heartbeatJSON = bean.toJSON(); + const monitorData = [{ id: monitor.id, + active: monitor.active + }]; + const preloadData = await Monitor.preparePreloadData(monitorData); + // Prevent if the msg is undefined, notifications such as Discord cannot send out. + if (!heartbeatJSON["msg"]) { + heartbeatJSON["msg"] = "N/A"; + } + + // Also provide the time in server timezone + heartbeatJSON["timezone"] = await UptimeKumaServer.getInstance().getTimezone(); + heartbeatJSON["timezoneOffset"] = UptimeKumaServer.getInstance().getTimezoneOffset(); + heartbeatJSON["localDateTime"] = dayjs.utc(heartbeatJSON["time"]).tz(heartbeatJSON["timezone"]).format(SQL_DATETIME_FORMAT); + + await Notification.send(JSON.parse(notification.config), msg, monitor.toJSON(preloadData, false), heartbeatJSON); + } catch (e) { + log.error("monitor", "Cannot send notification to " + notification.name); + log.error("monitor", e); + } + } + } + } + + /** + * Get list of notification providers for a given monitor + * @param {Monitor} monitor Monitor to get notification providers for + * @returns {Promise<LooseObject<any>[]>} List of notifications + */ + static async getNotificationList(monitor) { + let notificationList = await R.getAll("SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id ", [ + monitor.id, + ]); + return notificationList; + } + + /** + * checks certificate chain for expiring certificates + * @param {object} tlsInfoObject Information about certificate + * @returns {void} + */ + async checkCertExpiryNotifications(tlsInfoObject) { + if (tlsInfoObject && tlsInfoObject.certInfo && tlsInfoObject.certInfo.daysRemaining) { + const notificationList = await Monitor.getNotificationList(this); + + if (! notificationList.length > 0) { + // fail fast. If no notification is set, all the following checks can be skipped. + log.debug("monitor", "No notification, no need to send cert notification"); + return; + } + + let notifyDays = await setting("tlsExpiryNotifyDays"); + if (notifyDays == null || !Array.isArray(notifyDays)) { + // Reset Default + await setSetting("tlsExpiryNotifyDays", [ 7, 14, 21 ], "general"); + notifyDays = [ 7, 14, 21 ]; + } + + if (Array.isArray(notifyDays)) { + for (const targetDays of notifyDays) { + let certInfo = tlsInfoObject.certInfo; + while (certInfo) { + let subjectCN = certInfo.subject["CN"]; + if (rootCertificates.has(certInfo.fingerprint256)) { + log.debug("monitor", `Known root cert: ${certInfo.certType} certificate "${subjectCN}" (${certInfo.daysRemaining} days valid) on ${targetDays} deadline.`); + break; + } else if (certInfo.daysRemaining > targetDays) { + log.debug("monitor", `No need to send cert notification for ${certInfo.certType} certificate "${subjectCN}" (${certInfo.daysRemaining} days valid) on ${targetDays} deadline.`); + } else { + log.debug("monitor", `call sendCertNotificationByTargetDays for ${targetDays} deadline on certificate ${subjectCN}.`); + await this.sendCertNotificationByTargetDays(subjectCN, certInfo.certType, certInfo.daysRemaining, targetDays, notificationList); + } + certInfo = certInfo.issuerCertificate; + } + } + } + } + } + + /** + * Send a certificate notification when certificate expires in less + * than target days + * @param {string} certCN Common Name attribute from the certificate subject + * @param {string} certType certificate type + * @param {number} daysRemaining Number of days remaining on certificate + * @param {number} targetDays Number of days to alert after + * @param {LooseObject<any>[]} notificationList List of notification providers + * @returns {Promise<void>} + */ + async sendCertNotificationByTargetDays(certCN, certType, daysRemaining, targetDays, notificationList) { + + let row = await R.getRow("SELECT * FROM notification_sent_history WHERE type = ? AND monitor_id = ? AND days <= ?", [ + "certificate", + this.id, + targetDays, + ]); + + // Sent already, no need to send again + if (row) { + log.debug("monitor", "Sent already, no need to send again"); + return; + } + + let sent = false; + log.debug("monitor", "Send certificate notification"); + + for (let notification of notificationList) { + try { + log.debug("monitor", "Sending to " + notification.name); + await Notification.send(JSON.parse(notification.config), `[${this.name}][${this.url}] ${certType} certificate ${certCN} will be expired in ${daysRemaining} days`); + sent = true; + } catch (e) { + log.error("monitor", "Cannot send cert notification to " + notification.name); + log.error("monitor", e); + } + } + + if (sent) { + await R.exec("INSERT INTO notification_sent_history (type, monitor_id, days) VALUES(?, ?, ?)", [ + "certificate", + this.id, + targetDays, + ]); + } + } + + /** + * Get the status of the previous heartbeat + * @param {number} monitorID ID of monitor to check + * @returns {Promise<LooseObject<any>>} Previous heartbeat + */ + static async getPreviousHeartbeat(monitorID) { + return await R.findOne("heartbeat", " id = (select MAX(id) from heartbeat where monitor_id = ?)", [ + monitorID + ]); + } + + /** + * Check if monitor is under maintenance + * @param {number} monitorID ID of monitor to check + * @returns {Promise<boolean>} Is the monitor under maintenance + */ + static async isUnderMaintenance(monitorID) { + const maintenanceIDList = await R.getCol(` + SELECT maintenance_id FROM monitor_maintenance + WHERE monitor_id = ? + `, [ monitorID ]); + + for (const maintenanceID of maintenanceIDList) { + const maintenance = await UptimeKumaServer.getInstance().getMaintenance(maintenanceID); + if (maintenance && await maintenance.isUnderMaintenance()) { + return true; + } + } + + const parent = await Monitor.getParent(monitorID); + if (parent != null) { + return await Monitor.isUnderMaintenance(parent.id); + } + + return false; + } + + /** + * Make sure monitor interval is between bounds + * @returns {void} + * @throws Interval is outside of range + */ + validate() { + if (this.interval > MAX_INTERVAL_SECOND) { + throw new Error(`Interval cannot be more than ${MAX_INTERVAL_SECOND} seconds`); + } + if (this.interval < MIN_INTERVAL_SECOND) { + throw new Error(`Interval cannot be less than ${MIN_INTERVAL_SECOND} seconds`); + } + } + + /** + * Gets monitor notification of multiple monitor + * @param {Array} monitorIDs IDs of monitor to get + * @returns {Promise<LooseObject<any>>} object + */ + static async getMonitorNotification(monitorIDs) { + return await R.getAll(` + SELECT monitor_notification.monitor_id, monitor_notification.notification_id + FROM monitor_notification + WHERE monitor_notification.monitor_id IN (${monitorIDs.map((_) => "?").join(",")}) + `, monitorIDs); + } + + /** + * Gets monitor tags of multiple monitor + * @param {Array} monitorIDs IDs of monitor to get + * @returns {Promise<LooseObject<any>>} object + */ + static async getMonitorTag(monitorIDs) { + return await R.getAll(` + SELECT monitor_tag.monitor_id, monitor_tag.tag_id, tag.name, tag.color + FROM monitor_tag + JOIN tag ON monitor_tag.tag_id = tag.id + WHERE monitor_tag.monitor_id IN (${monitorIDs.map((_) => "?").join(",")}) + `, monitorIDs); + } + + /** + * prepare preloaded data for efficient access + * @param {Array} monitorData IDs & active field of monitor to get + * @returns {Promise<LooseObject<any>>} object + */ + static async preparePreloadData(monitorData) { + + const notificationsMap = new Map(); + const tagsMap = new Map(); + const maintenanceStatusMap = new Map(); + const childrenIDsMap = new Map(); + const activeStatusMap = new Map(); + const forceInactiveMap = new Map(); + const pathsMap = new Map(); + + if (monitorData.length > 0) { + const monitorIDs = monitorData.map(monitor => monitor.id); + const notifications = await Monitor.getMonitorNotification(monitorIDs); + const tags = await Monitor.getMonitorTag(monitorIDs); + const maintenanceStatuses = await Promise.all(monitorData.map(monitor => Monitor.isUnderMaintenance(monitor.id))); + const childrenIDs = await Promise.all(monitorData.map(monitor => Monitor.getAllChildrenIDs(monitor.id))); + const activeStatuses = await Promise.all(monitorData.map(monitor => Monitor.isActive(monitor.id, monitor.active))); + const forceInactiveStatuses = await Promise.all(monitorData.map(monitor => Monitor.isParentActive(monitor.id))); + const paths = await Promise.all(monitorData.map(monitor => Monitor.getAllPath(monitor.id, monitor.name))); + + notifications.forEach(row => { + if (!notificationsMap.has(row.monitor_id)) { + notificationsMap.set(row.monitor_id, {}); + } + notificationsMap.get(row.monitor_id)[row.notification_id] = true; + }); + + tags.forEach(row => { + if (!tagsMap.has(row.monitor_id)) { + tagsMap.set(row.monitor_id, []); + } + tagsMap.get(row.monitor_id).push({ + tag_id: row.tag_id, + name: row.name, + color: row.color + }); + }); + + monitorData.forEach((monitor, index) => { + maintenanceStatusMap.set(monitor.id, maintenanceStatuses[index]); + }); + + monitorData.forEach((monitor, index) => { + childrenIDsMap.set(monitor.id, childrenIDs[index]); + }); + + monitorData.forEach((monitor, index) => { + activeStatusMap.set(monitor.id, activeStatuses[index]); + }); + + monitorData.forEach((monitor, index) => { + forceInactiveMap.set(monitor.id, !forceInactiveStatuses[index]); + }); + + monitorData.forEach((monitor, index) => { + pathsMap.set(monitor.id, paths[index]); + }); + } + + return { + notifications: notificationsMap, + tags: tagsMap, + maintenanceStatus: maintenanceStatusMap, + childrenIDs: childrenIDsMap, + activeStatus: activeStatusMap, + forceInactive: forceInactiveMap, + paths: pathsMap, + }; + } + + /** + * Gets Parent of the monitor + * @param {number} monitorID ID of monitor to get + * @returns {Promise<LooseObject<any>>} Parent + */ + static async getParent(monitorID) { + return await R.getRow(` + SELECT parent.* FROM monitor parent + LEFT JOIN monitor child + ON child.parent = parent.id + WHERE child.id = ? + `, [ + monitorID, + ]); + } + + /** + * Gets all Children of the monitor + * @param {number} monitorID ID of monitor to get + * @returns {Promise<LooseObject<any>>} Children + */ + static async getChildren(monitorID) { + return await R.getAll(` + SELECT * FROM monitor + WHERE parent = ? + `, [ + monitorID, + ]); + } + + /** + * Gets the full path + * @param {number} monitorID ID of the monitor to get + * @param {string} name of the monitor to get + * @returns {Promise<string[]>} Full path (includes groups and the name) of the monitor + */ + static async getAllPath(monitorID, name) { + const path = [ name ]; + + if (this.parent === null) { + return path; + } + + let parent = await Monitor.getParent(monitorID); + while (parent !== null) { + path.unshift(parent.name); + parent = await Monitor.getParent(parent.id); + } + + return path; + } + + /** + * Gets recursive all child ids + * @param {number} monitorID ID of the monitor to get + * @returns {Promise<Array>} IDs of all children + */ + static async getAllChildrenIDs(monitorID) { + const childs = await Monitor.getChildren(monitorID); + + if (childs === null) { + return []; + } + + let childrenIDs = []; + + for (const child of childs) { + childrenIDs.push(child.id); + childrenIDs = childrenIDs.concat(await Monitor.getAllChildrenIDs(child.id)); + } + + return childrenIDs; + } + + /** + * Unlinks all children of the group monitor + * @param {number} groupID ID of group to remove children of + * @returns {Promise<void>} + */ + static async unlinkAllChildren(groupID) { + return await R.exec("UPDATE `monitor` SET parent = ? WHERE parent = ? ", [ + null, groupID + ]); + } + + /** + * Checks recursive if parent (ancestors) are active + * @param {number} monitorID ID of the monitor to get + * @returns {Promise<boolean>} Is the parent monitor active? + */ + static async isParentActive(monitorID) { + const parent = await Monitor.getParent(monitorID); + + if (parent === null) { + return true; + } + + const parentActive = await Monitor.isParentActive(parent.id); + return parent.active && parentActive; + } + + /** + * Obtains a new Oidc Token + * @returns {Promise<object>} OAuthProvider client + */ + async makeOidcTokenClientCredentialsRequest() { + log.debug("monitor", `[${this.name}] The oauth access-token undefined or expired. Requesting a new token`); + const oAuthAccessToken = await getOidcTokenClientCredentials(this.oauth_token_url, this.oauth_client_id, this.oauth_client_secret, this.oauth_scopes, this.oauth_auth_method); + if (this.oauthAccessToken?.expires_at) { + log.debug("monitor", `[${this.name}] Obtained oauth access-token. Expires at ${new Date(this.oauthAccessToken?.expires_at * 1000)}`); + } else { + log.debug("monitor", `[${this.name}] Obtained oauth access-token. Time until expiry was not provided`); + } + + return oAuthAccessToken; + } + + /** + * Store TLS certificate information and check for expiry + * @param {object} tlsInfo Information about the TLS connection + * @returns {Promise<void>} + */ + async handleTlsInfo(tlsInfo) { + await this.updateTlsInfo(tlsInfo); + this.prometheus?.update(null, tlsInfo); + + if (!this.getIgnoreTls() && this.isEnabledExpiryNotification()) { + log.debug("monitor", `[${this.name}] call checkCertExpiryNotifications`); + await this.checkCertExpiryNotifications(tlsInfo); + } + } +} + +module.exports = Monitor; diff --git a/server/model/proxy.js b/server/model/proxy.js new file mode 100644 index 0000000..ec78403 --- /dev/null +++ b/server/model/proxy.js @@ -0,0 +1,25 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +class Proxy extends BeanModel { + /** + * Return an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + toJSON() { + return { + id: this._id, + userId: this._user_id, + protocol: this._protocol, + host: this._host, + port: this._port, + auth: !!this._auth, + username: this._username, + password: this._password, + active: !!this._active, + default: !!this._default, + createdDate: this._created_date, + }; + } +} + +module.exports = Proxy; diff --git a/server/model/remote_browser.js b/server/model/remote_browser.js new file mode 100644 index 0000000..49299ad --- /dev/null +++ b/server/model/remote_browser.js @@ -0,0 +1,17 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +class RemoteBrowser extends BeanModel { + /** + * Returns an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + toJSON() { + return { + id: this.id, + url: this.url, + name: this.name, + }; + } +} + +module.exports = RemoteBrowser; diff --git a/server/model/status_page.js b/server/model/status_page.js new file mode 100644 index 0000000..38f548e --- /dev/null +++ b/server/model/status_page.js @@ -0,0 +1,491 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); +const { R } = require("redbean-node"); +const cheerio = require("cheerio"); +const { UptimeKumaServer } = require("../uptime-kuma-server"); +const jsesc = require("jsesc"); +const googleAnalytics = require("../google-analytics"); +const { marked } = require("marked"); +const { Feed } = require("feed"); +const config = require("../config"); + +const { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE, DOWN } = require("../../src/util"); + +class StatusPage extends BeanModel { + + /** + * Like this: { "test-uptime.kuma.pet": "default" } + * @type {{}} + */ + static domainMappingList = { }; + + /** + * Handle responses to RSS pages + * @param {Response} response Response object + * @param {string} slug Status page slug + * @returns {Promise<void>} + */ + static async handleStatusPageRSSResponse(response, slug) { + let statusPage = await R.findOne("status_page", " slug = ? ", [ + slug + ]); + + if (statusPage) { + response.send(await StatusPage.renderRSS(statusPage, slug)); + } else { + response.status(404).send(UptimeKumaServer.getInstance().indexHTML); + } + } + + /** + * Handle responses to status page + * @param {Response} response Response object + * @param {string} indexHTML HTML to render + * @param {string} slug Status page slug + * @returns {Promise<void>} + */ + static async handleStatusPageResponse(response, indexHTML, slug) { + // Handle url with trailing slash (http://localhost:3001/status/) + // The slug comes from the route "/status/:slug". If the slug is empty, express converts it to "index.html" + if (slug === "index.html") { + slug = "default"; + } + + let statusPage = await R.findOne("status_page", " slug = ? ", [ + slug + ]); + + if (statusPage) { + response.send(await StatusPage.renderHTML(indexHTML, statusPage)); + } else { + response.status(404).send(UptimeKumaServer.getInstance().indexHTML); + } + } + + /** + * SSR for RSS feed + * @param {statusPage} statusPage object + * @param {slug} slug from router + * @returns {Promise<string>} the rendered html + */ + static async renderRSS(statusPage, slug) { + const { heartbeats, statusDescription } = await StatusPage.getRSSPageData(statusPage); + + let proto = config.isSSL ? "https" : "http"; + let host = `${proto}://${config.hostname || "localhost"}:${config.port}/status/${slug}`; + + const feed = new Feed({ + title: "uptime kuma rss feed", + description: `current status: ${statusDescription}`, + link: host, + language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes + updated: new Date(), // optional, default = today + }); + + heartbeats.forEach(heartbeat => { + feed.addItem({ + title: `${heartbeat.name} is down`, + description: `${heartbeat.name} has been down since ${heartbeat.time}`, + id: heartbeat.monitorID, + date: new Date(heartbeat.time), + }); + }); + + return feed.rss2(); + } + + /** + * SSR for status pages + * @param {string} indexHTML HTML page to render + * @param {StatusPage} statusPage Status page populate HTML with + * @returns {Promise<string>} the rendered html + */ + static async renderHTML(indexHTML, statusPage) { + const $ = cheerio.load(indexHTML); + + const description155 = marked(statusPage.description ?? "") + .replace(/<[^>]+>/gm, "") + .trim() + .substring(0, 155); + + $("title").text(statusPage.title); + $("meta[name=description]").attr("content", description155); + + if (statusPage.icon) { + $("link[rel=icon]") + .attr("href", statusPage.icon) + .removeAttr("type"); + + $("link[rel=apple-touch-icon]").remove(); + } + + const head = $("head"); + + if (statusPage.googleAnalyticsTagId) { + let escapedGoogleAnalyticsScript = googleAnalytics.getGoogleAnalyticsScript(statusPage.googleAnalyticsTagId); + head.append($(escapedGoogleAnalyticsScript)); + } + + // OG Meta Tags + let ogTitle = $("<meta property=\"og:title\" content=\"\" />").attr("content", statusPage.title); + head.append(ogTitle); + + let ogDescription = $("<meta property=\"og:description\" content=\"\" />").attr("content", description155); + head.append(ogDescription); + + // Preload data + // Add jsesc, fix https://github.com/louislam/uptime-kuma/issues/2186 + const escapedJSONObject = jsesc(await StatusPage.getStatusPageData(statusPage), { + "isScriptContext": true + }); + + const script = $(` + <script id="preload-data" data-json="{}"> + window.preloadData = ${escapedJSONObject}; + </script> + `); + + head.append(script); + + // manifest.json + $("link[rel=manifest]").attr("href", `/api/status-page/${statusPage.slug}/manifest.json`); + + return $.root().html(); + } + + /** + * @param {heartbeats} heartbeats from getRSSPageData + * @returns {number} status_page constant from util.ts + */ + static overallStatus(heartbeats) { + if (heartbeats.length === 0) { + return -1; + } + + let status = STATUS_PAGE_ALL_UP; + let hasUp = false; + + for (let beat of heartbeats) { + if (beat.status === MAINTENANCE) { + return STATUS_PAGE_MAINTENANCE; + } else if (beat.status === UP) { + hasUp = true; + } else { + status = STATUS_PAGE_PARTIAL_DOWN; + } + } + + if (! hasUp) { + status = STATUS_PAGE_ALL_DOWN; + } + + return status; + } + + /** + * @param {number} status from overallStatus + * @returns {string} description + */ + static getStatusDescription(status) { + if (status === -1) { + return "No Services"; + } + + if (status === STATUS_PAGE_ALL_UP) { + return "All Systems Operational"; + } + + if (status === STATUS_PAGE_PARTIAL_DOWN) { + return "Partially Degraded Service"; + } + + if (status === STATUS_PAGE_ALL_DOWN) { + return "Degraded Service"; + } + + // TODO: show the real maintenance information: title, description, time + if (status === MAINTENANCE) { + return "Under maintenance"; + } + + return "?"; + } + + /** + * Get all data required for RSS + * @param {StatusPage} statusPage Status page to get data for + * @returns {object} Status page data + */ + static async getRSSPageData(statusPage) { + // get all heartbeats that correspond to this statusPage + const config = await statusPage.toPublicJSON(); + + // Public Group List + const showTags = !!statusPage.show_tags; + + const list = await R.find("group", " public = 1 AND status_page_id = ? ORDER BY weight ", [ + statusPage.id + ]); + + let heartbeats = []; + + for (let groupBean of list) { + let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry); + for (const monitor of monitorGroup.monitorList) { + const heartbeat = await R.findOne("heartbeat", "monitor_id = ? ORDER BY time DESC", [ monitor.id ]); + if (heartbeat) { + heartbeats.push({ + ...monitor, + status: heartbeat.status, + time: heartbeat.time + }); + } + } + } + + // calculate RSS feed description + let status = StatusPage.overallStatus(heartbeats); + let statusDescription = StatusPage.getStatusDescription(status); + + // keep only DOWN heartbeats in the RSS feed + heartbeats = heartbeats.filter(heartbeat => heartbeat.status === DOWN); + + return { + heartbeats, + statusDescription + }; + } + + /** + * Get all status page data in one call + * @param {StatusPage} statusPage Status page to get data for + * @returns {object} Status page data + */ + static async getStatusPageData(statusPage) { + const config = await statusPage.toPublicJSON(); + + // Incident + let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [ + statusPage.id, + ]); + + if (incident) { + incident = incident.toPublicJSON(); + } + + let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id); + + // Public Group List + const publicGroupList = []; + const showTags = !!statusPage.show_tags; + + const list = await R.find("group", " public = 1 AND status_page_id = ? ORDER BY weight ", [ + statusPage.id + ]); + + for (let groupBean of list) { + let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry); + publicGroupList.push(monitorGroup); + } + + // Response + return { + config, + incident, + publicGroupList, + maintenanceList, + }; + } + + /** + * Loads domain mapping from DB + * Return object like this: { "test-uptime.kuma.pet": "default" } + * @returns {Promise<void>} + */ + static async loadDomainMappingList() { + StatusPage.domainMappingList = await R.getAssoc(` + SELECT domain, slug + FROM status_page, status_page_cname + WHERE status_page.id = status_page_cname.status_page_id + `); + } + + /** + * Send status page list to client + * @param {Server} io io Socket server instance + * @param {Socket} socket Socket.io instance + * @returns {Promise<Bean[]>} Status page list + */ + static async sendStatusPageList(io, socket) { + let result = {}; + + let list = await R.findAll("status_page", " ORDER BY title "); + + for (let item of list) { + result[item.id] = await item.toJSON(); + } + + io.to(socket.userID).emit("statusPageList", result); + return list; + } + + /** + * Update list of domain names + * @param {string[]} domainNameList List of status page domains + * @returns {Promise<void>} + */ + async updateDomainNameList(domainNameList) { + + if (!Array.isArray(domainNameList)) { + throw new Error("Invalid array"); + } + + let trx = await R.begin(); + + await trx.exec("DELETE FROM status_page_cname WHERE status_page_id = ?", [ + this.id, + ]); + + try { + for (let domain of domainNameList) { + if (typeof domain !== "string") { + throw new Error("Invalid domain"); + } + + if (domain.trim() === "") { + continue; + } + + // If the domain name is used in another status page, delete it + await trx.exec("DELETE FROM status_page_cname WHERE domain = ?", [ + domain, + ]); + + let mapping = trx.dispense("status_page_cname"); + mapping.status_page_id = this.id; + mapping.domain = domain; + await trx.store(mapping); + } + await trx.commit(); + } catch (error) { + await trx.rollback(); + throw error; + } + } + + /** + * Get list of domain names + * @returns {object[]} List of status page domains + */ + getDomainNameList() { + let domainList = []; + for (let domain in StatusPage.domainMappingList) { + let s = StatusPage.domainMappingList[domain]; + + if (this.slug === s) { + domainList.push(domain); + } + } + return domainList; + } + + /** + * Return an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + async toJSON() { + return { + id: this.id, + slug: this.slug, + title: this.title, + description: this.description, + icon: this.getIcon(), + theme: this.theme, + autoRefreshInterval: this.autoRefreshInterval, + published: !!this.published, + showTags: !!this.show_tags, + domainNameList: this.getDomainNameList(), + customCSS: this.custom_css, + footerText: this.footer_text, + showPoweredBy: !!this.show_powered_by, + googleAnalyticsId: this.google_analytics_tag_id, + showCertificateExpiry: !!this.show_certificate_expiry, + }; + } + + /** + * Return an object that ready to parse to JSON for public + * Only show necessary data to public + * @returns {object} Object ready to parse + */ + async toPublicJSON() { + return { + slug: this.slug, + title: this.title, + description: this.description, + icon: this.getIcon(), + autoRefreshInterval: this.autoRefreshInterval, + theme: this.theme, + published: !!this.published, + showTags: !!this.show_tags, + customCSS: this.custom_css, + footerText: this.footer_text, + showPoweredBy: !!this.show_powered_by, + googleAnalyticsId: this.google_analytics_tag_id, + showCertificateExpiry: !!this.show_certificate_expiry, + }; + } + + /** + * Convert slug to status page ID + * @param {string} slug Status page slug + * @returns {Promise<number>} ID of status page + */ + static async slugToID(slug) { + return await R.getCell("SELECT id FROM status_page WHERE slug = ? ", [ + slug + ]); + } + + /** + * Get path to the icon for the page + * @returns {string} Path + */ + getIcon() { + if (!this.icon) { + return "/icon.svg"; + } else { + return this.icon; + } + } + + /** + * Get list of maintenances + * @param {number} statusPageId ID of status page to get maintenance for + * @returns {object} Object representing maintenances sanitized for public + */ + static async getMaintenanceList(statusPageId) { + try { + const publicMaintenanceList = []; + + let maintenanceIDList = await R.getCol(` + SELECT DISTINCT maintenance_id + FROM maintenance_status_page + WHERE status_page_id = ? + `, [ statusPageId ]); + + for (const maintenanceID of maintenanceIDList) { + let maintenance = UptimeKumaServer.getInstance().getMaintenance(maintenanceID); + if (maintenance && await maintenance.isUnderMaintenance()) { + publicMaintenanceList.push(await maintenance.toPublicJSON()); + } + } + + return publicMaintenanceList; + + } catch (error) { + return []; + } + } +} + +module.exports = StatusPage; diff --git a/server/model/tag.js b/server/model/tag.js new file mode 100644 index 0000000..bc8a4db --- /dev/null +++ b/server/model/tag.js @@ -0,0 +1,18 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +class Tag extends BeanModel { + + /** + * Return an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + toJSON() { + return { + id: this._id, + name: this._name, + color: this._color, + }; + } +} + +module.exports = Tag; diff --git a/server/model/user.js b/server/model/user.js new file mode 100644 index 0000000..329402f --- /dev/null +++ b/server/model/user.js @@ -0,0 +1,53 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); +const passwordHash = require("../password-hash"); +const { R } = require("redbean-node"); +const jwt = require("jsonwebtoken"); +const { shake256, SHAKE256_LENGTH } = require("../util-server"); + +class User extends BeanModel { + /** + * Reset user password + * Fix #1510, as in the context reset-password.js, there is no auto model mapping. Call this static function instead. + * @param {number} userID ID of user to update + * @param {string} newPassword Users new password + * @returns {Promise<void>} + */ + static async resetPassword(userID, newPassword) { + await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [ + passwordHash.generate(newPassword), + userID + ]); + } + + /** + * Reset this users password + * @param {string} newPassword Users new password + * @returns {Promise<void>} + */ + async resetPassword(newPassword) { + const hashedPassword = passwordHash.generate(newPassword); + + await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [ + hashedPassword, + this.id + ]); + + this.password = hashedPassword; + } + + /** + * Create a new JWT for a user + * @param {User} user The User to create a JsonWebToken for + * @param {string} jwtSecret The key used to sign the JsonWebToken + * @returns {string} the JsonWebToken as a string + */ + static createJWT(user, jwtSecret) { + return jwt.sign({ + username: user.username, + h: shake256(user.password, SHAKE256_LENGTH), + }, jwtSecret); + } + +} + +module.exports = User; diff --git a/server/modules/apicache/apicache.js b/server/modules/apicache/apicache.js new file mode 100644 index 0000000..41930b2 --- /dev/null +++ b/server/modules/apicache/apicache.js @@ -0,0 +1,917 @@ +let url = require("url"); +let MemoryCache = require("./memory-cache"); + +let t = { + ms: 1, + second: 1000, + minute: 60000, + hour: 3600000, + day: 3600000 * 24, + week: 3600000 * 24 * 7, + month: 3600000 * 24 * 30, +}; + +let instances = []; + +/** + * Does a === b + * @param {any} a + * @returns {function(any): boolean} + */ +let matches = function (a) { + return function (b) { + return a === b; + }; +}; + +/** + * Does a!==b + * @param {any} a + * @returns {function(any): boolean} + */ +let doesntMatch = function (a) { + return function (b) { + return !matches(a)(b); + }; +}; + +/** + * Get log duration + * @param {number} d Time in ms + * @param {string} prefix Prefix for log + * @returns {string} Coloured log string + */ +let logDuration = function (d, prefix) { + let str = d > 1000 ? (d / 1000).toFixed(2) + "sec" : d + "ms"; + return "\x1b[33m- " + (prefix ? prefix + " " : "") + str + "\x1b[0m"; +}; + +/** + * Get safe headers + * @param {Object} res Express response object + * @returns {Object} + */ +function getSafeHeaders(res) { + return res.getHeaders ? res.getHeaders() : res._headers; +} + +/** Constructor for ApiCache instance */ +function ApiCache() { + let memCache = new MemoryCache(); + + let globalOptions = { + debug: false, + defaultDuration: 3600000, + enabled: true, + appendKey: [], + jsonp: false, + redisClient: false, + headerBlacklist: [], + statusCodes: { + include: [], + exclude: [], + }, + events: { + expire: undefined, + }, + headers: { + // 'cache-control': 'no-cache' // example of header overwrite + }, + trackPerformance: false, + respectCacheControl: false, + }; + + let middlewareOptions = []; + let instance = this; + let index = null; + let timers = {}; + let performanceArray = []; // for tracking cache hit rate + + instances.push(this); + this.id = instances.length; + + /** + * Logs a message to the console if the `DEBUG` environment variable is set. + * @param {string} a The first argument to log. + * @param {string} b The second argument to log. + * @param {string} c The third argument to log. + * @param {string} d The fourth argument to log, and so on... (optional) + * + * Generated by Trelent + */ + function debug(a, b, c, d) { + let arr = ["\x1b[36m[apicache]\x1b[0m", a, b, c, d].filter(function (arg) { + return arg !== undefined; + }); + let debugEnv = process.env.DEBUG && process.env.DEBUG.split(",").indexOf("apicache") !== -1; + + return (globalOptions.debug || debugEnv) && console.log.apply(null, arr); + } + + /** + * Returns true if the given request and response should be logged. + * @param {Object} request The HTTP request object. + * @param {Object} response The HTTP response object. + * @param {function(Object, Object):boolean} toggle + * @returns {boolean} + */ + function shouldCacheResponse(request, response, toggle) { + let opt = globalOptions; + let codes = opt.statusCodes; + + if (!response) { + return false; + } + + if (toggle && !toggle(request, response)) { + return false; + } + + if (codes.exclude && codes.exclude.length && codes.exclude.indexOf(response.statusCode) !== -1) { + return false; + } + if (codes.include && codes.include.length && codes.include.indexOf(response.statusCode) === -1) { + return false; + } + + return true; + } + + /** + * Add key to index array + * @param {string} key Key to add + * @param {Object} req Express request object + */ + function addIndexEntries(key, req) { + let groupName = req.apicacheGroup; + + if (groupName) { + debug("group detected \"" + groupName + "\""); + let group = (index.groups[groupName] = index.groups[groupName] || []); + group.unshift(key); + } + + index.all.unshift(key); + } + + /** + * Returns a new object containing only the whitelisted headers. + * @param {Object} headers The original object of header names and + * values. + * @param {string[]} globalOptions.headerWhitelist An array of + * strings representing the whitelisted header names to keep in the + * output object. + * + * Generated by Trelent + */ + function filterBlacklistedHeaders(headers) { + return Object.keys(headers) + .filter(function (key) { + return globalOptions.headerBlacklist.indexOf(key) === -1; + }) + .reduce(function (acc, header) { + acc[header] = headers[header]; + return acc; + }, {}); + } + + /** + * Create a cache object + * @param {Object} headers The response headers to filter. + * @returns {Object} A new object containing only the whitelisted + * response headers. + * + * Generated by Trelent + */ + function createCacheObject(status, headers, data, encoding) { + return { + status: status, + headers: filterBlacklistedHeaders(headers), + data: data, + encoding: encoding, + timestamp: new Date().getTime() / 1000, // seconds since epoch. This is used to properly decrement max-age headers in cached responses. + }; + } + + /** + * Sets a cache value for the given key. + * @param {string} key The cache key to set. + * @param {any} value The cache value to set. + * @param {number} duration How long in milliseconds the cached + * response should be valid for (defaults to 1 hour). + * + * Generated by Trelent + */ + function cacheResponse(key, value, duration) { + let redis = globalOptions.redisClient; + let expireCallback = globalOptions.events.expire; + + if (redis && redis.connected) { + try { + redis.hset(key, "response", JSON.stringify(value)); + redis.hset(key, "duration", duration); + redis.expire(key, duration / 1000, expireCallback || function () {}); + } catch (err) { + debug("[apicache] error in redis.hset()"); + } + } else { + memCache.add(key, value, duration, expireCallback); + } + + // add automatic cache clearing from duration, includes max limit on setTimeout + timers[key] = setTimeout(function () { + instance.clear(key, true); + }, Math.min(duration, 2147483647)); + } + + /** + * Appends content to the response. + * @param {Object} res Express response object + * @param {(string|Buffer)} content The content to append. + * + * Generated by Trelent + */ + function accumulateContent(res, content) { + if (content) { + if (typeof content == "string") { + res._apicache.content = (res._apicache.content || "") + content; + } else if (Buffer.isBuffer(content)) { + let oldContent = res._apicache.content; + + if (typeof oldContent === "string") { + oldContent = !Buffer.from ? new Buffer(oldContent) : Buffer.from(oldContent); + } + + if (!oldContent) { + oldContent = !Buffer.alloc ? new Buffer(0) : Buffer.alloc(0); + } + + res._apicache.content = Buffer.concat( + [oldContent, content], + oldContent.length + content.length + ); + } else { + res._apicache.content = content; + } + } + } + + /** + * Monkeypatches the response object to add cache control headers + * and create a cache object. + * @param {Object} req Express request object + * @param {Object} res Express response object + * @param {function} next Function to call next + * @param {string} key Key to add response as + * @param {number} duration Time to cache response for + * @param {string} strDuration Duration in string form + * @param {function(Object, Object):boolean} toggle + */ + function makeResponseCacheable(req, res, next, key, duration, strDuration, toggle) { + // monkeypatch res.end to create cache object + res._apicache = { + write: res.write, + writeHead: res.writeHead, + end: res.end, + cacheable: true, + content: undefined, + }; + + // append header overwrites if applicable + Object.keys(globalOptions.headers).forEach(function (name) { + res.setHeader(name, globalOptions.headers[name]); + }); + + res.writeHead = function () { + // add cache control headers + if (!globalOptions.headers["cache-control"]) { + if (shouldCacheResponse(req, res, toggle)) { + res.setHeader("cache-control", "max-age=" + (duration / 1000).toFixed(0)); + } else { + res.setHeader("cache-control", "no-cache, no-store, must-revalidate"); + } + } + + res._apicache.headers = Object.assign({}, getSafeHeaders(res)); + return res._apicache.writeHead.apply(this, arguments); + }; + + // patch res.write + res.write = function (content) { + accumulateContent(res, content); + return res._apicache.write.apply(this, arguments); + }; + + // patch res.end + res.end = function (content, encoding) { + if (shouldCacheResponse(req, res, toggle)) { + accumulateContent(res, content); + + if (res._apicache.cacheable && res._apicache.content) { + addIndexEntries(key, req); + let headers = res._apicache.headers || getSafeHeaders(res); + let cacheObject = createCacheObject( + res.statusCode, + headers, + res._apicache.content, + encoding + ); + cacheResponse(key, cacheObject, duration); + + // display log entry + let elapsed = new Date() - req.apicacheTimer; + debug("adding cache entry for \"" + key + "\" @ " + strDuration, logDuration(elapsed)); + debug("_apicache.headers: ", res._apicache.headers); + debug("res.getHeaders(): ", getSafeHeaders(res)); + debug("cacheObject: ", cacheObject); + } + } + + return res._apicache.end.apply(this, arguments); + }; + + next(); + } + + /** + * Send a cached response to client + * @param {Request} request Express request object + * @param {Response} response Express response object + * @param {object} cacheObject Cache object to send + * @param {function(Object, Object):boolean} toggle + * @param {function} next Function to call next + * @param {number} duration Not used + * @returns {boolean|undefined} true if the request should be + * cached, false otherwise. If undefined, defaults to true. + */ + function sendCachedResponse(request, response, cacheObject, toggle, next, duration) { + if (toggle && !toggle(request, response)) { + return next(); + } + + let headers = getSafeHeaders(response); + + // Modified by @louislam, removed Cache-control, since I don't need client side cache! + // Original Source: https://github.com/kwhitley/apicache/blob/0d5686cc21fad353c6dddee646288c2fca3e4f50/src/apicache.js#L254 + Object.assign(headers, filterBlacklistedHeaders(cacheObject.headers || {})); + + // only embed apicache headers when not in production environment + if (process.env.NODE_ENV !== "production") { + Object.assign(headers, { + "apicache-store": globalOptions.redisClient ? "redis" : "memory", + "apicache-version": "1.6.2-modified", + }); + } + + // unstringify buffers + let data = cacheObject.data; + if (data && data.type === "Buffer") { + data = + typeof data.data === "number" ? new Buffer.alloc(data.data) : new Buffer.from(data.data); + } + + // test Etag against If-None-Match for 304 + let cachedEtag = cacheObject.headers.etag; + let requestEtag = request.headers["if-none-match"]; + + if (requestEtag && cachedEtag === requestEtag) { + response.writeHead(304, headers); + return response.end(); + } + + response.writeHead(cacheObject.status || 200, headers); + + return response.end(data, cacheObject.encoding); + } + + /** Sync caching options */ + function syncOptions() { + for (let i in middlewareOptions) { + Object.assign(middlewareOptions[i].options, globalOptions, middlewareOptions[i].localOptions); + } + } + + /** + * Clear key from cache + * @param {string} target Key to clear + * @param {boolean} isAutomatic Is the key being cleared automatically + * @returns {number} + */ + this.clear = function (target, isAutomatic) { + let group = index.groups[target]; + let redis = globalOptions.redisClient; + + if (group) { + debug("clearing group \"" + target + "\""); + + group.forEach(function (key) { + debug("clearing cached entry for \"" + key + "\""); + clearTimeout(timers[key]); + delete timers[key]; + if (!globalOptions.redisClient) { + memCache.delete(key); + } else { + try { + redis.del(key); + } catch (err) { + console.log("[apicache] error in redis.del(\"" + key + "\")"); + } + } + index.all = index.all.filter(doesntMatch(key)); + }); + + delete index.groups[target]; + } else if (target) { + debug("clearing " + (isAutomatic ? "expired" : "cached") + " entry for \"" + target + "\""); + clearTimeout(timers[target]); + delete timers[target]; + // clear actual cached entry + if (!redis) { + memCache.delete(target); + } else { + try { + redis.del(target); + } catch (err) { + console.log("[apicache] error in redis.del(\"" + target + "\")"); + } + } + + // remove from global index + index.all = index.all.filter(doesntMatch(target)); + + // remove target from each group that it may exist in + Object.keys(index.groups).forEach(function (groupName) { + index.groups[groupName] = index.groups[groupName].filter(doesntMatch(target)); + + // delete group if now empty + if (!index.groups[groupName].length) { + delete index.groups[groupName]; + } + }); + } else { + debug("clearing entire index"); + + if (!redis) { + memCache.clear(); + } else { + // clear redis keys one by one from internal index to prevent clearing non-apicache entries + index.all.forEach(function (key) { + clearTimeout(timers[key]); + delete timers[key]; + try { + redis.del(key); + } catch (err) { + console.log("[apicache] error in redis.del(\"" + key + "\")"); + } + }); + } + this.resetIndex(); + } + + return this.getIndex(); + }; + + /** + * Converts a duration string to an integer number of milliseconds. + * @param {(string|number)} duration The string to convert. + * @param {number} defaultDuration The default duration to return if + * can't parse duration + * @returns {number} The converted value in milliseconds, or the + * defaultDuration if it can't be parsed. + */ + function parseDuration(duration, defaultDuration) { + if (typeof duration === "number") { + return duration; + } + + if (typeof duration === "string") { + let split = duration.match(/^([\d\.,]+)\s?(\w+)$/); + + if (split.length === 3) { + let len = parseFloat(split[1]); + let unit = split[2].replace(/s$/i, "").toLowerCase(); + if (unit === "m") { + unit = "ms"; + } + + return (len || 1) * (t[unit] || 0); + } + } + + return defaultDuration; + } + + /** + * Parse duration + * @param {(number|string)} duration + * @returns {number} Duration parsed to a number + */ + this.getDuration = function (duration) { + return parseDuration(duration, globalOptions.defaultDuration); + }; + + /** + * Return cache performance statistics (hit rate). Suitable for + * putting into a route: + * <code> + * app.get('/api/cache/performance', (req, res) => { + * res.json(apicache.getPerformance()) + * }) + * </code> + * @returns {any[]} + */ + this.getPerformance = function () { + return performanceArray.map(function (p) { + return p.report(); + }); + }; + + /** + * Get index of a group + * @param {string} group + * @returns {number} + */ + this.getIndex = function (group) { + if (group) { + return index.groups[group]; + } else { + return index; + } + }; + + /** + * Express middleware + * @param {(string|number)} strDuration Duration to cache responses + * for. + * @param {function(Object, Object):boolean} middlewareToggle + * @param {Object} localOptions Options for APICache + * @returns + */ + this.middleware = function cache(strDuration, middlewareToggle, localOptions) { + let duration = instance.getDuration(strDuration); + let opt = {}; + + middlewareOptions.push({ + options: opt, + }); + + let options = function (localOptions) { + if (localOptions) { + middlewareOptions.find(function (middleware) { + return middleware.options === opt; + }).localOptions = localOptions; + } + + syncOptions(); + + return opt; + }; + + options(localOptions); + + /** + * A Function for non tracking performance + */ + function NOOPCachePerformance() { + this.report = this.hit = this.miss = function () {}; // noop; + } + + /** + * A function for tracking and reporting hit rate. These + * statistics are returned by the getPerformance() call above. + */ + function CachePerformance() { + /** + * Tracks the hit rate for the last 100 requests. If there + * have been fewer than 100 requests, the hit rate just + * considers the requests that have happened. + */ + this.hitsLast100 = new Uint8Array(100 / 4); // each hit is 2 bits + + /** + * Tracks the hit rate for the last 1000 requests. If there + * have been fewer than 1000 requests, the hit rate just + * considers the requests that have happened. + */ + this.hitsLast1000 = new Uint8Array(1000 / 4); // each hit is 2 bits + + /** + * Tracks the hit rate for the last 10000 requests. If there + * have been fewer than 10000 requests, the hit rate just + * considers the requests that have happened. + */ + this.hitsLast10000 = new Uint8Array(10000 / 4); // each hit is 2 bits + + /** + * Tracks the hit rate for the last 100000 requests. If + * there have been fewer than 100000 requests, the hit rate + * just considers the requests that have happened. + */ + this.hitsLast100000 = new Uint8Array(100000 / 4); // each hit is 2 bits + + /** + * The number of calls that have passed through the + * middleware since the server started. + */ + this.callCount = 0; + + /** + * The total number of hits since the server started + */ + this.hitCount = 0; + + /** + * The key from the last cache hit. This is useful in + * identifying which route these statistics apply to. + */ + this.lastCacheHit = null; + + /** + * The key from the last cache miss. This is useful in + * identifying which route these statistics apply to. + */ + this.lastCacheMiss = null; + + /** + * Return performance statistics + * @returns {Object} + */ + this.report = function () { + return { + lastCacheHit: this.lastCacheHit, + lastCacheMiss: this.lastCacheMiss, + callCount: this.callCount, + hitCount: this.hitCount, + missCount: this.callCount - this.hitCount, + hitRate: this.callCount == 0 ? null : this.hitCount / this.callCount, + hitRateLast100: this.hitRate(this.hitsLast100), + hitRateLast1000: this.hitRate(this.hitsLast1000), + hitRateLast10000: this.hitRate(this.hitsLast10000), + hitRateLast100000: this.hitRate(this.hitsLast100000), + }; + }; + + /** + * Computes a cache hit rate from an array of hits and + * misses. + * @param {Uint8Array} array An array representing hits and + * misses. + * @returns {?number} a number between 0 and 1, or null if + * the array has no hits or misses + */ + this.hitRate = function (array) { + let hits = 0; + let misses = 0; + for (let i = 0; i < array.length; i++) { + let n8 = array[i]; + for (let j = 0; j < 4; j++) { + switch (n8 & 3) { + case 1: + hits++; + break; + case 2: + misses++; + break; + } + n8 >>= 2; + } + } + let total = hits + misses; + if (total == 0) { + return null; + } + return hits / total; + }; + + /** + * Record a hit or miss in the given array. It will be + * recorded at a position determined by the current value of + * the callCount variable. + * @param {Uint8Array} array An array representing hits and + * misses. + * @param {boolean} hit true for a hit, false for a miss + * Each element in the array is 8 bits, and encodes 4 + * hit/miss records. Each hit or miss is encoded as to bits + * as follows: 00 means no hit or miss has been recorded in + * these bits 01 encodes a hit 10 encodes a miss + */ + this.recordHitInArray = function (array, hit) { + let arrayIndex = ~~(this.callCount / 4) % array.length; + let bitOffset = (this.callCount % 4) * 2; // 2 bits per record, 4 records per uint8 array element + let clearMask = ~(3 << bitOffset); + let record = (hit ? 1 : 2) << bitOffset; + array[arrayIndex] = (array[arrayIndex] & clearMask) | record; + }; + + /** + * Records the hit or miss in the tracking arrays and + * increments the call count. + * @param {boolean} hit true records a hit, false records a + * miss + */ + this.recordHit = function (hit) { + this.recordHitInArray(this.hitsLast100, hit); + this.recordHitInArray(this.hitsLast1000, hit); + this.recordHitInArray(this.hitsLast10000, hit); + this.recordHitInArray(this.hitsLast100000, hit); + if (hit) { + this.hitCount++; + } + this.callCount++; + }; + + /** + * Records a hit event, setting lastCacheMiss to the given key + * @param {string} key The key that had the cache hit + */ + this.hit = function (key) { + this.recordHit(true); + this.lastCacheHit = key; + }; + + /** + * Records a miss event, setting lastCacheMiss to the given key + * @param {string} key The key that had the cache miss + */ + this.miss = function (key) { + this.recordHit(false); + this.lastCacheMiss = key; + }; + } + + let perf = globalOptions.trackPerformance ? new CachePerformance() : new NOOPCachePerformance(); + + performanceArray.push(perf); + + /** + * Cache a request + * @param {Object} req Express request object + * @param {Object} res Express response object + * @param {function} next Function to call next + * @returns {any} + */ + let cache = function (req, res, next) { + function bypass() { + debug("bypass detected, skipping cache."); + return next(); + } + + // initial bypass chances + if (!opt.enabled) { + return bypass(); + } + if ( + req.headers["x-apicache-bypass"] || + req.headers["x-apicache-force-fetch"] || + (opt.respectCacheControl && req.headers["cache-control"] == "no-cache") + ) { + return bypass(); + } + + // REMOVED IN 0.11.1 TO CORRECT MIDDLEWARE TOGGLE EXECUTE ORDER + // if (typeof middlewareToggle === 'function') { + // if (!middlewareToggle(req, res)) return bypass() + // } else if (middlewareToggle !== undefined && !middlewareToggle) { + // return bypass() + // } + + // embed timer + req.apicacheTimer = new Date(); + + // In Express 4.x the url is ambigious based on where a router is mounted. originalUrl will give the full Url + let key = req.originalUrl || req.url; + + // Remove querystring from key if jsonp option is enabled + if (opt.jsonp) { + key = url.parse(key).pathname; + } + + // add appendKey (either custom function or response path) + if (typeof opt.appendKey === "function") { + key += "$$appendKey=" + opt.appendKey(req, res); + } else if (opt.appendKey.length > 0) { + let appendKey = req; + + for (let i = 0; i < opt.appendKey.length; i++) { + appendKey = appendKey[opt.appendKey[i]]; + } + key += "$$appendKey=" + appendKey; + } + + // attempt cache hit + let redis = opt.redisClient; + let cached = !redis ? memCache.getValue(key) : null; + + // send if cache hit from memory-cache + if (cached) { + let elapsed = new Date() - req.apicacheTimer; + debug("sending cached (memory-cache) version of", key, logDuration(elapsed)); + + perf.hit(key); + return sendCachedResponse(req, res, cached, middlewareToggle, next, duration); + } + + // send if cache hit from redis + if (redis && redis.connected) { + try { + redis.hgetall(key, function (err, obj) { + if (!err && obj && obj.response) { + let elapsed = new Date() - req.apicacheTimer; + debug("sending cached (redis) version of", key, logDuration(elapsed)); + + perf.hit(key); + return sendCachedResponse( + req, + res, + JSON.parse(obj.response), + middlewareToggle, + next, + duration + ); + } else { + perf.miss(key); + return makeResponseCacheable( + req, + res, + next, + key, + duration, + strDuration, + middlewareToggle + ); + } + }); + } catch (err) { + // bypass redis on error + perf.miss(key); + return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle); + } + } else { + perf.miss(key); + return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle); + } + }; + + cache.options = options; + + return cache; + }; + + /** + * Process options + * @param {Object} options + * @returns {Object} + */ + this.options = function (options) { + if (options) { + Object.assign(globalOptions, options); + syncOptions(); + + if ("defaultDuration" in options) { + // Convert the default duration to a number in milliseconds (if needed) + globalOptions.defaultDuration = parseDuration(globalOptions.defaultDuration, 3600000); + } + + if (globalOptions.trackPerformance) { + debug("WARNING: using trackPerformance flag can cause high memory usage!"); + } + + return this; + } else { + return globalOptions; + } + }; + + /** Reset the index */ + this.resetIndex = function () { + index = { + all: [], + groups: {}, + }; + }; + + /** + * Create a new instance of ApiCache + * @param {Object} config Config to pass + * @returns {ApiCache} + */ + this.newInstance = function (config) { + let instance = new ApiCache(); + + if (config) { + instance.options(config); + } + + return instance; + }; + + /** Clone this instance */ + this.clone = function () { + return this.newInstance(this.options()); + }; + + // initialize index + this.resetIndex(); +} + +module.exports = new ApiCache(); diff --git a/server/modules/apicache/index.js b/server/modules/apicache/index.js new file mode 100644 index 0000000..b8bb9b3 --- /dev/null +++ b/server/modules/apicache/index.js @@ -0,0 +1,14 @@ +const apicache = require("./apicache"); + +apicache.options({ + headerBlacklist: [ + "cache-control" + ], + headers: { + // Disable client side cache, only server side cache. + // BUG! Not working for the second request + "cache-control": "no-cache", + }, +}); + +module.exports = apicache; diff --git a/server/modules/apicache/memory-cache.js b/server/modules/apicache/memory-cache.js new file mode 100644 index 0000000..a91eee3 --- /dev/null +++ b/server/modules/apicache/memory-cache.js @@ -0,0 +1,87 @@ +function MemoryCache() { + this.cache = {}; + this.size = 0; +} + +/** + * + * @param {string} key Key to store cache as + * @param {any} value Value to store + * @param {number} time Time to store for + * @param {function(any, string)} timeoutCallback Callback to call in + * case of timeout + * @returns {Object} + */ +MemoryCache.prototype.add = function (key, value, time, timeoutCallback) { + let old = this.cache[key]; + let instance = this; + + let entry = { + value: value, + expire: time + Date.now(), + timeout: setTimeout(function () { + instance.delete(key); + return timeoutCallback && typeof timeoutCallback === "function" && timeoutCallback(value, key); + }, time) + }; + + this.cache[key] = entry; + this.size = Object.keys(this.cache).length; + + return entry; +}; + +/** + * Delete a cache entry + * @param {string} key Key to delete + * @returns {null} + */ +MemoryCache.prototype.delete = function (key) { + let entry = this.cache[key]; + + if (entry) { + clearTimeout(entry.timeout); + } + + delete this.cache[key]; + + this.size = Object.keys(this.cache).length; + + return null; +}; + +/** + * Get value of key + * @param {string} key + * @returns {Object} + */ +MemoryCache.prototype.get = function (key) { + let entry = this.cache[key]; + + return entry; +}; + +/** + * Get value of cache entry + * @param {string} key + * @returns {any} + */ +MemoryCache.prototype.getValue = function (key) { + let entry = this.get(key); + + return entry && entry.value; +}; + +/** + * Clear cache + * @returns {boolean} + */ +MemoryCache.prototype.clear = function () { + Object.keys(this.cache).forEach(function (key) { + this.delete(key); + }, this); + + return true; +}; + +module.exports = MemoryCache; diff --git a/server/modules/axios-ntlm/LICENSE b/server/modules/axios-ntlm/LICENSE new file mode 100644 index 0000000..1744ee4 --- /dev/null +++ b/server/modules/axios-ntlm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 CatButtes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/server/modules/axios-ntlm/lib/flags.js b/server/modules/axios-ntlm/lib/flags.js new file mode 100644 index 0000000..c16028c --- /dev/null +++ b/server/modules/axios-ntlm/lib/flags.js @@ -0,0 +1,77 @@ +'use strict'; +// Original file https://raw.githubusercontent.com/elasticio/node-ntlm-client/master/lib/flags.js +module.exports.NTLMFLAG_NEGOTIATE_UNICODE = 1 << 0; +/* Indicates that Unicode strings are supported for use in security buffer + data. */ +module.exports.NTLMFLAG_NEGOTIATE_OEM = 1 << 1; +/* Indicates that OEM strings are supported for use in security buffer data. */ +module.exports.NTLMFLAG_REQUEST_TARGET = 1 << 2; +/* Requests that the server's authentication realm be included in the Type 2 + message. */ +/* unknown (1<<3) */ +module.exports.NTLMFLAG_NEGOTIATE_SIGN = 1 << 4; +/* Specifies that authenticated communication between the client and server + should carry a digital signature (message integrity). */ +module.exports.NTLMFLAG_NEGOTIATE_SEAL = 1 << 5; +/* Specifies that authenticated communication between the client and server + should be encrypted (message confidentiality). */ +module.exports.NTLMFLAG_NEGOTIATE_DATAGRAM_STYLE = 1 << 6; +/* Indicates that datagram authentication is being used. */ +module.exports.NTLMFLAG_NEGOTIATE_LM_KEY = 1 << 7; +/* Indicates that the LAN Manager session key should be used for signing and + sealing authenticated communications. */ +module.exports.NTLMFLAG_NEGOTIATE_NETWARE = 1 << 8; +/* unknown purpose */ +module.exports.NTLMFLAG_NEGOTIATE_NTLM_KEY = 1 << 9; +/* Indicates that NTLM authentication is being used. */ +/* unknown (1<<10) */ +module.exports.NTLMFLAG_NEGOTIATE_ANONYMOUS = 1 << 11; +/* Sent by the client in the Type 3 message to indicate that an anonymous + context has been established. This also affects the response fields. */ +module.exports.NTLMFLAG_NEGOTIATE_DOMAIN_SUPPLIED = 1 << 12; +/* Sent by the client in the Type 1 message to indicate that a desired + authentication realm is included in the message. */ +module.exports.NTLMFLAG_NEGOTIATE_WORKSTATION_SUPPLIED = 1 << 13; +/* Sent by the client in the Type 1 message to indicate that the client + workstation's name is included in the message. */ +module.exports.NTLMFLAG_NEGOTIATE_LOCAL_CALL = 1 << 14; +/* Sent by the server to indicate that the server and client are on the same + machine. Implies that the client may use a pre-established local security + context rather than responding to the challenge. */ +module.exports.NTLMFLAG_NEGOTIATE_ALWAYS_SIGN = 1 << 15; +/* Indicates that authenticated communication between the client and server + should be signed with a "dummy" signature. */ +module.exports.NTLMFLAG_TARGET_TYPE_DOMAIN = 1 << 16; +/* Sent by the server in the Type 2 message to indicate that the target + authentication realm is a domain. */ +module.exports.NTLMFLAG_TARGET_TYPE_SERVER = 1 << 17; +/* Sent by the server in the Type 2 message to indicate that the target + authentication realm is a server. */ +module.exports.NTLMFLAG_TARGET_TYPE_SHARE = 1 << 18; +/* Sent by the server in the Type 2 message to indicate that the target + authentication realm is a share. Presumably, this is for share-level + authentication. Usage is unclear. */ +module.exports.NTLMFLAG_NEGOTIATE_NTLM2_KEY = 1 << 19; +/* Indicates that the NTLM2 signing and sealing scheme should be used for + protecting authenticated communications. */ +module.exports.NTLMFLAG_REQUEST_INIT_RESPONSE = 1 << 20; +/* unknown purpose */ +module.exports.NTLMFLAG_REQUEST_ACCEPT_RESPONSE = 1 << 21; +/* unknown purpose */ +module.exports.NTLMFLAG_REQUEST_NONNT_SESSION_KEY = 1 << 22; +/* unknown purpose */ +module.exports.NTLMFLAG_NEGOTIATE_TARGET_INFO = 1 << 23; +/* Sent by the server in the Type 2 message to indicate that it is including a + Target Information block in the message. */ +/* unknown (1<24) */ +/* unknown (1<25) */ +/* unknown (1<26) */ +/* unknown (1<27) */ +/* unknown (1<28) */ +module.exports.NTLMFLAG_NEGOTIATE_128 = 1 << 29; +/* Indicates that 128-bit encryption is supported. */ +module.exports.NTLMFLAG_NEGOTIATE_KEY_EXCHANGE = 1 << 30; +/* Indicates that the client will provide an encrypted master key in + the "Session Key" field of the Type 3 message. */ +module.exports.NTLMFLAG_NEGOTIATE_56 = 1 << 31; +//# sourceMappingURL=flags.js.map
\ No newline at end of file diff --git a/server/modules/axios-ntlm/lib/hash.js b/server/modules/axios-ntlm/lib/hash.js new file mode 100644 index 0000000..4e5aa26 --- /dev/null +++ b/server/modules/axios-ntlm/lib/hash.js @@ -0,0 +1,122 @@ +'use strict'; +// Original source at https://github.com/elasticio/node-ntlm-client/blob/master/lib/hash.js +var crypto = require('crypto'); +function createLMResponse(challenge, lmhash) { + var buf = new Buffer.alloc(24), pwBuffer = new Buffer.alloc(21).fill(0); + lmhash.copy(pwBuffer); + calculateDES(pwBuffer.slice(0, 7), challenge).copy(buf); + calculateDES(pwBuffer.slice(7, 14), challenge).copy(buf, 8); + calculateDES(pwBuffer.slice(14), challenge).copy(buf, 16); + return buf; +} +function createLMHash(password) { + var buf = new Buffer.alloc(16), pwBuffer = new Buffer.alloc(14), magicKey = new Buffer.from('KGS!@#$%', 'ascii'); + if (password.length > 14) { + buf.fill(0); + return buf; + } + pwBuffer.fill(0); + pwBuffer.write(password.toUpperCase(), 0, 'ascii'); + return Buffer.concat([ + calculateDES(pwBuffer.slice(0, 7), magicKey), + calculateDES(pwBuffer.slice(7), magicKey) + ]); +} +function calculateDES(key, message) { + var desKey = new Buffer.alloc(8); + desKey[0] = key[0] & 0xFE; + desKey[1] = ((key[0] << 7) & 0xFF) | (key[1] >> 1); + desKey[2] = ((key[1] << 6) & 0xFF) | (key[2] >> 2); + desKey[3] = ((key[2] << 5) & 0xFF) | (key[3] >> 3); + desKey[4] = ((key[3] << 4) & 0xFF) | (key[4] >> 4); + desKey[5] = ((key[4] << 3) & 0xFF) | (key[5] >> 5); + desKey[6] = ((key[5] << 2) & 0xFF) | (key[6] >> 6); + desKey[7] = (key[6] << 1) & 0xFF; + for (var i = 0; i < 8; i++) { + var parity = 0; + for (var j = 1; j < 8; j++) { + parity += (desKey[i] >> j) % 2; + } + desKey[i] |= (parity % 2) === 0 ? 1 : 0; + } + var des = crypto.createCipheriv('DES-ECB', desKey, ''); + return des.update(message); +} +function createNTLMResponse(challenge, ntlmhash) { + var buf = new Buffer.alloc(24), ntlmBuffer = new Buffer.alloc(21).fill(0); + ntlmhash.copy(ntlmBuffer); + calculateDES(ntlmBuffer.slice(0, 7), challenge).copy(buf); + calculateDES(ntlmBuffer.slice(7, 14), challenge).copy(buf, 8); + calculateDES(ntlmBuffer.slice(14), challenge).copy(buf, 16); + return buf; +} +function createNTLMHash(password) { + var md4sum = crypto.createHash('md4'); + md4sum.update(new Buffer.from(password, 'ucs2')); + return md4sum.digest(); +} +function createNTLMv2Hash(ntlmhash, username, authTargetName) { + var hmac = crypto.createHmac('md5', ntlmhash); + hmac.update(new Buffer.from(username.toUpperCase() + authTargetName, 'ucs2')); + return hmac.digest(); +} +function createLMv2Response(type2message, username, ntlmhash, nonce, targetName) { + var buf = new Buffer.alloc(24), ntlm2hash = createNTLMv2Hash(ntlmhash, username, targetName), hmac = crypto.createHmac('md5', ntlm2hash); + //server challenge + type2message.challenge.copy(buf, 8); + //client nonce + buf.write(nonce || createPseudoRandomValue(16), 16, 'hex'); + //create hash + hmac.update(buf.slice(8)); + var hashedBuffer = hmac.digest(); + hashedBuffer.copy(buf); + return buf; +} +function createNTLMv2Response(type2message, username, ntlmhash, nonce, targetName) { + var buf = new Buffer.alloc(48 + type2message.targetInfo.buffer.length), ntlm2hash = createNTLMv2Hash(ntlmhash, username, targetName), hmac = crypto.createHmac('md5', ntlm2hash); + //the first 8 bytes are spare to store the hashed value before the blob + //server challenge + type2message.challenge.copy(buf, 8); + //blob signature + buf.writeUInt32BE(0x01010000, 16); + //reserved + buf.writeUInt32LE(0, 20); + //timestamp + //TODO: we are loosing precision here since js is not able to handle those large integers + // maybe think about a different solution here + // 11644473600000 = diff between 1970 and 1601 + var timestamp = ((Date.now() + 11644473600000) * 10000).toString(16); + var timestampLow = Number('0x' + timestamp.substring(Math.max(0, timestamp.length - 8))); + var timestampHigh = Number('0x' + timestamp.substring(0, Math.max(0, timestamp.length - 8))); + buf.writeUInt32LE(timestampLow, 24, false); + buf.writeUInt32LE(timestampHigh, 28, false); + //random client nonce + buf.write(nonce || createPseudoRandomValue(16), 32, 'hex'); + //zero + buf.writeUInt32LE(0, 40); + //complete target information block from type 2 message + type2message.targetInfo.buffer.copy(buf, 44); + //zero + buf.writeUInt32LE(0, 44 + type2message.targetInfo.buffer.length); + hmac.update(buf.slice(8)); + var hashedBuffer = hmac.digest(); + hashedBuffer.copy(buf); + return buf; +} +function createPseudoRandomValue(length) { + var str = ''; + while (str.length < length) { + str += Math.floor(Math.random() * 16).toString(16); + } + return str; +} +module.exports = { + createLMHash: createLMHash, + createNTLMHash: createNTLMHash, + createLMResponse: createLMResponse, + createNTLMResponse: createNTLMResponse, + createLMv2Response: createLMv2Response, + createNTLMv2Response: createNTLMv2Response, + createPseudoRandomValue: createPseudoRandomValue +}; +//# sourceMappingURL=hash.js.map
\ No newline at end of file diff --git a/server/modules/axios-ntlm/lib/ntlm.js b/server/modules/axios-ntlm/lib/ntlm.js new file mode 100644 index 0000000..54490c0 --- /dev/null +++ b/server/modules/axios-ntlm/lib/ntlm.js @@ -0,0 +1,220 @@ +'use strict'; +// Original file https://raw.githubusercontent.com/elasticio/node-ntlm-client/master/lib/ntlm.js +var os = require('os'), flags = require('./flags'), hash = require('./hash'); +var NTLMSIGNATURE = "NTLMSSP\0"; +function createType1Message(workstation, target) { + var dataPos = 32, pos = 0, buf = new Buffer.alloc(1024); + workstation = workstation === undefined ? os.hostname() : workstation; + target = target === undefined ? '' : target; + //signature + buf.write(NTLMSIGNATURE, pos, NTLMSIGNATURE.length, 'ascii'); + pos += NTLMSIGNATURE.length; + //message type + buf.writeUInt32LE(1, pos); + pos += 4; + //flags + buf.writeUInt32LE(flags.NTLMFLAG_NEGOTIATE_OEM | + flags.NTLMFLAG_REQUEST_TARGET | + flags.NTLMFLAG_NEGOTIATE_NTLM_KEY | + flags.NTLMFLAG_NEGOTIATE_NTLM2_KEY | + flags.NTLMFLAG_NEGOTIATE_ALWAYS_SIGN, pos); + pos += 4; + //domain security buffer + buf.writeUInt16LE(target.length, pos); + pos += 2; + buf.writeUInt16LE(target.length, pos); + pos += 2; + buf.writeUInt32LE(target.length === 0 ? 0 : dataPos, pos); + pos += 4; + if (target.length > 0) { + dataPos += buf.write(target, dataPos, 'ascii'); + } + //workstation security buffer + buf.writeUInt16LE(workstation.length, pos); + pos += 2; + buf.writeUInt16LE(workstation.length, pos); + pos += 2; + buf.writeUInt32LE(workstation.length === 0 ? 0 : dataPos, pos); + pos += 4; + if (workstation.length > 0) { + dataPos += buf.write(workstation, dataPos, 'ascii'); + } + return 'NTLM ' + buf.toString('base64', 0, dataPos); +} +function decodeType2Message(str) { + if (str === undefined) { + throw new Error('Invalid argument'); + } + //convenience + if (Object.prototype.toString.call(str) !== '[object String]') { + if (str.hasOwnProperty('headers') && str.headers.hasOwnProperty('www-authenticate')) { + str = str.headers['www-authenticate']; + } + else { + throw new Error('Invalid argument'); + } + } + var ntlmMatch = /^NTLM ([^,\s]+)/.exec(str); + if (ntlmMatch) { + str = ntlmMatch[1]; + } + var buf = new Buffer.from(str, 'base64'), obj = {}; + //check signature + if (buf.toString('ascii', 0, NTLMSIGNATURE.length) !== NTLMSIGNATURE) { + throw new Error('Invalid message signature: ' + str); + } + //check message type + if (buf.readUInt32LE(NTLMSIGNATURE.length) !== 2) { + throw new Error('Invalid message type (no type 2)'); + } + //read flags + obj.flags = buf.readUInt32LE(20); + obj.encoding = (obj.flags & flags.NTLMFLAG_NEGOTIATE_OEM) ? 'ascii' : 'ucs2'; + obj.version = (obj.flags & flags.NTLMFLAG_NEGOTIATE_NTLM2_KEY) ? 2 : 1; + obj.challenge = buf.slice(24, 32); + //read target name + obj.targetName = (function () { + var length = buf.readUInt16LE(12); + //skipping allocated space + var offset = buf.readUInt32LE(16); + if (length === 0) { + return ''; + } + if ((offset + length) > buf.length || offset < 32) { + throw new Error('Bad type 2 message'); + } + return buf.toString(obj.encoding, offset, offset + length); + })(); + //read target info + if (obj.flags & flags.NTLMFLAG_NEGOTIATE_TARGET_INFO) { + obj.targetInfo = (function () { + var info = {}; + var length = buf.readUInt16LE(40); + //skipping allocated space + var offset = buf.readUInt32LE(44); + var targetInfoBuffer = new Buffer.alloc(length); + buf.copy(targetInfoBuffer, 0, offset, offset + length); + if (length === 0) { + return info; + } + if ((offset + length) > buf.length || offset < 32) { + throw new Error('Bad type 2 message'); + } + var pos = offset; + while (pos < (offset + length)) { + var blockType = buf.readUInt16LE(pos); + pos += 2; + var blockLength = buf.readUInt16LE(pos); + pos += 2; + if (blockType === 0) { + //reached the terminator subblock + break; + } + var blockTypeStr = void 0; + switch (blockType) { + case 1: + blockTypeStr = 'SERVER'; + break; + case 2: + blockTypeStr = 'DOMAIN'; + break; + case 3: + blockTypeStr = 'FQDN'; + break; + case 4: + blockTypeStr = 'DNS'; + break; + case 5: + blockTypeStr = 'PARENT_DNS'; + break; + default: + blockTypeStr = ''; + break; + } + if (blockTypeStr) { + info[blockTypeStr] = buf.toString('ucs2', pos, pos + blockLength); + } + pos += blockLength; + } + return { + parsed: info, + buffer: targetInfoBuffer + }; + })(); + } + return obj; +} +function createType3Message(type2Message, username, password, workstation, target) { + var dataPos = 52, buf = new Buffer.alloc(1024); + if (workstation === undefined) { + workstation = os.hostname(); + } + if (target === undefined) { + target = type2Message.targetName; + } + //signature + buf.write(NTLMSIGNATURE, 0, NTLMSIGNATURE.length, 'ascii'); + //message type + buf.writeUInt32LE(3, 8); + if (type2Message.version === 2) { + dataPos = 64; + var ntlmHash = hash.createNTLMHash(password), nonce = hash.createPseudoRandomValue(16), lmv2 = hash.createLMv2Response(type2Message, username, ntlmHash, nonce, target), ntlmv2 = hash.createNTLMv2Response(type2Message, username, ntlmHash, nonce, target); + //lmv2 security buffer + buf.writeUInt16LE(lmv2.length, 12); + buf.writeUInt16LE(lmv2.length, 14); + buf.writeUInt32LE(dataPos, 16); + lmv2.copy(buf, dataPos); + dataPos += lmv2.length; + //ntlmv2 security buffer + buf.writeUInt16LE(ntlmv2.length, 20); + buf.writeUInt16LE(ntlmv2.length, 22); + buf.writeUInt32LE(dataPos, 24); + ntlmv2.copy(buf, dataPos); + dataPos += ntlmv2.length; + } + else { + var lmHash = hash.createLMHash(password), ntlmHash = hash.createNTLMHash(password), lm = hash.createLMResponse(type2Message.challenge, lmHash), ntlm = hash.createNTLMResponse(type2Message.challenge, ntlmHash); + //lm security buffer + buf.writeUInt16LE(lm.length, 12); + buf.writeUInt16LE(lm.length, 14); + buf.writeUInt32LE(dataPos, 16); + lm.copy(buf, dataPos); + dataPos += lm.length; + //ntlm security buffer + buf.writeUInt16LE(ntlm.length, 20); + buf.writeUInt16LE(ntlm.length, 22); + buf.writeUInt32LE(dataPos, 24); + ntlm.copy(buf, dataPos); + dataPos += ntlm.length; + } + //target name security buffer + buf.writeUInt16LE(type2Message.encoding === 'ascii' ? target.length : target.length * 2, 28); + buf.writeUInt16LE(type2Message.encoding === 'ascii' ? target.length : target.length * 2, 30); + buf.writeUInt32LE(dataPos, 32); + dataPos += buf.write(target, dataPos, type2Message.encoding); + //user name security buffer + buf.writeUInt16LE(type2Message.encoding === 'ascii' ? username.length : username.length * 2, 36); + buf.writeUInt16LE(type2Message.encoding === 'ascii' ? username.length : username.length * 2, 38); + buf.writeUInt32LE(dataPos, 40); + dataPos += buf.write(username, dataPos, type2Message.encoding); + //workstation name security buffer + buf.writeUInt16LE(type2Message.encoding === 'ascii' ? workstation.length : workstation.length * 2, 44); + buf.writeUInt16LE(type2Message.encoding === 'ascii' ? workstation.length : workstation.length * 2, 46); + buf.writeUInt32LE(dataPos, 48); + dataPos += buf.write(workstation, dataPos, type2Message.encoding); + if (type2Message.version === 2) { + //session key security buffer + buf.writeUInt16LE(0, 52); + buf.writeUInt16LE(0, 54); + buf.writeUInt32LE(0, 56); + //flags + buf.writeUInt32LE(type2Message.flags, 60); + } + return 'NTLM ' + buf.toString('base64', 0, dataPos); +} +module.exports = { + createType1Message: createType1Message, + decodeType2Message: decodeType2Message, + createType3Message: createType3Message +}; +//# sourceMappingURL=ntlm.js.map
\ No newline at end of file diff --git a/server/modules/axios-ntlm/lib/ntlmClient.js b/server/modules/axios-ntlm/lib/ntlmClient.js new file mode 100644 index 0000000..682de5f --- /dev/null +++ b/server/modules/axios-ntlm/lib/ntlmClient.js @@ -0,0 +1,127 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; + return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (_) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NtlmClient = void 0; +var axios_1 = __importDefault(require("axios")); +var ntlm = __importStar(require("./ntlm")); +var https = __importStar(require("https")); +var http = __importStar(require("http")); +var dev_null_1 = __importDefault(require("dev-null")); +/** +* @param credentials An NtlmCredentials object containing the username and password +* @param AxiosConfig The Axios config for the instance you wish to create +* +* @returns This function returns an axios instance configured to use the provided credentials +*/ +function NtlmClient(credentials, AxiosConfig) { + var _this = this; + var config = AxiosConfig !== null && AxiosConfig !== void 0 ? AxiosConfig : {}; + if (!config.httpAgent) { + config.httpAgent = new http.Agent({ keepAlive: true }); + } + if (!config.httpsAgent) { + config.httpsAgent = new https.Agent({ keepAlive: true }); + } + var client = axios_1.default.create(config); + client.interceptors.response.use(function (response) { + return response; + }, function (err) { return __awaiter(_this, void 0, void 0, function () { + var error, t1Msg, t2Msg, t3Msg, stream_1; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + error = err.response; + if (!(error && error.status === 401 + && error.headers['www-authenticate'] + && error.headers['www-authenticate'].includes('NTLM'))) return [3 /*break*/, 3]; + // This length check is a hack because SharePoint is awkward and will + // include the Negotiate option when responding with the T2 message + // There is nore we could do to ensure we are processing correctly, + // but this is the easiest option for now + if (error.headers['www-authenticate'].length < 50) { + t1Msg = ntlm.createType1Message(credentials.workstation, credentials.domain); + error.config.headers["Authorization"] = t1Msg; + } + else { + t2Msg = ntlm.decodeType2Message((error.headers['www-authenticate'].match(/^NTLM\s+(.+?)(,|\s+|$)/) || [])[1]); + t3Msg = ntlm.createType3Message(t2Msg, credentials.username, credentials.password, credentials.workstation, credentials.domain); + error.config.headers["X-retry"] = "false"; + error.config.headers["Authorization"] = t3Msg; + } + if (!(error.config.responseType === "stream")) return [3 /*break*/, 2]; + stream_1 = (_a = err.response) === null || _a === void 0 ? void 0 : _a.data; + if (!(stream_1 && !stream_1.readableEnded)) return [3 /*break*/, 2]; + return [4 /*yield*/, new Promise(function (resolve) { + stream_1.pipe((0, dev_null_1.default)()); + stream_1.once('close', resolve); + })]; + case 1: + _b.sent(); + _b.label = 2; + case 2: return [2 /*return*/, client(error.config)]; + case 3: throw err; + } + }); + }); }); + return client; +} +exports.NtlmClient = NtlmClient; +//# sourceMappingURL=ntlmClient.js.map
\ No newline at end of file diff --git a/server/modules/dayjs/plugin/timezone.d.ts b/server/modules/dayjs/plugin/timezone.d.ts new file mode 100644 index 0000000..d504f69 --- /dev/null +++ b/server/modules/dayjs/plugin/timezone.d.ts @@ -0,0 +1,20 @@ +import { PluginFunc, ConfigType } from 'dayjs' + +declare const plugin: PluginFunc +export = plugin + +declare module 'dayjs' { + interface Dayjs { + tz(timezone?: string, keepLocalTime?: boolean): Dayjs + offsetName(type?: 'short' | 'long'): string | undefined + } + + interface DayjsTimezone { + (date: ConfigType, timezone?: string): Dayjs + (date: ConfigType, format: string, timezone?: string): Dayjs + guess(): string + setDefault(timezone?: string): void + } + + const tz: DayjsTimezone +} diff --git a/server/modules/dayjs/plugin/timezone.js b/server/modules/dayjs/plugin/timezone.js new file mode 100644 index 0000000..de709ae --- /dev/null +++ b/server/modules/dayjs/plugin/timezone.js @@ -0,0 +1,115 @@ +/** + * Copy from node_modules/dayjs/plugin/timezone.js + * Try to fix https://github.com/louislam/uptime-kuma/issues/2318 + * Source: https://github.com/iamkun/dayjs/tree/dev/src/plugin/utc + * License: MIT + */ +!function (t, e) { + // eslint-disable-next-line no-undef + typeof exports == "object" && typeof module != "undefined" ? module.exports = e() : typeof define == "function" && define.amd ? define(e) : (t = typeof globalThis != "undefined" ? globalThis : t || self).dayjs_plugin_timezone = e(); +}(this, (function () { + "use strict"; + let t = { + year: 0, + month: 1, + day: 2, + hour: 3, + minute: 4, + second: 5 + }; + let e = {}; + return function (n, i, o) { + let r; + let a = function (t, n, i) { + void 0 === i && (i = {}); + let o = new Date(t); + let r = function (t, n) { + void 0 === n && (n = {}); + let i = n.timeZoneName || "short"; + let o = t + "|" + i; + let r = e[o]; + return r || (r = new Intl.DateTimeFormat("en-US", { + hour12: !1, + timeZone: t, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZoneName: i + }), e[o] = r), r; + }(n, i); + return r.formatToParts(o); + }; + let u = function (e, n) { + let i = a(e, n); + let r = []; + let u = 0; + for (; u < i.length; u += 1) { + let f = i[u]; + let s = f.type; + let m = f.value; + let c = t[s]; + c >= 0 && (r[c] = parseInt(m, 10)); + } + let d = r[3]; + let l = d === 24 ? 0 : d; + let v = r[0] + "-" + r[1] + "-" + r[2] + " " + l + ":" + r[4] + ":" + r[5] + ":000"; + let h = +e; + return (o.utc(v).valueOf() - (h -= h % 1e3)) / 6e4; + }; + let f = i.prototype; + f.tz = function (t, e) { + void 0 === t && (t = r); + let n = this.utcOffset(); + let i = this.toDate(); + let a = i.toLocaleString("en-US", { timeZone: t }).replace("\u202f", " "); + let u = Math.round((i - new Date(a)) / 1e3 / 60); + let f = o(a).$set("millisecond", this.$ms).utcOffset(15 * -Math.round(i.getTimezoneOffset() / 15) - u, !0); + if (e) { + let s = f.utcOffset(); + f = f.add(n - s, "minute"); + } + return f.$x.$timezone = t, f; + }, f.offsetName = function (t) { + let e = this.$x.$timezone || o.tz.guess(); + let n = a(this.valueOf(), e, { timeZoneName: t }).find((function (t) { + return t.type.toLowerCase() === "timezonename"; + })); + return n && n.value; + }; + let s = f.startOf; + f.startOf = function (t, e) { + if (!this.$x || !this.$x.$timezone) { + return s.call(this, t, e); + } + let n = o(this.format("YYYY-MM-DD HH:mm:ss:SSS")); + return s.call(n, t, e).tz(this.$x.$timezone, !0); + }, o.tz = function (t, e, n) { + let i = n && e; + let a = n || e || r; + let f = u(+o(), a); + if (typeof t != "string") { + return o(t).tz(a); + } + let s = function (t, e, n) { + let i = t - 60 * e * 1e3; + let o = u(i, n); + if (e === o) { + return [ i, e ]; + } + let r = u(i -= 60 * (o - e) * 1e3, n); + return o === r ? [ i, o ] : [ t - 60 * Math.min(o, r) * 1e3, Math.max(o, r) ]; + }(o.utc(t, i).valueOf(), f, a); + let m = s[0]; + let c = s[1]; + let d = o(m).utcOffset(c); + return d.$x.$timezone = a, d; + }, o.tz.guess = function () { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + }, o.tz.setDefault = function (t) { + r = t; + }; + }; +})); diff --git a/server/monitor-conditions/evaluator.js b/server/monitor-conditions/evaluator.js new file mode 100644 index 0000000..3860a33 --- /dev/null +++ b/server/monitor-conditions/evaluator.js @@ -0,0 +1,71 @@ +const { ConditionExpressionGroup, ConditionExpression, LOGICAL } = require("./expression"); +const { operatorMap } = require("./operators"); + +/** + * @param {ConditionExpression} expression Expression to evaluate + * @param {object} context Context to evaluate against; These are values for variables in the expression + * @returns {boolean} Whether the expression evaluates true or false + * @throws {Error} + */ +function evaluateExpression(expression, context) { + /** + * @type {import("./operators").ConditionOperator|null} + */ + const operator = operatorMap.get(expression.operator) || null; + if (operator === null) { + throw new Error("Unexpected expression operator ID '" + expression.operator + "'. Expected one of [" + operatorMap.keys().join(",") + "]"); + } + + if (!Object.prototype.hasOwnProperty.call(context, expression.variable)) { + throw new Error("Variable missing in context: " + expression.variable); + } + + return operator.test(context[expression.variable], expression.value); +} + +/** + * @param {ConditionExpressionGroup} group Group of expressions to evaluate + * @param {object} context Context to evaluate against; These are values for variables in the expression + * @returns {boolean} Whether the group evaluates true or false + * @throws {Error} + */ +function evaluateExpressionGroup(group, context) { + if (!group.children.length) { + throw new Error("ConditionExpressionGroup must contain at least one child."); + } + + let result = null; + + for (const child of group.children) { + let childResult; + + if (child instanceof ConditionExpression) { + childResult = evaluateExpression(child, context); + } else if (child instanceof ConditionExpressionGroup) { + childResult = evaluateExpressionGroup(child, context); + } else { + throw new Error("Invalid child type in ConditionExpressionGroup. Expected ConditionExpression or ConditionExpressionGroup"); + } + + if (result === null) { + result = childResult; // Initialize result with the first child's result + } else if (child.andOr === LOGICAL.OR) { + result = result || childResult; + } else if (child.andOr === LOGICAL.AND) { + result = result && childResult; + } else { + throw new Error("Invalid logical operator in child of ConditionExpressionGroup. Expected 'and' or 'or'. Got '" + group.andOr + "'"); + } + } + + if (result === null) { + throw new Error("ConditionExpressionGroup did not result in a boolean."); + } + + return result; +} + +module.exports = { + evaluateExpression, + evaluateExpressionGroup, +}; diff --git a/server/monitor-conditions/expression.js b/server/monitor-conditions/expression.js new file mode 100644 index 0000000..1e70369 --- /dev/null +++ b/server/monitor-conditions/expression.js @@ -0,0 +1,111 @@ +/** + * @readonly + * @enum {string} + */ +const LOGICAL = { + AND: "and", + OR: "or", +}; + +/** + * Recursively processes an array of raw condition objects and populates the given parent group with + * corresponding ConditionExpression or ConditionExpressionGroup instances. + * @param {Array} conditions Array of raw condition objects, where each object represents either a group or an expression. + * @param {ConditionExpressionGroup} parentGroup The parent group to which the instantiated ConditionExpression or ConditionExpressionGroup objects will be added. + * @returns {void} + */ +function processMonitorConditions(conditions, parentGroup) { + conditions.forEach(condition => { + const andOr = condition.andOr === LOGICAL.OR ? LOGICAL.OR : LOGICAL.AND; + + if (condition.type === "group") { + const group = new ConditionExpressionGroup([], andOr); + + // Recursively process the group's children + processMonitorConditions(condition.children, group); + + parentGroup.children.push(group); + } else if (condition.type === "expression") { + const expression = new ConditionExpression(condition.variable, condition.operator, condition.value, andOr); + parentGroup.children.push(expression); + } + }); +} + +class ConditionExpressionGroup { + /** + * @type {ConditionExpressionGroup[]|ConditionExpression[]} Groups and/or expressions to test + */ + children = []; + + /** + * @type {LOGICAL} Connects group result with previous group/expression results + */ + andOr; + + /** + * @param {ConditionExpressionGroup[]|ConditionExpression[]} children Groups and/or expressions to test + * @param {LOGICAL} andOr Connects group result with previous group/expression results + */ + constructor(children = [], andOr = LOGICAL.AND) { + this.children = children; + this.andOr = andOr; + } + + /** + * @param {Monitor} monitor Monitor instance + * @returns {ConditionExpressionGroup|null} A ConditionExpressionGroup with the Monitor's conditions + */ + static fromMonitor(monitor) { + const conditions = JSON.parse(monitor.conditions); + if (conditions.length === 0) { + return null; + } + + const root = new ConditionExpressionGroup(); + processMonitorConditions(conditions, root); + + return root; + } +} + +class ConditionExpression { + /** + * @type {string} ID of variable + */ + variable; + + /** + * @type {string} ID of operator + */ + operator; + + /** + * @type {string} Value to test with the operator + */ + value; + + /** + * @type {LOGICAL} Connects expression result with previous group/expression results + */ + andOr; + + /** + * @param {string} variable ID of variable to test against + * @param {string} operator ID of operator to test the variable with + * @param {string} value Value to test with the operator + * @param {LOGICAL} andOr Connects expression result with previous group/expression results + */ + constructor(variable, operator, value, andOr = LOGICAL.AND) { + this.variable = variable; + this.operator = operator; + this.value = value; + this.andOr = andOr; + } +} + +module.exports = { + LOGICAL, + ConditionExpressionGroup, + ConditionExpression, +}; diff --git a/server/monitor-conditions/operators.js b/server/monitor-conditions/operators.js new file mode 100644 index 0000000..d900dff --- /dev/null +++ b/server/monitor-conditions/operators.js @@ -0,0 +1,318 @@ +class ConditionOperator { + id = undefined; + caption = undefined; + + /** + * @type {mixed} variable + * @type {mixed} value + */ + test(variable, value) { + throw new Error("You need to override test()"); + } +} + +const OP_STR_EQUALS = "equals"; + +const OP_STR_NOT_EQUALS = "not_equals"; + +const OP_CONTAINS = "contains"; + +const OP_NOT_CONTAINS = "not_contains"; + +const OP_STARTS_WITH = "starts_with"; + +const OP_NOT_STARTS_WITH = "not_starts_with"; + +const OP_ENDS_WITH = "ends_with"; + +const OP_NOT_ENDS_WITH = "not_ends_with"; + +const OP_NUM_EQUALS = "num_equals"; + +const OP_NUM_NOT_EQUALS = "num_not_equals"; + +const OP_LT = "lt"; + +const OP_GT = "gt"; + +const OP_LTE = "lte"; + +const OP_GTE = "gte"; + +/** + * Asserts a variable is equal to a value. + */ +class StringEqualsOperator extends ConditionOperator { + id = OP_STR_EQUALS; + caption = "equals"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable === value; + } +} + +/** + * Asserts a variable is not equal to a value. + */ +class StringNotEqualsOperator extends ConditionOperator { + id = OP_STR_NOT_EQUALS; + caption = "not equals"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable !== value; + } +} + +/** + * Asserts a variable contains a value. + * Handles both Array and String variable types. + */ +class ContainsOperator extends ConditionOperator { + id = OP_CONTAINS; + caption = "contains"; + + /** + * @inheritdoc + */ + test(variable, value) { + if (Array.isArray(variable)) { + return variable.includes(value); + } + + return variable.indexOf(value) !== -1; + } +} + +/** + * Asserts a variable does not contain a value. + * Handles both Array and String variable types. + */ +class NotContainsOperator extends ConditionOperator { + id = OP_NOT_CONTAINS; + caption = "not contains"; + + /** + * @inheritdoc + */ + test(variable, value) { + if (Array.isArray(variable)) { + return !variable.includes(value); + } + + return variable.indexOf(value) === -1; + } +} + +/** + * Asserts a variable starts with a value. + */ +class StartsWithOperator extends ConditionOperator { + id = OP_STARTS_WITH; + caption = "starts with"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable.startsWith(value); + } +} + +/** + * Asserts a variable does not start with a value. + */ +class NotStartsWithOperator extends ConditionOperator { + id = OP_NOT_STARTS_WITH; + caption = "not starts with"; + + /** + * @inheritdoc + */ + test(variable, value) { + return !variable.startsWith(value); + } +} + +/** + * Asserts a variable ends with a value. + */ +class EndsWithOperator extends ConditionOperator { + id = OP_ENDS_WITH; + caption = "ends with"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable.endsWith(value); + } +} + +/** + * Asserts a variable does not end with a value. + */ +class NotEndsWithOperator extends ConditionOperator { + id = OP_NOT_ENDS_WITH; + caption = "not ends with"; + + /** + * @inheritdoc + */ + test(variable, value) { + return !variable.endsWith(value); + } +} + +/** + * Asserts a numeric variable is equal to a value. + */ +class NumberEqualsOperator extends ConditionOperator { + id = OP_NUM_EQUALS; + caption = "equals"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable === Number(value); + } +} + +/** + * Asserts a numeric variable is not equal to a value. + */ +class NumberNotEqualsOperator extends ConditionOperator { + id = OP_NUM_NOT_EQUALS; + caption = "not equals"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable !== Number(value); + } +} + +/** + * Asserts a variable is less than a value. + */ +class LessThanOperator extends ConditionOperator { + id = OP_LT; + caption = "less than"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable < Number(value); + } +} + +/** + * Asserts a variable is greater than a value. + */ +class GreaterThanOperator extends ConditionOperator { + id = OP_GT; + caption = "greater than"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable > Number(value); + } +} + +/** + * Asserts a variable is less than or equal to a value. + */ +class LessThanOrEqualToOperator extends ConditionOperator { + id = OP_LTE; + caption = "less than or equal to"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable <= Number(value); + } +} + +/** + * Asserts a variable is greater than or equal to a value. + */ +class GreaterThanOrEqualToOperator extends ConditionOperator { + id = OP_GTE; + caption = "greater than or equal to"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable >= Number(value); + } +} + +const operatorMap = new Map([ + [ OP_STR_EQUALS, new StringEqualsOperator ], + [ OP_STR_NOT_EQUALS, new StringNotEqualsOperator ], + [ OP_CONTAINS, new ContainsOperator ], + [ OP_NOT_CONTAINS, new NotContainsOperator ], + [ OP_STARTS_WITH, new StartsWithOperator ], + [ OP_NOT_STARTS_WITH, new NotStartsWithOperator ], + [ OP_ENDS_WITH, new EndsWithOperator ], + [ OP_NOT_ENDS_WITH, new NotEndsWithOperator ], + [ OP_NUM_EQUALS, new NumberEqualsOperator ], + [ OP_NUM_NOT_EQUALS, new NumberNotEqualsOperator ], + [ OP_LT, new LessThanOperator ], + [ OP_GT, new GreaterThanOperator ], + [ OP_LTE, new LessThanOrEqualToOperator ], + [ OP_GTE, new GreaterThanOrEqualToOperator ], +]); + +const defaultStringOperators = [ + operatorMap.get(OP_STR_EQUALS), + operatorMap.get(OP_STR_NOT_EQUALS), + operatorMap.get(OP_CONTAINS), + operatorMap.get(OP_NOT_CONTAINS), + operatorMap.get(OP_STARTS_WITH), + operatorMap.get(OP_NOT_STARTS_WITH), + operatorMap.get(OP_ENDS_WITH), + operatorMap.get(OP_NOT_ENDS_WITH) +]; + +const defaultNumberOperators = [ + operatorMap.get(OP_NUM_EQUALS), + operatorMap.get(OP_NUM_NOT_EQUALS), + operatorMap.get(OP_LT), + operatorMap.get(OP_GT), + operatorMap.get(OP_LTE), + operatorMap.get(OP_GTE) +]; + +module.exports = { + OP_STR_EQUALS, + OP_STR_NOT_EQUALS, + OP_CONTAINS, + OP_NOT_CONTAINS, + OP_STARTS_WITH, + OP_NOT_STARTS_WITH, + OP_ENDS_WITH, + OP_NOT_ENDS_WITH, + OP_NUM_EQUALS, + OP_NUM_NOT_EQUALS, + OP_LT, + OP_GT, + OP_LTE, + OP_GTE, + operatorMap, + defaultStringOperators, + defaultNumberOperators, + ConditionOperator, +}; diff --git a/server/monitor-conditions/variables.js b/server/monitor-conditions/variables.js new file mode 100644 index 0000000..af98d2f --- /dev/null +++ b/server/monitor-conditions/variables.js @@ -0,0 +1,31 @@ +/** + * Represents a variable used in a condition and the set of operators that can be applied to this variable. + * + * A `ConditionVariable` holds the ID of the variable and a list of operators that define how this variable can be evaluated + * in conditions. For example, if the variable is a request body or a specific field in a request, the operators can include + * operations such as equality checks, comparisons, or other custom evaluations. + */ +class ConditionVariable { + /** + * @type {string} + */ + id; + + /** + * @type {import("./operators").ConditionOperator[]} + */ + operators = {}; + + /** + * @param {string} id ID of variable + * @param {import("./operators").ConditionOperator[]} operators Operators the condition supports + */ + constructor(id, operators = []) { + this.id = id; + this.operators = operators; + } +} + +module.exports = { + ConditionVariable, +}; diff --git a/server/monitor-types/dns.js b/server/monitor-types/dns.js new file mode 100644 index 0000000..8b87932 --- /dev/null +++ b/server/monitor-types/dns.js @@ -0,0 +1,85 @@ +const { MonitorType } = require("./monitor-type"); +const { UP, DOWN } = require("../../src/util"); +const dayjs = require("dayjs"); +const { dnsResolve } = require("../util-server"); +const { R } = require("redbean-node"); +const { ConditionVariable } = require("../monitor-conditions/variables"); +const { defaultStringOperators } = require("../monitor-conditions/operators"); +const { ConditionExpressionGroup } = require("../monitor-conditions/expression"); +const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator"); + +class DnsMonitorType extends MonitorType { + name = "dns"; + + supportsConditions = true; + + conditionVariables = [ + new ConditionVariable("record", defaultStringOperators ), + ]; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, _server) { + let startTime = dayjs().valueOf(); + let dnsMessage = ""; + + let dnsRes = await dnsResolve(monitor.hostname, monitor.dns_resolve_server, monitor.port, monitor.dns_resolve_type); + heartbeat.ping = dayjs().valueOf() - startTime; + + const conditions = ConditionExpressionGroup.fromMonitor(monitor); + let conditionsResult = true; + const handleConditions = (data) => conditions ? evaluateExpressionGroup(conditions, data) : true; + + switch (monitor.dns_resolve_type) { + case "A": + case "AAAA": + case "TXT": + case "PTR": + dnsMessage = `Records: ${dnsRes.join(" | ")}`; + conditionsResult = dnsRes.some(record => handleConditions({ record })); + break; + + case "CNAME": + dnsMessage = dnsRes[0]; + conditionsResult = handleConditions({ record: dnsRes[0] }); + break; + + case "CAA": + dnsMessage = dnsRes[0].issue; + conditionsResult = handleConditions({ record: dnsRes[0].issue }); + break; + + case "MX": + dnsMessage = dnsRes.map(record => `Hostname: ${record.exchange} - Priority: ${record.priority}`).join(" | "); + conditionsResult = dnsRes.some(record => handleConditions({ record: record.exchange })); + break; + + case "NS": + dnsMessage = `Servers: ${dnsRes.join(" | ")}`; + conditionsResult = dnsRes.some(record => handleConditions({ record })); + break; + + case "SOA": + dnsMessage = `NS-Name: ${dnsRes.nsname} | Hostmaster: ${dnsRes.hostmaster} | Serial: ${dnsRes.serial} | Refresh: ${dnsRes.refresh} | Retry: ${dnsRes.retry} | Expire: ${dnsRes.expire} | MinTTL: ${dnsRes.minttl}`; + conditionsResult = handleConditions({ record: dnsRes.nsname }); + break; + + case "SRV": + dnsMessage = dnsRes.map(record => `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight}`).join(" | "); + conditionsResult = dnsRes.some(record => handleConditions({ record: record.name })); + break; + } + + if (monitor.dns_last_result !== dnsMessage && dnsMessage !== undefined) { + await R.exec("UPDATE `monitor` SET dns_last_result = ? WHERE id = ? ", [ dnsMessage, monitor.id ]); + } + + heartbeat.msg = dnsMessage; + heartbeat.status = conditionsResult ? UP : DOWN; + } +} + +module.exports = { + DnsMonitorType, +}; diff --git a/server/monitor-types/mongodb.js b/server/monitor-types/mongodb.js new file mode 100644 index 0000000..73747db --- /dev/null +++ b/server/monitor-types/mongodb.js @@ -0,0 +1,63 @@ +const { MonitorType } = require("./monitor-type"); +const { UP } = require("../../src/util"); +const { MongoClient } = require("mongodb"); +const jsonata = require("jsonata"); + +class MongodbMonitorType extends MonitorType { + name = "mongodb"; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, _server) { + let command = { "ping": 1 }; + if (monitor.databaseQuery) { + command = JSON.parse(monitor.databaseQuery); + } + + let result = await this.runMongodbCommand(monitor.databaseConnectionString, command); + + if (result["ok"] !== 1) { + throw new Error("MongoDB command failed"); + } else { + heartbeat.msg = "Command executed successfully"; + } + + if (monitor.jsonPath) { + let expression = jsonata(monitor.jsonPath); + result = await expression.evaluate(result); + if (result) { + heartbeat.msg = "Command executed successfully and the jsonata expression produces a result."; + } else { + throw new Error("Queried value not found."); + } + } + + if (monitor.expectedValue) { + if (result.toString() === monitor.expectedValue) { + heartbeat.msg = "Command executed successfully and expected value was found"; + } else { + throw new Error("Query executed, but value is not equal to expected value, value was: [" + JSON.stringify(result) + "]"); + } + } + + heartbeat.status = UP; + } + + /** + * Connect to and run MongoDB command on a MongoDB database + * @param {string} connectionString The database connection string + * @param {object} command MongoDB command to run on the database + * @returns {Promise<(string[] | object[] | object)>} Response from server + */ + async runMongodbCommand(connectionString, command) { + let client = await MongoClient.connect(connectionString); + let result = await client.db().command(command); + await client.close(); + return result; + } +} + +module.exports = { + MongodbMonitorType, +}; diff --git a/server/monitor-types/monitor-type.js b/server/monitor-types/monitor-type.js new file mode 100644 index 0000000..8f3cbca --- /dev/null +++ b/server/monitor-types/monitor-type.js @@ -0,0 +1,31 @@ +class MonitorType { + name = undefined; + + /** + * Whether or not this type supports monitor conditions. Controls UI visibility in monitor form. + * @type {boolean} + */ + supportsConditions = false; + + /** + * Variables supported by this type. e.g. an HTTP type could have a "response_code" variable to test against. + * This property controls the choices displayed in the monitor edit form. + * @type {import("../monitor-conditions/variables").ConditionVariable[]} + */ + conditionVariables = []; + + /** + * Run the monitoring check on the given monitor + * @param {Monitor} monitor Monitor to check + * @param {Heartbeat} heartbeat Monitor heartbeat to update + * @param {UptimeKumaServer} server Uptime Kuma server + * @returns {Promise<void>} + */ + async check(monitor, heartbeat, server) { + throw new Error("You need to override check()"); + } +} + +module.exports = { + MonitorType, +}; diff --git a/server/monitor-types/mqtt.js b/server/monitor-types/mqtt.js new file mode 100644 index 0000000..ad734ce --- /dev/null +++ b/server/monitor-types/mqtt.js @@ -0,0 +1,117 @@ +const { MonitorType } = require("./monitor-type"); +const { log, UP } = require("../../src/util"); +const mqtt = require("mqtt"); +const jsonata = require("jsonata"); + +class MqttMonitorType extends MonitorType { + name = "mqtt"; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, server) { + const receivedMessage = await this.mqttAsync(monitor.hostname, monitor.mqttTopic, { + port: monitor.port, + username: monitor.mqttUsername, + password: monitor.mqttPassword, + interval: monitor.interval, + }); + + if (monitor.mqttCheckType == null || monitor.mqttCheckType === "") { + // use old default + monitor.mqttCheckType = "keyword"; + } + + if (monitor.mqttCheckType === "keyword") { + if (receivedMessage != null && receivedMessage.includes(monitor.mqttSuccessMessage)) { + heartbeat.msg = `Topic: ${monitor.mqttTopic}; Message: ${receivedMessage}`; + heartbeat.status = UP; + } else { + throw Error(`Message Mismatch - Topic: ${monitor.mqttTopic}; Message: ${receivedMessage}`); + } + } else if (monitor.mqttCheckType === "json-query") { + const parsedMessage = JSON.parse(receivedMessage); + + let expression = jsonata(monitor.jsonPath); + + let result = await expression.evaluate(parsedMessage); + + if (result?.toString() === monitor.expectedValue) { + heartbeat.msg = "Message received, expected value is found"; + heartbeat.status = UP; + } else { + throw new Error("Message received but value is not equal to expected value, value was: [" + result + "]"); + } + } else { + throw Error("Unknown MQTT Check Type"); + } + } + + /** + * Connect to MQTT Broker, subscribe to topic and receive message as String + * @param {string} hostname Hostname / address of machine to test + * @param {string} topic MQTT topic + * @param {object} options MQTT options. Contains port, username, + * password and interval (interval defaults to 20) + * @returns {Promise<string>} Received MQTT message + */ + mqttAsync(hostname, topic, options = {}) { + return new Promise((resolve, reject) => { + const { port, username, password, interval = 20 } = options; + + // Adds MQTT protocol to the hostname if not already present + if (!/^(?:http|mqtt|ws)s?:\/\//.test(hostname)) { + hostname = "mqtt://" + hostname; + } + + const timeoutID = setTimeout(() => { + log.debug("mqtt", "MQTT timeout triggered"); + client.end(); + reject(new Error("Timeout, Message not received")); + }, interval * 1000 * 0.8); + + const mqttUrl = `${hostname}:${port}`; + + log.debug("mqtt", `MQTT connecting to ${mqttUrl}`); + + let client = mqtt.connect(mqttUrl, { + username, + password, + clientId: "uptime-kuma_" + Math.random().toString(16).substr(2, 8) + }); + + client.on("connect", () => { + log.debug("mqtt", "MQTT connected"); + + try { + client.subscribe(topic, () => { + log.debug("mqtt", "MQTT subscribed to topic"); + }); + } catch (e) { + client.end(); + clearTimeout(timeoutID); + reject(new Error("Cannot subscribe topic")); + } + }); + + client.on("error", (error) => { + client.end(); + clearTimeout(timeoutID); + reject(error); + }); + + client.on("message", (messageTopic, message) => { + if (messageTopic === topic) { + client.end(); + clearTimeout(timeoutID); + resolve(message.toString("utf8")); + } + }); + + }); + } +} + +module.exports = { + MqttMonitorType, +}; diff --git a/server/monitor-types/rabbitmq.js b/server/monitor-types/rabbitmq.js new file mode 100644 index 0000000..165a0ed --- /dev/null +++ b/server/monitor-types/rabbitmq.js @@ -0,0 +1,67 @@ +const { MonitorType } = require("./monitor-type"); +const { log, UP, DOWN } = require("../../src/util"); +const { axiosAbortSignal } = require("../util-server"); +const axios = require("axios"); + +class RabbitMqMonitorType extends MonitorType { + name = "rabbitmq"; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, server) { + let baseUrls = []; + try { + baseUrls = JSON.parse(monitor.rabbitmqNodes); + } catch (error) { + throw new Error("Invalid RabbitMQ Nodes"); + } + + heartbeat.status = DOWN; + for (let baseUrl of baseUrls) { + try { + // Without a trailing slash, path in baseUrl will be removed. https://example.com/api -> https://example.com + if ( !baseUrl.endsWith("/") ) { + baseUrl += "/"; + } + const options = { + // Do not start with slash, it will strip the trailing slash from baseUrl + url: new URL("api/health/checks/alarms/", baseUrl).href, + method: "get", + timeout: monitor.timeout * 1000, + headers: { + "Accept": "application/json", + "Authorization": "Basic " + Buffer.from(`${monitor.rabbitmqUsername || ""}:${monitor.rabbitmqPassword || ""}`).toString("base64"), + }, + signal: axiosAbortSignal((monitor.timeout + 10) * 1000), + // Capture reason for 503 status + validateStatus: (status) => status === 200 || status === 503, + }; + log.debug("monitor", `[${monitor.name}] Axios Request: ${JSON.stringify(options)}`); + const res = await axios.request(options); + log.debug("monitor", `[${monitor.name}] Axios Response: status=${res.status} body=${JSON.stringify(res.data)}`); + if (res.status === 200) { + heartbeat.status = UP; + heartbeat.msg = "OK"; + break; + } else if (res.status === 503) { + heartbeat.msg = res.data.reason; + } else { + heartbeat.msg = `${res.status} - ${res.statusText}`; + } + } catch (error) { + if (axios.isCancel(error)) { + heartbeat.msg = "Request timed out"; + log.debug("monitor", `[${monitor.name}] Request timed out`); + } else { + log.debug("monitor", `[${monitor.name}] Axios Error: ${JSON.stringify(error.message)}`); + heartbeat.msg = error.message; + } + } + } + } +} + +module.exports = { + RabbitMqMonitorType, +}; diff --git a/server/monitor-types/real-browser-monitor-type.js b/server/monitor-types/real-browser-monitor-type.js new file mode 100644 index 0000000..f1219af --- /dev/null +++ b/server/monitor-types/real-browser-monitor-type.js @@ -0,0 +1,273 @@ +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"); +const { RemoteBrowser } = require("../remote-browser"); + +/** + * Cached instance of a browser + * @type {import ("playwright-core").Browser} + */ +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"); + + // Allow MS Edge + allowedList.push(process.env["ProgramFiles(x86)"] + "\\Microsoft\\Edge\\Application\\msedge.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", + "/snap/bin/chromium", // Ubuntu + ]; +} else if (process.platform === "darwin") { + allowedList = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ]; +} + +/** + * Is the executable path allowed? + * @param {string} executablePath Path to executable + * @returns {Promise<boolean>} The executable is allowed? + */ +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); +} + +/** + * Get the current instance of the browser. If there isn't one, create + * it. + * @returns {Promise<import ("playwright-core").Browser>} The browser + */ +async function getBrowser() { + if (browser && browser.isConnected()) { + return browser; + } else { + let executablePath = await Settings.get("chromeExecutable"); + + executablePath = await prepareChromeExecutable(executablePath); + + browser = await chromium.launch({ + //headless: false, + executablePath, + }); + + return browser; + } +} + +/** + * Get the current instance of the browser. If there isn't one, create it + * @param {integer} remoteBrowserID Path to executable + * @param {integer} userId User ID + * @returns {Promise<Browser>} The browser + */ +async function getRemoteBrowser(remoteBrowserID, userId) { + let remoteBrowser = await RemoteBrowser.get(remoteBrowserID, userId); + log.debug("MONITOR", `Using remote browser: ${remoteBrowser.name} (${remoteBrowser.id})`); + browser = await chromium.connect(remoteBrowser.url); + return browser; +} + +/** + * Prepare the chrome executable path + * @param {string} executablePath Path to chrome executable + * @returns {Promise<string>} Executable path + */ +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; +} + +/** + * Find the chrome executable + * @param {any[]} executables Executables to search through + * @returns {any} Executable + * @throws Could not find executable + */ +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."); +} + +/** + * Reset chrome + * @returns {Promise<void>} + */ +async function resetChrome() { + if (browser) { + await browser.close(); + browser = null; + } +} + +/** + * Test if the chrome executable is valid and return the version + * @param {string} executablePath Path to executable + * @returns {Promise<string>} Chrome version + */ +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); + } +} +// test remote browser +/** + * @param {string} remoteBrowserURL Remote Browser URL + * @returns {Promise<boolean>} Returns if connection worked + */ +async function testRemoteBrowser(remoteBrowserURL) { + try { + const browser = await chromium.connect(remoteBrowserURL); + browser.version(); + await browser.close(); + return true; + } catch (e) { + throw new Error(e.message); + } +} +class RealBrowserMonitorType extends MonitorType { + + name = "real-browser"; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, server) { + const browser = monitor.remote_browser ? await getRemoteBrowser(monitor.remote_browser, monitor.user_id) : 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, + testRemoteBrowser, +}; diff --git a/server/monitor-types/snmp.js b/server/monitor-types/snmp.js new file mode 100644 index 0000000..a1760fa --- /dev/null +++ b/server/monitor-types/snmp.js @@ -0,0 +1,63 @@ +const { MonitorType } = require("./monitor-type"); +const { UP, log, evaluateJsonQuery } = require("../../src/util"); +const snmp = require("net-snmp"); + +class SNMPMonitorType extends MonitorType { + name = "snmp"; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, _server) { + let session; + try { + const sessionOptions = { + port: monitor.port || "161", + retries: monitor.maxretries, + timeout: monitor.timeout * 1000, + version: snmp.Version[monitor.snmpVersion], + }; + session = snmp.createSession(monitor.hostname, monitor.radiusPassword, sessionOptions); + + // Handle errors during session creation + session.on("error", (error) => { + throw new Error(`Error creating SNMP session: ${error.message}`); + }); + + const varbinds = await new Promise((resolve, reject) => { + session.get([ monitor.snmpOid ], (error, varbinds) => { + error ? reject(error) : resolve(varbinds); + }); + }); + log.debug("monitor", `SNMP: Received varbinds (Type: ${snmp.ObjectType[varbinds[0].type]} Value: ${varbinds[0].value})`); + + if (varbinds.length === 0) { + throw new Error(`No varbinds returned from SNMP session (OID: ${monitor.snmpOid})`); + } + + if (varbinds[0].type === snmp.ObjectType.NoSuchInstance) { + throw new Error(`The SNMP query returned that no instance exists for OID ${monitor.snmpOid}`); + } + + // We restrict querying to one OID per monitor, therefore `varbinds[0]` will always contain the value we're interested in. + const value = varbinds[0].value; + + const { status, response } = await evaluateJsonQuery(value, monitor.jsonPath, monitor.jsonPathOperator, monitor.expectedValue); + + if (status) { + heartbeat.status = UP; + heartbeat.msg = `JSON query passes (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`; + } else { + throw new Error(`JSON query does not pass (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`); + } + } finally { + if (session) { + session.close(); + } + } + } +} + +module.exports = { + SNMPMonitorType, +}; diff --git a/server/monitor-types/tailscale-ping.js b/server/monitor-types/tailscale-ping.js new file mode 100644 index 0000000..8537651 --- /dev/null +++ b/server/monitor-types/tailscale-ping.js @@ -0,0 +1,77 @@ +const { MonitorType } = require("./monitor-type"); +const { UP } = require("../../src/util"); +const childProcessAsync = require("promisify-child-process"); + +class TailscalePing extends MonitorType { + name = "tailscale-ping"; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, _server) { + try { + let tailscaleOutput = await this.runTailscalePing(monitor.hostname, monitor.interval); + this.parseTailscaleOutput(tailscaleOutput, heartbeat); + } catch (err) { + // trigger log function somewhere to display a notification or alert to the user (but how?) + throw new Error(`Error checking Tailscale ping: ${err}`); + } + } + + /** + * Runs the Tailscale ping command to the given URL. + * @param {string} hostname The hostname to ping. + * @param {number} interval Interval to send ping + * @returns {Promise<string>} A Promise that resolves to the output of the Tailscale ping command + * @throws Will throw an error if the command execution encounters any error. + */ + async runTailscalePing(hostname, interval) { + let timeout = interval * 1000 * 0.8; + let res = await childProcessAsync.spawn("tailscale", [ "ping", "--c", "1", hostname ], { + timeout: timeout, + encoding: "utf8", + }); + if (res.stderr && res.stderr.toString()) { + throw new Error(`Error in output: ${res.stderr.toString()}`); + } + if (res.stdout && res.stdout.toString()) { + return res.stdout.toString(); + } else { + throw new Error("No output from Tailscale ping"); + } + } + + /** + * Parses the output of the Tailscale ping command to update the heartbeat. + * @param {string} tailscaleOutput The output of the Tailscale ping command. + * @param {object} heartbeat The heartbeat object to update. + * @returns {void} + * @throws Will throw an eror if the output contains any unexpected string. + */ + parseTailscaleOutput(tailscaleOutput, heartbeat) { + let lines = tailscaleOutput.split("\n"); + + for (let line of lines) { + if (line.includes("pong from")) { + heartbeat.status = UP; + let time = line.split(" in ")[1].split(" ")[0]; + heartbeat.ping = parseInt(time); + heartbeat.msg = "OK"; + break; + } else if (line.includes("timed out")) { + throw new Error(`Ping timed out: "${line}"`); + // Immediately throws upon "timed out" message, the server is expected to re-call the check function + } else if (line.includes("no matching peer")) { + throw new Error(`Nonexistant or inaccessible due to ACLs: "${line}"`); + } else if (line.includes("is local Tailscale IP")) { + throw new Error(`Tailscale only works if used on other machines: "${line}"`); + } else if (line !== "") { + throw new Error(`Unexpected output: "${line}"`); + } + } + } +} + +module.exports = { + TailscalePing, +}; diff --git a/server/notification-providers/46elks.js b/server/notification-providers/46elks.js new file mode 100644 index 0000000..4b15e9f --- /dev/null +++ b/server/notification-providers/46elks.js @@ -0,0 +1,35 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Elks extends NotificationProvider { + name = "Elks"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const url = "https://api.46elks.com/a1/sms"; + + try { + let data = new URLSearchParams(); + data.append("from", notification.elksFromNumber); + data.append("to", notification.elksToNumber ); + data.append("message", msg); + + const config = { + headers: { + "Authorization": "Basic " + Buffer.from(`${notification.elksUsername}:${notification.elksAuthToken}`).toString("base64") + } + }; + + await axios.post(url, data, config); + + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Elks; diff --git a/server/notification-providers/alerta.js b/server/notification-providers/alerta.js new file mode 100644 index 0000000..f9a273b --- /dev/null +++ b/server/notification-providers/alerta.js @@ -0,0 +1,68 @@ +const NotificationProvider = require("./notification-provider"); +const { DOWN, UP } = require("../../src/util"); +const axios = require("axios"); + +class Alerta extends NotificationProvider { + name = "alerta"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let config = { + headers: { + "Content-Type": "application/json;charset=UTF-8", + "Authorization": "Key " + notification.alertaApiKey, + } + }; + let data = { + environment: notification.alertaEnvironment, + severity: "critical", + correlate: [], + service: [ "UptimeKuma" ], + value: "Timeout", + tags: [ "uptimekuma" ], + attributes: {}, + origin: "uptimekuma", + type: "exceptionAlert", + }; + + if (heartbeatJSON == null) { + let postData = Object.assign({ + event: "msg", + text: msg, + group: "uptimekuma-msg", + resource: "Message", + }, data); + + await axios.post(notification.alertaApiEndpoint, postData, config); + } else { + let datadup = Object.assign( { + correlate: [ "service_up", "service_down" ], + event: monitorJSON["type"], + group: "uptimekuma-" + monitorJSON["type"], + resource: monitorJSON["name"], + }, data ); + + if (heartbeatJSON["status"] === DOWN) { + datadup.severity = notification.alertaAlertState; // critical + datadup.text = "Service " + monitorJSON["type"] + " is down."; + await axios.post(notification.alertaApiEndpoint, datadup, config); + } else if (heartbeatJSON["status"] === UP) { + datadup.severity = notification.alertaRecoverState; // cleaned + datadup.text = "Service " + monitorJSON["type"] + " is up."; + await axios.post(notification.alertaApiEndpoint, datadup, config); + } + } + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + + } +} + +module.exports = Alerta; diff --git a/server/notification-providers/alertnow.js b/server/notification-providers/alertnow.js new file mode 100644 index 0000000..4257ca9 --- /dev/null +++ b/server/notification-providers/alertnow.js @@ -0,0 +1,53 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { setting } = require("../util-server"); +const { getMonitorRelativeURL, UP, DOWN } = require("../../src/util"); + +class AlertNow extends NotificationProvider { + name = "AlertNow"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let textMsg = ""; + let status = "open"; + let eventType = "ERROR"; + let eventId = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + + if (heartbeatJSON && heartbeatJSON.status === UP) { + textMsg = `[${heartbeatJSON.name}] ✅ Application is back online`; + status = "close"; + eventType = "INFO"; + eventId += `_${heartbeatJSON.name.replace(/\s/g, "")}`; + } else if (heartbeatJSON && heartbeatJSON.status === DOWN) { + textMsg = `[${heartbeatJSON.name}] 🔴 Application went down`; + } + + textMsg += ` - ${msg}`; + + const baseURL = await setting("primaryBaseURL"); + if (baseURL && monitorJSON) { + textMsg += ` >> ${baseURL + getMonitorRelativeURL(monitorJSON.id)}`; + } + + const data = { + "summary": textMsg, + "status": status, + "event_type": eventType, + "event_id": eventId, + }; + + await axios.post(notification.alertNowWebhookURL, data); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + + } +} + +module.exports = AlertNow; diff --git a/server/notification-providers/aliyun-sms.js b/server/notification-providers/aliyun-sms.js new file mode 100644 index 0000000..ff38bd0 --- /dev/null +++ b/server/notification-providers/aliyun-sms.js @@ -0,0 +1,143 @@ +const NotificationProvider = require("./notification-provider"); +const { DOWN, UP } = require("../../src/util"); +const { default: axios } = require("axios"); +const Crypto = require("crypto"); +const qs = require("qs"); + +class AliyunSMS extends NotificationProvider { + name = "AliyunSMS"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + if (heartbeatJSON != null) { + let msgBody = JSON.stringify({ + name: monitorJSON["name"], + time: heartbeatJSON["time"], + status: this.statusToString(heartbeatJSON["status"]), + msg: heartbeatJSON["msg"], + }); + if (await this.sendSms(notification, msgBody)) { + return okMsg; + } + } else { + let msgBody = JSON.stringify({ + name: "", + time: "", + status: "", + msg: msg, + }); + if (await this.sendSms(notification, msgBody)) { + return okMsg; + } + } + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + + /** + * Send the SMS notification + * @param {BeanModel} notification Notification details + * @param {string} msgbody Message template + * @returns {Promise<boolean>} True if successful else false + */ + async sendSms(notification, msgbody) { + let params = { + PhoneNumbers: notification.phonenumber, + TemplateCode: notification.templateCode, + SignName: notification.signName, + TemplateParam: msgbody, + AccessKeyId: notification.accessKeyId, + Format: "JSON", + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + SignatureNonce: Math.random().toString(), + Timestamp: new Date().toISOString(), + Action: "SendSms", + Version: "2017-05-25", + }; + + params.Signature = this.sign(params, notification.secretAccessKey); + let config = { + method: "POST", + url: "http://dysmsapi.aliyuncs.com/", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + data: qs.stringify(params), + }; + + let result = await axios(config); + if (result.data.Message === "OK") { + return true; + } + + throw new Error(result.data.Message); + } + + /** + * Aliyun request sign + * @param {object} param Parameters object to sign + * @param {string} AccessKeySecret Secret key to sign parameters with + * @returns {string} Base64 encoded request + */ + sign(param, AccessKeySecret) { + let param2 = {}; + let data = []; + + let oa = Object.keys(param).sort(); + + for (let i = 0; i < oa.length; i++) { + let key = oa[i]; + param2[key] = param[key]; + } + + // Escape more characters than encodeURIComponent does. + // For generating Aliyun signature, all characters except A-Za-z0-9~-._ are encoded. + // See https://help.aliyun.com/document_detail/315526.html + // This encoding methods as known as RFC 3986 (https://tools.ietf.org/html/rfc3986) + let moreEscapesTable = function (m) { + return { + "!": "%21", + "*": "%2A", + "'": "%27", + "(": "%28", + ")": "%29" + }[m]; + }; + + for (let key in param2) { + let value = encodeURIComponent(param2[key]).replace(/[!*'()]/g, moreEscapesTable); + data.push(`${encodeURIComponent(key)}=${value}`); + } + + let StringToSign = `POST&${encodeURIComponent("/")}&${encodeURIComponent(data.join("&"))}`; + return Crypto + .createHmac("sha1", `${AccessKeySecret}&`) + .update(Buffer.from(StringToSign)) + .digest("base64"); + } + + /** + * Convert status constant to string + * @param {const} status The status constant + * @returns {string} Status + */ + statusToString(status) { + switch (status) { + case DOWN: + return "DOWN"; + case UP: + return "UP"; + default: + return status; + } + } +} + +module.exports = AliyunSMS; diff --git a/server/notification-providers/apprise.js b/server/notification-providers/apprise.js new file mode 100644 index 0000000..0f69821 --- /dev/null +++ b/server/notification-providers/apprise.js @@ -0,0 +1,37 @@ +const NotificationProvider = require("./notification-provider"); +const childProcessAsync = require("promisify-child-process"); + +class Apprise extends NotificationProvider { + name = "apprise"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + const args = [ "-vv", "-b", msg, notification.appriseURL ]; + if (notification.title) { + args.push("-t"); + args.push(notification.title); + } + const s = await childProcessAsync.spawn("apprise", args, { + encoding: "utf8", + }); + + const output = (s.stdout) ? s.stdout.toString() : "ERROR: maybe apprise not found"; + + if (output) { + + if (! output.includes("ERROR")) { + return okMsg; + } + + throw new Error(output); + } else { + return "No output from apprise"; + } + } +} + +module.exports = Apprise; diff --git a/server/notification-providers/bitrix24.js b/server/notification-providers/bitrix24.js new file mode 100644 index 0000000..ba12126 --- /dev/null +++ b/server/notification-providers/bitrix24.js @@ -0,0 +1,31 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { UP } = require("../../src/util"); + +class Bitrix24 extends NotificationProvider { + name = "Bitrix24"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + const params = { + user_id: notification.bitrix24UserID, + message: "[B]Uptime Kuma[/B]", + "ATTACH[COLOR]": (heartbeatJSON ?? {})["status"] === UP ? "#b73419" : "#67b518", + "ATTACH[BLOCKS][0][MESSAGE]": msg + }; + + await axios.get(`${notification.bitrix24WebhookURL}/im.notify.system.add.json`, { params }); + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Bitrix24; diff --git a/server/notification-providers/call-me-bot.js b/server/notification-providers/call-me-bot.js new file mode 100644 index 0000000..daa9ccd --- /dev/null +++ b/server/notification-providers/call-me-bot.js @@ -0,0 +1,23 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class CallMeBot extends NotificationProvider { + name = "CallMeBot"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + try { + const url = new URL(notification.callMeBotEndpoint); + url.searchParams.set("text", msg); + await axios.get(url.toString()); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = CallMeBot; diff --git a/server/notification-providers/cellsynt.js b/server/notification-providers/cellsynt.js new file mode 100644 index 0000000..e842237 --- /dev/null +++ b/server/notification-providers/cellsynt.js @@ -0,0 +1,39 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Cellsynt extends NotificationProvider { + name = "Cellsynt"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const data = { + // docs at https://www.cellsynt.com/en/sms/api-integration + params: { + "username": notification.cellsyntLogin, + "password": notification.cellsyntPassword, + "destination": notification.cellsyntDestination, + "text": msg.replace(/[^\x00-\x7F]/g, ""), + "originatortype": notification.cellsyntOriginatortype, + "originator": notification.cellsyntOriginator, + "allowconcat": notification.cellsyntAllowLongSMS ? 6 : 1 + } + }; + try { + const resp = await axios.post("https://se-1.cellsynt.net/sms.php", null, data); + if (resp.data == null ) { + throw new Error("Could not connect to Cellsynt, please try again."); + } else if (resp.data.includes("Error:")) { + resp.data = resp.data.replaceAll("Error:", ""); + throw new Error(resp.data); + } + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Cellsynt; diff --git a/server/notification-providers/clicksendsms.js b/server/notification-providers/clicksendsms.js new file mode 100644 index 0000000..c090b7f --- /dev/null +++ b/server/notification-providers/clicksendsms.js @@ -0,0 +1,45 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class ClickSendSMS extends NotificationProvider { + name = "clicksendsms"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const url = "https://rest.clicksend.com/v3/sms/send"; + + try { + let config = { + headers: { + "Content-Type": "application/json", + "Authorization": "Basic " + Buffer.from(notification.clicksendsmsLogin + ":" + notification.clicksendsmsPassword).toString("base64"), + "Accept": "text/json", + } + }; + let data = { + messages: [ + { + "body": msg.replace(/[^\x00-\x7F]/g, ""), + "to": notification.clicksendsmsToNumber, + "source": "uptime-kuma", + "from": notification.clicksendsmsSenderName, + } + ] + }; + let resp = await axios.post(url, data, config); + if (resp.data.data.messages[0].status !== "SUCCESS") { + let error = "Something gone wrong. Api returned " + resp.data.data.messages[0].status + "."; + this.throwGeneralAxiosError(error); + } + + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = ClickSendSMS; diff --git a/server/notification-providers/dingding.js b/server/notification-providers/dingding.js new file mode 100644 index 0000000..c66f270 --- /dev/null +++ b/server/notification-providers/dingding.js @@ -0,0 +1,101 @@ +const NotificationProvider = require("./notification-provider"); +const { DOWN, UP } = require("../../src/util"); +const { default: axios } = require("axios"); +const Crypto = require("crypto"); + +class DingDing extends NotificationProvider { + name = "DingDing"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + if (heartbeatJSON != null) { + let params = { + msgtype: "markdown", + markdown: { + title: `[${this.statusToString(heartbeatJSON["status"])}] ${monitorJSON["name"]}`, + text: `## [${this.statusToString(heartbeatJSON["status"])}] ${monitorJSON["name"]} \n> ${heartbeatJSON["msg"]}\n> Time (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`, + }, + "at": { + "isAtAll": notification.mentioning === "everyone" + } + }; + if (await this.sendToDingDing(notification, params)) { + return okMsg; + } + } else { + let params = { + msgtype: "text", + text: { + content: msg + } + }; + if (await this.sendToDingDing(notification, params)) { + return okMsg; + } + } + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + + /** + * Send message to DingDing + * @param {BeanModel} notification Notification to send + * @param {object} params Parameters of message + * @returns {Promise<boolean>} True if successful else false + */ + async sendToDingDing(notification, params) { + let timestamp = Date.now(); + + let config = { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + url: `${notification.webHookUrl}×tamp=${timestamp}&sign=${encodeURIComponent(this.sign(timestamp, notification.secretKey))}`, + data: JSON.stringify(params), + }; + + let result = await axios(config); + if (result.data.errmsg === "ok") { + return true; + } + throw new Error(result.data.errmsg); + } + + /** + * DingDing sign + * @param {Date} timestamp Timestamp of message + * @param {string} secretKey Secret key to sign data with + * @returns {string} Base64 encoded signature + */ + sign(timestamp, secretKey) { + return Crypto + .createHmac("sha256", Buffer.from(secretKey, "utf8")) + .update(Buffer.from(`${timestamp}\n${secretKey}`, "utf8")) + .digest("base64"); + } + + /** + * Convert status constant to string + * @param {const} status The status constant + * @returns {string} Status + */ + statusToString(status) { + switch (status) { + case DOWN: + return "DOWN"; + case UP: + return "UP"; + default: + return status; + } + } +} + +module.exports = DingDing; diff --git a/server/notification-providers/discord.js b/server/notification-providers/discord.js new file mode 100644 index 0000000..6a52f8f --- /dev/null +++ b/server/notification-providers/discord.js @@ -0,0 +1,120 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class Discord extends NotificationProvider { + name = "discord"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + const discordDisplayName = notification.discordUsername || "Uptime Kuma"; + const webhookUrl = new URL(notification.discordWebhookUrl); + if (notification.discordChannelType === "postToThread") { + webhookUrl.searchParams.append("thread_id", notification.threadId); + } + + // If heartbeatJSON is null, assume we're testing. + if (heartbeatJSON == null) { + let discordtestdata = { + username: discordDisplayName, + content: msg, + }; + + if (notification.discordChannelType === "createNewForumPost") { + discordtestdata.thread_name = notification.postName; + } + + await axios.post(webhookUrl.toString(), discordtestdata); + return okMsg; + } + + // If heartbeatJSON is not null, we go into the normal alerting loop. + if (heartbeatJSON["status"] === DOWN) { + let discorddowndata = { + username: discordDisplayName, + embeds: [{ + title: "❌ Your service " + monitorJSON["name"] + " went down. ❌", + color: 16711680, + timestamp: heartbeatJSON["time"], + fields: [ + { + name: "Service Name", + value: monitorJSON["name"], + }, + { + name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL", + value: this.extractAddress(monitorJSON), + }, + { + name: `Time (${heartbeatJSON["timezone"]})`, + value: heartbeatJSON["localDateTime"], + }, + { + name: "Error", + value: heartbeatJSON["msg"] == null ? "N/A" : heartbeatJSON["msg"], + }, + ], + }], + }; + if (notification.discordChannelType === "createNewForumPost") { + discorddowndata.thread_name = notification.postName; + } + if (notification.discordPrefixMessage) { + discorddowndata.content = notification.discordPrefixMessage; + } + + await axios.post(webhookUrl.toString(), discorddowndata); + return okMsg; + + } else if (heartbeatJSON["status"] === UP) { + let discordupdata = { + username: discordDisplayName, + embeds: [{ + title: "✅ Your service " + monitorJSON["name"] + " is up! ✅", + color: 65280, + timestamp: heartbeatJSON["time"], + fields: [ + { + name: "Service Name", + value: monitorJSON["name"], + }, + { + name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL", + value: this.extractAddress(monitorJSON), + }, + { + name: `Time (${heartbeatJSON["timezone"]})`, + value: heartbeatJSON["localDateTime"], + }, + { + name: "Ping", + value: heartbeatJSON["ping"] == null ? "N/A" : heartbeatJSON["ping"] + " ms", + }, + ], + }], + }; + + if (notification.discordChannelType === "createNewForumPost") { + discordupdata.thread_name = notification.postName; + } + + if (notification.discordPrefixMessage) { + discordupdata.content = notification.discordPrefixMessage; + } + + await axios.post(webhookUrl.toString(), discordupdata); + return okMsg; + } + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + +} + +module.exports = Discord; diff --git a/server/notification-providers/feishu.js b/server/notification-providers/feishu.js new file mode 100644 index 0000000..cd5331d --- /dev/null +++ b/server/notification-providers/feishu.js @@ -0,0 +1,104 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class Feishu extends NotificationProvider { + name = "Feishu"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + if (heartbeatJSON == null) { + let testdata = { + msg_type: "text", + content: { + text: msg, + }, + }; + await axios.post(notification.feishuWebHookUrl, testdata); + return okMsg; + } + + if (heartbeatJSON["status"] === DOWN) { + let downdata = { + msg_type: "interactive", + card: { + config: { + update_multi: false, + wide_screen_mode: true, + }, + header: { + title: { + tag: "plain_text", + content: "UptimeKuma Alert: [Down] " + monitorJSON["name"], + }, + template: "red", + }, + elements: [ + { + tag: "div", + text: { + tag: "lark_md", + content: getContent(heartbeatJSON), + }, + } + ] + } + }; + await axios.post(notification.feishuWebHookUrl, downdata); + return okMsg; + } + + if (heartbeatJSON["status"] === UP) { + let updata = { + msg_type: "interactive", + card: { + config: { + update_multi: false, + wide_screen_mode: true, + }, + header: { + title: { + tag: "plain_text", + content: "UptimeKuma Alert: [UP] " + monitorJSON["name"], + }, + template: "green", + }, + elements: [ + { + tag: "div", + text: { + tag: "lark_md", + content: getContent(heartbeatJSON), + }, + }, + ] + } + }; + await axios.post(notification.feishuWebHookUrl, updata); + return okMsg; + } + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +/** + * Get content + * @param {?object} heartbeatJSON Heartbeat details (For Up/Down only) + * @returns {string} Return Successful Message + */ +function getContent(heartbeatJSON) { + return [ + "**Message**: " + heartbeatJSON["msg"], + "**Ping**: " + (heartbeatJSON["ping"] == null ? "N/A" : heartbeatJSON["ping"] + " ms"), + `**Time (${heartbeatJSON["timezone"]})**: ${heartbeatJSON["localDateTime"]}` + ].join("\n"); +} + +module.exports = Feishu; diff --git a/server/notification-providers/flashduty.js b/server/notification-providers/flashduty.js new file mode 100644 index 0000000..c340ed0 --- /dev/null +++ b/server/notification-providers/flashduty.js @@ -0,0 +1,108 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util"); +const { setting } = require("../util-server"); +const successMessage = "Sent Successfully."; + +class FlashDuty extends NotificationProvider { + name = "FlashDuty"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + try { + if (heartbeatJSON == null) { + const title = "Uptime Kuma Alert"; + const monitor = { + type: "ping", + url: msg, + name: "https://flashcat.cloud" + }; + return this.postNotification(notification, title, msg, monitor); + } + + if (heartbeatJSON.status === UP) { + const title = "Uptime Kuma Monitor ✅ Up"; + + return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, "Ok"); + } + + if (heartbeatJSON.status === DOWN) { + const title = "Uptime Kuma Monitor 🔴 Down"; + return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, notification.flashdutySeverity); + } + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + + /** + * Generate a monitor url from the monitors infomation + * @param {object} monitorInfo Monitor details + * @returns {string|undefined} Monitor URL + */ + genMonitorUrl(monitorInfo) { + if (monitorInfo.type === "port" && monitorInfo.port) { + return monitorInfo.hostname + ":" + monitorInfo.port; + } + if (monitorInfo.hostname != null) { + return monitorInfo.hostname; + } + return monitorInfo.url; + } + + /** + * Send the message + * @param {BeanModel} notification Message title + * @param {string} title Message + * @param {string} body Message + * @param {object} monitorInfo Monitor details + * @param {string} eventStatus Monitor status (Info, Warning, Critical, Ok) + * @returns {string} Success message + */ + async postNotification(notification, title, body, monitorInfo, eventStatus) { + let labels = { + resource: this.genMonitorUrl(monitorInfo), + check: monitorInfo.name, + }; + if (monitorInfo.tags && monitorInfo.tags.length > 0) { + for (let tag of monitorInfo.tags) { + labels[tag.name] = tag.value; + } + } + const options = { + method: "POST", + url: "https://api.flashcat.cloud/event/push/alert/standard?integration_key=" + notification.flashdutyIntegrationKey, + headers: { "Content-Type": "application/json" }, + data: { + description: `[${title}] [${monitorInfo.name}] ${body}`, + title, + event_status: eventStatus || "Info", + alert_key: String(monitorInfo.id) || Math.random().toString(36).substring(7), + labels, + } + }; + + const baseURL = await setting("primaryBaseURL"); + if (baseURL && monitorInfo) { + options.client = "Uptime Kuma"; + options.client_url = baseURL + getMonitorRelativeURL(monitorInfo.id); + } + + let result = await axios.request(options); + if (result.status == null) { + throw new Error("FlashDuty notification failed with invalid response!"); + } + if (result.status < 200 || result.status >= 300) { + throw new Error("FlashDuty notification failed with status code " + result.status); + } + if (result.statusText != null) { + return "FlashDuty notification succeed: " + result.statusText; + } + + return successMessage; + } +} + +module.exports = FlashDuty; diff --git a/server/notification-providers/freemobile.js b/server/notification-providers/freemobile.js new file mode 100644 index 0000000..4de45ac --- /dev/null +++ b/server/notification-providers/freemobile.js @@ -0,0 +1,27 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class FreeMobile extends NotificationProvider { + name = "FreeMobile"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + await axios.post(`https://smsapi.free-mobile.fr/sendmsg?msg=${encodeURIComponent(msg.replace("🔴", "⛔️"))}`, { + "user": notification.freemobileUser, + "pass": notification.freemobilePass, + }); + + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = FreeMobile; diff --git a/server/notification-providers/goalert.js b/server/notification-providers/goalert.js new file mode 100644 index 0000000..847c6a0 --- /dev/null +++ b/server/notification-providers/goalert.js @@ -0,0 +1,36 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { UP } = require("../../src/util"); + +class GoAlert extends NotificationProvider { + name = "GoAlert"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let data = { + summary: msg, + }; + if (heartbeatJSON != null && heartbeatJSON["status"] === UP) { + data["action"] = "close"; + } + let headers = { + "Content-Type": "multipart/form-data", + }; + let config = { + headers: headers + }; + await axios.post(`${notification.goAlertBaseURL}/api/v2/generic/incoming?token=${notification.goAlertToken}`, data, config); + return okMsg; + } catch (error) { + let msg = (error.response.data) ? error.response.data : "Error without response"; + throw new Error(msg); + } + } +} + +module.exports = GoAlert; diff --git a/server/notification-providers/google-chat.js b/server/notification-providers/google-chat.js new file mode 100644 index 0000000..0b72fea --- /dev/null +++ b/server/notification-providers/google-chat.js @@ -0,0 +1,94 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { setting } = require("../util-server"); +const { getMonitorRelativeURL, UP } = require("../../src/util"); + +class GoogleChat extends NotificationProvider { + name = "GoogleChat"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + // Google Chat message formatting: https://developers.google.com/chat/api/guides/message-formats/basic + + let chatHeader = { + title: "Uptime Kuma Alert", + }; + + if (monitorJSON && heartbeatJSON) { + chatHeader["title"] = + heartbeatJSON["status"] === UP + ? `✅ ${monitorJSON["name"]} is back online` + : `🔴 ${monitorJSON["name"]} went down`; + } + + // always show msg + let sectionWidgets = [ + { + textParagraph: { + text: `<b>Message:</b>\n${msg}`, + }, + }, + ]; + + // add time if available + if (heartbeatJSON) { + sectionWidgets.push({ + textParagraph: { + text: `<b>Time (${heartbeatJSON["timezone"]}):</b>\n${heartbeatJSON["localDateTime"]}`, + }, + }); + } + + // add button for monitor link if available + const baseURL = await setting("primaryBaseURL"); + if (baseURL) { + const urlPath = monitorJSON ? getMonitorRelativeURL(monitorJSON.id) : "/"; + sectionWidgets.push({ + buttonList: { + buttons: [ + { + text: "Visit Uptime Kuma", + onClick: { + openLink: { + url: baseURL + urlPath, + }, + }, + }, + ], + }, + }); + } + + let chatSections = [ + { + widgets: sectionWidgets, + }, + ]; + + // construct json data + let data = { + cardsV2: [ + { + card: { + header: chatHeader, + sections: chatSections, + }, + }, + ], + }; + + await axios.post(notification.googleChatWebhookURL, data); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + + } +} + +module.exports = GoogleChat; diff --git a/server/notification-providers/gorush.js b/server/notification-providers/gorush.js new file mode 100644 index 0000000..ba9d470 --- /dev/null +++ b/server/notification-providers/gorush.js @@ -0,0 +1,44 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Gorush extends NotificationProvider { + name = "gorush"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + let platformMapping = { + "ios": 1, + "android": 2, + "huawei": 3, + }; + + try { + let data = { + "notifications": [ + { + "tokens": [ notification.gorushDeviceToken ], + "platform": platformMapping[notification.gorushPlatform], + "message": msg, + // Optional + "title": notification.gorushTitle, + "priority": notification.gorushPriority, + "retry": parseInt(notification.gorushRetry) || 0, + "topic": notification.gorushTopic, + } + ] + }; + let config = {}; + + await axios.post(`${notification.gorushServerURL}/api/push`, data, config); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Gorush; diff --git a/server/notification-providers/gotify.js b/server/notification-providers/gotify.js new file mode 100644 index 0000000..a52ef51 --- /dev/null +++ b/server/notification-providers/gotify.js @@ -0,0 +1,31 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Gotify extends NotificationProvider { + name = "gotify"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + if (notification.gotifyserverurl && notification.gotifyserverurl.endsWith("/")) { + notification.gotifyserverurl = notification.gotifyserverurl.slice(0, -1); + } + await axios.post(`${notification.gotifyserverurl}/message?token=${notification.gotifyapplicationToken}`, { + "message": msg, + "priority": notification.gotifyPriority || 8, + "title": "Uptime-Kuma", + }); + + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Gotify; diff --git a/server/notification-providers/grafana-oncall.js b/server/notification-providers/grafana-oncall.js new file mode 100644 index 0000000..e93c77c --- /dev/null +++ b/server/notification-providers/grafana-oncall.js @@ -0,0 +1,51 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class GrafanaOncall extends NotificationProvider { + name = "GrafanaOncall"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + if (!notification.GrafanaOncallURL) { + throw new Error("GrafanaOncallURL cannot be empty"); + } + + try { + if (heartbeatJSON === null) { + let grafanaupdata = { + title: "General notification", + message: msg, + state: "alerting", + }; + await axios.post(notification.GrafanaOncallURL, grafanaupdata); + return okMsg; + } else if (heartbeatJSON["status"] === DOWN) { + let grafanadowndata = { + title: monitorJSON["name"] + " is down", + message: heartbeatJSON["msg"], + state: "alerting", + }; + await axios.post(notification.GrafanaOncallURL, grafanadowndata); + return okMsg; + } else if (heartbeatJSON["status"] === UP) { + let grafanaupdata = { + title: monitorJSON["name"] + " is up", + message: heartbeatJSON["msg"], + state: "ok", + }; + await axios.post(notification.GrafanaOncallURL, grafanaupdata); + return okMsg; + } + } catch (error) { + this.throwGeneralAxiosError(error); + } + + } +} + +module.exports = GrafanaOncall; diff --git a/server/notification-providers/gtx-messaging.js b/server/notification-providers/gtx-messaging.js new file mode 100644 index 0000000..1ff97d1 --- /dev/null +++ b/server/notification-providers/gtx-messaging.js @@ -0,0 +1,33 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class GtxMessaging extends NotificationProvider { + name = "gtxmessaging"; + + /** + * @inheritDoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + // The UP/DOWN symbols will be replaced with `???` by gtx-messaging + const text = msg.replaceAll("🔴 ", "").replaceAll("✅ ", ""); + + try { + const data = new URLSearchParams(); + data.append("from", notification.gtxMessagingFrom.trim()); + data.append("to", notification.gtxMessagingTo.trim()); + data.append("text", text); + + const url = `https://rest.gtx-messaging.net/smsc/sendsms/${notification.gtxMessagingApiKey}/json`; + + await axios.post(url, data); + + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = GtxMessaging; diff --git a/server/notification-providers/heii-oncall.js b/server/notification-providers/heii-oncall.js new file mode 100644 index 0000000..20b53e6 --- /dev/null +++ b/server/notification-providers/heii-oncall.js @@ -0,0 +1,52 @@ +const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util"); +const { setting } = require("../util-server"); + +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +class HeiiOnCall extends NotificationProvider { + name = "HeiiOnCall"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const payload = heartbeatJSON || {}; + + const baseURL = await setting("primaryBaseURL"); + if (baseURL && monitorJSON) { + payload["url"] = baseURL + getMonitorRelativeURL(monitorJSON.id); + } + + const config = { + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: "Bearer " + notification.heiiOnCallApiKey, + }, + }; + const heiiUrl = `https://heiioncall.com/triggers/${notification.heiiOnCallTriggerId}/`; + // docs https://heiioncall.com/docs#manual-triggers + try { + if (!heartbeatJSON) { + // Testing or general notification like certificate expiry + payload["msg"] = msg; + await axios.post(heiiUrl + "alert", payload, config); + return okMsg; + } + + if (heartbeatJSON.status === DOWN) { + await axios.post(heiiUrl + "alert", payload, config); + return okMsg; + } + if (heartbeatJSON.status === UP) { + await axios.post(heiiUrl + "resolve", payload, config); + return okMsg; + } + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = HeiiOnCall; diff --git a/server/notification-providers/home-assistant.js b/server/notification-providers/home-assistant.js new file mode 100644 index 0000000..4536b2a --- /dev/null +++ b/server/notification-providers/home-assistant.js @@ -0,0 +1,45 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +const defaultNotificationService = "notify"; + +class HomeAssistant extends NotificationProvider { + name = "HomeAssistant"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + const notificationService = notification?.notificationService || defaultNotificationService; + + try { + await axios.post( + `${notification.homeAssistantUrl.trim().replace(/\/*$/, "")}/api/services/notify/${notificationService}`, + { + title: "Uptime Kuma", + message: msg, + ...(notificationService !== "persistent_notification" && { data: { + name: monitorJSON?.name, + status: heartbeatJSON?.status, + channel: "Uptime Kuma", + icon_url: "https://github.com/louislam/uptime-kuma/blob/master/public/icon.png?raw=true", + } }), + }, + { + headers: { + Authorization: `Bearer ${notification.longLivedAccessToken}`, + "Content-Type": "application/json", + }, + } + ); + + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = HomeAssistant; diff --git a/server/notification-providers/keep.js b/server/notification-providers/keep.js new file mode 100644 index 0000000..aa65a86 --- /dev/null +++ b/server/notification-providers/keep.js @@ -0,0 +1,42 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Keep extends NotificationProvider { + name = "Keep"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let data = { + heartbeat: heartbeatJSON, + monitor: monitorJSON, + msg, + }; + let config = { + headers: { + "x-api-key": notification.webhookAPIKey, + "content-type": "application/json", + }, + }; + + let url = notification.webhookURL; + + if (url.endsWith("/")) { + url = url.slice(0, -1); + } + + let webhookURL = url + "/alerts/event/uptimekuma"; + + await axios.post(webhookURL, data, config); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Keep; diff --git a/server/notification-providers/kook.js b/server/notification-providers/kook.js new file mode 100644 index 0000000..dab1951 --- /dev/null +++ b/server/notification-providers/kook.js @@ -0,0 +1,34 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Kook extends NotificationProvider { + name = "Kook"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const url = "https://www.kookapp.cn/api/v3/message/create"; + + let data = { + target_id: notification.kookGuildID, + content: msg, + }; + let config = { + headers: { + "Authorization": "Bot " + notification.kookBotToken, + "Content-Type": "application/json", + }, + }; + try { + await axios.post(url, data, config); + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Kook; diff --git a/server/notification-providers/line.js b/server/notification-providers/line.js new file mode 100644 index 0000000..57dc87e --- /dev/null +++ b/server/notification-providers/line.js @@ -0,0 +1,69 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class Line extends NotificationProvider { + name = "line"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const url = "https://api.line.me/v2/bot/message/push"; + + try { + let config = { + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + notification.lineChannelAccessToken + } + }; + if (heartbeatJSON == null) { + let testMessage = { + "to": notification.lineUserID, + "messages": [ + { + "type": "text", + "text": "Test Successful!" + } + ] + }; + await axios.post(url, testMessage, config); + } else if (heartbeatJSON["status"] === DOWN) { + let downMessage = { + "to": notification.lineUserID, + "messages": [ + { + "type": "text", + "text": "UptimeKuma Alert: [🔴 Down]\n" + + "Name: " + monitorJSON["name"] + " \n" + + heartbeatJSON["msg"] + + `\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}` + } + ] + }; + await axios.post(url, downMessage, config); + } else if (heartbeatJSON["status"] === UP) { + let upMessage = { + "to": notification.lineUserID, + "messages": [ + { + "type": "text", + "text": "UptimeKuma Alert: [✅ Up]\n" + + "Name: " + monitorJSON["name"] + " \n" + + heartbeatJSON["msg"] + + `\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}` + } + ] + }; + await axios.post(url, upMessage, config); + } + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Line; diff --git a/server/notification-providers/linenotify.js b/server/notification-providers/linenotify.js new file mode 100644 index 0000000..2622e3f --- /dev/null +++ b/server/notification-providers/linenotify.js @@ -0,0 +1,52 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const qs = require("qs"); +const { DOWN, UP } = require("../../src/util"); + +class LineNotify extends NotificationProvider { + name = "LineNotify"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const url = "https://notify-api.line.me/api/notify"; + + try { + let config = { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Bearer " + notification.lineNotifyAccessToken + } + }; + if (heartbeatJSON == null) { + let testMessage = { + "message": msg, + }; + await axios.post(url, qs.stringify(testMessage), config); + } else if (heartbeatJSON["status"] === DOWN) { + let downMessage = { + "message": "\n[🔴 Down]\n" + + "Name: " + monitorJSON["name"] + " \n" + + heartbeatJSON["msg"] + "\n" + + `Time (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}` + }; + await axios.post(url, qs.stringify(downMessage), config); + } else if (heartbeatJSON["status"] === UP) { + let upMessage = { + "message": "\n[✅ Up]\n" + + "Name: " + monitorJSON["name"] + " \n" + + heartbeatJSON["msg"] + "\n" + + `Time (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}` + }; + await axios.post(url, qs.stringify(upMessage), config); + } + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = LineNotify; diff --git a/server/notification-providers/lunasea.js b/server/notification-providers/lunasea.js new file mode 100644 index 0000000..787a704 --- /dev/null +++ b/server/notification-providers/lunasea.js @@ -0,0 +1,67 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class LunaSea extends NotificationProvider { + name = "lunasea"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const url = "https://notify.lunasea.app/v1"; + + try { + const target = this.getTarget(notification); + if (heartbeatJSON == null) { + let testdata = { + "title": "Uptime Kuma Alert", + "body": msg, + }; + await axios.post(`${url}/custom/${target}`, testdata); + return okMsg; + } + + if (heartbeatJSON["status"] === DOWN) { + let downdata = { + "title": "UptimeKuma Alert: " + monitorJSON["name"], + "body": "[🔴 Down] " + + heartbeatJSON["msg"] + + `\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}` + }; + await axios.post(`${url}/custom/${target}`, downdata); + return okMsg; + } + + if (heartbeatJSON["status"] === UP) { + let updata = { + "title": "UptimeKuma Alert: " + monitorJSON["name"], + "body": "[✅ Up] " + + heartbeatJSON["msg"] + + `\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}` + }; + await axios.post(`${url}/custom/${target}`, updata); + return okMsg; + } + + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + + /** + * Generates the lunasea target to send the notification to + * @param {BeanModel} notification Notification details + * @returns {string} The target to send the notification to + */ + getTarget(notification) { + if (notification.lunaseaTarget === "user") { + return "user/" + notification.lunaseaUserID; + } + return "device/" + notification.lunaseaDevice; + + } +} + +module.exports = LunaSea; diff --git a/server/notification-providers/matrix.js b/server/notification-providers/matrix.js new file mode 100644 index 0000000..805a494 --- /dev/null +++ b/server/notification-providers/matrix.js @@ -0,0 +1,48 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const Crypto = require("crypto"); +const { log } = require("../../src/util"); + +class Matrix extends NotificationProvider { + name = "matrix"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + const size = 20; + const randomString = encodeURIComponent( + Crypto + .randomBytes(size) + .toString("base64") + .slice(0, size) + ); + + log.debug("notification", "Random String: " + randomString); + + const roomId = encodeURIComponent(notification.internalRoomId); + + log.debug("notification", "Matrix Room ID: " + roomId); + + try { + let config = { + headers: { + "Authorization": `Bearer ${notification.accessToken}`, + } + }; + let data = { + "msgtype": "m.text", + "body": msg + }; + + await axios.put(`${notification.homeserverUrl}/_matrix/client/r0/rooms/${roomId}/send/m.room.message/${randomString}`, data, config); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Matrix; diff --git a/server/notification-providers/mattermost.js b/server/notification-providers/mattermost.js new file mode 100644 index 0000000..9946d02 --- /dev/null +++ b/server/notification-providers/mattermost.js @@ -0,0 +1,110 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class Mattermost extends NotificationProvider { + name = "mattermost"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + const mattermostUserName = notification.mattermostusername || "Uptime Kuma"; + // If heartbeatJSON is null, assume non monitoring notification (Certificate warning) or testing. + if (heartbeatJSON == null) { + let mattermostTestData = { + username: mattermostUserName, + text: msg, + }; + await axios.post(notification.mattermostWebhookUrl, mattermostTestData); + return okMsg; + } + + let mattermostChannel; + + if (typeof notification.mattermostchannel === "string") { + mattermostChannel = notification.mattermostchannel.toLowerCase(); + } + + const mattermostIconEmoji = notification.mattermosticonemo; + let mattermostIconEmojiOnline = ""; + let mattermostIconEmojiOffline = ""; + + 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.pathName + + " service went " + + statusText, + color: color, + title: + monitorJSON.pathName + + " 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); + } + + } +} + +module.exports = Mattermost; diff --git a/server/notification-providers/nostr.js b/server/notification-providers/nostr.js new file mode 100644 index 0000000..8784738 --- /dev/null +++ b/server/notification-providers/nostr.js @@ -0,0 +1,122 @@ +const NotificationProvider = require("./notification-provider"); +const { + relayInit, + getPublicKey, + getEventHash, + getSignature, + nip04, + nip19 +} = require("nostr-tools"); + +// polyfills for node versions +const semver = require("semver"); +const nodeVersion = process.version; +if (semver.lt(nodeVersion, "20.0.0")) { + // polyfills for node 18 + global.crypto = require("crypto"); + global.WebSocket = require("isomorphic-ws"); +} else { + // polyfills for node 20 + global.WebSocket = require("isomorphic-ws"); +} + +class Nostr extends NotificationProvider { + name = "nostr"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + // All DMs should have same timestamp + const createdAt = Math.floor(Date.now() / 1000); + + const senderPrivateKey = await this.getPrivateKey(notification.sender); + const senderPublicKey = getPublicKey(senderPrivateKey); + const recipientsPublicKeys = await this.getPublicKeys(notification.recipients); + + // Create NIP-04 encrypted direct message event for each recipient + const events = []; + for (const recipientPublicKey of recipientsPublicKeys) { + const ciphertext = await nip04.encrypt(senderPrivateKey, recipientPublicKey, msg); + let event = { + kind: 4, + pubkey: senderPublicKey, + created_at: createdAt, + tags: [[ "p", recipientPublicKey ]], + content: ciphertext, + }; + event.id = getEventHash(event); + event.sig = getSignature(event, senderPrivateKey); + events.push(event); + } + + // Publish events to each relay + const relays = notification.relays.split("\n"); + let successfulRelays = 0; + + // Connect to each relay + for (const relayUrl of relays) { + const relay = relayInit(relayUrl); + try { + await relay.connect(); + successfulRelays++; + + // Publish events + for (const event of events) { + relay.publish(event); + } + } catch (error) { + continue; + } finally { + relay.close(); + } + } + + // Report success or failure + if (successfulRelays === 0) { + throw Error("Failed to connect to any relays."); + } + return `${successfulRelays}/${relays.length} relays connected.`; + } + + /** + * Get the private key for the sender + * @param {string} sender Sender to retrieve key for + * @returns {nip19.DecodeResult} Private key + */ + async getPrivateKey(sender) { + try { + const senderDecodeResult = await nip19.decode(sender); + const { data } = senderDecodeResult; + return data; + } catch (error) { + throw new Error(`Failed to get private key: ${error.message}`); + } + } + + /** + * Get public keys for recipients + * @param {string} recipients Newline delimited list of recipients + * @returns {Promise<nip19.DecodeResult[]>} Public keys + */ + async getPublicKeys(recipients) { + const recipientsList = recipients.split("\n"); + const publicKeys = []; + for (const recipient of recipientsList) { + try { + const recipientDecodeResult = await nip19.decode(recipient); + const { type, data } = recipientDecodeResult; + if (type === "npub") { + publicKeys.push(data); + } else { + throw new Error("not an npub"); + } + } catch (error) { + throw new Error(`Error decoding recipient: ${error}`); + } + } + return publicKeys; + } +} + +module.exports = Nostr; diff --git a/server/notification-providers/notification-provider.js b/server/notification-providers/notification-provider.js new file mode 100644 index 0000000..b9fb3d8 --- /dev/null +++ b/server/notification-providers/notification-provider.js @@ -0,0 +1,73 @@ +class NotificationProvider { + + /** + * Notification Provider Name + * @type {string} + */ + name = undefined; + + /** + * Send a notification + * @param {BeanModel} notification Notification to send + * @param {string} msg General Message + * @param {?object} monitorJSON Monitor details (For Up/Down only) + * @param {?object} heartbeatJSON Heartbeat details (For Up/Down only) + * @returns {Promise<string>} Return Successful Message + * @throws Error with fail msg + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + throw new Error("Have to override Notification.send(...)"); + } + + /** + * Extracts the address from a monitor JSON object based on its type. + * @param {?object} monitorJSON Monitor details (For Up/Down only) + * @returns {string} The extracted address based on the monitor type. + */ + extractAddress(monitorJSON) { + if (!monitorJSON) { + return ""; + } + switch (monitorJSON["type"]) { + case "push": + return "Heartbeat"; + case "ping": + return monitorJSON["hostname"]; + case "port": + case "dns": + case "gamedig": + case "steam": + if (monitorJSON["port"]) { + return monitorJSON["hostname"] + ":" + monitorJSON["port"]; + } + return monitorJSON["hostname"]; + default: + if (![ "https://", "http://", "" ].includes(monitorJSON["url"])) { + return monitorJSON["url"]; + } + return ""; + } + } + + /** + * Throws an error + * @param {any} error The error to throw + * @returns {void} + * @throws {any} The error specified + */ + throwGeneralAxiosError(error) { + let msg = "Error: " + error + " "; + + if (error.response && error.response.data) { + if (typeof error.response.data === "string") { + msg += error.response.data; + } else { + msg += JSON.stringify(error.response.data); + } + } + + throw new Error(msg); + } +} + +module.exports = NotificationProvider; diff --git a/server/notification-providers/ntfy.js b/server/notification-providers/ntfy.js new file mode 100644 index 0000000..ad1d39f --- /dev/null +++ b/server/notification-providers/ntfy.js @@ -0,0 +1,83 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class Ntfy extends NotificationProvider { + name = "ntfy"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let headers = {}; + 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": heartbeatJSON.msg, + "priority": priority, + "title": monitorJSON.name + " " + status + " [Uptime-Kuma]", + "tags": tags, + }; + + if (monitorJSON.url && monitorJSON.url !== "https://") { + data.actions = [ + { + "action": "view", + "label": "Open " + monitorJSON.name, + "url": monitorJSON.url, + }, + ]; + } + + if (notification.ntfyIcon) { + data.icon = notification.ntfyIcon; + } + + await axios.post(notification.ntfyserverurl, data, { headers: headers }); + + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Ntfy; diff --git a/server/notification-providers/octopush.js b/server/notification-providers/octopush.js new file mode 100644 index 0000000..7576e0a --- /dev/null +++ b/server/notification-providers/octopush.js @@ -0,0 +1,76 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Octopush extends NotificationProvider { + name = "octopush"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const urlV2 = "https://api.octopush.com/v1/public/sms-campaign/send"; + const urlV1 = "https://www.octopush-dm.com/api/sms/json"; + + try { + // Default - V2 + if (notification.octopushVersion === "2" || !notification.octopushVersion) { + let config = { + headers: { + "api-key": notification.octopushAPIKey, + "api-login": notification.octopushLogin, + "cache-control": "no-cache" + } + }; + let data = { + "recipients": [ + { + "phone_number": notification.octopushPhoneNumber + } + ], + //octopush not supporting non ascii char + "text": msg.replace(/[^\x00-\x7F]/g, ""), + "type": notification.octopushSMSType, + "purpose": "alert", + "sender": notification.octopushSenderName + }; + await axios.post(urlV2, data, config); + } else if (notification.octopushVersion === "1") { + let data = { + "user_login": notification.octopushDMLogin, + "api_key": notification.octopushDMAPIKey, + "sms_recipients": notification.octopushDMPhoneNumber, + "sms_sender": notification.octopushDMSenderName, + "sms_type": (notification.octopushDMSMSType === "sms_premium") ? "FR" : "XXX", + "transactional": "1", + //octopush not supporting non ascii char + "sms_text": msg.replace(/[^\x00-\x7F]/g, ""), + }; + + let config = { + headers: { + "cache-control": "no-cache" + }, + params: data + }; + + // V1 API returns 200 even on error so we must check + // response data + let response = await axios.post(urlV1, {}, config); + if ("error_code" in response.data) { + if (response.data.error_code !== "000") { + this.throwGeneralAxiosError(`Octopush error ${JSON.stringify(response.data)}`); + } + } + } else { + throw new Error("Unknown Octopush version!"); + } + + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Octopush; diff --git a/server/notification-providers/onebot.js b/server/notification-providers/onebot.js new file mode 100644 index 0000000..b04794d --- /dev/null +++ b/server/notification-providers/onebot.js @@ -0,0 +1,48 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class OneBot extends NotificationProvider { + name = "OneBot"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let url = notification.httpAddr; + if (!url.startsWith("http")) { + url = "http://" + url; + } + if (!url.endsWith("/")) { + url += "/"; + } + url += "send_msg"; + let config = { + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + notification.accessToken, + } + }; + let pushText = "UptimeKuma Alert: " + msg; + let data = { + "auto_escape": true, + "message": pushText, + }; + if (notification.msgType === "group") { + data["message_type"] = "group"; + data["group_id"] = notification.recieverId; + } else { + data["message_type"] = "private"; + data["user_id"] = notification.recieverId; + } + await axios.post(url, data, config); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = OneBot; diff --git a/server/notification-providers/onesender.js b/server/notification-providers/onesender.js new file mode 100644 index 0000000..4a33931 --- /dev/null +++ b/server/notification-providers/onesender.js @@ -0,0 +1,47 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Onesender extends NotificationProvider { + name = "Onesender"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let data = { + heartbeat: heartbeatJSON, + monitor: monitorJSON, + msg, + to: notification.onesenderReceiver, + type: "text", + recipient_type: "individual", + text: { + body: msg + } + }; + if (notification.onesenderTypeReceiver === "private") { + data.to = notification.onesenderReceiver + "@s.whatsapp.net"; + } else { + data.recipient_type = "group"; + data.to = notification.onesenderReceiver + "@g.us"; + } + let config = { + headers: { + "Authorization": "Bearer " + notification.onesenderToken, + } + }; + await axios.post(notification.onesenderURL, data, config); + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + + } + +} + +module.exports = Onesender; diff --git a/server/notification-providers/opsgenie.js b/server/notification-providers/opsgenie.js new file mode 100644 index 0000000..59a7970 --- /dev/null +++ b/server/notification-providers/opsgenie.js @@ -0,0 +1,96 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { UP, DOWN } = require("../../src/util"); + +const opsgenieAlertsUrlEU = "https://api.eu.opsgenie.com/v2/alerts"; +const opsgenieAlertsUrlUS = "https://api.opsgenie.com/v2/alerts"; +const okMsg = "Sent Successfully."; + +class Opsgenie extends NotificationProvider { + name = "Opsgenie"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + let opsgenieAlertsUrl; + let priority = (!notification.opsgeniePriority) ? 3 : notification.opsgeniePriority; + const textMsg = "Uptime Kuma Alert"; + + try { + switch (notification.opsgenieRegion) { + case "us": + opsgenieAlertsUrl = opsgenieAlertsUrlUS; + break; + case "eu": + opsgenieAlertsUrl = opsgenieAlertsUrlEU; + break; + default: + opsgenieAlertsUrl = opsgenieAlertsUrlUS; + } + + if (heartbeatJSON == null) { + let notificationTestAlias = "uptime-kuma-notification-test"; + let data = { + "message": msg, + "alias": notificationTestAlias, + "source": "Uptime Kuma", + "priority": "P5" + }; + + return this.post(notification, opsgenieAlertsUrl, data); + } + + if (heartbeatJSON.status === DOWN) { + let data = { + "message": monitorJSON ? textMsg + `: ${monitorJSON.name}` : textMsg, + "alias": monitorJSON.name, + "description": msg, + "source": "Uptime Kuma", + "priority": `P${priority}` + }; + + return this.post(notification, opsgenieAlertsUrl, data); + } + + if (heartbeatJSON.status === UP) { + let opsgenieAlertsCloseUrl = `${opsgenieAlertsUrl}/${encodeURIComponent(monitorJSON.name)}/close?identifierType=alias`; + let data = { + "source": "Uptime Kuma", + }; + + return this.post(notification, opsgenieAlertsCloseUrl, data); + } + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + + /** + * Make POST request to Opsgenie + * @param {BeanModel} notification Notification to send + * @param {string} url Request url + * @param {object} data Request body + * @returns {Promise<string>} Success message + */ + async post(notification, url, data) { + let config = { + headers: { + "Content-Type": "application/json", + "Authorization": `GenieKey ${notification.opsgenieApiKey}`, + } + }; + + let res = await axios.post(url, data, config); + if (res.status == null) { + return "Opsgenie notification failed with invalid response!"; + } + if (res.status < 200 || res.status >= 300) { + return `Opsgenie notification failed with status code ${res.status}`; + } + + return okMsg; + } +} + +module.exports = Opsgenie; diff --git a/server/notification-providers/pagerduty.js b/server/notification-providers/pagerduty.js new file mode 100644 index 0000000..c60d782 --- /dev/null +++ b/server/notification-providers/pagerduty.js @@ -0,0 +1,114 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util"); +const { setting } = require("../util-server"); +let successMessage = "Sent Successfully."; + +class PagerDuty extends NotificationProvider { + name = "PagerDuty"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + try { + if (heartbeatJSON == null) { + const title = "Uptime Kuma Alert"; + const monitor = { + type: "ping", + url: "Uptime Kuma Test Button", + }; + return this.postNotification(notification, title, msg, monitor); + } + + if (heartbeatJSON.status === UP) { + const title = "Uptime Kuma Monitor ✅ Up"; + const eventAction = notification.pagerdutyAutoResolve || null; + + return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, eventAction); + } + + if (heartbeatJSON.status === DOWN) { + const title = "Uptime Kuma Monitor 🔴 Down"; + return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, "trigger"); + } + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + + /** + * Check if result is successful, result code should be in range 2xx + * @param {object} result Axios response object + * @returns {void} + * @throws {Error} The status code is not in range 2xx + */ + checkResult(result) { + if (result.status == null) { + throw new Error("PagerDuty notification failed with invalid response!"); + } + if (result.status < 200 || result.status >= 300) { + throw new Error("PagerDuty notification failed with status code " + result.status); + } + } + + /** + * Send the message + * @param {BeanModel} notification Message title + * @param {string} title Message title + * @param {string} body Message + * @param {object} monitorInfo Monitor details (For Up/Down only) + * @param {?string} eventAction Action event for PagerDuty (trigger, acknowledge, resolve) + * @returns {Promise<string>} Success message + */ + async postNotification(notification, title, body, monitorInfo, eventAction = "trigger") { + + if (eventAction == null) { + return "No action required"; + } + + let monitorUrl; + if (monitorInfo.type === "port") { + monitorUrl = monitorInfo.hostname; + if (monitorInfo.port) { + monitorUrl += ":" + monitorInfo.port; + } + } else if (monitorInfo.hostname != null) { + monitorUrl = monitorInfo.hostname; + } else { + monitorUrl = monitorInfo.url; + } + + const options = { + method: "POST", + url: notification.pagerdutyIntegrationUrl, + headers: { "Content-Type": "application/json" }, + data: { + payload: { + summary: `[${title}] [${monitorInfo.name}] ${body}`, + severity: notification.pagerdutyPriority || "warning", + source: monitorUrl, + }, + routing_key: notification.pagerdutyIntegrationKey, + event_action: eventAction, + dedup_key: "Uptime Kuma/" + monitorInfo.id, + } + }; + + const baseURL = await setting("primaryBaseURL"); + if (baseURL && monitorInfo) { + options.client = "Uptime Kuma"; + options.client_url = baseURL + getMonitorRelativeURL(monitorInfo.id); + } + + let result = await axios.request(options); + this.checkResult(result); + if (result.statusText != null) { + return "PagerDuty notification succeed: " + result.statusText; + } + + return successMessage; + } +} + +module.exports = PagerDuty; diff --git a/server/notification-providers/pagertree.js b/server/notification-providers/pagertree.js new file mode 100644 index 0000000..c7a5338 --- /dev/null +++ b/server/notification-providers/pagertree.js @@ -0,0 +1,93 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util"); +const { setting } = require("../util-server"); +let successMessage = "Sent Successfully."; + +class PagerTree extends NotificationProvider { + name = "PagerTree"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + try { + if (heartbeatJSON == null) { + // general messages + return this.postNotification(notification, msg, monitorJSON, heartbeatJSON); + } + + if (heartbeatJSON.status === UP && notification.pagertreeAutoResolve === "resolve") { + return this.postNotification(notification, null, monitorJSON, heartbeatJSON, notification.pagertreeAutoResolve); + } + + if (heartbeatJSON.status === DOWN) { + const title = `Uptime Kuma Monitor "${monitorJSON.name}" is DOWN`; + return this.postNotification(notification, title, monitorJSON, heartbeatJSON); + } + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + + /** + * Check if result is successful, result code should be in range 2xx + * @param {object} result Axios response object + * @returns {void} + * @throws {Error} The status code is not in range 2xx + */ + checkResult(result) { + if (result.status == null) { + throw new Error("PagerTree notification failed with invalid response!"); + } + if (result.status < 200 || result.status >= 300) { + throw new Error("PagerTree notification failed with status code " + result.status); + } + } + + /** + * Send the message + * @param {BeanModel} notification Message title + * @param {string} title Message title + * @param {object} monitorJSON Monitor details (For Up/Down only) + * @param {object} heartbeatJSON Heartbeat details (For Up/Down only) + * @param {?string} eventAction Action event for PagerTree (create, resolve) + * @returns {Promise<string>} Success state + */ + async postNotification(notification, title, monitorJSON, heartbeatJSON, eventAction = "create") { + + if (eventAction == null) { + return "No action required"; + } + + const options = { + method: "POST", + url: notification.pagertreeIntegrationUrl, + headers: { "Content-Type": "application/json" }, + data: { + event_type: eventAction, + id: heartbeatJSON?.monitorID || "uptime-kuma", + title: title, + urgency: notification.pagertreeUrgency, + heartbeat: heartbeatJSON, + monitor: monitorJSON + } + }; + + const baseURL = await setting("primaryBaseURL"); + if (baseURL && monitorJSON) { + options.client = "Uptime Kuma"; + options.client_url = baseURL + getMonitorRelativeURL(monitorJSON.id); + } + + let result = await axios.request(options); + this.checkResult(result); + if (result.statusText != null) { + return "PagerTree notification succeed: " + result.statusText; + } + + return successMessage; + } +} + +module.exports = PagerTree; diff --git a/server/notification-providers/promosms.js b/server/notification-providers/promosms.js new file mode 100644 index 0000000..05334e9 --- /dev/null +++ b/server/notification-providers/promosms.js @@ -0,0 +1,53 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class PromoSMS extends NotificationProvider { + name = "promosms"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const url = "https://promosms.com/api/rest/v3_2/sms"; + + if (notification.promosmsAllowLongSMS === undefined) { + notification.promosmsAllowLongSMS = false; + } + + //TODO: Add option for enabling special characters. It will decrese message max length from 160 to 70 chars. + //Lets remove non ascii char + let cleanMsg = msg.replace(/[^\x00-\x7F]/g, ""); + + try { + let config = { + headers: { + "Content-Type": "application/json", + "Authorization": "Basic " + Buffer.from(notification.promosmsLogin + ":" + notification.promosmsPassword).toString("base64"), + "Accept": "text/json", + } + }; + let data = { + "recipients": [ notification.promosmsPhoneNumber ], + //Trim message to maximum length of 1 SMS or 4 if we allowed long messages + "text": notification.promosmsAllowLongSMS ? cleanMsg.substring(0, 639) : cleanMsg.substring(0, 159), + "long-sms": notification.promosmsAllowLongSMS, + "type": Number(notification.promosmsSMSType), + "sender": notification.promosmsSenderName + }; + + let resp = await axios.post(url, data, config); + + if (resp.data.response.status !== 0) { + let error = "Something gone wrong. Api returned " + resp.data.response.status + "."; + this.throwGeneralAxiosError(error); + } + + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = PromoSMS; diff --git a/server/notification-providers/pushbullet.js b/server/notification-providers/pushbullet.js new file mode 100644 index 0000000..0b73031 --- /dev/null +++ b/server/notification-providers/pushbullet.js @@ -0,0 +1,56 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +const { DOWN, UP } = require("../../src/util"); + +class Pushbullet extends NotificationProvider { + name = "pushbullet"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const url = "https://api.pushbullet.com/v2/pushes"; + + try { + let config = { + headers: { + "Access-Token": notification.pushbulletAccessToken, + "Content-Type": "application/json" + } + }; + if (heartbeatJSON == null) { + let data = { + "type": "note", + "title": "Uptime Kuma Alert", + "body": msg, + }; + await axios.post(url, data, config); + } else if (heartbeatJSON["status"] === DOWN) { + let downData = { + "type": "note", + "title": "UptimeKuma Alert: " + monitorJSON["name"], + "body": "[🔴 Down] " + + heartbeatJSON["msg"] + + `\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`, + }; + await axios.post(url, downData, config); + } else if (heartbeatJSON["status"] === UP) { + let upData = { + "type": "note", + "title": "UptimeKuma Alert: " + monitorJSON["name"], + "body": "[✅ Up] " + + heartbeatJSON["msg"] + + `\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`, + }; + await axios.post(url, upData, config); + } + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Pushbullet; diff --git a/server/notification-providers/pushdeer.js b/server/notification-providers/pushdeer.js new file mode 100644 index 0000000..276c2f4 --- /dev/null +++ b/server/notification-providers/pushdeer.js @@ -0,0 +1,55 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class PushDeer extends NotificationProvider { + name = "PushDeer"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const serverUrl = notification.pushdeerServer || "https://api2.pushdeer.com"; + const url = `${serverUrl.trim().replace(/\/*$/, "")}/message/push`; + + let valid = msg != null && monitorJSON != null && heartbeatJSON != null; + + let title; + if (valid && heartbeatJSON.status === UP) { + title = "## Uptime Kuma: " + monitorJSON.name + " up"; + } else if (valid && heartbeatJSON.status === DOWN) { + title = "## Uptime Kuma: " + monitorJSON.name + " down"; + } else { + title = "## Uptime Kuma Message"; + } + + let data = { + "pushkey": notification.pushdeerKey, + "text": title, + "desp": msg.replace(/\n/g, "\n\n"), + "type": "markdown", + }; + + try { + let res = await axios.post(url, data); + + if ("error" in res.data) { + let error = res.data.error; + this.throwGeneralAxiosError(error); + } + if (res.data.content.result.length === 0) { + let error = "Invalid PushDeer key"; + this.throwGeneralAxiosError(error); + } else if (JSON.parse(res.data.content.result[0]).success !== "ok") { + let error = "Unknown error"; + this.throwGeneralAxiosError(error); + } + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = PushDeer; diff --git a/server/notification-providers/pushover.js b/server/notification-providers/pushover.js new file mode 100644 index 0000000..8422b64 --- /dev/null +++ b/server/notification-providers/pushover.js @@ -0,0 +1,58 @@ +const { getMonitorRelativeURL } = require("../../src/util"); +const { setting } = require("../util-server"); + +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Pushover extends NotificationProvider { + name = "pushover"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const url = "https://api.pushover.net/1/messages.json"; + + let data = { + "message": msg, + "user": notification.pushoveruserkey, + "token": notification.pushoverapptoken, + "sound": notification.pushoversounds, + "priority": notification.pushoverpriority, + "title": notification.pushovertitle, + "retry": "30", + "expire": "3600", + "html": 1, + }; + + const baseURL = await setting("primaryBaseURL"); + if (baseURL && monitorJSON) { + data["url"] = baseURL + getMonitorRelativeURL(monitorJSON.id); + data["url_title"] = "Link to Monitor"; + } + + if (notification.pushoverdevice) { + data.device = notification.pushoverdevice; + } + if (notification.pushoverttl) { + data.ttl = notification.pushoverttl; + } + + try { + if (heartbeatJSON == null) { + await axios.post(url, data); + return okMsg; + } else { + data.message += `\n<b>Time (${heartbeatJSON["timezone"]})</b>:${heartbeatJSON["localDateTime"]}`; + await axios.post(url, data); + return okMsg; + } + } catch (error) { + this.throwGeneralAxiosError(error); + } + + } +} + +module.exports = Pushover; diff --git a/server/notification-providers/pushy.js b/server/notification-providers/pushy.js new file mode 100644 index 0000000..cb70022 --- /dev/null +++ b/server/notification-providers/pushy.js @@ -0,0 +1,32 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Pushy extends NotificationProvider { + name = "pushy"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + await axios.post(`https://api.pushy.me/push?api_key=${notification.pushyAPIKey}`, { + "to": notification.pushyToken, + "data": { + "message": "Uptime-Kuma" + }, + "notification": { + "body": msg, + "badge": 1, + "sound": "ping.aiff" + } + }); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Pushy; diff --git a/server/notification-providers/rocket-chat.js b/server/notification-providers/rocket-chat.js new file mode 100644 index 0000000..690e33a --- /dev/null +++ b/server/notification-providers/rocket-chat.js @@ -0,0 +1,67 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const Slack = require("./slack"); +const { setting } = require("../util-server"); +const { getMonitorRelativeURL, DOWN } = require("../../src/util"); + +class RocketChat extends NotificationProvider { + name = "rocket.chat"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + if (heartbeatJSON == null) { + let data = { + "text": msg, + "channel": notification.rocketchannel, + "username": notification.rocketusername, + "icon_emoji": notification.rocketiconemo, + }; + await axios.post(notification.rocketwebhookURL, data); + return okMsg; + } + + let data = { + "text": "Uptime Kuma Alert", + "channel": notification.rocketchannel, + "username": notification.rocketusername, + "icon_emoji": notification.rocketiconemo, + "attachments": [ + { + "title": `Uptime Kuma Alert *Time (${heartbeatJSON["timezone"]})*\n${heartbeatJSON["localDateTime"]}`, + "text": "*Message*\n" + msg, + } + ] + }; + + // Color + if (heartbeatJSON.status === DOWN) { + data.attachments[0].color = "#ff0000"; + } else { + data.attachments[0].color = "#32cd32"; + } + + if (notification.rocketbutton) { + await Slack.deprecateURL(notification.rocketbutton); + } + + const baseURL = await setting("primaryBaseURL"); + + if (baseURL) { + data.attachments[0].title_link = baseURL + getMonitorRelativeURL(monitorJSON.id); + } + + await axios.post(notification.rocketwebhookURL, data); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + + } +} + +module.exports = RocketChat; diff --git a/server/notification-providers/send-grid.js b/server/notification-providers/send-grid.js new file mode 100644 index 0000000..3489f63 --- /dev/null +++ b/server/notification-providers/send-grid.js @@ -0,0 +1,65 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class SendGrid extends NotificationProvider { + name = "SendGrid"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let config = { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${notification.sendgridApiKey}`, + }, + }; + + let personalizations = { + to: [{ email: notification.sendgridToEmail }], + }; + + // Add CC recipients if provided + if (notification.sendgridCcEmail) { + personalizations.cc = notification.sendgridCcEmail + .split(",") + .map((email) => ({ email: email.trim() })); + } + + // Add BCC recipients if provided + if (notification.sendgridBccEmail) { + personalizations.bcc = notification.sendgridBccEmail + .split(",") + .map((email) => ({ email: email.trim() })); + } + + let data = { + personalizations: [ personalizations ], + from: { email: notification.sendgridFromEmail.trim() }, + subject: + notification.sendgridSubject || + "Notification from Your Uptime Kuma", + content: [ + { + type: "text/plain", + value: msg, + }, + ], + }; + + await axios.post( + "https://api.sendgrid.com/v3/mail/send", + data, + config + ); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = SendGrid; diff --git a/server/notification-providers/serverchan.js b/server/notification-providers/serverchan.js new file mode 100644 index 0000000..aee22f8 --- /dev/null +++ b/server/notification-providers/serverchan.js @@ -0,0 +1,51 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class ServerChan extends NotificationProvider { + name = "ServerChan"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + // serverchan3 requires sending via ft07.com + const matchResult = String(notification.serverChanSendKey).match(/^sctp(\d+)t/i); + const url = matchResult && matchResult[1] + ? `https://${matchResult[1]}.push.ft07.com/send/${notification.serverChanSendKey}.send` + : `https://sctapi.ftqq.com/${notification.serverChanSendKey}.send`; + + try { + await axios.post(url, { + "title": this.checkStatus(heartbeatJSON, monitorJSON), + "desp": msg, + }); + + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + + /** + * Get the formatted title for message + * @param {?object} heartbeatJSON Heartbeat details (For Up/Down only) + * @param {?object} monitorJSON Monitor details (For Up/Down only) + * @returns {string} Formatted title + */ + checkStatus(heartbeatJSON, monitorJSON) { + let title = "UptimeKuma Message"; + if (heartbeatJSON != null && heartbeatJSON["status"] === UP) { + title = "UptimeKuma Monitor Up " + monitorJSON["name"]; + } + if (heartbeatJSON != null && heartbeatJSON["status"] === DOWN) { + title = "UptimeKuma Monitor Down " + monitorJSON["name"]; + } + return title; + } +} + +module.exports = ServerChan; diff --git a/server/notification-providers/serwersms.js b/server/notification-providers/serwersms.js new file mode 100644 index 0000000..f7c8644 --- /dev/null +++ b/server/notification-providers/serwersms.js @@ -0,0 +1,47 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class SerwerSMS extends NotificationProvider { + name = "serwersms"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const url = "https://api2.serwersms.pl/messages/send_sms"; + + try { + let config = { + headers: { + "Content-Type": "application/json", + } + }; + let data = { + "username": notification.serwersmsUsername, + "password": notification.serwersmsPassword, + "phone": notification.serwersmsPhoneNumber, + "text": msg.replace(/[^\x00-\x7F]/g, ""), + "sender": notification.serwersmsSenderName, + }; + + let resp = await axios.post(url, data, config); + + if (!resp.data.success) { + if (resp.data.error) { + let error = `SerwerSMS.pl API returned error code ${resp.data.error.code} (${resp.data.error.type}) with error message: ${resp.data.error.message}`; + this.throwGeneralAxiosError(error); + } else { + let error = "SerwerSMS.pl API returned an unexpected response"; + this.throwGeneralAxiosError(error); + } + } + + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = SerwerSMS; diff --git a/server/notification-providers/sevenio.js b/server/notification-providers/sevenio.js new file mode 100644 index 0000000..eac38a2 --- /dev/null +++ b/server/notification-providers/sevenio.js @@ -0,0 +1,57 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class SevenIO extends NotificationProvider { + name = "SevenIO"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + const data = { + to: notification.sevenioTo, + from: notification.sevenioSender || "Uptime Kuma", + text: msg, + }; + + const config = { + baseURL: "https://gateway.seven.io/api/", + headers: { + "Content-Type": "application/json", + "X-API-Key": notification.sevenioApiKey, + }, + }; + + try { + // testing or certificate expiry notification + if (heartbeatJSON == null) { + await axios.post("sms", data, config); + return okMsg; + } + + let address = this.extractAddress(monitorJSON); + if (address !== "") { + address = `(${address}) `; + } + + // If heartbeatJSON is not null, we go into the normal alerting loop. + if (heartbeatJSON["status"] === DOWN) { + data.text = `Your service ${monitorJSON["name"]} ${address}went down at ${heartbeatJSON["localDateTime"]} ` + + `(${heartbeatJSON["timezone"]}). Error: ${heartbeatJSON["msg"]}`; + } else if (heartbeatJSON["status"] === UP) { + data.text = `Your service ${monitorJSON["name"]} ${address}went back up at ${heartbeatJSON["localDateTime"]} ` + + `(${heartbeatJSON["timezone"]}).`; + } + await axios.post("sms", data, config); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + +} + +module.exports = SevenIO; diff --git a/server/notification-providers/signal.js b/server/notification-providers/signal.js new file mode 100644 index 0000000..9702d06 --- /dev/null +++ b/server/notification-providers/signal.js @@ -0,0 +1,29 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Signal extends NotificationProvider { + name = "signal"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let data = { + "message": msg, + "number": notification.signalNumber, + "recipients": notification.signalRecipients.replace(/\s/g, "").split(","), + }; + let config = {}; + + await axios.post(notification.signalURL, data, config); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Signal; diff --git a/server/notification-providers/signl4.js b/server/notification-providers/signl4.js new file mode 100644 index 0000000..8261a73 --- /dev/null +++ b/server/notification-providers/signl4.js @@ -0,0 +1,52 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { UP, DOWN } = require("../../src/util"); + +class SIGNL4 extends NotificationProvider { + name = "SIGNL4"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let data = { + heartbeat: heartbeatJSON, + monitor: monitorJSON, + msg, + // Source system + "X-S4-SourceSystem": "UptimeKuma", + monitorUrl: this.extractAddress(monitorJSON), + }; + + const config = { + headers: { + "Content-Type": "application/json" + } + }; + + if (heartbeatJSON == null) { + // Test alert + data.title = "Uptime Kuma Alert"; + data.message = msg; + } else if (heartbeatJSON.status === UP) { + data.title = "Uptime Kuma Monitor ✅ Up"; + data["X-S4-ExternalID"] = "UptimeKuma-" + monitorJSON.monitorID; + data["X-S4-Status"] = "resolved"; + } else if (heartbeatJSON.status === DOWN) { + data.title = "Uptime Kuma Monitor 🔴 Down"; + data["X-S4-ExternalID"] = "UptimeKuma-" + monitorJSON.monitorID; + data["X-S4-Status"] = "new"; + } + + await axios.post(notification.webhookURL, data, config); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = SIGNL4; diff --git a/server/notification-providers/slack.js b/server/notification-providers/slack.js new file mode 100644 index 0000000..209c7c0 --- /dev/null +++ b/server/notification-providers/slack.js @@ -0,0 +1,173 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { setSettings, setting } = require("../util-server"); +const { getMonitorRelativeURL, UP } = require("../../src/util"); + +class Slack extends NotificationProvider { + name = "slack"; + + /** + * Deprecated property notification.slackbutton + * Set it as primary base url if this is not yet set. + * @deprecated + * @param {string} url The primary base URL to use + * @returns {Promise<void>} + */ + static async deprecateURL(url) { + let currentPrimaryBaseURL = await setting("primaryBaseURL"); + + if (!currentPrimaryBaseURL) { + console.log("Move the url to be the primary base URL"); + await setSettings("general", { + primaryBaseURL: url, + }); + } else { + console.log("Already there, no need to move the primary base URL"); + } + } + + /** + * Builds the actions available in the slack message + * @param {string} baseURL Uptime Kuma base URL + * @param {object} monitorJSON The monitor config + * @returns {Array} The relevant action objects + */ + buildActions(baseURL, monitorJSON) { + const actions = []; + + if (baseURL) { + actions.push({ + "type": "button", + "text": { + "type": "plain_text", + "text": "Visit Uptime Kuma", + }, + "value": "Uptime-Kuma", + "url": baseURL + getMonitorRelativeURL(monitorJSON.id), + }); + + } + + const address = this.extractAddress(monitorJSON); + if (address) { + actions.push({ + "type": "button", + "text": { + "type": "plain_text", + "text": "Visit site", + }, + "value": "Site", + "url": address, + }); + } + + return actions; + } + + /** + * Builds the different blocks the Slack message consists of. + * @param {string} baseURL Uptime Kuma base URL + * @param {object} monitorJSON The monitor object + * @param {object} heartbeatJSON The heartbeat object + * @param {string} title The message title + * @param {string} msg The message body + * @returns {Array<object>} The rich content blocks for the Slack message + */ + buildBlocks(baseURL, monitorJSON, heartbeatJSON, title, msg) { + + //create an array to dynamically add blocks + const blocks = []; + + // the header block + blocks.push({ + "type": "header", + "text": { + "type": "plain_text", + "text": title, + }, + }); + + // the body block, containing the details + blocks.push({ + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Message*\n" + msg, + }, + { + "type": "mrkdwn", + "text": `*Time (${heartbeatJSON["timezone"]})*\n${heartbeatJSON["localDateTime"]}`, + } + ], + }); + + const actions = this.buildActions(baseURL, monitorJSON); + if (actions.length > 0) { + //the actions block, containing buttons + blocks.push({ + "type": "actions", + "elements": actions, + }); + } + + return blocks; + } + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + if (notification.slackchannelnotify) { + msg += " <!channel>"; + } + + try { + if (heartbeatJSON == null) { + let data = { + "text": msg, + "channel": notification.slackchannel, + "username": notification.slackusername, + "icon_emoji": notification.slackiconemo, + }; + await axios.post(notification.slackwebhookURL, data); + return okMsg; + } + + const baseURL = await setting("primaryBaseURL"); + + const title = "Uptime Kuma Alert"; + let data = { + "channel": notification.slackchannel, + "username": notification.slackusername, + "icon_emoji": notification.slackiconemo, + "attachments": [], + }; + + if (notification.slackrichmessage) { + data.attachments.push( + { + "color": (heartbeatJSON["status"] === UP) ? "#2eb886" : "#e01e5a", + "blocks": this.buildBlocks(baseURL, monitorJSON, heartbeatJSON, title, msg), + } + ); + } else { + data.text = `${title}\n${msg}`; + } + + if (notification.slackbutton) { + await Slack.deprecateURL(notification.slackbutton); + } + + await axios.post(notification.slackwebhookURL, data); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + + } +} + +module.exports = Slack; diff --git a/server/notification-providers/smsc.js b/server/notification-providers/smsc.js new file mode 100644 index 0000000..89f01d0 --- /dev/null +++ b/server/notification-providers/smsc.js @@ -0,0 +1,47 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class SMSC extends NotificationProvider { + name = "smsc"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const url = "https://smsc.kz/sys/send.php?"; + + try { + let config = { + headers: { + "Content-Type": "application/json", + "Accept": "text/json", + } + }; + + let getArray = [ + "fmt=3", + "translit=" + notification.smscTranslit, + "login=" + notification.smscLogin, + "psw=" + notification.smscPassword, + "phones=" + notification.smscToNumber, + "mes=" + encodeURIComponent(msg.replace(/[^\x00-\x7F]/g, "")), + ]; + if (notification.smscSenderName !== "") { + getArray.push("sender=" + notification.smscSenderName); + } + + let resp = await axios.get(url + getArray.join("&"), config); + if (resp.data.id === undefined) { + let error = `Something gone wrong. Api returned code ${resp.data.error_code}: ${resp.data.error}`; + this.throwGeneralAxiosError(error); + } + + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = SMSC; diff --git a/server/notification-providers/smseagle.js b/server/notification-providers/smseagle.js new file mode 100644 index 0000000..4e89700 --- /dev/null +++ b/server/notification-providers/smseagle.js @@ -0,0 +1,73 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class SMSEagle extends NotificationProvider { + name = "SMSEagle"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let config = { + headers: { + "Content-Type": "application/json", + } + }; + + let postData; + let sendMethod; + let recipientType; + + let encoding = (notification.smseagleEncoding) ? "1" : "0"; + let priority = (notification.smseaglePriority) ? notification.smseaglePriority : "0"; + + if (notification.smseagleRecipientType === "smseagle-contact") { + recipientType = "contactname"; + sendMethod = "sms.send_tocontact"; + } + if (notification.smseagleRecipientType === "smseagle-group") { + recipientType = "groupname"; + sendMethod = "sms.send_togroup"; + } + if (notification.smseagleRecipientType === "smseagle-to") { + recipientType = "to"; + sendMethod = "sms.send_sms"; + } + + let params = { + access_token: notification.smseagleToken, + [recipientType]: notification.smseagleRecipient, + message: msg, + responsetype: "extended", + unicode: encoding, + highpriority: priority + }; + + postData = { + method: sendMethod, + params: params + }; + + let resp = await axios.post(notification.smseagleUrl + "/jsonrpc/sms", postData, config); + + if ((JSON.stringify(resp.data)).indexOf("message_id") === -1) { + let error = ""; + if (resp.data.result && resp.data.result.error_text) { + error = `SMSEagle API returned error: ${JSON.stringify(resp.data.result.error_text)}`; + } else { + error = "SMSEagle API returned an unexpected response"; + } + throw new Error(error); + } + + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = SMSEagle; diff --git a/server/notification-providers/smsmanager.js b/server/notification-providers/smsmanager.js new file mode 100644 index 0000000..d01285d --- /dev/null +++ b/server/notification-providers/smsmanager.js @@ -0,0 +1,29 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class SMSManager extends NotificationProvider { + name = "SMSManager"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const url = "https://http-api.smsmanager.cz/Send"; + + try { + let data = { + apikey: notification.smsmanagerApiKey, + message: msg.replace(/[^\x00-\x7F]/g, ""), + number: notification.numbers, + gateway: notification.messageType, + }; + await axios.get(`${url}?apikey=${data.apikey}&message=${data.message}&number=${data.number}&gateway=${data.messageType}`); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = SMSManager; diff --git a/server/notification-providers/smspartner.js b/server/notification-providers/smspartner.js new file mode 100644 index 0000000..5595217 --- /dev/null +++ b/server/notification-providers/smspartner.js @@ -0,0 +1,46 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class SMSPartner extends NotificationProvider { + name = "SMSPartner"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const url = "https://api.smspartner.fr/v1/send"; + + try { + // smspartner does not support non ascii characters and only a maximum 639 characters + let cleanMsg = msg.replace(/[^\x00-\x7F]/g, "").substring(0, 639); + + let data = { + "apiKey": notification.smspartnerApikey, + "sender": notification.smspartnerSenderName.substring(0, 11), + "phoneNumbers": notification.smspartnerPhoneNumber, + "message": cleanMsg, + }; + + let config = { + headers: { + "Content-Type": "application/json", + "cache-control": "no-cache", + "Accept": "application/json", + } + }; + + let resp = await axios.post(url, data, config); + + if (resp.data.success !== true) { + throw Error(`Api returned ${resp.data.response.status}.`); + } + + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = SMSPartner; diff --git a/server/notification-providers/smtp.js b/server/notification-providers/smtp.js new file mode 100644 index 0000000..9f3defa --- /dev/null +++ b/server/notification-providers/smtp.js @@ -0,0 +1,120 @@ +const nodemailer = require("nodemailer"); +const NotificationProvider = require("./notification-provider"); +const { DOWN } = require("../../src/util"); +const { Liquid } = require("liquidjs"); + +class SMTP extends NotificationProvider { + name = "smtp"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + const config = { + host: notification.smtpHost, + port: notification.smtpPort, + secure: notification.smtpSecure, + tls: { + rejectUnauthorized: !notification.smtpIgnoreTLSError || false, + } + }; + + // Fix #1129 + if (notification.smtpDkimDomain) { + config.dkim = { + domainName: notification.smtpDkimDomain, + keySelector: notification.smtpDkimKeySelector, + privateKey: notification.smtpDkimPrivateKey, + hashAlgo: notification.smtpDkimHashAlgo, + headerFieldNames: notification.smtpDkimheaderFieldNames, + skipFields: notification.smtpDkimskipFields, + }; + } + + // Should fix the issue in https://github.com/louislam/uptime-kuma/issues/26#issuecomment-896373904 + if (notification.smtpUsername || notification.smtpPassword) { + config.auth = { + user: notification.smtpUsername, + pass: notification.smtpPassword, + }; + } + + // default values in case the user does not want to template + let subject = msg; + let body = msg; + if (heartbeatJSON) { + body = `${msg}\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`; + } + // subject and body are templated + if ((monitorJSON && heartbeatJSON) || msg.endsWith("Testing")) { + // cannot end with whitespace as this often raises spam scores + const customSubject = notification.customSubject?.trim() || ""; + const customBody = notification.customBody?.trim() || ""; + + const context = this.generateContext(msg, monitorJSON, heartbeatJSON); + const engine = new Liquid(); + if (customSubject !== "") { + const tpl = engine.parse(customSubject); + subject = await engine.render(tpl, context); + } + if (customBody !== "") { + const tpl = engine.parse(customBody); + body = await engine.render(tpl, context); + } + } + + // send mail with defined transport object + let transporter = nodemailer.createTransport(config); + await transporter.sendMail({ + from: notification.smtpFrom, + cc: notification.smtpCC, + bcc: notification.smtpBCC, + to: notification.smtpTo, + subject: subject, + text: body, + }); + + return okMsg; + } + + /** + * Generate context for LiquidJS + * @param {string} msg the message that will be included in the context + * @param {?object} monitorJSON Monitor details (For Up/Down/Cert-Expiry only) + * @param {?object} heartbeatJSON Heartbeat details (For Up/Down only) + * @returns {{STATUS: string, status: string, HOSTNAME_OR_URL: string, hostnameOrUrl: string, NAME: string, name: string, monitorJSON: ?object, heartbeatJSON: ?object, msg: string}} the context + */ + generateContext(msg, monitorJSON, heartbeatJSON) { + // Let's start with dummy values to simplify code + let monitorName = "Monitor Name not available"; + let monitorHostnameOrURL = "testing.hostname"; + + if (monitorJSON !== null) { + monitorName = monitorJSON["name"]; + monitorHostnameOrURL = this.extractAddress(monitorJSON); + } + + let serviceStatus = "⚠️ Test"; + if (heartbeatJSON !== null) { + serviceStatus = (heartbeatJSON["status"] === DOWN) ? "🔴 Down" : "✅ Up"; + } + return { + // for v1 compatibility, to be removed in v3 + "STATUS": serviceStatus, + "NAME": monitorName, + "HOSTNAME_OR_URL": monitorHostnameOrURL, + + // variables which are officially supported + "status": serviceStatus, + "name": monitorName, + "hostnameOrURL": monitorHostnameOrURL, + monitorJSON, + heartbeatJSON, + msg, + }; + } +} + +module.exports = SMTP; diff --git a/server/notification-providers/splunk.js b/server/notification-providers/splunk.js new file mode 100644 index 0000000..e07c510 --- /dev/null +++ b/server/notification-providers/splunk.js @@ -0,0 +1,114 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util"); +const { setting } = require("../util-server"); +let successMessage = "Sent Successfully."; + +class Splunk extends NotificationProvider { + name = "Splunk"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + try { + if (heartbeatJSON == null) { + const title = "Uptime Kuma Alert"; + const monitor = { + type: "ping", + url: "Uptime Kuma Test Button", + }; + return this.postNotification(notification, title, msg, monitor, "trigger"); + } + + if (heartbeatJSON.status === UP) { + const title = "Uptime Kuma Monitor ✅ Up"; + return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, "recovery"); + } + + if (heartbeatJSON.status === DOWN) { + const title = "Uptime Kuma Monitor 🔴 Down"; + return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, "trigger"); + } + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + + /** + * Check if result is successful, result code should be in range 2xx + * @param {object} result Axios response object + * @returns {void} + * @throws {Error} The status code is not in range 2xx + */ + checkResult(result) { + if (result.status == null) { + throw new Error("Splunk notification failed with invalid response!"); + } + if (result.status < 200 || result.status >= 300) { + throw new Error("Splunk notification failed with status code " + result.status); + } + } + + /** + * Send the message + * @param {BeanModel} notification Message title + * @param {string} title Message title + * @param {string} body Message + * @param {object} monitorInfo Monitor details (For Up/Down only) + * @param {?string} eventAction Action event for PagerDuty (trigger, acknowledge, resolve) + * @returns {Promise<string>} Success state + */ + async postNotification(notification, title, body, monitorInfo, eventAction = "trigger") { + + let monitorUrl; + if (monitorInfo.type === "port") { + monitorUrl = monitorInfo.hostname; + if (monitorInfo.port) { + monitorUrl += ":" + monitorInfo.port; + } + } else if (monitorInfo.hostname != null) { + monitorUrl = monitorInfo.hostname; + } else { + monitorUrl = monitorInfo.url; + } + + if (eventAction === "recovery") { + if (notification.splunkAutoResolve === "0") { + return "No action required"; + } + eventAction = notification.splunkAutoResolve; + } else { + eventAction = notification.splunkSeverity; + } + + const options = { + method: "POST", + url: notification.splunkRestURL, + headers: { "Content-Type": "application/json" }, + data: { + message_type: eventAction, + state_message: `[${title}] [${monitorUrl}] ${body}`, + entity_display_name: "Uptime Kuma Alert: " + monitorInfo.name, + routing_key: notification.pagerdutyIntegrationKey, + entity_id: "Uptime Kuma/" + monitorInfo.id, + } + }; + + const baseURL = await setting("primaryBaseURL"); + if (baseURL && monitorInfo) { + options.client = "Uptime Kuma"; + options.client_url = baseURL + getMonitorRelativeURL(monitorInfo.id); + } + + let result = await axios.request(options); + this.checkResult(result); + if (result.statusText != null) { + return "Splunk notification succeed: " + result.statusText; + } + + return successMessage; + } +} + +module.exports = Splunk; diff --git a/server/notification-providers/squadcast.js b/server/notification-providers/squadcast.js new file mode 100644 index 0000000..5713783 --- /dev/null +++ b/server/notification-providers/squadcast.js @@ -0,0 +1,60 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN } = require("../../src/util"); + +class Squadcast extends NotificationProvider { + name = "squadcast"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + + let config = {}; + let data = { + message: msg, + description: "", + tags: {}, + heartbeat: heartbeatJSON, + source: "uptime-kuma" + }; + + if (heartbeatJSON !== null) { + data.description = heartbeatJSON["msg"]; + data.event_id = heartbeatJSON["monitorID"]; + + if (heartbeatJSON["status"] === DOWN) { + data.message = `${monitorJSON["name"]} is DOWN`; + data.status = "trigger"; + } else { + data.message = `${monitorJSON["name"]} is UP`; + data.status = "resolve"; + } + + data.tags["AlertAddress"] = this.extractAddress(monitorJSON); + + monitorJSON["tags"].forEach(tag => { + data.tags[tag["name"]] = { + value: tag["value"] + }; + if (tag["color"] !== null) { + data.tags[tag["name"]]["color"] = tag["color"]; + } + }); + } + + await axios.post(notification.squadcastWebhookURL, data, config); + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + + } + +} + +module.exports = Squadcast; diff --git a/server/notification-providers/stackfield.js b/server/notification-providers/stackfield.js new file mode 100644 index 0000000..65a9245 --- /dev/null +++ b/server/notification-providers/stackfield.js @@ -0,0 +1,44 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { setting } = require("../util-server"); +const { getMonitorRelativeURL } = require("../../src/util"); + +class Stackfield extends NotificationProvider { + name = "stackfield"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + // Stackfield message formatting: https://www.stackfield.com/help/formatting-messages-2001 + + let textMsg = "+Uptime Kuma Alert+"; + + if (monitorJSON && monitorJSON.name) { + textMsg += `\n*${monitorJSON.name}*`; + } + + textMsg += `\n${msg}`; + + const baseURL = await setting("primaryBaseURL"); + if (baseURL) { + textMsg += `\n${baseURL + getMonitorRelativeURL(monitorJSON.id)}`; + } + + const data = { + "Title": textMsg, + }; + + await axios.post(notification.stackfieldwebhookURL, data); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + + } +} + +module.exports = Stackfield; diff --git a/server/notification-providers/teams.js b/server/notification-providers/teams.js new file mode 100644 index 0000000..2793604 --- /dev/null +++ b/server/notification-providers/teams.js @@ -0,0 +1,240 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { setting } = require("../util-server"); +const { DOWN, UP, getMonitorRelativeURL } = require("../../src/util"); + +class Teams extends NotificationProvider { + name = "teams"; + + /** + * Generate the message to send + * @param {const} status The status constant + * @param {string} monitorName Name of monitor + * @param {boolean} withStatusSymbol If the status should be prepended as symbol + * @returns {string} Status message + */ + _statusMessageFactory = (status, monitorName, withStatusSymbol) => { + if (status === DOWN) { + return (withStatusSymbol ? "🔴 " : "") + `[${monitorName}] went down`; + } else if (status === UP) { + return (withStatusSymbol ? "✅ " : "") + `[${monitorName}] is back online`; + } + return "Notification"; + }; + + /** + * Select the style to use based on status + * @param {const} status The status constant + * @returns {string} Selected style for adaptive cards + */ + _getStyle = (status) => { + if (status === DOWN) { + return "attention"; + } + if (status === UP) { + return "good"; + } + return "emphasis"; + }; + + /** + * Generate payload for notification + * @param {object} args Method arguments + * @param {object} args.heartbeatJSON Heartbeat details + * @param {string} args.monitorName Name of the monitor affected + * @param {string} args.monitorUrl URL of the monitor affected + * @param {string} args.dashboardUrl URL of the dashboard affected + * @returns {object} Notification payload + */ + _notificationPayloadFactory = ({ + heartbeatJSON, + monitorName, + monitorUrl, + dashboardUrl, + }) => { + const status = heartbeatJSON?.status; + const facts = []; + const actions = []; + + if (dashboardUrl) { + actions.push({ + "type": "Action.OpenUrl", + "title": "Visit Uptime Kuma", + "url": dashboardUrl + }); + } + + if (heartbeatJSON?.msg) { + facts.push({ + title: "Description", + value: heartbeatJSON.msg, + }); + } + + if (monitorName) { + facts.push({ + title: "Monitor", + value: monitorName, + }); + } + + if (monitorUrl && monitorUrl !== "https://") { + facts.push({ + title: "URL", + // format URL as markdown syntax, to be clickable + value: `[${monitorUrl}](${monitorUrl})`, + }); + actions.push({ + "type": "Action.OpenUrl", + "title": "Visit Monitor URL", + "url": monitorUrl + }); + } + + if (heartbeatJSON?.localDateTime) { + facts.push({ + title: "Time", + value: heartbeatJSON.localDateTime + (heartbeatJSON.timezone ? ` (${heartbeatJSON.timezone})` : ""), + }); + } + + const payload = { + "type": "message", + // message with status prefix as notification text + "summary": this._statusMessageFactory(status, monitorName, true), + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": "", + "content": { + "type": "AdaptiveCard", + "body": [ + { + "type": "Container", + "verticalContentAlignment": "Center", + "items": [ + { + "type": "ColumnSet", + "style": this._getStyle(status), + "columns": [ + { + "type": "Column", + "width": "auto", + "verticalContentAlignment": "Center", + "items": [ + { + "type": "Image", + "width": "32px", + "style": "Person", + "url": "https://raw.githubusercontent.com/louislam/uptime-kuma/master/public/icon.png", + "altText": "Uptime Kuma Logo" + } + ] + }, + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "size": "Medium", + "weight": "Bolder", + "text": `**${this._statusMessageFactory(status, monitorName, false)}**`, + }, + { + "type": "TextBlock", + "size": "Small", + "weight": "Default", + "text": "Uptime Kuma Alert", + "isSubtle": true, + "spacing": "None" + } + ] + } + ] + } + ] + }, + { + "type": "FactSet", + "separator": false, + "facts": facts + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5" + } + } + ] + }; + + if (actions) { + payload.attachments[0].content.body.push({ + "type": "ActionSet", + "actions": actions, + }); + } + + return payload; + }; + + /** + * Send the notification + * @param {string} webhookUrl URL to send the request to + * @param {object} payload Payload generated by _notificationPayloadFactory + * @returns {Promise<void>} + */ + _sendNotification = async (webhookUrl, payload) => { + await axios.post(webhookUrl, payload); + }; + + /** + * Send a general notification + * @param {string} webhookUrl URL to send request to + * @param {string} msg Message to send + * @returns {Promise<void>} + */ + _handleGeneralNotification = (webhookUrl, msg) => { + const payload = this._notificationPayloadFactory({ + heartbeatJSON: { + msg: msg + } + }); + + return this._sendNotification(webhookUrl, payload); + }; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + if (heartbeatJSON == null) { + await this._handleGeneralNotification(notification.webhookUrl, msg); + return okMsg; + } + + const baseURL = await setting("primaryBaseURL"); + let dashboardUrl; + if (baseURL) { + dashboardUrl = baseURL + getMonitorRelativeURL(monitorJSON.id); + } + + const payload = this._notificationPayloadFactory({ + heartbeatJSON: heartbeatJSON, + monitorName: monitorJSON.name, + monitorUrl: this.extractAddress(monitorJSON), + dashboardUrl: dashboardUrl, + }); + + await this._sendNotification(notification.webhookUrl, payload); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Teams; diff --git a/server/notification-providers/techulus-push.js b/server/notification-providers/techulus-push.js new file mode 100644 index 0000000..bf688b1 --- /dev/null +++ b/server/notification-providers/techulus-push.js @@ -0,0 +1,36 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class TechulusPush extends NotificationProvider { + name = "PushByTechulus"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + let data = { + "title": notification?.pushTitle?.length ? notification.pushTitle : "Uptime-Kuma", + "body": msg, + "timeSensitive": notification.pushTimeSensitive ?? true, + }; + + if (notification.pushChannel) { + data.channel = notification.pushChannel; + } + + if (notification.pushSound) { + data.sound = notification.pushSound; + } + + try { + await axios.post(`https://push.techulus.com/api/v1/notify/${notification.pushAPIKey}`, data); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = TechulusPush; diff --git a/server/notification-providers/telegram.js b/server/notification-providers/telegram.js new file mode 100644 index 0000000..c5bbb19 --- /dev/null +++ b/server/notification-providers/telegram.js @@ -0,0 +1,36 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Telegram extends NotificationProvider { + name = "telegram"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + const url = "https://api.telegram.org"; + + try { + let params = { + chat_id: notification.telegramChatID, + text: msg, + disable_notification: notification.telegramSendSilently ?? false, + protect_content: notification.telegramProtectContent ?? false, + }; + if (notification.telegramMessageThreadID) { + params.message_thread_id = notification.telegramMessageThreadID; + } + + await axios.get(`${url}/bot${notification.telegramBotToken}/sendMessage`, { + params: params, + }); + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = Telegram; diff --git a/server/notification-providers/threema.js b/server/notification-providers/threema.js new file mode 100644 index 0000000..07a54ab --- /dev/null +++ b/server/notification-providers/threema.js @@ -0,0 +1,77 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Threema extends NotificationProvider { + name = "threema"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const url = "https://msgapi.threema.ch/send_simple"; + + const config = { + headers: { + "Accept": "*/*", + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8" + } + }; + + const data = { + from: notification.threemaSenderIdentity, + secret: notification.threemaSecret, + text: msg + }; + + switch (notification.threemaRecipientType) { + case "identity": + data.to = notification.threemaRecipient; + break; + case "phone": + data.phone = notification.threemaRecipient; + break; + case "email": + data.email = notification.threemaRecipient; + break; + default: + throw new Error(`Unsupported recipient type: ${notification.threemaRecipientType}`); + } + + try { + await axios.post(url, new URLSearchParams(data), config); + return "Threema notification sent successfully."; + } catch (error) { + const errorMessage = this.handleApiError(error); + this.throwGeneralAxiosError(errorMessage); + } + } + + /** + * Handle Threema API errors + * @param {any} error The error to handle + * @returns {string} Additional error context + */ + handleApiError(error) { + if (!error.response) { + return error.message; + } + switch (error.response.status) { + case 400: + return "Invalid recipient identity or account not set up for basic mode (400)."; + case 401: + return "Incorrect API identity or secret (401)."; + case 402: + return "No credits remaining (402)."; + case 404: + return "Recipient not found (404)."; + case 413: + return "Message is too long (413)."; + case 500: + return "Temporary internal server error (500)."; + default: + return error.message; + } + } +} + +module.exports = Threema; diff --git a/server/notification-providers/twilio.js b/server/notification-providers/twilio.js new file mode 100644 index 0000000..c38a6d7 --- /dev/null +++ b/server/notification-providers/twilio.js @@ -0,0 +1,38 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Twilio extends NotificationProvider { + name = "twilio"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + let apiKey = notification.twilioApiKey ? notification.twilioApiKey : notification.twilioAccountSID; + + try { + let config = { + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", + "Authorization": "Basic " + Buffer.from(apiKey + ":" + notification.twilioAuthToken).toString("base64"), + } + }; + + let data = new URLSearchParams(); + data.append("To", notification.twilioToNumber); + data.append("From", notification.twilioFromNumber); + data.append("Body", msg); + + await axios.post(`https://api.twilio.com/2010-04-01/Accounts/${(notification.twilioAccountSID)}/Messages.json`, data, config); + + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + +} + +module.exports = Twilio; diff --git a/server/notification-providers/webhook.js b/server/notification-providers/webhook.js new file mode 100644 index 0000000..986986d --- /dev/null +++ b/server/notification-providers/webhook.js @@ -0,0 +1,66 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const FormData = require("form-data"); +const { Liquid } = require("liquidjs"); + +class Webhook extends NotificationProvider { + name = "webhook"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let data = { + heartbeat: heartbeatJSON, + monitor: monitorJSON, + msg, + }; + let config = { + headers: {} + }; + + if (notification.webhookContentType === "form-data") { + const formData = new FormData(); + formData.append("data", JSON.stringify(data)); + config.headers = formData.getHeaders(); + data = formData; + } else if (notification.webhookContentType === "custom") { + // Initialize LiquidJS and parse the custom Body Template + const engine = new Liquid(); + const tpl = engine.parse(notification.webhookCustomBody); + + // Insert templated values into Body + data = await engine.render(tpl, + { + msg, + heartbeatJSON, + monitorJSON + }); + } + + if (notification.webhookAdditionalHeaders) { + try { + config.headers = { + ...config.headers, + ...JSON.parse(notification.webhookAdditionalHeaders) + }; + } catch (err) { + throw "Additional Headers is not a valid JSON"; + } + } + + await axios.post(notification.webhookURL, data, config); + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + + } + +} + +module.exports = Webhook; diff --git a/server/notification-providers/wecom.js b/server/notification-providers/wecom.js new file mode 100644 index 0000000..1eb0690 --- /dev/null +++ b/server/notification-providers/wecom.js @@ -0,0 +1,51 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class WeCom extends NotificationProvider { + name = "WeCom"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let config = { + headers: { + "Content-Type": "application/json" + } + }; + let body = this.composeMessage(heartbeatJSON, msg); + await axios.post(`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${notification.weComBotKey}`, body, config); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + + /** + * Generate the message to send + * @param {object} heartbeatJSON Heartbeat details (For Up/Down only) + * @param {string} msg General message + * @returns {object} Message + */ + composeMessage(heartbeatJSON, msg) { + let title = "UptimeKuma Message"; + if (msg != null && heartbeatJSON != null && heartbeatJSON["status"] === UP) { + title = "UptimeKuma Monitor Up"; + } + if (msg != null && heartbeatJSON != null && heartbeatJSON["status"] === DOWN) { + title = "UptimeKuma Monitor Down"; + } + return { + msgtype: "text", + text: { + content: title + "\n" + msg + } + }; + } +} + +module.exports = WeCom; diff --git a/server/notification-providers/whapi.js b/server/notification-providers/whapi.js new file mode 100644 index 0000000..70e0fbb --- /dev/null +++ b/server/notification-providers/whapi.js @@ -0,0 +1,39 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Whapi extends NotificationProvider { + name = "whapi"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + const config = { + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": "Bearer " + notification.whapiAuthToken, + } + }; + + let data = { + "to": notification.whapiRecipient, + "body": msg, + }; + + let url = (notification.whapiApiUrl || "https://gate.whapi.cloud/").replace(/\/+$/, "") + "/messages/text"; + + await axios.post(url, data, config); + + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + +} + +module.exports = Whapi; diff --git a/server/notification-providers/wpush.js b/server/notification-providers/wpush.js new file mode 100644 index 0000000..db043f9 --- /dev/null +++ b/server/notification-providers/wpush.js @@ -0,0 +1,51 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class WPush extends NotificationProvider { + name = "WPush"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + const context = { + "title": this.checkStatus(heartbeatJSON, monitorJSON), + "content": msg, + "apikey": notification.wpushAPIkey, + "channel": notification.wpushChannel + }; + const result = await axios.post("https://api.wpush.cn/api/v1/send", context); + if (result.data.code !== 0) { + throw result.data.message; + } + + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + + /** + * Get the formatted title for message + * @param {?object} heartbeatJSON Heartbeat details (For Up/Down only) + * @param {?object} monitorJSON Monitor details (For Up/Down only) + * @returns {string} Formatted title + */ + checkStatus(heartbeatJSON, monitorJSON) { + let title = "UptimeKuma Message"; + if (heartbeatJSON != null && heartbeatJSON["status"] === UP) { + title = "UptimeKuma Monitor Up " + monitorJSON["name"]; + } + if (heartbeatJSON != null && heartbeatJSON["status"] === DOWN) { + title = "UptimeKuma Monitor Down " + monitorJSON["name"]; + } + return title; + } +} + +module.exports = WPush; diff --git a/server/notification-providers/zoho-cliq.js b/server/notification-providers/zoho-cliq.js new file mode 100644 index 0000000..3a504de --- /dev/null +++ b/server/notification-providers/zoho-cliq.js @@ -0,0 +1,101 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class ZohoCliq extends NotificationProvider { + name = "ZohoCliq"; + + /** + * Generate the message to send + * @param {const} status The status constant + * @param {string} monitorName Name of monitor + * @returns {string} Status message + */ + _statusMessageFactory = (status, monitorName) => { + if (status === DOWN) { + return `🔴 [${monitorName}] went down\n`; + } else if (status === UP) { + return `### ✅ [${monitorName}] is back online\n`; + } + return "Notification\n"; + }; + + /** + * Send the notification + * @param {string} webhookUrl URL to send the request to + * @param {Array} payload Payload generated by _notificationPayloadFactory + * @returns {Promise<void>} + */ + _sendNotification = async (webhookUrl, payload) => { + await axios.post(webhookUrl, { text: payload.join("\n") }); + }; + + /** + * Generate payload for notification + * @param {object} args Method arguments + * @param {const} args.status The status of the monitor + * @param {string} args.monitorMessage Message to send + * @param {string} args.monitorName Name of monitor affected + * @param {string} args.monitorUrl URL of monitor affected + * @returns {Array} Notification payload + */ + _notificationPayloadFactory = ({ + status, + monitorMessage, + monitorName, + monitorUrl, + }) => { + const payload = []; + payload.push(this._statusMessageFactory(status, monitorName)); + payload.push(`*Description:* ${monitorMessage}`); + + if (monitorUrl && monitorUrl !== "https://") { + payload.push(`*URL:* ${monitorUrl}`); + } + + return payload; + }; + + /** + * Send a general notification + * @param {string} webhookUrl URL to send request to + * @param {string} msg Message to send + * @returns {Promise<void>} + */ + _handleGeneralNotification = (webhookUrl, msg) => { + const payload = this._notificationPayloadFactory({ + monitorMessage: msg + }); + + return this._sendNotification(webhookUrl, payload); + }; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + if (heartbeatJSON == null) { + await this._handleGeneralNotification(notification.webhookUrl, msg); + return okMsg; + } + + const payload = this._notificationPayloadFactory({ + monitorMessage: heartbeatJSON.msg, + monitorName: monitorJSON.name, + monitorUrl: this.extractAddress(monitorJSON), + status: heartbeatJSON.status + }); + + await this._sendNotification(notification.webhookUrl, payload); + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = ZohoCliq; diff --git a/server/notification.js b/server/notification.js new file mode 100644 index 0000000..e7977eb --- /dev/null +++ b/server/notification.js @@ -0,0 +1,284 @@ +const { R } = require("redbean-node"); +const { log } = require("../src/util"); +const Alerta = require("./notification-providers/alerta"); +const AlertNow = require("./notification-providers/alertnow"); +const AliyunSms = require("./notification-providers/aliyun-sms"); +const Apprise = require("./notification-providers/apprise"); +const Bark = require("./notification-providers/bark"); +const Bitrix24 = require("./notification-providers/bitrix24"); +const ClickSendSMS = require("./notification-providers/clicksendsms"); +const CallMeBot = require("./notification-providers/call-me-bot"); +const SMSC = require("./notification-providers/smsc"); +const DingDing = require("./notification-providers/dingding"); +const Discord = require("./notification-providers/discord"); +const Elks = require("./notification-providers/46elks"); +const Feishu = require("./notification-providers/feishu"); +const FreeMobile = require("./notification-providers/freemobile"); +const GoogleChat = require("./notification-providers/google-chat"); +const Gorush = require("./notification-providers/gorush"); +const Gotify = require("./notification-providers/gotify"); +const GrafanaOncall = require("./notification-providers/grafana-oncall"); +const HomeAssistant = require("./notification-providers/home-assistant"); +const HeiiOnCall = require("./notification-providers/heii-oncall"); +const Keep = require("./notification-providers/keep"); +const Kook = require("./notification-providers/kook"); +const Line = require("./notification-providers/line"); +const LineNotify = require("./notification-providers/linenotify"); +const LunaSea = require("./notification-providers/lunasea"); +const Matrix = require("./notification-providers/matrix"); +const Mattermost = require("./notification-providers/mattermost"); +const Nostr = require("./notification-providers/nostr"); +const Ntfy = require("./notification-providers/ntfy"); +const Octopush = require("./notification-providers/octopush"); +const OneBot = require("./notification-providers/onebot"); +const Opsgenie = require("./notification-providers/opsgenie"); +const PagerDuty = require("./notification-providers/pagerduty"); +const FlashDuty = require("./notification-providers/flashduty"); +const PagerTree = require("./notification-providers/pagertree"); +const PromoSMS = require("./notification-providers/promosms"); +const Pushbullet = require("./notification-providers/pushbullet"); +const PushDeer = require("./notification-providers/pushdeer"); +const Pushover = require("./notification-providers/pushover"); +const Pushy = require("./notification-providers/pushy"); +const RocketChat = require("./notification-providers/rocket-chat"); +const SerwerSMS = require("./notification-providers/serwersms"); +const Signal = require("./notification-providers/signal"); +const SIGNL4 = require("./notification-providers/signl4"); +const Slack = require("./notification-providers/slack"); +const SMSPartner = require("./notification-providers/smspartner"); +const SMSEagle = require("./notification-providers/smseagle"); +const SMTP = require("./notification-providers/smtp"); +const Squadcast = require("./notification-providers/squadcast"); +const Stackfield = require("./notification-providers/stackfield"); +const Teams = require("./notification-providers/teams"); +const TechulusPush = require("./notification-providers/techulus-push"); +const Telegram = require("./notification-providers/telegram"); +const Threema = require("./notification-providers/threema"); +const Twilio = require("./notification-providers/twilio"); +const Splunk = require("./notification-providers/splunk"); +const Webhook = require("./notification-providers/webhook"); +const WeCom = require("./notification-providers/wecom"); +const GoAlert = require("./notification-providers/goalert"); +const SMSManager = require("./notification-providers/smsmanager"); +const ServerChan = require("./notification-providers/serverchan"); +const ZohoCliq = require("./notification-providers/zoho-cliq"); +const SevenIO = require("./notification-providers/sevenio"); +const Whapi = require("./notification-providers/whapi"); +const GtxMessaging = require("./notification-providers/gtx-messaging"); +const Cellsynt = require("./notification-providers/cellsynt"); +const Onesender = require("./notification-providers/onesender"); +const Wpush = require("./notification-providers/wpush"); +const SendGrid = require("./notification-providers/send-grid"); + +class Notification { + + providerList = {}; + + /** + * Initialize the notification providers + * @returns {void} + * @throws Notification provider does not have a name + * @throws Duplicate notification providers in list + */ + static init() { + log.debug("notification", "Prepare Notification Providers"); + + this.providerList = {}; + + const list = [ + new Alerta(), + new AlertNow(), + new AliyunSms(), + new Apprise(), + new Bark(), + new Bitrix24(), + new ClickSendSMS(), + new CallMeBot(), + new SMSC(), + new DingDing(), + new Discord(), + new Elks(), + new Feishu(), + new FreeMobile(), + new GoogleChat(), + new Gorush(), + new Gotify(), + new GrafanaOncall(), + new HomeAssistant(), + new HeiiOnCall(), + new Keep(), + new Kook(), + new Line(), + new LineNotify(), + new LunaSea(), + new Matrix(), + new Mattermost(), + new Nostr(), + new Ntfy(), + new Octopush(), + new OneBot(), + new Onesender(), + new Opsgenie(), + new PagerDuty(), + new FlashDuty(), + new PagerTree(), + new PromoSMS(), + new Pushbullet(), + new PushDeer(), + new Pushover(), + new Pushy(), + new RocketChat(), + new ServerChan(), + new SerwerSMS(), + new Signal(), + new SIGNL4(), + new SMSManager(), + new SMSPartner(), + new Slack(), + new SMSEagle(), + new SMTP(), + new Squadcast(), + new Stackfield(), + new Teams(), + new TechulusPush(), + new Telegram(), + new Threema(), + new Twilio(), + new Splunk(), + new Webhook(), + new WeCom(), + new GoAlert(), + new ZohoCliq(), + new SevenIO(), + new Whapi(), + new GtxMessaging(), + new Cellsynt(), + new Wpush(), + new SendGrid() + ]; + for (let item of list) { + if (! item.name) { + throw new Error("Notification provider without name"); + } + + if (this.providerList[item.name]) { + throw new Error("Duplicate notification provider name"); + } + this.providerList[item.name] = item; + } + } + + /** + * Send a notification + * @param {BeanModel} notification Notification to send + * @param {string} msg General Message + * @param {object} monitorJSON Monitor details (For Up/Down only) + * @param {object} heartbeatJSON Heartbeat details (For Up/Down only) + * @returns {Promise<string>} Successful msg + * @throws Error with fail msg + */ + static async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + if (this.providerList[notification.type]) { + return this.providerList[notification.type].send(notification, msg, monitorJSON, heartbeatJSON); + } else { + throw new Error("Notification type is not supported"); + } + } + + /** + * Save a notification + * @param {object} notification Notification to save + * @param {?number} notificationID ID of notification to update + * @param {number} userID ID of user who adds notification + * @returns {Promise<Bean>} Notification that was saved + */ + static async save(notification, notificationID, userID) { + let bean; + + if (notificationID) { + bean = await R.findOne("notification", " id = ? AND user_id = ? ", [ + notificationID, + userID, + ]); + + if (! bean) { + throw new Error("notification not found"); + } + + } else { + bean = R.dispense("notification"); + } + + bean.name = notification.name; + bean.user_id = userID; + bean.config = JSON.stringify(notification); + bean.is_default = notification.isDefault || false; + await R.store(bean); + + if (notification.applyExisting) { + await applyNotificationEveryMonitor(bean.id, userID); + } + + return bean; + } + + /** + * Delete a notification + * @param {number} notificationID ID of notification to delete + * @param {number} userID ID of user who created notification + * @returns {Promise<void>} + */ + static async delete(notificationID, userID) { + let bean = await R.findOne("notification", " id = ? AND user_id = ? ", [ + notificationID, + userID, + ]); + + if (! bean) { + throw new Error("notification not found"); + } + + await R.trash(bean); + } + + /** + * Check if apprise exists + * @returns {boolean} Does the command apprise exist? + */ + static checkApprise() { + let commandExistsSync = require("command-exists").sync; + let exists = commandExistsSync("apprise"); + return exists; + } + +} + +/** + * Apply the notification to every monitor + * @param {number} notificationID ID of notification to apply + * @param {number} userID ID of user who created notification + * @returns {Promise<void>} + */ +async function applyNotificationEveryMonitor(notificationID, userID) { + let monitors = await R.getAll("SELECT id FROM monitor WHERE user_id = ?", [ + userID + ]); + + for (let i = 0; i < monitors.length; i++) { + let checkNotification = await R.findOne("monitor_notification", " monitor_id = ? AND notification_id = ? ", [ + monitors[i].id, + notificationID, + ]); + + if (! checkNotification) { + let relation = R.dispense("monitor_notification"); + relation.monitor_id = monitors[i].id; + relation.notification_id = notificationID; + await R.store(relation); + } + } +} + +module.exports = { + Notification, +}; diff --git a/server/password-hash.js b/server/password-hash.js new file mode 100644 index 0000000..83a23d9 --- /dev/null +++ b/server/password-hash.js @@ -0,0 +1,44 @@ +const passwordHashOld = require("password-hash"); +const bcrypt = require("bcryptjs"); +const saltRounds = 10; + +/** + * Hash a password + * @param {string} password Password to hash + * @returns {string} Hash + */ +exports.generate = function (password) { + return bcrypt.hashSync(password, saltRounds); +}; + +/** + * Verify a password against a hash + * @param {string} password Password to verify + * @param {string} hash Hash to verify against + * @returns {boolean} Does the password match the hash? + */ +exports.verify = function (password, hash) { + if (isSHA1(hash)) { + return passwordHashOld.verify(password, hash); + } + + return bcrypt.compareSync(password, hash); +}; + +/** + * Is the hash a SHA1 hash + * @param {string} hash Hash to check + * @returns {boolean} Is SHA1 hash? + */ +function isSHA1(hash) { + return (typeof hash === "string" && hash.startsWith("sha1")); +} + +/** + * Does the hash need to be rehashed? + * @param {string} hash Hash to check + * @returns {boolean} Needs to be rehashed? + */ +exports.needRehash = function (hash) { + return isSHA1(hash); +}; diff --git a/server/prometheus.js b/server/prometheus.js new file mode 100644 index 0000000..f26125d --- /dev/null +++ b/server/prometheus.js @@ -0,0 +1,123 @@ +const PrometheusClient = require("prom-client"); +const { log } = require("../src/util"); + +const commonLabels = [ + "monitor_name", + "monitor_type", + "monitor_url", + "monitor_hostname", + "monitor_port", +]; + +const monitorCertDaysRemaining = new PrometheusClient.Gauge({ + name: "monitor_cert_days_remaining", + help: "The number of days remaining until the certificate expires", + labelNames: commonLabels +}); + +const monitorCertIsValid = new PrometheusClient.Gauge({ + name: "monitor_cert_is_valid", + help: "Is the certificate still valid? (1 = Yes, 0= No)", + labelNames: commonLabels +}); +const monitorResponseTime = new PrometheusClient.Gauge({ + name: "monitor_response_time", + help: "Monitor Response Time (ms)", + labelNames: commonLabels +}); + +const monitorStatus = new PrometheusClient.Gauge({ + name: "monitor_status", + help: "Monitor Status (1 = UP, 0= DOWN, 2= PENDING, 3= MAINTENANCE)", + labelNames: commonLabels +}); + +class Prometheus { + monitorLabelValues = {}; + + /** + * @param {object} monitor Monitor object to monitor + */ + constructor(monitor) { + this.monitorLabelValues = { + monitor_name: monitor.name, + monitor_type: monitor.type, + monitor_url: monitor.url, + monitor_hostname: monitor.hostname, + monitor_port: monitor.port + }; + } + + /** + * Update the metrics page + * @param {object} heartbeat Heartbeat details + * @param {object} tlsInfo TLS details + * @returns {void} + */ + update(heartbeat, tlsInfo) { + + if (typeof tlsInfo !== "undefined") { + try { + let isValid; + if (tlsInfo.valid === true) { + isValid = 1; + } else { + isValid = 0; + } + monitorCertIsValid.set(this.monitorLabelValues, isValid); + } catch (e) { + log.error("prometheus", "Caught error"); + log.error("prometheus", e); + } + + try { + if (tlsInfo.certInfo != null) { + monitorCertDaysRemaining.set(this.monitorLabelValues, tlsInfo.certInfo.daysRemaining); + } + } catch (e) { + log.error("prometheus", "Caught error"); + log.error("prometheus", e); + } + } + + if (heartbeat) { + try { + monitorStatus.set(this.monitorLabelValues, heartbeat.status); + } catch (e) { + log.error("prometheus", "Caught error"); + log.error("prometheus", e); + } + + try { + if (typeof heartbeat.ping === "number") { + monitorResponseTime.set(this.monitorLabelValues, heartbeat.ping); + } else { + // Is it good? + monitorResponseTime.set(this.monitorLabelValues, -1); + } + } catch (e) { + log.error("prometheus", "Caught error"); + log.error("prometheus", e); + } + } + } + + /** + * Remove monitor from prometheus + * @returns {void} + */ + remove() { + try { + monitorCertDaysRemaining.remove(this.monitorLabelValues); + monitorCertIsValid.remove(this.monitorLabelValues); + monitorResponseTime.remove(this.monitorLabelValues); + monitorStatus.remove(this.monitorLabelValues); + } catch (e) { + console.error(e); + } + } +} + +module.exports = { + Prometheus +}; diff --git a/server/proxy.js b/server/proxy.js new file mode 100644 index 0000000..3f3771a --- /dev/null +++ b/server/proxy.js @@ -0,0 +1,202 @@ +const { R } = require("redbean-node"); +const HttpProxyAgent = require("http-proxy-agent"); +const HttpsProxyAgent = require("https-proxy-agent"); +const SocksProxyAgent = require("socks-proxy-agent"); +const { debug } = require("../src/util"); +const { UptimeKumaServer } = require("./uptime-kuma-server"); +const { CookieJar } = require("tough-cookie"); +const { createCookieAgent } = require("http-cookie-agent/http"); + +class Proxy { + + static SUPPORTED_PROXY_PROTOCOLS = [ "http", "https", "socks", "socks5", "socks5h", "socks4" ]; + + /** + * Saves and updates given proxy entity + * @param {object} proxy Proxy to store + * @param {number} proxyID ID of proxy to update + * @param {number} userID ID of user the proxy belongs to + * @returns {Promise<Bean>} Updated proxy + */ + static async save(proxy, proxyID, userID) { + let bean; + + if (proxyID) { + bean = await R.findOne("proxy", " id = ? AND user_id = ? ", [ proxyID, userID ]); + + if (!bean) { + throw new Error("proxy not found"); + } + + } else { + bean = R.dispense("proxy"); + } + + // Make sure given proxy protocol is supported + if (!this.SUPPORTED_PROXY_PROTOCOLS.includes(proxy.protocol)) { + throw new Error(` + Unsupported proxy protocol "${proxy.protocol}. + Supported protocols are ${this.SUPPORTED_PROXY_PROTOCOLS.join(", ")}."` + ); + } + + // When proxy is default update deactivate old default proxy + if (proxy.default) { + await R.exec("UPDATE proxy SET `default` = 0 WHERE `default` = 1"); + } + + bean.user_id = userID; + bean.protocol = proxy.protocol; + bean.host = proxy.host; + bean.port = proxy.port; + bean.auth = proxy.auth; + bean.username = proxy.username; + bean.password = proxy.password; + bean.active = proxy.active || true; + bean.default = proxy.default || false; + + await R.store(bean); + + if (proxy.applyExisting) { + await applyProxyEveryMonitor(bean.id, userID); + } + + return bean; + } + + /** + * Deletes proxy with given id and removes it from monitors + * @param {number} proxyID ID of proxy to delete + * @param {number} userID ID of proxy owner + * @returns {Promise<void>} + */ + static async delete(proxyID, userID) { + const bean = await R.findOne("proxy", " id = ? AND user_id = ? ", [ proxyID, userID ]); + + if (!bean) { + throw new Error("proxy not found"); + } + + // Delete removed proxy from monitors if exists + await R.exec("UPDATE monitor SET proxy_id = null WHERE proxy_id = ?", [ proxyID ]); + + // Delete proxy from list + await R.trash(bean); + } + + /** + * Create HTTP and HTTPS agents related with given proxy bean object + * @param {object} proxy proxy bean object + * @param {object} options http and https agent options + * @returns {{httpAgent: Agent, httpsAgent: Agent}} New HTTP and HTTPS agents + * @throws Proxy protocol is unsupported + */ + static createAgents(proxy, options) { + const { httpAgentOptions, httpsAgentOptions } = options || {}; + let agent; + let httpAgent; + let httpsAgent; + + let jar = new CookieJar(); + + const proxyOptions = { + protocol: proxy.protocol, + host: proxy.host, + port: proxy.port, + cookies: { jar }, + }; + + if (proxy.auth) { + proxyOptions.auth = `${proxy.username}:${proxy.password}`; + } + + debug(`Proxy Options: ${JSON.stringify(proxyOptions)}`); + debug(`HTTP Agent Options: ${JSON.stringify(httpAgentOptions)}`); + debug(`HTTPS Agent Options: ${JSON.stringify(httpsAgentOptions)}`); + + switch (proxy.protocol) { + case "http": + case "https": + // eslint-disable-next-line no-case-declarations + const HttpCookieProxyAgent = createCookieAgent(HttpProxyAgent); + // eslint-disable-next-line no-case-declarations + const HttpsCookieProxyAgent = createCookieAgent(HttpsProxyAgent); + + httpAgent = new HttpCookieProxyAgent({ + ...httpAgentOptions || {}, + ...proxyOptions, + }); + + httpsAgent = new HttpsCookieProxyAgent({ + ...httpsAgentOptions || {}, + ...proxyOptions, + }); + break; + case "socks": + case "socks5": + case "socks5h": + case "socks4": + // eslint-disable-next-line no-case-declarations + const SocksCookieProxyAgent = createCookieAgent(SocksProxyAgent); + agent = new SocksCookieProxyAgent({ + ...httpAgentOptions, + ...httpsAgentOptions, + ...proxyOptions, + tls: { + rejectUnauthorized: httpsAgentOptions.rejectUnauthorized, + }, + }); + + httpAgent = agent; + httpsAgent = agent; + break; + + default: throw new Error(`Unsupported proxy protocol provided. ${proxy.protocol}`); + } + + return { + httpAgent, + httpsAgent + }; + } + + /** + * Reload proxy settings for current monitors + * @returns {Promise<void>} + */ + static async reloadProxy() { + const server = UptimeKumaServer.getInstance(); + + let updatedList = await R.getAssoc("SELECT id, proxy_id FROM monitor"); + + for (let monitorID in server.monitorList) { + let monitor = server.monitorList[monitorID]; + + if (updatedList[monitorID]) { + monitor.proxy_id = updatedList[monitorID].proxy_id; + } + } + } +} + +/** + * Applies given proxy id to monitors + * @param {number} proxyID ID of proxy to apply + * @param {number} userID ID of proxy owner + * @returns {Promise<void>} + */ +async function applyProxyEveryMonitor(proxyID, userID) { + // Find all monitors with id and proxy id + const monitors = await R.getAll("SELECT id, proxy_id FROM monitor WHERE user_id = ?", [ userID ]); + + // Update proxy id not match with given proxy id + for (const monitor of monitors) { + if (monitor.proxy_id !== proxyID) { + await R.exec("UPDATE monitor SET proxy_id = ? WHERE id = ?", [ proxyID, monitor.id ]); + } + } +} + +module.exports = { + Proxy, +}; diff --git a/server/rate-limiter.js b/server/rate-limiter.js new file mode 100644 index 0000000..3c269b6 --- /dev/null +++ b/server/rate-limiter.js @@ -0,0 +1,75 @@ +const { RateLimiter } = require("limiter"); +const { log } = require("../src/util"); + +class KumaRateLimiter { + /** + * @param {object} config Rate limiter configuration object + */ + constructor(config) { + this.errorMessage = config.errorMessage; + this.rateLimiter = new RateLimiter(config); + } + + /** + * Callback for pass + * @callback passCB + * @param {object} err Too many requests + */ + + /** + * Should the request be passed through + * @param {passCB} callback Callback function to call with decision + * @param {number} num Number of tokens to remove + * @returns {Promise<boolean>} Should the request be allowed? + */ + async pass(callback, num = 1) { + const remainingRequests = await this.removeTokens(num); + log.info("rate-limit", "remaining requests: " + remainingRequests); + if (remainingRequests < 0) { + if (callback) { + callback({ + ok: false, + msg: this.errorMessage, + }); + } + return false; + } + return true; + } + + /** + * Remove a given number of tokens + * @param {number} num Number of tokens to remove + * @returns {Promise<number>} Number of remaining tokens + */ + async removeTokens(num = 1) { + return await this.rateLimiter.removeTokens(num); + } +} + +const loginRateLimiter = new KumaRateLimiter({ + tokensPerInterval: 20, + interval: "minute", + fireImmediately: true, + errorMessage: "Too frequently, try again later." +}); + +const apiRateLimiter = new KumaRateLimiter({ + tokensPerInterval: 60, + interval: "minute", + fireImmediately: true, + errorMessage: "Too frequently, try again later." +}); + +const twoFaRateLimiter = new KumaRateLimiter({ + tokensPerInterval: 30, + interval: "minute", + fireImmediately: true, + errorMessage: "Too frequently, try again later." +}); + +module.exports = { + loginRateLimiter, + apiRateLimiter, + twoFaRateLimiter, +}; diff --git a/server/remote-browser.js b/server/remote-browser.js new file mode 100644 index 0000000..da8e9a5 --- /dev/null +++ b/server/remote-browser.js @@ -0,0 +1,74 @@ +const { R } = require("redbean-node"); + +class RemoteBrowser { + + /** + * Gets remote browser from ID + * @param {number} remoteBrowserID ID of the remote browser + * @param {number} userID ID of the user who created the remote browser + * @returns {Promise<Bean>} Remote Browser + */ + static async get(remoteBrowserID, userID) { + let bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]); + + if (!bean) { + throw new Error("Remote browser not found"); + } + + return bean; + } + + /** + * Save a Remote Browser + * @param {object} remoteBrowser Remote Browser to save + * @param {?number} remoteBrowserID ID of the Remote Browser to update + * @param {number} userID ID of the user who adds the Remote Browser + * @returns {Promise<Bean>} Updated Remote Browser + */ + static async save(remoteBrowser, remoteBrowserID, userID) { + let bean; + + if (remoteBrowserID) { + bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]); + + if (!bean) { + throw new Error("Remote browser not found"); + } + + } else { + bean = R.dispense("remote_browser"); + } + + bean.user_id = userID; + bean.name = remoteBrowser.name; + bean.url = remoteBrowser.url; + + await R.store(bean); + + return bean; + } + + /** + * Delete a Remote Browser + * @param {number} remoteBrowserID ID of the Remote Browser to delete + * @param {number} userID ID of the user who created the Remote Browser + * @returns {Promise<void>} + */ + static async delete(remoteBrowserID, userID) { + let bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]); + + if (!bean) { + throw new Error("Remote Browser not found"); + } + + // Delete removed remote browser from monitors if exists + await R.exec("UPDATE monitor SET remote_browser = null WHERE remote_browser = ?", [ remoteBrowserID ]); + + await R.trash(bean); + } + +} + +module.exports = { + RemoteBrowser, +}; diff --git a/server/routers/api-router.js b/server/routers/api-router.js new file mode 100644 index 0000000..ed6db2c --- /dev/null +++ b/server/routers/api-router.js @@ -0,0 +1,631 @@ +let express = require("express"); +const { + setting, + allowDevAllOrigin, + allowAllOrigin, + percentageToColor, + filterAndJoin, + sendHttpError, +} = require("../util-server"); +const { R } = require("redbean-node"); +const apicache = require("../modules/apicache"); +const Monitor = require("../model/monitor"); +const dayjs = require("dayjs"); +const { UP, MAINTENANCE, DOWN, PENDING, flipStatus, log, badgeConstants } = require("../../src/util"); +const StatusPage = require("../model/status_page"); +const { UptimeKumaServer } = require("../uptime-kuma-server"); +const { makeBadge } = require("badge-maker"); +const { Prometheus } = require("../prometheus"); +const Database = require("../database"); +const { UptimeCalculator } = require("../uptime-calculator"); + +let router = express.Router(); + +let cache = apicache.middleware; +const server = UptimeKumaServer.getInstance(); +let io = server.io; + +router.get("/api/entry-page", async (request, response) => { + allowDevAllOrigin(response); + + let result = { }; + let hostname = request.hostname; + if ((await setting("trustProxy")) && request.headers["x-forwarded-host"]) { + hostname = request.headers["x-forwarded-host"]; + } + + if (hostname in StatusPage.domainMappingList) { + result.type = "statusPageMatchedDomain"; + result.statusPageSlug = StatusPage.domainMappingList[hostname]; + } else { + result.type = "entryPage"; + result.entryPage = server.entryPage; + } + response.json(result); +}); + +router.all("/api/push/:pushToken", async (request, response) => { + try { + let pushToken = request.params.pushToken; + let msg = request.query.msg || "OK"; + let ping = parseFloat(request.query.ping) || null; + let statusString = request.query.status || "up"; + let status = (statusString === "up") ? UP : DOWN; + + let monitor = await R.findOne("monitor", " push_token = ? AND active = 1 ", [ + pushToken + ]); + + if (! monitor) { + throw new Error("Monitor not found or not active."); + } + + const previousHeartbeat = await Monitor.getPreviousHeartbeat(monitor.id); + + let isFirstBeat = true; + + let bean = R.dispense("heartbeat"); + bean.time = R.isoDateTimeMillis(dayjs.utc()); + bean.monitor_id = monitor.id; + bean.ping = ping; + bean.msg = msg; + bean.downCount = previousHeartbeat?.downCount || 0; + + if (previousHeartbeat) { + isFirstBeat = false; + bean.duration = dayjs(bean.time).diff(dayjs(previousHeartbeat.time), "second"); + } + + if (await Monitor.isUnderMaintenance(monitor.id)) { + msg = "Monitor under maintenance"; + bean.status = MAINTENANCE; + } else { + determineStatus(status, previousHeartbeat, monitor.maxretries, monitor.isUpsideDown(), bean); + } + + // Calculate uptime + let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitor.id); + let endTimeDayjs = await uptimeCalculator.update(bean.status, parseFloat(bean.ping)); + bean.end_time = R.isoDateTimeMillis(endTimeDayjs); + + log.debug("router", `/api/push/ called at ${dayjs().format("YYYY-MM-DD HH:mm:ss.SSS")}`); + log.debug("router", "PreviousStatus: " + previousHeartbeat?.status); + log.debug("router", "Current Status: " + bean.status); + + bean.important = Monitor.isImportantBeat(isFirstBeat, previousHeartbeat?.status, status); + + if (Monitor.isImportantForNotification(isFirstBeat, previousHeartbeat?.status, status)) { + // Reset down count + bean.downCount = 0; + + log.debug("monitor", `[${this.name}] sendNotification`); + await Monitor.sendNotification(isFirstBeat, monitor, bean); + } else { + if (bean.status === DOWN && this.resendInterval > 0) { + ++bean.downCount; + if (bean.downCount >= this.resendInterval) { + // Send notification again, because we are still DOWN + log.debug("monitor", `[${this.name}] sendNotification again: Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`); + await Monitor.sendNotification(isFirstBeat, this, bean); + + // Reset down count + bean.downCount = 0; + } + } + } + + await R.store(bean); + + io.to(monitor.user_id).emit("heartbeat", bean.toJSON()); + + Monitor.sendStats(io, monitor.id, monitor.user_id); + new Prometheus(monitor).update(bean, undefined); + + response.json({ + ok: true, + }); + } catch (e) { + response.status(404).json({ + ok: false, + msg: e.message + }); + } +}); + +router.get("/api/badge/:id/status", cache("5 minutes"), async (request, response) => { + allowAllOrigin(response); + + const { + label, + upLabel = "Up", + downLabel = "Down", + pendingLabel = "Pending", + maintenanceLabel = "Maintenance", + upColor = badgeConstants.defaultUpColor, + downColor = badgeConstants.defaultDownColor, + pendingColor = badgeConstants.defaultPendingColor, + maintenanceColor = badgeConstants.defaultMaintenanceColor, + style = badgeConstants.defaultStyle, + value, // for demo purpose only + } = request.query; + + try { + const requestedMonitorId = parseInt(request.params.id, 10); + const overrideValue = value !== undefined ? parseInt(value) : undefined; + + let publicMonitor = await R.getRow(` + SELECT monitor_group.monitor_id FROM monitor_group, \`group\` + WHERE monitor_group.group_id = \`group\`.id + AND monitor_group.monitor_id = ? + AND public = 1 + `, + [ requestedMonitorId ] + ); + + const badgeValues = { style }; + + if (!publicMonitor) { + // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non exsitant + + badgeValues.message = "N/A"; + badgeValues.color = badgeConstants.naColor; + } else { + const heartbeat = await Monitor.getPreviousHeartbeat(requestedMonitorId); + const state = overrideValue !== undefined ? overrideValue : heartbeat.status; + + if (label === undefined) { + badgeValues.label = "Status"; + } else { + badgeValues.label = label; + } + switch (state) { + case DOWN: + badgeValues.color = downColor; + badgeValues.message = downLabel; + break; + case UP: + badgeValues.color = upColor; + badgeValues.message = upLabel; + break; + case PENDING: + badgeValues.color = pendingColor; + badgeValues.message = pendingLabel; + break; + case MAINTENANCE: + badgeValues.color = maintenanceColor; + badgeValues.message = maintenanceLabel; + break; + default: + badgeValues.color = badgeConstants.naColor; + badgeValues.message = "N/A"; + } + } + + // build the svg based on given values + const svg = makeBadge(badgeValues); + + response.type("image/svg+xml"); + response.send(svg); + } catch (error) { + sendHttpError(response, error.message); + } +}); + +router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (request, response) => { + allowAllOrigin(response); + + const { + label, + labelPrefix, + labelSuffix = badgeConstants.defaultUptimeLabelSuffix, + prefix, + suffix = badgeConstants.defaultUptimeValueSuffix, + color, + labelColor, + style = badgeConstants.defaultStyle, + value, // for demo purpose only + } = request.query; + + try { + const requestedMonitorId = parseInt(request.params.id, 10); + // if no duration is given, set value to 24 (h) + let requestedDuration = request.params.duration !== undefined ? request.params.duration : "24h"; + const overrideValue = value && parseFloat(value); + + if (/^[0-9]+$/.test(requestedDuration)) { + requestedDuration = `${requestedDuration}h`; + } + + let publicMonitor = await R.getRow(` + SELECT monitor_group.monitor_id FROM monitor_group, \`group\` + WHERE monitor_group.group_id = \`group\`.id + AND monitor_group.monitor_id = ? + AND public = 1 + `, + [ requestedMonitorId ] + ); + + const badgeValues = { style }; + + if (!publicMonitor) { + // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non existent + badgeValues.message = "N/A"; + badgeValues.color = badgeConstants.naColor; + } else { + const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(requestedMonitorId); + const uptime = overrideValue ?? uptimeCalculator.getDataByDuration(requestedDuration).uptime; + + // limit the displayed uptime percentage to four (two, when displayed as percent) decimal digits + const cleanUptime = (uptime * 100).toPrecision(4); + + // use a given, custom color or calculate one based on the uptime value + badgeValues.color = color ?? percentageToColor(uptime); + // use a given, custom labelColor or use the default badge label color (defined by badge-maker) + badgeValues.labelColor = labelColor ?? ""; + // build a label string. If a custom label is given, override the default one (requestedDuration) + badgeValues.label = filterAndJoin([ + labelPrefix, + label ?? `Uptime (${requestedDuration.slice(0, -1)}${labelSuffix})`, + ]); + badgeValues.message = filterAndJoin([ prefix, cleanUptime, suffix ]); + } + + // build the SVG based on given values + const svg = makeBadge(badgeValues); + + response.type("image/svg+xml"); + response.send(svg); + } catch (error) { + sendHttpError(response, error.message); + } +}); + +router.get("/api/badge/:id/ping/:duration?", cache("5 minutes"), async (request, response) => { + allowAllOrigin(response); + + const { + label, + labelPrefix, + labelSuffix = badgeConstants.defaultPingLabelSuffix, + prefix, + suffix = badgeConstants.defaultPingValueSuffix, + color = badgeConstants.defaultPingColor, + labelColor, + style = badgeConstants.defaultStyle, + value, // for demo purpose only + } = request.query; + + try { + const requestedMonitorId = parseInt(request.params.id, 10); + + // Default duration is 24 (h) if not defined in queryParam, limited to 720h (30d) + let requestedDuration = request.params.duration !== undefined ? request.params.duration : "24h"; + const overrideValue = value && parseFloat(value); + + if (/^[0-9]+$/.test(requestedDuration)) { + requestedDuration = `${requestedDuration}h`; + } + + // Check if monitor is public + + const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(requestedMonitorId); + const publicAvgPing = uptimeCalculator.getDataByDuration(requestedDuration).avgPing; + + const badgeValues = { style }; + + if (!publicAvgPing) { + // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non exsitant + + badgeValues.message = "N/A"; + badgeValues.color = badgeConstants.naColor; + } else { + const avgPing = parseInt(overrideValue ?? publicAvgPing); + + badgeValues.color = color; + // use a given, custom labelColor or use the default badge label color (defined by badge-maker) + badgeValues.labelColor = labelColor ?? ""; + // build a lable string. If a custom label is given, override the default one (requestedDuration) + badgeValues.label = filterAndJoin([ labelPrefix, label ?? `Avg. Ping (${requestedDuration.slice(0, -1)}${labelSuffix})` ]); + badgeValues.message = filterAndJoin([ prefix, avgPing, suffix ]); + } + + // build the SVG based on given values + const svg = makeBadge(badgeValues); + + response.type("image/svg+xml"); + response.send(svg); + } catch (error) { + sendHttpError(response, error.message); + } +}); + +router.get("/api/badge/:id/avg-response/:duration?", cache("5 minutes"), async (request, response) => { + allowAllOrigin(response); + + const { + label, + labelPrefix, + labelSuffix, + prefix, + suffix = badgeConstants.defaultPingValueSuffix, + color = badgeConstants.defaultPingColor, + labelColor, + style = badgeConstants.defaultStyle, + value, // for demo purpose only + } = request.query; + + try { + const requestedMonitorId = parseInt(request.params.id, 10); + + // Default duration is 24 (h) if not defined in queryParam, limited to 720h (30d) + const requestedDuration = Math.min( + request.params.duration + ? parseInt(request.params.duration, 10) + : 24, + 720 + ); + const overrideValue = value && parseFloat(value); + + const sqlHourOffset = Database.sqlHourOffset(); + + const publicAvgPing = parseInt(await R.getCell(` + SELECT AVG(ping) FROM monitor_group, \`group\`, heartbeat + WHERE monitor_group.group_id = \`group\`.id + AND heartbeat.time > ${sqlHourOffset} + AND heartbeat.ping IS NOT NULL + AND public = 1 + AND heartbeat.monitor_id = ? + `, + [ -requestedDuration, requestedMonitorId ] + )); + + const badgeValues = { style }; + + if (!publicAvgPing) { + // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non existent + + badgeValues.message = "N/A"; + badgeValues.color = badgeConstants.naColor; + } else { + const avgPing = parseInt(overrideValue ?? publicAvgPing); + + badgeValues.color = color; + // use a given, custom labelColor or use the default badge label color (defined by badge-maker) + badgeValues.labelColor = labelColor ?? ""; + // build a label string. If a custom label is given, override the default one (requestedDuration) + badgeValues.label = filterAndJoin([ + labelPrefix, + label ?? `Avg. Response (${requestedDuration}h)`, + labelSuffix, + ]); + badgeValues.message = filterAndJoin([ prefix, avgPing, suffix ]); + } + + // build the SVG based on given values + const svg = makeBadge(badgeValues); + + response.type("image/svg+xml"); + response.send(svg); + } catch (error) { + sendHttpError(response, error.message); + } +}); + +router.get("/api/badge/:id/cert-exp", cache("5 minutes"), async (request, response) => { + allowAllOrigin(response); + + const date = request.query.date; + + const { + label, + labelPrefix, + labelSuffix, + prefix, + suffix = date ? "" : badgeConstants.defaultCertExpValueSuffix, + upColor = badgeConstants.defaultUpColor, + warnColor = badgeConstants.defaultWarnColor, + downColor = badgeConstants.defaultDownColor, + warnDays = badgeConstants.defaultCertExpireWarnDays, + downDays = badgeConstants.defaultCertExpireDownDays, + labelColor, + style = badgeConstants.defaultStyle, + value, // for demo purpose only + } = request.query; + + try { + const requestedMonitorId = parseInt(request.params.id, 10); + + const overrideValue = value && parseFloat(value); + + let publicMonitor = await R.getRow(` + SELECT monitor_group.monitor_id FROM monitor_group, \`group\` + WHERE monitor_group.group_id = \`group\`.id + AND monitor_group.monitor_id = ? + AND public = 1 + `, + [ requestedMonitorId ] + ); + + const badgeValues = { style }; + + if (!publicMonitor) { + // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non existent + + badgeValues.message = "N/A"; + badgeValues.color = badgeConstants.naColor; + } else { + const tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [ + requestedMonitorId, + ]); + + if (!tlsInfoBean) { + // return a "No/Bad Cert" badge in naColor (grey), if no cert saved (does not save bad certs?) + badgeValues.message = "No/Bad Cert"; + badgeValues.color = badgeConstants.naColor; + } else { + const tlsInfo = JSON.parse(tlsInfoBean.info_json); + + if (!tlsInfo.valid) { + // return a "Bad Cert" badge in naColor (grey), when cert is not valid + badgeValues.message = "Bad Cert"; + badgeValues.color = downColor; + } else { + const daysRemaining = parseInt(overrideValue ?? tlsInfo.certInfo.daysRemaining); + + if (daysRemaining > warnDays) { + badgeValues.color = upColor; + } else if (daysRemaining > downDays) { + badgeValues.color = warnColor; + } else { + badgeValues.color = downColor; + } + // use a given, custom labelColor or use the default badge label color (defined by badge-maker) + badgeValues.labelColor = labelColor ?? ""; + // build a label string. If a custom label is given, override the default one + badgeValues.label = filterAndJoin([ + labelPrefix, + label ?? "Cert Exp.", + labelSuffix, + ]); + badgeValues.message = filterAndJoin([ prefix, date ? tlsInfo.certInfo.validTo : daysRemaining, suffix ]); + } + } + } + + // build the SVG based on given values + const svg = makeBadge(badgeValues); + + response.type("image/svg+xml"); + response.send(svg); + } catch (error) { + sendHttpError(response, error.message); + } +}); + +router.get("/api/badge/:id/response", cache("5 minutes"), async (request, response) => { + allowAllOrigin(response); + + const { + label, + labelPrefix, + labelSuffix, + prefix, + suffix = badgeConstants.defaultPingValueSuffix, + color = badgeConstants.defaultPingColor, + labelColor, + style = badgeConstants.defaultStyle, + value, // for demo purpose only + } = request.query; + + try { + const requestedMonitorId = parseInt(request.params.id, 10); + + const overrideValue = value && parseFloat(value); + + let publicMonitor = await R.getRow(` + SELECT monitor_group.monitor_id FROM monitor_group, \`group\` + WHERE monitor_group.group_id = \`group\`.id + AND monitor_group.monitor_id = ? + AND public = 1 + `, + [ requestedMonitorId ] + ); + + const badgeValues = { style }; + + if (!publicMonitor) { + // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non existent + + badgeValues.message = "N/A"; + badgeValues.color = badgeConstants.naColor; + } else { + const heartbeat = await Monitor.getPreviousHeartbeat( + requestedMonitorId + ); + + if (!heartbeat.ping) { + // return a "N/A" badge in naColor (grey), if previous heartbeat has no ping + + badgeValues.message = "N/A"; + badgeValues.color = badgeConstants.naColor; + } else { + const ping = parseInt(overrideValue ?? heartbeat.ping); + + badgeValues.color = color; + // use a given, custom labelColor or use the default badge label color (defined by badge-maker) + badgeValues.labelColor = labelColor ?? ""; + // build a label string. If a custom label is given, override the default one + badgeValues.label = filterAndJoin([ + labelPrefix, + label ?? "Response", + labelSuffix, + ]); + badgeValues.message = filterAndJoin([ prefix, ping, suffix ]); + } + } + + // build the SVG based on given values + const svg = makeBadge(badgeValues); + + response.type("image/svg+xml"); + response.send(svg); + } catch (error) { + sendHttpError(response, error.message); + } +}); + +/** + * Determines the status of the next beat in the push route handling. + * @param {string} status - The reported new status. + * @param {object} previousHeartbeat - The previous heartbeat object. + * @param {number} maxretries - The maximum number of retries allowed. + * @param {boolean} isUpsideDown - Indicates if the monitor is upside down. + * @param {object} bean - The new heartbeat object. + * @returns {void} + */ +function determineStatus(status, previousHeartbeat, maxretries, isUpsideDown, bean) { + if (isUpsideDown) { + status = flipStatus(status); + } + + if (previousHeartbeat) { + if (previousHeartbeat.status === UP && status === DOWN) { + // Going Down + if ((maxretries > 0) && (previousHeartbeat.retries < maxretries)) { + // Retries available + bean.retries = previousHeartbeat.retries + 1; + bean.status = PENDING; + } else { + // No more retries + bean.retries = 0; + bean.status = DOWN; + } + } else if (previousHeartbeat.status === PENDING && status === DOWN && previousHeartbeat.retries < maxretries) { + // Retries available + bean.retries = previousHeartbeat.retries + 1; + bean.status = PENDING; + } else { + // No more retries or not pending + if (status === DOWN) { + bean.retries = previousHeartbeat.retries + 1; + bean.status = status; + } else { + bean.retries = 0; + bean.status = status; + } + } + } else { + // First beat? + if (status === DOWN && maxretries > 0) { + // Retries available + bean.retries = 1; + bean.status = PENDING; + } else { + // Retires not enabled + bean.retries = 0; + bean.status = status; + } + } +} + +module.exports = router; diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js new file mode 100644 index 0000000..b209d33 --- /dev/null +++ b/server/routers/status-page-router.js @@ -0,0 +1,241 @@ +let express = require("express"); +const apicache = require("../modules/apicache"); +const { UptimeKumaServer } = require("../uptime-kuma-server"); +const StatusPage = require("../model/status_page"); +const { allowDevAllOrigin, sendHttpError } = require("../util-server"); +const { R } = require("redbean-node"); +const { badgeConstants } = require("../../src/util"); +const { makeBadge } = require("badge-maker"); +const { UptimeCalculator } = require("../uptime-calculator"); + +let router = express.Router(); + +let cache = apicache.middleware; +const server = UptimeKumaServer.getInstance(); + +router.get("/status/:slug", cache("5 minutes"), async (request, response) => { + let slug = request.params.slug; + await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug); +}); + +router.get("/status/:slug/rss", cache("5 minutes"), async (request, response) => { + let slug = request.params.slug; + await StatusPage.handleStatusPageRSSResponse(response, slug); +}); + +router.get("/status", cache("5 minutes"), async (request, response) => { + let slug = "default"; + await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug); +}); + +router.get("/status-page", cache("5 minutes"), async (request, response) => { + let slug = "default"; + await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug); +}); + +// Status page config, incident, monitor list +router.get("/api/status-page/:slug", cache("5 minutes"), async (request, response) => { + allowDevAllOrigin(response); + let slug = request.params.slug; + + try { + // Get Status Page + let statusPage = await R.findOne("status_page", " slug = ? ", [ + slug + ]); + + if (!statusPage) { + sendHttpError(response, "Status Page Not Found"); + return null; + } + + let statusPageData = await StatusPage.getStatusPageData(statusPage); + + // Response + response.json(statusPageData); + + } catch (error) { + sendHttpError(response, error.message); + } +}); + +// Status Page Polling Data +// Can fetch only if published +router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (request, response) => { + allowDevAllOrigin(response); + + try { + let heartbeatList = {}; + let uptimeList = {}; + + let slug = request.params.slug; + let statusPageID = await StatusPage.slugToID(slug); + + let monitorIDList = await R.getCol(` + SELECT monitor_group.monitor_id FROM monitor_group, \`group\` + WHERE monitor_group.group_id = \`group\`.id + AND public = 1 + AND \`group\`.status_page_id = ? + `, [ + statusPageID + ]); + + for (let monitorID of monitorIDList) { + let list = await R.getAll(` + SELECT * FROM heartbeat + WHERE monitor_id = ? + ORDER BY time DESC + LIMIT 50 + `, [ + monitorID, + ]); + + list = R.convertToBeans("heartbeat", list); + heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON()); + + const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID); + uptimeList[`${monitorID}_24`] = uptimeCalculator.get24Hour().uptime; + } + + response.json({ + heartbeatList, + uptimeList + }); + + } catch (error) { + sendHttpError(response, error.message); + } +}); + +// Status page's manifest.json +router.get("/api/status-page/:slug/manifest.json", cache("1440 minutes"), async (request, response) => { + allowDevAllOrigin(response); + let slug = request.params.slug; + + try { + // Get Status Page + let statusPage = await R.findOne("status_page", " slug = ? ", [ + slug + ]); + + if (!statusPage) { + sendHttpError(response, "Not Found"); + return; + } + + // Response + response.json({ + "name": statusPage.title, + "start_url": "/status/" + statusPage.slug, + "display": "standalone", + "icons": [ + { + "src": statusPage.icon, + "sizes": "128x128", + "type": "image/png" + } + ] + }); + + } catch (error) { + sendHttpError(response, error.message); + } +}); + +// overall status-page status badge +router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, response) => { + allowDevAllOrigin(response); + const slug = request.params.slug; + const statusPageID = await StatusPage.slugToID(slug); + const { + label, + upColor = badgeConstants.defaultUpColor, + downColor = badgeConstants.defaultDownColor, + partialColor = "#F6BE00", + maintenanceColor = "#808080", + style = badgeConstants.defaultStyle + } = request.query; + + try { + let monitorIDList = await R.getCol(` + SELECT monitor_group.monitor_id FROM monitor_group, \`group\` + WHERE monitor_group.group_id = \`group\`.id + AND public = 1 + AND \`group\`.status_page_id = ? + `, [ + statusPageID + ]); + + let hasUp = false; + let hasDown = false; + let hasMaintenance = false; + + for (let monitorID of monitorIDList) { + // retrieve the latest heartbeat + let beat = await R.getAll(` + SELECT * FROM heartbeat + WHERE monitor_id = ? + ORDER BY time DESC + LIMIT 1 + `, [ + monitorID, + ]); + + // to be sure, when corresponding monitor not found + if (beat.length === 0) { + continue; + } + // handle status of beat + if (beat[0].status === 3) { + hasMaintenance = true; + } else if (beat[0].status === 2) { + // ignored + } else if (beat[0].status === 1) { + hasUp = true; + } else { + hasDown = true; + } + + } + + const badgeValues = { style }; + + if (!hasUp && !hasDown && !hasMaintenance) { + // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non exsitant + + badgeValues.message = "N/A"; + badgeValues.color = badgeConstants.naColor; + + } else { + if (hasMaintenance) { + badgeValues.label = label ? label : ""; + badgeValues.color = maintenanceColor; + badgeValues.message = "Maintenance"; + } else if (hasUp && !hasDown) { + badgeValues.label = label ? label : ""; + badgeValues.color = upColor; + badgeValues.message = "Up"; + } else if (hasUp && hasDown) { + badgeValues.label = label ? label : ""; + badgeValues.color = partialColor; + badgeValues.message = "Degraded"; + } else { + badgeValues.label = label ? label : ""; + badgeValues.color = downColor; + badgeValues.message = "Down"; + } + + } + + // build the svg based on given values + const svg = makeBadge(badgeValues); + + response.type("image/svg+xml"); + response.send(svg); + + } catch (error) { + sendHttpError(response, error.message); + } +}); + +module.exports = router; diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..ec5ad49 --- /dev/null +++ b/server/server.js @@ -0,0 +1,1877 @@ +/* + * Uptime Kuma Server + * node "server/server.js" + * DO NOT require("./server") in other modules, it likely creates circular dependency! + */ +console.log("Welcome to Uptime Kuma"); + +// As the log function need to use dayjs, it should be very top +const dayjs = require("dayjs"); +dayjs.extend(require("dayjs/plugin/utc")); +dayjs.extend(require("./modules/dayjs/plugin/timezone")); +dayjs.extend(require("dayjs/plugin/customParseFormat")); + +// Load environment variables from `.env` +require("dotenv").config(); + +// Check Node.js Version +const nodeVersion = process.versions.node; + +// Get the required Node.js version from package.json +const requiredNodeVersions = require("../package.json").engines.node; +const bannedNodeVersions = " < 18 || 20.0.* || 20.1.* || 20.2.* || 20.3.* "; +console.log(`Your Node.js version: ${nodeVersion}`); + +const semver = require("semver"); +const requiredNodeVersionsComma = requiredNodeVersions.split("||").map((version) => version.trim()).join(", "); + +// Exit Uptime Kuma immediately if the Node.js version is banned +if (semver.satisfies(nodeVersion, bannedNodeVersions)) { + console.error("\x1b[31m%s\x1b[0m", `Error: Your Node.js version: ${nodeVersion} is not supported, please upgrade your Node.js to ${requiredNodeVersionsComma}.`); + process.exit(-1); +} + +// Warning if the Node.js version is not in the support list, but it maybe still works +if (!semver.satisfies(nodeVersion, requiredNodeVersions)) { + console.warn("\x1b[31m%s\x1b[0m", `Warning: Your Node.js version: ${nodeVersion} is not officially supported, please upgrade your Node.js to ${requiredNodeVersionsComma}.`); +} + +const args = require("args-parser")(process.argv); +const { sleep, log, getRandomInt, genSecret, isDev } = require("../src/util"); +const config = require("./config"); + +log.debug("server", "Arguments"); +log.debug("server", args); + +if (! process.env.NODE_ENV) { + process.env.NODE_ENV = "production"; +} + +if (!process.env.UPTIME_KUMA_WS_ORIGIN_CHECK) { + process.env.UPTIME_KUMA_WS_ORIGIN_CHECK = "cors-like"; +} + +log.info("server", "Env: " + process.env.NODE_ENV); +log.debug("server", "Inside Container: " + (process.env.UPTIME_KUMA_IS_CONTAINER === "1")); + +if (process.env.UPTIME_KUMA_WS_ORIGIN_CHECK === "bypass") { + log.warn("server", "WebSocket Origin Check: " + process.env.UPTIME_KUMA_WS_ORIGIN_CHECK); +} + +const checkVersion = require("./check-version"); +log.info("server", "Uptime Kuma Version: " + checkVersion.version); + +log.info("server", "Loading modules"); + +log.debug("server", "Importing express"); +const express = require("express"); +const expressStaticGzip = require("express-static-gzip"); +log.debug("server", "Importing redbean-node"); +const { R } = require("redbean-node"); +log.debug("server", "Importing jsonwebtoken"); +const jwt = require("jsonwebtoken"); +log.debug("server", "Importing http-graceful-shutdown"); +const gracefulShutdown = require("http-graceful-shutdown"); +log.debug("server", "Importing prometheus-api-metrics"); +const prometheusAPIMetrics = require("prometheus-api-metrics"); +const { passwordStrength } = require("check-password-strength"); + +log.debug("server", "Importing 2FA Modules"); +const notp = require("notp"); +const base32 = require("thirty-two"); + +const { UptimeKumaServer } = require("./uptime-kuma-server"); +const server = UptimeKumaServer.getInstance(); +const io = module.exports.io = server.io; +const app = server.app; + +log.debug("server", "Importing Monitor"); +const Monitor = require("./model/monitor"); +const User = require("./model/user"); + +log.debug("server", "Importing Settings"); +const { getSettings, setSettings, setting, initJWTSecret, checkLogin, doubleCheckPassword, shake256, SHAKE256_LENGTH, allowDevAllOrigin, +} = require("./util-server"); + +log.debug("server", "Importing Notification"); +const { Notification } = require("./notification"); +Notification.init(); + +log.debug("server", "Importing Database"); +const Database = require("./database"); + +log.debug("server", "Importing Background Jobs"); +const { initBackgroundJobs, stopBackgroundJobs } = require("./jobs"); +const { loginRateLimiter, twoFaRateLimiter } = require("./rate-limiter"); + +const { apiAuth } = require("./auth"); +const { login } = require("./auth"); +const passwordHash = require("./password-hash"); + +const hostname = config.hostname; + +if (hostname) { + log.info("server", "Custom hostname: " + hostname); +} + +const port = config.port; + +const disableFrameSameOrigin = !!process.env.UPTIME_KUMA_DISABLE_FRAME_SAMEORIGIN || args["disable-frame-sameorigin"] || false; +const cloudflaredToken = args["cloudflared-token"] || process.env.UPTIME_KUMA_CLOUDFLARED_TOKEN || undefined; + +// 2FA / notp verification defaults +const twoFAVerifyOptions = { + "window": 1, + "time": 30 +}; + +/** + * Run unit test after the server is ready + * @type {boolean} + */ +const testMode = !!args["test"] || false; + +// Must be after io instantiation +const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList, sendRemoteBrowserList, sendMonitorTypeList } = require("./client"); +const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler"); +const { databaseSocketHandler } = require("./socket-handlers/database-socket-handler"); +const { remoteBrowserSocketHandler } = require("./socket-handlers/remote-browser-socket-handler"); +const TwoFA = require("./2fa"); +const StatusPage = require("./model/status_page"); +const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart, stop: cloudflaredStop } = require("./socket-handlers/cloudflared-socket-handler"); +const { proxySocketHandler } = require("./socket-handlers/proxy-socket-handler"); +const { dockerSocketHandler } = require("./socket-handlers/docker-socket-handler"); +const { maintenanceSocketHandler } = require("./socket-handlers/maintenance-socket-handler"); +const { apiKeySocketHandler } = require("./socket-handlers/api-key-socket-handler"); +const { generalSocketHandler } = require("./socket-handlers/general-socket-handler"); +const { Settings } = require("./settings"); +const apicache = require("./modules/apicache"); +const { resetChrome } = require("./monitor-types/real-browser-monitor-type"); +const { EmbeddedMariaDB } = require("./embedded-mariadb"); +const { SetupDatabase } = require("./setup-database"); +const { chartSocketHandler } = require("./socket-handlers/chart-socket-handler"); + +app.use(express.json()); + +// Global Middleware +app.use(function (req, res, next) { + if (!disableFrameSameOrigin) { + res.setHeader("X-Frame-Options", "SAMEORIGIN"); + } + res.removeHeader("X-Powered-By"); + next(); +}); + +/** + * Show Setup Page + * @type {boolean} + */ +let needSetup = false; + +(async () => { + // Create a data directory + Database.initDataDir(args); + + // Check if is chosen a database type + let setupDatabase = new SetupDatabase(args, server); + if (setupDatabase.isNeedSetup()) { + // Hold here and start a special setup page until user choose a database type + await setupDatabase.start(hostname, port); + } + + // Connect to database + try { + await initDatabase(testMode); + } catch (e) { + log.error("server", "Failed to prepare your database: " + e.message); + process.exit(1); + } + + // Database should be ready now + await server.initAfterDatabaseReady(); + server.entryPage = await Settings.get("entryPage"); + await StatusPage.loadDomainMappingList(); + + log.debug("server", "Adding route"); + + // *************************** + // Normal Router here + // *************************** + + // Entry Page + app.get("/", async (request, response) => { + let hostname = request.hostname; + if (await setting("trustProxy")) { + const proxy = request.headers["x-forwarded-host"]; + if (proxy) { + hostname = proxy; + } + } + + log.debug("entry", `Request Domain: ${hostname}`); + + const uptimeKumaEntryPage = server.entryPage; + if (hostname in StatusPage.domainMappingList) { + log.debug("entry", "This is a status page domain"); + + let slug = StatusPage.domainMappingList[hostname]; + await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug); + + } else if (uptimeKumaEntryPage && uptimeKumaEntryPage.startsWith("statusPage-")) { + response.redirect("/status/" + uptimeKumaEntryPage.replace("statusPage-", "")); + + } else { + response.redirect("/dashboard"); + } + }); + + app.get("/setup-database-info", (request, response) => { + allowDevAllOrigin(response); + response.json({ + runningSetup: false, + needSetup: false, + }); + }); + + if (isDev) { + app.use(express.urlencoded({ extended: true })); + app.post("/test-webhook", async (request, response) => { + log.debug("test", request.headers); + log.debug("test", request.body); + response.send("OK"); + }); + + app.post("/test-x-www-form-urlencoded", async (request, response) => { + log.debug("test", request.headers); + log.debug("test", request.body); + response.send("OK"); + }); + + const fs = require("fs"); + + app.get("/_e2e/take-sqlite-snapshot", async (request, response) => { + await Database.close(); + try { + fs.cpSync(Database.sqlitePath, `${Database.sqlitePath}.e2e-snapshot`); + } catch (err) { + throw new Error("Unable to copy SQLite DB."); + } + await Database.connect(); + + response.send("Snapshot taken."); + }); + + app.get("/_e2e/restore-sqlite-snapshot", async (request, response) => { + if (!fs.existsSync(`${Database.sqlitePath}.e2e-snapshot`)) { + throw new Error("Snapshot doesn't exist."); + } + + await Database.close(); + try { + fs.cpSync(`${Database.sqlitePath}.e2e-snapshot`, Database.sqlitePath); + } catch (err) { + throw new Error("Unable to copy snapshot file."); + } + await Database.connect(); + + response.send("Snapshot restored."); + }); + } + + // Robots.txt + app.get("/robots.txt", async (_request, response) => { + let txt = "User-agent: *\nDisallow:"; + if (!await setting("searchEngineIndex")) { + txt += " /"; + } + response.setHeader("Content-Type", "text/plain"); + response.send(txt); + }); + + // Basic Auth Router here + + // Prometheus API metrics /metrics + // With Basic Auth using the first user's username/password + app.get("/metrics", apiAuth, prometheusAPIMetrics()); + + app.use("/", expressStaticGzip("dist", { + enableBrotli: true, + })); + + // ./data/upload + app.use("/upload", express.static(Database.uploadDir)); + + app.get("/.well-known/change-password", async (_, response) => { + response.redirect("https://github.com/louislam/uptime-kuma/wiki/Reset-Password-via-CLI"); + }); + + // API Router + const apiRouter = require("./routers/api-router"); + app.use(apiRouter); + + // Status Page Router + const statusPageRouter = require("./routers/status-page-router"); + app.use(statusPageRouter); + + // Universal Route Handler, must be at the end of all express routes. + app.get("*", async (_request, response) => { + if (_request.originalUrl.startsWith("/upload/")) { + response.status(404).send("File not found."); + } else { + response.send(server.indexHTML); + } + }); + + log.debug("server", "Adding socket handler"); + io.on("connection", async (socket) => { + + await sendInfo(socket, true); + + if (needSetup) { + log.info("server", "Redirect to setup page"); + socket.emit("setup"); + } + + // *************************** + // Public Socket API + // *************************** + + socket.on("loginByToken", async (token, callback) => { + const clientIP = await server.getClientIP(socket); + + log.info("auth", `Login by token. IP=${clientIP}`); + + try { + let decoded = jwt.verify(token, server.jwtSecret); + + log.info("auth", "Username from JWT: " + decoded.username); + + let user = await R.findOne("user", " username = ? AND active = 1 ", [ + decoded.username, + ]); + + if (user) { + // Check if the password changed + if (decoded.h !== shake256(user.password, SHAKE256_LENGTH)) { + throw new Error("The token is invalid due to password change or old token"); + } + + log.debug("auth", "afterLogin"); + await afterLogin(socket, user); + log.debug("auth", "afterLogin ok"); + + log.info("auth", `Successfully logged in user ${decoded.username}. IP=${clientIP}`); + + callback({ + ok: true, + }); + } else { + + log.info("auth", `Inactive or deleted user ${decoded.username}. IP=${clientIP}`); + + callback({ + ok: false, + msg: "authUserInactiveOrDeleted", + msgi18n: true, + }); + } + } catch (error) { + log.error("auth", `Invalid token. IP=${clientIP}`); + if (error.message) { + log.error("auth", error.message, `IP=${clientIP}`); + } + callback({ + ok: false, + msg: "authInvalidToken", + msgi18n: true, + }); + } + + }); + + socket.on("login", async (data, callback) => { + const clientIP = await server.getClientIP(socket); + + log.info("auth", `Login by username + password. IP=${clientIP}`); + + // Checking + if (typeof callback !== "function") { + return; + } + + if (!data) { + return; + } + + // Login Rate Limit + if (!await loginRateLimiter.pass(callback)) { + log.info("auth", `Too many failed requests for user ${data.username}. IP=${clientIP}`); + return; + } + + let user = await login(data.username, data.password); + + if (user) { + if (user.twofa_status === 0) { + await afterLogin(socket, user); + + log.info("auth", `Successfully logged in user ${data.username}. IP=${clientIP}`); + + callback({ + ok: true, + token: User.createJWT(user, server.jwtSecret), + }); + } + + if (user.twofa_status === 1 && !data.token) { + + log.info("auth", `2FA token required for user ${data.username}. IP=${clientIP}`); + + callback({ + tokenRequired: true, + }); + } + + if (data.token) { + let verify = notp.totp.verify(data.token, user.twofa_secret, twoFAVerifyOptions); + + if (user.twofa_last_token !== data.token && verify) { + await afterLogin(socket, user); + + await R.exec("UPDATE `user` SET twofa_last_token = ? WHERE id = ? ", [ + data.token, + socket.userID, + ]); + + log.info("auth", `Successfully logged in user ${data.username}. IP=${clientIP}`); + + callback({ + ok: true, + token: User.createJWT(user, server.jwtSecret), + }); + } else { + + log.warn("auth", `Invalid token provided for user ${data.username}. IP=${clientIP}`); + + callback({ + ok: false, + msg: "authInvalidToken", + msgi18n: true, + }); + } + } + } else { + + log.warn("auth", `Incorrect username or password for user ${data.username}. IP=${clientIP}`); + + callback({ + ok: false, + msg: "authIncorrectCreds", + msgi18n: true, + }); + } + + }); + + socket.on("logout", async (callback) => { + // Rate Limit + if (!await loginRateLimiter.pass(callback)) { + return; + } + + socket.leave(socket.userID); + socket.userID = null; + + if (typeof callback === "function") { + callback(); + } + }); + + socket.on("prepare2FA", async (currentPassword, callback) => { + try { + if (!await twoFaRateLimiter.pass(callback)) { + return; + } + + checkLogin(socket); + await doubleCheckPassword(socket, currentPassword); + + let user = await R.findOne("user", " id = ? AND active = 1 ", [ + socket.userID, + ]); + + if (user.twofa_status === 0) { + let newSecret = genSecret(); + let encodedSecret = base32.encode(newSecret); + + // Google authenticator doesn't like equal signs + // The fix is found at https://github.com/guyht/notp + // Related issue: https://github.com/louislam/uptime-kuma/issues/486 + encodedSecret = encodedSecret.toString().replace(/=/g, ""); + + let uri = `otpauth://totp/Uptime%20Kuma:${user.username}?secret=${encodedSecret}`; + + await R.exec("UPDATE `user` SET twofa_secret = ? WHERE id = ? ", [ + newSecret, + socket.userID, + ]); + + callback({ + ok: true, + uri: uri, + }); + } else { + callback({ + ok: false, + msg: "2faAlreadyEnabled", + msgi18n: true, + }); + } + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + + socket.on("save2FA", async (currentPassword, callback) => { + const clientIP = await server.getClientIP(socket); + + try { + if (!await twoFaRateLimiter.pass(callback)) { + return; + } + + checkLogin(socket); + await doubleCheckPassword(socket, currentPassword); + + await R.exec("UPDATE `user` SET twofa_status = 1 WHERE id = ? ", [ + socket.userID, + ]); + + log.info("auth", `Saved 2FA token. IP=${clientIP}`); + + callback({ + ok: true, + msg: "2faEnabled", + msgi18n: true, + }); + } catch (error) { + + log.error("auth", `Error changing 2FA token. IP=${clientIP}`); + + callback({ + ok: false, + msg: error.message, + }); + } + }); + + socket.on("disable2FA", async (currentPassword, callback) => { + const clientIP = await server.getClientIP(socket); + + try { + if (!await twoFaRateLimiter.pass(callback)) { + return; + } + + checkLogin(socket); + await doubleCheckPassword(socket, currentPassword); + await TwoFA.disable2FA(socket.userID); + + log.info("auth", `Disabled 2FA token. IP=${clientIP}`); + + callback({ + ok: true, + msg: "2faDisabled", + msgi18n: true, + }); + } catch (error) { + + log.error("auth", `Error disabling 2FA token. IP=${clientIP}`); + + callback({ + ok: false, + msg: error.message, + }); + } + }); + + socket.on("verifyToken", async (token, currentPassword, callback) => { + try { + checkLogin(socket); + await doubleCheckPassword(socket, currentPassword); + + let user = await R.findOne("user", " id = ? AND active = 1 ", [ + socket.userID, + ]); + + let verify = notp.totp.verify(token, user.twofa_secret, twoFAVerifyOptions); + + if (user.twofa_last_token !== token && verify) { + callback({ + ok: true, + valid: true, + }); + } else { + callback({ + ok: false, + msg: "authInvalidToken", + msgi18n: true, + valid: false, + }); + } + + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + + socket.on("twoFAStatus", async (callback) => { + try { + checkLogin(socket); + + let user = await R.findOne("user", " id = ? AND active = 1 ", [ + socket.userID, + ]); + + if (user.twofa_status === 1) { + callback({ + ok: true, + status: true, + }); + } else { + callback({ + ok: true, + status: false, + }); + } + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + + socket.on("needSetup", async (callback) => { + callback(needSetup); + }); + + socket.on("setup", async (username, password, callback) => { + try { + if (passwordStrength(password).value === "Too weak") { + throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length."); + } + + if ((await R.knex("user").count("id as count").first()).count !== 0) { + throw new Error("Uptime Kuma has been initialized. If you want to run setup again, please delete the database."); + } + + let user = R.dispense("user"); + user.username = username; + user.password = passwordHash.generate(password); + await R.store(user); + + needSetup = false; + + callback({ + ok: true, + msg: "successAdded", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + // *************************** + // Auth Only API + // *************************** + + // Add a new monitor + socket.on("add", async (monitor, callback) => { + try { + checkLogin(socket); + let bean = R.dispense("monitor"); + + let notificationIDList = monitor.notificationIDList; + delete monitor.notificationIDList; + + // Ensure status code ranges are strings + if (!monitor.accepted_statuscodes.every((code) => typeof code === "string")) { + throw new Error("Accepted status codes are not all strings"); + } + monitor.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes); + delete monitor.accepted_statuscodes; + + monitor.kafkaProducerBrokers = JSON.stringify(monitor.kafkaProducerBrokers); + monitor.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions); + + monitor.conditions = JSON.stringify(monitor.conditions); + + monitor.rabbitmqNodes = JSON.stringify(monitor.rabbitmqNodes); + + bean.import(monitor); + bean.user_id = socket.userID; + + bean.validate(); + + await R.store(bean); + + await updateMonitorNotification(bean.id, notificationIDList); + + await server.sendUpdateMonitorIntoList(socket, bean.id); + + if (monitor.active !== false) { + await startMonitor(socket.userID, bean.id); + } + + log.info("monitor", `Added Monitor: ${bean.id} User ID: ${socket.userID}`); + + callback({ + ok: true, + msg: "successAdded", + msgi18n: true, + monitorID: bean.id, + }); + + } catch (e) { + + log.error("monitor", `Error adding Monitor: ${monitor.id} User ID: ${socket.userID}`); + + callback({ + ok: false, + msg: e.message, + }); + } + }); + + // Edit a monitor + socket.on("editMonitor", async (monitor, callback) => { + try { + let removeGroupChildren = false; + checkLogin(socket); + + let bean = await R.findOne("monitor", " id = ? ", [ monitor.id ]); + + if (bean.user_id !== socket.userID) { + 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; + } + + // Ensure status code ranges are strings + if (!monitor.accepted_statuscodes.every((code) => typeof code === "string")) { + throw new Error("Accepted status codes are not all strings"); + } + + bean.name = monitor.name; + bean.description = monitor.description; + bean.parent = monitor.parent; + bean.type = monitor.type; + bean.url = monitor.url; + bean.method = monitor.method; + bean.body = monitor.body; + bean.headers = monitor.headers; + bean.basic_auth_user = monitor.basic_auth_user; + bean.basic_auth_pass = monitor.basic_auth_pass; + bean.timeout = monitor.timeout; + bean.oauth_client_id = monitor.oauth_client_id; + bean.oauth_client_secret = monitor.oauth_client_secret; + bean.oauth_auth_method = monitor.oauth_auth_method; + bean.oauth_token_url = monitor.oauth_token_url; + bean.oauth_scopes = monitor.oauth_scopes; + bean.tlsCa = monitor.tlsCa; + bean.tlsCert = monitor.tlsCert; + bean.tlsKey = monitor.tlsKey; + bean.interval = monitor.interval; + bean.retryInterval = monitor.retryInterval; + bean.resendInterval = monitor.resendInterval; + bean.hostname = monitor.hostname; + bean.game = monitor.game; + bean.maxretries = monitor.maxretries; + bean.port = parseInt(monitor.port); + + if (isNaN(bean.port)) { + bean.port = null; + } + + bean.keyword = monitor.keyword; + bean.invertKeyword = monitor.invertKeyword; + bean.ignoreTls = monitor.ignoreTls; + bean.expiryNotification = monitor.expiryNotification; + bean.upsideDown = monitor.upsideDown; + bean.packetSize = monitor.packetSize; + bean.maxredirects = monitor.maxredirects; + bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes); + bean.dns_resolve_type = monitor.dns_resolve_type; + bean.dns_resolve_server = monitor.dns_resolve_server; + bean.pushToken = monitor.pushToken; + bean.docker_container = monitor.docker_container; + bean.docker_host = monitor.docker_host; + bean.proxyId = Number.isInteger(monitor.proxyId) ? monitor.proxyId : null; + bean.mqttUsername = monitor.mqttUsername; + bean.mqttPassword = monitor.mqttPassword; + bean.mqttTopic = monitor.mqttTopic; + bean.mqttSuccessMessage = monitor.mqttSuccessMessage; + bean.mqttCheckType = monitor.mqttCheckType; + bean.databaseConnectionString = monitor.databaseConnectionString; + bean.databaseQuery = monitor.databaseQuery; + bean.authMethod = monitor.authMethod; + bean.authWorkstation = monitor.authWorkstation; + bean.authDomain = monitor.authDomain; + bean.grpcUrl = monitor.grpcUrl; + bean.grpcProtobuf = monitor.grpcProtobuf; + bean.grpcServiceName = monitor.grpcServiceName; + bean.grpcMethod = monitor.grpcMethod; + bean.grpcBody = monitor.grpcBody; + bean.grpcMetadata = monitor.grpcMetadata; + bean.grpcEnableTls = monitor.grpcEnableTls; + bean.radiusUsername = monitor.radiusUsername; + bean.radiusPassword = monitor.radiusPassword; + bean.radiusCalledStationId = monitor.radiusCalledStationId; + bean.radiusCallingStationId = monitor.radiusCallingStationId; + bean.radiusSecret = monitor.radiusSecret; + bean.httpBodyEncoding = monitor.httpBodyEncoding; + bean.expectedValue = monitor.expectedValue; + bean.jsonPath = monitor.jsonPath; + bean.kafkaProducerTopic = monitor.kafkaProducerTopic; + bean.kafkaProducerBrokers = JSON.stringify(monitor.kafkaProducerBrokers); + bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation; + bean.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions); + bean.kafkaProducerMessage = monitor.kafkaProducerMessage; + bean.cacheBust = monitor.cacheBust; + bean.kafkaProducerSsl = monitor.kafkaProducerSsl; + bean.kafkaProducerAllowAutoTopicCreation = + monitor.kafkaProducerAllowAutoTopicCreation; + bean.gamedigGivenPortOnly = monitor.gamedigGivenPortOnly; + bean.remote_browser = monitor.remote_browser; + bean.snmpVersion = monitor.snmpVersion; + bean.snmpOid = monitor.snmpOid; + bean.jsonPathOperator = monitor.jsonPathOperator; + bean.timeout = monitor.timeout; + bean.rabbitmqNodes = JSON.stringify(monitor.rabbitmqNodes); + bean.rabbitmqUsername = monitor.rabbitmqUsername; + bean.rabbitmqPassword = monitor.rabbitmqPassword; + bean.conditions = JSON.stringify(monitor.conditions); + + bean.validate(); + + await R.store(bean); + + if (removeGroupChildren) { + await Monitor.unlinkAllChildren(monitor.id); + } + + await updateMonitorNotification(bean.id, monitor.notificationIDList); + + if (await Monitor.isActive(bean.id, bean.active)) { + await restartMonitor(socket.userID, bean.id); + } + + await server.sendUpdateMonitorIntoList(socket, bean.id); + + callback({ + ok: true, + msg: "Saved.", + msgi18n: true, + monitorID: bean.id, + }); + + } catch (e) { + log.error("monitor", e); + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getMonitorList", async (callback) => { + try { + checkLogin(socket); + await server.sendMonitorList(socket); + callback({ + ok: true, + }); + } catch (e) { + log.error("monitor", e); + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getMonitor", async (monitorID, callback) => { + try { + checkLogin(socket); + + log.info("monitor", `Get Monitor: ${monitorID} User ID: ${socket.userID}`); + + let monitor = await R.findOne("monitor", " id = ? AND user_id = ? ", [ + monitorID, + socket.userID, + ]); + const monitorData = [{ id: monitor.id, + active: monitor.active + }]; + const preloadData = await Monitor.preparePreloadData(monitorData); + callback({ + ok: true, + monitor: monitor.toJSON(preloadData), + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getMonitorBeats", async (monitorID, period, callback) => { + try { + checkLogin(socket); + + log.info("monitor", `Get Monitor Beats: ${monitorID} User ID: ${socket.userID}`); + + if (period == null) { + throw new Error("Invalid period."); + } + + const sqlHourOffset = Database.sqlHourOffset(); + + let list = await R.getAll(` + SELECT * + FROM heartbeat + WHERE monitor_id = ? + AND time > ${sqlHourOffset} + ORDER BY time ASC + `, [ + monitorID, + -period, + ]); + + callback({ + ok: true, + data: list, + }); + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + // Start or Resume the monitor + socket.on("resumeMonitor", async (monitorID, callback) => { + try { + checkLogin(socket); + await startMonitor(socket.userID, monitorID); + await server.sendUpdateMonitorIntoList(socket, monitorID); + + callback({ + ok: true, + msg: "successResumed", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("pauseMonitor", async (monitorID, callback) => { + try { + checkLogin(socket); + await pauseMonitor(socket.userID, monitorID); + await server.sendUpdateMonitorIntoList(socket, monitorID); + + callback({ + ok: true, + msg: "successPaused", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteMonitor", async (monitorID, callback) => { + try { + checkLogin(socket); + + log.info("manage", `Delete Monitor: ${monitorID} User ID: ${socket.userID}`); + + if (monitorID in server.monitorList) { + await server.monitorList[monitorID].stop(); + delete server.monitorList[monitorID]; + } + + const startTime = Date.now(); + + await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [ + monitorID, + socket.userID, + ]); + + // Fix #2880 + apicache.clear(); + + const endTime = Date.now(); + + log.info("DB", `Delete Monitor completed in : ${endTime - startTime} ms`); + + callback({ + ok: true, + msg: "successDeleted", + msgi18n: true, + }); + await server.sendDeleteMonitorFromList(socket, monitorID); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getTags", async (callback) => { + try { + checkLogin(socket); + + const list = await R.findAll("tag"); + + callback({ + ok: true, + tags: list.map(bean => bean.toJSON()), + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("addTag", async (tag, callback) => { + try { + checkLogin(socket); + + let bean = R.dispense("tag"); + bean.name = tag.name; + bean.color = tag.color; + await R.store(bean); + + callback({ + ok: true, + tag: await bean.toJSON(), + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("editTag", async (tag, callback) => { + try { + checkLogin(socket); + + let bean = await R.findOne("tag", " id = ? ", [ tag.id ]); + if (bean == null) { + callback({ + ok: false, + msg: "tagNotFound", + msgi18n: true, + }); + return; + } + bean.name = tag.name; + bean.color = tag.color; + await R.store(bean); + + callback({ + ok: true, + msg: "Saved.", + msgi18n: true, + tag: await bean.toJSON(), + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteTag", async (tagID, callback) => { + try { + checkLogin(socket); + + await R.exec("DELETE FROM tag WHERE id = ? ", [ tagID ]); + + callback({ + ok: true, + msg: "successDeleted", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("addMonitorTag", async (tagID, monitorID, value, callback) => { + try { + checkLogin(socket); + + await R.exec("INSERT INTO monitor_tag (tag_id, monitor_id, value) VALUES (?, ?, ?)", [ + tagID, + monitorID, + value, + ]); + + callback({ + ok: true, + msg: "successAdded", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("editMonitorTag", async (tagID, monitorID, value, callback) => { + try { + checkLogin(socket); + + await R.exec("UPDATE monitor_tag SET value = ? WHERE tag_id = ? AND monitor_id = ?", [ + value, + tagID, + monitorID, + ]); + + callback({ + ok: true, + msg: "successEdited", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteMonitorTag", async (tagID, monitorID, value, callback) => { + try { + checkLogin(socket); + + await R.exec("DELETE FROM monitor_tag WHERE tag_id = ? AND monitor_id = ? AND value = ?", [ + tagID, + monitorID, + value, + ]); + + callback({ + ok: true, + msg: "successDeleted", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("monitorImportantHeartbeatListCount", async (monitorID, callback) => { + try { + checkLogin(socket); + + let count; + if (monitorID == null) { + count = await R.count("heartbeat", "important = 1"); + } else { + count = await R.count("heartbeat", "monitor_id = ? AND important = 1", [ + monitorID, + ]); + } + + callback({ + ok: true, + count: count, + }); + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("monitorImportantHeartbeatListPaged", async (monitorID, offset, count, callback) => { + try { + checkLogin(socket); + + let list; + if (monitorID == null) { + list = await R.find("heartbeat", ` + important = 1 + ORDER BY time DESC + LIMIT ? + OFFSET ? + `, [ + count, + offset, + ]); + } else { + list = await R.find("heartbeat", ` + monitor_id = ? + AND important = 1 + ORDER BY time DESC + LIMIT ? + OFFSET ? + `, [ + monitorID, + count, + offset, + ]); + } + + callback({ + ok: true, + data: list, + }); + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("changePassword", async (password, callback) => { + try { + checkLogin(socket); + + if (!password.newPassword) { + throw new Error("Invalid new password"); + } + + if (passwordStrength(password.newPassword).value === "Too weak") { + throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length."); + } + + let user = await doubleCheckPassword(socket, password.currentPassword); + await user.resetPassword(password.newPassword); + + server.disconnectAllSocketClients(user.id, socket.id); + + callback({ + ok: true, + token: User.createJWT(user, server.jwtSecret), + msg: "successAuthChangePassword", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getSettings", async (callback) => { + try { + checkLogin(socket); + const data = await getSettings("general"); + + if (!data.serverTimezone) { + data.serverTimezone = await server.getTimezone(); + } + + callback({ + ok: true, + data: data, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("setSettings", async (data, currentPassword, callback) => { + try { + checkLogin(socket); + + // If currently is disabled auth, don't need to check + // Disabled Auth + Want to Disable Auth => No Check + // Disabled Auth + Want to Enable Auth => No Check + // Enabled Auth + Want to Disable Auth => Check!! + // Enabled Auth + Want to Enable Auth => No Check + const currentDisabledAuth = await setting("disableAuth"); + if (!currentDisabledAuth && data.disableAuth) { + await doubleCheckPassword(socket, currentPassword); + } + + // Log out all clients if enabling auth + // GHSA-23q2-5gf8-gjpp + if (currentDisabledAuth && !data.disableAuth) { + server.disconnectAllSocketClients(socket.userID, socket.id); + } + + const previousChromeExecutable = await Settings.get("chromeExecutable"); + const previousNSCDStatus = await Settings.get("nscd"); + + await setSettings("general", data); + server.entryPage = data.entryPage; + + // Also need to apply timezone globally + if (data.serverTimezone) { + 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(); + } + + // Update nscd status + if (previousNSCDStatus !== data.nscd) { + if (data.nscd) { + await server.startNSCDServices(); + } else { + await server.stopNSCDServices(); + } + } + + callback({ + ok: true, + msg: "Saved.", + msgi18n: true, + }); + + await sendInfo(socket); + await server.sendMaintenanceList(socket); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + // Add or Edit + socket.on("addNotification", async (notification, notificationID, callback) => { + try { + checkLogin(socket); + + let notificationBean = await Notification.save(notification, notificationID, socket.userID); + await sendNotificationList(socket); + + callback({ + ok: true, + msg: "Saved.", + msgi18n: true, + id: notificationBean.id, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteNotification", async (notificationID, callback) => { + try { + checkLogin(socket); + + await Notification.delete(notificationID, socket.userID); + await sendNotificationList(socket); + + callback({ + ok: true, + msg: "successDeleted", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("testNotification", async (notification, callback) => { + try { + checkLogin(socket); + + let msg = await Notification.send(notification, notification.name + " Testing"); + + callback({ + ok: true, + msg, + }); + + } catch (e) { + console.error(e); + + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("checkApprise", async (callback) => { + try { + checkLogin(socket); + callback(Notification.checkApprise()); + } catch (e) { + callback(false); + } + }); + + socket.on("clearEvents", async (monitorID, callback) => { + try { + checkLogin(socket); + + log.info("manage", `Clear Events Monitor: ${monitorID} User ID: ${socket.userID}`); + + await R.exec("UPDATE heartbeat SET msg = ?, important = ? WHERE monitor_id = ? ", [ + "", + "0", + monitorID, + ]); + + callback({ + ok: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("clearHeartbeats", async (monitorID, callback) => { + try { + checkLogin(socket); + + log.info("manage", `Clear Heartbeats Monitor: ${monitorID} User ID: ${socket.userID}`); + + await R.exec("DELETE FROM heartbeat WHERE monitor_id = ?", [ + monitorID + ]); + + await sendHeartbeatList(socket, monitorID, true, true); + + callback({ + ok: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("clearStatistics", async (callback) => { + try { + checkLogin(socket); + + log.info("manage", `Clear Statistics User ID: ${socket.userID}`); + + await R.exec("DELETE FROM heartbeat"); + await R.exec("DELETE FROM stat_daily"); + await R.exec("DELETE FROM stat_hourly"); + await R.exec("DELETE FROM stat_minutely"); + + // Restart all monitors to reset the stats + for (let monitorID in server.monitorList) { + await restartMonitor(socket.userID, monitorID); + } + + callback({ + ok: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + // Status Page Socket Handler for admin only + statusPageSocketHandler(socket); + cloudflaredSocketHandler(socket); + databaseSocketHandler(socket); + proxySocketHandler(socket); + dockerSocketHandler(socket); + maintenanceSocketHandler(socket); + apiKeySocketHandler(socket); + remoteBrowserSocketHandler(socket); + generalSocketHandler(socket, server); + chartSocketHandler(socket); + + log.debug("server", "added all socket handlers"); + + // *************************** + // Better do anything after added all socket handlers here + // *************************** + + log.debug("auth", "check auto login"); + if (await setting("disableAuth")) { + log.info("auth", "Disabled Auth: auto login to admin"); + await afterLogin(socket, await R.findOne("user")); + socket.emit("autoLogin"); + } else { + socket.emit("loginRequired"); + log.debug("auth", "need auth"); + } + + }); + + log.debug("server", "Init the server"); + + server.httpServer.once("error", async (err) => { + log.error("server", "Cannot listen: " + err.message); + await shutdownFunction(); + process.exit(1); + }); + + await server.start(); + + server.httpServer.listen(port, hostname, async () => { + if (hostname) { + log.info("server", `Listening on ${hostname}:${port}`); + } else { + log.info("server", `Listening on ${port}`); + } + await startMonitors(); + + // Put this here. Start background jobs after the db and server is ready to prevent clear up during db migration. + await initBackgroundJobs(); + + checkVersion.startInterval(); + }); + + // Start cloudflared at the end if configured + await cloudflaredAutoStart(cloudflaredToken); + +})(); + +/** + * Update notifications for a given monitor + * @param {number} monitorID ID of monitor to update + * @param {number[]} notificationIDList List of new notification + * providers to add + * @returns {Promise<void>} + */ +async function updateMonitorNotification(monitorID, notificationIDList) { + await R.exec("DELETE FROM monitor_notification WHERE monitor_id = ? ", [ + monitorID, + ]); + + for (let notificationID in notificationIDList) { + if (notificationIDList[notificationID]) { + let relation = R.dispense("monitor_notification"); + relation.monitor_id = monitorID; + relation.notification_id = notificationID; + await R.store(relation); + } + } +} + +/** + * Check if a given user owns a specific monitor + * @param {number} userID ID of user to check + * @param {number} monitorID ID of monitor to check + * @returns {Promise<void>} + * @throws {Error} The specified user does not own the monitor + */ +async function checkOwner(userID, monitorID) { + let row = await R.getRow("SELECT id FROM monitor WHERE id = ? AND user_id = ? ", [ + monitorID, + userID, + ]); + + if (! row) { + throw new Error("You do not own this monitor."); + } +} + +/** + * Function called after user login + * This function is used to send the heartbeat list of a monitor. + * @param {Socket} socket Socket.io instance + * @param {object} user User object + * @returns {Promise<void>} + */ +async function afterLogin(socket, user) { + socket.userID = user.id; + socket.join(user.id); + + let monitorList = await server.sendMonitorList(socket); + await Promise.allSettled([ + sendInfo(socket), + server.sendMaintenanceList(socket), + sendNotificationList(socket), + sendProxyList(socket), + sendDockerHostList(socket), + sendAPIKeyList(socket), + sendRemoteBrowserList(socket), + sendMonitorTypeList(socket), + ]); + + await StatusPage.sendStatusPageList(io, socket); + + const monitorPromises = []; + for (let monitorID in monitorList) { + monitorPromises.push(sendHeartbeatList(socket, monitorID)); + monitorPromises.push(Monitor.sendStats(io, monitorID, user.id)); + } + + await Promise.all(monitorPromises); + + // Set server timezone from client browser if not set + // It should be run once only + if (! await Settings.get("initServerTimezone")) { + log.debug("server", "emit initServerTimezone"); + socket.emit("initServerTimezone"); + } +} + +/** + * Initialize the database + * @param {boolean} testMode Should the connection be + * started in test mode? + * @returns {Promise<void>} + */ +async function initDatabase(testMode = false) { + log.debug("server", "Connecting to the database"); + await Database.connect(testMode); + log.info("server", "Connected to the database"); + + // Patch the database + await Database.patch(port, hostname); + + let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [ + "jwtSecret", + ]); + + if (! jwtSecretBean) { + log.info("server", "JWT secret is not found, generate one."); + jwtSecretBean = await initJWTSecret(); + log.info("server", "Stored JWT secret into database"); + } else { + log.debug("server", "Load JWT secret from database."); + } + + // If there is no record in user table, it is a new Uptime Kuma instance, need to setup + if ((await R.knex("user").count("id as count").first()).count === 0) { + log.info("server", "No user, need setup"); + needSetup = true; + } + + server.jwtSecret = jwtSecretBean.value; +} + +/** + * Start the specified monitor + * @param {number} userID ID of user who owns monitor + * @param {number} monitorID ID of monitor to start + * @returns {Promise<void>} + */ +async function startMonitor(userID, monitorID) { + await checkOwner(userID, monitorID); + + log.info("manage", `Resume Monitor: ${monitorID} User ID: ${userID}`); + + await R.exec("UPDATE monitor SET active = 1 WHERE id = ? AND user_id = ? ", [ + monitorID, + userID, + ]); + + let monitor = await R.findOne("monitor", " id = ? ", [ + monitorID, + ]); + + if (monitor.id in server.monitorList) { + await server.monitorList[monitor.id].stop(); + } + + server.monitorList[monitor.id] = monitor; + await monitor.start(io); +} + +/** + * Restart a given monitor + * @param {number} userID ID of user who owns monitor + * @param {number} monitorID ID of monitor to start + * @returns {Promise<void>} + */ +async function restartMonitor(userID, monitorID) { + return await startMonitor(userID, monitorID); +} + +/** + * Pause a given monitor + * @param {number} userID ID of user who owns monitor + * @param {number} monitorID ID of monitor to start + * @returns {Promise<void>} + */ +async function pauseMonitor(userID, monitorID) { + await checkOwner(userID, monitorID); + + log.info("manage", `Pause Monitor: ${monitorID} User ID: ${userID}`); + + await R.exec("UPDATE monitor SET active = 0 WHERE id = ? AND user_id = ? ", [ + monitorID, + userID, + ]); + + if (monitorID in server.monitorList) { + await server.monitorList[monitorID].stop(); + server.monitorList[monitorID].active = 0; + } +} + +/** + * Resume active monitors + * @returns {Promise<void>} + */ +async function startMonitors() { + let list = await R.find("monitor", " active = 1 "); + + for (let monitor of list) { + server.monitorList[monitor.id] = monitor; + } + + for (let monitor of list) { + try { + await monitor.start(io); + } catch (e) { + log.error("monitor", e); + } + // Give some delays, so all monitors won't make request at the same moment when just start the server. + await sleep(getRandomInt(300, 1000)); + } +} + +/** + * Shutdown the application + * Stops all monitors and closes the database connection. + * @param {string} signal The signal that triggered this function to be called. + * @returns {Promise<void>} + */ +async function shutdownFunction(signal) { + log.info("server", "Shutdown requested"); + log.info("server", "Called signal: " + signal); + + await server.stop(); + + log.info("server", "Stopping all monitors"); + for (let id in server.monitorList) { + let monitor = server.monitorList[id]; + await monitor.stop(); + } + await sleep(2000); + await Database.close(); + + if (EmbeddedMariaDB.hasInstance()) { + EmbeddedMariaDB.getInstance().stop(); + } + + stopBackgroundJobs(); + await cloudflaredStop(); + Settings.stopCacheCleaner(); +} + +/** + * Final function called before application exits + * @returns {void} + */ +function finalFunction() { + log.info("server", "Graceful shutdown successful!"); +} + +gracefulShutdown(server.httpServer, { + signals: "SIGINT SIGTERM", + timeout: 30000, // timeout: 30 secs + development: false, // not in dev mode + forceExit: true, // triggers process.exit() at the end of shutdown process + onShutdown: shutdownFunction, // shutdown function (async) - e.g. for cleanup DB, ... + finally: finalFunction, // finally function (sync) - e.g. for logging +}); + +// Catch unexpected errors here +let unexpectedErrorHandler = (error, promise) => { + console.trace(error); + UptimeKumaServer.errorLog(error, false); + console.error("If you keep encountering errors, please report to https://github.com/louislam/uptime-kuma/issues"); +}; +process.addListener("unhandledRejection", unexpectedErrorHandler); +process.addListener("uncaughtException", unexpectedErrorHandler); diff --git a/server/settings.js b/server/settings.js new file mode 100644 index 0000000..4776c55 --- /dev/null +++ b/server/settings.js @@ -0,0 +1,177 @@ +const { R } = require("redbean-node"); +const { log } = require("../src/util"); + +class Settings { + + /** + * Example: + * { + * key1: { + * value: "value2", + * timestamp: 12345678 + * }, + * key2: { + * value: 2, + * timestamp: 12345678 + * }, + * } + * @type {{}} + */ + static cacheList = { + + }; + + static cacheCleaner = null; + + /** + * Retrieve value of setting based on key + * @param {string} key Key of setting to retrieve + * @returns {Promise<any>} Value + */ + static async get(key) { + + // Start cache clear if not started yet + if (!Settings.cacheCleaner) { + Settings.cacheCleaner = setInterval(() => { + log.debug("settings", "Cache Cleaner is just started."); + for (key in Settings.cacheList) { + if (Date.now() - Settings.cacheList[key].timestamp > 60 * 1000) { + log.debug("settings", "Cache Cleaner deleted: " + key); + delete Settings.cacheList[key]; + } + } + + }, 60 * 1000); + } + + // Query from cache + if (key in Settings.cacheList) { + const v = Settings.cacheList[key].value; + log.debug("settings", `Get Setting (cache): ${key}: ${v}`); + return v; + } + + let value = await R.getCell("SELECT `value` FROM setting WHERE `key` = ? ", [ + key, + ]); + + try { + const v = JSON.parse(value); + log.debug("settings", `Get Setting: ${key}: ${v}`); + + Settings.cacheList[key] = { + value: v, + timestamp: Date.now() + }; + + return v; + } catch (e) { + return value; + } + } + + /** + * Sets the specified setting to specified value + * @param {string} key Key of setting to set + * @param {any} value Value to set to + * @param {?string} type Type of setting + * @returns {Promise<void>} + */ + static async set(key, value, type = null) { + + let bean = await R.findOne("setting", " `key` = ? ", [ + key, + ]); + if (!bean) { + bean = R.dispense("setting"); + bean.key = key; + } + bean.type = type; + bean.value = JSON.stringify(value); + await R.store(bean); + + Settings.deleteCache([ key ]); + } + + /** + * Get settings based on type + * @param {string} type The type of setting + * @returns {Promise<Bean>} Settings + */ + static async getSettings(type) { + let list = await R.getAll("SELECT `key`, `value` FROM setting WHERE `type` = ? ", [ + type, + ]); + + let result = {}; + + for (let row of list) { + try { + result[row.key] = JSON.parse(row.value); + } catch (e) { + result[row.key] = row.value; + } + } + + return result; + } + + /** + * Set settings based on type + * @param {string} type Type of settings to set + * @param {object} data Values of settings + * @returns {Promise<void>} + */ + static async setSettings(type, data) { + let keyList = Object.keys(data); + + let promiseList = []; + + for (let key of keyList) { + let bean = await R.findOne("setting", " `key` = ? ", [ + key + ]); + + if (bean == null) { + bean = R.dispense("setting"); + bean.type = type; + bean.key = key; + } + + if (bean.type === type) { + bean.value = JSON.stringify(data[key]); + promiseList.push(R.store(bean)); + } + } + + await Promise.all(promiseList); + + Settings.deleteCache(keyList); + } + + /** + * Delete selected keys from settings cache + * @param {string[]} keyList Keys to remove + * @returns {void} + */ + static deleteCache(keyList) { + for (let key of keyList) { + delete Settings.cacheList[key]; + } + } + + /** + * Stop the cache cleaner if running + * @returns {void} + */ + static stopCacheCleaner() { + if (Settings.cacheCleaner) { + clearInterval(Settings.cacheCleaner); + Settings.cacheCleaner = null; + } + } +} + +module.exports = { + Settings, +}; diff --git a/server/setup-database.js b/server/setup-database.js new file mode 100644 index 0000000..483f2c9 --- /dev/null +++ b/server/setup-database.js @@ -0,0 +1,271 @@ +const express = require("express"); +const { log } = require("../src/util"); +const expressStaticGzip = require("express-static-gzip"); +const fs = require("fs"); +const path = require("path"); +const Database = require("./database"); +const { allowDevAllOrigin } = require("./util-server"); +const mysql = require("mysql2/promise"); + +/** + * A standalone express app that is used to setup a database + * It is used when db-config.json and kuma.db are not found or invalid + * Once it is configured, it will shut down and start the main server + */ +class SetupDatabase { + /** + * Show Setup Page + * @type {boolean} + */ + needSetup = true; + /** + * If the server has finished the setup + * @type {boolean} + * @private + */ + runningSetup = false; + /** + * @inheritDoc + * @type {UptimeKumaServer} + * @private + */ + server; + + /** + * @param {object} args The arguments passed from the command line + * @param {UptimeKumaServer} server the main server instance + */ + constructor(args, server) { + this.server = server; + + // Priority: env > db-config.json + // If env is provided, write it to db-config.json + // If db-config.json is found, check if it is valid + // If db-config.json is not found or invalid, check if kuma.db is found + // If kuma.db is not found, show setup page + + let dbConfig; + + try { + dbConfig = Database.readDBConfig(); + log.debug("setup-database", "db-config.json is found and is valid"); + this.needSetup = false; + + } catch (e) { + log.info("setup-database", "db-config.json is not found or invalid: " + e.message); + + // Check if kuma.db is found (1.X.X users), generate db-config.json + if (fs.existsSync(path.join(Database.dataDir, "kuma.db"))) { + this.needSetup = false; + + log.info("setup-database", "kuma.db is found, generate db-config.json"); + Database.writeDBConfig({ + type: "sqlite", + }); + } else { + this.needSetup = true; + } + dbConfig = {}; + } + + if (process.env.UPTIME_KUMA_DB_TYPE) { + this.needSetup = false; + log.info("setup-database", "UPTIME_KUMA_DB_TYPE is provided by env, try to override db-config.json"); + dbConfig.type = process.env.UPTIME_KUMA_DB_TYPE; + dbConfig.hostname = process.env.UPTIME_KUMA_DB_HOSTNAME; + dbConfig.port = process.env.UPTIME_KUMA_DB_PORT; + dbConfig.dbName = process.env.UPTIME_KUMA_DB_NAME; + dbConfig.username = process.env.UPTIME_KUMA_DB_USERNAME; + dbConfig.password = process.env.UPTIME_KUMA_DB_PASSWORD; + Database.writeDBConfig(dbConfig); + } + + } + + /** + * Show Setup Page + * @returns {boolean} true if the setup page should be shown + */ + isNeedSetup() { + return this.needSetup; + } + + /** + * Check if the embedded MariaDB is enabled + * @returns {boolean} true if the embedded MariaDB is enabled + */ + isEnabledEmbeddedMariaDB() { + return process.env.UPTIME_KUMA_ENABLE_EMBEDDED_MARIADB === "1"; + } + + /** + * Start the setup-database server + * @param {string} hostname where the server is listening + * @param {number} port where the server is listening + * @returns {Promise<void>} + */ + start(hostname, port) { + return new Promise((resolve) => { + const app = express(); + let tempServer; + app.use(express.json()); + + // Disable Keep Alive, otherwise the server will not shutdown, as the client will keep the connection alive + app.use(function (req, res, next) { + res.setHeader("Connection", "close"); + next(); + }); + + app.get("/", async (request, response) => { + response.redirect("/setup-database"); + }); + + app.get("/api/entry-page", async (request, response) => { + allowDevAllOrigin(response); + response.json({ + type: "setup-database", + }); + }); + + app.get("/setup-database-info", (request, response) => { + allowDevAllOrigin(response); + console.log("Request /setup-database-info"); + response.json({ + runningSetup: this.runningSetup, + needSetup: this.needSetup, + isEnabledEmbeddedMariaDB: this.isEnabledEmbeddedMariaDB(), + }); + }); + + app.post("/setup-database", async (request, response) => { + allowDevAllOrigin(response); + + if (this.runningSetup) { + response.status(400).json("Setup is already running"); + return; + } + + this.runningSetup = true; + + let dbConfig = request.body.dbConfig; + + let supportedDBTypes = [ "mariadb", "sqlite" ]; + + if (this.isEnabledEmbeddedMariaDB()) { + supportedDBTypes.push("embedded-mariadb"); + } + + // Validate input + if (typeof dbConfig !== "object") { + response.status(400).json("Invalid dbConfig"); + this.runningSetup = false; + return; + } + + if (!dbConfig.type) { + response.status(400).json("Database Type is required"); + this.runningSetup = false; + return; + } + + if (!supportedDBTypes.includes(dbConfig.type)) { + response.status(400).json("Unsupported Database Type"); + this.runningSetup = false; + return; + } + + // External MariaDB + if (dbConfig.type === "mariadb") { + if (!dbConfig.hostname) { + response.status(400).json("Hostname is required"); + this.runningSetup = false; + return; + } + + if (!dbConfig.port) { + response.status(400).json("Port is required"); + this.runningSetup = false; + return; + } + + if (!dbConfig.dbName) { + response.status(400).json("Database name is required"); + this.runningSetup = false; + return; + } + + if (!dbConfig.username) { + response.status(400).json("Username is required"); + this.runningSetup = false; + return; + } + + if (!dbConfig.password) { + response.status(400).json("Password is required"); + this.runningSetup = false; + return; + } + + // Test connection + try { + const connection = await mysql.createConnection({ + host: dbConfig.hostname, + port: dbConfig.port, + user: dbConfig.username, + password: dbConfig.password, + }); + await connection.execute("SELECT 1"); + connection.end(); + } catch (e) { + response.status(400).json("Cannot connect to the database: " + e.message); + this.runningSetup = false; + return; + } + } + + // Write db-config.json + Database.writeDBConfig(dbConfig); + + response.json({ + ok: true, + }); + + // Shutdown down this express and start the main server + log.info("setup-database", "Database is configured, close the setup-database server and start the main server now."); + if (tempServer) { + tempServer.close(() => { + log.info("setup-database", "The setup-database server is closed"); + resolve(); + }); + } else { + resolve(); + } + + }); + + app.use("/", expressStaticGzip("dist", { + enableBrotli: true, + })); + + app.get("*", async (_request, response) => { + response.send(this.server.indexHTML); + }); + + app.options("*", async (_request, response) => { + allowDevAllOrigin(response); + response.end(); + }); + + tempServer = app.listen(port, hostname, () => { + log.info("setup-database", `Starting Setup Database on ${port}`); + let domain = (hostname) ? hostname : "localhost"; + log.info("setup-database", `Open http://${domain}:${port} in your browser`); + log.info("setup-database", "Waiting for user action..."); + }); + }); + } +} + +module.exports = { + SetupDatabase, +}; diff --git a/server/socket-handlers/api-key-socket-handler.js b/server/socket-handlers/api-key-socket-handler.js new file mode 100644 index 0000000..f76b909 --- /dev/null +++ b/server/socket-handlers/api-key-socket-handler.js @@ -0,0 +1,155 @@ +const { checkLogin } = require("../util-server"); +const { log } = require("../../src/util"); +const { R } = require("redbean-node"); +const { nanoid } = require("nanoid"); +const passwordHash = require("../password-hash"); +const apicache = require("../modules/apicache"); +const APIKey = require("../model/api_key"); +const { Settings } = require("../settings"); +const { sendAPIKeyList } = require("../client"); + +/** + * Handlers for API keys + * @param {Socket} socket Socket.io instance + * @returns {void} + */ +module.exports.apiKeySocketHandler = (socket) => { + // Add a new api key + socket.on("addAPIKey", async (key, callback) => { + try { + checkLogin(socket); + + let clearKey = nanoid(40); + let hashedKey = passwordHash.generate(clearKey); + key["key"] = hashedKey; + let bean = await APIKey.save(key, socket.userID); + + log.debug("apikeys", "Added API Key"); + log.debug("apikeys", key); + + // Append key ID and prefix to start of key seperated by _, used to get + // correct hash when validating key. + let formattedKey = "uk" + bean.id + "_" + clearKey; + await sendAPIKeyList(socket); + + // Enable API auth if the user creates a key, otherwise only basic + // auth will be used for API. + await Settings.set("apiKeysEnabled", true); + + callback({ + ok: true, + msg: "successAdded", + msgi18n: true, + key: formattedKey, + keyID: bean.id, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getAPIKeyList", async (callback) => { + try { + checkLogin(socket); + await sendAPIKeyList(socket); + callback({ + ok: true, + }); + } catch (e) { + console.error(e); + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteAPIKey", async (keyID, callback) => { + try { + checkLogin(socket); + + log.debug("apikeys", `Deleted API Key: ${keyID} User ID: ${socket.userID}`); + + await R.exec("DELETE FROM api_key WHERE id = ? AND user_id = ? ", [ + keyID, + socket.userID, + ]); + + apicache.clear(); + + callback({ + ok: true, + msg: "successDeleted", + msgi18n: true, + }); + + await sendAPIKeyList(socket); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("disableAPIKey", async (keyID, callback) => { + try { + checkLogin(socket); + + log.debug("apikeys", `Disabled Key: ${keyID} User ID: ${socket.userID}`); + + await R.exec("UPDATE api_key SET active = 0 WHERE id = ? ", [ + keyID, + ]); + + apicache.clear(); + + callback({ + ok: true, + msg: "successDisabled", + msgi18n: true, + }); + + await sendAPIKeyList(socket); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("enableAPIKey", async (keyID, callback) => { + try { + checkLogin(socket); + + log.debug("apikeys", `Enabled Key: ${keyID} User ID: ${socket.userID}`); + + await R.exec("UPDATE api_key SET active = 1 WHERE id = ? ", [ + keyID, + ]); + + apicache.clear(); + + callback({ + ok: true, + msg: "successEnabled", + msgi18n: true, + }); + + await sendAPIKeyList(socket); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); +}; diff --git a/server/socket-handlers/chart-socket-handler.js b/server/socket-handlers/chart-socket-handler.js new file mode 100644 index 0000000..654db0e --- /dev/null +++ b/server/socket-handlers/chart-socket-handler.js @@ -0,0 +1,38 @@ +const { checkLogin } = require("../util-server"); +const { UptimeCalculator } = require("../uptime-calculator"); +const { log } = require("../../src/util"); + +module.exports.chartSocketHandler = (socket) => { + socket.on("getMonitorChartData", async (monitorID, period, callback) => { + try { + checkLogin(socket); + + log.debug("monitor", `Get Monitor Chart Data: ${monitorID} User ID: ${socket.userID}`); + + if (period == null) { + throw new Error("Invalid period."); + } + + let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID); + + let data; + if (period <= 24) { + data = uptimeCalculator.getDataArray(period * 60, "minute"); + } else if (period <= 720) { + data = uptimeCalculator.getDataArray(period, "hour"); + } else { + data = uptimeCalculator.getDataArray(period / 24, "day"); + } + + callback({ + ok: true, + data, + }); + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); +}; diff --git a/server/socket-handlers/cloudflared-socket-handler.js b/server/socket-handlers/cloudflared-socket-handler.js new file mode 100644 index 0000000..809191f --- /dev/null +++ b/server/socket-handlers/cloudflared-socket-handler.js @@ -0,0 +1,122 @@ +const { checkLogin, setSetting, setting, doubleCheckPassword } = require("../util-server"); +const { CloudflaredTunnel } = require("node-cloudflared-tunnel"); +const { UptimeKumaServer } = require("../uptime-kuma-server"); +const { log } = require("../../src/util"); +const io = UptimeKumaServer.getInstance().io; + +const prefix = "cloudflared_"; +const cloudflared = new CloudflaredTunnel(); + +/** + * Change running state + * @param {string} running Is it running? + * @param {string} message Message to pass + * @returns {void} + */ +cloudflared.change = (running, message) => { + io.to("cloudflared").emit(prefix + "running", running); + io.to("cloudflared").emit(prefix + "message", message); +}; + +/** + * Emit an error message + * @param {string} errorMessage Error message to send + * @returns {void} + */ +cloudflared.error = (errorMessage) => { + io.to("cloudflared").emit(prefix + "errorMessage", errorMessage); +}; + +/** + * Handler for cloudflared + * @param {Socket} socket Socket.io instance + * @returns {void} + */ +module.exports.cloudflaredSocketHandler = (socket) => { + + socket.on(prefix + "join", async () => { + try { + checkLogin(socket); + socket.join("cloudflared"); + io.to(socket.userID).emit(prefix + "installed", cloudflared.checkInstalled()); + io.to(socket.userID).emit(prefix + "running", cloudflared.running); + io.to(socket.userID).emit(prefix + "token", await setting("cloudflaredTunnelToken")); + } catch (error) { } + }); + + socket.on(prefix + "leave", async () => { + try { + checkLogin(socket); + socket.leave("cloudflared"); + } catch (error) { } + }); + + socket.on(prefix + "start", async (token) => { + try { + checkLogin(socket); + if (token && typeof token === "string") { + await setSetting("cloudflaredTunnelToken", token); + cloudflared.token = token; + } else { + cloudflared.token = null; + } + cloudflared.start(); + } catch (error) { } + }); + + socket.on(prefix + "stop", async (currentPassword, callback) => { + try { + checkLogin(socket); + const disabledAuth = await setting("disableAuth"); + if (!disabledAuth) { + await doubleCheckPassword(socket, currentPassword); + } + cloudflared.stop(); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + + socket.on(prefix + "removeToken", async () => { + try { + checkLogin(socket); + await setSetting("cloudflaredTunnelToken", ""); + } catch (error) { } + }); + +}; + +/** + * Automatically start cloudflared + * @param {string} token Cloudflared tunnel token + * @returns {Promise<void>} + */ +module.exports.autoStart = async (token) => { + if (!token) { + token = await setting("cloudflaredTunnelToken"); + } else { + // Override the current token via args or env var + await setSetting("cloudflaredTunnelToken", token); + console.log("Use cloudflared token from args or env var"); + } + + if (token) { + console.log("Start cloudflared"); + cloudflared.token = token; + cloudflared.start(); + } +}; + +/** + * Stop cloudflared + * @returns {Promise<void>} + */ +module.exports.stop = async () => { + log.info("cloudflared", "Stop cloudflared"); + if (cloudflared) { + cloudflared.stop(); + } +}; diff --git a/server/socket-handlers/database-socket-handler.js b/server/socket-handlers/database-socket-handler.js new file mode 100644 index 0000000..ee2394b --- /dev/null +++ b/server/socket-handlers/database-socket-handler.js @@ -0,0 +1,42 @@ +const { checkLogin } = require("../util-server"); +const Database = require("../database"); + +/** + * Handlers for database + * @param {Socket} socket Socket.io instance + * @returns {void} + */ +module.exports.databaseSocketHandler = (socket) => { + + // Post or edit incident + socket.on("getDatabaseSize", async (callback) => { + try { + checkLogin(socket); + callback({ + ok: true, + size: Database.getSize(), + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + + socket.on("shrinkDatabase", async (callback) => { + try { + checkLogin(socket); + await Database.shrink(); + callback({ + ok: true, + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + +}; diff --git a/server/socket-handlers/docker-socket-handler.js b/server/socket-handlers/docker-socket-handler.js new file mode 100644 index 0000000..95a60bc --- /dev/null +++ b/server/socket-handlers/docker-socket-handler.js @@ -0,0 +1,82 @@ +const { sendDockerHostList } = require("../client"); +const { checkLogin } = require("../util-server"); +const { DockerHost } = require("../docker"); +const { log } = require("../../src/util"); + +/** + * Handlers for docker hosts + * @param {Socket} socket Socket.io instance + * @returns {void} + */ +module.exports.dockerSocketHandler = (socket) => { + socket.on("addDockerHost", async (dockerHost, dockerHostID, callback) => { + try { + checkLogin(socket); + + let dockerHostBean = await DockerHost.save(dockerHost, dockerHostID, socket.userID); + await sendDockerHostList(socket); + + callback({ + ok: true, + msg: "Saved.", + msgi18n: true, + id: dockerHostBean.id, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteDockerHost", async (dockerHostID, callback) => { + try { + checkLogin(socket); + + await DockerHost.delete(dockerHostID, socket.userID); + await sendDockerHostList(socket); + + callback({ + ok: true, + msg: "successDeleted", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("testDockerHost", async (dockerHost, callback) => { + try { + checkLogin(socket); + + let amount = await DockerHost.testDockerHost(dockerHost); + let msg; + + if (amount >= 1) { + msg = "Connected Successfully. Amount of containers: " + amount; + } else { + msg = "Connected Successfully, but there are no containers?"; + } + + callback({ + ok: true, + msg, + }); + + } catch (e) { + log.error("docker", e); + + callback({ + ok: false, + msg: e.message, + }); + } + }); +}; diff --git a/server/socket-handlers/general-socket-handler.js b/server/socket-handlers/general-socket-handler.js new file mode 100644 index 0000000..50dcd94 --- /dev/null +++ b/server/socket-handlers/general-socket-handler.js @@ -0,0 +1,127 @@ +const { log } = require("../../src/util"); +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"); +const fs = require("fs"); +const path = require("path"); + +let gameResolver = new GameResolver(); +let gameList = null; + +/** + * Get a game list via GameDig + * @returns {object[]} list of games supported by GameDig + */ +function getGameList() { + if (gameList == null) { + gameList = gameResolver._readGames().games.sort((a, b) => { + if ( a.pretty < b.pretty ) { + return -1; + } + if ( a.pretty > b.pretty ) { + return 1; + } + return 0; + }); + } + return gameList; +} + +/** + * Handler for general events + * @param {Socket} socket Socket.io instance + * @param {UptimeKumaServer} server Uptime Kuma server + * @returns {void} + */ +module.exports.generalSocketHandler = (socket, server) => { + socket.on("initServerTimezone", async (timezone) => { + try { + checkLogin(socket); + log.debug("generalSocketHandler", "Timezone: " + timezone); + await Settings.set("initServerTimezone", true); + await server.setTimezone(timezone); + await sendInfo(socket); + } catch (e) { + log.warn("initServerTimezone", e.message); + } + }); + + socket.on("getGameList", async (callback) => { + try { + checkLogin(socket); + callback({ + ok: true, + gameList: getGameList(), + }); + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("testChrome", (executable, callback) => { + try { + checkLogin(socket); + // Just noticed that await call could block the whole socket.io server!!! Use pure promise instead. + testChrome(executable).then((version) => { + callback({ + ok: true, + msg: { + key: "foundChromiumVersion", + values: [ version ], + }, + msgi18n: true, + }); + }).catch((e) => { + callback({ + ok: false, + msg: e.message, + }); + }); + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getPushExample", (language, callback) => { + + try { + let dir = path.join("./extra/push-examples", language); + let files = fs.readdirSync(dir); + + for (let file of files) { + if (file.startsWith("index.")) { + callback({ + ok: true, + code: fs.readFileSync(path.join(dir, file), "utf8"), + }); + return; + } + } + } catch (e) { + + } + + callback({ + ok: false, + msg: "Not found", + }); + }); + + // Disconnect all other socket clients of the user + socket.on("disconnectOtherSocketClients", async () => { + try { + checkLogin(socket); + server.disconnectAllSocketClients(socket.userID, socket.id); + } catch (e) { + log.warn("disconnectAllSocketClients", e.message); + } + }); +}; diff --git a/server/socket-handlers/maintenance-socket-handler.js b/server/socket-handlers/maintenance-socket-handler.js new file mode 100644 index 0000000..7de13fe --- /dev/null +++ b/server/socket-handlers/maintenance-socket-handler.js @@ -0,0 +1,337 @@ +const { checkLogin } = require("../util-server"); +const { log } = require("../../src/util"); +const { R } = require("redbean-node"); +const apicache = require("../modules/apicache"); +const { UptimeKumaServer } = require("../uptime-kuma-server"); +const Maintenance = require("../model/maintenance"); +const server = UptimeKumaServer.getInstance(); + +/** + * Handlers for Maintenance + * @param {Socket} socket Socket.io instance + * @returns {void} + */ +module.exports.maintenanceSocketHandler = (socket) => { + // Add a new maintenance + socket.on("addMaintenance", async (maintenance, callback) => { + try { + checkLogin(socket); + + log.debug("maintenance", maintenance); + + let bean = await Maintenance.jsonToBean(R.dispense("maintenance"), maintenance); + bean.user_id = socket.userID; + let maintenanceID = await R.store(bean); + + server.maintenanceList[maintenanceID] = bean; + await bean.run(true); + + await server.sendMaintenanceList(socket); + + callback({ + ok: true, + msg: "successAdded", + msgi18n: true, + maintenanceID, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + // Edit a maintenance + socket.on("editMaintenance", async (maintenance, callback) => { + try { + checkLogin(socket); + + let bean = server.getMaintenance(maintenance.id); + + if (bean.user_id !== socket.userID) { + throw new Error("Permission denied."); + } + + await Maintenance.jsonToBean(bean, maintenance); + await R.store(bean); + await bean.run(true); + await server.sendMaintenanceList(socket); + + callback({ + ok: true, + msg: "Saved.", + msgi18n: true, + maintenanceID: bean.id, + }); + + } catch (e) { + console.error(e); + callback({ + ok: false, + msg: e.message, + }); + } + }); + + // Add a new monitor_maintenance + socket.on("addMonitorMaintenance", async (maintenanceID, monitors, callback) => { + try { + checkLogin(socket); + + await R.exec("DELETE FROM monitor_maintenance WHERE maintenance_id = ?", [ + maintenanceID + ]); + + for await (const monitor of monitors) { + let bean = R.dispense("monitor_maintenance"); + + bean.import({ + monitor_id: monitor.id, + maintenance_id: maintenanceID + }); + await R.store(bean); + } + + apicache.clear(); + + callback({ + ok: true, + msg: "successAdded", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + // Add a new monitor_maintenance + socket.on("addMaintenanceStatusPage", async (maintenanceID, statusPages, callback) => { + try { + checkLogin(socket); + + await R.exec("DELETE FROM maintenance_status_page WHERE maintenance_id = ?", [ + maintenanceID + ]); + + for await (const statusPage of statusPages) { + let bean = R.dispense("maintenance_status_page"); + + bean.import({ + status_page_id: statusPage.id, + maintenance_id: maintenanceID + }); + await R.store(bean); + } + + apicache.clear(); + + callback({ + ok: true, + msg: "successAdded", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getMaintenance", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + log.debug("maintenance", `Get Maintenance: ${maintenanceID} User ID: ${socket.userID}`); + + let bean = await R.findOne("maintenance", " id = ? AND user_id = ? ", [ + maintenanceID, + socket.userID, + ]); + + callback({ + ok: true, + maintenance: await bean.toJSON(), + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getMaintenanceList", async (callback) => { + try { + checkLogin(socket); + await server.sendMaintenanceList(socket); + callback({ + ok: true, + }); + } catch (e) { + console.error(e); + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getMonitorMaintenance", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + log.debug("maintenance", `Get Monitors for Maintenance: ${maintenanceID} User ID: ${socket.userID}`); + + 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, + ]); + + callback({ + ok: true, + monitors, + }); + + } catch (e) { + console.error(e); + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("getMaintenanceStatusPage", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + log.debug("maintenance", `Get Status Pages for Maintenance: ${maintenanceID} User ID: ${socket.userID}`); + + let statusPages = await R.getAll("SELECT status_page.id, status_page.title FROM maintenance_status_page msp JOIN status_page ON msp.status_page_id = status_page.id WHERE msp.maintenance_id = ? ", [ + maintenanceID, + ]); + + callback({ + ok: true, + statusPages, + }); + + } catch (e) { + console.error(e); + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteMaintenance", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + log.debug("maintenance", `Delete Maintenance: ${maintenanceID} User ID: ${socket.userID}`); + + if (maintenanceID in server.maintenanceList) { + server.maintenanceList[maintenanceID].stop(); + delete server.maintenanceList[maintenanceID]; + } + + await R.exec("DELETE FROM maintenance WHERE id = ? AND user_id = ? ", [ + maintenanceID, + socket.userID, + ]); + + apicache.clear(); + + callback({ + ok: true, + msg: "successDeleted", + msgi18n: true, + }); + + await server.sendMaintenanceList(socket); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("pauseMaintenance", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + log.debug("maintenance", `Pause Maintenance: ${maintenanceID} User ID: ${socket.userID}`); + + let maintenance = server.getMaintenance(maintenanceID); + + if (!maintenance) { + throw new Error("Maintenance not found"); + } + + maintenance.active = false; + await R.store(maintenance); + maintenance.stop(); + + apicache.clear(); + + callback({ + ok: true, + msg: "successPaused", + msgi18n: true, + }); + + await server.sendMaintenanceList(socket); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("resumeMaintenance", async (maintenanceID, callback) => { + try { + checkLogin(socket); + + log.debug("maintenance", `Resume Maintenance: ${maintenanceID} User ID: ${socket.userID}`); + + let maintenance = server.getMaintenance(maintenanceID); + + if (!maintenance) { + throw new Error("Maintenance not found"); + } + + maintenance.active = true; + await R.store(maintenance); + await maintenance.run(); + + apicache.clear(); + + callback({ + ok: true, + msg: "successResumed", + msgi18n: true, + }); + + await server.sendMaintenanceList(socket); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); +}; diff --git a/server/socket-handlers/proxy-socket-handler.js b/server/socket-handlers/proxy-socket-handler.js new file mode 100644 index 0000000..9e80371 --- /dev/null +++ b/server/socket-handlers/proxy-socket-handler.js @@ -0,0 +1,61 @@ +const { checkLogin } = require("../util-server"); +const { Proxy } = require("../proxy"); +const { sendProxyList } = require("../client"); +const { UptimeKumaServer } = require("../uptime-kuma-server"); +const server = UptimeKumaServer.getInstance(); + +/** + * Handlers for proxy + * @param {Socket} socket Socket.io instance + * @returns {void} + */ +module.exports.proxySocketHandler = (socket) => { + socket.on("addProxy", async (proxy, proxyID, callback) => { + try { + checkLogin(socket); + + const proxyBean = await Proxy.save(proxy, proxyID, socket.userID); + await sendProxyList(socket); + + if (proxy.applyExisting) { + await Proxy.reloadProxy(); + await server.sendMonitorList(socket); + } + + callback({ + ok: true, + msg: "Saved.", + msgi18n: true, + id: proxyBean.id, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteProxy", async (proxyID, callback) => { + try { + checkLogin(socket); + + await Proxy.delete(proxyID, socket.userID); + await sendProxyList(socket); + await Proxy.reloadProxy(); + + callback({ + ok: true, + msg: "successDeleted", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); +}; diff --git a/server/socket-handlers/remote-browser-socket-handler.js b/server/socket-handlers/remote-browser-socket-handler.js new file mode 100644 index 0000000..ae53030 --- /dev/null +++ b/server/socket-handlers/remote-browser-socket-handler.js @@ -0,0 +1,82 @@ +const { sendRemoteBrowserList } = require("../client"); +const { checkLogin } = require("../util-server"); +const { RemoteBrowser } = require("../remote-browser"); + +const { log } = require("../../src/util"); +const { testRemoteBrowser } = require("../monitor-types/real-browser-monitor-type"); + +/** + * Handlers for docker hosts + * @param {Socket} socket Socket.io instance + * @returns {void} + */ +module.exports.remoteBrowserSocketHandler = (socket) => { + socket.on("addRemoteBrowser", async (remoteBrowser, remoteBrowserID, callback) => { + try { + checkLogin(socket); + + let remoteBrowserBean = await RemoteBrowser.save(remoteBrowser, remoteBrowserID, socket.userID); + await sendRemoteBrowserList(socket); + + callback({ + ok: true, + msg: "Saved.", + msgi18n: true, + id: remoteBrowserBean.id, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteRemoteBrowser", async (dockerHostID, callback) => { + try { + checkLogin(socket); + + await RemoteBrowser.delete(dockerHostID, socket.userID); + await sendRemoteBrowserList(socket); + + callback({ + ok: true, + msg: "successDeleted", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("testRemoteBrowser", async (remoteBrowser, callback) => { + try { + checkLogin(socket); + let check = await testRemoteBrowser(remoteBrowser.url); + log.info("remoteBrowser", "Tested remote browser: " + check); + let msg; + + if (check) { + msg = "Connected Successfully."; + } + + callback({ + ok: true, + msg, + }); + + } catch (e) { + log.error("remoteBrowser", e); + + callback({ + ok: false, + msg: e.message, + }); + } + }); +}; diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js new file mode 100644 index 0000000..0804da1 --- /dev/null +++ b/server/socket-handlers/status-page-socket-handler.js @@ -0,0 +1,374 @@ +const { R } = require("redbean-node"); +const { checkLogin, setSetting } = require("../util-server"); +const dayjs = require("dayjs"); +const { log } = require("../../src/util"); +const ImageDataURI = require("../image-data-uri"); +const Database = require("../database"); +const apicache = require("../modules/apicache"); +const StatusPage = require("../model/status_page"); +const { UptimeKumaServer } = require("../uptime-kuma-server"); + +/** + * Socket handlers for status page + * @param {Socket} socket Socket.io instance to add listeners on + * @returns {void} + */ +module.exports.statusPageSocketHandler = (socket) => { + + // Post or edit incident + socket.on("postIncident", async (slug, incident, callback) => { + try { + checkLogin(socket); + + let statusPageID = await StatusPage.slugToID(slug); + + if (!statusPageID) { + throw new Error("slug is not found"); + } + + await R.exec("UPDATE incident SET pin = 0 WHERE status_page_id = ? ", [ + statusPageID + ]); + + let incidentBean; + + if (incident.id) { + incidentBean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [ + incident.id, + statusPageID + ]); + } + + if (incidentBean == null) { + incidentBean = R.dispense("incident"); + } + + incidentBean.title = incident.title; + incidentBean.content = incident.content; + incidentBean.style = incident.style; + incidentBean.pin = true; + incidentBean.status_page_id = statusPageID; + + if (incident.id) { + incidentBean.lastUpdatedDate = R.isoDateTime(dayjs.utc()); + } else { + incidentBean.createdDate = R.isoDateTime(dayjs.utc()); + } + + await R.store(incidentBean); + + callback({ + ok: true, + incident: incidentBean.toPublicJSON(), + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + + socket.on("unpinIncident", async (slug, callback) => { + try { + checkLogin(socket); + + let statusPageID = await StatusPage.slugToID(slug); + + await R.exec("UPDATE incident SET pin = 0 WHERE pin = 1 AND status_page_id = ? ", [ + statusPageID + ]); + + callback({ + ok: true, + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + + socket.on("getStatusPage", async (slug, callback) => { + try { + checkLogin(socket); + + let statusPage = await R.findOne("status_page", " slug = ? ", [ + slug + ]); + + if (!statusPage) { + throw new Error("No slug?"); + } + + callback({ + ok: true, + config: await statusPage.toJSON(), + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + + // Save Status Page + // imgDataUrl Only Accept PNG! + socket.on("saveStatusPage", async (slug, config, imgDataUrl, publicGroupList, callback) => { + try { + checkLogin(socket); + + // Save Config + let statusPage = await R.findOne("status_page", " slug = ? ", [ + slug + ]); + + if (!statusPage) { + throw new Error("No slug?"); + } + + checkSlug(config.slug); + + const header = "data:image/png;base64,"; + + // Check logo format + // If is image data url, convert to png file + // Else assume it is a url, nothing to do + if (imgDataUrl.startsWith("data:")) { + if (! imgDataUrl.startsWith(header)) { + throw new Error("Only allowed PNG logo."); + } + + const filename = `logo${statusPage.id}.png`; + + // Convert to file + await ImageDataURI.outputFile(imgDataUrl, Database.uploadDir + filename); + config.logo = `/upload/${filename}?t=` + Date.now(); + + } else { + config.logo = imgDataUrl; + } + + statusPage.slug = config.slug; + statusPage.title = config.title; + statusPage.description = config.description; + statusPage.icon = config.logo; + statusPage.autoRefreshInterval = config.autoRefreshInterval, + statusPage.theme = config.theme; + //statusPage.published = ; + //statusPage.search_engine_index = ; + statusPage.show_tags = config.showTags; + //statusPage.password = null; + statusPage.footer_text = config.footerText; + statusPage.custom_css = config.customCSS; + statusPage.show_powered_by = config.showPoweredBy; + statusPage.show_certificate_expiry = config.showCertificateExpiry; + statusPage.modified_date = R.isoDateTime(); + statusPage.google_analytics_tag_id = config.googleAnalyticsId; + + await R.store(statusPage); + + await statusPage.updateDomainNameList(config.domainNameList); + await StatusPage.loadDomainMappingList(); + + // Save Public Group List + const groupIDList = []; + let groupOrder = 1; + + for (let group of publicGroupList) { + let groupBean; + if (group.id) { + groupBean = await R.findOne("group", " id = ? AND public = 1 AND status_page_id = ? ", [ + group.id, + statusPage.id + ]); + } else { + groupBean = R.dispense("group"); + } + + groupBean.status_page_id = statusPage.id; + groupBean.name = group.name; + groupBean.public = true; + groupBean.weight = groupOrder++; + + await R.store(groupBean); + + await R.exec("DELETE FROM monitor_group WHERE group_id = ? ", [ + groupBean.id + ]); + + let monitorOrder = 1; + + for (let monitor of group.monitorList) { + let relationBean = R.dispense("monitor_group"); + relationBean.weight = monitorOrder++; + relationBean.group_id = groupBean.id; + relationBean.monitor_id = monitor.id; + + if (monitor.sendUrl !== undefined) { + relationBean.send_url = monitor.sendUrl; + } + + await R.store(relationBean); + } + + groupIDList.push(groupBean.id); + group.id = groupBean.id; + } + + // Delete groups that are not in the list + log.debug("socket", "Delete groups that are not in the list"); + const slots = groupIDList.map(() => "?").join(","); + + const data = [ + ...groupIDList, + statusPage.id + ]; + await R.exec(`DELETE FROM \`group\` WHERE id NOT IN (${slots}) AND status_page_id = ?`, data); + + const server = UptimeKumaServer.getInstance(); + + // Also change entry page to new slug if it is the default one, and slug is changed. + if (server.entryPage === "statusPage-" + slug && statusPage.slug !== slug) { + server.entryPage = "statusPage-" + statusPage.slug; + await setSetting("entryPage", server.entryPage, "general"); + } + + apicache.clear(); + + callback({ + ok: true, + publicGroupList, + }); + + } catch (error) { + log.error("socket", error); + + callback({ + ok: false, + msg: error.message, + }); + } + }); + + // Add a new status page + socket.on("addStatusPage", async (title, slug, callback) => { + try { + checkLogin(socket); + + title = title?.trim(); + slug = slug?.trim(); + + // Check empty + if (!title || !slug) { + throw new Error("Please input all fields"); + } + + // Make sure slug is string + if (typeof slug !== "string") { + throw new Error("Slug -Accept string only"); + } + + // lower case only + slug = slug.toLowerCase(); + + checkSlug(slug); + + let statusPage = R.dispense("status_page"); + statusPage.slug = slug; + statusPage.title = title; + statusPage.theme = "auto"; + statusPage.icon = ""; + statusPage.autoRefreshInterval = 300; + await R.store(statusPage); + + callback({ + ok: true, + msg: "successAdded", + msgi18n: true, + }); + + } catch (error) { + console.error(error); + callback({ + ok: false, + msg: error.message, + }); + } + }); + + // Delete a status page + socket.on("deleteStatusPage", async (slug, callback) => { + const server = UptimeKumaServer.getInstance(); + + try { + checkLogin(socket); + + let statusPageID = await StatusPage.slugToID(slug); + + if (statusPageID) { + + // Reset entry page if it is the default one. + if (server.entryPage === "statusPage-" + slug) { + server.entryPage = "dashboard"; + await setSetting("entryPage", server.entryPage, "general"); + } + + // No need to delete records from `status_page_cname`, because it has cascade foreign key. + // But for incident & group, it is hard to add cascade foreign key during migration, so they have to be deleted manually. + + // Delete incident + await R.exec("DELETE FROM incident WHERE status_page_id = ? ", [ + statusPageID + ]); + + // Delete group + await R.exec("DELETE FROM `group` WHERE status_page_id = ? ", [ + statusPageID + ]); + + // Delete status_page + await R.exec("DELETE FROM status_page WHERE id = ? ", [ + statusPageID + ]); + + } else { + throw new Error("Status Page is not found"); + } + + callback({ + ok: true, + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); +}; + +/** + * Check slug a-z, 0-9, - only + * Regex from: https://stackoverflow.com/questions/22454258/js-regex-string-validation-for-slug + * @param {string} slug Slug to test + * @returns {void} + * @throws Slug is not valid + */ +function checkSlug(slug) { + if (typeof slug !== "string") { + throw new Error("Slug must be string"); + } + + slug = slug.trim(); + + if (!slug) { + throw new Error("Slug cannot be empty"); + } + + if (!slug.match(/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/)) { + throw new Error("Invalid Slug"); + } +} diff --git a/server/uptime-calculator.js b/server/uptime-calculator.js new file mode 100644 index 0000000..71d1d45 --- /dev/null +++ b/server/uptime-calculator.js @@ -0,0 +1,865 @@ +const dayjs = require("dayjs"); +const { UP, MAINTENANCE, DOWN, PENDING } = require("../src/util"); +const { LimitQueue } = require("./utils/limit-queue"); +const { log } = require("../src/util"); +const { R } = require("redbean-node"); + +/** + * Calculates the uptime of a monitor. + */ +class UptimeCalculator { + /** + * @private + * @type {{string:UptimeCalculator}} + */ + static list = {}; + + /** + * For testing purposes, we can set the current date to a specific date. + * @type {dayjs.Dayjs} + */ + static currentDate = null; + + /** + * monitorID the id of the monitor + * @type {number} + */ + monitorID; + + /** + * Recent 24-hour uptime, each item is a 1-minute interval + * Key: {number} DivisionKey + * @type {LimitQueue<number,string>} + */ + minutelyUptimeDataList = new LimitQueue(24 * 60); + + /** + * Recent 30-day uptime, each item is a 1-hour interval + * Key: {number} DivisionKey + * @type {LimitQueue<number,string>} + */ + hourlyUptimeDataList = new LimitQueue(30 * 24); + + /** + * Daily uptime data, + * Key: {number} DailyKey + */ + dailyUptimeDataList = new LimitQueue(365); + + lastUptimeData = null; + lastHourlyUptimeData = null; + lastDailyUptimeData = null; + + lastDailyStatBean = null; + lastHourlyStatBean = null; + lastMinutelyStatBean = null; + + /** + * For migration purposes. + * @type {boolean} + */ + migrationMode = false; + + statMinutelyKeepHour = 24; + statHourlyKeepDay = 30; + + /** + * Get the uptime calculator for a monitor + * Initializes and returns the monitor if it does not exist + * @param {number} monitorID the id of the monitor + * @returns {Promise<UptimeCalculator>} UptimeCalculator + */ + static async getUptimeCalculator(monitorID) { + if (!monitorID) { + throw new Error("Monitor ID is required"); + } + + if (!UptimeCalculator.list[monitorID]) { + UptimeCalculator.list[monitorID] = new UptimeCalculator(); + await UptimeCalculator.list[monitorID].init(monitorID); + } + return UptimeCalculator.list[monitorID]; + } + + /** + * Remove a monitor from the list + * @param {number} monitorID the id of the monitor + * @returns {Promise<void>} + */ + static async remove(monitorID) { + delete UptimeCalculator.list[monitorID]; + } + + /** + * + */ + constructor() { + if (process.env.TEST_BACKEND) { + // Override the getCurrentDate() method to return a specific date + // Only for testing + this.getCurrentDate = () => { + if (UptimeCalculator.currentDate) { + return UptimeCalculator.currentDate; + } else { + return dayjs.utc(); + } + }; + } + } + + /** + * Initialize the uptime calculator for a monitor + * @param {number} monitorID the id of the monitor + * @returns {Promise<void>} + */ + async init(monitorID) { + this.monitorID = monitorID; + + let now = this.getCurrentDate(); + + // Load minutely data from database (recent 24 hours only) + let minutelyStatBeans = await R.find("stat_minutely", " monitor_id = ? AND timestamp > ? ORDER BY timestamp", [ + monitorID, + this.getMinutelyKey(now.subtract(24, "hour")), + ]); + + for (let bean of minutelyStatBeans) { + let data = { + up: bean.up, + down: bean.down, + avgPing: bean.ping, + minPing: bean.pingMin, + maxPing: bean.pingMax, + }; + + if (bean.extras != null) { + data = { + ...data, + ...JSON.parse(bean.extras), + }; + } + + let key = bean.timestamp; + this.minutelyUptimeDataList.push(key, data); + } + + // Load hourly data from database (recent 30 days only) + let hourlyStatBeans = await R.find("stat_hourly", " monitor_id = ? AND timestamp > ? ORDER BY timestamp", [ + monitorID, + this.getHourlyKey(now.subtract(30, "day")), + ]); + + for (let bean of hourlyStatBeans) { + let data = { + up: bean.up, + down: bean.down, + avgPing: bean.ping, + minPing: bean.pingMin, + maxPing: bean.pingMax, + }; + + if (bean.extras != null) { + data = { + ...data, + ...JSON.parse(bean.extras), + }; + } + + this.hourlyUptimeDataList.push(bean.timestamp, data); + } + + // Load daily data from database (recent 365 days only) + let dailyStatBeans = await R.find("stat_daily", " monitor_id = ? AND timestamp > ? ORDER BY timestamp", [ + monitorID, + this.getDailyKey(now.subtract(365, "day")), + ]); + + for (let bean of dailyStatBeans) { + let data = { + up: bean.up, + down: bean.down, + avgPing: bean.ping, + minPing: bean.pingMin, + maxPing: bean.pingMax, + }; + + if (bean.extras != null) { + data = { + ...data, + ...JSON.parse(bean.extras), + }; + } + + this.dailyUptimeDataList.push(bean.timestamp, data); + } + } + + /** + * @param {number} status status + * @param {number} ping Ping + * @param {dayjs.Dayjs} date Date (Only for migration) + * @returns {dayjs.Dayjs} date + * @throws {Error} Invalid status + */ + async update(status, ping = 0, date) { + if (!date) { + date = this.getCurrentDate(); + } + + let flatStatus = this.flatStatus(status); + + if (flatStatus === DOWN && ping > 0) { + log.debug("uptime-calc", "The ping is not effective when the status is DOWN"); + } + + let divisionKey = this.getMinutelyKey(date); + let hourlyKey = this.getHourlyKey(date); + let dailyKey = this.getDailyKey(date); + + let minutelyData = this.minutelyUptimeDataList[divisionKey]; + let hourlyData = this.hourlyUptimeDataList[hourlyKey]; + let dailyData = this.dailyUptimeDataList[dailyKey]; + + if (status === MAINTENANCE) { + minutelyData.maintenance = minutelyData.maintenance ? minutelyData.maintenance + 1 : 1; + hourlyData.maintenance = hourlyData.maintenance ? hourlyData.maintenance + 1 : 1; + dailyData.maintenance = dailyData.maintenance ? dailyData.maintenance + 1 : 1; + + } else if (flatStatus === UP) { + minutelyData.up += 1; + hourlyData.up += 1; + dailyData.up += 1; + + // Only UP status can update the ping + if (!isNaN(ping)) { + // Add avg ping + // The first beat of the minute, the ping is the current ping + if (minutelyData.up === 1) { + minutelyData.avgPing = ping; + minutelyData.minPing = ping; + minutelyData.maxPing = ping; + } else { + minutelyData.avgPing = (minutelyData.avgPing * (minutelyData.up - 1) + ping) / minutelyData.up; + minutelyData.minPing = Math.min(minutelyData.minPing, ping); + minutelyData.maxPing = Math.max(minutelyData.maxPing, ping); + } + + // Add avg ping + // The first beat of the hour, the ping is the current ping + if (hourlyData.up === 1) { + hourlyData.avgPing = ping; + hourlyData.minPing = ping; + hourlyData.maxPing = ping; + } else { + hourlyData.avgPing = (hourlyData.avgPing * (hourlyData.up - 1) + ping) / hourlyData.up; + hourlyData.minPing = Math.min(hourlyData.minPing, ping); + hourlyData.maxPing = Math.max(hourlyData.maxPing, ping); + } + + // Add avg ping (daily) + // The first beat of the day, the ping is the current ping + if (dailyData.up === 1) { + dailyData.avgPing = ping; + dailyData.minPing = ping; + dailyData.maxPing = ping; + } else { + dailyData.avgPing = (dailyData.avgPing * (dailyData.up - 1) + ping) / dailyData.up; + dailyData.minPing = Math.min(dailyData.minPing, ping); + dailyData.maxPing = Math.max(dailyData.maxPing, ping); + } + } + + } else if (flatStatus === DOWN) { + minutelyData.down += 1; + hourlyData.down += 1; + dailyData.down += 1; + } + + if (minutelyData !== this.lastUptimeData) { + this.lastUptimeData = minutelyData; + } + + if (hourlyData !== this.lastHourlyUptimeData) { + this.lastHourlyUptimeData = hourlyData; + } + + if (dailyData !== this.lastDailyUptimeData) { + this.lastDailyUptimeData = dailyData; + } + + // Don't store data in test mode + if (process.env.TEST_BACKEND) { + log.debug("uptime-calc", "Skip storing data in test mode"); + return date; + } + + let dailyStatBean = await this.getDailyStatBean(dailyKey); + dailyStatBean.up = dailyData.up; + dailyStatBean.down = dailyData.down; + dailyStatBean.ping = dailyData.avgPing; + dailyStatBean.pingMin = dailyData.minPing; + dailyStatBean.pingMax = dailyData.maxPing; + { + // eslint-disable-next-line no-unused-vars + const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = dailyData; + if (Object.keys(extras).length > 0) { + dailyStatBean.extras = JSON.stringify(extras); + } + } + await R.store(dailyStatBean); + + let currentDate = this.getCurrentDate(); + + // For migration mode, we don't need to store old hourly and minutely data, but we need 30-day's hourly data + // Run anyway for non-migration mode + if (!this.migrationMode || date.isAfter(currentDate.subtract(this.statHourlyKeepDay, "day"))) { + let hourlyStatBean = await this.getHourlyStatBean(hourlyKey); + hourlyStatBean.up = hourlyData.up; + hourlyStatBean.down = hourlyData.down; + hourlyStatBean.ping = hourlyData.avgPing; + hourlyStatBean.pingMin = hourlyData.minPing; + hourlyStatBean.pingMax = hourlyData.maxPing; + { + // eslint-disable-next-line no-unused-vars + const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = hourlyData; + if (Object.keys(extras).length > 0) { + hourlyStatBean.extras = JSON.stringify(extras); + } + } + await R.store(hourlyStatBean); + } + + // For migration mode, we don't need to store old hourly and minutely data, but we need 24-hour's minutely data + // Run anyway for non-migration mode + if (!this.migrationMode || date.isAfter(currentDate.subtract(this.statMinutelyKeepHour, "hour"))) { + let minutelyStatBean = await this.getMinutelyStatBean(divisionKey); + minutelyStatBean.up = minutelyData.up; + minutelyStatBean.down = minutelyData.down; + minutelyStatBean.ping = minutelyData.avgPing; + minutelyStatBean.pingMin = minutelyData.minPing; + minutelyStatBean.pingMax = minutelyData.maxPing; + { + // eslint-disable-next-line no-unused-vars + const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = minutelyData; + if (Object.keys(extras).length > 0) { + minutelyStatBean.extras = JSON.stringify(extras); + } + } + await R.store(minutelyStatBean); + } + + // No need to remove old data in migration mode + if (!this.migrationMode) { + // Remove the old data + // TODO: Improvement: Convert it to a job? + log.debug("uptime-calc", "Remove old data"); + await R.exec("DELETE FROM stat_minutely WHERE monitor_id = ? AND timestamp < ?", [ + this.monitorID, + this.getMinutelyKey(currentDate.subtract(this.statMinutelyKeepHour, "hour")), + ]); + + await R.exec("DELETE FROM stat_hourly WHERE monitor_id = ? AND timestamp < ?", [ + this.monitorID, + this.getHourlyKey(currentDate.subtract(this.statHourlyKeepDay, "day")), + ]); + } + + return date; + } + + /** + * Get the daily stat bean + * @param {number} timestamp milliseconds + * @returns {Promise<import("redbean-node").Bean>} stat_daily bean + */ + async getDailyStatBean(timestamp) { + if (this.lastDailyStatBean && this.lastDailyStatBean.timestamp === timestamp) { + return this.lastDailyStatBean; + } + + let bean = await R.findOne("stat_daily", " monitor_id = ? AND timestamp = ?", [ + this.monitorID, + timestamp, + ]); + + if (!bean) { + bean = R.dispense("stat_daily"); + bean.monitor_id = this.monitorID; + bean.timestamp = timestamp; + } + + this.lastDailyStatBean = bean; + return this.lastDailyStatBean; + } + + /** + * Get the hourly stat bean + * @param {number} timestamp milliseconds + * @returns {Promise<import("redbean-node").Bean>} stat_hourly bean + */ + async getHourlyStatBean(timestamp) { + if (this.lastHourlyStatBean && this.lastHourlyStatBean.timestamp === timestamp) { + return this.lastHourlyStatBean; + } + + let bean = await R.findOne("stat_hourly", " monitor_id = ? AND timestamp = ?", [ + this.monitorID, + timestamp, + ]); + + if (!bean) { + bean = R.dispense("stat_hourly"); + bean.monitor_id = this.monitorID; + bean.timestamp = timestamp; + } + + this.lastHourlyStatBean = bean; + return this.lastHourlyStatBean; + } + + /** + * Get the minutely stat bean + * @param {number} timestamp milliseconds + * @returns {Promise<import("redbean-node").Bean>} stat_minutely bean + */ + async getMinutelyStatBean(timestamp) { + if (this.lastMinutelyStatBean && this.lastMinutelyStatBean.timestamp === timestamp) { + return this.lastMinutelyStatBean; + } + + let bean = await R.findOne("stat_minutely", " monitor_id = ? AND timestamp = ?", [ + this.monitorID, + timestamp, + ]); + + if (!bean) { + bean = R.dispense("stat_minutely"); + bean.monitor_id = this.monitorID; + bean.timestamp = timestamp; + } + + this.lastMinutelyStatBean = bean; + return this.lastMinutelyStatBean; + } + + /** + * Convert timestamp to minutely key + * @param {dayjs.Dayjs} date The heartbeat date + * @returns {number} Timestamp + */ + getMinutelyKey(date) { + // Truncate value to minutes (e.g. 2021-01-01 12:34:56 -> 2021-01-01 12:34:00) + date = date.startOf("minute"); + + // Convert to timestamp in second + let divisionKey = date.unix(); + + if (! (divisionKey in this.minutelyUptimeDataList)) { + this.minutelyUptimeDataList.push(divisionKey, { + up: 0, + down: 0, + avgPing: 0, + minPing: 0, + maxPing: 0, + }); + } + + return divisionKey; + } + + /** + * Convert timestamp to hourly key + * @param {dayjs.Dayjs} date The heartbeat date + * @returns {number} Timestamp + */ + getHourlyKey(date) { + // Truncate value to hours (e.g. 2021-01-01 12:34:56 -> 2021-01-01 12:00:00) + date = date.startOf("hour"); + + // Convert to timestamp in second + let divisionKey = date.unix(); + + if (! (divisionKey in this.hourlyUptimeDataList)) { + this.hourlyUptimeDataList.push(divisionKey, { + up: 0, + down: 0, + avgPing: 0, + minPing: 0, + maxPing: 0, + }); + } + + return divisionKey; + } + + /** + * Convert timestamp to daily key + * @param {dayjs.Dayjs} date The heartbeat date + * @returns {number} Timestamp + */ + getDailyKey(date) { + // Truncate value to start of day (e.g. 2021-01-01 12:34:56 -> 2021-01-01 00:00:00) + // Considering if the user keep changing could affect the calculation, so use UTC time to avoid this problem. + date = date.utc().startOf("day"); + let dailyKey = date.unix(); + + if (!this.dailyUptimeDataList[dailyKey]) { + this.dailyUptimeDataList.push(dailyKey, { + up: 0, + down: 0, + avgPing: 0, + minPing: 0, + maxPing: 0, + }); + } + + return dailyKey; + } + + /** + * Convert timestamp to key + * @param {dayjs.Dayjs} datetime Datetime + * @param {"day" | "hour" | "minute"} type the type of data which is expected to be returned + * @returns {number} Timestamp + * @throws {Error} If the type is invalid + */ + getKey(datetime, type) { + switch (type) { + case "day": + return this.getDailyKey(datetime); + case "hour": + return this.getHourlyKey(datetime); + case "minute": + return this.getMinutelyKey(datetime); + default: + throw new Error("Invalid type"); + } + } + + /** + * Flat status to UP or DOWN + * @param {number} status the status which should be turned into a flat status + * @returns {UP|DOWN|PENDING} The flat status + * @throws {Error} Invalid status + */ + flatStatus(status) { + switch (status) { + case UP: + case MAINTENANCE: + return UP; + case DOWN: + case PENDING: + return DOWN; + } + throw new Error("Invalid status"); + } + + /** + * @param {number} num the number of data points which are expected to be returned + * @param {"day" | "hour" | "minute"} type the type of data which is expected to be returned + * @returns {UptimeDataResult} UptimeDataResult + * @throws {Error} The maximum number of minutes greater than 1440 + */ + getData(num, type = "day") { + + if (type === "hour" && num > 24 * 30) { + throw new Error("The maximum number of hours is 720"); + } + if (type === "minute" && num > 24 * 60) { + throw new Error("The maximum number of minutes is 1440"); + } + if (type === "day" && num > 365) { + throw new Error("The maximum number of days is 365"); + } + // Get the current time period key based on the type + let key = this.getKey(this.getCurrentDate(), type); + + let total = { + up: 0, + down: 0, + }; + + let totalPing = 0; + let endTimestamp; + + // Get the eariest timestamp of the required period based on the type + switch (type) { + case "day": + endTimestamp = key - 86400 * (num - 1); + break; + case "hour": + endTimestamp = key - 3600 * (num - 1); + break; + case "minute": + endTimestamp = key - 60 * (num - 1); + break; + default: + throw new Error("Invalid type"); + } + + // Sum up all data in the specified time range + while (key >= endTimestamp) { + let data; + + switch (type) { + case "day": + data = this.dailyUptimeDataList[key]; + break; + case "hour": + data = this.hourlyUptimeDataList[key]; + break; + case "minute": + data = this.minutelyUptimeDataList[key]; + break; + default: + throw new Error("Invalid type"); + } + + if (data) { + total.up += data.up; + total.down += data.down; + totalPing += data.avgPing * data.up; + } + + // Set key to the previous time period + switch (type) { + case "day": + key -= 86400; + break; + case "hour": + key -= 3600; + break; + case "minute": + key -= 60; + break; + default: + throw new Error("Invalid type"); + } + } + + let uptimeData = new UptimeDataResult(); + + // If there is no data in the previous time ranges, use the last data? + if (total.up === 0 && total.down === 0) { + switch (type) { + case "day": + if (this.lastDailyUptimeData) { + total = this.lastDailyUptimeData; + totalPing = total.avgPing * total.up; + } else { + return uptimeData; + } + break; + case "hour": + if (this.lastHourlyUptimeData) { + total = this.lastHourlyUptimeData; + totalPing = total.avgPing * total.up; + } else { + return uptimeData; + } + break; + case "minute": + if (this.lastUptimeData) { + total = this.lastUptimeData; + totalPing = total.avgPing * total.up; + } else { + return uptimeData; + } + break; + default: + throw new Error("Invalid type"); + } + } + + let avgPing; + + if (total.up === 0) { + avgPing = null; + } else { + avgPing = totalPing / total.up; + } + + if (total.up + total.down === 0) { + uptimeData.uptime = 0; + } else { + uptimeData.uptime = total.up / (total.up + total.down); + } + uptimeData.avgPing = avgPing; + return uptimeData; + } + + /** + * Get data in form of an array + * @param {number} num the number of data points which are expected to be returned + * @param {"day" | "hour" | "minute"} type the type of data which is expected to be returned + * @returns {Array<object>} uptime data + * @throws {Error} The maximum number of minutes greater than 1440 + */ + getDataArray(num, type = "day") { + if (type === "hour" && num > 24 * 30) { + throw new Error("The maximum number of hours is 720"); + } + if (type === "minute" && num > 24 * 60) { + throw new Error("The maximum number of minutes is 1440"); + } + + // Get the current time period key based on the type + let key = this.getKey(this.getCurrentDate(), type); + + let result = []; + + let endTimestamp; + + // Get the eariest timestamp of the required period based on the type + switch (type) { + case "day": + endTimestamp = key - 86400 * (num - 1); + break; + case "hour": + endTimestamp = key - 3600 * (num - 1); + break; + case "minute": + endTimestamp = key - 60 * (num - 1); + break; + default: + throw new Error("Invalid type"); + } + + // Get datapoints in the specified time range + while (key >= endTimestamp) { + let data; + + switch (type) { + case "day": + data = this.dailyUptimeDataList[key]; + break; + case "hour": + data = this.hourlyUptimeDataList[key]; + break; + case "minute": + data = this.minutelyUptimeDataList[key]; + break; + default: + throw new Error("Invalid type"); + } + + if (data) { + data.timestamp = key; + result.push(data); + } + + // Set key to the previous time period + switch (type) { + case "day": + key -= 86400; + break; + case "hour": + key -= 3600; + break; + case "minute": + key -= 60; + break; + default: + throw new Error("Invalid type"); + } + } + + return result; + } + + /** + * Get the uptime data for given duration. + * @param {string} duration A string with a number and a unit (m,h,d,w,M,y), such as 24h, 30d, 1y. + * @returns {UptimeDataResult} UptimeDataResult + * @throws {Error} Invalid duration / Unsupported unit + */ + getDataByDuration(duration) { + const durationNumStr = duration.slice(0, -1); + + if (!/^[0-9]+$/.test(durationNumStr)) { + throw new Error(`Invalid duration: ${duration}`); + } + const num = Number(durationNumStr); + const unit = duration.slice(-1); + + switch (unit) { + case "m": + return this.getData(num, "minute"); + case "h": + return this.getData(num, "hour"); + case "d": + return this.getData(num, "day"); + case "w": + return this.getData(7 * num, "day"); + case "M": + return this.getData(30 * num, "day"); + case "y": + return this.getData(365 * num, "day"); + default: + throw new Error(`Unsupported unit (${unit}) for badge duration ${duration}` + ); + } + } + + /** + * 1440 = 24 * 60mins + * @returns {UptimeDataResult} UptimeDataResult + */ + get24Hour() { + return this.getData(1440, "minute"); + } + + /** + * @returns {UptimeDataResult} UptimeDataResult + */ + get7Day() { + return this.getData(168, "hour"); + } + + /** + * @returns {UptimeDataResult} UptimeDataResult + */ + get30Day() { + return this.getData(30); + } + + /** + * @returns {UptimeDataResult} UptimeDataResult + */ + get1Year() { + return this.getData(365); + } + + /** + * @returns {dayjs.Dayjs} Current datetime in UTC + */ + getCurrentDate() { + return dayjs.utc(); + } + + /** + * For migration purposes. + * @param {boolean} value Migration mode on/off + * @returns {void} + */ + setMigrationMode(value) { + this.migrationMode = value; + } +} + +class UptimeDataResult { + /** + * @type {number} Uptime + */ + uptime = 0; + + /** + * @type {number} Average ping + */ + avgPing = null; +} + +module.exports = { + UptimeCalculator, + UptimeDataResult, +}; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js new file mode 100644 index 0000000..062f098 --- /dev/null +++ b/server/uptime-kuma-server.js @@ -0,0 +1,557 @@ +const express = require("express"); +const https = require("https"); +const fs = require("fs"); +const http = require("http"); +const { Server } = require("socket.io"); +const { R } = require("redbean-node"); +const { log, isDev } = require("../src/util"); +const Database = require("./database"); +const util = require("util"); +const { Settings } = require("./settings"); +const dayjs = require("dayjs"); +const childProcessAsync = require("promisify-child-process"); +const path = require("path"); +const axios = require("axios"); +const { isSSL, sslKey, sslCert, sslKeyPassphrase } = require("./config"); +// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead. + +/** + * `module.exports` (alias: `server`) should be inside this class, in order to avoid circular dependency issue. + * @type {UptimeKumaServer} + */ +class UptimeKumaServer { + /** + * Current server instance + * @type {UptimeKumaServer} + */ + static instance = null; + + /** + * Main monitor list + * @type {{}} + */ + monitorList = {}; + + /** + * Main maintenance list + * @type {{}} + */ + maintenanceList = {}; + + entryPage = "dashboard"; + app = undefined; + httpServer = undefined; + io = undefined; + + /** + * Cache Index HTML + * @type {string} + */ + indexHTML = ""; + + /** + * @type {{}} + */ + static monitorTypeList = { + + }; + + /** + * Use for decode the auth object + * @type {null} + */ + jwtSecret = null; + + /** + * Get the current instance of the server if it exists, otherwise + * create a new instance. + * @returns {UptimeKumaServer} Server instance + */ + static getInstance() { + if (UptimeKumaServer.instance == null) { + UptimeKumaServer.instance = new UptimeKumaServer(); + } + return UptimeKumaServer.instance; + } + + /** + * + */ + constructor() { + // Set axios default user-agent to Uptime-Kuma/version + axios.defaults.headers.common["User-Agent"] = this.getUserAgent(); + + // Set default axios timeout to 5 minutes instead of infinity + axios.defaults.timeout = 300 * 1000; + + log.info("server", "Creating express and socket.io instance"); + this.app = express(); + if (isSSL) { + log.info("server", "Server Type: HTTPS"); + this.httpServer = https.createServer({ + key: fs.readFileSync(sslKey), + cert: fs.readFileSync(sslCert), + passphrase: sslKeyPassphrase, + }, this.app); + } else { + log.info("server", "Server Type: HTTP"); + this.httpServer = http.createServer(this.app); + } + + try { + this.indexHTML = fs.readFileSync("./dist/index.html").toString(); + } catch (e) { + // "dist/index.html" is not necessary for development + if (process.env.NODE_ENV !== "development") { + log.error("server", "Error: Cannot find 'dist/index.html', did you install correctly?"); + process.exit(1); + } + } + + // Set Monitor Types + UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType(); + UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing(); + UptimeKumaServer.monitorTypeList["dns"] = new DnsMonitorType(); + UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType(); + UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType(); + UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType(); + UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType(); + + // Allow all CORS origins (polling) in development + let cors = undefined; + if (isDev) { + cors = { + origin: "*", + }; + } + + this.io = new Server(this.httpServer, { + cors, + allowRequest: async (req, callback) => { + let transport; + // It should be always true, but just in case, because this property is not documented + if (req._query) { + transport = req._query.transport; + } else { + log.error("socket", "Ops!!! Cannot get transport type, assume that it is polling"); + transport = "polling"; + } + + const clientIP = await this.getClientIPwithProxy(req.connection.remoteAddress, req.headers); + log.info("socket", `New ${transport} connection, IP = ${clientIP}`); + + // The following check is only for websocket connections, polling connections are already protected by CORS + if (transport === "polling") { + callback(null, true); + } else if (transport === "websocket") { + const bypass = process.env.UPTIME_KUMA_WS_ORIGIN_CHECK === "bypass"; + if (bypass) { + log.info("auth", "WebSocket origin check is bypassed"); + callback(null, true); + } else if (!req.headers.origin) { + log.info("auth", "WebSocket with no origin is allowed"); + callback(null, true); + } else { + let host = req.headers.host; + let origin = req.headers.origin; + + try { + let originURL = new URL(origin); + let xForwardedFor; + if (await Settings.get("trustProxy")) { + xForwardedFor = req.headers["x-forwarded-for"]; + } + + if (host !== originURL.host && xForwardedFor !== originURL.host) { + callback(null, false); + log.error("auth", `Origin (${origin}) does not match host (${host}), IP: ${clientIP}`); + } else { + callback(null, true); + } + } catch (e) { + // Invalid origin url, probably not from browser + callback(null, false); + log.error("auth", `Invalid origin url (${origin}), IP: ${clientIP}`); + } + } + } + } + }); + } + + /** + * Initialise app after the database has been set up + * @returns {Promise<void>} + */ + async initAfterDatabaseReady() { + // Static + this.app.use("/screenshots", express.static(Database.screenshotDir)); + + process.env.TZ = await this.getTimezone(); + dayjs.tz.setDefault(process.env.TZ); + log.debug("DEBUG", "Timezone: " + process.env.TZ); + log.debug("DEBUG", "Current Time: " + dayjs.tz().format()); + + await this.loadMaintenanceList(); + } + + /** + * Send list of monitors to client + * @param {Socket} socket Socket to send list on + * @returns {Promise<object>} List of monitors + */ + async sendMonitorList(socket) { + let list = await this.getMonitorJSONList(socket.userID); + this.io.to(socket.userID).emit("monitorList", list); + return list; + } + + /** + * Update Monitor into list + * @param {Socket} socket Socket to send list on + * @param {number} monitorID update or deleted monitor id + * @returns {Promise<void>} + */ + async sendUpdateMonitorIntoList(socket, monitorID) { + let list = await this.getMonitorJSONList(socket.userID, monitorID); + this.io.to(socket.userID).emit("updateMonitorIntoList", list); + } + + /** + * Delete Monitor from list + * @param {Socket} socket Socket to send list on + * @param {number} monitorID update or deleted monitor id + * @returns {Promise<void>} + */ + async sendDeleteMonitorFromList(socket, monitorID) { + this.io.to(socket.userID).emit("deleteMonitorFromList", monitorID); + } + + /** + * Get a list of monitors for the given user. + * @param {string} userID - The ID of the user to get monitors for. + * @param {number} monitorID - The ID of monitor for. + * @returns {Promise<object>} A promise that resolves to an object with monitor IDs as keys and monitor objects as values. + * + * Generated by Trelent + */ + async getMonitorJSONList(userID, monitorID = null) { + + let query = " user_id = ? "; + let queryParams = [ userID ]; + + if (monitorID) { + query += "AND id = ? "; + queryParams.push(monitorID); + } + + let monitorList = await R.find("monitor", query + "ORDER BY weight DESC, name", queryParams); + + const monitorData = monitorList.map(monitor => ({ + id: monitor.id, + active: monitor.active, + name: monitor.name, + })); + const preloadData = await Monitor.preparePreloadData(monitorData); + + const result = {}; + monitorList.forEach(monitor => result[monitor.id] = monitor.toJSON(preloadData)); + return result; + } + + /** + * Send maintenance list to client + * @param {Socket} socket Socket.io instance to send to + * @returns {Promise<object>} Maintenance list + */ + async sendMaintenanceList(socket) { + return await this.sendMaintenanceListByUserID(socket.userID); + } + + /** + * Send list of maintenances to user + * @param {number} userID User to send list to + * @returns {Promise<object>} Maintenance list + */ + async sendMaintenanceListByUserID(userID) { + let list = await this.getMaintenanceJSONList(userID); + this.io.to(userID).emit("maintenanceList", list); + return list; + } + + /** + * Get a list of maintenances for the given user. + * @param {string} userID - The ID of the user to get maintenances for. + * @returns {Promise<object>} A promise that resolves to an object with maintenance IDs as keys and maintenances objects as values. + */ + async getMaintenanceJSONList(userID) { + let result = {}; + for (let maintenanceID in this.maintenanceList) { + result[maintenanceID] = await this.maintenanceList[maintenanceID].toJSON(); + } + return result; + } + + /** + * Load maintenance list and run + * @param {any} userID Unused + * @returns {Promise<void>} + */ + async loadMaintenanceList(userID) { + let maintenanceList = await R.findAll("maintenance", " ORDER BY end_date DESC, title", [ + + ]); + + for (let maintenance of maintenanceList) { + this.maintenanceList[maintenance.id] = maintenance; + maintenance.run(this); + } + } + + /** + * Retrieve a specific maintenance + * @param {number} maintenanceID ID of maintenance to retrieve + * @returns {(object|null)} Maintenance if it exists + */ + getMaintenance(maintenanceID) { + if (this.maintenanceList[maintenanceID]) { + return this.maintenanceList[maintenanceID]; + } + return null; + } + + /** + * Write error to log file + * @param {any} error The error to write + * @param {boolean} outputToConsole Should the error also be output to console? + * @returns {void} + */ + static errorLog(error, outputToConsole = true) { + const errorLogStream = fs.createWriteStream(path.join(Database.dataDir, "/error.log"), { + flags: "a" + }); + + errorLogStream.on("error", () => { + log.info("", "Cannot write to error.log"); + }); + + if (errorLogStream) { + const dateTime = R.isoDateTime(); + errorLogStream.write(`[${dateTime}] ` + util.format(error) + "\n"); + + if (outputToConsole) { + console.error(error); + } + } + + errorLogStream.end(); + } + + /** + * Get the IP of the client connected to the socket + * @param {Socket} socket Socket to query + * @returns {Promise<string>} IP of client + */ + getClientIP(socket) { + return this.getClientIPwithProxy(socket.client.conn.remoteAddress, socket.client.conn.request.headers); + } + + /** + * @param {string} clientIP Raw client IP + * @param {IncomingHttpHeaders} headers HTTP headers + * @returns {Promise<string>} Client IP with proxy (if trusted) + */ + async getClientIPwithProxy(clientIP, headers) { + if (clientIP === undefined) { + clientIP = ""; + } + + if (await Settings.get("trustProxy")) { + const forwardedFor = headers["x-forwarded-for"]; + + return (typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : null) + || headers["x-real-ip"] + || clientIP.replace(/^::ffff:/, ""); + } else { + return clientIP.replace(/^::ffff:/, ""); + } + } + + /** + * Attempt to get the current server timezone + * If this fails, fall back to environment variables and then make a + * guess. + * @returns {Promise<string>} Current timezone + */ + async getTimezone() { + // From process.env.TZ + try { + if (process.env.TZ) { + this.checkTimezone(process.env.TZ); + return process.env.TZ; + } + } catch (e) { + log.warn("timezone", e.message + " in process.env.TZ"); + } + + let timezone = await Settings.get("serverTimezone"); + + // From Settings + try { + log.debug("timezone", "Using timezone from settings: " + timezone); + if (timezone) { + this.checkTimezone(timezone); + return timezone; + } + } catch (e) { + log.warn("timezone", e.message + " in settings"); + } + + // Guess + try { + let guess = dayjs.tz.guess(); + log.debug("timezone", "Guessing timezone: " + guess); + if (guess) { + this.checkTimezone(guess); + return guess; + } else { + return "UTC"; + } + } catch (e) { + // Guess failed, fall back to UTC + log.debug("timezone", "Guessed an invalid timezone. Use UTC as fallback"); + return "UTC"; + } + } + + /** + * Get the current offset + * @returns {string} Time offset + */ + getTimezoneOffset() { + return dayjs().format("Z"); + } + + /** + * Throw an error if the timezone is invalid + * @param {string} timezone Timezone to test + * @returns {void} + * @throws The timezone is invalid + */ + checkTimezone(timezone) { + try { + dayjs.utc("2013-11-18 11:55").tz(timezone).format(); + } catch (e) { + throw new Error("Invalid timezone:" + timezone); + } + } + + /** + * Set the current server timezone and environment variables + * @param {string} timezone Timezone to set + * @returns {Promise<void>} + */ + async setTimezone(timezone) { + this.checkTimezone(timezone); + await Settings.set("serverTimezone", timezone, "general"); + process.env.TZ = timezone; + dayjs.tz.setDefault(timezone); + } + + /** + * TODO: Listen logic should be moved to here + * @returns {Promise<void>} + */ + async start() { + let enable = await Settings.get("nscd"); + + if (enable || enable === null) { + await this.startNSCDServices(); + } + } + + /** + * Stop the server + * @returns {Promise<void>} + */ + async stop() { + let enable = await Settings.get("nscd"); + + if (enable || enable === null) { + await this.stopNSCDServices(); + } + } + + /** + * Start all system services (e.g. nscd) + * For now, only used in Docker + * @returns {void} + */ + async startNSCDServices() { + if (process.env.UPTIME_KUMA_IS_CONTAINER) { + try { + log.info("services", "Starting nscd"); + await childProcessAsync.exec("sudo service nscd start"); + } catch (e) { + log.info("services", "Failed to start nscd"); + } + } + } + + /** + * Stop all system services + * @returns {void} + */ + async stopNSCDServices() { + if (process.env.UPTIME_KUMA_IS_CONTAINER) { + try { + log.info("services", "Stopping nscd"); + await childProcessAsync.exec("sudo service nscd stop"); + } catch (e) { + log.info("services", "Failed to stop nscd"); + } + } + } + + /** + * Default User-Agent when making HTTP requests + * @returns {string} User-Agent + */ + getUserAgent() { + return "Uptime-Kuma/" + require("../package.json").version; + } + + /** + * Force connected sockets of a user to refresh and disconnect. + * Used for resetting password. + * @param {string} userID User ID + * @param {string?} currentSocketID Current socket ID + * @returns {void} + */ + disconnectAllSocketClients(userID, currentSocketID = undefined) { + for (const socket of this.io.sockets.sockets.values()) { + if (socket.userID === userID && socket.id !== currentSocketID) { + try { + socket.emit("refresh"); + socket.disconnect(); + } catch (e) { + + } + } + } + } +} + +module.exports = { + UptimeKumaServer +}; + +// Must be at the end to avoid circular dependencies +const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type"); +const { TailscalePing } = require("./monitor-types/tailscale-ping"); +const { DnsMonitorType } = require("./monitor-types/dns"); +const { MqttMonitorType } = require("./monitor-types/mqtt"); +const { SNMPMonitorType } = require("./monitor-types/snmp"); +const { MongodbMonitorType } = require("./monitor-types/mongodb"); +const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq"); +const Monitor = require("./model/monitor"); diff --git a/server/util-server.js b/server/util-server.js new file mode 100644 index 0000000..5ebc62a --- /dev/null +++ b/server/util-server.js @@ -0,0 +1,1064 @@ +const tcpp = require("tcp-ping"); +const ping = require("@louislam/ping"); +const { R } = require("redbean-node"); +const { log, genSecret, badgeConstants } = require("../src/util"); +const passwordHash = require("./password-hash"); +const { Resolver } = require("dns"); +const iconv = require("iconv-lite"); +const chardet = require("chardet"); +const chroma = require("chroma-js"); +const mssql = require("mssql"); +const { Client } = require("pg"); +const postgresConParse = require("pg-connection-string").parse; +const mysql = require("mysql2"); +const { NtlmClient } = require("./modules/axios-ntlm/lib/ntlmClient.js"); +const { Settings } = require("./settings"); +const grpc = require("@grpc/grpc-js"); +const protojs = require("protobufjs"); +const radiusClient = require("node-radius-client"); +const redis = require("redis"); +const oidc = require("openid-client"); +const tls = require("tls"); + +const { + dictionaries: { + rfc2865: { file, attributes }, + }, +} = require("node-radius-utils"); +const dayjs = require("dayjs"); + +// SASLOptions used in JSDoc +// eslint-disable-next-line no-unused-vars +const { Kafka, SASLOptions } = require("kafkajs"); +const crypto = require("crypto"); + +const isWindows = process.platform === /^win/.test(process.platform); +/** + * Init or reset JWT secret + * @returns {Promise<Bean>} JWT secret + */ +exports.initJWTSecret = async () => { + let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [ + "jwtSecret", + ]); + + if (!jwtSecretBean) { + jwtSecretBean = R.dispense("setting"); + jwtSecretBean.key = "jwtSecret"; + } + + jwtSecretBean.value = passwordHash.generate(genSecret()); + await R.store(jwtSecretBean); + return jwtSecretBean; +}; + +/** + * Decodes a jwt and returns the payload portion without verifying the jqt. + * @param {string} jwt The input jwt as a string + * @returns {object} Decoded jwt payload object + */ +exports.decodeJwt = (jwt) => { + return JSON.parse(Buffer.from(jwt.split(".")[1], "base64").toString()); +}; + +/** + * Gets a Access Token form a oidc/oauth2 provider + * @param {string} tokenEndpoint The token URI form the auth service provider + * @param {string} clientId The oidc/oauth application client id + * @param {string} clientSecret The oidc/oauth application client secret + * @param {string} scope The scope the for which the token should be issued for + * @param {string} authMethod The method on how to sent the credentials. Default client_secret_basic + * @returns {Promise<oidc.TokenSet>} TokenSet promise if the token request was successful + */ +exports.getOidcTokenClientCredentials = async (tokenEndpoint, clientId, clientSecret, scope, authMethod = "client_secret_basic") => { + const oauthProvider = new oidc.Issuer({ token_endpoint: tokenEndpoint }); + let client = new oauthProvider.Client({ + client_id: clientId, + client_secret: clientSecret, + token_endpoint_auth_method: authMethod + }); + + // Increase default timeout and clock tolerance + client[oidc.custom.http_options] = () => ({ timeout: 10000 }); + client[oidc.custom.clock_tolerance] = 5; + + let grantParams = { grant_type: "client_credentials" }; + if (scope) { + grantParams.scope = scope; + } + return await client.grant(grantParams); +}; + +/** + * Send TCP request to specified hostname and port + * @param {string} hostname Hostname / address of machine + * @param {number} port TCP port to test + * @returns {Promise<number>} Maximum time in ms rounded to nearest integer + */ +exports.tcping = function (hostname, port) { + return new Promise((resolve, reject) => { + tcpp.ping({ + address: hostname, + port: port, + attempts: 1, + }, function (err, data) { + + if (err) { + reject(err); + } + + if (data.results.length >= 1 && data.results[0].err) { + reject(data.results[0].err); + } + + resolve(Math.round(data.max)); + }); + }); +}; + +/** + * Ping the specified machine + * @param {string} hostname Hostname / address of machine + * @param {number} size Size of packet to send + * @returns {Promise<number>} Time for ping in ms rounded to nearest integer + */ +exports.ping = async (hostname, size = 56) => { + try { + return await exports.pingAsync(hostname, false, size); + } catch (e) { + // If the host cannot be resolved, try again with ipv6 + log.debug("ping", "IPv6 error message: " + e.message); + + // As node-ping does not report a specific error for this, try again if it is an empty message with ipv6 no matter what. + if (!e.message) { + return await exports.pingAsync(hostname, true, size); + } else { + throw e; + } + } +}; + +/** + * Ping the specified machine + * @param {string} hostname Hostname / address of machine to ping + * @param {boolean} ipv6 Should IPv6 be used? + * @param {number} size Size of ping packet to send + * @returns {Promise<number>} Time for ping in ms rounded to nearest integer + */ +exports.pingAsync = function (hostname, ipv6 = false, size = 56) { + return new Promise((resolve, reject) => { + ping.promise.probe(hostname, { + v6: ipv6, + min_reply: 1, + deadline: 10, + packetSize: size, + }).then((res) => { + // If ping failed, it will set field to unknown + if (res.alive) { + resolve(res.time); + } else { + if (isWindows) { + reject(new Error(exports.convertToUTF8(res.output))); + } else { + reject(new Error(res.output)); + } + } + }).catch((err) => { + reject(err); + }); + }); +}; + +/** + * Monitor Kafka using Producer + * @param {string[]} brokers List of kafka brokers to connect, host and + * port joined by ':' + * @param {string} topic Topic name to produce into + * @param {string} message Message to produce + * @param {object} options Kafka client options. Contains ssl, clientId, + * allowAutoTopicCreation and interval (interval defaults to 20, + * allowAutoTopicCreation defaults to false, clientId defaults to + * "Uptime-Kuma" and ssl defaults to false) + * @param {SASLOptions} saslOptions Options for kafka client + * Authentication (SASL) (defaults to {}) + * @returns {Promise<string>} Status message + */ +exports.kafkaProducerAsync = function (brokers, topic, message, options = {}, saslOptions = {}) { + return new Promise((resolve, reject) => { + const { interval = 20, allowAutoTopicCreation = false, ssl = false, clientId = "Uptime-Kuma" } = options; + + let connectedToKafka = false; + + const timeoutID = setTimeout(() => { + log.debug("kafkaProducer", "KafkaProducer timeout triggered"); + connectedToKafka = true; + reject(new Error("Timeout")); + }, interval * 1000 * 0.8); + + if (saslOptions.mechanism === "None") { + saslOptions = undefined; + } + + let client = new Kafka({ + brokers: brokers, + clientId: clientId, + sasl: saslOptions, + retry: { + retries: 0, + }, + ssl: ssl, + }); + + let producer = client.producer({ + allowAutoTopicCreation: allowAutoTopicCreation, + retry: { + retries: 0, + } + }); + + producer.connect().then( + () => { + producer.send({ + topic: topic, + messages: [{ + value: message, + }], + }).then((_) => { + resolve("Message sent successfully"); + }).catch((e) => { + connectedToKafka = true; + producer.disconnect(); + clearTimeout(timeoutID); + reject(new Error("Error sending message: " + e.message)); + }).finally(() => { + connectedToKafka = true; + clearTimeout(timeoutID); + }); + } + ).catch( + (e) => { + connectedToKafka = true; + producer.disconnect(); + clearTimeout(timeoutID); + reject(new Error("Error in producer connection: " + e.message)); + } + ); + + producer.on("producer.network.request_timeout", (_) => { + if (!connectedToKafka) { + clearTimeout(timeoutID); + reject(new Error("producer.network.request_timeout")); + } + }); + + producer.on("producer.disconnect", (_) => { + if (!connectedToKafka) { + clearTimeout(timeoutID); + reject(new Error("producer.disconnect")); + } + }); + }); +}; + +/** + * Use NTLM Auth for a http request. + * @param {object} options The http request options + * @param {object} ntlmOptions The auth options + * @returns {Promise<(string[] | object[] | object)>} NTLM response + */ +exports.httpNtlm = function (options, ntlmOptions) { + return new Promise((resolve, reject) => { + let client = NtlmClient(ntlmOptions); + + client(options) + .then((resp) => { + resolve(resp); + }) + .catch((err) => { + reject(err); + }); + }); +}; + +/** + * Resolves a given record using the specified DNS server + * @param {string} hostname The hostname of the record to lookup + * @param {string} resolverServer The DNS server to use + * @param {string} resolverPort Port the DNS server is listening on + * @param {string} rrtype The type of record to request + * @returns {Promise<(string[] | object[] | object)>} DNS response + */ +exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype) { + const resolver = new Resolver(); + // Remove brackets from IPv6 addresses so we can re-add them to + // prevent issues with ::1:5300 (::1 port 5300) + resolverServer = resolverServer.replace("[", "").replace("]", ""); + resolver.setServers([ `[${resolverServer}]:${resolverPort}` ]); + return new Promise((resolve, reject) => { + if (rrtype === "PTR") { + resolver.reverse(hostname, (err, records) => { + if (err) { + reject(err); + } else { + resolve(records); + } + }); + } else { + resolver.resolve(hostname, rrtype, (err, records) => { + if (err) { + reject(err); + } else { + resolve(records); + } + }); + } + }); +}; + +/** + * Run a query on SQL Server + * @param {string} connectionString The database connection string + * @param {string} query The query to validate the database with + * @returns {Promise<(string[] | object[] | object)>} Response from + * server + */ +exports.mssqlQuery = async function (connectionString, query) { + let pool; + try { + pool = new mssql.ConnectionPool(connectionString); + await pool.connect(); + if (!query) { + query = "SELECT 1"; + } + await pool.request().query(query); + pool.close(); + } catch (e) { + if (pool) { + pool.close(); + } + throw e; + } +}; + +/** + * Run a query on Postgres + * @param {string} connectionString The database connection string + * @param {string} query The query to validate the database with + * @returns {Promise<(string[] | object[] | object)>} Response from + * server + */ +exports.postgresQuery = function (connectionString, query) { + return new Promise((resolve, reject) => { + const config = postgresConParse(connectionString); + + // Fix #3868, which true/false is not parsed to boolean + if (typeof config.ssl === "string") { + config.ssl = config.ssl === "true"; + } + + if (config.password === "") { + // See https://github.com/brianc/node-postgres/issues/1927 + reject(new Error("Password is undefined.")); + return; + } + const client = new Client(config); + + client.on("error", (error) => { + log.debug("postgres", "Error caught in the error event handler."); + reject(error); + }); + + client.connect((err) => { + if (err) { + reject(err); + client.end(); + } else { + // Connected here + try { + // No query provided by user, use SELECT 1 + if (!query || (typeof query === "string" && query.trim() === "")) { + query = "SELECT 1"; + } + + client.query(query, (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + client.end(); + }); + } catch (e) { + reject(e); + client.end(); + } + } + }); + + }); +}; + +/** + * Run a query on MySQL/MariaDB + * @param {string} connectionString The database connection string + * @param {string} query The query to validate the database with + * @param {?string} password The password to use + * @returns {Promise<(string)>} Response from server + */ +exports.mysqlQuery = function (connectionString, query, password = undefined) { + return new Promise((resolve, reject) => { + const connection = mysql.createConnection({ + uri: connectionString, + password + }); + + connection.on("error", (err) => { + reject(err); + }); + + connection.query(query, (err, res) => { + if (err) { + reject(err); + } else { + if (Array.isArray(res)) { + resolve("Rows: " + res.length); + } else { + resolve("No Error, but the result is not an array. Type: " + typeof res); + } + } + + try { + connection.end(); + } catch (_) { + connection.destroy(); + } + }); + }); +}; + +/** + * Query radius server + * @param {string} hostname Hostname of radius server + * @param {string} username Username to use + * @param {string} password Password to use + * @param {string} calledStationId ID of called station + * @param {string} callingStationId ID of calling station + * @param {string} secret Secret to use + * @param {number} port Port to contact radius server on + * @param {number} timeout Timeout for connection to use + * @returns {Promise<any>} Response from server + */ +exports.radius = function ( + hostname, + username, + password, + calledStationId, + callingStationId, + secret, + port = 1812, + timeout = 2500, +) { + const client = new radiusClient({ + host: hostname, + hostPort: port, + timeout: timeout, + retries: 1, + dictionaries: [ file ], + }); + + return client.accessRequest({ + secret: secret, + attributes: [ + [ attributes.USER_NAME, username ], + [ attributes.USER_PASSWORD, password ], + [ attributes.CALLING_STATION_ID, callingStationId ], + [ attributes.CALLED_STATION_ID, calledStationId ], + ], + }).catch((error) => { + if (error.response?.code) { + throw Error(error.response.code); + } else { + throw Error(error.message); + } + }); +}; + +/** + * Redis server ping + * @param {string} dsn The redis connection string + * @param {boolean} rejectUnauthorized If false, allows unverified server certificates. + * @returns {Promise<any>} Response from server + */ +exports.redisPingAsync = function (dsn, rejectUnauthorized) { + return new Promise((resolve, reject) => { + const client = redis.createClient({ + url: dsn, + socket: { + rejectUnauthorized + } + }); + 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(); + } + if (err) { + reject(err); + } else { + resolve(res); + } + }).catch(error => reject(error)); + }); + }); +}; + +/** + * Retrieve value of setting based on key + * @param {string} key Key of setting to retrieve + * @returns {Promise<any>} Value + * @deprecated Use await Settings.get(key) + */ +exports.setting = async function (key) { + return await Settings.get(key); +}; + +/** + * Sets the specified setting to specified value + * @param {string} key Key of setting to set + * @param {any} value Value to set to + * @param {?string} type Type of setting + * @returns {Promise<void>} + */ +exports.setSetting = async function (key, value, type = null) { + await Settings.set(key, value, type); +}; + +/** + * Get settings based on type + * @param {string} type The type of setting + * @returns {Promise<Bean>} Settings of requested type + */ +exports.getSettings = async function (type) { + return await Settings.getSettings(type); +}; + +/** + * Set settings based on type + * @param {string} type Type of settings to set + * @param {object} data Values of settings + * @returns {Promise<void>} + */ +exports.setSettings = async function (type, data) { + await Settings.setSettings(type, data); +}; + +// ssl-checker by @dyaa +//https://github.com/dyaa/ssl-checker/blob/master/src/index.ts + +/** + * Get number of days between two dates + * @param {Date} validFrom Start date + * @param {Date} validTo End date + * @returns {number} Number of days + */ +const getDaysBetween = (validFrom, validTo) => + Math.round(Math.abs(+validFrom - +validTo) / 8.64e7); + +/** + * Get days remaining from a time range + * @param {Date} validFrom Start date + * @param {Date} validTo End date + * @returns {number} Number of days remaining + */ +const getDaysRemaining = (validFrom, validTo) => { + const daysRemaining = getDaysBetween(validFrom, validTo); + if (new Date(validTo).getTime() < new Date().getTime()) { + return -daysRemaining; + } + return daysRemaining; +}; + +/** + * Fix certificate info for display + * @param {object} info The chain obtained from getPeerCertificate() + * @returns {object} An object representing certificate information + * @throws The certificate chain length exceeded 500. + */ +const parseCertificateInfo = function (info) { + let link = info; + let i = 0; + + const existingList = {}; + + while (link) { + log.debug("cert", `[${i}] ${link.fingerprint}`); + + if (!link.valid_from || !link.valid_to) { + break; + } + link.validTo = new Date(link.valid_to); + link.validFor = link.subjectaltname?.replace(/DNS:|IP Address:/g, "").split(", "); + link.daysRemaining = getDaysRemaining(new Date(), link.validTo); + + existingList[link.fingerprint] = true; + + // 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; + } + + // Should be no use, but just in case. + if (i > 500) { + throw new Error("Dead loop occurred in parseCertificateInfo"); + } + i++; + } + + return info; +}; + +/** + * Check if certificate is valid + * @param {tls.TLSSocket} socket TLSSocket, which may or may not be connected + * @returns {object} Object containing certificate information + */ +exports.checkCertificate = function (socket) { + let certInfoStartTime = dayjs().valueOf(); + + // Return null if there is no socket + if (socket === undefined || socket == null) { + return null; + } + + const info = socket.getPeerCertificate(true); + const valid = socket.authorized || false; + + log.debug("cert", "Parsing Certificate Info"); + const parsedInfo = parseCertificateInfo(info); + + if (process.env.TIMELOGGER === "1") { + log.debug("monitor", "Cert Info Query Time: " + (dayjs().valueOf() - certInfoStartTime) + "ms"); + } + + return { + valid: valid, + certInfo: parsedInfo + }; +}; + +/** + * Check if the provided status code is within the accepted ranges + * @param {number} status The status code to check + * @param {string[]} acceptedCodes An array of accepted status codes + * @returns {boolean} True if status code within range, false otherwise + */ +exports.checkStatusCode = function (status, acceptedCodes) { + if (acceptedCodes == null || acceptedCodes.length === 0) { + return false; + } + + for (const codeRange of acceptedCodes) { + if (typeof codeRange !== "string") { + log.error("monitor", `Accepted status code not a string. ${codeRange} is of type ${typeof codeRange}`); + continue; + } + + const codeRangeSplit = codeRange.split("-").map(string => parseInt(string)); + if (codeRangeSplit.length === 1) { + if (status === codeRangeSplit[0]) { + return true; + } + } else if (codeRangeSplit.length === 2) { + if (status >= codeRangeSplit[0] && status <= codeRangeSplit[1]) { + return true; + } + } else { + log.error("monitor", `${codeRange} is not a valid status code range`); + continue; + } + } + + return false; +}; + +/** + * Get total number of clients in room + * @param {Server} io Socket server instance + * @param {string} roomName Name of room to check + * @returns {number} Total clients in room + */ +exports.getTotalClientInRoom = (io, roomName) => { + + const sockets = io.sockets; + + if (!sockets) { + return 0; + } + + const adapter = sockets.adapter; + + if (!adapter) { + return 0; + } + + const room = adapter.rooms.get(roomName); + + if (room) { + return room.size; + } else { + return 0; + } +}; + +/** + * Allow CORS all origins if development + * @param {object} res Response object from axios + * @returns {void} + */ +exports.allowDevAllOrigin = (res) => { + if (process.env.NODE_ENV === "development") { + exports.allowAllOrigin(res); + } +}; + +/** + * Allow CORS all origins + * @param {object} res Response object from axios + * @returns {void} + */ +exports.allowAllOrigin = (res) => { + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS"); + res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); +}; + +/** + * Check if a user is logged in + * @param {Socket} socket Socket instance + * @returns {void} + * @throws The user is not logged in + */ +exports.checkLogin = (socket) => { + if (!socket.userID) { + throw new Error("You are not logged in."); + } +}; + +/** + * For logged-in users, double-check the password + * @param {Socket} socket Socket.io instance + * @param {string} currentPassword Password to validate + * @returns {Promise<Bean>} User + * @throws The current password is not a string + * @throws The provided password is not correct + */ +exports.doubleCheckPassword = async (socket, currentPassword) => { + if (typeof currentPassword !== "string") { + throw new Error("Wrong data type?"); + } + + let user = await R.findOne("user", " id = ? AND active = 1 ", [ + socket.userID, + ]); + + if (!user || !passwordHash.verify(currentPassword, user.password)) { + throw new Error("Incorrect current password"); + } + + return user; +}; + +/** + * Convert unknown string to UTF8 + * @param {Uint8Array} body Buffer + * @returns {string} UTF8 string + */ +exports.convertToUTF8 = (body) => { + const guessEncoding = chardet.detect(body); + const str = iconv.decode(body, guessEncoding); + return str.toString(); +}; + +/** + * Returns a color code in hex format based on a given percentage: + * 0% => hue = 10 => red + * 100% => hue = 90 => green + * @param {number} percentage float, 0 to 1 + * @param {number} maxHue Maximum hue - int + * @param {number} minHue Minimum hue - int + * @returns {string} Color in hex + */ +exports.percentageToColor = (percentage, maxHue = 90, minHue = 10) => { + const hue = percentage * (maxHue - minHue) + minHue; + try { + return chroma(`hsl(${hue}, 90%, 40%)`).hex(); + } catch (err) { + return badgeConstants.naColor; + } +}; + +/** + * Joins and array of string to one string after filtering out empty values + * @param {string[]} parts Strings to join + * @param {string} connector Separator for joined strings + * @returns {string} Joined strings + */ +exports.filterAndJoin = (parts, connector = "") => { + return parts.filter((part) => !!part && part !== "").join(connector); +}; + +/** + * Send an Error response + * @param {object} res Express response object + * @param {string} msg Message to send + * @returns {void} + */ +module.exports.sendHttpError = (res, msg = "") => { + if (msg.includes("SQLITE_BUSY") || msg.includes("SQLITE_LOCKED")) { + res.status(503).json({ + "status": "fail", + "msg": msg, + }); + } else if (msg.toLowerCase().includes("not found")) { + res.status(404).json({ + "status": "fail", + "msg": msg, + }); + } else { + res.status(403).json({ + "status": "fail", + "msg": msg, + }); + } +}; + +/** + * Convert timezone of time object + * @param {object} obj Time object to update + * @param {string} timezone New timezone to set + * @param {boolean} timeObjectToUTC Convert time object to UTC + * @returns {object} Time object with updated timezone + */ +function timeObjectConvertTimezone(obj, timezone, timeObjectToUTC = true) { + let offsetString; + + if (timezone) { + offsetString = dayjs().tz(timezone).format("Z"); + } else { + offsetString = dayjs().format("Z"); + } + + let hours = parseInt(offsetString.substring(1, 3)); + let minutes = parseInt(offsetString.substring(4, 6)); + + if ( + (timeObjectToUTC && offsetString.startsWith("+")) || + (!timeObjectToUTC && offsetString.startsWith("-")) + ) { + hours *= -1; + minutes *= -1; + } + + obj.hours += hours; + obj.minutes += minutes; + + // Handle out of bound + if (obj.minutes < 0) { + obj.minutes += 60; + obj.hours--; + } else if (obj.minutes > 60) { + obj.minutes -= 60; + obj.hours++; + } + + if (obj.hours < 0) { + obj.hours += 24; + } else if (obj.hours > 24) { + obj.hours -= 24; + } + + return obj; +} + +/** + * Convert time object to UTC + * @param {object} obj Object to convert + * @param {string} timezone Timezone of time object + * @returns {object} Updated time object + */ +module.exports.timeObjectToUTC = (obj, timezone = undefined) => { + return timeObjectConvertTimezone(obj, timezone, true); +}; + +/** + * Convert time object to local time + * @param {object} obj Object to convert + * @param {string} timezone Timezone to convert to + * @returns {object} Updated object + */ +module.exports.timeObjectToLocal = (obj, timezone = undefined) => { + return timeObjectConvertTimezone(obj, timezone, false); +}; + +/** + * Create gRPC client stib + * @param {object} options from gRPC client + * @returns {Promise<object>} Result of gRPC query + */ +module.exports.grpcQuery = async (options) => { + const { grpcUrl, grpcProtobufData, grpcServiceName, grpcEnableTls, grpcMethod, grpcBody } = options; + const protocObject = protojs.parse(grpcProtobufData); + const protoServiceObject = protocObject.root.lookupService(grpcServiceName); + const Client = grpc.makeGenericClientConstructor({}); + const credentials = grpcEnableTls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure(); + const client = new Client( + grpcUrl, + credentials + ); + const grpcService = protoServiceObject.create(function (method, requestData, cb) { + const fullServiceName = method.fullName; + const serviceFQDN = fullServiceName.split("."); + const serviceMethod = serviceFQDN.pop(); + const serviceMethodClientImpl = `/${serviceFQDN.slice(1).join(".")}/${serviceMethod}`; + log.debug("monitor", `gRPC method ${serviceMethodClientImpl}`); + client.makeUnaryRequest( + serviceMethodClientImpl, + arg => arg, + arg => arg, + requestData, + cb); + }, false, false); + return new Promise((resolve, _) => { + try { + return grpcService[`${grpcMethod}`](JSON.parse(grpcBody), function (err, response) { + const responseData = JSON.stringify(response); + if (err) { + return resolve({ + code: err.code, + errorMessage: err.details, + data: "" + }); + } else { + log.debug("monitor:", `gRPC response: ${JSON.stringify(response)}`); + return resolve({ + code: 1, + errorMessage: "", + data: responseData + }); + } + }); + } catch (err) { + return resolve({ + code: -1, + errorMessage: `Error ${err}. Please review your gRPC configuration option. The service name must not include package name value, and the method name must follow camelCase format`, + data: "" + }); + } + + }); +}; + +/** + * Returns an array of SHA256 fingerprints for all known root certificates. + * @returns {Set} A set of SHA256 fingerprints. + */ +module.exports.rootCertificatesFingerprints = () => { + let fingerprints = tls.rootCertificates.map(cert => { + let certLines = cert.split("\n"); + certLines.shift(); + certLines.pop(); + let certBody = certLines.join(""); + let buf = Buffer.from(certBody, "base64"); + + const shasum = crypto.createHash("sha256"); + shasum.update(buf); + + return shasum.digest("hex").toUpperCase().replace(/(.{2})(?!$)/g, "$1:"); + }); + + fingerprints.push("6D:99:FB:26:5E:B1:C5:B3:74:47:65:FC:BC:64:8F:3C:D8:E1:BF:FA:FD:C4:C2:F9:9B:9D:47:CF:7F:F1:C2:4F"); // ISRG X1 cross-signed with DST X3 + fingerprints.push("8B:05:B6:8C:C6:59:E5:ED:0F:CB:38:F2:C9:42:FB:FD:20:0E:6F:2F:F9:F8:5D:63:C6:99:4E:F5:E0:B0:27:01"); // ISRG X2 cross-signed with ISRG X1 + + return new Set(fingerprints); +}; + +module.exports.SHAKE256_LENGTH = 16; + +/** + * @param {string} data The data to be hashed + * @param {number} len Output length of the hash + * @returns {string} The hashed data in hex format + */ +module.exports.shake256 = (data, len) => { + if (!data) { + return ""; + } + return crypto.createHash("shake256", { outputLength: len }) + .update(data) + .digest("hex"); +}; + +/** + * Non await sleep + * Source: https://stackoverflow.com/questions/59099454/is-there-a-way-to-call-sleep-without-await-keyword + * @param {number} n Milliseconds to wait + * @returns {void} + */ +module.exports.wait = (n) => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, n); +}; + +// For unit test, export functions +if (process.env.TEST_BACKEND) { + module.exports.__test = { + parseCertificateInfo, + }; + module.exports.__getPrivateFunction = (functionName) => { + return module.exports.__test[functionName]; + }; +} + +/** + * Generates an abort signal with the specified timeout. + * @param {number} timeoutMs - The timeout in milliseconds. + * @returns {AbortSignal | null} - The generated abort signal, or null if not supported. + */ +module.exports.axiosAbortSignal = (timeoutMs) => { + try { + // Just in case, as 0 timeout here will cause the request to be aborted immediately + if (!timeoutMs || timeoutMs <= 0) { + timeoutMs = 5000; + } + return AbortSignal.timeout(timeoutMs); + } catch (_) { + // v16-: AbortSignal.timeout is not supported + try { + const abortController = new AbortController(); + setTimeout(() => abortController.abort(), timeoutMs); + + return abortController.signal; + } catch (_) { + // v15-: AbortController is not supported + return null; + } + } +}; diff --git a/server/utils/array-with-key.js b/server/utils/array-with-key.js new file mode 100644 index 0000000..94afc79 --- /dev/null +++ b/server/utils/array-with-key.js @@ -0,0 +1,85 @@ +/** + * An object that can be used as an array with a key + * Like PHP's array + * @template K + * @template V + */ +class ArrayWithKey { + /** + * All keys that are stored in the current object + * @type {K[]} + * @private + */ + __stack = []; + + /** + * Push an element to the end of the array + * @param {K} key The key of the element + * @param {V} value The value of the element + * @returns {void} + */ + push(key, value) { + this[key] = value; + this.__stack.push(key); + } + + /** + * Get the last element and remove it from the array + * @returns {V|undefined} The first value, or undefined if there is no element to pop + */ + pop() { + let key = this.__stack.pop(); + let prop = this[key]; + delete this[key]; + return prop; + } + + /** + * Get the last key + * @returns {K|null} The last key, or null if the array is empty + */ + getLastKey() { + if (this.__stack.length === 0) { + return null; + } + return this.__stack[this.__stack.length - 1]; + } + + /** + * Get the first element + * @returns {{key:K,value:V}|null} The first element, or null if the array is empty + */ + shift() { + let key = this.__stack.shift(); + let value = this[key]; + delete this[key]; + return { + key, + value, + }; + } + + /** + * Get the length of the array + * @returns {number} Amount of elements stored + */ + length() { + return this.__stack.length; + } + + /** + * Get the last value + * @returns {V|null} The last element without removing it, or null if the array is empty + */ + last() { + let key = this.getLastKey(); + if (key === null) { + return null; + } + return this[key]; + } +} + +module.exports = { + ArrayWithKey +}; diff --git a/server/utils/knex/lib/dialects/mysql2/schema/mysql2-columncompiler.js b/server/utils/knex/lib/dialects/mysql2/schema/mysql2-columncompiler.js new file mode 100644 index 0000000..d05a6bc --- /dev/null +++ b/server/utils/knex/lib/dialects/mysql2/schema/mysql2-columncompiler.js @@ -0,0 +1,22 @@ +const ColumnCompilerMySQL = require("knex/lib/dialects/mysql/schema/mysql-columncompiler"); +const { formatDefault } = require("knex/lib/formatter/formatterUtils"); +const { log } = require("../../../../../../../src/util"); + +class KumaColumnCompiler extends ColumnCompilerMySQL { + /** + * Override defaultTo method to handle default value for TEXT fields + * @param {any} value Value + * @returns {string|void} Default value (Don't understand why it can return void or string, but it's the original code, lol) + */ + defaultTo(value) { + if (this.type === "text" && typeof value === "string") { + log.debug("defaultTo", `${this.args[0]}: ${this.type} ${value} ${typeof value}`); + // MySQL 8.0 is required and only if the value is written as an expression: https://dev.mysql.com/doc/refman/8.0/en/data-type-defaults.html + // MariaDB 10.2 is required: https://mariadb.com/kb/en/text/ + return `default (${formatDefault(value, this.type, this.client)})`; + } + return super.defaultTo.apply(this, arguments); + } +} + +module.exports = KumaColumnCompiler; diff --git a/server/utils/limit-queue.js b/server/utils/limit-queue.js new file mode 100644 index 0000000..9da6d41 --- /dev/null +++ b/server/utils/limit-queue.js @@ -0,0 +1,48 @@ +const { ArrayWithKey } = require("./array-with-key"); + +/** + * Limit Queue + * The first element will be removed when the length exceeds the limit + */ +class LimitQueue extends ArrayWithKey { + + /** + * The limit of the queue after which the first element will be removed + * @private + * @type {number} + */ + __limit; + /** + * The callback function when the queue exceeds the limit + * @private + * @callback onExceedCallback + * @param {{key:K,value:V}|nul} item + */ + __onExceed = null; + + /** + * @param {number} limit The limit of the queue after which the first element will be removed + */ + constructor(limit) { + super(); + this.__limit = limit; + } + + /** + * @inheritDoc + */ + push(key, value) { + super.push(key, value); + if (this.length() > this.__limit) { + let item = this.shift(); + if (this.__onExceed) { + this.__onExceed(item); + } + } + } + +} + +module.exports = { + LimitQueue +}; diff --git a/server/utils/simple-migration-server.js b/server/utils/simple-migration-server.js new file mode 100644 index 0000000..680f8df --- /dev/null +++ b/server/utils/simple-migration-server.js @@ -0,0 +1,84 @@ +const express = require("express"); +const http = require("node:http"); +const { log } = require("../../src/util"); + +/** + * SimpleMigrationServer + * For displaying the migration status of the server + * Also, it is used to let Docker healthcheck know the status of the server, as the main server is not started yet, healthcheck will think the server is down incorrectly. + */ +class SimpleMigrationServer { + /** + * Express app instance + * @type {?Express} + */ + app; + + /** + * Server instance + * @type {?Server} + */ + server; + + /** + * Response object + * @type {?Response} + */ + response; + + /** + * Start the server + * @param {number} port Port + * @param {string} hostname Hostname + * @returns {Promise<void>} + */ + start(port, hostname) { + this.app = express(); + this.server = http.createServer(this.app); + + this.app.get("/", (req, res) => { + res.set("Content-Type", "text/plain"); + res.write("Migration is in progress, listening message...\n"); + if (this.response) { + this.response.write("Disconnected\n"); + this.response.end(); + } + this.response = res; + // never ending response + }); + + return new Promise((resolve) => { + this.server.listen(port, hostname, () => { + if (hostname) { + log.info("migration", `Migration server is running on http://${hostname}:${port}`); + } else { + log.info("migration", `Migration server is running on http://localhost:${port}`); + } + resolve(); + }); + }); + } + + /** + * Update the message + * @param {string} msg Message to update + * @returns {void} + */ + update(msg) { + this.response?.write(msg + "\n"); + } + + /** + * Stop the server + * @returns {Promise<void>} + */ + async stop() { + this.response?.write("Finished, please refresh this page.\n"); + this.response?.end(); + await this.server?.close(); + } +} + +module.exports = { + SimpleMigrationServer, +}; |