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(); // 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} */ 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} List of monitors */ async sendMonitorList(socket) { let list = await this.getMonitorJSONList(socket.userID); this.io.to(socket.userID).emit("monitorList", list); return list; } /** * Get a list of monitors for the given user. * @param {string} userID - The ID of the user to get monitors for. * @returns {Promise} A promise that resolves to an object with monitor IDs as keys and monitor objects as values. * * Generated by Trelent */ async getMonitorJSONList(userID) { let result = {}; let monitorList = await R.find("monitor", " user_id = ? ORDER BY weight DESC, name", [ userID, ]); // Collect monitor IDs // Create monitorData with id, active const monitorData = monitorList.map(monitor => ({ id: monitor.id, active: monitor.active, })); const preloadData = await Monitor.preparePreloadData(monitorData); // Create an array of promises to convert each monitor to JSON in parallel const monitorPromises = monitorList.map(monitor => monitor.toJSON(preloadData).then(json => { return { id: monitor.id, json }; })); // Wait for all promises to resolve const monitors = await Promise.all(monitorPromises); // Populate the result object with monitor IDs as keys, JSON objects as values monitors.forEach(monitor => { result[monitor.id] = monitor.json; }); return result; } /** * Send maintenance list to client * @param {Socket} socket Socket.io instance to send to * @returns {Promise} 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} 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} 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} */ 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} 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} 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} 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} */ 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} */ async start() { let enable = await Settings.get("nscd"); if (enable || enable === null) { await this.startNSCDServices(); } } /** * Stop the server * @returns {Promise} */ 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 Monitor = require("./model/monitor");