From 62780001f74d7432e584bf216f7b5d4f1cbf28af Mon Sep 17 00:00:00 2001 From: Adam Hancock Date: Fri, 1 Dec 2023 07:29:10 +0000 Subject: [PATCH] Feature: remote browser support (#3904) * [empty commit] pull request for remote browser support * Remote browser: Added UI screens and DB tables. * Remote browser working * Fixing tests * Fix tests * Fix tests * fix tests * Test browser * revert init_db.js * Changed drop down to ActionSelect * Fix translations * added remote browsers toggle * revert changes package-lock * Fix bad english * Set default remote browser * Remote browsers Requested changes * fixed description. --- .../2023-10-16-0000-create-remote-browsers.js | 21 ++ server/client.js | 27 ++- server/model/monitor.js | 1 + server/model/remote_browser.js | 17 ++ .../real-browser-monitor-type.js | 33 +++- server/remote-browser.js | 84 ++++++++ server/server.js | 6 +- .../remote-browser-socket-handler.js | 82 ++++++++ src/components/RemoteBrowserDialog.vue | 185 ++++++++++++++++++ src/components/settings/RemoteBrowsers.vue | 53 +++++ src/lang/en.json | 9 + src/mixins/socket.js | 5 + src/pages/EditMonitor.vue | 55 +++++- src/pages/Settings.vue | 3 + src/router.js | 5 + 15 files changed, 579 insertions(+), 7 deletions(-) create mode 100644 db/knex_migrations/2023-10-16-0000-create-remote-browsers.js create mode 100644 server/model/remote_browser.js create mode 100644 server/remote-browser.js create mode 100644 server/socket-handlers/remote-browser-socket-handler.js create mode 100644 src/components/RemoteBrowserDialog.vue create mode 100644 src/components/settings/RemoteBrowsers.vue diff --git a/db/knex_migrations/2023-10-16-0000-create-remote-browsers.js b/db/knex_migrations/2023-10-16-0000-create-remote-browsers.js new file mode 100644 index 000000000..c720d3f4a --- /dev/null +++ b/db/knex_migrations/2023-10-16-0000-create-remote-browsers.js @@ -0,0 +1,21 @@ +exports.up = function (knex) { + return knex.schema + .createTable("remote_browser", function (table) { + table.increments("id"); + table.string("name", 255).notNullable(); + table.string("url", 255).notNullable(); + table.integer("user_id").unsigned(); + }).alterTable("monitor", function (table) { + // Add new column monitor.remote_browser + table.integer("remote_browser").nullable().defaultTo(null).unsigned() + .index() + .references("id") + .inTable("remote_browser"); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTable("remote_browser").alterTable("monitor", function (table) { + table.dropColumn("remote_browser"); + }); +}; diff --git a/server/client.js b/server/client.js index d03065f36..260e77a73 100644 --- a/server/client.js +++ b/server/client.js @@ -185,6 +185,30 @@ async function sendDockerHostList(socket) { return list; } +/** + * Send list of docker hosts to client + * @param {Socket} socket Socket.io socket instance + * @returns {Promise} 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; +} + module.exports = { sendNotificationList, sendImportantHeartbeatList, @@ -192,5 +216,6 @@ module.exports = { sendProxyList, sendAPIKeyList, sendInfo, - sendDockerHostList + sendDockerHostList, + sendRemoteBrowserList, }; diff --git a/server/model/monitor.js b/server/model/monitor.js index 42d6ad0fb..3cf72d235 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -154,6 +154,7 @@ class Monitor extends BeanModel { kafkaProducerAllowAutoTopicCreation: this.getKafkaProducerAllowAutoTopicCreation(), kafkaProducerMessage: this.kafkaProducerMessage, screenshot, + remote_browser: this.remote_browser, }; if (includeSensitiveData) { diff --git a/server/model/remote_browser.js b/server/model/remote_browser.js new file mode 100644 index 000000000..49299ad4f --- /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/monitor-types/real-browser-monitor-type.js b/server/monitor-types/real-browser-monitor-type.js index 4003c68eb..1bf5091a2 100644 --- a/server/monitor-types/real-browser-monitor-type.js +++ b/server/monitor-types/real-browser-monitor-type.js @@ -8,6 +8,7 @@ const path = require("path"); const Database = require("../database"); const jwt = require("jsonwebtoken"); const config = require("../config"); +const { RemoteBrowser } = require("../remote-browser"); let browser = null; @@ -85,6 +86,19 @@ async function getBrowser() { 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} 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 = chromium.connect(remoteBrowser.url); + return browser; +} + /** * Prepare the chrome executable path * @param {string} executablePath Path to chrome executable @@ -191,11 +205,21 @@ async function testChrome(executablePath) { throw new Error(e.message); } } - +// test remote browser /** - * TODO: connect remote browser? https://playwright.dev/docs/api/class-browsertype#browser-type-connect - * + * @param {string} remoteBrowserURL Remote Browser URL + * @returns {Promise} 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"; @@ -204,7 +228,7 @@ class RealBrowserMonitorType extends MonitorType { * @inheritdoc */ async check(monitor, heartbeat, server) { - const browser = await getBrowser(); + 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(); @@ -237,4 +261,5 @@ module.exports = { RealBrowserMonitorType, testChrome, resetChrome, + testRemoteBrowser, }; diff --git a/server/remote-browser.js b/server/remote-browser.js new file mode 100644 index 000000000..0d17f1a56 --- /dev/null +++ b/server/remote-browser.js @@ -0,0 +1,84 @@ +const { R } = require("redbean-node"); +const { testRemoteBrowser } = require("./monitor-types/real-browser-monitor-type.js"); +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} 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} 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} + */ + 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); + } + + /** + * Tests the connection to Remote Browser + * @param {object} remoteBrowser Docker host to check for + * @returns {boolean} Returns if connection worked + */ + static async test(remoteBrowser) { + const testResult = await testRemoteBrowser(remoteBrowser.id, remoteBrowser.user_id); + return testResult; + } + +} + +module.exports = { + RemoteBrowser, +}; diff --git a/server/server.js b/server/server.js index f21288938..18bc6b0ca 100644 --- a/server/server.js +++ b/server/server.js @@ -131,9 +131,10 @@ const testMode = !!args["test"] || false; const e2eTestMode = !!args["e2e"] || false; // Must be after io instantiation -const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList } = require("./client"); +const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList, sendRemoteBrowserList } = 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"); @@ -827,6 +828,7 @@ let needSetup = false; bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation; bean.gamedigGivenPortOnly = monitor.gamedigGivenPortOnly; + bean.remote_browser = monitor.remote_browser; bean.validate(); @@ -1508,6 +1510,7 @@ let needSetup = false; dockerSocketHandler(socket); maintenanceSocketHandler(socket); apiKeySocketHandler(socket); + remoteBrowserSocketHandler(socket); generalSocketHandler(socket, server); log.debug("server", "added all socket handlers"); @@ -1616,6 +1619,7 @@ async function afterLogin(socket, user) { sendProxyList(socket); sendDockerHostList(socket); sendAPIKeyList(socket); + sendRemoteBrowserList(socket); await sleep(500); 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 000000000..ae53030ec --- /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/src/components/RemoteBrowserDialog.vue b/src/components/RemoteBrowserDialog.vue new file mode 100644 index 000000000..941ab8f7d --- /dev/null +++ b/src/components/RemoteBrowserDialog.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/src/components/settings/RemoteBrowsers.vue b/src/components/settings/RemoteBrowsers.vue new file mode 100644 index 000000000..b449ac63a --- /dev/null +++ b/src/components/settings/RemoteBrowsers.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/lang/en.json b/src/lang/en.json index 806d6fbd6..c6da8dbd3 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -859,6 +859,15 @@ "successEnabled": "Enabled Successfully.", "tagNotFound": "Tag not found.", "foundChromiumVersion": "Found Chromium/Chrome. Version: {0}", + "Remote Browsers": "Remote Browsers", + "Remote Browser": "Remote Browser", + "Add a Remote Browser": "Add a Remote Browser", + "Remote Browser not found!": "Remote Browser not found!", + "remoteBrowsersDescription": "Remote Browsers are an alternative to running Chromium locally. Setup with a service like browserless.io or connect to your own", + "self-hosted container": "self-hosted container", + "remoteBrowserToggle": "By default Chromium runs inside the Uptime Kuma container. You can use a remote browser by toggling this switch.", + "useRemoteBrowser": "Use a Remote Browser", + "deleteRemoteBrowserMessage": "Are you sure want to delete this Remote Browser for all monitors?", "GrafanaOncallUrl": "Grafana Oncall URL", "Browser Screenshot": "Browser Screenshot" } diff --git a/src/mixins/socket.js b/src/mixins/socket.js index bbb06658e..a6338742e 100644 --- a/src/mixins/socket.js +++ b/src/mixins/socket.js @@ -46,6 +46,7 @@ export default { tlsInfoList: {}, notificationList: [], dockerHostList: [], + remoteBrowserList: [], statusPageListLoaded: false, statusPageList: [], proxyList: [], @@ -174,6 +175,10 @@ export default { this.dockerHostList = data; }); + socket.on("remoteBrowserList", (data) => { + this.remoteBrowserList = data; + }); + socket.on("heartbeat", (data) => { if (! (data.monitorID in this.heartbeatList)) { this.heartbeatList[data.monitorID] = []; diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 4b82bdf67..43a5e2ec0 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -144,6 +144,30 @@ + +
+ +
+ + +
+ {{ $t("remoteBrowserToggle") }} +
+
+ +
+ + +
+
+
@@ -834,6 +858,7 @@ +
@@ -846,6 +871,7 @@ import CopyableInput from "../components/CopyableInput.vue"; import CreateGroupDialog from "../components/CreateGroupDialog.vue"; import NotificationDialog from "../components/NotificationDialog.vue"; import DockerHostDialog from "../components/DockerHostDialog.vue"; +import RemoteBrowserDialog from "../components/RemoteBrowserDialog.vue"; import ProxyDialog from "../components/ProxyDialog.vue"; import TagsManager from "../components/TagsManager.vue"; import { genSecret, isDev, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND } from "../util.ts"; @@ -894,6 +920,7 @@ const monitorDefaults = { kafkaProducerSsl: false, kafkaProducerAllowAutoTopicCreation: false, gamedigGivenPortOnly: true, + remote_browser: null }; export default { @@ -905,6 +932,7 @@ export default { CreateGroupDialog, NotificationDialog, DockerHostDialog, + RemoteBrowserDialog, TagsManager, VueMultiselect, }, @@ -932,6 +960,7 @@ export default { "mongodb": "mongodb://username:password@host:port/database", }, draftGroupName: null, + remoteBrowsersEnabled: false, }; }, @@ -955,7 +984,31 @@ export default { } return this.$t(name); }, - + remoteBrowsersOptions() { + return this.$root.remoteBrowserList.map(browser => { + return { + label: browser.name, + value: browser.id, + }; + }); + }, + remoteBrowsersToggle: { + get() { + return this.remoteBrowsersEnabled || this.monitor.remote_browser != null; + }, + set(value) { + if (value) { + this.remoteBrowsersEnabled = true; + if (this.monitor.remote_browser == null && this.$root.remoteBrowserList.length > 0) { + // set a default remote browser if there is one. Otherwise, the user will have to select one manually. + this.monitor.remote_browser = this.$root.remoteBrowserList[0].id; + } + } else { + this.remoteBrowsersEnabled = false; + this.monitor.remote_browser = null; + } + } + }, isAdd() { return this.$route.path === "/add"; }, diff --git a/src/pages/Settings.vue b/src/pages/Settings.vue index 4598dfb61..3da1ed9a0 100644 --- a/src/pages/Settings.vue +++ b/src/pages/Settings.vue @@ -104,6 +104,9 @@ export default { "docker-hosts": { title: this.$t("Docker Hosts"), }, + "remote-browsers": { + title: this.$t("Remote Browsers"), + }, security: { title: this.$t("Security"), }, diff --git a/src/router.js b/src/router.js index 0ceb139f9..36cdeadae 100644 --- a/src/router.js +++ b/src/router.js @@ -31,6 +31,7 @@ import MonitorHistory from "./components/settings/MonitorHistory.vue"; const Security = () => import("./components/settings/Security.vue"); import Proxies from "./components/settings/Proxies.vue"; import About from "./components/settings/About.vue"; +import RemoteBrowsers from "./components/settings/RemoteBrowsers.vue"; const routes = [ { @@ -113,6 +114,10 @@ const routes = [ path: "docker-hosts", component: DockerHosts, }, + { + path: "remote-browsers", + component: RemoteBrowsers, + }, { path: "security", component: Security,