7.0 KiB
Architecture
rveng is one analysis engine with three faces. The engine is framework-free Python that knows nothing about HTTP or React. A thin FastAPI layer adapts it to the web. A React app consumes that API. Progress is persisted behind a small interface so it can be swapped. This shape exists so the engine and the lesson content can be lifted out of this project and mounted inside a larger application without dragging a web framework along.
One engine, three faces
+--------------------------+
| rveng/engine/ (pure) |
| elf disasm plt xref cfg |
| hex strings patch discover|
| challenge (grading) |
+------------+--------------+
|
+------------------------+------------------------+
| | |
+----+-----+ +------+------+ +------+------+
| HTTP API | | library | | CLI |
| FastAPI | | (import it)| | (potential) |
+----+-----+ +-------------+ +-------------+
|
+----+-----+
| React app|
+----------+
The engine is the product. The API is an adapter. The React app is a face. The
same engine that answers /api/challenges/05-find-the-gate/disasm could be
imported directly by CertGames and driven with no HTTP at all. Nothing in
rveng/engine/ imports FastAPI, and nothing renders HTML. That separation is the
whole point.
The engine modules
Each module is small and does one thing against raw bytes. None of them execute anything.
engine/elf.py parse the ELF64 header, sections, and symbols from bytes
engine/hex.py render a canonical offset/hex/ascii dump
engine/strings.py scan bytes for printable runs
engine/disasm.py decode x86-64 with capstone, annotate the interesting ops
engine/plt.py resolve a PLT stub address to an imported symbol name
engine/xref.py collect cross-references from decoded instructions
engine/cfg.py split a function into basic blocks and connect them
engine/discover.py find functions in a stripped binary by prologue scan
engine/patch.py apply and diff byte edits, verify a patch statically
engine/challenge.py the challenge model and the solve-then-reveal grader
The dependency direction is strict: everything depends on elf.py and its byte
buffer, disasm.py feeds xref.py and cfg.py, and challenge.py depends on
patch.py for patch grading. Nothing points back up toward the API.
The API layer
rveng/api/ is the adapter. It loads the curated challenges once at startup,
exposes read-only analysis of them, and grades submissions.
api/app.py builds the FastAPI app and defines every route
api/store.py loads challenges from disk; the progress store (Protocol + impls)
api/schemas.py the Pydantic response and request shapes
api/limits.py size and length caps for untrusted input
api/middleware.py a body-size cap enforced before the body is parsed
api/server.py the ASGI entry point (create_app())
The routes are deliberately boring. Given a challenge id they return a hex view, an ELF summary, a disassembly, a control-flow graph, cross-references, or the string list. One route accepts a submission and grades it. No route accepts a binary to analyze; the only binaries in the system are the curated challenge assets.
How a request flows
Take a learner disassembling the check function of the sample:
browser
GET /api/challenges/05-find-the-gate/disasm?symbol=check
|
nginx (serves the React app, proxies /api to the API container)
|
FastAPI route in app.py
| look up the challenge by id (store.py)
| parse its binary (elf.ElfImage)
| find the symbol "check" (image.symbol)
| disassemble the symbol (disasm.disassemble_symbol)
| resolve any call names (plt.plt_map)
| serialize to schemas.DisasmView
v
JSON back to the browser, rendered by the disasm pane
The engine did the analysis; the API only translated ids to calls and objects to JSON. Crucially, the gate-highlight annotation on the disassembly is withheld until the challenge is solved, so a learner sees honest raw disassembly first and the "this is the gate" hint only after they have found it themselves.
Progress behind an interface
Progress (which challenges a session has solved) is the one piece of state. It
lives behind a ProgressStore protocol with two methods:
mark_solved(session, challenge_id) -> None
solved(session) -> set[str]
Two implementations satisfy it: InMemoryProgress (a dict, used in tests) and
SqliteProgress (a file-backed table, the default the server runs). The routes
only ever see the protocol, so swapping the backing store is a one-line change in
create_app and touches no route. When rveng is extracted into a host
application, that application points the same protocol at its own database, and
nothing else moves.
routes ---> ProgressStore (protocol)
|
+--------+---------+
| |
InMemoryProgress SqliteProgress (host app plugs its own here)
The deployment topology
rveng self-hosts on localhost with Docker. There is no cloud, no TLS, no domain, no account. Two containers in production:
PROD (compose.yml, project "rveng") DEV (dev.compose.yml, project "rveng-dev")
browser :8790 browser :8791
| |
[ nginx ] serves built React dist [ nginx ] proxies everything
| /api | / | /api
[ api ] uvicorn (FastAPI engine) [ vite HMR ] [ api ] uvicorn --reload
| |
rveng_data volume (sqlite progress) rveng_data_dev volume
nginx serves the compiled frontend and proxies /api to the API container. The
API is a pure engine adapter that never learns to serve a SPA, which keeps it
extractable. In development, nginx fronts the Vite dev server for hot reload
instead of serving a static build. The two projects use structural-literal names
(rveng and rveng-dev), so dev and prod are namespace-isolated by
construction and cannot collide. Everything runs on built-in defaults, so a clean
clone comes up with no configuration.
Extraction into a host application
The end state this architecture buys: mounting rveng as a feature elsewhere means
importing rveng.engine, reusing the challenge assets as-is, pointing the
ProgressStore protocol at the host's store, and mounting the self-contained
React components. The engine and content come along unchanged. That is the
difference between building a throwaway project and building a feature that
happens to also stand alone.