# JudgeForge **Repository Path**: wxk38bdc/judge-forge ## Basic Information - **Project Name**: JudgeForge - **Description**: 从零构建的在线判题平台(OJ):题库、代码提交、沙箱判题、比赛与排行榜。 - **Primary Language**: C++ - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-11 - **Last Updated**: 2026-09-13 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # JudgeForge 一个 LeetCode 形态的在线判题系统(Online Judge)。用户在浏览器里阅读题目、用 Monaco 编辑 C++ 代码、 提交后由后端在**自建的 Linux namespace 沙箱**中执行,按 OI 赛制逐测试点给出得分与耗时/内存数据。 完整的需求与架构设计见 **[SPEC.md](SPEC.md)**。 --- ## 项目状态 | 里程碑 | 内容 | 状态 | |---|---|---| | **M0** | 环境与可行性验证 | ✅ **已完成** | | **M1** | 端到端最小闭环 | ✅ **已完成** | | **M2** | 用户系统 | ✅ **已完成** | | **M3** | 题目与题库(含个人主页) | ✅ **已完成** | | **M4** | 沙箱加固 | ✅ **已完成** | | M5 | 异步队列与并发 | ⬜ 未开始 | | M6 | 前端完善与演示准备 | ⬜ 未开始 | **M1 已完成**:从浏览器点击提交,到沙箱执行,到界面渲染得分,整条链路打通。 `scripts/m1-acceptance.sh` 15 项全绿(部分分、编译错误、超时、内存超限、 判题可复现性、资源清理)。 **M2 已完成**:注册 / 登录 / 登出 / JWT 鉴权 / 提交冷却 / 管理员校验, `current_user_id()` 的单点占位被删除,所有提交归属真实账号。 **M3 已完成**:管理员题目 CRUD、题目筛选查询、测试点 multipart 上传/删除、文件大小与扩展名校验、 题目删除保护、管理后台、提交记录与个人主页基础页面均已实现;初始题库现含 5 道题。 个人主页 API 仅允许本人或管理员访问,防止通过修改 URL 枚举他人统计;`scripts/m3-acceptance.sh` 12 项全绿并覆盖该边界, 并通过 M1/M2 回归验收。 | 验收方式 | 项数 | 覆盖 | |---|---|---| | `scripts/m2-acceptance.sh` | 52 | 认证契约、授权边界、密码存储、令牌健壮性 | | `--auth-selftest` | 73 | base64、PBKDF2(含 3 个已知向量)、JWT | | `scripts/m2-browser-e2e.mjs` | 22 | 真实浏览器:Cookie 携带、路由守卫、刷新保持登录态 | | `scripts/m1-acceptance.sh` | 15 | 判题链路回归 | | `scripts/m3-acceptance.sh` | 12 | 题目 CRUD、测试点上传、公开边界、个人主页 | | `scripts/m4-acceptance.sh` | 9 | namespace 文件、网络、进程与资源隔离 | --- ## 已知边界(诚实清单) 以下均为**有意识的推迟或选型的固有代价**,不是缺陷。面试被问到时应当能直接回答。 ### 与沙箱有关 1. **NamespaceSandbox 已启用** —— 判题进程使用 user/mount/PID/network/IPC/UTS namespace, 通过 `pivot_root` 进入最小根文件系统;系统运行库只读挂载,任务目录仅映射到 `/work`。 2. **资源限制由内核执行** —— CPU、地址空间、进程数、输出文件、打开文件数、栈和 core dump 均设置了上限;输出超过 64MB 判定为 OLE。 3. **部署要求** —— Linux 内核必须允许 user namespace、mount namespace 和 PID/network namespace。 启动自检失败时服务应停止,不应回退到无隔离的 `SimpleSandbox`。 4. M5 已切换到 MySQL 持久化队列,`--mode=server` / `--mode=judge` 可分进程运行; 判题进程启动会按 `judge_started_at` 恢复超时任务,并清理残留测试点结果。 并发背压已通过数据库 advisory lock 将容量检查与入库原子化。 ### 分进程启动(M5) 生产部署时分别启动 Web 与判题进程,二者通过 MySQL `submissions` 表共享任务: ```bash ./build/judge-forge --mode=judge --config=config/judge-forge.conf ./build/judge-forge --mode=server --config=config/judge-forge.conf ``` 开发调试仍可使用 `--mode=all`。构建后可运行 `bash scripts/m5-acceptance.sh` 做并发回归。 ### 与认证有关(M2 引入) 4. **JWT 无法撤销。** 登出只清除浏览器里的 Cookie;一个被复制走的 token 在 `exp`(默认 24 小时)之前**仍然有效**。这是无状态 JWT 的固有代价 —— 想要"立即失效"就得引入服务端会话表,而那会消灭选 JWT 的唯一理由 (见 SPEC §10 R6)。已实测确认:登出后重放登出前捕获的 token 仍返回 200。 5. **种子账号是演示用弱口令** —— `admin` / `admin12345`(见 `sql/seed.sql`)。 它能通过系统自己的强度校验,但**任何真实部署前必须修改**。 6. **`--dev-user=` 是危险开关** —— 默认关闭,开启后所有请求都被当作该用户, **不需要登录即可提交**。它存在的唯一理由是本机用 curl 调试时省一次登录, 而登录本身也只需要一条 curl。开启时启动日志会打醒目警告。 7. **没有登录失败锁定或按 IP 限流** —— SPEC 只定义了提交冷却(FR-C10)。 口令哈希本身把爆破成本抬到了每次 127ms,但没有失败计数。 **M0 明细**(6 项全部通过): - [x] **T0-5** 沙箱可行性验证 —— 8/8 通过,结论见 [probe/RESULTS.md](probe/RESULTS.md) - [x] **T0-1** nvm + Node 22(镜像加速) - [x] **T0-7** cpp-httplib v0.56.0 - [x] **T0-6** 项目骨架(CMake / 配置 / 日志 / 双角色) - [x] **T0-2/3/4** MySQL 8.0.46 + libmysqlclient-dev + libssl-dev 3.0.2 M0 阶段解除了 SPEC 中的两个高风险项(R2 数据库选型、R7 进程数限制), 并发现一个真实的安全盲区(不重挂 procfs 会导致 `/proc//root` 逃逸)。 详见 [probe/RESULTS.md](probe/RESULTS.md)。 --- ## 技术栈 | 层 | 选型 | |---|---| | 后端 | C++17 + [cpp-httplib](backend/third_party/README.md) v0.56.0(header-only) | | 数据库 | MySQL 8.0(元数据)+ 文件系统(测试用例) | | 沙箱 | `unshare` + user/mount/pid/net namespace + `pivot_root` + `setrlimit`,**不依赖 Docker** | | 队列 | MySQL 表 + `SELECT ... FOR UPDATE SKIP LOCKED` | | 前端 | Vite + Vue 3 + Monaco | | 构建 | CMake 3.16+ | --- ## 快速开始 ### 1. 环境要求 - Linux(开发环境为 WSL2 / Ubuntu 22.04) - g++ 支持 C++17、CMake 3.16+ - Node.js 18+(前端) - **内核必须支持非特权 user namespace** —— 沙箱的基础,用 `unshare -Ur id` 验证 ### 2. 安装依赖 ```bash # 系统依赖(需要 sudo 权限) sudo bash scripts/m0-install-deps.sh # Node.js(无需 sudo,走国内镜像) bash scripts/setup-node.sh ``` ### 3. 构建与运行 ```bash # 1. 初始化数据库(建表 + 灌入种子账号、A+B 题目与 5 个测试点) bash scripts/m1-init-db.sh # 2. 生成 JWT 密钥(无需 sudo;已存在则复用,不会覆盖) bash scripts/m2-init-auth.sh # 3. 构建后端 cmake -B build -DCMAKE_BUILD_TYPE=Release cmake --build build # 4. 构建前端 cd frontend && npm install && npm run build && cd .. # 5. 启动(前端产物由后端同源托管) ./build/judge-forge --mode=all # 浏览器打开 http://localhost:8080 # 用种子账号登录试试: admin / admin12345 ``` > **登录凭据是必需的**:M2 起提交需要真实身份,未登录会被 401 拦下。 > JWT 密钥缺失或为空时服务**拒绝启动**(而不是回退到某个默认密钥)—— > 一个能启动但用着默认密钥的服务,比起不来的服务危险得多。 ### 前端开发模式 改前端时不必每次 `npm run build`,用 Vite 的热更新: ```bash ./build/judge-forge --mode=all # 终端 1:后端 :8080 cd frontend && npm run dev # 终端 2:前端 :5173(代理 /api 到 8080) # 浏览器打开 http://localhost:5173 ``` ### 自检与验收 ```bash # 自检(都不启动服务,随时可跑) ./build/judge-forge --db-check # 数据层:题目/提交读写往返 + 用户层 8 项 ./build/judge-forge --auth-selftest # 认证模块:base64 / PBKDF2 / JWT,73 项断言 ./build/judge-forge --judge-one=1 # 直接判某条提交(不经过 HTTP 与队列) # 工具 printf '%s' 'myPassword1' | ./build/judge-forge --hash-password # 生成口令哈希 printf '%s\n%s\n' 'pw' "$HASH" | ./build/judge-forge --verify-password # 校验 # 端到端验收 bash scripts/m1-acceptance.sh # 判题链路,15 项 bash scripts/m2-acceptance.sh # 认证与授权,52 项 ``` 浏览器的真实行为(Cookie 携带、路由守卫、刷新保持登录态)由 `scripts/m2-browser-e2e.mjs` 覆盖 —— 它需要 Chrome 开着远程调试端口, 运行方式见该文件头部的说明。 > **MySQL 与 libcrypto 都是必需依赖**(M1 起数据库层是业务代码的一部分; > M2 起口令哈希与 JWT 签名依赖 OpenSSL 的 libcrypto)。编译参数由 `mysql_config` > 提供而非 pkg-config,因为后者在本机并未安装。找不到 `mysql_config` 时 CMake 会 > 直接报错并提示运行 `scripts/m0-install-deps.sh`。 > > **HTTP 线程池大小是编译期常量**,不能用配置文件改,需用 CMake 选项: > `cmake -B build -DJF_HTTP_THREADS=32`。启动日志会打印实际生效值。 > > `JF_WITH_HTTPS` 默认 **OFF** —— 它只影响 cpp-httplib 是否支持 `https://`, > 而 v1 在本地以明文 HTTP 运行。注意这**不影响**认证:口令哈希与 JWT 签名 > 用的是 libcrypto,它无论如何都会链接。 启动后浏览器访问 **http://localhost:8080**。 > **为什么是双进程?** `cpp-httplib` 是同步阻塞模型,每个请求占用一个线程直到返回。 > 判题是秒级的 CPU 密集操作,若放在 HTTP 线程里执行,一个慢提交就能阻塞一个连接。 > 详见 [SPEC.md](SPEC.md) §7.2。 > > 这一点在 M2 有了新的分量:PBKDF2 单次哈希实测 **127ms**,同样占用 HTTP 线程。 > 由 Little's Law,持续约 126 次/秒的登录请求会把 16 个线程全部吃干, > 让提交与轮询接口拿不到线程 —— 而进程不崩、日志不报错,只是所有接口一起变慢。 > 取舍的完整分析见 `backend/auth/password.h`。 ### 4. 配置 ```bash cp config/judge-forge.conf.example config/judge-forge.conf ``` 配置优先级:**命令行参数 > 配置文件 > 内置默认值**。启动时会打印最终生效的配置。 **两处敏感值都不写进主配置**,而是各自单独成文件(均已 gitignore、权限 600): | 文件 | 内容 | 生成方式 | |---|---|---| | `config/db.conf` | 数据库密码 | `scripts/m0-install-deps.sh` | | `config/jwt.conf` | JWT 签名密钥 | `scripts/m2-init-auth.sh` | > 这不只是"别把密码提交上去"的洁癖:`main.cpp` 的 `print_effective_config()` > 会把 `cfg.all()` 里的**每一个键**打进日志 —— 敏感值混进主配置就会明文落盘。 --- ## 目录结构 ``` judge-forge/ ├── SPEC.md 需求与架构规格书(唯一事实来源) ├── CMakeLists.txt ├── backend/ │ ├── main.cpp 入口:解析参数、装配角色、各调试开关 │ ├── common/ log(线程安全日志)、json(极简构造器)、kv_file │ ├── config/config.* 配置解析 │ ├── auth/ base64 · password(PBKDF2)· jwt(HS256) │ │ · auth_config · selftest(73 项断言) │ ├── http/ server(静态资源与错误处理)· routes(业务路由) │ │ · auth(鉴权中间件与认证路由)· http_util · error_codes │ ├── db/ pool · stmt · dao(Problem/Submission/User) │ ├── model/ 领域模型 │ ├── judge/ sandbox/ · compiler · checker · judger · queue · worker │ └── third_party/ cpp-httplib ├── frontend/src/ api/ · stores/ · router/ · views/ · components/ ├── probe/ M0 沙箱可行性验证探针(含攻击载荷与实测结论) ├── scripts/ 安装、初始化与验收脚本 ├── config/ 配置模板 ├── data/problems/ 题目测试数据(随仓库分发) └── sql/ 数据库 schema 与种子数据 ``` > **改后端时请勿在头文件里包含 `httplib.h`**(22875 行,编译约 15 秒)。 > 目前只有 `http/server.cpp`、`http/routes.cpp`、`http/auth.cpp` 三个编译单元 > 包含它。`http/auth.h` 刻意只做前向声明,`http/http_util.h` 则在头部注明了 > 这个约束。 --- ## 沙箱安全边界 本项目最有技术含量的部分。**实测结论见 [probe/RESULTS.md](probe/RESULTS.md)。** **v1 能防住的**: - 死循环、CPU 耗尽 → `RLIMIT_CPU` + wall clock 兜底 - 内存爆炸 → `RLIMIT_AS` 硬墙 + `ru_maxrss` 精确判定 - **fork 炸弹** → PID namespace + `RLIMIT_NPROC`(已实测精确生效) - **读取宿主机文件** → mount namespace + `pivot_root` 到空根 - **`/proc//root` 逃逸** → 在新 PID namespace 内重挂 procfs - **访问网络** → net namespace(连 WSL 网关都不可达) - 写爆磁盘 → `RLIMIT_FSIZE` + 输出量监测 **v1 明确不防的**(诚实的边界): - seccomp 级系统调用过滤(计划于 v2.0) - 侧信道攻击(CPU 缓存、时间侧信道) - 提权类攻击的完整封堵 --- ## 认证与授权(M2) ### 凭据怎么走 登录成功后服务端下发 `Set-Cookie: token=; HttpOnly; SameSite=Lax; Path=/`, 之后每个请求由**浏览器自动携带**,前端不需要任何 JS 参与。 **前端 store 里没有 token,这是特性不是遗漏。** `httpOnly` 的含义就是 `document.cookie` 读不到它;任何试图在前端保存 token 的设计,要么拿不到值, 要么迫使你放弃 httpOnly 把它改回 JS 可读 —— 那正是 XSS 能偷走凭据的那条路。 ### 鉴权中间件:注入用户对象,而不是让路由去查身份 httplib 没有原生中间件。本项目的做法是包装器: ```cpp svr.Post("/api/submissions", with_auth(ctx, [](const Request& req, Response& res, const AuthUser& me) { ... 用 me.id ... })); ``` 关键在于 M1 的 `current_user_id()` 被**整个删除**而不是改写成读 JWT: 现在路由体里再也拿不到"当前用户 id"这种东西,除非通过包装器注入。 于是失败模式从「某个接口信任了一个可伪造的 id」(漏洞) 降级为「某个接口是公开的」(功能缺失)—— 后者会被测试发现,前者不会。 (`set_pre_routing_handler` 这条路走不通:httplib 里它是**赋值不是追加**, 而 `server.cpp` 在 `register_routes` 之后才设置优雅停机钩子, 任何在其中注册的鉴权都会被静默销毁。) ### 口令怎么存 `PBKDF2-HMAC-SHA256`,16 字节随机盐,60 万次迭代(对齐 OWASP 2023), 自描述格式存进单个列: ``` pbkdf2-sha256$600000$$ ``` **参数随行存储**是为了拿到升级路径:不存迭代次数,就永远不敢改默认值 —— 无法分辨某一行是 60 万还是 10 万次算出来的,改了会让所有老用户登不进去。 **加盐与恒时比较都做了,但它们不是装饰**:验收脚本会断言 「同一口令的两个用户哈希必须不同」(证明盐真的参与运算), 自检会用 **3 个 PBKDF2 已知向量**把实现钉在标准上 —— 速度差异可以解释,算错一个字节不行。 ### 登录接口为什么"慢"且"消息一样" 「用户不存在」与「口令错误」返回**逐字相同**的响应体,且前者也会跑一次哈希。 只做第一条是不够的:不跑哈希的话,"用户不存在"会比"口令错误"快约 127ms, 构成一个**时序预言机**,一样能把用户名枚举出来。 ### 提交冷却 单用户 5 秒(FR-C10),实现是查 `submissions` 表而不是内存计数器: 内存实现重启即清零、M5 多进程时被除以进程数,还要额外的互斥与淘汰逻辑。 --- ## 开发说明 ### 编译速度 `backend/third_party/httplib.h` 有 22875 行,编译一次约 15 秒。 目前只有 `http/server.cpp`、`http/routes.cpp`、`http/auth.cpp` 三个编译单元 包含它,其余模块的增量编译不受影响 —— **新增代码时请勿在头文件中包含 httplib.h**。 (`http/http_util.h` 是个例外,它在文件头注明了这个约束:定义放在头里 是为了避免产生第四个包含 httplib.h 的编译单元。) ### 网络环境 开发机 `raw.githubusercontent.com` 不可达。若需获取 GitHub 资源,使用 `ghproxy.net` 代理或 `codeload.github.com`。详见 [backend/third_party/README.md](backend/third_party/README.md)。 --- ## 许可证 见 [LICENSE](LICENSE)。