部署: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:
1 parent
081ac6f924
commit
697f31e9ce
25 files changed
+869
-52
No files matched your search
@@ -0,0 +1,58 @@
|
||||
// 访问口令:账号系统(第 2 阶段)做好之前,用一个统一口令挡住外人。
|
||||
// 输对口令后发一个 cookie,值是口令的哈希;老师换口令后,旧 cookie 全部失效。
|
||||
import type { Context } from 'hono'
|
||||
import { setCookie } from 'hono/cookie'
|
||||
import { config } from './config'
|
||||
|
||||
const COOKIE = 'iot_access'
|
||||
// 全班通常共用学校的一个出口 IP,上限不能太小,否则几个同学输错就把全班挡在外面
|
||||
const MAX_FAILURES = 30
|
||||
const FAILURE_WINDOW_MS = 10 * 60_000
|
||||
|
||||
const token = config.accessPassword
|
||||
? new Bun.CryptoHasher('sha256').update(`iot-sim-access:${config.accessPassword}`).digest('hex')
|
||||
: ''
|
||||
|
||||
export const accessRequired = token !== ''
|
||||
|
||||
/** 请求是否带着有效的口令 cookie(没设口令时总是放行) */
|
||||
export function hasAccess(cookieHeader: string | null) {
|
||||
if (!accessRequired) return true
|
||||
const value = cookieHeader
|
||||
?.split(';')
|
||||
.map((part) => part.trim().split('='))
|
||||
.find(([name]) => name === COOKIE)?.[1]
|
||||
return value === token
|
||||
}
|
||||
|
||||
/** 每个 IP 一段时间内的失败次数,防止暴力猜口令 */
|
||||
const failures = new Map<string, { count: number; since: number }>()
|
||||
|
||||
export function tooManyFailures(ip: string) {
|
||||
const entry = failures.get(ip)
|
||||
if (!entry) return false
|
||||
if (Date.now() - entry.since > FAILURE_WINDOW_MS) {
|
||||
failures.delete(ip)
|
||||
return false
|
||||
}
|
||||
return entry.count >= MAX_FAILURES
|
||||
}
|
||||
|
||||
/** 检查口令;对了就设置 cookie */
|
||||
export function login(c: Context, password: string, ip: string) {
|
||||
if (password !== config.accessPassword) {
|
||||
const entry = failures.get(ip) ?? { count: 0, since: Date.now() }
|
||||
entry.count++
|
||||
failures.set(ip, entry)
|
||||
return false
|
||||
}
|
||||
failures.delete(ip)
|
||||
setCookie(c, COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
secure: new URL(c.req.url).protocol === 'https:' || c.req.header('x-forwarded-proto') === 'https',
|
||||
path: '/',
|
||||
maxAge: 180 * 24 * 3600,
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Subprocess } from 'bun'
|
||||
import type { Diagnostic } from '@iot/shared'
|
||||
import { RUNNER_DLL, readLines } from './runner-process'
|
||||
import { config } from './config'
|
||||
import { readLines } from './runner-process'
|
||||
|
||||
export interface CompileResult {
|
||||
ok: boolean
|
||||
@@ -37,11 +38,11 @@ class CompileService {
|
||||
/** 服务器启动时调用,提前完成编译器的加载和预热 */
|
||||
start() {
|
||||
if (this.ready) return this.ready
|
||||
const proc = Bun.spawn(['dotnet', RUNNER_DLL, 'compile-server'], {
|
||||
const proc = Bun.spawn(['dotnet', config.runnerDll, 'compile-server'], {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'inherit',
|
||||
env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1' },
|
||||
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: process.env.HOME ?? '/tmp', DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1' },
|
||||
})
|
||||
this.proc = proc
|
||||
this.ready = new Promise<void>((resolveReady) => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// 服务器配置:都可以用环境变量覆盖,开发时不设置也能直接运行。
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const env = process.env
|
||||
|
||||
/** 项目根目录。部署时服务端打包成单个文件,位置变了,用 APP_ROOT 指定 */
|
||||
export const ROOT = env.APP_ROOT ?? resolve(import.meta.dir, '../..')
|
||||
|
||||
export const config = {
|
||||
port: Number(env.PORT ?? 3000),
|
||||
/** 运行时程序(编译服务和运行进程共用),部署时用 Release 版 */
|
||||
runnerDll: resolve(ROOT, env.RUNNER_DLL ?? 'runtime/Sim.Runner/bin/Debug/net10.0/Sim.Runner.dll'),
|
||||
/** 每次运行的临时工程目录放在这里 */
|
||||
runsDir: resolve(ROOT, env.RUNS_DIR ?? '.runs'),
|
||||
samplesDir: resolve(ROOT, env.SAMPLES_DIR ?? 'samples'),
|
||||
/** 打包好的网页(web/dist);目录不存在时不提供网页,开发时由 Vite 提供 */
|
||||
staticDir: resolve(ROOT, env.STATIC_DIR ?? 'web/dist'),
|
||||
/** 学生程序的沙箱:bwrap(bubblewrap)或 none;默认装了 bwrap 就用 */
|
||||
sandbox: (env.SIM_SANDBOX ?? (Bun.which('bwrap') ? 'bwrap' : 'none')) as 'bwrap' | 'none',
|
||||
/** 学生程序托管堆上限(MB) */
|
||||
heapLimitMb: Number(env.SIM_HEAP_LIMIT_MB ?? 128),
|
||||
/**
|
||||
* 一次运行最多用多少秒 CPU。正常的界面程序大部分时间在等事件,一小时也用不了几秒;
|
||||
* 写出死循环的程序会一直占满一个核(服务器只有 2 核),到时间就被结束
|
||||
*/
|
||||
cpuLimitSeconds: Number(env.SIM_CPU_LIMIT_SECONDS ?? 120),
|
||||
/** 全站同时运行的学生程序最多多少个(两个班约 100 人) */
|
||||
maxRuns: Number(env.SIM_MAX_RUNS ?? 150),
|
||||
/** 一次运行最长多少分钟,到时自动结束 */
|
||||
maxRunMinutes: Number(env.SIM_MAX_RUN_MINUTES ?? 60),
|
||||
/** 访问口令;不设置表示不需要口令(本机开发) */
|
||||
accessPassword: env.ACCESS_PASSWORD ?? '',
|
||||
timeZone: env.TZ ?? 'Asia/Shanghai',
|
||||
}
|
||||
+35
-3
@@ -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,
|
||||
|
||||
+28
-14
@@ -4,9 +4,14 @@ import type { Subprocess } from 'bun'
|
||||
import type { ClientMessage, RunnerInput, RunnerMessage, ServerMessage, SourceFile } from '@iot/shared'
|
||||
import { Board } from './board'
|
||||
import { compileService } from './compile-service'
|
||||
import { ROOT, RUNNER_DLL, readLines } from './runner-process'
|
||||
import { config } from './config'
|
||||
import { readLines } from './runner-process'
|
||||
import { cpuLimitMessage, runCommand, sandboxError } from './sandbox'
|
||||
|
||||
const RUNS_DIR = join(ROOT, '.runs')
|
||||
const RUNS_DIR = config.runsDir
|
||||
|
||||
/** 全站正在运行的学生程序数 */
|
||||
let liveRuns = 0
|
||||
|
||||
/** 服务器启动时清掉上次异常退出遗留的运行目录 */
|
||||
export async function cleanupStaleRuns() {
|
||||
@@ -37,6 +42,7 @@ export class RunSession {
|
||||
}
|
||||
|
||||
private async startNow(files: SourceFile[]) {
|
||||
if (sandboxError) throw new Error(sandboxError)
|
||||
await this.dispose()
|
||||
const generation = ++this.generation
|
||||
await mkdir(RUNS_DIR, { recursive: true })
|
||||
@@ -61,20 +67,24 @@ export class RunSession {
|
||||
await this.cleanup()
|
||||
return
|
||||
}
|
||||
if (liveRuns >= config.maxRuns) {
|
||||
this.send({ type: 'serverError', message: '服务器繁忙:同时运行的程序太多,请稍后再试' })
|
||||
this.send({ type: 'status', state: 'stopped' })
|
||||
await this.cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(['dotnet', RUNNER_DLL, 'run', dir], {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: '1',
|
||||
DOTNET_NOLOGO: '1',
|
||||
// 启动时就带上传感器读数,学生程序第一次 SetData() 前不必等消息
|
||||
SIM_BOARD: JSON.stringify(this.board.runnerInputs()),
|
||||
},
|
||||
})
|
||||
// 启动时就带上传感器读数,学生程序第一次 SetData() 前不必等消息
|
||||
const { cmd, env } = runCommand(dir, { SIM_BOARD: JSON.stringify(this.board.runnerInputs()) })
|
||||
const proc = Bun.spawn(cmd, { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env })
|
||||
this.proc = proc
|
||||
liveRuns++
|
||||
// 运行太久自动结束,防止忘了关的程序一直占着服务器
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.proc !== proc) return
|
||||
this.send({ type: 'console', text: `\n程序已运行 ${config.maxRunMinutes} 分钟,自动结束。\n` })
|
||||
proc.kill()
|
||||
}, config.maxRunMinutes * 60_000)
|
||||
|
||||
void readLines(proc.stdout, (line) => {
|
||||
let message: RunnerMessage
|
||||
@@ -94,8 +104,12 @@ export class RunSession {
|
||||
void readLines(proc.stderr, (line) => this.send({ type: 'serverError', message: line }))
|
||||
|
||||
proc.exited.then(async (code) => {
|
||||
liveRuns--
|
||||
clearTimeout(timeout)
|
||||
if (this.proc !== proc) return
|
||||
this.proc = undefined
|
||||
const cpuMessage = cpuLimitMessage(code, proc.signalCode)
|
||||
if (cpuMessage) this.send({ type: 'console', text: cpuMessage })
|
||||
this.send({ type: 'exited', code })
|
||||
this.send({ type: 'status', state: 'stopped' })
|
||||
await this.cleanup()
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
export const ROOT = resolve(import.meta.dir, '../..')
|
||||
export const RUNNER_DLL = resolve(ROOT, 'runtime/Sim.Runner/bin/Debug/net10.0/Sim.Runner.dll')
|
||||
|
||||
/** 逐行读取子进程输出 */
|
||||
export async function readLines(stream: ReadableStream<Uint8Array>, onLine: (line: string) => void) {
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
import { config } from './config'
|
||||
|
||||
const SAMPLES_DIR = resolve(import.meta.dir, '../../samples')
|
||||
const SAMPLES_DIR = config.samplesDir
|
||||
|
||||
export interface SampleInfo {
|
||||
id: string
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// 学生程序的沙箱:每次运行用 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,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// 提供打包好的网页文件(web/dist)。带哈希的 assets 长期缓存,index.html 每次都检查更新。
|
||||
import { normalize, resolve } from 'node:path'
|
||||
import { config } from './config'
|
||||
|
||||
export async function serveStatic(pathname: string) {
|
||||
const root = config.staticDir
|
||||
let path = resolve(root, `.${normalize(decodeURIComponent(pathname))}`)
|
||||
if (!path.startsWith(`${root}/`) || !(await Bun.file(path).exists())) path = resolve(root, 'index.html')
|
||||
const file = Bun.file(path)
|
||||
if (!(await file.exists())) return new Response('网页还没有打包:bun run --filter web build', { status: 404 })
|
||||
const immutable = path.startsWith(`${root}/assets/`)
|
||||
return new Response(file, {
|
||||
headers: { 'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache' },
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user