嘿,大家好!把安全帽戴好,把代码敲起来。今天我们不讲 Hello World,不讲 CRUD。我们要聊的是一个能让你“手起刀落,代码听命”的终极系统——一个基于 React 和 NestJS 的自动化脚本平台,核心功能是:别让你的用户把你的服务器变成一台洗牌机。
欢迎来到“脚本的伊甸园与地狱”现场。
第一部分:前端——不仅仅是写代码,更是在修仙
首先,咱们得有个地方让用户挥舞鼠标。想象一下,你在 Netflix 上写搜索框,那叫 UI 交互。在这里,用户要写的是逻辑流。这不仅仅是写代码,这就像是在给一个喜怒无常的精灵写咒语。
我们的前端基于 React。为什么要 React?因为它的状态管理能让你的“运行”、“停止”、“暂停”按钮像过山车一样丝滑。我们要实现一个脚本编辑器。这里有个误区:你以为只要放一个 <textarea> 就行?太天真了。
场景: 用户正在写一个脚本,突然他写错了变量名,程序崩溃了,他一脸懵逼。
正确姿势: 我们需要一个带语法高亮、行号显示、自动补全的编辑器。虽然标准的 Monaco Editor(VS Code 的核心)很强大,但为了不扯皮,我们这里用一个轻量级的思路来模拟。
来看看我们的 ScriptEditor 组件。它不仅仅是个输入框,它是个状态监控器。
// ScriptEditor.tsx
import React, { useState, useEffect, useRef } from 'react';
interface ScriptEditorProps {
initialCode: string;
onRun: (code: string) => void;
onStop: () => void;
isRunning: boolean;
}
// 模拟一个简单的语法高亮显示器,实际生产中请直接上 Monaco
const SyntaxHighlighter: React.FC<{ code: string }> = ({ code }) => {
return (
<pre style={{ margin: 0, fontFamily: 'monospace', color: '#333' }}>
<code>{code}</code>
</pre>
);
};
export const ScriptEditor: React.FC<ScriptEditorProps> = ({
initialCode,
onRun,
onStop,
isRunning
}) => {
const [code, setCode] = useState(initialCode);
const [status, setStatus] = useState<string>('');
const [logs, setLogs] = useState<string[]>([]);
const logContainerRef = useRef<HTMLDivElement>(null);
// 滚动到底部的小技巧
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs]);
const handleRun = () => {
if (isRunning) return;
setStatus('Executing...');
onRun(code);
};
const addLog = (msg: string) => {
setLogs(prev => [...prev, `> ${new Date().toLocaleTimeString()}: ${msg}`]);
};
return (
<div style={{ border: '1px solid #ccc', borderRadius: '8px', overflow: 'hidden', display: 'flex', flexDirection: 'column', height: '500px' }}>
<div style={{ background: '#f0f0f0', padding: '10px', borderBottom: '1px solid #ccc' }}>
<button onClick={handleRun} disabled={isRunning} style={{ marginRight: '10px' }}>
{isRunning ? 'Running...' : '▶ Execute'}
</button>
<button onClick={onStop} disabled={!isRunning} style={{ background: '#ffcccc', color: 'red' }}>
⏹ Stop
</button>
<span style={{ marginLeft: 'auto', fontSize: '0.9rem' }}>{status}</span>
</div>
{/* 编辑区 */}
<div style={{ flex: 1, padding: '10px', background: '#fff' }}>
<SyntaxHighlighter code={code} />
</div>
{/* 日志区 */}
<div
ref={logContainerRef}
style={{
height: '150px',
background: '#2d2d2d',
color: '#00ff00',
padding: '10px',
overflowY: 'auto',
fontFamily: 'monospace',
fontSize: '0.85rem'
}}
>
{logs.length === 0 && <span style={{ opacity: 0.5 }}>Waiting for execution...</span>}
{logs.map((log, idx) => <div key={idx}>{log}</div>)}
</div>
</div>
);
};
看到了吗?这不仅仅是个输入框。isRunning 状态控制着按钮,addLog 函数把后端发回来的“剧本”实时翻译给用户看。用户在 React 里写的是“人话”,通过 onRun 传给后端的是“天书”。
第二部分:NestJS 后端——当后端开始当“保姆”
好了,前端把代码扔过来了。现在 NestJS 站到了舞台中央。我们的任务是:别让它跑了,跑出火葬场,或者至少别让它把你的数据库删了。
1. 基础架构与 DTO
首先,我们得定义数据结构。一个脚本不仅仅是代码,它还有元数据。
// script.dto.ts
import { IsString, IsBoolean, IsOptional, IsEnum, IsArray } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export enum ScriptStatus {
ACTIVE = 'ACTIVE',
DISABLED = 'DISABLED',
}
export enum ScriptRole {
ADMIN = 'ADMIN', // 能干脏活累活
USER = 'USER', // 只能查查数据
GUEST = 'GUEST', // 只能跑Hello World
}
export class CreateScriptDto {
@ApiProperty()
@IsString()
name: string;
@ApiProperty({ example: 'const a = 1; console.log(a);' })
@IsString()
code: string;
@ApiProperty({ enum: ScriptStatus })
@IsEnum(ScriptStatus)
status: ScriptStatus;
@ApiProperty({ enum: ScriptRole })
@IsEnum(ScriptRole)
role: ScriptRole; // 权限控制的核心
}
看,这里有个 ScriptRole。这就是我们权限隔离的第一道防线。用户不能随便上传一个带 fs.unlink 的脚本,系统会检查这个脚本的元数据里的 Role。
2. 权限守卫
NestJS 的 Guards 是个好东西。我们要在这里拦截请求。
// auth.guard.ts
@Injectable()
export class ScriptExecutionGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const scriptRole = this.reflector.get<ScriptRole>('role', context.getHandler());
const userRole = context.switchToHttp().getRequest().user.role; // 假设从JWT里拿用户信息
// 简单的 RBAC 逻辑
if (scriptRole === ScriptRole.ADMIN) {
return true; // Admin 谁的脚本能跑谁都能跑(不过跑完还是要看沙箱脸色)
}
if (scriptRole === ScriptRole.GUEST && userRole === ScriptRole.GUEST) {
return true;
}
// 如果用户试图用普通用户的权限去跑管理员脚本 -> 拒绝
return false;
}
}
// 使用装饰器
@UseGuards(ScriptExecutionGuard)
@Post('run')
async runScript(@Body() dto: { code: string }) {
// 这里是危险区域
}
第三部分:核心——沙箱隔离(这是保命的关键)
好了,如果权限守卫放行了,代码就会往下走。但是! 我们不能直接 eval(dto.code)。
为什么?因为你的用户可能写了这么一段代码:
// 用户写的代码
const fs = require('fs');
fs.writeFileSync('/etc/passwd', 'hacked');
process.exit(0);
如果你直接运行,恭喜你,服务器变成了肉鸡。我们需要沙箱。
在 Node.js 里,我们有两种主流手段:
- Docker 容器化: 就像把每个脚本扔进一个独立的微型 Linux 机器里跑。这是最安全的,但是开销大,有延迟。
- VM 沙箱(如 vm2, isolated-vm): 在同一个 Node 进程里,创建一个受限的虚拟机。
作为一个追求极致架构的专家,我建议你组合使用。对于普通的轻量脚本,用 VM 沙箱;对于耗时的、需要系统调用的脚本,用 Docker。
这里我们演示一个“黑名单/白名单”式的沙箱构建器。我们只给脚本提供它需要的工具,其他的统统拿走。
3. 构建安全的执行环境
我们需要一个 ScriptExecutor 服务。
// script.service.ts
import { Injectable, Logger, ForbiddenException } from '@nestjs/common';
import { createRequire } from 'module';
import vm from 'vm';
@Injectable()
export class ScriptService {
private logger = new Logger('ScriptService');
// 黑名单:绝对不能允许用户访问的模块
private readonly BLACKLIST = ['fs', 'child_process', 'http', 'net', 'dns', 'tls', 'child_process'];
// 白名单:允许暴露给脚本的基础 API
private readonly ALLOWED_APIS = {
console: console, // 允许打印,但得拦截一下,不然日志满天飞
setTimeout: setTimeout,
clearTimeout: clearTimeout,
process: {
env: process.env, // 甚至可以只暴露部分 env
},
};
async executeScript(code: string, userId: string) {
// 1. 预处理:安全检查(防止用户在代码里注入恶意代码)
this.validateCodeSanity(code);
// 2. 创建沙箱上下文
const sandbox = {
...this.ALLOWED_APIS,
console: {
log: (...args: any[]) => this.logger.log(`[Script:${userId}] ${args.join(' ')}`),
error: (...args: any[]) => this.logger.error(`[Script:${userId}] ERROR: ${args.join(' ')}`),
warn: (...args: any[]) => this.logger.warn(`[Script:${userId}] WARN: ${args.join(' ')}`),
},
// 防止用户修改全局对象
global: this.ALLOWED_APIS,
};
// 3. 获取 require 函数(伪装成用户能用的 require)
const require = createRequire(import.meta.url);
// 4. 创建 VM 上下文
const context = vm.createContext(sandbox);
// 5. 超时机制:防止死循环
const timeout = 5000; // 5秒超时
const timeoutId = setTimeout(() => {
throw new Error('Script execution timeout');
}, timeout);
try {
// 6. 运行代码
vm.runInContext(code, context, { timeout });
clearTimeout(timeoutId);
return { success: true, output: 'Script completed successfully.' };
} catch (error) {
clearTimeout(timeoutId);
this.logger.error(`Script failed for user ${userId}: ${error.message}`);
throw new ForbiddenException('Script execution failed or was killed.');
}
}
private validateCodeSanity(code: string) {
// 简单的正则检查,防止明显的恶意注入
// 注意:正则不是终极防线,真正的防线是 vm.createContext 的隔离性
if (code.includes('require("fs")') || code.includes('require("child_process")')) {
throw new ForbiddenException('Disallowed module usage detected.');
}
}
}
4. 进阶:Docker 化执行(如果脚本很重)
如果脚本需要上传文件、读写大量的 I/O,上面的 VM 方法就不行了,因为 Docker 是唯一能真正隔离文件系统的方法。这时候,我们需要 NestJS 调用 Docker Daemon。
// docker.service.ts
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { Docker } from 'node-docker';
@Injectable()
export class DockerScriptExecutor {
private docker = new Docker();
async runDockerScript(code: string, userId: string) {
// 创建一个临时的容器
const container = await this.docker.createContainer({
Image: 'node:18-alpine', // 使用轻量级镜像
HostConfig: {
Memory: 52428800, // 限制内存 50MB
CpuShares: 512,
AutoRemove: true, // 运行完自动删,防止堆积
},
Cmd: ['node', '-e', code], // 直接在容器里运行代码
});
// 启动
await container.start();
// 获取日志
const logs = await container.logs({
stdout: true,
stderr: true,
follow: true,
});
// 实时处理日志流... (这里简化了)
return { status: 'Running', containerId: container.id };
}
}
第四部分:权限隔离——不仅仅是防火墙
沙箱管的是“脚本能不能做系统操作”,权限隔离管的是“这个脚本属于谁,能干什么”。
1. 数据隔离
在 SQL 中,我们用 WHERE user_id = ? 来隔离数据。在脚本里,我们需要把这个 userId 注入进去。
// 在 NestJS Controller 中注入用户信息
@Post('run')
async runScript(
@Body() dto: { code: string },
@Request() req // 从 JWT 上下文获取
) {
// 这里的 userId 是安全的,因为后端验证过身份
return this.scriptService.executeScript(dto.code, req.user.id);
}
然后在脚本里,代码是这样写的:
// 用户提交的代码片段
// 这里的 db 是我们在沙箱里暴露的一个“安全代理”对象
db.query("SELECT * FROM orders WHERE user_id = ?", [db.currentUser.id]);
2. 资源配额
你不能让脚本无限循环消耗 CPU。我们需要中间件。
// quota.middleware.ts
@Injectable()
export class QuotaMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) {
// 检查用户是否在冷却期
const user = req.user;
const now = Date.now();
if (user.lastRun && now - user.lastRun < 5000) {
res.status(429).json({ error: 'Too many requests' });
return;
}
user.lastRun = now;
next();
}
}
第五部分:实战中的坑与调试
1. 环境变量泄露
如果你在沙箱里暴露了 process.env,用户就能拿到你的数据库密码。
对策: 深度克隆一份 process.env,或者只暴露 NODE_ENV。
2. 脚本崩溃导致宿主进程挂掉
如果代码里有个无限循环,你的 vm.runInContext 会卡死。
对策: 强制超时机制。如果脚本在 5 秒内没返回,强制杀死进程(Docker 里的 SIGKILL 或 VM 里的中断)。
3. 日志管理
如果成千上万用户同时跑脚本,日志会瞬间爆炸。
对策: 使用消息队列(RabbitMQ/Redis Stream)处理日志。脚本只负责输出,后端消费者负责把日志写入 ElasticSearch 或数据库。
第六部分:React 侧的微交互——用户体验的极致
既然是平台,除了能跑,还得好看。我们聊聊如何让前端用户觉得这东西很“牛”。
1. 执行结果的可视化
不仅仅是一行行黑底绿字的日志。我们可以把脚本输出渲染成 HTML,或者图表。
// ResultPanel.tsx
interface ResultPanelProps {
logs: string[];
data: any; // 脚本返回的 JSON 数据
}
export const ResultPanel: React.FC<ResultPanelProps> = ({ logs, data }) => {
return (
<div style={{ marginTop: '20px', padding: '20px', background: '#f9f9f9', borderRadius: '8px' }}>
<h4>Execution Result</h4>
<div style={{ marginBottom: '20px' }}>
<h5>Logs</h5>
<div style={{
background: '#222',
color: '#0f0',
padding: '10px',
borderRadius: '4px',
fontFamily: 'monospace',
maxHeight: '200px',
overflowY: 'auto'
}}>
{logs.map((log, i) => <div key={i}>{log}</div>)}
</div>
</div>
{data && (
<div>
<h5>Output Data</h5>
<pre style={{ background: '#eee', padding: '10px', borderRadius: '4px' }}>
{JSON.stringify(data, null, 2)}
</pre>
</div>
)}
</div>
);
};
2. 脚本版本管理
用户可能会改了脚本,结果跑崩了。我们需要历史版本。
// 在 ScriptService 中
async saveScriptVersion(scriptId: string, code: string, userId: string) {
await this.versionRepo.save({
scriptId,
code,
userId,
timestamp: new Date()
});
}
在 React 里,做一个“历史记录”抽屉,点一下回滚代码。
第七部分:架构图解(脑补时刻)
想象一下这个数据流:
- 用户 在 React 编辑器敲下
db.save(user)。 - React 检查语法(简单校验),打包成 JSON 发给 NestJS。
- NestJS 拦截请求,检查 JWT Token(身份)。
- NestJS 检查 Guards(脚本角色是否匹配用户角色)。
- NestJS 调用 ScriptService。
- ScriptService 启动 沙箱(VM 或 Docker)。
- 沙箱 执行代码。
- 如果是 VM:代码试图调用
fs-> 拒绝。 - 如果是 Docker:代码试图连接外部网络 -> 被防火墙拦截。
- 如果是 VM:代码试图调用
- 结果 返回给 React,渲染在漂亮的日志面板里。
总结——这就是架构的艺术
构建这个平台,其实就是在玩一场“信任与隔离”的游戏。
- 前端 是门面,负责把枯燥的代码变得可爱。
- NestJS 是守门员,负责规则和权限。
- 沙箱 是笼子,负责物理隔离。
千万别觉得 eval 简单好用。在这个 Web 2.0 甚至 Web 3.0 的时代,代码是最大的安全漏洞。当你允许用户在服务器上写代码时,你就在把自己的命交到了他们手上。
但如果你做好了沙箱,做好了权限隔离,做好了监控,那么恭喜你,你拥有了一个强大的自动化工具。用户负责写逻辑,你负责保证服务器不炸。
所以,拿起你的 NestJS 和 React,去构建一个安全、高效、又酷炫的脚本平台吧!代码在手里,安全在心中。
(完)