- 学生程序每次运行放进 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>
110 lines
4.4 KiB
TypeScript
110 lines
4.4 KiB
TypeScript
// 学生程序的沙箱:每次运行用 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,
|
||
]
|
||
}
|