From 8dfa6791277043f39930acd94688d31db531f096 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Mon, 1 Jan 2024 04:26:57 +0800 Subject: [PATCH] feat: add custom script monitor provider --- package.json | 1 + pnpm-lock.yaml | 95 +++++++++- .../components/monitor/provider/custom.tsx | 24 +++ .../components/monitor/provider/index.ts | 2 + src/server/model/monitor/provider/custom.ts | 46 +++++ src/server/model/monitor/provider/index.ts | 2 + src/server/utils/env.ts | 3 + src/server/utils/sandbox.ts | 174 ++++++++++++++++++ src/server/ws/index.ts | 2 +- 9 files changed, 339 insertions(+), 10 deletions(-) create mode 100644 src/client/components/monitor/provider/custom.tsx create mode 100644 src/server/model/monitor/provider/custom.ts create mode 100644 src/server/utils/sandbox.ts diff --git a/package.json b/package.json index a8c0d88..ee0e8ec 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "filesize": "^10.0.12", "fs-extra": "^11.1.1", "is-localhost-ip": "^2.0.0", + "isolated-vm": "^4.6.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "lodash-es": "^4.17.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd51f15..ddf6c1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,9 @@ dependencies: is-localhost-ip: specifier: ^2.0.0 version: 2.0.0 + isolated-vm: + specifier: ^4.6.0 + version: 4.6.0 jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 @@ -3184,7 +3187,6 @@ packages: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 - dev: true /bl@5.1.0: resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} @@ -3430,6 +3432,10 @@ packages: fsevents: 2.3.3 dev: true + /chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + dev: false + /chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} @@ -4181,7 +4187,6 @@ packages: engines: {node: '>=10'} dependencies: mimic-response: 3.1.0 - dev: true /deep-equal@1.1.1: resolution: {integrity: sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==} @@ -4197,7 +4202,6 @@ packages: /deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} - dev: true /default-browser-id@3.0.0: resolution: {integrity: sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==} @@ -4302,6 +4306,11 @@ packages: resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==} dev: false + /detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + dev: false + /devtools-protocol@0.0.1179426: resolution: {integrity: sha512-KKC7IGwdOr7u9kTGgjUvGTov/z1s2H7oHi3zKCdR9eSDyCPia5CBi4aRhtp7d8uR7l0GS5UTDw3TjKGu5CqINg==} dev: false @@ -4683,6 +4692,11 @@ packages: strip-final-newline: 3.0.0 dev: true + /expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + dev: false + /express-async-errors@3.1.1(express@4.18.2): resolution: {integrity: sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==} peerDependencies: @@ -4990,7 +5004,6 @@ packages: /fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - dev: true /fs-extra@11.1.1: resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==} @@ -5135,6 +5148,10 @@ packages: git-up: 7.0.0 dev: true + /github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + dev: false + /gl-matrix@3.4.3: resolution: {integrity: sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==} dev: false @@ -5516,7 +5533,6 @@ packages: /ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - dev: true /ini@2.0.0: resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} @@ -5859,6 +5875,14 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + /isolated-vm@4.6.0: + resolution: {integrity: sha512-MEnfC/54q5PED3VJ9UJYJPOlU6mYFHS3ivR9E8yeNNBEFRFUNBnY0xO4Rj3D/SOtFKPNmsQp9NWUYSKZqAoZiA==} + engines: {node: '>=16.0.0'} + requiresBuild: true + dependencies: + prebuild-install: 7.1.1 + dev: false + /issue-parser@6.0.0: resolution: {integrity: sha512-zKa/Dxq2lGsBIXQ7CUZWTHfvxPC2ej0KfO7fIPqLlHB9J2hJ7rGhZ5rilhuufylr4RXYPzJUeFjKxz305OsNlA==} engines: {node: '>=10.13'} @@ -6389,7 +6413,6 @@ packages: /mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} - dev: true /mimic-response@4.0.0: resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} @@ -6557,6 +6580,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + /napi-build-utils@1.0.2: + resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} + dev: false + /negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -6582,6 +6609,13 @@ packages: type-fest: 2.19.0 dev: true + /node-abi@3.52.0: + resolution: {integrity: sha512-JJ98b02z16ILv7859irtXn4oUaFWADtvkzy2c0IAatNVX2Mc9Yoh8z6hZInn3QwvMEYhHuQloYi+TTQy67SIdQ==} + engines: {node: '>=10'} + dependencies: + semver: 7.5.4 + dev: false + /node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -7260,6 +7294,25 @@ packages: resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} dev: false + /prebuild-install@7.1.1: + resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==} + engines: {node: '>=10'} + hasBin: true + dependencies: + detect-libc: 2.0.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 1.0.2 + node-abi: 3.52.0 + pump: 3.0.0 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.1 + tunnel-agent: 0.6.0 + dev: false + /prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -8108,7 +8161,6 @@ packages: ini: 1.3.8 minimist: 1.2.8 strip-json-comments: 2.0.1 - dev: true /react-color@2.17.1(react@18.2.0): resolution: {integrity: sha512-S+I6TkUKJaqfALLkAIfiCZ/MANQyy7dKkf7g9ZU5GTUy2rf8c2Rx62otyvADAviWR+6HRkzdf2vL1Qvz9goCLQ==} @@ -8791,6 +8843,18 @@ packages: engines: {node: '>=14'} dev: true + /simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + dev: false + + /simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + dev: false + /simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} dependencies: @@ -9132,7 +9196,6 @@ packages: /strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} - dev: true /stylis@4.3.0: resolution: {integrity: sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ==} @@ -9253,6 +9316,15 @@ packages: through: 2.3.8 dev: false + /tar-fs@2.1.1: + resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.0 + tar-stream: 2.2.0 + dev: false + /tar-fs@3.0.4: resolution: {integrity: sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==} dependencies: @@ -9270,7 +9342,6 @@ packages: fs-constants: 1.0.0 inherits: 2.0.4 readable-stream: 3.6.2 - dev: true /tar-stream@3.1.6: resolution: {integrity: sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==} @@ -9525,6 +9596,12 @@ packages: /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + /tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /type-fest@0.16.0: resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} engines: {node: '>=10'} diff --git a/src/client/components/monitor/provider/custom.tsx b/src/client/components/monitor/provider/custom.tsx new file mode 100644 index 0000000..c040d9d --- /dev/null +++ b/src/client/components/monitor/provider/custom.tsx @@ -0,0 +1,24 @@ +import { Form, Input } from 'antd'; +import React from 'react'; +import { MonitorProvider } from './types'; + +export const MonitorCustom: React.FC = React.memo(() => { + return ( + <> + + + + + ); +}); +MonitorCustom.displayName = 'MonitorCustom'; + +export const customProvider: MonitorProvider = { + label: 'Custom', + name: 'custom', + form: MonitorCustom, +}; diff --git a/src/client/components/monitor/provider/index.ts b/src/client/components/monitor/provider/index.ts index 9bb8029..05dcf2a 100644 --- a/src/client/components/monitor/provider/index.ts +++ b/src/client/components/monitor/provider/index.ts @@ -4,11 +4,13 @@ import { pingProvider } from './ping'; import { httpProvider } from './http'; import { MonitorProvider } from './types'; import { openaiProvider } from './openai'; +import { customProvider } from './custom'; export const monitorProviders: MonitorProvider[] = [ pingProvider, // ping httpProvider, // http openaiProvider, // http + customProvider, // custom node script ]; export function getMonitorProvider(type: string) { diff --git a/src/server/model/monitor/provider/custom.ts b/src/server/model/monitor/provider/custom.ts new file mode 100644 index 0000000..39c1c91 --- /dev/null +++ b/src/server/model/monitor/provider/custom.ts @@ -0,0 +1,46 @@ +import { MonitorProvider } from './type'; +import ivm from 'isolated-vm'; +import { buildSandbox, environmentScript } from '../../../utils/sandbox'; +import { env } from '../../../utils/env'; + +export const custom: MonitorProvider<{ + code: string; +}> = { + run: async (monitor) => { + if (typeof monitor.payload !== 'object') { + throw new Error('monitor.payload should be object'); + } + + const { code } = monitor.payload; + + const res = await runCodeInVM(code); + + if (typeof res !== 'number') { + return -1; + } + + return res; + }, +}; + +async function runCodeInVM(_code: string) { + const isolate = new ivm.Isolate({ memoryLimit: env.sandboxMemoryLimit }); + + const code = `${environmentScript}\n\n;(async () => {${_code}})()`; + + const [context, script] = await Promise.all([ + isolate.createContext(), + isolate.compileScript(code), + ]); + + buildSandbox(context); + + const res = await script.run(context, { + promise: true, + }); + + context.release(); + script.release(); + + return res; +} diff --git a/src/server/model/monitor/provider/index.ts b/src/server/model/monitor/provider/index.ts index 7339fea..0a225dc 100644 --- a/src/server/model/monitor/provider/index.ts +++ b/src/server/model/monitor/provider/index.ts @@ -2,9 +2,11 @@ import { http } from './http'; import { ping } from './ping'; import { openai } from './openai'; import type { MonitorProvider } from './type'; +import { custom } from './custom'; export const monitorProviders: Record> = { ping, http, openai, + custom, }; diff --git a/src/server/utils/env.ts b/src/server/utils/env.ts index ccc6e50..6f1bad9 100644 --- a/src/server/utils/env.ts +++ b/src/server/utils/env.ts @@ -3,6 +3,9 @@ export const env = { allowOpenapi: checkEnvTrusty(process.env.ALLOW_OPENAPI), websiteId: process.env.WEBSITE_ID, serverUrl: process.env.SERVER_URL, // example: https://tianji.example.com + sandboxMemoryLimit: process.env.SANDBOX_MEMORY_LIMIT + ? Number(process.env.SANDBOX_MEMORY_LIMIT) + : 16, // MB }; export function checkEnvTrusty(env: string | undefined): boolean { diff --git a/src/server/utils/sandbox.ts b/src/server/utils/sandbox.ts new file mode 100644 index 0000000..293df9b --- /dev/null +++ b/src/server/utils/sandbox.ts @@ -0,0 +1,174 @@ +import axios, { AxiosRequestConfig } from 'axios'; +import EventEmitter from 'events'; +import ivm, { Context } from 'isolated-vm'; + +function isTransferable(data: any): data is ivm.Transferable { + const dataType = typeof data; + + if (data === ivm) { + return true; + } + + if ( + ['null', 'undefined', 'string', 'number', 'boolean', 'function'].includes( + dataType + ) + ) { + return true; + } + + if (dataType !== 'object') { + return false; + } + + return ( + data instanceof ivm.Isolate || + data instanceof ivm.Context || + data instanceof ivm.Script || + data instanceof ivm.ExternalCopy || + data instanceof ivm.Callback || + data instanceof ivm.Reference + ); +} + +function proxyObject( + obj: Record, + forbiddenKeys: string[] = [] +): Record { + return copyObject({ + isProxy: true, + get: (key: string) => { + if (forbiddenKeys.includes(key)) { + return undefined; + } + + const value = obj[key]; + + if (typeof value === 'function') { + return new ivm.Reference(async (...args: any[]) => { + const result = (obj[key] as any)(...args); + + if (result && result instanceof Promise) { + return new Promise(async (resolve, reject) => { + try { + const awaitedResult = await result; + resolve(makeTransferable(awaitedResult)); + } catch (e) { + reject(e); + } + }); + } + + return makeTransferable(result); + }); + } + + return makeTransferable(value); + }, + }); +} + +// Semi-transferable data can be copied with an ivm.ExternalCopy without needing any manipulation. +function isSemiTransferable(data: any) { + return data instanceof ArrayBuffer; +} + +export function copyObject( + obj: Record | any[] +): Record | any[] { + if (Array.isArray(obj)) { + return obj.map((data) => copyData(data)); + } + + if (obj instanceof Response) { + return proxyObject(obj, ['clone']); + } + + if (isSemiTransferable(obj)) { + return obj; + } + + if (typeof obj[Symbol.iterator as any] === 'function') { + return copyObject(Array.from(obj as any)); + } + + if (obj instanceof EventEmitter) { + return {}; + } + + const keys = Object.keys(obj); + + return { + ...Object.fromEntries( + keys.map((key) => { + const data = obj[key]; + + if (typeof data === 'function') { + return [key, new ivm.Callback((...args: any[]) => obj[key](...args))]; + } + + return [key, copyData(data)]; + }) + ), + }; +} + +function copyData | any[]>( + data: T +) { + return isTransferable(data) ? data : copyObject(data); +} + +function makeTransferable(data: any) { + return isTransferable(data) + ? data + : new ivm.ExternalCopy(copyObject(data)).copyInto(); +} + +export function buildSandbox(context: Context) { + const jail = context.global; + jail.setSync('global', jail.derefInto()); + jail.setSync('ivm', ivm); + jail.setSync('console', makeTransferable(console)); + jail.setSync( + '_request', + new ivm.Reference(async (config: AxiosRequestConfig) => { + const result = await axios.request(config); + + return makeTransferable({ + data: result.data, + status: result.status, + }); + }) + ); +} + +export const environmentScript = ` +const reproxy = (reference) => { + return new Proxy(reference, { + get(target, p, receiver) { + if (target !== reference || p === 'then') { + return Reflect.get(target, p, receiver); + } + + const data = reference.get(p); + + if (typeof data === 'object' && data instanceof ivm.Reference && data.typeof === 'function') { + return (...args) => data.apply(undefined, args, { arguments: { copy: true }, result: { promise: true } }); + } + + return data; + } + }); +}; + +const request = async (...args) => { + const result = await _request.apply(undefined, args, { arguments: { copy: true }, result: { promise: true } }); + + if (result && typeof result === 'object' && result.isProxy) { + return reproxy(result); + } + + return result; +}; +`; diff --git a/src/server/ws/index.ts b/src/server/ws/index.ts index d9125ee..263a989 100644 --- a/src/server/ws/index.ts +++ b/src/server/ws/index.ts @@ -53,7 +53,7 @@ export function initSocketio(httpServer: HTTPServer) { } socket.onAny((eventName, eventData, callback) => { - console.log('[Socket] receive:', { eventName, eventData }); + // console.log('[Socket] receive:', { eventName, eventData }); socketEventBus.emit(eventName, eventData, socket, callback); }); });