前言

由于关于「OJ 怎么写」、「判题机怎么保证安全」之类的话题的讨论已经足够多了,现在的各类资料也比数年前丰富,所以写一个 OJ、写一个安全的 OJ、写一个高效的、用户友好的 OJ,大概也都不是什么难事。因为涉及一些操作系统下层操作,所以比较适合 Web CRUD 写手拿来进阶练手。(笑)

由于实现的 C++ 源代码过于黑历史,就不公开了,下面会摘录部分代码。剥离了判题逻辑的代码在此: https://github.com/airstone42/mini-sandbox ,大概是一个简单的沙箱,主要使用 C 编写,安全机制由 seccomp 保证,写得更好看一些。

之所以说是黑历史,是因为之前的 C++ 使用了极多的 worst practice,包括但不限于使用异常作为控制流程等,使用了 seccomp 但实际上也并不保证安全。鉴于这只是一个原型,为了之后重写思路更清晰,因此在此进行简要的总结,避免重写后的正式判题机还是大量 worst practice……好了,自我批评到此为止。

此文尽量不出现我自己都不明白的高端技术名词,以免露怯。(笑

先列一下「参考文献」吧,也不是正式论文。感谢以下文章的作者。

流程

程序中使用到的一些关键点:POSIX API、C++ 线程支持库、C++ 文件系统库、网络库 ZeroMQ。

基本逻辑是:判题机启动监听 TCP 端口 - 客户端发送网络请求 - 判题机的工作线程接收消息 - 根据消息对消息中包含的路径下的源文件进行编译、执行 - 返回编译、执行结果 - 工作线程发送返回的消息 - 客户端接收返回的消息。

网络

鉴于之前只有 HTTP 相关的经验,并不太熟悉 TCP 相关,特别是 C/C++ 原生的 Socket 网络编程。机缘巧合,使用了 ZeroMQ 做消息队列(其实更主要是网络库)。 ZeroMQ 提供了几种消息模型,这里也基本只使用了 REQ/REP 的方式,因此相当于操作封装了几层的原生 Socket。

网络请求部分基本如此:

zmq::context_t context(1);

zmq::socket_t master(context, ZMQ_ROUTER);
master.bind(std::string("tcp://*:") + judge::PORT);

zmq::socket_t worker(context, ZMQ_DEALER);
worker.bind(judge::INPROC_ADDRESS);

std::cout << "..." << std::endl; //省略相关输出

for (int i = 0; i < judge::MAX_THREADS; ++i)
    std::thread(&judge::Container::handle, std::ref(container), std::ref(context)).detach();

zmq::proxy(static_cast<void *>(master), static_cast<void *>(worker), nullptr);

转发给的 Container::handle() 函数的工作部分:

zmq::socket_t socket(context, ZMQ_REP);
socket.connect(INPROC_ADDRESS);

while (true) {
    zmq::message_t request;
    socket.recv(&request);

    std::cout << "..." << std::endl; //省略相关输出

    if (!request.size())
        return;

    if (working < MAX_WORKS)
        run(request, socket);
    else
        reply("Busy!:0ms:0ms:0kB", socket);
}

基本逻辑如此,大概不太难理解,细节不必深究。

关于 ZeroMQ 这样多线程的写法主要是参照: http://zguide.zeromq.org/cpp:mtserver。

也不复杂,但是程序运行出现了一些问题:当指定的 MAX_WORKS 过大的时候,服务端会抛出 Assertion failed: ok (src/mailbox.cpp:99) 并异常地停止工作。

查看了一下 src/mailbox.cpp 的第 99 行和一些 issue。由于技术能力有限,无法从代码中看懂太多;而从 issue 中看到此问题大多由多线程操作引发,联想到 ZeroMQ 并不保证多线程共享 socket 的线程安全。可能是……特有问题吧,大概。这一点之后需要解决,采用更合适的 ZeroMQ 使用方法,或者更换 TCP 操作方式(更换网络库 / 原生)等等。

编译 & 执行

编译和执行也遵循一个基本逻辑流程:fork()- 子进程替换文件描述符 - 子进程执行 execv() 替换成对应的编译/执行程序 - 父进程在子进程产生后监视其状态并轮询 - 父进程期望子进程在指定时间内结束 - 若子进程在规定的时间内正常退出则收集其运行信息 - 若父进程的等待若超过指定时间则对子进程发出 kill()

大体如下:

int *status = nullptr;
pid_t proc = fork();
if (!proc) {
    int fd1 = open(in_path.c_str(), O_RDONLY);
    dup2(fd1, 0);
    close(fd1);
    int fd2 = open(result_path.c_str(), O_RDWR | O_CREAT | O_TRUNC, 0644);
    dup2(fd2, 1);
    close(fd2);
    int fd3 = open(log_path.c_str(), O_RDWR | O_CREAT, 0644);
    dup2(fd3, 2);
    close(fd3);
    execlp(path.c_str(), path.c_str(), nullptr);
}
rusage usage{};
int poll_count = 0;
poll:
std::this_thread::sleep_for(std::chrono::milliseconds(TIME_INTERVAL_MS));
++poll_count;
if (!wait4(proc, status, WNOHANG, &usage)) {
    if (poll_count <= TIME_LIMIT_MS / TIME_INTERVAL_MS) {
        goto poll;
    } else {
        kill(proc, SIGKILL);
        return;
    }
} else {
    time = time_cast(usage.ru_utime);
    memory = usage.ru_maxrss;
}

此处使用 goto 的原因还要从历史代码说起,之前的代码如下:

int *status = nullptr;
pid_t proc = fork();
if (!proc) {
    /* ...... */
    execlp(path.c_str(), path.c_str(), nullptr);
}
std::this_thread::sleep_for(std::chrono::seconds(TIME_LIMIT));
rusage usage{};
if (!wait4(proc, status, WNOHANG, &usage)) {
    kill(proc, SIGKILL);
    return;
} else {
    time = time_cast(usage.ru_utime);
    memory = usage.ru_maxrss;
}

原本采用的方法是,等待较长固定时间后进行超时逻辑判断,很明显这样做效率太低。在改成多次等待较短时间直至达到上限的方法时,为了更加简洁直观而采用了这个 bad practice,这样对代码结构更改较小,也没有过多地破坏程序可读性。(此处大概是从阻塞改为了非阻塞,不太清楚名词,暂且不多提。)

关于运行时如何保证安全,参考「参考文献」即可,基本没有改变使用方法。原本是想法是,在用户输入的 main() 函数后一行手动添加字符串 seccomp_add,现在看来是没必要的。当然这种做法也有隐患,例如用户手动定义 __libc_start_main() 就有可能覆盖这里加入的 seccomp 规则。另外,这种做法也是 C/C++ 限定的,关于 Go 语言据说有 init(),但 Rust 应该没有类似方法,总之,很被动。几个解决方案(可混用)的参考:

  • 使用 ptrace
  • 使用 Docker
  • 用户提交代码片段,与内部定义了 seccomp 规则的主函数联合编译/执行

大概需要之后根据需求进行选择。

总结

没什么好总结的,基本的东西也就在这里了。至于怎么编写一个可用的判题机……正在思考和进行中,如果有好的想法欢迎指点&批评。