部署: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,10 @@
|
||||
# 只把构建需要的源码送进 Docker
|
||||
**/node_modules
|
||||
**/bin
|
||||
**/obj
|
||||
**/dist
|
||||
.runs
|
||||
.git
|
||||
docs
|
||||
*.pdf
|
||||
.env
|
||||
@@ -0,0 +1,5 @@
|
||||
# 复制为 .env 后修改。.env 不进 git。
|
||||
# 学生进入平台要输入的口令,换口令后所有人要重新输入
|
||||
ACCESS_PASSWORD=请改成不容易猜到的口令
|
||||
# 平台最多使用的内存,默认 4g
|
||||
# APP_MEM_LIMIT=4g
|
||||
@@ -16,3 +16,6 @@ obj/
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# 部署配置(含访问口令)
|
||||
.env
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# 物联网编程仿真平台:一个镜像里包含 Bun 服务端、打包好的网页、.NET 运行时和 bubblewrap 沙箱。
|
||||
# 构建:docker compose build;部署说明见 deploy/README.md
|
||||
|
||||
# ---- C# 运行时(编译服务 + 仿真库) ----
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS runtime-build
|
||||
WORKDIR /src
|
||||
COPY runtime/ runtime/
|
||||
RUN dotnet build runtime/Runtime.slnx -c Release
|
||||
|
||||
# ---- 网页和服务端 ----
|
||||
FROM oven/bun:1.4.3 AS web-build
|
||||
WORKDIR /src
|
||||
COPY package.json bun.lock ./
|
||||
COPY shared/package.json shared/
|
||||
COPY server/package.json server/
|
||||
COPY web/package.json web/
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY shared/ shared/
|
||||
COPY server/ server/
|
||||
COPY web/ web/
|
||||
RUN cd web && bun run build
|
||||
# 服务端打包成单个文件,运行镜像里不需要 node_modules
|
||||
RUN bun build server/src/index.ts --target=bun --outfile=server.js
|
||||
|
||||
# ---- 运行镜像 ----
|
||||
FROM mcr.microsoft.com/dotnet/runtime:10.0
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends bubblewrap tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=oven/bun:1.4.3 /usr/local/bin/bun /usr/local/bin/bun
|
||||
WORKDIR /app
|
||||
COPY --from=runtime-build /src/runtime/Sim.Runner/bin/Release/net10.0/ runtime/
|
||||
COPY --from=web-build /src/web/dist/ web/dist/
|
||||
COPY --from=web-build /src/server.js ./
|
||||
COPY samples/ samples/
|
||||
ENV APP_ROOT=/app \
|
||||
RUNNER_DLL=runtime/Sim.Runner.dll \
|
||||
RUNS_DIR=/tmp/runs \
|
||||
SIM_SANDBOX=bwrap \
|
||||
TZ=Asia/Shanghai \
|
||||
PORT=3000 \
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
# 镜像自带的非 root 用户
|
||||
USER $APP_UID
|
||||
EXPOSE 3000
|
||||
CMD ["bun", "server.js"]
|
||||
@@ -13,10 +13,11 @@
|
||||
| `runtime/` | .NET 10:仿真 WinForms(`Sim.WinForms`)、教材设备类库(`Sim.Devices`)、运行器(`Sim.Runner`) |
|
||||
| `samples/` | 示例工程(按教材任务整理) |
|
||||
| `docs/` | 设计方案(`设计方案.md`)、教材逐任务摘要(`教材摘要/`) |
|
||||
| `deploy/` | 部署:Caddyfile、部署脚本和说明(`deploy/README.md`) |
|
||||
|
||||
## 本地开发
|
||||
|
||||
需要 Bun 和 .NET 10 SDK。
|
||||
需要 Bun 和 .NET 10 SDK;装了 bubblewrap(`bwrap`)时,学生程序在本机也会放进沙箱运行,和服务器一致。
|
||||
|
||||
```bash
|
||||
bun install # 安装前端和后端依赖
|
||||
@@ -34,6 +35,10 @@ cd ../web && bun test src # 窗体设计器:Designer.cs 读写、编辑操
|
||||
bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序
|
||||
```
|
||||
|
||||
## 部署
|
||||
|
||||
Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。在开发机执行 `deploy/deploy.sh 用户@服务器`,详见 [deploy/README.md](deploy/README.md)。
|
||||
|
||||
## 运行器的两种模式
|
||||
|
||||
`Sim.Runner` 通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条):
|
||||
@@ -65,8 +70,8 @@ bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序
|
||||
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
|
||||
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
|
||||
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
|
||||
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、访问口令、Docker Compose 部署脚本。
|
||||
|
||||
尚未完成:
|
||||
|
||||
- 学生代码目前直接在本机进程里运行,**还没有沙箱**,只能在本机开发时使用;部署前要改为在 Docker 容器中运行。
|
||||
- 教材项目 4–8 的其余任务(需要摄像头、线程 API、ListBox、数据库、TrackBar、PictureBox、图表)、账号和班级、自动评测。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 部署:先在 .env 里写好 ACCESS_PASSWORD(参考 .env.example),再 docker compose up -d
|
||||
# HTTPS 由 Nginx Proxy Manager 负责,它在 npm_proxy 网络里转发到 iot-sim:80;步骤见 deploy/README.md
|
||||
services:
|
||||
app:
|
||||
image: iot-sim:latest
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ACCESS_PASSWORD: ${ACCESS_PASSWORD:?请在 .env 里设置 ACCESS_PASSWORD}
|
||||
# 以下可在 .env 里调整,说明见 deploy/README.md
|
||||
SIM_MAX_RUNS: ${SIM_MAX_RUNS:-150}
|
||||
SIM_HEAP_LIMIT_MB: ${SIM_HEAP_LIMIT_MB:-128}
|
||||
SIM_CPU_LIMIT_SECONDS: ${SIM_CPU_LIMIT_SECONDS:-120}
|
||||
SIM_MAX_RUN_MINUTES: ${SIM_MAX_RUN_MINUTES:-60}
|
||||
# 容器文件系统只读,只有 /tmp(学生工程的临时目录)可写
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=1g
|
||||
cap_drop: [ALL]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
# bubblewrap 要创建用户命名空间、挂载新的 /proc,Docker 默认的 seccomp 规则和 /proc 遮罩会拦住它。
|
||||
# 放开后,学生程序仍然在 bubblewrap 里:断网、看不到其他进程和文件、不是 root。
|
||||
- seccomp=unconfined
|
||||
- apparmor=unconfined
|
||||
- systempaths=unconfined
|
||||
# 整个平台的资源上限(两个班同时运行约需 1.5 GB),可在 .env 里用 APP_MEM_LIMIT 调整
|
||||
mem_limit: ${APP_MEM_LIMIT:-4g}
|
||||
pids_limit: 4096
|
||||
expose: ["3000"]
|
||||
networks: [default]
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
depends_on: [app]
|
||||
expose:
|
||||
- 80
|
||||
networks:
|
||||
default:
|
||||
npm_proxy:
|
||||
# NPM 里转发到 http://iot-sim:80
|
||||
aliases: [iot-sim]
|
||||
volumes:
|
||||
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
networks:
|
||||
npm_proxy:
|
||||
external: true
|
||||
@@ -0,0 +1,17 @@
|
||||
# HTTPS 由 Nginx Proxy Manager 负责,Caddy 只在 80 端口收 HTTP,转发给应用(包括 WebSocket /ws)。
|
||||
{
|
||||
auto_https off
|
||||
servers {
|
||||
# NPM 在 Docker 内网里,信任它带来的 X-Forwarded-For,取到学生的真实 IP。
|
||||
# 如果 NPM 前面还有公网上的 CDN,把 CDN 的 IP 段加在 private_ranges 后面。
|
||||
trusted_proxies static private_ranges
|
||||
trusted_proxies_strict
|
||||
}
|
||||
}
|
||||
|
||||
:80 {
|
||||
encode zstd gzip
|
||||
reverse_proxy app:3000 {
|
||||
header_up X-Real-IP {client_ip}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# 部署
|
||||
|
||||
服务器上的结构:
|
||||
|
||||
```
|
||||
学生浏览器 ──HTTPS──▶ Nginx Proxy Manager(443) ──npm_proxy 网络──▶ caddy(iot-sim:80) ──▶ app(Bun + .NET + bubblewrap)
|
||||
```
|
||||
|
||||
- **app**:Bun 服务端、打包好的网页、C# 编译服务。学生程序每次运行都放进 bubblewrap 沙箱:断网,看不到服务器文件和其他进程,工程目录只读,限制内存(128 MB)、CPU 时间(120 秒)和运行时长(60 分钟)。
|
||||
- **caddy**:只收 HTTP,转发给 app,压缩网页;从 NPM 转发来的 `X-Forwarded-For` 里取学生的真实 IP。
|
||||
- 不映射宿主机端口,NPM 通过外部网络 `npm_proxy` 访问 `iot-sim:80`。
|
||||
|
||||
## 服务器要求
|
||||
|
||||
- Linux,Docker 和 Docker Compose v2,已有外部网络 `npm_proxy`(NPM 所在的网络)。
|
||||
- 2 核 8G 足够两个班同时使用(实测 100 个程序同时运行约 1.1 GB)。
|
||||
- 沙箱需要宿主机允许非特权用户命名空间。**Debian 12(本平台的服务器)默认允许**,不用设置,可用 `sysctl kernel.unprivileged_userns_clone` 确认输出为 1。
|
||||
- **Ubuntu 23.10 及以上**默认禁止,沙箱会起不来,需要放开:
|
||||
|
||||
```bash
|
||||
echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/60-userns.conf
|
||||
sudo sysctl --system
|
||||
```
|
||||
|
||||
其他发行版一般不需要。服务器启动时会自检沙箱,不通过会在日志里打印原因,并拒绝运行学生代码(不会在无隔离的情况下运行)。
|
||||
|
||||
## 第一次部署
|
||||
|
||||
1. 在服务器上准备目录和口令:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/iot-sim && cd ~/iot-sim
|
||||
# 部署脚本会上传 .env.example;也可以直接新建 .env:
|
||||
echo 'ACCESS_PASSWORD=换成你的口令' > .env
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
2. 在本机(开发机)执行:
|
||||
|
||||
```bash
|
||||
deploy/deploy.sh 用户@服务器
|
||||
```
|
||||
|
||||
脚本在本机构建镜像,通过 ssh 传到服务器并启动。日志最后应看到"C# 编译服务已就绪",且没有"沙箱无法启动"。
|
||||
|
||||
3. 在 Nginx Proxy Manager 里添加 Proxy Host:
|
||||
- Domain Names:已备案的域名
|
||||
- Scheme:`http`,Forward Hostname / IP:`iot-sim`,Forward Port:`80`
|
||||
- **打开 Websockets Support**(运行程序靠 WebSocket,不打开会一直连不上)
|
||||
- SSL:申请证书,打开 Force SSL
|
||||
|
||||
4. 浏览器打开域名,输入口令进入。
|
||||
|
||||
## 日常操作
|
||||
|
||||
在服务器的 `~/iot-sim` 目录下:
|
||||
|
||||
| 做什么 | 命令 |
|
||||
|---|---|
|
||||
| 更新到新版本 | 在本机再执行一次 `deploy/deploy.sh 用户@服务器` |
|
||||
| 换口令 | 修改 `.env` 后 `docker compose up -d`(所有人要重新输入口令) |
|
||||
| 看日志 | `docker compose logs -f app` |
|
||||
| 看资源占用 | `docker stats` |
|
||||
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
|
||||
|
||||
## 可调参数
|
||||
|
||||
写在 `.env` 里,改完 `docker compose up -d` 生效。
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `ACCESS_PASSWORD` | (必填) | 访问口令 |
|
||||
| `APP_MEM_LIMIT` | `4g` | 整个平台的内存上限 |
|
||||
| `SIM_MAX_RUNS` | `150` | 全站同时运行的学生程序上限,超过时提示"服务器繁忙" |
|
||||
| `SIM_HEAP_LIMIT_MB` | `128` | 每个学生程序的内存上限 |
|
||||
| `SIM_CPU_LIMIT_SECONDS` | `120` | 每次运行累计 CPU 时间上限(防死循环) |
|
||||
| `SIM_MAX_RUN_MINUTES` | `60` | 每次运行最长时间 |
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在本机构建镜像,传到服务器后启动(服务器不需要访问 Docker Hub,也不用自己编译)。
|
||||
# 用法:deploy/deploy.sh 用户@服务器 [服务器上的目录,默认 iot-sim]
|
||||
# 第一次部署前,先按 deploy/README.md 在服务器上准备好 .env
|
||||
set -euo pipefail
|
||||
|
||||
host=${1:?用法:deploy/deploy.sh 用户@服务器 [目录]}
|
||||
dir=${2:-iot-sim}
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "== 构建镜像"
|
||||
docker compose build app
|
||||
docker pull -q caddy:2-alpine
|
||||
|
||||
echo "== 上传配置文件到 $host:$dir"
|
||||
ssh "$host" "mkdir -p $dir/deploy"
|
||||
scp -q compose.yaml .env.example "$host:$dir/"
|
||||
scp -q deploy/Caddyfile "$host:$dir/deploy/"
|
||||
|
||||
echo "== 上传镜像(约 130 MB)"
|
||||
docker save iot-sim:latest | gzip -1 | ssh "$host" 'gunzip | docker load'
|
||||
if ! ssh "$host" 'docker image inspect caddy:2-alpine >/dev/null 2>&1'; then
|
||||
docker save caddy:2-alpine | gzip -1 | ssh "$host" 'gunzip | docker load'
|
||||
fi
|
||||
|
||||
echo "== 启动"
|
||||
ssh "$host" "cd $dir && test -f .env || { echo '服务器上还没有 .env:cp .env.example .env 后设置 ACCESS_PASSWORD'; exit 1; }
|
||||
docker compose up -d --no-build --remove-orphans && docker image prune -f >/dev/null
|
||||
sleep 5 && docker compose logs --tail 5 app"
|
||||
+21
-16
@@ -29,7 +29,7 @@
|
||||
| 学生代码在哪运行 | 服务器(沙箱隔离) | 只供本校一两个班同时使用;原生 .NET 多线程行为真实;浏览器轻量 |
|
||||
| 前端 | Vue 3 + Naive UI + Chart.js | 开发者熟悉 TypeScript 生态 |
|
||||
| 后端 | Bun + SQLite | 一人开发,单机足够 |
|
||||
| 部署 | 公网云服务器 | 需办 ICP 备案 |
|
||||
| 部署 | 公网云服务器(2 核 8G,已备案);HTTPS 由服务器上已有的 Nginx Proxy Manager 负责 | 本平台只在 Docker 网络里暴露 HTTP |
|
||||
| 开发人力 | 一人 | 范围和路线图按此控制 |
|
||||
|
||||
## 学生使用体验
|
||||
@@ -113,7 +113,7 @@ flowchart TB
|
||||
BoardState["实训台状态<br/>传感器输入、继电器输出、LED 文字"]
|
||||
Data["账号、班级、实验、评测(SQLite)"]
|
||||
end
|
||||
subgraph Runtime[".NET 10 运行时(部署时放进 Docker 沙箱)"]
|
||||
subgraph Runtime[".NET 10 运行时(运行进程在 bubblewrap 沙箱里)"]
|
||||
Compiler["编译服务(Roslyn)<br/>全服务器共用一个常驻进程"]
|
||||
Runner["运行进程(每次运行一个)<br/>仿真 WinForms + 教材设备类库"]
|
||||
end
|
||||
@@ -170,28 +170,31 @@ IOT/
|
||||
| C# 运行时 | 代码补全 | 计划:Roslyn `CompletionService` |
|
||||
| 工程 | 仓库 | Bun workspaces 单仓库 |
|
||||
| 工程 | 测试 | `bun test`(端到端、单元、压力测试);计划:用教材参考工程做回归测试、Playwright |
|
||||
| 部署 | 服务器 | 一台 Linux 云服务器,4 核 8 GB 起步 |
|
||||
| 部署 | 沙箱 | 计划:Docker(断网、只读、限制资源) |
|
||||
| 部署 | HTTPS / 静态文件 | 计划:Caddy |
|
||||
| 部署 | 进程管理 | 计划:Docker Compose |
|
||||
| 部署 | 服务器 | 一台 Linux 云服务器,2 核 8 GB |
|
||||
| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 |
|
||||
| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 |
|
||||
| 部署 | 反向代理 / 压缩 | Caddy(只收 HTTP),网页文件由 Bun 直接提供 |
|
||||
| 部署 | 进程管理 | Docker Compose;镜像在开发机构建后经 ssh 传到服务器(`deploy/deploy.sh`) |
|
||||
| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 |
|
||||
|
||||
## 安全与服务器资源
|
||||
|
||||
在公网上运行学生代码,必须做三层防护;第 0 阶段实测,100 个学生程序同时运行合计约 1.1 GB 内存。
|
||||
|
||||
**安全措施**(部署前完成)
|
||||
**安全措施**(1、2 已完成;3 在第 2 阶段之前先用访问口令代替)
|
||||
|
||||
1. **沙箱隔离**:每次运行放进 Docker 容器,禁止联网、文件系统只读,限制内存(如 128 MB)、CPU、进程数和运行时长。
|
||||
2. **编译时白名单**:只允许使用白名单内的命名空间;用到文件读写、`Process`、反射等时,直接给出中文报错。
|
||||
3. **账号管控**:不开放自由注册,由老师用班级码或批量导入创建账号。
|
||||
1. **沙箱隔离**:每次运行用 bubblewrap 放进独立的命名空间:断网,只能看到系统只读文件、运行时程序和自己的工程目录(只读),/tmp 是独立内存盘,看不到其他进程,不继承服务器的环境变量。另外限制托管堆 128 MB、累计 CPU 时间 120 秒(防死循环占满 CPU)、运行时长 60 分钟,降低优先级(nice 10),全站同时运行上限 150 个。整个平台再放在 Docker 容器里:文件系统只读、去掉全部 capabilities、限制内存和进程数。服务器启动时自检沙箱,不可用时拒绝运行学生代码。
|
||||
- 没有选"每次运行一个 Docker 容器":每次多 0.3–0.5 秒,而且应用要能操作 Docker 守护进程,这本身权限过大。代价是应用容器要放开 seccomp / AppArmor,才能让 bubblewrap 创建命名空间。
|
||||
2. **编译检查**:网络(System.Net)、反射(System.Reflection,AssemblyInfo.cs 里的特性除外)、启动其他程序(Process)、调用本机代码(DllImport、Marshal)、加载程序集、进程间通信、dynamic,编译时直接给出中文报错(SIM001)。文件读写由沙箱限制,不在编译时禁止(项目 7、8 要读图片)。
|
||||
3. **账号管控**:不开放自由注册,由老师用班级码或批量导入创建账号(第 2 阶段)。在此之前,全站用一个访问口令(`.env` 里设置,换口令后所有人要重新输入),按 IP 限制输错次数。
|
||||
|
||||
**资源估算**
|
||||
|
||||
- 编译器做成常驻进程,全服务器共用一个,约占 150 MB;学生程序运行时不加载编译器。
|
||||
- 每个运行中的学生程序平均约 11 MB(按 PSS 计,共享的 .NET 运行库按进程数平摊;单看 RSS 约 47 MB)。
|
||||
- 每个学生同时只运行一个程序,闲置超时自动结束。
|
||||
- 两个班约 100 人同时运行,合计约 1.3 GB;4 核 8 GB 的服务器有充足余量。加上 Docker 沙箱后需要复测。
|
||||
- 两个班约 100 人同时运行,合计约 1.3 GB;8 GB 内存有充足余量。
|
||||
- 2026-10-22 复测(Docker 容器限制为 2 核,加 bubblewrap 沙箱):50 人同一瞬间点"运行",4.4 秒内全部显示窗体(中位 2.3 秒),容器共 643 MB;100 人 8.4 秒(中位 4.1 秒),1.1 GB。瓶颈是 CPU(编译和启动),实际上课时点击会分散开。
|
||||
- SQLite 开启 WAL 模式,定时备份。
|
||||
|
||||
## 实验规划
|
||||
@@ -227,7 +230,7 @@ IOT/
|
||||
```mermaid
|
||||
flowchart TB
|
||||
P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
|
||||
P1["阶段 1 · MVP(进行中,约 3 个月)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔"]
|
||||
P1["阶段 1 · MVP(进行中,约 3 个月)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、访问口令、Docker Compose)✔"]
|
||||
P2["阶段 2 · 教学闭环(约 2 个月)<br/>班级码登录、提交作业、自动评测、老师看板、成绩导出;一个班试用"]
|
||||
P3["阶段 3 · 扩展(下学期)<br/>教材项目 4–8:摄像头、线程、SQLite 模拟数据库、图表、虚拟时钟"]
|
||||
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"]
|
||||
@@ -249,7 +252,7 @@ flowchart TB
|
||||
| 100 个程序同时运行 | 合计约 1.1 GB,平均每个 11 MB,另加编译服务约 150 MB |
|
||||
| 100 个程序同时点击 | 往返中位数约 60 ms,P95 约 75 ms |
|
||||
|
||||
验证中的调整:第一版每个运行进程都加载 Roslyn,单个约 170 MB;改为常驻编译服务后,运行进程不再加载编译器,编译也从约 0.9 s 降到几十毫秒。沙箱尚未接入,以上数字需在接入 Docker 后复测。
|
||||
验证中的调整:第一版每个运行进程都加载 Roslyn,单个约 170 MB;改为常驻编译服务后,运行进程不再加载编译器,编译也从约 0.9 s 降到几十毫秒。接入沙箱后在 2 核环境下的复测见上文"资源估算"。
|
||||
|
||||
**第 1 阶段进展**:
|
||||
|
||||
@@ -259,6 +262,8 @@ flowchart TB
|
||||
- 2026-10-22:设计器撤销 / 重做(撤销"生成事件处理方法"时连同插入的方法一起撤掉,不覆盖学生已写的代码);工具箱、属性、事件窗口全部显示中文,英文名放在悬停提示里;支持中文的控件名和类名。
|
||||
- 2026-10-22:多窗体:新建窗体(与 VS 新建窗体相同的两个文件,沿用工程命名空间)、删除窗体(启动窗体不能删);标签页按窗体分组;运行时点哪个窗口哪个到最前。新增示例:项目 1 任务 1 登录与增加用户(两个窗体的 Designer.cs 由设计器引擎生成)。
|
||||
- 2026-10-22:整理教材示例:项目 1 任务 2(计算器、多态)、项目 4 任务 1(红外对射)、项目 4 任务 3(报警灯)、项目 5 任务 1(火焰)。界面取自配套参考工程,用设计器引擎按书上的控件名改名。至此 10 个示例,全部有"能编译"检查和端到端行为测试。项目 4–8 其余任务需要的能力(摄像头、线程 API、ListBox、数据库、TrackBar、PictureBox、图表)放到第 3 阶段。
|
||||
- 2026-10-22:虚拟实训台按自身宽度自适应排列,可以收起成一行状态(传感器开关仍可点),给窗体显示区腾地方。
|
||||
- 2026-10-22:部署:学生程序放进 bubblewrap 沙箱(断网、只读、看不到其他进程和服务器环境变量;限制堆内存、CPU 时间、运行时长,全站并发上限);编译检查禁止网络、反射、启动进程等 API;访问口令;Bun 直接提供打包好的网页;Dockerfile + Docker Compose(Caddy 接入 NPM 的 npm_proxy 网络)+ 部署脚本,本机按服务器配置(2 核)完整验证。
|
||||
|
||||
## 技术风险与待确认事项
|
||||
|
||||
@@ -274,9 +279,9 @@ flowchart TB
|
||||
| 图片处理 | System.Drawing 在 Linux 上不受支持 | 自实现 `Image` / `Bitmap` 等少量类型,只传递和保存图片 |
|
||||
| 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 提供可调、可加速的虚拟时钟 |
|
||||
| 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms |
|
||||
| 并发资源 | 100 个程序同时运行时的内存和 CPU | 已压测:100 个程序合计约 1.1 GB;闲置超时回收 |
|
||||
| 公网安全 | 恶意或无意的危险代码 | 沙箱 + 白名单 + 账号管控 |
|
||||
| ICP 备案 | 办理需要数周 | 尽早启动 |
|
||||
| 并发资源 | 100 个程序同时运行时的内存和 CPU | 已压测:100 个程序合计约 1.1 GB;2 核下 100 人同时启动约 8 秒;CPU 时间上限防死循环,运行超时回收 |
|
||||
| 公网安全 | 恶意或无意的危险代码 | bubblewrap 沙箱 + 编译检查(已完成)+ 访问口令,第 2 阶段换成账号 |
|
||||
| ICP 备案 | 办理需要数周 | 已备案 |
|
||||
|
||||
**待确认**
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
|
||||
namespace Sim.Runner;
|
||||
|
||||
/// <summary>
|
||||
/// 编译时检查学生代码用到的 API:网络、启动外部程序、反射、调用本机代码等课程用不到、只会带来风险的功能,
|
||||
/// 直接给出中文报错。真正的隔离靠运行时沙箱,这里是第二道防线,也让学生看到明确的原因。
|
||||
/// </summary>
|
||||
internal static class ApiPolicy
|
||||
{
|
||||
#pragma warning disable RS2008 // 不是分析器,不需要发布跟踪
|
||||
private static readonly DiagnosticDescriptor NotAllowed = new(
|
||||
"SIM001", "平台不允许使用的功能", "平台不允许使用 {0}({1})", "Usage", DiagnosticSeverity.Error, isEnabledByDefault: true);
|
||||
#pragma warning restore RS2008
|
||||
|
||||
/// <summary>整个命名空间(含子命名空间)都不允许</summary>
|
||||
private static readonly (string Namespace, string Reason)[] DeniedNamespaces =
|
||||
{
|
||||
("System.Net", "网络访问"),
|
||||
("System.Reflection", "反射"),
|
||||
("System.Runtime.Loader", "加载程序集"),
|
||||
("System.IO.Pipes", "进程间通信"),
|
||||
("System.IO.MemoryMappedFiles", "进程间通信"),
|
||||
};
|
||||
|
||||
/// <summary>单个类型不允许(所在命名空间的其他类型可以用)</summary>
|
||||
private static readonly Dictionary<string, string> DeniedTypes = new()
|
||||
{
|
||||
["System.Diagnostics.Process"] = "启动其他程序",
|
||||
["System.Diagnostics.ProcessStartInfo"] = "启动其他程序",
|
||||
["System.AppDomain"] = "加载程序集",
|
||||
["System.Runtime.InteropServices.Marshal"] = "调用本机代码",
|
||||
["System.Runtime.InteropServices.DllImportAttribute"] = "调用本机代码",
|
||||
["System.Runtime.InteropServices.LibraryImportAttribute"] = "调用本机代码",
|
||||
["System.Runtime.InteropServices.NativeLibrary"] = "调用本机代码",
|
||||
["System.Runtime.InteropServices.NativeMemory"] = "调用本机代码",
|
||||
};
|
||||
|
||||
public static IEnumerable<Diagnostic> Check(CSharpCompilation compilation)
|
||||
{
|
||||
foreach (var tree in compilation.SyntaxTrees)
|
||||
{
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
foreach (var name in tree.GetRoot().DescendantNodes().OfType<SimpleNameSyntax>())
|
||||
{
|
||||
// A.B.C 只检查最右边的名字,避免同一处报好几次
|
||||
if (name.Parent is QualifiedNameSyntax q && q.Left == name) continue;
|
||||
// var 推断出的类型在初始化表达式里已经检查过
|
||||
if (name is IdentifierNameSyntax { IsVar: true }) continue;
|
||||
if (name.Parent is MemberAccessExpressionSyntax m && m.Expression == name && model.GetSymbolInfo(name).Symbol is INamespaceOrTypeSymbol) continue;
|
||||
|
||||
var symbol = model.GetSymbolInfo(name).Symbol;
|
||||
if (symbol == null) continue;
|
||||
if (Reason(symbol) is { } reason)
|
||||
{
|
||||
yield return Diagnostic.Create(NotAllowed, name.GetLocation(), Display(symbol), reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Reason(ISymbol symbol)
|
||||
{
|
||||
if (symbol is IDynamicTypeSymbol) return "dynamic 动态调用";
|
||||
// using 指令里的命名空间不算:VS 生成的 AssemblyInfo.cs 会 using System.Runtime.InteropServices
|
||||
if (symbol is INamespaceSymbol) return null;
|
||||
var type = symbol as INamedTypeSymbol ?? symbol.ContainingType;
|
||||
// 方法的返回值、参数类型由调用处检查;这里看符号本身属于哪个类型
|
||||
for (var t = type; t != null; t = t.ContainingType)
|
||||
{
|
||||
var fullName = t.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "");
|
||||
if (DeniedTypes.TryGetValue(fullName, out var reason)) return reason;
|
||||
}
|
||||
if (type == null) return null;
|
||||
// AssemblyInfo.cs 里的 [assembly: AssemblyTitle(...)] 等特性可以用
|
||||
if (type.Name.EndsWith("Attribute") && type.ContainingNamespace.ToDisplayString() == "System.Reflection") return null;
|
||||
var ns = type.ContainingNamespace.ToDisplayString();
|
||||
foreach (var (denied, reason) in DeniedNamespaces)
|
||||
{
|
||||
if (ns == denied || ns.StartsWith(denied + ".")) return reason;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Display(ISymbol symbol) => symbol switch
|
||||
{
|
||||
IDynamicTypeSymbol => "dynamic",
|
||||
INamedTypeSymbol type => type.ToDisplayString(),
|
||||
_ => $"{symbol.ContainingType.ToDisplayString()}.{symbol.Name}",
|
||||
};
|
||||
}
|
||||
@@ -55,15 +55,16 @@ internal static class StudentCompiler
|
||||
using var pdbStream = new MemoryStream();
|
||||
var emit = compilation.Emit(peStream, pdbStream,
|
||||
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb));
|
||||
var denied = ApiPolicy.Check(compilation).ToList();
|
||||
|
||||
var diagnostics = new JsonArray();
|
||||
foreach (var diagnostic in emit.Diagnostics
|
||||
foreach (var diagnostic in emit.Diagnostics.Concat(denied)
|
||||
.Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning)
|
||||
.OrderBy(d => d.Severity == DiagnosticSeverity.Error ? 0 : 1))
|
||||
{
|
||||
diagnostics.Add(ToJson(diagnostic));
|
||||
}
|
||||
return new Result(emit.Success, peStream.ToArray(), pdbStream.ToArray(), diagnostics);
|
||||
return new Result(emit.Success && denied.Count == 0, peStream.ToArray(), pdbStream.ToArray(), diagnostics);
|
||||
}
|
||||
|
||||
private static JsonObject ToJson(Diagnostic diagnostic)
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
+3
-2
@@ -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
@@ -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)
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
// 访问口令页:输对后刷新页面进入平台(口令由服务器记在 cookie 里,之后不用再输)。
|
||||
import { ref } from 'vue'
|
||||
import { NButton, NConfigProvider, NInput, zhCN } from 'naive-ui'
|
||||
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
|
||||
async function submit() {
|
||||
if (!password.value || busy.value) return
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: password.value }),
|
||||
})
|
||||
if (response.ok) {
|
||||
location.reload()
|
||||
return
|
||||
}
|
||||
error.value = ((await response.json().catch(() => ({}))) as { message?: string }).message ?? '登录失败'
|
||||
} catch {
|
||||
error.value = '连不上服务器,请稍后再试'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NConfigProvider :locale="zhCN">
|
||||
<div class="gate">
|
||||
<form class="card" @submit.prevent="submit">
|
||||
<h1>物联网编程仿真平台</h1>
|
||||
<p>请输入老师给的访问口令</p>
|
||||
<NInput v-model:value="password" type="password" show-password-on="click" placeholder="访问口令" autofocus />
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<NButton type="primary" attr-type="submit" block :loading="busy" :disabled="!password">进入</NButton>
|
||||
</form>
|
||||
</div>
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
padding: 28px 24px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
+7
-1
@@ -1,5 +1,11 @@
|
||||
import { createApp } from 'vue'
|
||||
import AccessGate from './AccessGate.vue'
|
||||
import App from './App.vue'
|
||||
import './winforms/winforms.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
// 服务器设置了访问口令而浏览器还没输过时,先显示口令页
|
||||
const auth = await fetch('/api/auth')
|
||||
.then((response) => response.json() as Promise<{ ok: boolean }>)
|
||||
.catch(() => ({ ok: true }))
|
||||
|
||||
createApp(auth.ok ? App : AccessGate).mount('#app')
|
||||
Reference in new issue
Block a user