feat: refactor SyntheticMonitor to use child processes for script execution

- Added isolated-vm dependency for secure script execution.
- Replaced direct Playwright usage in SyntheticMonitor with a worker process.
- Created SyntheticMonitorWorker to handle script execution in a sandboxed environment.
- Implemented proxy configuration handling for worker processes.
- Enhanced error handling and logging for script execution results.
- Removed unnecessary browser session management from SyntheticMonitor.
This commit is contained in:
Nawaz Dhandala
2026-02-18 14:14:24 +00:00
parent d62816dd49
commit 7f9ed4d439
15 changed files with 1105 additions and 428 deletions

View File

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

View File

@@ -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<GenericObject | string> | undefined;
};
}): Promise<ReturnResult> {
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<Record<string, unknown>> = 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<string>
> = new ivm.Reference(
async (
method: string,
url: string,
dataOrConfig?: string,
): Promise<string> => {
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<void>> =
new ivm.Reference((ms: number): Promise<void> => {
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<unknown> = context.eval(wrappedCode, {
promise: true,
timeout: timeout,
});
return {
returnValue: returnVal,
logMessages,
};
const overallTimeout: Promise<never> = 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<unknown>).copy();
} catch {
returnValue = undefined;
}
} else {
returnValue = result;
}
return {
returnValue,
logMessages,
};
} finally {
if (!isolate.isDisposed) {
isolate.dispose();
}
}
}
}

327
Common/package-lock.json generated
View File

@@ -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": {

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<void> {
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();
}

View File

@@ -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<string, string>;
executionTimeInMS: number;
}
export default class SyntheticMonitor {
@@ -111,13 +107,70 @@ export default class SyntheticMonitor {
return result;
}
private static getSanitizedEnv(): Record<string, string> {
// 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<string, string> = {};
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<SyntheticMonitorResponse | null> {
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<string> {
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<string> = [
`${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<string> {
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<string> = [
`${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<BrowserSession> {
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<WorkerResult> {
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<typeof setTimeout> = 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
if (!data.browser || !data.browser.contexts) {
return;
}
const contexts: Array<BrowserContext> = data.browser.contexts();
for (const context of contexts) {
await SyntheticMonitor.safeCloseBrowserContext(context);
}
// Send config to worker via IPC
child.send(config);
},
);
}
}

View File

@@ -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<string, string>;
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<string> {
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<string> = [
`${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<string> {
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<string> = [
`${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<string, unknown> = {
executablePath: await getChromeExecutablePath(),
};
if (proxyOptions) {
launchOptions["proxy"] = proxyOptions;
}
browser = await chromium.launch(launchOptions);
} else if (config.browserType === BrowserType.Firefox) {
const launchOptions: Record<string, unknown> = {
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<WorkerResult> {
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<string, unknown> =
returnVal && typeof returnVal === "object"
? (returnVal as Record<string, unknown>)
: {};
if (returnObj["screenshots"]) {
const screenshots: Record<string, unknown> = returnObj[
"screenshots"
] as Record<string, unknown>;
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<BrowserContext> = 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);
});
});

View File

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

View File

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

View File

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