From 4f6035899d7fe807c9bf57144eb22fc503fdfb4f Mon Sep 17 00:00:00 2001 From: Louis Lam Date: Tue, 27 Jun 2023 15:54:33 +0800 Subject: [PATCH] Real browser monitor type (#3308) --- docker/dockerfile | 2 + package-lock.json | 12 ++ package.json | 1 + server/database.js | 8 + server/model/monitor.js | 12 +- server/monitor-types/monitor-type.js | 3 +- .../real-browser-monitor-type.js | 164 ++++++++++++++++++ server/server.js | 23 +-- .../socket-handlers/general-socket-handler.js | 15 ++ server/uptime-kuma-server.js | 13 ++ src/components/settings/General.vue | 30 ++++ src/lang/en.json | 3 + src/pages/Details.vue | 21 +++ src/pages/EditMonitor.vue | 16 +- 14 files changed, 299 insertions(+), 24 deletions(-) create mode 100644 server/monitor-types/real-browser-monitor-type.js diff --git a/docker/dockerfile b/docker/dockerfile index 1799044af..239a0c95e 100644 --- a/docker/dockerfile +++ b/docker/dockerfile @@ -26,6 +26,8 @@ RUN chmod +x /app/extra/entrypoint.sh FROM louislam/uptime-kuma:base-debian AS release WORKDIR /app +ENV UPTIME_KUMA_IS_CONTAINER=1 + # Copy app files from build layer COPY --from=build /app /app diff --git a/package-lock.json b/package-lock.json index c6e18b9c0..f3bc2faec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "password-hash": "~1.2.2", "pg": "~8.8.0", "pg-connection-string": "~2.5.0", + "playwright-core": "~1.35.1", "prom-client": "~13.2.0", "prometheus-api-metrics": "~3.2.1", "protobufjs": "~7.1.1", @@ -15576,6 +15577,17 @@ "node": ">= 0.4.0" } }, + "node_modules/playwright-core": { + "version": "1.35.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.35.1.tgz", + "integrity": "sha512-pNXb6CQ7OqmGDRspEjlxE49w+4YtR6a3X6mT1hZXeJHWmsEz7SunmvZeiG/+y1yyMZdHnnn73WKYdtV1er0Xyg==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", diff --git a/package.json b/package.json index 4aeaddec7..1ca874aff 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "password-hash": "~1.2.2", "pg": "~8.8.0", "pg-connection-string": "~2.5.0", + "playwright-core": "~1.35.1", "prom-client": "~13.2.0", "prometheus-api-metrics": "~3.2.1", "protobufjs": "~7.1.1", diff --git a/server/database.js b/server/database.js index c02c70c69..b3a497bc2 100644 --- a/server/database.js +++ b/server/database.js @@ -22,6 +22,8 @@ class Database { */ static uploadDir; + static screenshotDir; + static path; /** @@ -105,6 +107,12 @@ class Database { fs.mkdirSync(Database.uploadDir, { recursive: true }); } + // Create screenshot dir + Database.screenshotDir = Database.dataDir + "screenshots/"; + if (! fs.existsSync(Database.screenshotDir)) { + fs.mkdirSync(Database.screenshotDir, { recursive: true }); + } + log.info("db", `Data Dir: ${Database.dataDir}`); } diff --git a/server/model/monitor.js b/server/model/monitor.js index 2732a3418..909464641 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -20,6 +20,7 @@ const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent"); const { DockerHost } = require("../docker"); const { UptimeCacheList } = require("../uptime-cache-list"); const Gamedig = require("gamedig"); +const jwt = require("jsonwebtoken"); /** * status: @@ -70,6 +71,12 @@ class Monitor extends BeanModel { const tags = await this.getTags(); + let screenshot = null; + + if (this.type === "real-browser") { + screenshot = "/screenshots/" + jwt.sign(this.id, UptimeKumaServer.getInstance().jwtSecret) + ".png"; + } + let data = { id: this.id, name: this.name, @@ -117,7 +124,8 @@ class Monitor extends BeanModel { radiusCalledStationId: this.radiusCalledStationId, radiusCallingStationId: this.radiusCallingStationId, game: this.game, - httpBodyEncoding: this.httpBodyEncoding + httpBodyEncoding: this.httpBodyEncoding, + screenshot, }; if (includeSensitiveData) { @@ -740,7 +748,7 @@ class Monitor extends BeanModel { } else if (this.type in UptimeKumaServer.monitorTypeList) { let startTime = dayjs().valueOf(); const monitorType = UptimeKumaServer.monitorTypeList[this.type]; - await monitorType.check(this, bean); + await monitorType.check(this, bean, UptimeKumaServer.getInstance()); if (!bean.ping) { bean.ping = dayjs().valueOf() - startTime; } diff --git a/server/monitor-types/monitor-type.js b/server/monitor-types/monitor-type.js index f2c7cbee8..80a0a7d92 100644 --- a/server/monitor-types/monitor-type.js +++ b/server/monitor-types/monitor-type.js @@ -6,9 +6,10 @@ class MonitorType { * * @param {Monitor} monitor * @param {Heartbeat} heartbeat + * @param {UptimeKumaServer} server * @returns {Promise} */ - async check(monitor, heartbeat) { + async check(monitor, heartbeat, server) { throw new Error("You need to override check()"); } 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 000000000..1e9bd2abd --- /dev/null +++ b/server/monitor-types/real-browser-monitor-type.js @@ -0,0 +1,164 @@ +const { MonitorType } = require("./monitor-type"); +const { chromium, Browser } = 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"); + +/** + * + * @type {Browser} + */ +let browser = null; + +async function getBrowser() { + if (!browser) { + let executablePath = await Settings.get("chromeExecutable"); + + executablePath = await prepareChromeExecutable(executablePath); + + browser = await chromium.launch({ + //headless: false, + executablePath, + }); + } + return browser; +} + +async function prepareChromeExecutable(executablePath) { + // Special code for using the playwright_chromium + if (typeof executablePath === "string" && executablePath.toLocaleLowerCase() === "#playwright_chromium") { + 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 if (process.platform === "win32") { + executablePath = findChrome([ + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + "D:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "D:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + "E:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "E:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + ]); + } else if (process.platform === "linux") { + executablePath = findChrome([ + "chromium-browser", + "chromium", + "google-chrome", + ]); + } + // TODO: Mac?? + } + return executablePath; +} + +function findChrome(executables) { + for (let executable of executables) { + if (commandExistsSync(executable)) { + return executable; + } + } + throw new Error("Chromium not found, please specify Chromium executable path in the settings page."); +} + +async function resetChrome() { + if (browser) { + await browser.close(); + browser = null; + } +} + +/** + * Test if the chrome executable is valid and return the version + * @param executablePath + * @returns {Promise} + */ +async function testChrome(executablePath) { + try { + executablePath = await prepareChromeExecutable(executablePath); + + log.info("Chromium", "Testing Chromium executable: " + executablePath); + + const browser = await chromium.launch({ + executablePath, + }); + const version = browser.version(); + await browser.close(); + return version; + } catch (e) { + throw new Error(e.message); + } +} + +/** + * TODO: connect remote browser? https://playwright.dev/docs/api/class-browsertype#browser-type-connect + * + */ +class RealBrowserMonitorType extends MonitorType { + + name = "real-browser"; + + async check(monitor, heartbeat, server) { + const browser = await getBrowser(); + const context = await browser.newContext(); + const page = await context.newPage(); + + const res = await page.goto(monitor.url, { + waitUntil: "networkidle", + timeout: monitor.interval * 1000 * 0.8, + }); + + let filename = jwt.sign(monitor.id, server.jwtSecret) + ".png"; + + await page.screenshot({ + path: path.join(Database.screenshotDir, filename), + }); + + await context.close(); + + if (res.status() >= 200 && res.status() < 400) { + heartbeat.status = UP; + heartbeat.msg = res.status(); + + const timing = res.request().timing(); + heartbeat.ping = timing.responseEnd; + } else { + throw new Error(res.status() + ""); + } + } +} + +module.exports = { + RealBrowserMonitorType, + testChrome, + resetChrome, +}; diff --git a/server/server.js b/server/server.js index 20b2fdfec..870dc7525 100644 --- a/server/server.js +++ b/server/server.js @@ -149,6 +149,7 @@ const { Settings } = require("./settings"); const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); const { pluginsHandler } = require("./socket-handlers/plugins-handler"); const apicache = require("./modules/apicache"); +const { resetChrome } = require("./monitor-types/real-browser-monitor-type"); app.use(express.json()); @@ -161,12 +162,6 @@ app.use(function (req, res, next) { next(); }); -/** - * Use for decode the auth object - * @type {null} - */ -let jwtSecret = null; - /** * Show Setup Page * @type {boolean} @@ -286,7 +281,7 @@ let needSetup = false; log.info("auth", `Login by token. IP=${clientIP}`); try { - let decoded = jwt.verify(token, jwtSecret); + let decoded = jwt.verify(token, server.jwtSecret); log.info("auth", "Username from JWT: " + decoded.username); @@ -357,7 +352,7 @@ let needSetup = false; ok: true, token: jwt.sign({ username: data.username, - }, jwtSecret), + }, server.jwtSecret), }); } @@ -387,7 +382,7 @@ let needSetup = false; ok: true, token: jwt.sign({ username: data.username, - }, jwtSecret), + }, server.jwtSecret), }); } else { @@ -1158,6 +1153,8 @@ let needSetup = false; await doubleCheckPassword(socket, currentPassword); } + const previousChromeExecutable = await Settings.get("chromeExecutable"); + await setSettings("general", data); server.entryPage = data.entryPage; @@ -1168,6 +1165,12 @@ let needSetup = false; await server.setTimezone(data.serverTimezone); } + // If Chrome Executable is changed, need to reset the browser + if (previousChromeExecutable !== data.chromeExecutable) { + log.info("settings", "Chrome executable is changed. Resetting Chrome..."); + await resetChrome(); + } + callback({ ok: true, msg: "Saved" @@ -1707,7 +1710,7 @@ async function initDatabase(testMode = false) { needSetup = true; } - jwtSecret = jwtSecretBean.value; + server.jwtSecret = jwtSecretBean.value; } /** diff --git a/server/socket-handlers/general-socket-handler.js b/server/socket-handlers/general-socket-handler.js index bb4a38086..2f0c63b41 100644 --- a/server/socket-handlers/general-socket-handler.js +++ b/server/socket-handlers/general-socket-handler.js @@ -3,6 +3,7 @@ const { Settings } = require("../settings"); const { sendInfo } = require("../client"); const { checkLogin } = require("../util-server"); const GameResolver = require("gamedig/lib/GameResolver"); +const { testChrome } = require("../monitor-types/real-browser-monitor-type"); let gameResolver = new GameResolver(); let gameList = null; @@ -47,4 +48,18 @@ module.exports.generalSocketHandler = (socket, server) => { }); }); + socket.on("testChrome", (executable, callback) => { + // Just noticed that await call could block the whole socket.io server!!! Use pure promise instead. + testChrome(executable).then((version) => { + callback({ + ok: true, + msg: "Found Chromium/Chrome. Version: " + version, + }); + }).catch((e) => { + callback({ + ok: false, + msg: e.message, + }); + }); + }); }; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index 914e12e48..af77586fa 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -61,6 +61,12 @@ class UptimeKumaServer { }; + /** + * Use for decode the auth object + * @type {null} + */ + jwtSecret = null; + static getInstance(args) { if (UptimeKumaServer.instance == null) { UptimeKumaServer.instance = new UptimeKumaServer(args); @@ -98,11 +104,17 @@ class UptimeKumaServer { } } + // Set Monitor Types + UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType(); + this.io = new Server(this.httpServer); } /** Initialise app after the database has been set up */ async initAfterDatabaseReady() { + // Static + this.app.use("/screenshots", express.static(Database.screenshotDir)); + await CacheableDnsHttpAgent.update(); process.env.TZ = await this.getTimezone(); @@ -337,3 +349,4 @@ module.exports = { // Must be at the end const { MonitorType } = require("./monitor-types/monitor-type"); +const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type"); diff --git a/src/components/settings/General.vue b/src/components/settings/General.vue index 1f6964155..9f9a88b65 100644 --- a/src/components/settings/General.vue +++ b/src/components/settings/General.vue @@ -190,6 +190,30 @@ + +
+ + +
+ + +
+ +
+ {{ $t("chromeExecutableDescription") }} +
+
+
+
@@ -131,6 +132,15 @@
+ +
+
+
+ +
+
+
+
@@ -103,7 +97,7 @@
-
+