All projects

info.bogdan.ws

A judge for competitive programming that compiles and runs strangers' C++ on my own server. Most of the project is the story of making that not a terrible idea.

  • React
  • Go
  • Monaco Editor
info.bogdan.ws — screenshot
info.bogdan.ws

What it is

It’s a pretty familiar loop. You pick a problem, write some C++ in a Monaco editor, then hit Run to try it against your own input or Submit to face the hidden tests. A few seconds later you get a verdict for every test, one of AC, WA, TLE, MLE, RE or CE, plus how long each one took and how much memory it used.

That is the core loop. Around it there is an admin area for writing problems and uploading test data, plus a dashboard that shows how you are improving over time. My favourite part is a debugger that lets you step forward and backward through a run while it shows what each variable held on each line.

What I got wrong about sandboxing

I started out thinking sandboxing mostly meant setting a timeout and a memory cap. It didn’t take long to realize that wasn’t enough.

Running strangers’ code on your own server is an adversarial problem. A submission can go read files, open sockets, fork until the machine crashes, or just sleep forever without burning any CPU. Each of those needs its own defense, and if you miss even one, it’s only a matter of time before the judge has a really bad day.

What I actually wanted was simple to say and hard to build, which meant verdicts I could trust and timing numbers I could believe, no matter what someone submitted.

How it works

The stack is simple on purpose. It’s Go with Fiber on the backend, PostgreSQL and Redis behind it, and a React 19 SPA built around Monaco on the front. Everything runs in Docker except the one service that actually executes user code. That’s where all the interesting work lives.

The executor is a small standalone Go service running natively under systemd. It has to live outside Docker because isolate, the sandbox used at the International Olympiad in Informatics, wants full cgroup v2 delegation, and Docker’s cgroup namespace won’t give it up. So the backend reaches it over host.docker.internal.

isolate does most of the heavy lifting: a fresh PID, network, and mount namespace for every run, cgroup v2 caps on CPU time, memory, and process count, and a read-only root filesystem.

What a submission is wrapped in
What a submission is wrapped inA chart with five rows, one per thing a submitted program tries to do, each running left to right through four gates: a seccomp syscall allowlist, a chroot with namespaces, cgroup memory and process limits, and CPU and wall-clock limits. Printing the answer passes every gate and reaches the verdict as accepted or wrong answer. A socket call is cut off at seccomp, reading /etc/shadow at the chroot, a one-gigabyte allocation at the cgroup, and an infinite loop at the clock. For each of those, isolate reports the kill as the verdict: runtime error, memory limit, or time limit.submissionuntrusted C++printf("%d\n", ans)socket(AF_INET, …)open("/etc/shadow")calloc(1 << 30, 1)while (1) {}SECCOMPCHROOTCGROUPCLOCKVerdictAC · WAREREMLETLE

Each row is one thing the program tries. Only its output gets through all four gates on its own. Everything else is cut off at the gate that owns it, and isolate reports the kill as the verdict.

I pass every limit as an explicit flag, and the sandbox refuses to start at all if cgroup support is missing. A judge that quietly stops enforcing memory limits is worse than one that’s just down.

executor/internal/sandbox/isolate.goprivate repo
args := []string{
	"--cg",                // apply limits to the whole box
	"--processes=20",      // allow compiler child processes
	"--quota=102400,1000", // 100 MB and 1000 inodes
	"--fsize=104857600",   // 100 MB file limit

	fmt.Sprintf("--mem=%d", cfg.MemoryKB),
	fmt.Sprintf("--time=%.2f", cfg.CPUSeconds),
	fmt.Sprintf("--wall-time=%.2f", cfg.CPUSeconds*3),
	fmt.Sprintf("--meta=%s", metaPath), // return time and memory
}
One flag per failure mode. A default I didn't set is a verdict I can't defend, so none of them are left to chance.

On top of that there’s a seccomp-BPF filter. Go assembles the allowlist and compiles it to BPF, then a tiny C launcher installs it at the last possible moment. The ordering is everything. The filter has to be in place before the untrusted binary runs a single instruction, and the process has to give up the right to gain privileges first, or a setuid binary could shed the filter on its way in.

executor/internal/seccomp/launcher/launcher.cprivate repo
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0)
	die("no_new_privs");

struct sock_fprog prog = { .len = filter_len, .filter = filter };
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) != 0)
	die("seccomp");

execvp(argv[i], &argv[i]);
die("execvp");
Three calls, in this order and no other. The last one is the last line of mine that ever runs in this process.

I whitelist compiler flags, and every test case gets a brand-new box, so nothing one test leaves behind can leak into the next.

One small detail I still like is that clang’s ANSI colors survive the whole trip back to the browser, so a compile error looks just like it would in your own terminal.

Where I struggled

I spent longer than I’d like to admit trying to make isolate happy inside Docker. Every workaround fixed one thing and broke another. Limits would stop being enforced, timings would come back wrong, or the container needed so many privileges that the sandbox stopped meaning anything.

In the end I stopped fighting it and put the executor on bare metal. It’s a slightly uglier deploy, with two process managers and two log streams, but the isolation actually holds, which is kind of the whole point.

What I'd change

Right now the executor judges submissions one at a time on a single machine. For the traffic it gets, that’s completely fine. If I ever ran a proper contest with a few dozen people all hitting submit at once, I’d turn the executor into a pool of workers instead of a single process. The design already leans that way, but I just haven’t needed it yet.