部署: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>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 15:40:09 +08:00
1 parent 081ac6f924
commit 697f31e9ce
25 files changed
+869 -52

No files matched your search

+3 -2
View File
@@ -5,7 +5,8 @@ import { cp, mkdir, rm } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { ControlNode, RunnerMessage } from '@iot/shared'
import { compileService } from '../src/compile-service'
import { ROOT, RUNNER_DLL, readLines } from '../src/runner-process'
import { config, ROOT } from '../src/config'
import { readLines } from '../src/runner-process'
const COUNT = Number(process.argv[2] ?? 100)
const dir = resolve(ROOT, '.runs/load-test')
@@ -47,7 +48,7 @@ const startAt = performance.now()
const instances: Instance[] = []
const shown: Promise<void>[] = []
for (let i = 0; i < COUNT; i++) {
const proc = Bun.spawn(['dotnet', RUNNER_DLL, 'run', dir], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' })
const proc = Bun.spawn(['dotnet', config.runnerDll, 'run', dir], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' })
const instance: Instance = { proc }
instances.push(instance)
shown.push(
+120 -3
View File
@@ -5,11 +5,13 @@ import { cp, rm } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { ControlNode, RunnerInput, RunnerMessage } from '@iot/shared'
import { compileService } from '../src/compile-service'
import { ROOT, RUNNER_DLL } from '../src/runner-process'
import { config, ROOT } from '../src/config'
import { cpuLimitMessage, runCommand } from '../src/sandbox'
class Runner {
readonly messages: RunnerMessage[] = []
private proc?: Bun.Subprocess<'pipe', 'pipe', 'inherit'>
private reading?: Promise<void>
private waiters: { match: (m: RunnerMessage) => boolean; resolve: (m: RunnerMessage) => void }[] = []
/** 与服务器相同的流程:先由编译服务编译,成功后启动运行进程 */
@@ -18,8 +20,9 @@ class Runner {
const compiled = await compileService.compile(projectDir)
runner.receive({ type: 'compiled', ...compiled })
if (compiled.ok) {
runner.proc = Bun.spawn(['dotnet', RUNNER_DLL, 'run', projectDir], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' })
void runner.read()
const { cmd, env } = runCommand(projectDir, {})
runner.proc = Bun.spawn(cmd, { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit', env })
runner.reading = runner.read()
}
return runner
}
@@ -87,6 +90,13 @@ class Runner {
kill() {
this.proc?.kill()
}
/** 等程序自己结束、输出全部读完 */
async finished() {
await this.proc!.exited
await this.reading
return { code: this.proc!.exitCode, signal: this.proc!.signalCode }
}
}
function findByName(node: ControlNode, name: string): ControlNode {
@@ -324,3 +334,110 @@ test('项目5 任务1:有火焰时显示"发现火情!"', async () => {
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, true, false, false] })
await runner.next('patch', (m) => m.changes.some((c) => c.id === id('txt_even') && c.value === '发现火情!'), 5000)
}, 30000)
/** 把一段 Main 方法体写成工程并运行,收集它的控制台输出 */
async function runMain(name: string, body: string, usings = '') {
const dir = resolve(ROOT, '.runs', `test-${name}`)
await rm(dir, { recursive: true, force: true })
await Bun.write(
`${dir}/Program.cs`,
`using System;\nusing System.IO;\nusing System.Linq;\n${usings}\nstatic class Program\n{\n static void Main()\n {\n${body}\n }\n}\n`,
)
runner = await Runner.start(dir)
return { runner, dir }
}
test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读', async () => {
process.env.SIM_TEST_SECRET = '不该被看到'
const { runner } = await runMain(
'sandbox',
`
Console.WriteLine("passwd=" + File.Exists("/etc/passwd"));
Console.WriteLine("runs=" + Directory.Exists(${JSON.stringify(config.runsDir)}));
Console.WriteLine("home=" + Directory.Exists("/home"));
Console.WriteLine("secret=" + (Environment.GetEnvironmentVariable("SIM_TEST_SECRET") ?? "null"));
Console.WriteLine("procs=" + Directory.GetDirectories("/proc").Count(d => char.IsDigit(Path.GetFileName(d)[0])));
try { File.WriteAllText("hack.txt", "x"); Console.WriteLine("write=ok"); } catch (Exception) { Console.WriteLine("write=denied"); }
File.WriteAllText("/tmp/ok.txt", "x");
Console.WriteLine("tmp=" + File.Exists("/tmp/ok.txt"));`,
)
expect((await runner.waitFor('compiled')).ok).toBe(true)
await runner.finished()
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(output).toContain('passwd=False')
expect(output).toContain('runs=False')
expect(output).toContain('home=False')
expect(output).toContain('secret=null')
// 只能看到 bwrap 和自己
expect(Number(/procs=(\d+)/.exec(output)?.[1])).toBeLessThanOrEqual(2)
expect(output).toContain('write=denied')
expect(output).toContain('tmp=True')
}, 30000)
test('内存上限:不停分配内存的程序得到 OutOfMemoryException,而不是吃光服务器内存', async () => {
const { runner } = await runMain(
'heap',
`
var blocks = new System.Collections.Generic.List<byte[]>();
try { while (true) blocks.Add(new byte[1 << 20]); }
catch (OutOfMemoryException) { Console.WriteLine("oom=" + blocks.Count); }`,
)
await runner.finished()
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
const megabytes = Number(/oom=(\d+)/.exec(output)?.[1])
expect(megabytes).toBeGreaterThan(50)
expect(megabytes).toBeLessThanOrEqual(config.heapLimitMb)
}, 30000)
test('编译检查:网络、启动程序、反射、dynamic 给出中文报错;AssemblyInfo.cs 里的特性不受影响', async () => {
const dir = resolve(ROOT, '.runs/test-policy')
await rm(dir, { recursive: true, force: true })
await Bun.write(
`${dir}/Program.cs`,
`using System;
using System.Diagnostics;
using System.Net.Http;
static class Program
{
static void Main()
{
Process.Start("sh");
var client = new HttpClient();
typeof(string).GetMethod("Trim", Type.EmptyTypes).Invoke("x", null);
dynamic d = 1;
}
}
`,
)
await Bun.write(
`${dir}/Properties/AssemblyInfo.cs`,
`using System.Reflection;
using System.Runtime.InteropServices;
[assembly: AssemblyTitle("WindowsFormsApplication1")]
[assembly: ComVisible(false)]
[assembly: Guid("0a1b2c3d-1111-2222-3333-444455556666")]
`,
)
const result = await compileService.compile(dir)
expect(result.ok).toBe(false)
const denied = result.diagnostics.filter((d) => d.code === 'SIM001')
expect(denied.map((d) => [d.line, d.message])).toEqual([
[8, '平台不允许使用 System.Diagnostics.Process.Start(启动其他程序)'],
[9, '平台不允许使用 System.Net.Http.HttpClient(网络访问)'],
[10, '平台不允许使用 System.Reflection.MethodBase.Invoke(反射)'],
[11, '平台不允许使用 dynamic(dynamic 动态调用)'],
])
expect(result.diagnostics.filter((d) => d.file.includes('AssemblyInfo'))).toEqual([])
}, 30000)
test.skipIf(config.sandbox === 'none')('CPU 时间上限:死循环的程序到时被结束,并提示检查死循环', async () => {
const limit = config.cpuLimitSeconds
config.cpuLimitSeconds = 2
try {
const { runner } = await runMain('cpu', ' long i = 0;\n while (true) i++;')
const { code, signal } = await runner.finished()
expect(cpuLimitMessage(code, signal)).toContain('死循环')
} finally {
config.cpuLimitSeconds = limit
}
}, 30000)