167 lines
7.0 KiB
Markdown
167 lines
7.0 KiB
Markdown
<!-- ©AngelaMos | 2026 -->
|
|
<!-- 02-ARCHITECTURE.md -->
|
|
|
|
# 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.
|