部署: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

+35 -3
View File
@@ -1,12 +1,40 @@
import { Hono } from 'hono'
import type { Server } from 'bun'
import type { ClientMessage, ServerMessage } from '@iot/shared'
import { accessRequired, hasAccess, login, tooManyFailures } from './access'
import { compileService } from './compile-service'
import { config } from './config'
import { cleanupStaleRuns, RunSession } from './run-session'
import { listSamples, loadSample } from './samples'
import { checkSandbox, sandboxError } from './sandbox'
import { serveStatic } from './static'
await cleanupStaleRuns()
await checkSandbox()
if (sandboxError) console.error(sandboxError)
const app = new Hono()
const app = new Hono<{ Bindings: Server<SocketData> }>()
/** 客户端 IP:经过 Caddy 转发时由 Caddy 填在 X-Real-IP 里(它已核对过前面代理的 X-Forwarded-For) */
function clientIp(req: Request, server: Server<SocketData>) {
return req.headers.get('x-real-ip') || server.requestIP(req)?.address || ''
}
app.get('/api/auth', (c) => c.json({ required: accessRequired, ok: hasAccess(c.req.header('cookie') ?? null) }))
app.post('/api/login', async (c) => {
const ip = clientIp(c.req.raw, c.env)
if (tooManyFailures(ip)) return c.json({ ok: false, message: '输错次数太多,请 10 分钟后再试' }, 429)
const { password } = await c.req.json<{ password?: string }>().catch(() => ({ password: undefined }))
if (!login(c, String(password ?? ''), ip)) return c.json({ ok: false, message: '口令不对' }, 401)
return c.json({ ok: true })
})
// 其余接口都要先输入口令
app.use('/api/*', async (c, next) => {
if (!hasAccess(c.req.header('cookie') ?? null)) return c.json({ message: '请先输入访问口令' }, 401)
await next()
})
app.get('/api/samples', async (c) => c.json(await listSamples()))
@@ -15,18 +43,22 @@ app.get('/api/samples/:id', async (c) => {
return files ? c.json(files) : c.notFound()
})
// 打包好的网页(部署时);开发时网页由 Vite 提供
app.get('*', (c) => serveStatic(new URL(c.req.url).pathname))
interface SocketData {
session?: RunSession
}
const server = Bun.serve({
port: Number(process.env.PORT ?? 3000),
port: config.port,
fetch(req, server) {
if (new URL(req.url).pathname === '/ws') {
if (!hasAccess(req.headers.get('cookie'))) return new Response('请先输入访问口令', { status: 401 })
if (server.upgrade(req, { data: {} })) return
return new Response('WebSocket 升级失败', { status: 400 })
}
return app.fetch(req)
return app.fetch(req, server)
},
websocket: {
data: {} as SocketData,