转载自: 我的个人博客
OJ实验平台,采用沙箱后端运行代码,实现安全的实时评测(OJ)。此平台搭建的初衷为学校的数据结构实验提供课程代码的编写和测评,以此方便学生实验时得到实时的反馈,此后陆续被多个实验课程采用。本人在项目开发过程中,主要承担了OJ部分和题目部分的后端开发。
需求分析
将实验平台的功能分为核心业务和底层服务两大模块。
核心业务模块:
- OJ评测模块
- 用户管理模块(涉及验证)
- 作业提交模块
底层服务模块:
- 日志记录
- 限流保护
- 用户反馈
- 评论/题解
- 数据库请求(题目、题解、评论等)
技术栈
- Springboot 后端开发框架,简化应用的搭建和开发过程
- Redis 非关系型数据库,可用于缓存,保存非持久化数据
- MySQL 关系型数据库管理系统
- MyBatis 持久层框架,简化数据库访问
- Java 后端开发语言
- 七牛云 用户头像(图片)存储
- Vue 构建界面的JavaScript框架,提供了一套声明式的、组件化的编程模型
- ElementUI Vue组件集合
- JavaScript web脚本语言,动态更新内容
- Node.js javascript运行时环境
开发工具
- IDEA 后端开发
- WebStorm 前端开发
- Xftp 服务器间文件传输
- Postman 后端接口测试工具,模拟发送http请求
相关技术原理
沙箱
github仓库: https://github.com/criyle/go-judge
系统架构
+------------------------------------------------------------------------+
| 传输层 (HTTP / WebSocket / FFI / ...) |
+------------------------------------------------------------------------+
| 工作协程 (环境池 和 环境生产者) |
+-----------------------------------------------------------+------------+
| 运行环境 | 文件存储 |
+--------------------+----------------+---------------------+------+-----+
| Linux (go-sandbox) | Windows (winc) | macOS (app sandbox) | 内存 | 磁盘|
+--------------------+----------------+---------------------+------+-----+
运行的代码以文件形式存储在沙箱服务器的内存/磁盘,在linux上,创建功能受限的容器,使代码在容器内运行,避免破坏环境。通过发送HTTP请求使沙箱执行相应操作,在受限的环境中运行程序,具体API接口如下一部分所示
REST API 接口
沙箱服务提供 REST API 接口来在受限制的环境中运行程序。本质是 envexec
的简单封装。
- /run POST 在受限制的环境中运行程序(下面有例子)
- /file GET 得到所有在文件存储中的文件 ID 到原始命名映射
- /file POST 上传一个文件到文件存储,返回一个文件 ID 用于提供给 /run 接口
- /file/:fileId GET 下载文件 ID 指定的文件
- /file/:fileId DELETE 删除文件 ID 指定的文件
- /ws /run 接口的 WebSocket 版
- /metrics 提供 prometheus 版监控 (使用
ES_ENABLE_METRICS=1
环境变量开启) - /debug 提供 go 语言调试接口 (使用
ES_ENABLE_DEBUG=1
环境变量开启) - /version 得到本程序编译版本和 go 语言运行时版本
/run 接口返回状态
- Accepted: 程序在资源限制内正常退出
- Memory Limit Exceeded: 超出内存限制
- Time Limit Exceeded:
- 超出
timeLimit
时间限制 - 或者超过
clockLimit
等待时间限制
- 超出
- Output Limit Exceeded:
- 超出
pipeCollector
限制 - 或者超出
-output-limit
最大输出限制
- 超出
- File Error:
copyIn
指定文件不存在- 或者
copyIn
指定文件大小超出沙箱文件系统限制 - 或者
copyOut
指定文件不存在
- Non Zero Exit Status: 程序用非 0 返回值退出
- Signalled: 程序收到结束信号而退出(例如
SIGSEGV
) - Dangerous Syscall: 程序被
seccomp
过滤器结束 - Internal Error:
- 指定程序路径不存在
- 或者容器创建失败
- 比如使用非特权 docker
- 或者在个人目录下以 root 权限运行
- 或者其他错误
接口定义
interface Cmd {
args: string[]; // 程序命令行参数
env?: string[]; // 程序环境变量
// 指定 标准输入、标准输出和标准错误的文件
files?: (LocalFile | MemoryFile | PreparedFile | Pipe | null)[];
tty?: boolean; // 开启 TTY (需要保证标准输出和标准错误为同一文件)同时需要指定 TERM 环境变量 (例如 TERM=xterm)
// 资源限制
cpuLimit?: number; // CPU时间限制,单位纳秒
clockLimit?: number; // 等待时间限制,单位纳秒 (通常为 cpuLimit 两倍)
memoryLimit?: number; // 内存限制,单位 byte
stackLimit?: number; // 栈内存限制,单位 byte
procLimit?: number; // 线程数量限制
strictMemoryLimit?: boolean; // 开启严格内存限制 (仅 Linux,设置 rlimit 内存限制)
// 在执行程序之前复制进容器的文件列表
copyIn?: {[dst:string]:LocalFile | MemoryFile | PreparedFile};
// 在执行程序后从容器文件系统中复制出来的文件列表
// 在文件名之后加入 '?' 来使文件变为可选,可选文件不存在的情况不会触发 FileError
copyOut?: string[];
// 和 copyOut 相同,不过文件不返回内容,而是返回一个对应文件 ID ,内容可以通过 /file/:fileId 接口下载
copyOutCached?: string[];
// 指定 copyOut 复制文件大小限制,单位 byte
copyOutMax: number;
}
enum Status {
Accepted, // normal
MemoryLimitExceeded, // mle
TimeLimitExceeded, // tle
OutputLimitExceeded, // ole
FileError, // fe
RuntimeError, // re
DangerousSyscall, // dgs
InternalError, // system error
}
interface Request {
requestId?: string; // 给 WebSocket 使用
cmd: Cmd[];
pipeMapping: PipeMap[];
}
interface Result {
status: Status;
error?: string; // 详细错误信息
exitStatus: number; // 程序返回值
time: number; // 程序运行 CPU 时间,单位纳秒
memory: number; // 程序运行内存,单位 byte
runTime: number; // 程序运行现实时间,单位纳秒
// copyOut 和 pipeCollector 指定的文件内容
files?: {[name:string]:string};
// copyFileCached 指定的文件 id
fileIds?: {[name:string]:string};
}
RSA加密原理
RSA是经典的非对称加密算法,拥有两个密钥:公钥和私钥。
公钥和私钥的生成
- 准备两个非常大的素数 p p p和 q q q。
- 利用字符串模拟计算大素数 p p p和 q q q 的乘积。
- 同样方法计算 m = ( p − 1 ) ( q − 1 ) m = (p-1)(q-1) m=(p−1)(q−1),这里的 m m m为 n n n的欧拉函数。
- 找到一个数 e ( 1 < e < m ) e(1<e<m) e(1<e<m),满足 g c d ( m , e ) = 1 gcd(m,e)=1 gcd(m,e)=1( m , e m, e m,e互素)。
- 计算 e e e在模 m m m上的逆元 d d d,即满足$ ed \ mod \ m = 1$。
- 公钥和私钥生成完毕, ( n , e ) (n,e) (n,e)为公钥, ( n , d ) (n,d) (n,d)为私钥。
加密
对于明文 x x x,用公钥 ( n , e ) (n,e) (n,e)对 x x x加密后得到的密文y为: y = x e m o d n y = x^e \ mod \ n y=xe mod n。
解密
对于密文 y y y,用私钥 ( n , d ) (n,d) (n,d)对 y y y解密后得到的明文x为: x = y d m o d n x = y^d \ mod \ n x=yd mod n。
主要功能实现思路
OJ在线评测
OJ评测部分的流程大致如下图所示:
- 根据时间创建文件,将提交的代码以文件形式存入沙箱。
- 在Cmd类对象中填入运行代码所需的工具链,如c语言对应
gcc
。 - 将代码按沙箱规定的输入格式进行转化。
- 向沙箱请求
/run
接口,发送HTTP请求,并对沙箱返回的结果进行解码。 - 若返回状态不是
Accepted
,则代码执行失败,将错误输出返回给用户,提示用户修改代码。 - 若返回状态为
Accepted
,则代码执行成功,返回代码执行的时间,所占用的内存,输出等信息。 - 删除存放在沙箱中的代码文件。
- 将用户提交的程序的输出和数据库中对应题目的正确答案进行对比,若一致,则创建提交记录,其状态为成功;否则创建提交记录,其状态为对应的失败原因。
用户管理–邮箱验证
- 随机产生6位验证码(包括字母/数字),并将其按用户对应的键存储到Redis。
- 只用JavaMailSender将验证码和提示信息发送给用户。
- 用户在页面输入验证码,提交,验证码由前端通过接口传到后端。
- 后端验证用户提交的验证码是否与Redis中存储的一致,若一致,则验证通过;若不一致,或Redis中不存在对应验证码(过期),则验证失败。
为什么使用Redis作为验证码的存储方式?
- Redis可设置过期时长,与验证码的时效性相吻合。
- 验证码是临时的数据,不需要持久化。
- 数据运行在内存中,唯有持久化时才写到磁盘中,读写性能高。
用户管理–登录
- 首先验证账户是否存在,前端发送用户账户名,后端生成
验证码a
,并以一定的名称+用户id作为键存入Redis(有效期5min),将验证码a
和查询数据库所得的用户id返回给前端。若用户id不存在,则登录失败。 - 前端将用户输入的密码+第1步返回的
验证码a
拼接,用SHA256加密后发送给后端。后端将正确密码+验证码a
拼接,用SHA256加密,和前端传来的密文进行比较。若一致,则登录成功;否则密码不正确,登录失败。 - 删除Redis中存储的
验证码a
。 - 根据用户名、用户id、权限级别,创建
验证码b
,以online_{id}
作为键存在Redis中,7天有效。(即意味着用户登陆后7天有效,无需每次使用时重新登录)。
用户管理–修改密码
1 旧密码已知,直接修改
- 验证账户是否存在。前端发送用户账户名,后端生成
验证码a
(有效期5min),并以一定的名称+用户id作为键存入Redis,将验证码a
和查询数据库所得的用户id返回给前端。若用户id不存在,则验证失败。 - 验证旧密码是否正确。前端将用户输入的旧密码+第1步返回的
验证码a
拼接,用SHA256加密后发送给后端。后端将正确旧密码+验证码a
拼接,用SHA256加密,和前端传来的密文进行比较。若一致,则旧密码验证成功;否则密码不正确,旧密码验证失败,修改密码失败,操作终止。 - 删除Redis中存储的
验证码a
。 - 若旧密码验证成功,生成
验证码b
(有效期5min),并以一定的名称+用户id作为键存入Redis(一段时间后失效),标志旧密码验证成功。 - 若旧密码验证成功,返回用于加密的RSA公钥。
- 修改新密码。若
验证码b
在redis中不存在,则修改失败,退出。 - 若
验证码b
在redis中存在,接受前端发送的用公钥加密后的密码,用RSA的私钥对加密后的密码解密,存入数据库。
2 旧密码遗忘,通过邮箱验证,修改
- 验证账户是否存在。前端发送用户账户名,后端生成
验证码a
,并以一定的名称+用户id作为键存入Redis。 - 将第1步生成的
验证码a
发送到用户的邮箱。 - 用户查看邮件后在页面输入收到的验证码。若用户输入的验证码与redis中存储的一致,则将
验证码a
删除并生成验证码b
,一定的名称+用户id作为键存入Redis,并返回RSA公钥。若用户输入的验证码与redis中存储的不一致,提示用户验证码不正确。 - 若用户邮箱验证成功(
验证码b
在redis中存在),则用RSA私钥解码前端传来的加密后的新密码,更新数据库,删除验证码b
。