diff --git a/Common/Server/EnvironmentConfig.ts b/Common/Server/EnvironmentConfig.ts index ef5f62fd5b..6eccd75d1c 100644 --- a/Common/Server/EnvironmentConfig.ts +++ b/Common/Server/EnvironmentConfig.ts @@ -161,6 +161,14 @@ export const ClusterKey: ObjectID = new ObjectID( export const HasClusterKey: boolean = Boolean(process.env["ONEUPTIME_SECRET"]); +export const RegisterProbeKey: ObjectID = new ObjectID( + process.env["REGISTER_PROBE_KEY"] || "secret", +); + +export const HasRegisterProbeKey: boolean = Boolean( + process.env["REGISTER_PROBE_KEY"], +); + export const AppApiHostname: Hostname = Hostname.fromString( `${process.env["SERVER_APP_HOSTNAME"] || "localhost"}:${ process.env["APP_PORT"] || 80 diff --git a/Common/Server/Utils/VM/VMRunner.ts b/Common/Server/Utils/VM/VMRunner.ts index 7054620e1e..85c56daff6 100644 --- a/Common/Server/Utils/VM/VMRunner.ts +++ b/Common/Server/Utils/VM/VMRunner.ts @@ -1,12 +1,8 @@ -import Dictionary from "../../../Types/Dictionary"; -import GenericObject from "../../../Types/GenericObject"; import ReturnResult from "../../../Types/IsolatedVM/ReturnResult"; -import { JSONObject, JSONValue } from "../../../Types/JSON"; -import axios from "axios"; -import http from "http"; -import https from "https"; +import { JSONObject } from "../../../Types/JSON"; +import axios, { AxiosResponse } from "axios"; import crypto from "crypto"; -import vm, { Context } from "node:vm"; +import ivm from "isolated-vm"; import CaptureSpan from "../Telemetry/CaptureSpan"; export default class VMRunner { @@ -16,49 +12,231 @@ export default class VMRunner { options: { timeout?: number; args?: JSONObject | undefined; - context?: Dictionary | undefined; }; }): Promise { const { code, options } = data; + const timeout: number = options.timeout || 5000; const logMessages: string[] = []; - let sandbox: Context = { - console: { - log: (...args: JSONValue[]) => { + const isolate: ivm.Isolate = new ivm.Isolate({ memoryLimit: 128 }); + + try { + const context: ivm.Context = await isolate.createContext(); + const jail: ivm.Reference> = context.global; + + // Set up global object + await jail.set("global", jail.derefInto()); + + // console.log - fire-and-forget callback + await jail.set( + "_log", + new ivm.Callback((...args: string[]) => { logMessages.push(args.join(" ")); + }), + ); + + await context.eval(` + const console = { log: (...a) => _log(...a.map(v => { + try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } + catch(_) { return String(v); } + }))}; + `); + + // args - deep copy into isolate + if (options.args) { + await jail.set( + "_args", + new ivm.ExternalCopy(options.args).copyInto(), + ); + await context.eval("const args = _args;"); + } else { + await context.eval("const args = {};"); + } + + // axios (get, post, put, delete) - bridged via applySyncPromise + const axiosRef: ivm.Reference< + ( + method: string, + url: string, + dataOrConfig?: string, + ) => Promise + > = new ivm.Reference( + async ( + method: string, + url: string, + dataOrConfig?: string, + ): Promise => { + const parsed: JSONObject | undefined = dataOrConfig + ? (JSON.parse(dataOrConfig) as JSONObject) + : undefined; + + let response: AxiosResponse; + + switch (method) { + case "get": + response = await axios.get(url, parsed); + break; + case "post": + response = await axios.post(url, parsed); + break; + case "put": + response = await axios.put(url, parsed); + break; + case "delete": + response = await axios.delete(url, parsed); + break; + default: + throw new Error(`Unsupported HTTP method: ${method}`); + } + + return JSON.stringify({ + status: response.status, + headers: response.headers, + data: response.data, + }); }, - }, - http: http, - https: https, - axios: axios, - crypto: crypto, - setTimeout: setTimeout, - clearTimeout: clearTimeout, - setInterval: setInterval, - ...options.context, - }; + ); - if (options.args) { - sandbox = { - ...sandbox, - args: options.args, - }; - } + await jail.set("_axiosRef", axiosRef); - vm.createContext(sandbox); // Contextify the object. + await context.eval(` + const axios = { + get: async (url, config) => { + const r = await _axiosRef.applySyncPromise(undefined, ['get', url, config ? JSON.stringify(config) : undefined]); + return JSON.parse(r); + }, + post: async (url, data) => { + const r = await _axiosRef.applySyncPromise(undefined, ['post', url, data ? JSON.stringify(data) : undefined]); + return JSON.parse(r); + }, + put: async (url, data) => { + const r = await _axiosRef.applySyncPromise(undefined, ['put', url, data ? JSON.stringify(data) : undefined]); + return JSON.parse(r); + }, + delete: async (url, config) => { + const r = await _axiosRef.applySyncPromise(undefined, ['delete', url, config ? JSON.stringify(config) : undefined]); + return JSON.parse(r); + }, + }; + `); - const script: string = `(async()=>{ + // crypto (createHash, createHmac, randomBytes) - bridged via applySync + const cryptoRef: ivm.Reference< + (op: string, ...args: string[]) => string + > = new ivm.Reference((op: string, ...args: string[]): string => { + switch (op) { + case "createHash": { + const [algorithm, inputData, encoding] = args; + return crypto + .createHash(algorithm!) + .update(inputData!) + .digest((encoding as crypto.BinaryToTextEncoding) || "hex"); + } + case "createHmac": { + const [algorithm, key, inputData, encoding] = args; + return crypto + .createHmac(algorithm!, key!) + .update(inputData!) + .digest((encoding as crypto.BinaryToTextEncoding) || "hex"); + } + case "randomBytes": { + const [size] = args; + return crypto.randomBytes(parseInt(size!)).toString("hex"); + } + default: + throw new Error(`Unsupported crypto operation: ${op}`); + } + }); + + await jail.set("_cryptoRef", cryptoRef); + + await context.eval(` + const crypto = { + createHash: (algorithm) => ({ + _alg: algorithm, _data: '', + update(d) { this._data = d; return this; }, + digest(enc) { return _cryptoRef.applySync(undefined, ['createHash', this._alg, this._data, enc || 'hex']); } + }), + createHmac: (algorithm, key) => ({ + _alg: algorithm, _key: key, _data: '', + update(d) { this._data = d; return this; }, + digest(enc) { return _cryptoRef.applySync(undefined, ['createHmac', this._alg, this._key, this._data, enc || 'hex']); } + }), + randomBytes: (size) => ({ + toString(enc) { return _cryptoRef.applySync(undefined, ['randomBytes', String(size)]); } + }), + }; + `); + + // setTimeout / sleep - bridged via applySyncPromise + const sleepRef: ivm.Reference<(ms: number) => Promise> = + new ivm.Reference((ms: number): Promise => { + return new Promise((resolve: () => void) => { + global.setTimeout(resolve, Math.min(ms, timeout)); + }); + }); + + await jail.set("_sleepRef", sleepRef); + + await context.eval(` + function setTimeout(fn, ms) { + _sleepRef.applySyncPromise(undefined, [ms || 0]); + if (typeof fn === 'function') fn(); + } + async function sleep(ms) { + await _sleepRef.applySyncPromise(undefined, [ms || 0]); + } + `); + + // Wrap user code in async IIFE and evaluate + const wrappedCode: string = `(async () => { ${code} })()`; - const returnVal: any = await vm.runInContext(script, sandbox, { - timeout: options.timeout || 5000, - }); // run the script + // Run with overall timeout covering both CPU and I/O wait + const resultPromise: Promise = context.eval(wrappedCode, { + promise: true, + timeout: timeout, + }); - return { - returnValue: returnVal, - logMessages, - }; + const overallTimeout: Promise = new Promise( + ( + _resolve: (value: never) => void, + reject: (reason: Error) => void, + ) => { + global.setTimeout(() => { + reject(new Error("Script execution timed out")); + }, timeout + 5000); // 5s grace period beyond isolate timeout + }, + ); + + let returnValue: unknown; + + const result: unknown = await Promise.race([ + resultPromise, + overallTimeout, + ]); + + // If the result is an ivm.Reference or ExternalCopy, extract the value + if (result && typeof result === "object" && "copy" in result) { + try { + returnValue = (result as ivm.Reference).copy(); + } catch { + returnValue = undefined; + } + } else { + returnValue = result; + } + + return { + returnValue, + logMessages, + }; + } finally { + if (!isolate.isDisposed) { + isolate.dispose(); + } + } } } diff --git a/Common/package-lock.json b/Common/package-lock.json index 595fd63448..15afcc8baa 100644 --- a/Common/package-lock.json +++ b/Common/package-lock.json @@ -62,6 +62,7 @@ "formik": "^2.4.6", "history": "^5.3.0", "ioredis": "^5.3.2", + "isolated-vm": "^6.0.2", "json2csv": "^5.0.7", "json5": "^2.2.3", "jsonwebtoken": "^9.0.0", @@ -6414,6 +6415,55 @@ "node": ">=6.0.0" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bn.js": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", @@ -7102,6 +7152,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -8174,6 +8230,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -8214,6 +8285,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deepmerge": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", @@ -8373,7 +8453,6 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -8628,6 +8707,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/engine.io": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz", @@ -9005,6 +9093,15 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "28.1.3", "resolved": "https://registry.npmjs.org/expect/-/expect-28.1.3.tgz", @@ -9466,6 +9563,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "11.3.2", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", @@ -9609,6 +9712,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -10115,6 +10224,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -10650,6 +10765,19 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isolated-vm": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/isolated-vm/-/isolated-vm-6.0.2.tgz", + "integrity": "sha512-Qw6AJuagG/VJuh2AIcSWmQPsAArti/L+lKhjXU+lyhYkbt3J57XZr+ZjgfTnOr4NJcY1r3f8f0eePS7MRGp+pg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -13760,6 +13888,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -13818,6 +13958,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -13922,6 +14068,12 @@ "node": ">= 10.16.0" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -13948,6 +14100,18 @@ "tslib": "^2.0.3" } }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -14175,7 +14339,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -14762,6 +14925,32 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -14946,6 +15135,16 @@ "punycode": "^2.3.1" } }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -15166,6 +15365,30 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -16555,6 +16778,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -17522,6 +17790,48 @@ "url": "https://github.com/sponsors/dcastil" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tar-fs/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tar-stream": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", @@ -17821,6 +18131,18 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/twilio": { "version": "4.23.0", "resolved": "https://registry.npmjs.org/twilio/-/twilio-4.23.0.tgz", @@ -18891,7 +19213,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/Common/package.json b/Common/package.json index 1d723eda8e..459ced9c7d 100644 --- a/Common/package.json +++ b/Common/package.json @@ -101,6 +101,7 @@ "formik": "^2.4.6", "history": "^5.3.0", "ioredis": "^5.3.2", + "isolated-vm": "^6.0.2", "json2csv": "^5.0.7", "json5": "^2.2.3", "jsonwebtoken": "^9.0.0", diff --git a/HelmChart/Public/oneuptime/templates/_helpers.tpl b/HelmChart/Public/oneuptime/templates/_helpers.tpl index e6c71e49e0..dbd4a39676 100644 --- a/HelmChart/Public/oneuptime/templates/_helpers.tpl +++ b/HelmChart/Public/oneuptime/templates/_helpers.tpl @@ -198,6 +198,18 @@ Usage: {{- end }} {{- end }} +{{- define "oneuptime.env.registerProbeKey" }} +- name: REGISTER_PROBE_KEY + {{- if $.Values.registerProbeKey }} + value: {{ $.Values.registerProbeKey }} + {{- else }} + valueFrom: + secretKeyRef: + name: {{ printf "%s-%s" $.Release.Name "secrets" }} + key: register-probe-key + {{- end }} +{{- end }} + {{- define "oneuptime.env.runtime" }} - name: VAPID_PRIVATE_KEY diff --git a/HelmChart/Public/oneuptime/templates/probe-ingest.yaml b/HelmChart/Public/oneuptime/templates/probe-ingest.yaml index 24d3eb3c09..46001257c3 100644 --- a/HelmChart/Public/oneuptime/templates/probe-ingest.yaml +++ b/HelmChart/Public/oneuptime/templates/probe-ingest.yaml @@ -112,6 +112,7 @@ spec: value: {{ $.Values.probeIngest.disableTelemetryCollection | quote }} - name: PROBE_INGEST_CONCURRENCY value: {{ $.Values.probeIngest.concurrency | squote }} + {{- include "oneuptime.env.registerProbeKey" (dict "Values" $.Values "Release" $.Release) | nindent 12 }} ports: - containerPort: {{ $.Values.probeIngest.ports.http }} protocol: TCP diff --git a/HelmChart/Public/oneuptime/templates/probe.yaml b/HelmChart/Public/oneuptime/templates/probe.yaml index 8bd31ef9f1..3c5de0fb93 100644 --- a/HelmChart/Public/oneuptime/templates/probe.yaml +++ b/HelmChart/Public/oneuptime/templates/probe.yaml @@ -131,7 +131,7 @@ spec: - name: NO_PROXY value: {{ $val.proxy.noProxy | squote }} {{- end }} - {{- include "oneuptime.env.oneuptimeSecret" (dict "Values" $.Values "Release" $.Release) | nindent 12 }} + {{- include "oneuptime.env.registerProbeKey" (dict "Values" $.Values "Release" $.Release) | nindent 12 }} ports: - containerPort: {{ if and $val.ports $val.ports.http }}{{ $val.ports.http }}{{ else }}3874{{ end }} protocol: TCP diff --git a/HelmChart/Public/oneuptime/templates/secrets.yaml b/HelmChart/Public/oneuptime/templates/secrets.yaml index 38465a9db7..6cf517a168 100644 --- a/HelmChart/Public/oneuptime/templates/secrets.yaml +++ b/HelmChart/Public/oneuptime/templates/secrets.yaml @@ -17,6 +17,13 @@ stringData: {{- else }} oneuptime-secret: {{ index (lookup "v1" "Secret" $.Release.Namespace (printf "%s-secrets" $.Release.Name)).data "oneuptime-secret" | b64dec }} {{- end }} + {{- if .Values.registerProbeKey }} + register-probe-key: {{ .Values.registerProbeKey | quote }} + {{- else if (index (lookup "v1" "Secret" $.Release.Namespace (printf "%s-secrets" $.Release.Name)).data "register-probe-key") }} + register-probe-key: {{ index (lookup "v1" "Secret" $.Release.Namespace (printf "%s-secrets" $.Release.Name)).data "register-probe-key" | b64dec }} + {{- else }} + register-probe-key: {{ randAlphaNum 32 | quote }} + {{- end }} {{- if .Values.encryptionSecret }} encryption-secret: {{ .Values.encryptionSecret | quote }} {{- else }} @@ -48,6 +55,11 @@ stringData: {{- else }} oneuptime-secret: {{ randAlphaNum 32 | quote }} {{- end }} + {{- if .Values.registerProbeKey }} + register-probe-key: {{ .Values.registerProbeKey | quote }} + {{- else }} + register-probe-key: {{ randAlphaNum 32 | quote }} + {{- end }} {{- if .Values.encryptionSecret }} encryption-secret: {{ .Values.encryptionSecret | quote }} {{- else }} diff --git a/HelmChart/Public/oneuptime/values.yaml b/HelmChart/Public/oneuptime/values.yaml index 26de1ad81b..296804e6d3 100644 --- a/HelmChart/Public/oneuptime/values.yaml +++ b/HelmChart/Public/oneuptime/values.yaml @@ -32,6 +32,7 @@ image: # Important: You do need to set this to a long random values if you're using OneUptime in production. # Please set this to string. oneuptimeSecret: +registerProbeKey: encryptionSecret: # External Secrets diff --git a/Probe/Services/Register.ts b/Probe/Services/Register.ts index fa25b40fbd..56de930094 100644 --- a/Probe/Services/Register.ts +++ b/Probe/Services/Register.ts @@ -15,10 +15,12 @@ import { JSONObject } from "Common/Types/JSON"; import ProbeStatusReport from "Common/Types/Probe/ProbeStatusReport"; import Sleep from "Common/Types/Sleep"; import API from "Common/Utils/API"; -import { HasClusterKey } from "Common/Server/EnvironmentConfig"; +import { + HasRegisterProbeKey, + RegisterProbeKey, +} from "Common/Server/EnvironmentConfig"; import LocalCache from "Common/Server/Infrastructure/LocalCache"; import logger from "Common/Server/Utils/Logger"; -import ClusterKeyAuthorization from "Common/Server/Middleware/ClusterKeyAuthorization"; import ProxyConfig from "../Utils/ProxyConfig"; export default class Register { @@ -117,7 +119,7 @@ export default class Register { } private static async _registerProbe(): Promise { - if (HasClusterKey) { + if (HasRegisterProbeKey) { const probeRegistrationUrl: URL = URL.fromString( PROBE_INGEST_URL.toString(), ).addRoute("/register"); @@ -131,7 +133,7 @@ export default class Register { probeKey: PROBE_KEY, probeName: PROBE_NAME, probeDescription: PROBE_DESCRIPTION, - clusterKey: ClusterKeyAuthorization.getClusterKey(), + registerProbeKey: RegisterProbeKey.toString(), }, options: { ...ProxyConfig.getRequestProxyAgents(probeRegistrationUrl), @@ -149,7 +151,7 @@ export default class Register { } else { // validate probe. if (!PROBE_ID) { - logger.error("PROBE_ID or ONEUPTIME_SECRET should be set"); + logger.error("PROBE_ID or REGISTER_PROBE_KEY should be set"); return process.exit(); } diff --git a/Probe/Utils/Monitors/MonitorTypes/SyntheticMonitor.ts b/Probe/Utils/Monitors/MonitorTypes/SyntheticMonitor.ts index a85629fc50..67f790debf 100644 --- a/Probe/Utils/Monitors/MonitorTypes/SyntheticMonitor.ts +++ b/Probe/Utils/Monitors/MonitorTypes/SyntheticMonitor.ts @@ -1,16 +1,12 @@ import { PROBE_SYNTHETIC_MONITOR_SCRIPT_TIMEOUT_IN_MS } from "../../../Config"; import ProxyConfig from "../../ProxyConfig"; -import BadDataException from "Common/Types/Exception/BadDataException"; -import ReturnResult from "Common/Types/IsolatedVM/ReturnResult"; import BrowserType from "Common/Types/Monitor/SyntheticMonitors/BrowserType"; import ScreenSizeType from "Common/Types/Monitor/SyntheticMonitors/ScreenSizeType"; import SyntheticMonitorResponse from "Common/Types/Monitor/SyntheticMonitors/SyntheticMonitorResponse"; import ObjectID from "Common/Types/ObjectID"; import logger from "Common/Server/Utils/Logger"; -import VMRunner from "Common/Server/Utils/VM/VMRunner"; -import { Browser, BrowserContext, Page, chromium, firefox } from "playwright"; -import LocalFile from "Common/Server/Utils/LocalFile"; -import os from "os"; +import { ChildProcess, fork } from "child_process"; +import path from "path"; export interface SyntheticMonitorOptions { monitorId?: ObjectID | undefined; @@ -20,24 +16,24 @@ export interface SyntheticMonitorOptions { retryCountOnError?: number | undefined; } -interface BrowserLaunchOptions { - executablePath?: string; +interface WorkerConfig { + script: string; + browserType: BrowserType; + screenSizeType: ScreenSizeType; + timeout: number; proxy?: { server: string; - username?: string; - password?: string; - bypass?: string; - }; - args?: string[]; - headless?: boolean; - devtools?: boolean; - timeout?: number; + username?: string | undefined; + password?: string | undefined; + } | undefined; } -interface BrowserSession { - browser: Browser; - context: BrowserContext; - page: Page; +interface WorkerResult { + logMessages: string[]; + scriptError?: string | undefined; + result?: unknown | undefined; + screenshots: Record; + executionTimeInMS: number; } export default class SyntheticMonitor { @@ -111,13 +107,70 @@ export default class SyntheticMonitor { return result; } + private static getSanitizedEnv(): Record { + // Only pass safe environment variables to the worker process. + // Explicitly exclude all secrets (DATABASE_PASSWORD, REDIS_PASSWORD, + // CLICKHOUSE_PASSWORD, ONEUPTIME_SECRET, ENCRYPTION_SECRET, BILLING_PRIVATE_KEY, etc.) + const safeKeys: string[] = [ + "PATH", + "HOME", + "NODE_ENV", + "PLAYWRIGHT_BROWSERS_PATH", + "HTTP_PROXY_URL", + "http_proxy", + "HTTPS_PROXY_URL", + "https_proxy", + "NO_PROXY", + "no_proxy", + ]; + + const env: Record = {}; + + for (const key of safeKeys) { + if (process.env[key]) { + env[key] = process.env[key]!; + } + } + + return env; + } + + private static getProxyConfig(): WorkerConfig["proxy"] | undefined { + if (!ProxyConfig.isProxyConfigured()) { + return undefined; + } + + const httpsProxyUrl: string | null = ProxyConfig.getHttpsProxyUrl(); + const httpProxyUrl: string | null = ProxyConfig.getHttpProxyUrl(); + const proxyUrl: string | null = httpsProxyUrl || httpProxyUrl; + + if (!proxyUrl) { + return undefined; + } + + const proxyConfig: WorkerConfig["proxy"] = { + server: proxyUrl, + }; + + try { + const parsedUrl: globalThis.URL = new URL(proxyUrl); + if (parsedUrl.username && parsedUrl.password) { + proxyConfig.username = parsedUrl.username; + proxyConfig.password = parsedUrl.password; + } + } catch (error) { + logger.warn(`Failed to parse proxy URL for authentication: ${error}`); + } + + return proxyConfig; + } + private static async executeByBrowserAndScreenSize(options: { script: string; browserType: BrowserType; screenSizeType: ScreenSizeType; }): Promise { if (!options) { - // this should never happen options = { script: "", browserType: BrowserType.Chromium, @@ -135,385 +188,116 @@ export default class SyntheticMonitor { screenSizeType: options.screenSizeType, }; - let browserSession: BrowserSession | null = null; + const timeout: number = PROBE_SYNTHETIC_MONITOR_SCRIPT_TIMEOUT_IN_MS; + + const workerConfig: WorkerConfig = { + script: options.script, + browserType: options.browserType, + screenSizeType: options.screenSizeType, + timeout: timeout, + proxy: this.getProxyConfig(), + }; try { - let result: ReturnResult | null = null; - - const startTime: [number, number] = process.hrtime(); - - browserSession = await SyntheticMonitor.getPageByBrowserType({ - browserType: options.browserType, - screenSizeType: options.screenSizeType, - }); - - if (!browserSession) { - throw new BadDataException( - "Could not create Playwright browser session", - ); - } - - result = await VMRunner.runCodeInSandbox({ - code: options.script, - options: { - timeout: PROBE_SYNTHETIC_MONITOR_SCRIPT_TIMEOUT_IN_MS, - args: {}, - context: { - browser: browserSession.browser, - page: browserSession.page, - screenSizeType: options.screenSizeType, - browserType: options.browserType, - }, - }, - }); - - const endTime: [number, number] = process.hrtime(startTime); - - const executionTimeInMS: number = Math.ceil( - (endTime[0] * 1000000000 + endTime[1]) / 1000000, + const workerResult: WorkerResult = await this.forkWorker( + workerConfig, + timeout, ); - scriptResult.executionTimeInMS = executionTimeInMS; - - scriptResult.logMessages = result.logMessages; - - if (result.returnValue?.screenshots) { - if (!scriptResult.screenshots) { - scriptResult.screenshots = {}; - } - - for (const screenshotName in result.returnValue.screenshots) { - if (!result.returnValue.screenshots[screenshotName]) { - continue; - } - - // check if this is of type Buffer. If it is not, continue. - - if ( - !(result.returnValue.screenshots[screenshotName] instanceof Buffer) - ) { - continue; - } - - const screenshotBuffer: Buffer = result.returnValue.screenshots[ - screenshotName - ] as Buffer; - scriptResult.screenshots[screenshotName] = - screenshotBuffer.toString("base64"); // convert screenshots to base 64 - } - } - - scriptResult.result = result?.returnValue?.data; + scriptResult.logMessages = workerResult.logMessages; + scriptResult.scriptError = workerResult.scriptError; + scriptResult.result = workerResult.result as typeof scriptResult.result; + scriptResult.screenshots = workerResult.screenshots; + scriptResult.executionTimeInMS = workerResult.executionTimeInMS; } catch (err: unknown) { logger.error(err); scriptResult.scriptError = (err as Error)?.message || (err as Error).toString(); - } finally { - // Always dispose browser session to prevent zombie processes - await SyntheticMonitor.disposeBrowserSession(browserSession); } return scriptResult; } - private static getViewportHeightAndWidth(options: { - screenSizeType: ScreenSizeType; - }): { - height: number; - width: number; - } { - let viewPortHeight: number = 0; - let viewPortWidth: number = 0; - - switch (options.screenSizeType) { - case ScreenSizeType.Desktop: - viewPortHeight = 1080; - viewPortWidth = 1920; - break; - case ScreenSizeType.Mobile: - viewPortHeight = 640; - viewPortWidth = 360; - break; - case ScreenSizeType.Tablet: - viewPortHeight = 768; - viewPortWidth = 1024; - break; - default: - viewPortHeight = 1080; - viewPortWidth = 1920; - break; - } - - return { height: viewPortHeight, width: viewPortWidth }; - } - - private static getPlaywrightBrowsersPath(): string { - return ( - process.env["PLAYWRIGHT_BROWSERS_PATH"] || - `${os.homedir()}/.cache/ms-playwright` - ); - } - - public static async getChromeExecutablePath(): Promise { - const browsersPath: string = this.getPlaywrightBrowsersPath(); - - const doesDirectoryExist: boolean = - await LocalFile.doesDirectoryExist(browsersPath); - if (!doesDirectoryExist) { - throw new BadDataException("Chrome executable path not found."); - } - - // get list of files in the directory - const directories: string[] = - await LocalFile.getListOfDirectories(browsersPath); - - if (directories.length === 0) { - throw new BadDataException("Chrome executable path not found."); - } - - const chromeInstallationName: string | undefined = directories.find( - (directory: string) => { - return directory.includes("chromium"); - }, - ); - - if (!chromeInstallationName) { - throw new BadDataException("Chrome executable path not found."); - } - - const chromeExecutableCandidates: Array = [ - `${browsersPath}/${chromeInstallationName}/chrome-linux/chrome`, - `${browsersPath}/${chromeInstallationName}/chrome-linux64/chrome`, - `${browsersPath}/${chromeInstallationName}/chrome64/chrome`, - `${browsersPath}/${chromeInstallationName}/chrome/chrome`, - ]; - - for (const executablePath of chromeExecutableCandidates) { - if (await LocalFile.doesFileExist(executablePath)) { - return executablePath; - } - } - - throw new BadDataException("Chrome executable path not found."); - } - - public static async getFirefoxExecutablePath(): Promise { - const browsersPath: string = this.getPlaywrightBrowsersPath(); - - const doesDirectoryExist: boolean = - await LocalFile.doesDirectoryExist(browsersPath); - if (!doesDirectoryExist) { - throw new BadDataException("Firefox executable path not found."); - } - - // get list of files in the directory - const directories: string[] = - await LocalFile.getListOfDirectories(browsersPath); - - if (directories.length === 0) { - throw new BadDataException("Firefox executable path not found."); - } - - const firefoxInstallationName: string | undefined = directories.find( - (directory: string) => { - return directory.includes("firefox"); - }, - ); - - if (!firefoxInstallationName) { - throw new BadDataException("Firefox executable path not found."); - } - - const firefoxExecutableCandidates: Array = [ - `${browsersPath}/${firefoxInstallationName}/firefox/firefox`, - `${browsersPath}/${firefoxInstallationName}/firefox-linux64/firefox`, - `${browsersPath}/${firefoxInstallationName}/firefox64/firefox`, - `${browsersPath}/${firefoxInstallationName}/firefox-64/firefox`, - ]; - - for (const executablePath of firefoxExecutableCandidates) { - if (await LocalFile.doesFileExist(executablePath)) { - return executablePath; - } - } - - throw new BadDataException("Firefox executable path not found."); - } - - private static async getPageByBrowserType(data: { - browserType: BrowserType; - screenSizeType: ScreenSizeType; - }): Promise { - const viewport: { - height: number; - width: number; - } = SyntheticMonitor.getViewportHeightAndWidth({ - screenSizeType: data.screenSizeType, - }); - - // Prepare browser launch options with proxy support - const baseOptions: BrowserLaunchOptions = {}; - - // Configure proxy if available - if (ProxyConfig.isProxyConfigured()) { - const httpsProxyUrl: string | null = ProxyConfig.getHttpsProxyUrl(); - const httpProxyUrl: string | null = ProxyConfig.getHttpProxyUrl(); - - // Prefer HTTPS proxy, fall back to HTTP proxy - const proxyUrl: string | null = httpsProxyUrl || httpProxyUrl; - - if (proxyUrl) { - baseOptions.proxy = { - server: proxyUrl, - }; - - // Extract username and password if present in proxy URL - try { - const parsedUrl: globalThis.URL = new URL(proxyUrl); - if (parsedUrl.username && parsedUrl.password) { - baseOptions.proxy.username = parsedUrl.username; - baseOptions.proxy.password = parsedUrl.password; - } - } catch (error) { - logger.warn(`Failed to parse proxy URL for authentication: ${error}`); - } - - logger.debug( - `Synthetic Monitor using proxy: ${proxyUrl} (HTTPS: ${Boolean(httpsProxyUrl)}, HTTP: ${Boolean(httpProxyUrl)})`, + private static async forkWorker( + config: WorkerConfig, + timeout: number, + ): Promise { + return new Promise( + ( + resolve: (value: WorkerResult) => void, + reject: (reason: Error) => void, + ) => { + // The worker file path. At runtime the compiled JS will be at the same + // relative location under the build output directory. + const workerPath: string = path.resolve( + __dirname, + "SyntheticMonitorWorker", ); - } - } - if (data.browserType === BrowserType.Chromium) { - const browser: Browser = await chromium.launch({ - executablePath: await this.getChromeExecutablePath(), - ...baseOptions, - }); - - const context: BrowserContext = await browser.newContext({ - viewport: { - width: viewport.width, - height: viewport.height, - }, - }); - - const page: Page = await context.newPage(); - - return { - browser, - context, - page, - }; - } - - if (data.browserType === BrowserType.Firefox) { - const browser: Browser = await firefox.launch({ - executablePath: await this.getFirefoxExecutablePath(), - ...baseOptions, - }); - - let context: BrowserContext | null = null; - - try { - context = await browser.newContext({ - viewport: { - width: viewport.width, - height: viewport.height, - }, + const child: ChildProcess = fork(workerPath, [], { + env: this.getSanitizedEnv(), + timeout: timeout + 30000, // fork-level timeout: script timeout + 30s for browser startup/shutdown + stdio: ["pipe", "pipe", "pipe", "ipc"], }); - const page: Page = await context.newPage(); + let resolved: boolean = false; - return { - browser, - context, - page, - }; - } catch (error) { - await SyntheticMonitor.safeCloseBrowserContext(context); - await SyntheticMonitor.safeCloseBrowser(browser); - throw error; - } - } + // Explicit kill timer as final safety net + const killTimer: ReturnType = global.setTimeout( + () => { + if (!resolved) { + resolved = true; + child.kill("SIGKILL"); + reject( + new Error( + "Synthetic monitor worker killed after timeout", + ), + ); + } + }, + timeout + 60000, // kill after script timeout + 60s + ); - throw new BadDataException("Invalid Browser Type."); - } + child.on("message", (result: WorkerResult) => { + if (!resolved) { + resolved = true; + global.clearTimeout(killTimer); + resolve(result); + } + }); - private static async disposeBrowserSession( - session: BrowserSession | null, - ): Promise { - if (!session) { - return; - } + child.on("error", (err: Error) => { + if (!resolved) { + resolved = true; + global.clearTimeout(killTimer); + reject(err); + } + }); - await SyntheticMonitor.safeClosePage(session.page); - await SyntheticMonitor.safeCloseBrowserContexts({ - browser: session.browser, - }); - await SyntheticMonitor.safeCloseBrowser(session.browser); - } + child.on("exit", (exitCode: number | null) => { + if (!resolved) { + resolved = true; + global.clearTimeout(killTimer); + if (exitCode !== 0) { + reject( + new Error( + `Synthetic monitor worker exited with code ${exitCode}`, + ), + ); + } else { + // Worker exited cleanly but didn't send a message — shouldn't happen + reject( + new Error( + "Synthetic monitor worker exited without sending results", + ), + ); + } + } + }); - private static async safeClosePage(page?: Page | null): Promise { - if (!page) { - return; - } - - try { - if (!page.isClosed()) { - await page.close(); - } - } catch (error) { - logger.warn( - `Failed to close Playwright page: ${(error as Error)?.message || error}`, - ); - } - } - - private static async safeCloseBrowserContext( - context?: BrowserContext | null, - ): Promise { - if (!context) { - return; - } - - try { - await context.close(); - } catch (error) { - logger.warn( - `Failed to close Playwright browser context: ${(error as Error)?.message || error}`, - ); - } - } - - private static async safeCloseBrowser( - browser?: Browser | null, - ): Promise { - if (!browser) { - return; - } - - try { - if (browser.isConnected()) { - await browser.close(); - } - } catch (error) { - logger.warn( - `Failed to close Playwright browser: ${(error as Error)?.message || error}`, - ); - } - } - - private static async safeCloseBrowserContexts(data: { - browser: Browser; - }): Promise { - if (!data.browser || !data.browser.contexts) { - return; - } - - const contexts: Array = data.browser.contexts(); - - for (const context of contexts) { - await SyntheticMonitor.safeCloseBrowserContext(context); - } + // Send config to worker via IPC + child.send(config); + }, + ); } } diff --git a/Probe/Utils/Monitors/MonitorTypes/SyntheticMonitorWorker.ts b/Probe/Utils/Monitors/MonitorTypes/SyntheticMonitorWorker.ts new file mode 100644 index 0000000000..7e61eb7dae --- /dev/null +++ b/Probe/Utils/Monitors/MonitorTypes/SyntheticMonitorWorker.ts @@ -0,0 +1,339 @@ +// This script is executed via child_process.fork() with a sanitized environment +// It launches Playwright, runs user code with node:vm (safe because env is stripped), +// and sends results back via IPC. + +import BrowserType from "Common/Types/Monitor/SyntheticMonitors/BrowserType"; +import ScreenSizeType from "Common/Types/Monitor/SyntheticMonitors/ScreenSizeType"; +import vm, { Context } from "node:vm"; +import { Browser, BrowserContext, Page, chromium, firefox } from "playwright"; +import LocalFile from "Common/Server/Utils/LocalFile"; +import os from "os"; + +interface WorkerConfig { + script: string; + browserType: BrowserType; + screenSizeType: ScreenSizeType; + timeout: number; + proxy?: { + server: string; + username?: string | undefined; + password?: string | undefined; + } | undefined; +} + +interface WorkerResult { + logMessages: string[]; + scriptError?: string | undefined; + result?: unknown | undefined; + screenshots: Record; + executionTimeInMS: number; +} + +interface ProxyOptions { + server: string; + username?: string | undefined; + password?: string | undefined; +} + +function getViewportHeightAndWidth(screenSizeType: ScreenSizeType): { + height: number; + width: number; +} { + switch (screenSizeType) { + case ScreenSizeType.Desktop: + return { height: 1080, width: 1920 }; + case ScreenSizeType.Mobile: + return { height: 640, width: 360 }; + case ScreenSizeType.Tablet: + return { height: 768, width: 1024 }; + default: + return { height: 1080, width: 1920 }; + } +} + +function getPlaywrightBrowsersPath(): string { + return ( + process.env["PLAYWRIGHT_BROWSERS_PATH"] || + `${os.homedir()}/.cache/ms-playwright` + ); +} + +async function getChromeExecutablePath(): Promise { + const browsersPath: string = getPlaywrightBrowsersPath(); + + const doesDirectoryExist: boolean = + await LocalFile.doesDirectoryExist(browsersPath); + if (!doesDirectoryExist) { + throw new Error("Chrome executable path not found."); + } + + const directories: string[] = + await LocalFile.getListOfDirectories(browsersPath); + + if (directories.length === 0) { + throw new Error("Chrome executable path not found."); + } + + const chromeInstallationName: string | undefined = directories.find( + (directory: string) => { + return directory.includes("chromium"); + }, + ); + + if (!chromeInstallationName) { + throw new Error("Chrome executable path not found."); + } + + const candidates: Array = [ + `${browsersPath}/${chromeInstallationName}/chrome-linux/chrome`, + `${browsersPath}/${chromeInstallationName}/chrome-linux64/chrome`, + `${browsersPath}/${chromeInstallationName}/chrome64/chrome`, + `${browsersPath}/${chromeInstallationName}/chrome/chrome`, + ]; + + for (const executablePath of candidates) { + if (await LocalFile.doesFileExist(executablePath)) { + return executablePath; + } + } + + throw new Error("Chrome executable path not found."); +} + +async function getFirefoxExecutablePath(): Promise { + const browsersPath: string = getPlaywrightBrowsersPath(); + + const doesDirectoryExist: boolean = + await LocalFile.doesDirectoryExist(browsersPath); + if (!doesDirectoryExist) { + throw new Error("Firefox executable path not found."); + } + + const directories: string[] = + await LocalFile.getListOfDirectories(browsersPath); + + if (directories.length === 0) { + throw new Error("Firefox executable path not found."); + } + + const firefoxInstallationName: string | undefined = directories.find( + (directory: string) => { + return directory.includes("firefox"); + }, + ); + + if (!firefoxInstallationName) { + throw new Error("Firefox executable path not found."); + } + + const candidates: Array = [ + `${browsersPath}/${firefoxInstallationName}/firefox/firefox`, + `${browsersPath}/${firefoxInstallationName}/firefox-linux64/firefox`, + `${browsersPath}/${firefoxInstallationName}/firefox64/firefox`, + `${browsersPath}/${firefoxInstallationName}/firefox-64/firefox`, + ]; + + for (const executablePath of candidates) { + if (await LocalFile.doesFileExist(executablePath)) { + return executablePath; + } + } + + throw new Error("Firefox executable path not found."); +} + +async function launchBrowser( + config: WorkerConfig, +): Promise<{ browser: Browser; context: BrowserContext; page: Page }> { + const viewport: { height: number; width: number } = + getViewportHeightAndWidth(config.screenSizeType); + + let proxyOptions: ProxyOptions | undefined; + + if (config.proxy) { + proxyOptions = { + server: config.proxy.server, + }; + + if (config.proxy.username && config.proxy.password) { + proxyOptions.username = config.proxy.username; + proxyOptions.password = config.proxy.password; + } + } + + let browser: Browser; + + if (config.browserType === BrowserType.Chromium) { + const launchOptions: Record = { + executablePath: await getChromeExecutablePath(), + }; + + if (proxyOptions) { + launchOptions["proxy"] = proxyOptions; + } + + browser = await chromium.launch(launchOptions); + } else if (config.browserType === BrowserType.Firefox) { + const launchOptions: Record = { + executablePath: await getFirefoxExecutablePath(), + }; + + if (proxyOptions) { + launchOptions["proxy"] = proxyOptions; + } + + browser = await firefox.launch(launchOptions); + } else { + throw new Error("Invalid Browser Type."); + } + + const context: BrowserContext = await browser.newContext({ + viewport: { + width: viewport.width, + height: viewport.height, + }, + }); + + const page: Page = await context.newPage(); + + return { browser, context, page }; +} + +async function run(config: WorkerConfig): Promise { + const workerResult: WorkerResult = { + logMessages: [], + scriptError: undefined, + result: undefined, + screenshots: {}, + executionTimeInMS: 0, + }; + + let browser: Browser | null = null; + + try { + const startTime: [number, number] = process.hrtime(); + + const session: { browser: Browser; context: BrowserContext; page: Page } = + await launchBrowser(config); + + browser = session.browser; + + const logMessages: string[] = []; + + const sandbox: Context = { + console: { + log: (...args: unknown[]) => { + logMessages.push( + args + .map((v: unknown) => { + return typeof v === "object" ? JSON.stringify(v) : String(v); + }) + .join(" "), + ); + }, + }, + browser: session.browser, + page: session.page, + screenSizeType: config.screenSizeType, + browserType: config.browserType, + setTimeout: setTimeout, + clearTimeout: clearTimeout, + setInterval: setInterval, + }; + + vm.createContext(sandbox); + + const script: string = `(async()=>{ + ${config.script} + })()`; + + const returnVal: unknown = await vm.runInContext(script, sandbox, { + timeout: config.timeout, + }); + + const endTime: [number, number] = process.hrtime(startTime); + const executionTimeInMS: number = Math.ceil( + (endTime[0] * 1000000000 + endTime[1]) / 1000000, + ); + + workerResult.executionTimeInMS = executionTimeInMS; + workerResult.logMessages = logMessages; + + // Convert screenshots from Buffer to base64 + const returnObj: Record = + returnVal && typeof returnVal === "object" + ? (returnVal as Record) + : {}; + + if (returnObj["screenshots"]) { + const screenshots: Record = returnObj[ + "screenshots" + ] as Record; + + for (const screenshotName in screenshots) { + if (!screenshots[screenshotName]) { + continue; + } + + if (!(screenshots[screenshotName] instanceof Buffer)) { + continue; + } + + const screenshotBuffer: Buffer = screenshots[ + screenshotName + ] as Buffer; + workerResult.screenshots[screenshotName] = + screenshotBuffer.toString("base64"); + } + } + + workerResult.result = returnObj["data"]; + } catch (err: unknown) { + workerResult.scriptError = + (err as Error)?.message || String(err); + } finally { + // Close browser + if (browser) { + try { + const contexts: Array = browser.contexts(); + for (const ctx of contexts) { + try { + await ctx.close(); + } catch (_e: unknown) { + // ignore + } + } + if (browser.isConnected()) { + await browser.close(); + } + } catch (_e: unknown) { + // ignore cleanup errors + } + } + } + + return workerResult; +} + +// Entry point: receive config via IPC message +process.on("message", (config: WorkerConfig) => { + run(config) + .then((result: WorkerResult) => { + if (process.send) { + process.send(result); + } + process.exit(0); + }) + .catch((err: unknown) => { + if (process.send) { + process.send({ + logMessages: [], + scriptError: (err as Error)?.message || String(err), + result: undefined, + screenshots: {}, + executionTimeInMS: 0, + } as WorkerResult); + } + process.exit(1); + }); +}); diff --git a/ProbeIngest/API/Register.ts b/ProbeIngest/API/Register.ts index 22c1c6eabd..4b67e334fa 100644 --- a/ProbeIngest/API/Register.ts +++ b/ProbeIngest/API/Register.ts @@ -1,7 +1,7 @@ import OneUptimeDate from "Common/Types/Date"; import BadDataException from "Common/Types/Exception/BadDataException"; import { JSONObject } from "Common/Types/JSON"; -import ClusterKeyAuthorization from "Common/Server/Middleware/ClusterKeyAuthorization"; +import { RegisterProbeKey } from "Common/Server/EnvironmentConfig"; import ProbeService from "Common/Server/Services/ProbeService"; import Express, { ExpressRequest, @@ -19,7 +19,6 @@ const router: ExpressRouter = Express.getRouter(); // Register Global Probe. Custom Probe can be registered via dashboard. router.post( "/register", - ClusterKeyAuthorization.isAuthorizedServiceMiddleware, async ( req: ExpressRequest, res: ExpressResponse, @@ -28,6 +27,23 @@ router.post( try { const data: JSONObject = req.body; + const registerProbeKey: string | undefined = data[ + "registerProbeKey" + ] as string; + + if ( + !registerProbeKey || + registerProbeKey !== RegisterProbeKey.toString() + ) { + return Response.sendErrorResponse( + req, + res, + new BadDataException( + "Invalid or missing registerProbeKey. Please check REGISTER_PROBE_KEY environment variable.", + ), + ); + } + if (!data["probeKey"]) { return Response.sendErrorResponse( req, diff --git a/config.example.env b/config.example.env index b7bee145e7..d8c6a1d0a9 100644 --- a/config.example.env +++ b/config.example.env @@ -22,6 +22,7 @@ CAPTCHA_SECRET_KEY= # Secrets - PLEASE CHANGE THESE. Please change these to something random. All of these can be different values. ONEUPTIME_SECRET=please-change-this-to-random-value +REGISTER_PROBE_KEY=please-change-this-to-random-value DATABASE_PASSWORD=please-change-this-to-random-value CLICKHOUSE_PASSWORD=please-change-this-to-random-value REDIS_PASSWORD=please-change-this-to-random-value diff --git a/docker-compose.base.yml b/docker-compose.base.yml index 482a6cd757..b7212c9cb7 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -400,7 +400,7 @@ services: network_mode: host environment: ONEUPTIME_URL: ${GLOBAL_PROBE_1_ONEUPTIME_URL} - ONEUPTIME_SECRET: ${ONEUPTIME_SECRET} + REGISTER_PROBE_KEY: ${REGISTER_PROBE_KEY} PROBE_NAME: ${GLOBAL_PROBE_1_NAME} PROBE_DESCRIPTION: ${GLOBAL_PROBE_1_DESCRIPTION} PROBE_MONITORING_WORKERS: ${GLOBAL_PROBE_1_MONITORING_WORKERS} @@ -424,7 +424,7 @@ services: network_mode: host environment: ONEUPTIME_URL: ${GLOBAL_PROBE_2_ONEUPTIME_URL} - ONEUPTIME_SECRET: ${ONEUPTIME_SECRET} + REGISTER_PROBE_KEY: ${REGISTER_PROBE_KEY} PROBE_NAME: ${GLOBAL_PROBE_2_NAME} PROBE_DESCRIPTION: ${GLOBAL_PROBE_2_DESCRIPTION} PROBE_MONITORING_WORKERS: ${GLOBAL_PROBE_2_MONITORING_WORKERS} @@ -515,6 +515,7 @@ services: PORT: ${PROBE_INGEST_PORT} DISABLE_TELEMETRY: ${DISABLE_TELEMETRY_FOR_PROBE_INGEST} PROBE_INGEST_CONCURRENCY: ${PROBE_INGEST_CONCURRENCY} + REGISTER_PROBE_KEY: ${REGISTER_PROBE_KEY} logging: driver: "local" options: