Skip to content

epsilon003/asmbly-compiler

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

63 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

nasm.run -- a web-based NASM compiler and sandboxed runner

Write x86 / x86-64 NASM assembly in the browser, hit Run, and get back real compiled-and-executed output: stdout/stderr, exit code, execution time, peak memory, a full syscall trace, and (optionally) a register dump captured at the program's entry point -- all from code assembled with nasm, linked with ld, and executed under qemu-user, inside a sandbox hardened with gVisor, seccomp, and cgroups.

Architecture

Why this exists

This is a from-scratch build of a teaching/portfolio tool for low-level systems programming: a real compile-and-run loop for assembly, with the kind of introspection (syscall trace, register dump) you'd normally only get from running a debugger locally. The interesting engineering is less "yet another code runner" and more:

  • Getting QEMU's user-mode gdbstub and GDB to cooperate reliably, which involved finding and fixing two genuinely non-obvious bugs (a throwaway TCP probe killing QEMU's single-shot gdbstub session; GDB hanging forever with no controlling terminal at all) -- see backend/app/run.py's docstrings for both.
  • Building a seccomp profile from evidence, not guesswork -- every syscall in sandbox/docker/seccomp-profile.json came from real strace -f -c output against the actual toolchain, with an automated test (backend/tests/test_seccomp_drift.py) that re-traces the pipeline and fails CI if the profile and reality diverge. It already caught one real gap once (see "What's been caught by testing" below).
  • A Redis-backed job queue decoupling HTTP request concurrency from sandboxed-execution concurrency, with a bounded worker pool so a burst of submissions can't spawn unbounded simultaneous sandboxes.
  • Layered, justified hardening (gVisor + seccomp + cgroups + network isolation + read-only rootfs), documented with what each layer actually stops -- see sandbox/docker/ARCHITECTURE.md.

Project layout

backend/
  app/
    run.py          the core pipeline: assemble -> link -> run+trace -> register dump -> JSON
    executor.py      swappable execution backend (local subprocess for dev / hardened container for prod)
    queue.py         Redis-backed job queue + bounded worker pool (optional, see below)
    schemas.py       Pydantic request/response models
    main.py          FastAPI routes: /api/health, /api/run, /api/jobs, /api/jobs/{id}
  tests/             40 tests against the REAL toolchain and REAL Redis -- nothing mocked
  Dockerfile         backend service image
  requirements*.txt

frontend/
  src/
    App.jsx                main state + layout
    api.js                  backend client (sync + queue-polling paths)
    monaco-nasm.js           NASM syntax highlighting + dark theme for Monaco
    components/              Header, Editor, OutputPanel + its four tabs (Output/Syscalls/Registers/Build Log)
  src/**/__tests__/         77 component + API client tests (vitest + React Testing Library)

sandbox/docker/
  Dockerfile               the execution sandbox image: nasm, ld, qemu-user, gdb, python3 -- nothing else
  seccomp-profile.json      syscall allowlist, built from strace evidence (see SECCOMP_NOTES.md)
  run_sandboxed.sh          the actual hardened `docker run` invocation: gVisor + seccomp + cgroups + network isolation
  docker-compose.yml        backend + sandbox wired together for production
  ARCHITECTURE.md           layer-by-layer: what each hardening layer actually stops
  SECCOMP_NOTES.md          per-syscall-group rationale and provenance

.github/workflows/ci.yml   backend + frontend test suites, against real toolchain/Redis, on every PR

Running it locally (development)

This runs the pipeline as a plain subprocess -- no container, no gVisor. Fine for development; do not expose this configuration to the public internet (see "Security posture" below).

Prerequisites: Python 3.12+, Node 22+, and nasm, binutils (ld), qemu-user, gdb installed and on PATH.

Every setting has a working default baked into the code, so none of this is required to get started -- but .env.example (in both backend/ and frontend/) documents every environment variable either service reads, with its real default value, in case you want to override something:

cp backend/.env.example backend/.env      # optional, defaults work as-is
cp frontend/.env.example frontend/.env    # optional, defaults work as-is
# Backend
cd backend
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000

# Frontend (separate terminal)
cd frontend
npm install
npm run dev

Open the Vite dev server URL, write some NASM, hit Run.

Optional: enable the job queue locally

By default /api/run calls the executor directly and /api/jobs returns 503. To run with the Redis-backed queue instead:

redis-server --daemonize yes --port 6379

NASM_IDE_ENABLE_QUEUE=true \
NASM_IDE_REDIS_URL=redis://localhost:6379/0 \
uvicorn app.main:app --reload --port 8000

The frontend detects this automatically via /api/health's queue_enabled field and switches to the queue-polling path with no other configuration needed.

Running the tests

# Backend -- needs nasm/ld/qemu-user/gdb/strace on PATH; Redis-dependent
# tests skip gracefully if Redis isn't reachable
cd backend
pip install -r requirements-dev.txt
pytest tests/ -v

# Frontend
cd frontend
npm test

Both suites are exercised against real systems, not mocks: the backend tests run the actual toolchain and (when available) actual Redis; the frontend tests render real components with real props and assert on real DOM output. .github/workflows/ci.yml runs both on every push/PR with a Redis service container provided automatically.

Production deployment (hardened)

The dev setup above (LocalSubprocessExecutor) executes submitted code as a plain host process. For anything internet-facing, switch to ContainerExecutor, which runs each submission inside an ephemeral, locked-down container:

docker build -f sandbox/docker/Dockerfile -t nasm-ide-sandbox:latest backend/
docker compose -f sandbox/docker/docker-compose.yml up --build

This requires gVisor installed and registered as the runsc Docker runtime on the host -- see sandbox/docker/run_sandboxed.sh's header comment for the one-time setup steps, and sandbox/docker/ARCHITECTURE.md for what each hardening layer (gVisor, seccomp, cgroups, network isolation, read-only rootfs) is actually responsible for stopping.

Honest caveat: the sandbox image has been built and run for real against a live Docker daemon -- not just reviewed -- and the full pipeline (assemble, link, run, syscall trace, register dump) was verified working correctly inside the actual container. What remains specifically unverified is the gVisor/runsc runtime itself: that requires a real Linux host with appropriate kernel support that wasn't available while building this, so --runtime=runsc has never actually been exercised, only reviewed. Verify that specific piece on your target host before relying on it for untrusted traffic. See sandbox/docker/ARCHITECTURE.md's "What's genuinely still unverified" section for the complete, current list -- including two real, previously-undetected Dockerfile bugs that a real docker build caught and a real Docker test corrected, one of which would have completely broken the register-dump feature in production.

Security posture

  • LocalSubprocessExecutor (dev default): no isolation beyond normal OS process boundaries. Fine for trusted local use; not for the public internet.
  • ContainerExecutor (production): gVisor + seccomp + cgroups + no network + read-only rootfs + non-root user. See sandbox/docker/ARCHITECTURE.md for the full breakdown.
  • The backend's own rate limiter is in-memory and per-process -- fine for a single instance, not sufficient across multiple replicas (see the comment in backend/app/main.py for what to swap in).
  • The job queue's worker pool size (NASM_IDE_WORKER_POOL_SIZE, default 4) is the real cap on concurrent sandboxed executions in production -- size it to what your host can actually run simultaneously under gVisor.

What's been caught by actually running things

A few examples worth keeping visible, since they're the reason this project leans so heavily on real execution over assumed-correct code:

  • The seccomp profile was missing mkdir, epoll_create1, close_range, and getsockname -- all from run.py's own Python process, not the external tools, and missed by the original manual strace pass because it only traced nasm/ld/qemu/gdb, never the orchestrating script itself. Found by tests/test_seccomp_drift.py.
  • requirements.txt was missing redis, despite queue.py importing it -- would have broken on a first pip install anywhere except a machine that happened to already have it. Found by actually running a fresh virtualenv install.
  • QEMU's user-mode gdbstub treats its listening socket as single-shot: a routine "probe the port, then connect for real" pattern silently kills the debug session before GDB gets to connect. Found by adding logging and noticing qemu had already exited.
  • GDB in batch mode hangs indefinitely with no controlling terminal at all (not just no TTY on stdin specifically) -- fixed by allocating a real pseudo-terminal via Python's pty module.
  • sandbox/docker/Dockerfile's COPY --from=builder /usr/lib/python3* /usr/lib/ line built successfully and looked correct, but silently broke gdb completely: copying two glob-matched directories (python3 and python3.12) into a shared destination flattens their contents directly into that destination rather than preserving them as named subdirectories, so /usr/lib/python3.12/ ended up missing entirely. GDB's embedded Python interpreter failed at startup with ModuleNotFoundError: No module named 'encodings' -- gdb --version exited 1 with zero output, which would have completely broken the register-dump feature, the entire reason GDB is in this image. Caught only once a real Docker daemon became available partway through this project and the image was actually built and its tools actually run, not merely reviewed. (An earlier pass of this README claimed a different line in the same COPY block -- the ld* symlink glob -- was broken, based on simulating the copy with cp -P outside Docker. That simulation used the wrong tool for the question and the conclusion was wrong: a real docker build proved Docker's COPY correctly dereferences that symlink chain on its own. Corrected here rather than quietly fixed, since a wrong conclusion confidently documented is still wrong, and the corrected understanding is the more useful lesson.)

None of these would have been caught by code review alone.

License

MIT.

About

Browser IDE for x86/x86-64 NASM — compile, run, trace syscalls, and inspect registers, all inside a hardened gVisor/seccomp/cgroups sandbox.

Topics

Resources

Stars

Watchers

Forks

Contributors

Languages