Files
IOT_SIM/server/src/sandbox.ts
T
xuyueandClaude Opus 5.5 697f31e9ce 部署:bubblewrap 沙箱、编译检查、访问口令、Docker Compose
- 学生程序每次运行放进 bubblewrap:断网,只读挂载系统文件、运行时和自己的工程目录,
  看不到其他进程和服务器环境变量;限制堆内存 128 MB、CPU 时间 120 秒、运行 60 分钟,
  nice 10,全站同时运行上限 150;启动时自检沙箱,不可用时拒绝运行
- 编译检查(SIM001):网络、反射、启动进程、调用本机代码、加载程序集、dynamic 给出中文报错
- 访问口令:cookie 保存口令哈希,按真实 IP 限制输错次数;前端口令页
- 服务端配置集中到 config.ts,可用环境变量覆盖;Bun 直接提供打包好的网页
- Dockerfile(多阶段,服务端打包成单文件)、compose.yaml(Caddy 只收 HTTP,接入 NPM 的
  npm_proxy 网络)、deploy/deploy.sh(本机构建后经 ssh 传到服务器)和部署说明
- 测试:沙箱隔离、内存上限、CPU 时间上限、编译检查

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:40:09 +08:00

110 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 学生程序的沙箱:每次运行用 bubblewrap 放进独立的命名空间。
// 断网;只能看到系统只读文件、运行时程序和自己的工程目录;/tmp 是独立的内存盘;
// 看不到服务器上的其他进程(包括别的学生程序);服务器进程的环境变量也不会传进去。
import { lstatSync, readlinkSync, realpathSync } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import { dirname } from 'node:path'
import { config } from './config'
const RUNNER_DIR = '/sim/runner'
const APP_DIR = '/sim/app'
/** 学生程序的全部环境变量(不继承服务器的) */
function runnerEnv(extra: Record<string, string>) {
return {
PATH: '/usr/local/bin:/usr/bin:/bin',
HOME: '/tmp',
TZ: config.timeZone,
DOTNET_CLI_TELEMETRY_OPTOUT: '1',
DOTNET_NOLOGO: '1',
// 学生写出死循环分配内存时,在这里报 OutOfMemoryException,而不是拖垮服务器
DOTNET_GCHeapHardLimit: (config.heapLimitMb * 1024 * 1024).toString(16),
...extra,
}
}
/** /lib、/bin 等在新系统上是指向 /usr 的链接,照原样建链接;老系统上是真目录,只读挂载 */
function rootLinks(): string[] {
const args: string[] = []
for (const path of ['/bin', '/sbin', '/lib', '/lib32', '/lib64']) {
let stat
try {
stat = lstatSync(path)
} catch {
continue
}
if (stat.isSymbolicLink()) args.push('--symlink', readlinkSync(path), path)
else if (stat.isDirectory()) args.push('--ro-bind', path, path)
}
return args
}
/** dotnet 不在 /usr 下时(比如装在 /opt),把它所在目录也挂进去 */
function dotnetMounts(): string[] {
const dotnet = Bun.which('dotnet')
if (!dotnet) return []
const home = dirname(realpathSync(dotnet))
return home.startsWith('/usr/') ? [] : ['--ro-bind', home, home]
}
/** 沙箱不可用的原因;可用(或没启用沙箱)时为 undefined。服务器启动时检查一次 */
export let sandboxError: string | undefined
export async function checkSandbox() {
if (config.sandbox === 'none') return
await mkdir(config.runsDir, { recursive: true })
const proc = Bun.spawn([...bwrapArgs(config.runsDir), 'true'], { stdout: 'ignore', stderr: 'pipe', env: runnerEnv({}) })
const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()])
if (code === 0) return
sandboxError =
`沙箱(bubblewrap)无法启动:${stderr.trim().replace(/[.。]$/, '')}。` +
'学生程序不会在没有隔离的情况下运行。检查 compose.yaml 里的 security_opt;' +
'Ubuntu 23.10 及以上的宿主机还要执行 sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0(见 deploy/README.md)'
}
/** 启动一次学生程序运行的命令行和环境变量 */
export function runCommand(projectDir: string, extraEnv: Record<string, string>) {
const env = runnerEnv(extraEnv)
if (config.sandbox === 'none') {
return { cmd: ['dotnet', config.runnerDll, 'run', projectDir], env }
}
const cmd = [
// 学生程序写出死循环时,降低它的优先级,网页服务和编译服务仍然流畅
'nice', '-n', '10',
// CPU 时间用完先收到 SIGXCPU 结束,过几秒还在就强制结束
'prlimit', `--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds + 5}`,
...bwrapArgs(projectDir),
'dotnet', `${RUNNER_DIR}/${config.runnerDll.split('/').pop()}`, 'run', APP_DIR,
]
return { cmd, env }
}
const SIGXCPU = 24
/** 运行进程退出时,如果是因为 CPU 时间用完,给学生的说明 */
export function cpuLimitMessage(code: number | null, signal: string | number | null) {
// bwrap 把子进程"被信号结束"转成退出码 128 + 信号编号
return signal === 'SIGXCPU' || code === 128 + SIGXCPU
? `\n程序占用 CPU 太多,已被结束(累计超过 ${config.cpuLimitSeconds} 秒)。检查是否有死循环:循环里要有退出条件,等待请用 Timer 或 Thread.Sleep。\n`
: undefined
}
function bwrapArgs(projectDir: string) {
return [
'bwrap',
'--unshare-all',
'--die-with-parent',
'--new-session',
'--ro-bind', '/usr', '/usr',
...rootLinks(),
'--ro-bind-try', '/etc/localtime', '/etc/localtime',
...dotnetMounts(),
'--proc', '/proc',
'--dev', '/dev',
'--tmpfs', '/tmp',
'--ro-bind', dirname(config.runnerDll), RUNNER_DIR,
'--ro-bind', projectDir, APP_DIR,
'--chdir', APP_DIR,
]
}