diff options
Diffstat (limited to 'server/uptime-kuma-server.js')
-rw-r--r-- | server/uptime-kuma-server.js | 557 |
1 files changed, 557 insertions, 0 deletions
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"); |