Merge branch 'master' into 1.23.X-merge-to-2.X.X-2

# Conflicts:
#	package.json
This commit is contained in:
Louis Lam
2023-12-02 18:43:06 +08:00
25 changed files with 803 additions and 74 deletions

View File

@@ -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<Bean[]>} 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,
};

View File

@@ -1,10 +1,10 @@
const axios = require("axios");
const { R } = require("redbean-node");
const version = require("../package.json").version;
const https = require("https");
const fs = require("fs");
const path = require("path");
const Database = require("./database");
const { axiosAbortSignal } = require("./util-server");
class DockerHost {
@@ -70,9 +70,11 @@ class DockerHost {
static async testDockerHost(dockerHost) {
const options = {
url: "/containers/json?all=true",
timeout: 5000,
headers: {
"Accept": "*/*",
},
signal: axiosAbortSignal(6000),
};
if (dockerHost.dockerType === "socket") {
@@ -82,26 +84,33 @@ class DockerHost {
options.httpsAgent = new https.Agent(DockerHost.getHttpsAgentOptions(dockerHost.dockerType, options.baseURL));
}
let res = await axios.request(options);
try {
let res = await axios.request(options);
if (Array.isArray(res.data)) {
if (Array.isArray(res.data)) {
if (res.data.length > 1) {
if (res.data.length > 1) {
if ("ImageID" in res.data[0]) {
return res.data.length;
} else {
throw new Error("Invalid Docker response, is it Docker really a daemon?");
}
if ("ImageID" in res.data[0]) {
return res.data.length;
} else {
throw new Error("Invalid Docker response, is it Docker really a daemon?");
return res.data.length;
}
} else {
return res.data.length;
throw new Error("Invalid Docker response, is it Docker really a daemon?");
}
} catch (e) {
if (e.code === "ECONNABORTED" || e.name === "CanceledError") {
throw new Error("Connection to Docker daemon timed out.");
} else {
throw e;
}
} else {
throw new Error("Invalid Docker response, is it Docker really a daemon?");
}
}
/**

View File

@@ -29,13 +29,14 @@ class Heartbeat extends BeanModel {
*/
toJSON() {
return {
monitorID: this.monitor_id,
status: this.status,
time: this.time,
msg: this.msg,
ping: this.ping,
important: this.important,
duration: this.duration,
monitorID: this._monitorId,
status: this._status,
time: this._time,
msg: this._msg,
ping: this._ping,
important: this._important,
duration: this._duration,
retries: this._retries,
};
}

View File

@@ -1,4 +1,3 @@
const https = require("https");
const dayjs = require("dayjs");
const axios = require("axios");
const { Prometheus } = require("../prometheus");
@@ -23,6 +22,8 @@ const jsonata = require("jsonata");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const { UptimeCalculator } = require("../uptime-calculator");
const { CookieJar } = require("tough-cookie");
const { HttpsCookieAgent } = require("http-cookie-agent/http");
const rootCertificates = rootCertificatesFingerprints();
@@ -153,6 +154,7 @@ class Monitor extends BeanModel {
kafkaProducerAllowAutoTopicCreation: this.getKafkaProducerAllowAutoTopicCreation(),
kafkaProducerMessage: this.kafkaProducerMessage,
screenshot,
remote_browser: this.remote_browser,
};
if (includeSensitiveData) {
@@ -351,6 +353,9 @@ class Monitor extends BeanModel {
previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [
this.id,
]);
if (previousBeat) {
retries = previousBeat.retries;
}
}
const isFirstBeat = !previousBeat;
@@ -504,7 +509,12 @@ class Monitor extends BeanModel {
}
if (!options.httpsAgent) {
options.httpsAgent = new https.Agent(httpsAgentOptions);
let jar = new CookieJar();
let httpsCookieAgentOptions = {
...httpsAgentOptions,
cookies: { jar }
};
options.httpsAgent = new HttpsCookieAgent(httpsCookieAgentOptions);
}
if (this.auth_method === "mtls") {
@@ -622,6 +632,7 @@ class Monitor extends BeanModel {
// If the previous beat was down or pending we use the regular
// beatInterval/retryInterval in the setTimeout further below
if (previousBeat.status !== (this.isUpsideDown() ? DOWN : UP) || msSinceLastBeat > beatInterval * 1000 + bufferTime) {
bean.duration = Math.round(msSinceLastBeat / 1000);
throw new Error("No heartbeat in the time window");
} else {
let timeout = beatInterval * 1000 - msSinceLastBeat;
@@ -637,6 +648,7 @@ class Monitor extends BeanModel {
return;
}
} else {
bean.duration = beatInterval;
throw new Error("No heartbeat in the time window");
}
@@ -909,9 +921,14 @@ class Monitor extends BeanModel {
} else if ((this.maxretries > 0) && (retries < this.maxretries)) {
retries++;
bean.status = PENDING;
} else {
// Continue counting retries during DOWN
retries++;
}
}
bean.retries = retries;
log.debug("monitor", `[${this.name}] Check isImportant`);
let isImportant = Monitor.isImportantBeat(isFirstBeat, previousBeat?.status, bean.status);
@@ -1425,10 +1442,7 @@ class Monitor extends BeanModel {
* @returns {Promise<LooseObject<any>>} Previous heartbeat
*/
static async getPreviousHeartbeat(monitorID) {
return await R.getRow(`
SELECT ping, status, time FROM heartbeat
WHERE id = (select MAX(id) from heartbeat where monitor_id = ?)
`, [
return await R.findOne("heartbeat", " id = (select MAX(id) from heartbeat where monitor_id = ?)", [
monitorID
]);
}

View File

@@ -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;

View File

@@ -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;
@@ -86,6 +87,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<Browser>} 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
@@ -192,11 +206,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<boolean>} 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";
@@ -205,7 +229,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();
@@ -238,4 +262,5 @@ module.exports = {
RealBrowserMonitorType,
testChrome,
resetChrome,
testRemoteBrowser,
};

View File

@@ -78,12 +78,12 @@ class Mattermost extends NotificationProvider {
{
fallback:
"Your " +
monitorJSON.name +
monitorJSON.pathName +
" service went " +
statusText,
color: color,
title:
monitorJSON.name +
monitorJSON.pathName +
" service went " +
statusText,
title_link: monitorJSON.url,

View File

@@ -4,6 +4,8 @@ const HttpsProxyAgent = require("https-proxy-agent");
const SocksProxyAgent = require("socks-proxy-agent");
const { debug } = require("../src/util");
const { UptimeKumaServer } = require("./uptime-kuma-server");
const { CookieJar } = require("tough-cookie");
const { createCookieAgent } = require("http-cookie-agent/http");
class Proxy {
@@ -95,10 +97,13 @@ class Proxy {
let httpAgent;
let httpsAgent;
let jar = new CookieJar();
const proxyOptions = {
protocol: proxy.protocol,
host: proxy.host,
port: proxy.port,
cookies: { jar },
};
if (proxy.auth) {
@@ -112,12 +117,17 @@ class Proxy {
switch (proxy.protocol) {
case "http":
case "https":
httpAgent = new HttpProxyAgent({
// eslint-disable-next-line no-case-declarations
const HttpCookieProxyAgent = createCookieAgent(HttpProxyAgent);
// eslint-disable-next-line no-case-declarations
const HttpsCookieProxyAgent = createCookieAgent(HttpsProxyAgent);
httpAgent = new HttpCookieProxyAgent({
...httpAgentOptions || {},
...proxyOptions
...proxyOptions,
});
httpsAgent = new HttpsProxyAgent({
httpsAgent = new HttpsCookieProxyAgent({
...httpsAgentOptions || {},
...proxyOptions,
});
@@ -126,7 +136,9 @@ class Proxy {
case "socks5":
case "socks5h":
case "socks4":
agent = new SocksProxyAgent({
// eslint-disable-next-line no-case-declarations
const SocksCookieProxyAgent = createCookieAgent(SocksProxyAgent);
agent = new SocksCookieProxyAgent({
...httpAgentOptions,
...httpsAgentOptions,
...proxyOptions,

84
server/remote-browser.js Normal file
View File

@@ -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<Bean>} 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<Bean>} 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<void>}
*/
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,
};

View File

@@ -64,38 +64,57 @@ router.get("/api/push/:pushToken", async (request, response) => {
const previousHeartbeat = await Monitor.getPreviousHeartbeat(monitor.id);
if (monitor.isUpsideDown()) {
status = flipStatus(status);
}
let isFirstBeat = true;
let previousStatus = status;
let duration = 0;
let bean = R.dispense("heartbeat");
bean.time = R.isoDateTimeMillis(dayjs.utc());
bean.monitor_id = monitor.id;
bean.ping = ping;
bean.msg = msg;
bean.downCount = previousHeartbeat?.downCount || 0;
if (previousHeartbeat) {
isFirstBeat = false;
previousStatus = previousHeartbeat.status;
duration = dayjs(bean.time).diff(dayjs(previousHeartbeat.time), "second");
bean.duration = dayjs(bean.time).diff(dayjs(previousHeartbeat.time), "second");
}
if (await Monitor.isUnderMaintenance(monitor.id)) {
msg = "Monitor under maintenance";
status = MAINTENANCE;
bean.status = MAINTENANCE;
} else {
determineStatus(status, previousHeartbeat, monitor.maxretries, monitor.isUpsideDown(), bean);
}
log.debug("router", `/api/push/ called at ${dayjs().format("YYYY-MM-DD HH:mm:ss.SSS")}`);
log.debug("router", "PreviousStatus: " + previousStatus);
log.debug("router", "Current Status: " + status);
// Calculate uptime
let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitor.id);
let endTimeDayjs = await uptimeCalculator.update(bean.status, parseFloat(bean.ping));
bean.end_time = R.isoDateTimeMillis(endTimeDayjs);
bean.important = Monitor.isImportantBeat(isFirstBeat, previousStatus, status);
bean.monitor_id = monitor.id;
bean.status = status;
bean.msg = msg;
bean.ping = ping;
bean.duration = duration;
log.debug("router", `/api/push/ called at ${dayjs().format("YYYY-MM-DD HH:mm:ss.SSS")}`);
log.debug("router", "PreviousStatus: " + previousHeartbeat?.status);
log.debug("router", "Current Status: " + bean.status);
bean.important = Monitor.isImportantBeat(isFirstBeat, previousHeartbeat?.status, status);
if (Monitor.isImportantForNotification(isFirstBeat, previousHeartbeat?.status, status)) {
// Reset down count
bean.downCount = 0;
log.debug("monitor", `[${this.name}] sendNotification`);
await Monitor.sendNotification(isFirstBeat, monitor, bean);
} else {
if (bean.status === DOWN && this.resendInterval > 0) {
++bean.downCount;
if (bean.downCount >= this.resendInterval) {
// Send notification again, because we are still DOWN
log.debug("monitor", `[${this.name}] sendNotification again: Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`);
await Monitor.sendNotification(isFirstBeat, this, bean);
// Reset down count
bean.downCount = 0;
}
}
}
await R.store(bean);
@@ -107,11 +126,6 @@ router.get("/api/push/:pushToken", async (request, response) => {
response.json({
ok: true,
});
if (Monitor.isImportantForNotification(isFirstBeat, previousStatus, status)) {
await Monitor.sendNotification(isFirstBeat, monitor, bean);
}
} catch (e) {
response.status(404).json({
ok: false,
@@ -562,4 +576,58 @@ router.get("/api/badge/:id/response", cache("5 minutes"), async (request, respon
}
});
/**
* Determines the status of the next beat in the push route handling.
* @param {string} status - The reported new status.
* @param {object} previousHeartbeat - The previous heartbeat object.
* @param {number} maxretries - The maximum number of retries allowed.
* @param {boolean} isUpsideDown - Indicates if the monitor is upside down.
* @param {object} bean - The new heartbeat object.
* @returns {void}
*/
function determineStatus(status, previousHeartbeat, maxretries, isUpsideDown, bean) {
if (isUpsideDown) {
status = flipStatus(status);
}
if (previousHeartbeat) {
if (previousHeartbeat.status === UP && status === DOWN) {
// Going Down
if ((maxretries > 0) && (previousHeartbeat.retries < maxretries)) {
// Retries available
bean.retries = previousHeartbeat.retries + 1;
bean.status = PENDING;
} else {
// No more retries
bean.retries = 0;
bean.status = DOWN;
}
} else if (previousHeartbeat.status === PENDING && status === DOWN && previousHeartbeat.retries < maxretries) {
// Retries available
bean.retries = previousHeartbeat.retries + 1;
bean.status = PENDING;
} else {
// No more retries or not pending
if (status === DOWN) {
bean.retries = previousHeartbeat.retries + 1;
bean.status = status;
} else {
bean.retries = 0;
bean.status = status;
}
}
} else {
// First beat?
if (status === DOWN && maxretries > 0) {
// Retries available
bean.retries = 1;
bean.status = PENDING;
} else {
// Retires not enabled
bean.retries = 0;
bean.status = status;
}
}
}
module.exports = router;

View File

@@ -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);

View File

@@ -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,
});
}
});
};