diff --git a/PROJECTS/advanced/rveng/.dockerignore b/PROJECTS/advanced/rveng/.dockerignore new file mode 100644 index 00000000..069e90a9 --- /dev/null +++ b/PROJECTS/advanced/rveng/.dockerignore @@ -0,0 +1,18 @@ +# ©AngelaMos | 2026 +# .dockerignore + +.git +.venv +data +**/__pycache__ +**/*.pyc +.pytest_cache +**/.pytest_cache + +frontend/node_modules +frontend/dist +frontend/.vite +frontend/.biome_cache + +docs/plans +docs/context diff --git a/PROJECTS/advanced/rveng/.gitignore b/PROJECTS/advanced/rveng/.gitignore index 173d0337..b76929ba 100644 --- a/PROJECTS/advanced/rveng/.gitignore +++ b/PROJECTS/advanced/rveng/.gitignore @@ -8,3 +8,5 @@ __pycache__/ .pytest_cache/ .venv/ *.pyc + +data/ diff --git a/PROJECTS/advanced/rveng/README.md b/PROJECTS/advanced/rveng/README.md index e69de29b..0610a517 100644 --- a/PROJECTS/advanced/rveng/README.md +++ b/PROJECTS/advanced/rveng/README.md @@ -0,0 +1,160 @@ + + + +``` + ______ _____ ____ ____ _ + / ___/ | / / _ \/ __ \/ __ `/ + / / | |/ / __/ / / / /_/ / +/_/ |___/\___/_/ /_/\__, / + /____/ +``` + +[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2337-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/advanced/rveng) +[![Python](https://img.shields.io/badge/Python-3.13-3776AB?style=flat&logo=python&logoColor=white)](https://www.python.org) +[![FastAPI](https://img.shields.io/badge/FastAPI-009688?style=flat&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com) +[![React](https://img.shields.io/badge/React-19-61DAFB?style=flat&logo=react&logoColor=black)](https://react.dev) +[![capstone](https://img.shields.io/badge/disassembler-capstone-6d4aff?style=flat)](https://www.capstone-engine.org) +[![No execution](https://img.shields.io/badge/binaries-never%20executed-2ea043?style=flat)](#it-never-runs-a-binary) +[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) + +> An interactive reverse-engineering learning platform. It hands you a real compiled binary, asks a concrete question about it, gives you an in-browser hex viewer, disassembler, section map, and string scanner to answer it, and grades your answer. Only when you are right does it reveal the original C source, so you connect the machine code you just read back to the code that produced it. One framework-free Python analysis engine wears three faces: a web app, a read-only HTTP API, and an embeddable library. + +## Why a reverse-engineering platform + +A pile of worksheets and pre-compiled binaries can walk you through reverse engineering, but it cannot check your work and it cannot become anything more. rveng keeps the solve-then-reveal loop and makes it a real system. The engine that parses ELF, drives the disassembler, resolves imports, and grades answers is the core. The web app turns the worksheet into an interactive lab: an in-browser hex viewer, live disassembly, a section map, and a gradeable challenge runner. The engine and its lesson content are decoupled from the web framework, so they embed into a larger application as a standalone reverse-engineering feature. One core, three consumers, curated content. + +## It never runs a binary + +The single load-bearing decision, and the reason a web app that eats binaries is safe: the backend never executes any binary. Every operation is reading and parsing bytes. + +- Hex dump, ELF header parse, section walk, symbol read, and string scan are all pure byte reads. +- Disassembly is decoding, not running. capstone reads instruction bytes and returns their text form; it never transfers control to the decoded code. +- Patch challenges are graded by a static byte diff against a known-good patched target. The patched binary is never executed. + +Challenge binaries are curated and pre-compiled and shipped as static assets; nobody uploads an executable to run. So there is no arbitrary-code-execution surface, no sandbox to escape, and no resource-exhaustion path through a hostile binary, because nothing is ever run. This is a hard constraint, not a preference. Any feature that would require executing a binary is out of scope until it is redesigned against this posture, most likely by moving execution to an isolated, disposable sandbox that is explicitly not the analysis backend. + +## Features + +**The engine** +- A hand-rolled ELF64 parser: header, section table, and symbol table read straight from raw bytes +- x86-64 disassembly via capstone in Intel syntax, annotated with comparisons, conditional branches, call targets, and RIP-relative data references +- Import resolution through the PLT, walking `.plt`, `.rela.plt`, `.dynsym`, and `.dynstr` the way the loader would, so a bare `call 0x401050` becomes `call atoi` +- Cross-references (who calls this, what data it touches) and a basic-block control-flow graph for a single function +- Function discovery in stripped binaries by scanning executable sections for the standard prologue, so a symbol-stripped binary is still navigable + +**The platform** +- Six curated challenges over one sample binary, spanning the five core reverse-engineering skills plus a stripped variant +- Solve-then-reveal grading in three machine-checkable categories: found-value, identified-symbol, and patched-bytes +- Progress persisted in SQLite behind a swappable interface, so it drops into a host application's own store without touching the engine +- A React web app and a read-only FastAPI, served together for one-command self-hosting + +## Quick Start + +rveng self-hosts on localhost with Docker. No account, no secret, no external service, nothing to configure. + +```bash +curl -fsSL https://angelamos.com/rveng/install.sh | bash +# then open http://localhost:8790 +``` + +One command takes a fresh machine to the app built and running: it installs Docker if it is missing, builds the engine image and the React app, brings the stack up, and waits until it answers. The first thing you do is open the browser. + +Already have the repo cloned? Run it straight from the project directory: + +```bash +just up # build and serve on http://localhost:8790 +just dev-up # hot-reload dev stack on http://localhost:8791 +just test # engine tests, no server (uv run pytest -q) +just typecheck # frontend type check, no server +just down # stop +``` + +> [!TIP] +> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see every recipe grouped by area: `dev` (dockerized Vite hot-reload), `prod` (the self-host stack), `verify` (tests and type check), and `cleanup`. +> +> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin` + +## Architecture + +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 lives behind a small interface so the whole thing embeds into a larger application without dragging a web framework along. + +``` + +--------------------------+ + | rveng/engine/ (pure) | + | elf disasm plt xref cfg | + | hex strings patch discover| + | challenge (grading) | + +------------+--------------+ + | + +-----------------+-----------------+ + | | + +-----+------+ +------+------+ + | HTTP API | | library | + | FastAPI | | (import it)| + +-----+------+ +-------------+ + | + +-----+------+ + | React app | + +------------+ +``` + +In production, nginx serves the built frontend and proxies `/api` to the engine container, which stays a pure API and never learns to serve a SPA. Development mirrors that with nginx fronting the Vite dev server for hot reload. The two stacks use structural-literal project names (`rveng` and `rveng-dev`), so they are namespace-isolated by construction and never collide. + +``` +PROD (compose.yml, "rveng") DEV (dev.compose.yml, "rveng-dev") + + browser :8790 browser :8791 + | | + [ nginx ] serves dist [ nginx ] --> [ vite HMR ] + | /api | /api + [ api ] uvicorn [ api ] uvicorn --reload + | | + rveng_data (sqlite progress) rveng_data_dev +``` + +## Project Structure + +``` +rveng/ +├── compose.yml # prod self-host: nginx + api (name: rveng) +├── dev.compose.yml # dev: nginx + vite HMR + api --reload +├── justfile # dev / prod / verify / cleanup recipes +├── install.sh # one-shot curl|bash: installs Docker, builds, runs +├── uninstall.sh # tears the stacks down and removes the cache +├── infra/ +│ ├── docker/ # api.dockerfile, vite.dev, vite.prod (multistage -> nginx) +│ └── nginx/ # dev.nginx (fronts Vite HMR), prod.nginx (serves dist) +├── src/rveng/ +│ ├── engine/ # the pure analysis core (no HTTP, no framework) +│ │ ├── elf.py # hand-rolled ELF64 header / sections / symbols +│ │ ├── disasm.py # capstone x86-64 decode + annotation +│ │ ├── plt.py # PLT / GOT import resolution +│ │ ├── xref.py # cross-references from decoded instructions +│ │ ├── cfg.py # basic-block control-flow graph +│ │ ├── discover.py # stripped-binary function discovery +│ │ ├── hex.py strings.py patch.py # dump, string scan, byte diff +│ │ └── challenge.py # the challenge model and solve-then-reveal grader +│ └── api/ # thin FastAPI adapter over the engine +│ ├── app.py # create_app() and every read-only route +│ ├── store.py # challenge loader + ProgressStore (in-memory / sqlite) +│ ├── schemas.py limits.py middleware.py server.py +├── challenges/ # the six curated challenges (target + source + answer) +├── frontend/ # the React face (self-contained, extractable) +└── learn/ # the teaching track +``` + +## Learn + +This project ships a full teaching track. Read it in order, or jump to what you need. + +| Doc | What it covers | +|-----|----------------| +| [`learn/00-OVERVIEW.md`](learn/00-OVERVIEW.md) | What rveng is, the no-execution posture, and a quick tour | +| [`learn/01-CONCEPTS.md`](learn/01-CONCEPTS.md) | Reverse-engineering theory: static analysis, ELF, symbols, the PLT, patching, grounded in real analysis | +| [`learn/02-ARCHITECTURE.md`](learn/02-ARCHITECTURE.md) | The one-engine-three-faces design and how a request flows, with diagrams | +| [`learn/03-IMPLEMENTATION.md`](learn/03-IMPLEMENTATION.md) | A code walkthrough of every engine module against the sample binary | +| [`learn/04-CHALLENGES.md`](learn/04-CHALLENGES.md) | The six challenges, what each teaches, and how to add your own | + +## License + +[AGPL 3.0](LICENSE). diff --git a/PROJECTS/advanced/rveng/challenges/01-read-the-hex/challenge.json b/PROJECTS/advanced/rveng/challenges/01-read-the-hex/challenge.json new file mode 100644 index 00000000..8902224a --- /dev/null +++ b/PROJECTS/advanced/rveng/challenges/01-read-the-hex/challenge.json @@ -0,0 +1,7 @@ +{ + "id": "01-read-the-hex", + "module": "hex-reading", + "title": "Read the hex", + "mission": "This binary hides a secret string in its read-only data. Open the hex viewer and read the ASCII column on the right to recover it. Submit the string exactly as it appears.", + "answer": { "category": "found_value", "expected": "the_flag_is_here" } +} diff --git a/PROJECTS/advanced/rveng/challenges/01-read-the-hex/source.c b/PROJECTS/advanced/rveng/challenges/01-read-the-hex/source.c new file mode 100644 index 00000000..1c42d31b --- /dev/null +++ b/PROJECTS/advanced/rveng/challenges/01-read-the-hex/source.c @@ -0,0 +1,23 @@ +#include +#include + +int check(int n) { + if (n == 1337) { + return 1; + } + return 0; +} + +int main(int argc, char **argv) { + char *secret = "the_flag_is_here"; + int n = 0; + if (argc > 1) { + n = atoi(argv[1]); + } + if (check(n)) { + printf("unlocked: %s\n", secret); + } else { + printf("wrong number\n"); + } + return 0; +} diff --git a/PROJECTS/advanced/rveng/challenges/01-read-the-hex/target b/PROJECTS/advanced/rveng/challenges/01-read-the-hex/target new file mode 100755 index 00000000..bc1436b1 Binary files /dev/null and b/PROJECTS/advanced/rveng/challenges/01-read-the-hex/target differ diff --git a/PROJECTS/advanced/rveng/challenges/02-find-the-entry/challenge.json b/PROJECTS/advanced/rveng/challenges/02-find-the-entry/challenge.json new file mode 100644 index 00000000..a0eff77a --- /dev/null +++ b/PROJECTS/advanced/rveng/challenges/02-find-the-entry/challenge.json @@ -0,0 +1,7 @@ +{ + "id": "02-find-the-entry", + "module": "elf-anatomy", + "title": "Find the entry point", + "mission": "Every ELF file records the virtual address where execution begins. Read the ELF header and find the entry point (the e_entry field). Answer in hex or decimal.", + "answer": { "category": "found_value", "expected": 4198496 } +} diff --git a/PROJECTS/advanced/rveng/challenges/02-find-the-entry/source.c b/PROJECTS/advanced/rveng/challenges/02-find-the-entry/source.c new file mode 100644 index 00000000..1c42d31b --- /dev/null +++ b/PROJECTS/advanced/rveng/challenges/02-find-the-entry/source.c @@ -0,0 +1,23 @@ +#include +#include + +int check(int n) { + if (n == 1337) { + return 1; + } + return 0; +} + +int main(int argc, char **argv) { + char *secret = "the_flag_is_here"; + int n = 0; + if (argc > 1) { + n = atoi(argv[1]); + } + if (check(n)) { + printf("unlocked: %s\n", secret); + } else { + printf("wrong number\n"); + } + return 0; +} diff --git a/PROJECTS/advanced/rveng/challenges/02-find-the-entry/target b/PROJECTS/advanced/rveng/challenges/02-find-the-entry/target new file mode 100755 index 00000000..bc1436b1 Binary files /dev/null and b/PROJECTS/advanced/rveng/challenges/02-find-the-entry/target differ diff --git a/PROJECTS/advanced/rveng/compose.yml b/PROJECTS/advanced/rveng/compose.yml new file mode 100644 index 00000000..10624a95 --- /dev/null +++ b/PROJECTS/advanced/rveng/compose.yml @@ -0,0 +1,39 @@ +# ©AngelaMos | 2026 +# compose.yml + +name: rveng + +services: + api: + build: + context: . + dockerfile: infra/docker/api.dockerfile + container_name: rveng-api + expose: + - "8000" + volumes: + - rveng_data:/app/data + networks: + - app + restart: unless-stopped + + nginx: + build: + context: . + dockerfile: infra/docker/vite.prod + container_name: rveng-nginx + ports: + - "${NGINX_HOST_PORT:-8790}:80" + depends_on: + api: + condition: service_healthy + networks: + - app + restart: unless-stopped + +networks: + app: + driver: bridge + +volumes: + rveng_data: diff --git a/PROJECTS/advanced/rveng/dev.compose.yml b/PROJECTS/advanced/rveng/dev.compose.yml new file mode 100644 index 00000000..2f8d1056 --- /dev/null +++ b/PROJECTS/advanced/rveng/dev.compose.yml @@ -0,0 +1,61 @@ +# ©AngelaMos | 2026 +# dev.compose.yml + +name: rveng-dev + +services: + api: + build: + context: . + dockerfile: infra/docker/api.dockerfile + container_name: rveng-api-dev + command: ["uv", "run", "--no-sync", "uvicorn", "rveng.api.server:app", + "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "/app/src"] + environment: + - PYTHONDONTWRITEBYTECODE=1 + volumes: + - ./src:/app/src + - ./challenges:/app/challenges + - rveng_data_dev:/app/data + networks: + - app + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: ../infra/docker/vite.dev + container_name: rveng-frontend-dev + environment: + - CI=true + - npm_config_store_dir=/pnpm-store + volumes: + - ./frontend:/app + - frontend_modules:/app/node_modules + - frontend_pnpm_store:/pnpm-store + networks: + - app + restart: unless-stopped + + nginx: + image: nginx:1.27-alpine + container_name: rveng-nginx-dev + ports: + - "${NGINX_HOST_PORT:-8791}:80" + volumes: + - ./infra/nginx/dev.nginx:/etc/nginx/nginx.conf:ro + depends_on: + - api + - frontend + networks: + - app + restart: unless-stopped + +networks: + app: + driver: bridge + +volumes: + rveng_data_dev: + frontend_modules: + frontend_pnpm_store: diff --git a/PROJECTS/advanced/rveng/frontend/.gitignore b/PROJECTS/advanced/rveng/frontend/.gitignore index 61cb0c2e..cd1985a5 100644 --- a/PROJECTS/advanced/rveng/frontend/.gitignore +++ b/PROJECTS/advanced/rveng/frontend/.gitignore @@ -12,6 +12,7 @@ dist dist-ssr *.local .vite +.pnpm-store # Editor directories and files .vscode/* diff --git a/PROJECTS/advanced/rveng/infra/docker/api.dockerfile b/PROJECTS/advanced/rveng/infra/docker/api.dockerfile new file mode 100644 index 00000000..540706c7 --- /dev/null +++ b/PROJECTS/advanced/rveng/infra/docker/api.dockerfile @@ -0,0 +1,28 @@ +# ©AngelaMos | 2026 +# api.dockerfile + +FROM python:3.13-slim + +COPY --from=ghcr.io/astral-sh/uv:0.10.2 /uv /uvx /bin/ + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never + +WORKDIR /app + +COPY pyproject.toml uv.lock README.md ./ +RUN uv sync --frozen --no-dev --no-install-project + +COPY src ./src +COPY challenges ./challenges +RUN uv sync --frozen --no-dev + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["uv", "run", "--no-sync", "python", "-c", \ + "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/challenges')"] + +CMD ["uv", "run", "--no-sync", "uvicorn", "rveng.api.server:app", \ + "--host", "0.0.0.0", "--port", "8000"] diff --git a/PROJECTS/advanced/rveng/infra/docker/vite.dev b/PROJECTS/advanced/rveng/infra/docker/vite.dev new file mode 100644 index 00000000..c6d1fbd5 --- /dev/null +++ b/PROJECTS/advanced/rveng/infra/docker/vite.dev @@ -0,0 +1,12 @@ +# ©AngelaMos | 2026 +# vite.dev + +FROM node:24-slim + +RUN corepack enable && corepack prepare pnpm@10.29.1 --activate + +WORKDIR /app + +EXPOSE 5173 + +CMD ["sh", "-c", "pnpm install && exec pnpm dev --host 0.0.0.0"] diff --git a/PROJECTS/advanced/rveng/infra/docker/vite.prod b/PROJECTS/advanced/rveng/infra/docker/vite.prod new file mode 100644 index 00000000..c307a855 --- /dev/null +++ b/PROJECTS/advanced/rveng/infra/docker/vite.prod @@ -0,0 +1,21 @@ +# ©AngelaMos | 2026 +# vite.prod + +FROM node:24-slim AS build + +RUN corepack enable && corepack prepare pnpm@10.29.1 --activate + +WORKDIR /app + +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY frontend/ ./ +RUN pnpm build + +FROM nginx:1.27-alpine + +COPY infra/nginx/prod.nginx /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/PROJECTS/advanced/rveng/infra/nginx/dev.nginx b/PROJECTS/advanced/rveng/infra/nginx/dev.nginx new file mode 100644 index 00000000..0e2f21b0 --- /dev/null +++ b/PROJECTS/advanced/rveng/infra/nginx/dev.nginx @@ -0,0 +1,31 @@ +# ©AngelaMos | 2026 +# dev.nginx + +events {} + +http { + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 80; + server_name _; + + location /api/ { + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location / { + proxy_pass http://frontend:5173; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + } + } +} diff --git a/PROJECTS/advanced/rveng/infra/nginx/prod.nginx b/PROJECTS/advanced/rveng/infra/nginx/prod.nginx new file mode 100644 index 00000000..6a1c73a0 --- /dev/null +++ b/PROJECTS/advanced/rveng/infra/nginx/prod.nginx @@ -0,0 +1,28 @@ +# ©AngelaMos | 2026 +# prod.nginx + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; + + client_max_body_size 1m; + + location /api/ { + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/PROJECTS/advanced/rveng/install.sh b/PROJECTS/advanced/rveng/install.sh new file mode 100755 index 00000000..c4ac7d74 --- /dev/null +++ b/PROJECTS/advanced/rveng/install.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# install.sh +# +# One-shot installer for rveng, a self-hosted reverse-engineering learning +# platform. Takes a fresh machine to the app built, running, and reachable in a +# browser, with zero further steps, whether run from a clone or piped from a +# domain via curl. rveng is a Docker service, so the deliverable is the running +# app, not a binary on PATH. + +set -euo pipefail + +# ============================================================================ +# CONFIG +# ============================================================================ +REPO_OWNER="CarterPerez-dev" +REPO_NAME="Cybersecurity-Projects" +PROJECT_SUBDIR="PROJECTS/advanced/rveng" +TAGLINE="interactive reverse-engineering learning platform" +REPO_URL="https://github.com/${REPO_OWNER}/${REPO_NAME}.git" +DEFAULT_BRANCH="main" +HOST_PORT="${RVENG_PORT:-8790}" + +# ============================================================================ +# Colors — gated so `| bash`, logs, and CI stay clean +# ============================================================================ +if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then + BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GREEN=$'\033[32m' + YELLOW=$'\033[33m'; CYAN=$'\033[36m'; RESET=$'\033[0m' +else + BOLD=""; DIM=""; RED=""; GREEN=""; YELLOW=""; CYAN=""; RESET="" +fi + +info() { printf '%s\n' " ${CYAN}+${RESET} $*" >&2; } +ok() { printf '%s\n' " ${GREEN}+${RESET} $*" >&2; } +warn() { printf '%s\n' " ${YELLOW}!${RESET} $*" >&2; } +die() { printf '%s\n' " ${RED}x $*${RESET}" >&2; exit 1; } +header(){ printf '\n%s\n\n' "${BOLD}${CYAN}--- $* ---${RESET}" >&2; } +have() { command -v "$1" >/dev/null 2>&1; } + +trap 'printf "%s\n" "${RED}x install failed${RESET}" >&2' ERR + +banner() { + printf '%s' "${CYAN}${BOLD}" >&2 + cat >&2 <<'ART' + _ __ __ __ ___ _ __ __ _ + | '__/ \ / // -_)| '_ \ / _` | + |_| \_/\_/ \___||_.__/ \__, | + |___/ +ART + printf '%s\n' "${RESET}" >&2 + printf '%s\n' " ${DIM}${TAGLINE}${RESET}" >&2 +} + +# ============================================================================ +# Privilege + package-manager fan +# ============================================================================ +SUDO="" +if [ "$(id -u)" -ne 0 ]; then + if have sudo; then SUDO="sudo"; fi +fi + +pkg_install() { + if have apt-get; then $SUDO apt-get update -y || warn "apt update had errors; continuing" + $SUDO apt-get install -y --no-install-recommends "$@" + elif have dnf; then $SUDO dnf install -y "$@" + elif have pacman; then $SUDO pacman -S --needed --noconfirm "$@" + elif have zypper; then $SUDO zypper install -y "$@" + elif have apk; then $SUDO apk add "$@" + elif have brew; then brew install "$@" + else die "no known package manager; install manually: $*"; fi +} + +# ============================================================================ +# Args +# ============================================================================ +usage() { + cat >&2 </dev/null || warn "pull failed; using existing clone" + else + info "cloning ${REPO_URL}" + git clone --depth 1 --branch "$DEFAULT_BRANCH" --quiet "$REPO_URL" "$cache" \ + || die "clone failed from ${REPO_URL}" + fi + printf '%s\n' "$cache/$PROJECT_SUBDIR" +} + +# ============================================================================ +# Docker — the only real dependency; install it if missing +# ============================================================================ +ensure_docker() { + if ! have docker; then + if [ "$OS" = "darwin" ]; then + die "Docker not found. Install Docker Desktop for Mac, then re-run." + fi + info "installing Docker via get.docker.com" + download_docker + fi + if ! docker info >/dev/null 2>&1; then + if have systemctl; then + info "starting the Docker daemon" + $SUDO systemctl start docker 2>/dev/null || true + fi + fi + docker info >/dev/null 2>&1 || die "Docker is installed but the daemon is not running; start it and re-run" + if ! docker compose version >/dev/null 2>&1; then + info "installing the Docker Compose plugin" + pkg_install docker-compose-plugin || die "install the Docker Compose v2 plugin, then re-run" + fi + ok "docker $(docker --version | awk '{print $3}' | tr -d ,)" +} + +download_docker() { + local tmp + tmp="$(mktemp)" + if have curl; then curl -fsSL https://get.docker.com -o "$tmp" + elif have wget; then wget -qO "$tmp" https://get.docker.com + else die "need curl or wget to install Docker"; fi + $SUDO sh "$tmp" || { rm -f "$tmp"; die "Docker install failed"; } + rm -f "$tmp" + if [ -n "$SUDO" ] && have usermod; then + $SUDO usermod -aG docker "$(id -un)" 2>/dev/null || true + fi +} + +# ============================================================================ +# Compose wrapper — run as the current user against the prod stack +# ============================================================================ +compose() { + NGINX_HOST_PORT="$HOST_PORT" docker compose "$@" +} + +http_probe() { + if have curl; then curl -fsS "$1" >/dev/null 2>&1 + elif have wget; then wget -q -O /dev/null "$1" 2>/dev/null + else return 1; fi +} + +wait_healthy() { + local url="http://localhost:${HOST_PORT}/api/challenges" i + for i in $(seq 1 90); do + if http_probe "$url"; then return 0; fi + sleep 2 + done + return 1 +} + +# ============================================================================ +# Main +# ============================================================================ +main() { + banner + ensure_docker + + header "Fetching rveng" + PROJECT="$(resolve_project)" + [ -d "$PROJECT" ] || die "could not locate the rveng project directory" + cd "$PROJECT" + ok "project at $PROJECT" + + header "Building and starting (this compiles the frontend and engine image)" + compose up -d --build + + header "Waiting for the app" + if wait_healthy; then + ok "rveng is answering on port ${HOST_PORT}" + else + warn "app did not answer within the timeout; check 'docker compose logs'" + fi + + printf '\n%s\n\n' " ${GREEN}${BOLD}rveng is running.${RESET}" >&2 + cat >&2 <