/* * Uptime Kuma Server * node "server/server.js" * DO NOT require("./server") in other modules, it likely creates circular dependency! */ import { initDayjs, getSettings, setSettings, setting, checkLogin, allowDevAllOrigin, printServerUrls, allowDevOrigin, } from "./util"; import { getRandomInt, isDevEnv, log, sleep } from "../src/util"; import { auth, createHeaders, doubleCheckPassword, getDisableAuthSession, getSessionFromHeaders } from "./better-auth"; import { createBetterAuthRouter, needSetup } from "./routers/better-auth-router"; import { readApiKey } from "./auth"; import { initAdminFromEnv } from "./initial-admin"; import { betterAuthSocketHandler } from "./socket-handlers/better-auth-socket-handler"; import { loadEnvFile } from "node:process"; import * as fs from "node:fs"; console.log("Welcome to Uptime Kuma"); initDayjs(); // Load environment variables from `.env` try { loadEnvFile(); } catch (_) {} // 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 = "< 24"; 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 config = require("./config"); process.title = "uptime-kuma"; 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); } if (isDevEnv() || process.env.UPTIME_KUMA_DEBUG_INSPECTOR === "1") { const inspector = require("inspector"); let inspectorHost = "127.0.0.1"; log.warn("server", "Node.js Inspector is enabled. You can connect to it via Chrome DevTools or VSCode."); log.warn("server", "Node.js Inspector is listening on:", inspector.url()); if (process.env.UPTIME_KUMA_IS_CONTAINER === "1") { log.warn( "server", "You need to expose the port 9229:9229 in your docker command or docker compose, and ssh tunneling in order to connect to it." ); inspectorHost = "0.0.0.0"; } inspector.open(9229, inspectorHost); } 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 http-graceful-shutdown"); const gracefulShutdown = require("http-graceful-shutdown"); 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"); log.debug("server", "Importing Settings"); log.debug("server", "Importing Notification"); const { Notification } = require("./notification"); Notification.init(); log.debug("server", "Importing Web-Push"); const webpush = require("web-push"); log.debug("server", "Importing Database"); const Database = require("./database"); log.debug("server", "Importing Background Jobs"); const { initBackgroundJobs, stopBackgroundJobs } = require("./jobs"); const { DeferredListsSender } = require("./login-lists"); const { chunk, firstBeatDelayMs } = require("./start-monitors"); const { startStatsRefresh } = require("./stats-refresh"); const { apiAuth } = require("./auth"); const { Prometheus, startPrometheusMetrics, getPrometheusAPIMiddleware, stopPrometheusMetrics, } = require("./prometheus"); const { UptimeCalculator } = require("./uptime-calculator"); const { MonitorStats } = require("./monitor-stats"); const { socketRoom } = require("./heartbeat-emit"); 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; /** * Run unit test after the server is ready * @type {boolean} */ const testMode = !!args["test"] || false; // Must be after io instantiation const { sendNotificationList, sendHeartbeatList, sendHeartbeatLists, getHeartbeatHistory, sendInfo, sendProxyList, sendDockerHostList, 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 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("./utils/response-cache"); 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(); }); (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); } // Init Better Auth auth(); // Create the initial admin user from env, only when the database has no user yet try { await initAdminFromEnv(); } catch (e) { log.error("auth", "Failed to create the initial admin user: " + 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", "Initializing Prometheus"); await Prometheus.init(); 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 (isDevEnv()) { app.options("/*", async (request, response) => { allowDevOrigin(request, response); response.end(); }); 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.get("/_e2e/take-sqlite-snapshot", async (request, response) => { // Checkpoint WAL to flush all data to the main .db file, then copy. // No close/reopen needed — the file is consistent after checkpoint. await R.exec("PRAGMA wal_checkpoint(TRUNCATE)"); fs.cpSync(Database.sqlitePath, `${Database.sqlitePath}.e2e-snapshot`); 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."); }); app.post("/test-x-www-form-urlencoded", async (request, response) => { log.debug("test", request.headers); log.debug("test", request.body); response.send("OK"); }); } // 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 (Basic Auth, only when enabled in the settings) app.get( "/metrics", async (req, res, next) => { try { if ((await setting("prometheusApiEnabled")) !== true) { res.sendStatus(404); return; } next(); } catch (e) { next(e); } }, apiAuth, (req, res, next) => getPrometheusAPIMiddleware()(req, res, next) ); 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); // MCP Router, a TypeScript module, so `require` hands back its namespace const mcpRouter = require("./routers/mcp-router").default; app.use(mcpRouter); // Status Page Router const statusPageRouter = require("./routers/status-page-router"); app.use(statusPageRouter); // better auth API Router const betterAuthRouter = await createBetterAuthRouter(); app.use(betterAuthRouter); // 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 (await needSetup()) { log.info("server", "Redirect to setup page"); socket.emit("setup"); } let session; if (!(await Settings.get("disableAuth"))) { let apiKey; // API keys only work as a socket login when the API & MCP is enabled if (await Settings.get("apiEnabled")) { apiKey = readApiKey(socket.request.headers.authorization); } session = await getSessionFromHeaders(socket.request.headers.cookie, apiKey); } else { session = await getDisableAuthSession(); } if (session) { socket.userID = session.user.id; socket.session = session; socket.headers = createHeaders(socket.request.headers.cookie); socket.emit("session", session.user.username); log.debug("auth", `Session active:`, session.session.ipAddress, session.user.username); } // *************************** // 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); // Config is never imported as a whole, only its known keys are copied const clientConfig = monitor.config; delete monitor.config; /* * List of frontend-only properties that should not be saved to the database. * Should clean up before saving to the database. */ const frontendOnlyProperties = [ "humanReadableInterval", "globalpingdnsresolvetypeoptions", "responsecheck", ]; for (const prop of frontendOnlyProperties) { if (prop in monitor) { delete monitor[prop]; } } bean.import(monitor); bean.applyClientConfig(clientConfig); // Map camelCase frontend property to snake_case database column if (monitor.retryOnlyOnStatusCodeFailure !== undefined) { bean.retry_only_on_status_code_failure = monitor.retryOnlyOnStatusCodeFailure; } 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]); // 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.subtype = monitor.subtype; bean.url = monitor.url; bean.wsIgnoreSecWebsocketAcceptHeader = monitor.wsIgnoreSecWebsocketAcceptHeader; bean.wsSubprotocol = monitor.wsSubprotocol; bean.method = monitor.method; bean.body = monitor.body; bean.ipFamily = monitor.ipFamily; bean.headers = monitor.headers; bean.basic_auth_user = monitor.basic_auth_user; bean.basic_auth_pass = monitor.basic_auth_pass; bean.bearer_token = monitor.bearer_token; 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.oauth_audience = monitor.oauth_audience; 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); bean.location = monitor.location; bean.protocol = monitor.protocol; if (isNaN(bean.port)) { bean.port = null; } bean.keyword = monitor.keyword; bean.invertKeyword = monitor.invertKeyword; bean.ignoreTls = monitor.ignoreTls; bean.expiryNotification = monitor.expiryNotification; bean.domainExpiryNotification = monitor.domainExpiryNotification; bean.upsideDown = monitor.upsideDown; bean.packetSize = monitor.packetSize; bean.maxredirects = monitor.maxredirects; bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes); bean.save_response = monitor.saveResponse; bean.save_error_response = monitor.saveErrorResponse; bean.response_max_length = monitor.responseMaxLength; 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.mqttWebsocketPath = monitor.mqttWebsocketPath; 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.gamedigToken = monitor.gamedigToken; bean.remote_browser = monitor.remote_browser; bean.screenshot_delay = monitor.screenshot_delay; bean.smtpSecurity = monitor.smtpSecurity; bean.snmpVersion = monitor.snmpVersion; bean.snmpOid = monitor.snmpOid; bean.jsonPathOperator = monitor.jsonPathOperator; bean.retry_only_on_status_code_failure = Boolean(monitor.retryOnlyOnStatusCodeFailure); 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.manual_status = monitor.manual_status; // Known config keys only, so a payload never replaces the whole config bean.applyClientConfig(monitor.config); bean.sshUsername = monitor.sshUsername; bean.sshPassword = monitor.sshPassword; bean.sftpPath = monitor.sftpPath; bean.sshPrivateKey = monitor.sshPrivateKey; bean.sshPassphrase = monitor.sshPassphrase; bean.sshAuthMethod = monitor.sshAuthMethod; bean.ntp_stratum_threshold = monitor.ntpStratumThreshold; bean.ntp_time_offset_threshold = monitor.ntpTimeOffsetThreshold; bean.ntp_root_dispersion_threshold = monitor.ntpRootDispersionThreshold; // ping advanced options bean.ping_numeric = monitor.ping_numeric; bean.ping_count = monitor.ping_count; bean.ping_per_request_timeout = monitor.ping_per_request_timeout; 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 = ? ", [monitorID]); 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, }); } }); // partial { type, url, hostname, grpcUrl } socket.on("checkDomain", async (partial, callback) => { try { checkLogin(socket); const DomainExpiry = require("./model/domain_expiry"); const supportInfo = await DomainExpiry.checkSupport(partial); callback({ ok: true, domain: supportInfo.domain, tld: supportInfo.tld, }); } catch (e) { callback({ ok: false, msg: e.message, msgi18n: !!e.msgi18n, meta: e.meta ?? {}, }); } }); socket.on("getHeartbeatHistory", async (monitorID, callback) => { try { checkLogin(socket); const data = await getHeartbeatHistory(monitorID); log.debug("monitor", `History request #${monitorID} rows=${data.length}`); callback({ ok: true, data, }); } catch (e) { log.warn("monitor", `History request #${monitorID} failed: ${e.message}`); 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, deleteChildren, callback) => { try { // Backward compatibility: if deleteChildren is omitted, the second parameter is the callback if (typeof deleteChildren === "function") { callback = deleteChildren; deleteChildren = false; } checkLogin(socket); const startTime = Date.now(); // Check if this is a group monitor const monitor = await R.findOne("monitor", " id = ? ", [monitorID]); // Log with context about deletion type if (monitor && monitor.type === "group") { if (deleteChildren) { log.info("manage", `Delete Group and Children: ${monitorID} User ID: ${socket.userID}`); } else { log.info("manage", `Delete Group (unlink children): ${monitorID} User ID: ${socket.userID}`); } } else { log.info("manage", `Delete Monitor: ${monitorID} User ID: ${socket.userID}`); } if (monitor && monitor.type === "group") { // Get all children before processing const children = await Monitor.getChildren(monitorID); if (deleteChildren) { // Delete all child monitors recursively if (children && children.length > 0) { for (const child of children) { await Monitor.deleteMonitorRecursively(child.id); await server.sendDeleteMonitorFromList(socket, child.id); } } } else { // Unlink all children from the group (set parent to null) await Monitor.unlinkAllChildren(monitorID); // Notify frontend to update each child monitor's parent to null if (children && children.length > 0) { for (const child of children) { await server.sendUpdateMonitorIntoList(socket, child.id); } } } } // Delete the monitor itself await Monitor.deleteMonitor(monitorID); // Fix #2880 apicache.clear(); const endTime = Date.now(); // Log completion with context about children handling if (monitor && monitor.type === "group") { if (deleteChildren) { log.info( "DB", `Delete Monitor completed (group and children deleted) in: ${endTime - startTime} ms` ); } else { log.info( "DB", `Delete Monitor completed (group deleted, children unlinked) in: ${endTime - startTime} ms` ); } } else { 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, ]); await server.sendUpdateMonitorIntoList(socket, monitorID); 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, ]); await server.sendUpdateMonitorIntoList(socket, 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, ]); await server.sendUpdateMonitorIntoList(socket, monitorID); 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("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.request.headers.cookie, 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"); const previousPrometheusApiEnabled = await Settings.get("prometheusApiEnabled"); await setSettings("general", data); server.entryPage = data.entryPage; // Start or stop the Prometheus sampling when the API is toggled if (previousPrometheusApiEnabled !== data.prometheusApiEnabled) { if (data.prometheusApiEnabled) { startPrometheusMetrics(); } else { stopPrometheusMetrics(); } } // 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); 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) { log.error("server", e); callback({ ok: false, msg: e.message, }); } }); socket.on("checkApprise", async (callback) => { try { checkLogin(socket); callback(await Notification.checkApprise()); } catch (e) { callback(false); } }); socket.on("getWebpushVapidPublicKey", async (callback) => { try { let publicVapidKey = await Settings.get("webpushPublicVapidKey"); if (!publicVapidKey) { log.debug("webpush", "Generating new VAPID keys"); const vapidKeys = webpush.generateVAPIDKeys(); await Settings.set("webpushPublicVapidKey", vapidKeys.publicKey); await Settings.set("webpushPrivateVapidKey", vapidKeys.privateKey); publicVapidKey = vapidKeys.publicKey; } callback({ ok: true, msg: publicVapidKey, }); } catch (e) { callback({ ok: false, msg: e.message, }); } }); 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 UptimeCalculator.clearStatistics(monitorID); if (monitorID in server.monitorList) { const monitor = server.monitorList[monitorID]; if (monitor.active) { await restartMonitor(socket.userID, 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 UptimeCalculator.clearAllStatistics(); // Restart all monitors to reset the stats for (let monitorID in server.monitorList) { const monitor = server.monitorList[monitorID]; if (monitor.active) { await restartMonitor(socket.userID, monitorID); } } callback({ ok: true, }); } catch (e) { callback({ ok: false, msg: e.message, }); } }); betterAuthSocketHandler(socket); // 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 // *************************** if (session) { await afterLogin(socket, session.user); } 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 () => { printServerUrls("server", port, hostname, config.isSSL); await startMonitors(); // Warm the dashboard/prometheus stats cache once, then refresh it periodically. // Without this, every beat would load years of stat rows per monitor. const statMonitorIDs = Object.keys(server.monitorList); if (statMonitorIDs.length > 0) { startStatsRefresh({ monitorIDs: statMonitorIDs, refresh: (ids) => MonitorStats.refresh(ids), onError: (e) => log.error("monitor", "Failed to refresh monitor stats: " + e.message), }); } // 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} */ 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); } } } /** * 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} */ async function afterLogin(socket, user) { socket.userID = user.id; socket.join(user.id); socket.join(socketRoom); let tPerf = Date.now(); let monitorList = await server.sendMonitorList(socket); log.debug("perf", `[login] sendMonitorList ${Date.now() - tPerf}ms`); tPerf = Date.now(); await Promise.allSettled([ sendInfo(socket), server.sendMaintenanceList(socket), sendNotificationList(socket), sendProxyList(socket), sendDockerHostList(socket), sendRemoteBrowserList(socket), sendMonitorTypeList(socket), ]); await StatusPage.sendStatusPageList(io, socket); log.debug("perf", `[login] misc lists ${Date.now() - tPerf}ms`); tPerf = Date.now(); const monitorIDs = Object.keys(monitorList); // The heartbeat and stats lists are another ~1.2 MB with thousands of monitors. // Send them only after the client has painted the monitor list, so the dashboard // becomes usable immediately instead of waiting for all three payloads. const sendDeferredLists = async () => { let t = Date.now(); // One event with every monitor's recent heartbeats, instead of one query + event per monitor await sendHeartbeatLists(socket, monitorIDs); log.debug("perf", `[login] heartbeatLists ${Date.now() - t}ms`); t = Date.now(); // Serve the dashboard numbers from the cache. On a cold cache join the in-flight // aggregate scan instead of showing empty numbers; this runs after first paint. let stats = MonitorStats.getCached(monitorIDs); if (Object.keys(stats).length < monitorIDs.length) { stats = await MonitorStats.refresh(monitorIDs); } io.to(socket.userID).emit("stats", stats); MonitorStats.maybeRefresh(io, monitorIDs); log.debug("perf", `[login] stats ${Date.now() - t}ms`); }; const deferredLists = new DeferredListsSender({ send: sendDeferredLists, onError: (e) => log.error("monitor", "Failed to send dashboard lists: " + e.message), }); socket.once("dashboardRendered", deferredLists.onRendered); socket.once("disconnect", () => deferredLists.dispose()); // 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} */ 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); } /** * Start the specified monitor * @param {number} userID ID of user who owns monitor * @param {number} monitorID ID of monitor to start * @returns {Promise} */ async function startMonitor(userID, monitorID) { log.info("manage", `Resume Monitor: ${monitorID} User ID: ${userID}`); await R.exec("UPDATE monitor SET active = 1 WHERE id = ? ", [monitorID]); 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} */ 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} */ async function pauseMonitor(userID, monitorID) { log.info("manage", `Pause Monitor: ${monitorID} User ID: ${userID}`); await R.exec("UPDATE monitor SET active = 0 WHERE id = ? ", [monitorID]); if (monitorID in server.monitorList) { await server.monitorList[monitorID].stop(); server.monitorList[monitorID].active = 0; } } /** * Resume active monitors * @returns {Promise} */ async function startMonitors() { let list = await R.find("monitor", " active = 1 "); for (let monitor of list) { server.monitorList[monitor.id] = monitor; } // Start every monitor in small concurrent batches, but spread their first check // over the interval, so 5000 monitors do not all check at the same second for (const batch of chunk(list, 50)) { await Promise.allSettled( batch.map(async (monitor) => { try { await monitor.start(io, firstBeatDelayMs(monitor.interval)); } catch (e) { log.error("monitor", e); } }) ); } } /** * 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} */ 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);