Machine-readable context document for AI assistants. Covers the full system as of 2026-03-20. Always prefer reading actual source files over trusting stale details here. Workspace root:
/home/hytx/Desktop/CYBERSEC/tese/NervCTF
NervCTF is a two-binary Rust + Python toolchain for managing CTF competitions on top of CTFd:
nervctf(CLI) — readschallenge.ymlfiles from a local directory tree, deploys/syncs them to a CTFd instance via the remote-monitor, and registers per-challenge instance configs.remote-monitor(HTTP server) — runs on the CTFd host, writes all CTFd data directly via MariaDB SQL, manages ephemeral challenge containers/VMs per team.- CTFd plugin (
nervctf_instance, Python) — installed inside CTFd; adds theinstancechallenge type and proxies player lifecycle requests to the remote-monitor.
NervCTF/
├── Cargo.toml # workspace manifest; members: src/nervctf, src/remote-monitor
├── Cargo.lock
├── flake.nix # sole dev environment (provides rustc, cargo, ansible, openssl, …)
├── flake.lock
├── ARCHITECTURE.md # this file
├── .nervctf.yml # local config (gitignored in practice)
│
├── src/
│ ├── nervctf/ # CLI crate
│ │ ├── Cargo.toml
│ │ ├── src/
│ │ │ ├── lib.rs # re-exports: challenge_manager, ctfd_api, directory_scanner, fix, setup, validator, utils
│ │ │ ├── main.rs # clap CLI entry point + deploy/sync logic
│ │ │ ├── utils.rs # Config struct + load_config / save_config / find_config_path
│ │ │ ├── setup.rs # `nervctf setup` — interactive Ansible deployment
│ │ │ ├── fix.rs # `nervctf fix` — YAML issue fixer + container→instance migrator
│ │ │ ├── validator.rs # `nervctf validate` — schema/lint warnings for challenge.yml
│ │ │ ├── directory_scanner.rs # recursive challenge.yml finder (max_depth=5, no symlinks)
│ │ │ ├── challenge_manager/
│ │ │ │ ├── mod.rs # ChallengeManager: CRUD wrappers over CtfdClient
│ │ │ │ └── sync.rs # ChallengeSynchronizer + needs_update() + SyncAction enum
│ │ │ └── ctfd_api/
│ │ │ ├── mod.rs # pub use CtfdClient, RequirementsQueue, models
│ │ │ ├── client.rs # CtfdClient (async reqwest), execute(), pagination helpers
│ │ │ └── models/
│ │ │ └── mod.rs # all data types (Challenge, FlagContent, HintContent, …)
│ │ │ endpoints/
│ │ │ ├── challenges.rs
│ │ │ ├── flags.rs
│ │ │ ├── hints.rs
│ │ │ ├── tags.rs
│ │ │ └── files.rs
│ │ └── assets/
│ │ ├── nervctf_playbook.yml # Ansible playbook for full server setup
│ │ ├── install_docker_on_remote.sh # referenced by playbook (not embed in binary)
│ │ └── ctfd-plugin/ # Python CTFd plugin (deployed via Ansible rsync)
│ │ ├── __init__.py
│ │ ├── models/
│ │ │ ├── __init__.py
│ │ │ └── challenge.py # InstanceChallenge SQLAlchemy model
│ │ └── assets/
│ │ ├── view.{html,js}
│ │ ├── create.{html,js}
│ │ └── update.{html,js}
│ │
│ └── remote-monitor/ # HTTP server crate
│ ├── Cargo.toml
│ ├── assets/
│ │ └── admin.html # admin dashboard (embedded via include_str! at compile time)
│ └── src/
│ ├── main.rs # axum 0.7 server, all routes, AppState, background expiry
│ ├── db.rs # SQLite via rusqlite; Db = Arc<Mutex<Connection>>
│ └── instance/
│ ├── mod.rs # provision(), cleanup_container(), generate_flag(), sanitize_name()
│ ├── docker.rs # pick_free_port(), run_container(), remove_container(), build_image()
│ ├── compose.rs # up(), down(), compose_cmd() — real implementation
│ ├── lxc.rs # launch(), delete() — real implementation
│ └── vagrant.rs # up() — stub (returns error)
│
└── templates/ # challenge.yml templates for authors
├── standard/
├── docker/
├── compose/
├── lxc/
└── vagrant/
Always use Nix flake for any build/run command:
nix develop .# --command cargo build
nix develop .# --command cargo build --release -p remote-monitor
nix develop .# --command cargo test
The flake provides: pkg-config, openssl, rustc, cargo, rustfmt, clippy, ansible.
PKG_CONFIG_PATH is set for openssl. There is no shell.nix — only flake.nix.
Cross-compilation gotcha (musl/mingw targets): Each cross-stdenv sets CC globally;
pin each target via CC_<triple> env vars + reset CC to native gcc in shellHook to prevent
the last cross-compiler from poisoning native builds.
Priority (highest wins):
- CLI flags:
--monitor-url,--monitor-token - Env vars:
MONITOR_URL,MONITOR_TOKEN .nervctf.yml/.nervctf.yaml(walked up from challenges_dir)
Config struct (utils.rs):
pub struct Config {
pub monitor_url: Option<String>,
pub monitor_token: Option<String>,
pub challenges_dir: Option<String>,
pub target_ip: Option<String>, // setup only
pub target_user: Option<String>, // setup only
pub ssh_pubkey_path: Option<String>, // setup only
pub ctfd_remote_path: Option<String>, // setup only
pub monitor_port: Option<String>, // setup only
pub max_concurrent_provisions: Option<u32>, // monitor tuning
pub max_instances_per_team: Option<u32>, // monitor tuning (0 = unlimited)
pub runner_ip: Option<String>, // split-machine mode
pub runner_user: Option<String>, // split-machine mode
}load_config(start_dir) walks up directories looking for .nervctf.yml.
save_config(config, path) serialises to YAML (skips None fields).
Split-machine mode: when runner_ip + runner_user are set, nervctf deploy rsyncs
challenge files directly to {runner_user}@{runner_ip}:/home/docker/challenges/ instead of
uploading via the monitor. After rsync, it calls POST /api/v1/instance/build-compose-remote
to trigger image builds on the runner via SSH.
enum ChallengeType { Standard, Dynamic, Instance }
// serde: "standard" | "dynamic" | "instance"
// "instance" challenges deploy to CTFd as "standard" or "dynamic" depending on
// whether extra.initial is set. CTFd itself never sees "instance" as the type.Required fields: name, category, value, type
Key optional fields:
extra: Option<Extra>—{initial, decay, minimum}for Dynamic scoringinstance: Option<InstanceConfig>— only fortype: instanceflags: Option<Vec<FlagContent>>hints: Option<Vec<HintContent>>tags: Option<Vec<Tag>>files: Option<Vec<String>>— relative paths from challenge dirrequirements: Option<Requirements>state: Option<State>—"hidden"|"visible"connection_info: Option<String>attempts: Option<u32>source_path: String— injected by scanner, not serialised; absolute path to challenge dirunknown_yaml_keys: Vec<String>— injected by scanner for lint warnings
enum FlagContent {
Simple(String),
Detailed { id, challenge_id, type_: FlagType, content: String, data: Option<FlagData> }
}
// FlagType: "static" | "regex"
// FlagData: "case_sensitive" | "case_insensitive" (snake_case serde)enum HintContent {
Simple(String),
Detailed { content: String, cost: Option<u32>, title: Option<String> }
}
// .content_str() helper extracts content from either variantenum Tag {
Simple(String),
Detailed { challenge_id, id, value: String }
}
// .value_str() helperenum Requirements {
Simple(Vec<serde_json::Value>), // list of names or integer IDs
Advanced { prerequisites: Vec<Value>, anonymize: bool }
}
// .prerequisite_names() → Vec<String> (integers coerced to string)pub struct InstanceConfig {
pub backend: InstanceBackend, // docker | compose | lxc | vagrant
pub image: Option<String>,
pub compose_file: Option<String>, // default: "docker-compose.yml"
pub compose_service: Option<String>,
pub lxc_image: Option<String>,
pub vagrantfile: Option<String>,
pub internal_port: u32,
pub connection: String, // "nc" | "http" | "ssh"
pub timeout_minutes: Option<u32>,
pub max_renewals: Option<u32>,
pub command: Option<String>,
pub flag_mode: Option<InstanceFlagMode>, // "static" | "random"
pub flag_prefix: Option<String>,
pub flag_suffix: Option<String>,
pub random_flag_length: Option<u32>,
pub flag_delivery: Option<FlagDelivery>, // "env" (default) | "file"
pub flag_file_path: Option<String>, // absolute path inside container (file mode)
pub flag_service: Option<String>, // compose service receiving flag file
}Topological sorter for deploy ordering. Uses Kahn's algorithm.
resolve_dependencies(actions: Vec<SyncAction>) → Vec<SyncAction> — returns Create/Update
actions in dependency order, UpToDate/RemoteOnly appended after.
nervctf [OPTIONS] <COMMAND>
Options:
-c, --challenges-dir <PATH> default "."
-v, --verbose
--monitor-url <URL>
--monitor-token <TOKEN>
Commands:
deploy [--dry-run] [--recreate] create new challenges + update changed ones
list [--detailed] list local challenges
scan [--detailed] scan + print statistics
validate [--debug] lint challenge.yml files (full view with --debug)
fix [--dry-run] interactive YAML fixer (state/author/version)
setup [--upgrade] Ansible-based server deployment wizard
--recreate forces all challenges into the update queue (skips up-to-date check, skips
the Proceed? (y/N) prompt). Useful for re-syncing files to the runner or rebuilding images.
All API calls go to remote-monitor, which executes them against CTFd MariaDB directly.
- Scan local challenges tree →
Vec<Challenge> - Run validator; abort if any errors
GET /api/v1/challenges(paginated) → remote list- For each local challenge:
- Not on remote →
create_challenge_phase1()(POST /challenges→ monitor → SQL INSERT) - On remote +
needs_update()or--recreate→update_challenge_phase1()(PATCH /challenges/{id}→ monitor → SQL UPDATE)
- Not on remote →
- After all base creates/updates: flags, tags, files, hints, requirements, state patches
- For
type: instance:- Deploy to CTFd as
standardordynamic(based onextra.initialpresence) - Split-machine mode (
runner_ipset):- rsync challenge dir to
{runner_user}@{runner_ip}:/home/docker/challenges/<name>/ POST /api/v1/instance/build-compose-remote→ monitor SSHes to runner and runsdocker compose build
- rsync challenge dir to
- Single-machine mode:
POST /api/v1/instance/build(docker, local image path) orPOST /api/v1/instance/build-compose(compose) — uploads tar.gz
POST /api/v1/instance/register— registersInstanceConfigon monitor
- Deploy to CTFd as
Free function (also used as method via delegation). Compares:
category, value, description, state, connection_info, attempts,
extra (JSON comparison), flags (sorted content strings), tags (sorted values),
hints (sorted content strings), requirements (presence only, not deep comparison).
Note: CTFd list endpoint never returns flags/tags/hints, so those comparisons only fire
when both sides are Some (i.e. after a per-challenge detail fetch).
All files for a challenge must be sent in ONE multipart request (multiple "file" parts).
One request per file → CTFd returns 500. Matches ctfcli's _create_all_files() pattern.
- Challenge Visibility must be "Public" (Admin → Config → Visibility). Private mode blocks
even valid API tokens from
GET /api/v1/challenges. - Upload dir:
chown -R 1001:1001 <CTFd>/.data/CTFd/uploadswhen using Docker.
pub struct CtfdClient {
client: reqwest::Client,
base_url: String, // {monitor_url}/api/v1
// auth: Authorization: Token <monitor_token>
}All API calls go to remote-monitor ({monitor_url}/api/v1/...), which handles them via
direct MariaDB SQL. Response shape is CTFd-compatible ({"success": true, "data": ...}).
No CTFD_URL or CTFD_API_KEY — the monitor is the sole gateway.
execute(method, path, body) → Result<Option<Value>>get_challenges()— paginated viameta.pagination.next; loops until no next pageget_challenge_id(name)— scans all challenges for matching namecreate_challenge,update_challenge,delete_challengecreate_flag,delete_flag,get_challenge_flags_endpointcreate_tag,delete_tag,get_challenge_tags_endpointcreate_hint,delete_hint,get_challenge_hints_endpointcreate_file(multipart/form-data),delete_file,get_challenge_files_endpointparse_response— private; unwraps{"success": true, "data": ...}envelope
Dependency conflict: axum 0.7 uses http 1.x; reqwest 0.11 uses http 0.2.x.
Bridge via string conversion (.as_str() / .as_bytes()). Do not mix HeaderMap types.
pub struct AppState {
pub monitor_token: String, // MONITOR_TOKEN
pub public_host: String, // PUBLIC_HOST
pub db: Db, // Arc<Mutex<Connection>> (SQLite)
pub ctfd_pool: mysql_async::Pool, // CTFd MariaDB pool
pub challenges_base_dir: String, // CHALLENGES_BASE_DIR
pub ctfd_uploads_dir: String, // CTFD_UPLOADS_DIR
pub runner_ssh_target: Option<String>, // RUNNER_SSH_TARGET (split-machine mode)
pub provision_sem: Arc<Semaphore>, // MAX_CONCURRENT_PROVISIONS
pub max_instances_per_team: u64, // MAX_INSTANCES_PER_TEAM (0 = unlimited)
}| Var | Default | Purpose |
|---|---|---|
CTFD_DB_URL |
required | MariaDB URL (mysql://user:pass@host/db) |
MONITOR_TOKEN |
required | Admin auth token |
PUBLIC_HOST |
required | Hostname returned to players in connection strings |
CTFD_UPLOADS_DIR |
"" |
Absolute path to CTFd uploads dir (for file writes) |
CHALLENGES_BASE_DIR |
/opt/nervctf/challenges |
Root for challenge files |
RUNNER_SSH_TARGET |
"" |
SSH target for split-machine mode (e.g. docker@192.168.1.50) |
MONITOR_PORT |
33133 |
TCP bind port |
MONITOR_BIND |
0.0.0.0 |
TCP bind address |
DB_PATH |
./monitor.db |
SQLite file path |
MAX_CONCURRENT_PROVISIONS |
4 |
Semaphore limit for concurrent docker/compose ops |
MAX_INSTANCES_PER_TEAM |
0 |
Max active instances per team across all challenges (0 = unlimited) |
CTFD_DB_SYNC_INTERVAL |
30 |
Seconds between CTFd MariaDB → SQLite sync cycles |
GET /admin?token=<MONITOR_TOKEN> — self-contained HTML (no CDN; air-gap safe).
Token via ?token= query param or Authorization: Token header.
Three auto-refreshing tables:
- Flag sharing alerts (15 s) — flag submitted to wrong team's challenge instance
- Active instances (15 s) — all running containers with team/user/host:port/expiry
- Recent flag attempts (30 s) — last 200 attempts across all teams
No auth:
GET /health→{"status": "ok"}GET /instance/:name→ HTML player UI page (inline, no CDN)
Admin (Authorization: Token <MONITOR_TOKEN> or ?token=):
GET /admin→ serves admin dashboard HTML
Admin (Authorization: Token <MONITOR_TOKEN>):
POST /api/v1/instance/build— multipartchallenge_name+context(tar.gz); builds docker imagePOST /api/v1/instance/build-compose— multipart; wipes dir first, extracts tar.gz, builds compose images (single-machine)POST /api/v1/instance/build-compose-remote— JSON{challenge_name, compose_file?, challenges_dir?}; SSHes to runner and runsdocker compose build(split-machine)POST /api/v1/instance/register—{challenge_name, ctfd_id, backend, config_json}GET /api/v1/instance/list→ registered configsGET /api/v1/admin/instances→ all active instances as JSONGET /api/v1/admin/attempts[?alerts_only=true]→ flag attempt log (200 max) or sharing alertsGET /api/v1/admin/solves→ correct solves per team (from ctfd_solves cache)- CTFd CRUD (all via MariaDB SQL):
GET/POST /api/v1/challenges,GET/PATCH/DELETE /api/v1/challenges/{id}GET/POST /api/v1/flags,DELETE /api/v1/flags/{id}GET/POST /api/v1/hints,DELETE /api/v1/hints/{id}GET/POST /api/v1/tags,DELETE /api/v1/tags/{id}GET/POST /api/v1/files,DELETE /api/v1/files/{id}(disk + DB)POST /api/v1/topics
Plugin auth (admin token + explicit team_id in body — called by CTFd plugin):
GET /api/v1/plugin/info?challenge_name=X&team_id=NPOST /api/v1/plugin/request→ provisions instancePOST /api/v1/plugin/renew→ extends expiryDELETE /api/v1/plugin/stop→ destroys one team's instanceDELETE /api/v1/plugin/stop_all→ destroys all instances for a challengePOST /api/v1/plugin/solve→ marks solved + tears down instancePOST /api/v1/plugin/attempt→ records flag submission; detects sharing viateam_flagstable
Player auth (CTFd user token validated via direct MariaDB lookup):
POST /api/v1/instance/request→ provisions instanceGET /api/v1/instance/info?challenge_name=XPOST /api/v1/instance/renewDELETE /api/v1/instance/stop
Expiry task — every 30 s:
get_expired_instances()→ for each:cleanup_container(id, runner_ssh)+delete_instance()+delete_flag(ctfd_flag_id)- Orphan cleanup:
list_ctf_projects()(compose) → stop any not in DB
CTFd sync task — every CTFD_DB_SYNC_INTERVAL s (default 30):
ctfd_db::sync_solves()— full-replacectfd_solvesSQLite cache from MariaDBsubmissions; reverts solved instances where submission was deleted; removes staleis_correct=1flag_attemptsctfd_db::sync_users_and_teams()— full-replacectfd_teams+ctfd_userscaches
Checks Authorization: Token <value> header. Used for all admin and plugin routes.
Direct MariaDB query on users table (SELECT team_id FROM users WHERE token = ? AND banned = 0 AND hidden = 0).
Returns None if invalid/no team. Used for player routes.
CREATE TABLE instance_configs (
challenge_name TEXT PRIMARY KEY,
ctfd_id INTEGER NOT NULL,
backend TEXT NOT NULL, -- "docker"|"compose"|"lxc"|"vagrant"
config_json TEXT NOT NULL, -- full InstanceConfig as JSON
image_tag TEXT, -- resolved after build
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
challenge_name TEXT NOT NULL,
team_id INTEGER NOT NULL,
user_id INTEGER, -- CTFd user who requested
container_id TEXT, -- docker ID, compose project name, or LXC name
host TEXT NOT NULL,
port INTEGER NOT NULL,
connection_type TEXT NOT NULL,
status TEXT NOT NULL, -- "running"|"provisioning"|"solved"
flag TEXT, -- per-team random flag (null for static)
ctfd_flag_id INTEGER, -- CTFd flag ID for cleanup on stop/expire
renewals_used INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT NOT NULL, -- "YYYY-MM-DD HH:MM:SS" UTC
UNIQUE(challenge_name, team_id)
);
CREATE TABLE flag_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
challenge_name TEXT NOT NULL,
team_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
submitted_flag TEXT NOT NULL,
is_correct INTEGER NOT NULL DEFAULT 0, -- 0|1 boolean
is_flag_sharing INTEGER NOT NULL DEFAULT 0, -- 1 = flag belonged to a different team
owner_team_id INTEGER, -- team whose instance generated this flag (if sharing)
timestamp TEXT DEFAULT (datetime('now'))
);
-- Permanent flag history for sharing detection (never deleted on instance stop)
CREATE TABLE team_flags (
challenge_name TEXT NOT NULL,
team_id INTEGER NOT NULL,
flag TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (challenge_name, team_id, flag)
);
-- Read-only cache of correct solves synced from CTFd MariaDB submissions
CREATE TABLE ctfd_solves (
challenge_name TEXT NOT NULL,
team_id INTEGER NOT NULL,
user_id INTEGER,
solved_at TEXT,
PRIMARY KEY (challenge_name, team_id)
);
-- Cached team/user names from CTFd for display
CREATE TABLE ctfd_teams (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE ctfd_users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, team_id INTEGER);Db = Arc<Mutex<Connection>>. WAL mode enabled. Migrations are run on every open().
Key functions:
upsert_config / get_config / get_image_tag / update_image_tag / list_configsinsert_provisioning_stub— INSERT OR IGNORE placeholder row (status='provisioning')insert_instance— UPSERT (resetsrenewals_used=0); also writes toteam_flagsget_instance / delete_instance / mark_instance_solveddelete_all_instances_for_challenge— used on challenge deleteget_used_ports → HashSet<u16>/get_expired_instances/list_all_instancesinsert_flag_attempt / list_flag_attempts(limit) / list_sharing_alertsfind_flag_owner— queriesteam_flags(permanent); returnsSome(owner_team_id)if flag sharingreplace_ctfd_solves— full DELETE + INSERT cycle (so deleted submissions are reflected)revert_unsolved_instances— setsstatus='running'wherestatus='solved'but no ctfd_solve entrydelete_stale_correct_attempts— removesis_correct=1flag_attempts with no matching ctfd_solvereplace_ctfd_teams_and_users— full-replace name cacheshas_correct_solve / list_correct_solves
provision(db, challenge_name, team_id, user_id, config, public_host, ctfd_pool, runner_ssh) → (host, port, conn_type, expires_at)
generate_flag(config)→Option<String>—Noneunlessflag_mode = "random"; generates<prefix><N chars><suffix>sanitize_name(name)— lowercase, non-alphanumeric/hyphen → hyphen, trim- container naming:
ctf-<sanitized>-t<team_id> expires_at_string(minutes)→"YYYY-MM-DD HH:MM:SS"UTC (no chrono dep)cleanup_container(container_id, runner_ssh: Option<&str>)— tries compose::down, lxc::delete, docker::remove_container
All docker/compose/lxc functions accept runner_ssh: Option<&str>. When set, commands are
executed on the runner via ssh -o BatchMode=yes <target> <cmd>.
pick_free_port(used_ports) → u16— random in 40000–60000run_container(..., runner_ssh) → String—docker run -d -p host:internal ...remove_container(id, runner_ssh)—docker rm -fbuild_image(context_tar_path, image_tag, runner_ssh)—docker build -t <tag>
up(..., runner_ssh) → (u16, String)- Writes
<project_name>.override.yml(port mapping + optional flag file bind-mount) - In split-machine mode: writes files to runner via
ssh mkdir -p && cat > file flag_delivery = "env": injectsFLAG=<value>env var; challenge compose uses${FLAG}flag_delivery = "file": writes<project_name>.flag+ bind-mount override forflag_service- Runs:
DOCKER_BUILDKIT=1 docker compose -f <compose_file> -f <override> -p <project_name> up -d --force-recreate
- Writes
down(project_name, runner_ssh?)—docker compose -p <name> down -vbuild(compose_file, runner_ssh?)—docker compose -f <file> buildlist_ctf_projects()— lists runningctf-*compose projects (for orphan cleanup)
launch(lxc_image, name, host_port, internal_port, flag)—lxc launch→ wait → proxy port → flag injectiondelete(name)—lxc stop --force+lxc delete --force
up(...)— stub, returns error
Plugin name: nervctf_instance. Installed to CTFd/plugins/nervctf_instance/.
Registered as CHALLENGE_CLASSES["instance"]. CTFd routes it via polymorphic type = "instance".
Key methods:
create(request)— createsInstanceChallengerow, calls_register_with_monitor()read(challenge)— returns challenge data + backend/connection/timeout/flag_modeupdate(challenge, request)— updates fields, re-registers with monitordelete(challenge)— calls_stop_all_instances()then cascades DB deletessolve(user, team, challenge, request)— delegates toBaseChallenge.solve(), then callsPOST /api/v1/plugin/solveon monitor to tear down the team's instanceattempt(challenge, request)— delegates toBaseChallenge.attempt()for verdict (returns aChallengeResponseobject with.successbool attribute, not a tuple); then fire-and-forgetsPOST /api/v1/plugin/attemptto monitor (timeout=0.5s, swallowed) with{challenge_name, team_id, user_id, submitted_flag, is_correct}. Never blocks the CTFd flag submission response.
POSTs {challenge_name, ctfd_id, backend, config_json} to monitor's
POST /api/v1/instance/register. Called on create and update.
All use get_current_team() to get team_id. Forward to monitor's /api/v1/plugin/*
routes using admin token + explicit team_id in body/params (players never get admin token).
GET /api/v1/containers/info/<challenge_id>POST /api/v1/containers/request— also sendsuser_idfromget_current_user()POST /api/v1/containers/renewPOST /api/v1/containers/stop
SQLAlchemy polymorphic model (__mapper_args__ = {"polymorphic_identity": "instance"}).
Extra columns: backend, image, command, compose_file, compose_service,
lxc_image, vagrantfile, internal_port, connection, timeout_minutes,
max_renewals, flag_mode, flag_prefix, flag_suffix, random_flag_length,
initial_value, minimum_value, decay_value, decay_function.
DOM API only (no innerHTML). Fetch/Extend/Terminate buttons call /api/v1/containers/*.
Displays connection string and countdown timer when instance is running.
expires_at from monitor is "YYYY-MM-DD HH:MM:SS" UTC; plugin converts to ms in
_sqlite_to_ms() before sending to JS (which treats it as Unix ms).
Idempotent. Target group ctfd. Key extra vars: ssh_key, ctfd_path, monitor_token,
monitor_port; optional: monitor_binary, plugin_src, ctfd_api_key.
Tasks in order:
- Install Docker (if
docker --versionfails) - Create
dockergroup + user, authorize SSH pubkey - Clone CTFd (if not present), start via
docker compose up --build -d --no-recreate - Install LXD via snap +
lxd init --auto - Install Vagrant via HashiCorp apt repo
- Add
dockeruser tolxdandlibvirtgroups - Deploy plugin: rsync
plugin_src/toctfd_path/CTFd/plugins/nervctf_instance/ - Create
/data/challengeson host (owned by docker user) - Copy remote-monitor binary + write Dockerfile +
docker build -t nervctf-monitor:latest - Write
docker-compose.override.ymlinjectingremote-monitorservice + CTFd env vars:NERVCTF_MONITOR_URL=http://remote-monitor:<port>NERVCTF_MONITOR_TOKEN=<token>
docker compose up -d --force-recreate
services:
ctfd:
environment:
- NERVCTF_MONITOR_URL=http://remote-monitor:33133
- NERVCTF_MONITOR_TOKEN=<token>
remote-monitor:
image: nervctf-monitor:latest
restart: unless-stopped
networks:
- default
- internal # joins CTFd's internal network to reach MariaDB
environment:
- CTFD_DB_URL=mysql://ctfd:<db_password>@db/ctfd
- CTFD_UPLOADS_DIR=<ctfd_path>/.data/CTFd/uploads
- CTFD_URL=http://ctfd:8000
- MONITOR_TOKEN=<token>
- PUBLIC_HOST=<ansible_host>
- MONITOR_PORT=33133
- DB_PATH=/data/monitor.db
- CHALLENGES_BASE_DIR=/data/challenges
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro
- remote_monitor_data:/data
- /data/challenges:/data/challenges
- <ctfd_path>/.data/CTFd/uploads:<ctfd_path>/.data/CTFd/uploads
ports:
- "33133:33133"
networks:
internal: {}
volumes:
remote_monitor_data:Network note: CTFd's db service is on an internal: true network. The override
adds remote-monitor to that network so it can reach MariaDB at hostname db.
Critical path constraint: /data/challenges must use identical absolute paths on the host
and inside the monitor container. Challenge docker-compose.yml files reference files like
/data/challenges/my-challenge/certs/server.pem. The monitor container accesses the same
path via the bind mount, and the host Docker daemon resolves those paths when the monitor
instructs it to launch challenge containers (Docker-outside-of-Docker via socket mount).
Interactive wizard:
- Prompts: base_dir, target_ip, target_user, ctfd_remote_path (default
/home/docker/CTFd), monitor_port (default33133) - Generates or reuses
monitor_token(32 hex bytes from/dev/urandom) - Lists
~/.ssh/*.pubkeys; offers to generate new keypair - Saves config to
.nervctf.ymlbefore running playbook - TCP-checks port 80 on target; if already up, confirms redeploy
- Locates
remote-monitorbinary (prefers musl targets in target/ tree or next to exe) - Locates
ctfd-plugindir (next to exe, or in workspacesrc/nervctf/assets/ctfd-plugin/) - Writes playbook + inventory to tmpdir, runs
ansible-playbook - If
ansible-playbooknot in PATH: falls back tonix develop <flake_dir> --command ansible-playbook
validate_challenges(base_dir, fix_mode) → Result<()>
Runs DirectoryScanner + lint checks. Reports errors (block deploy) and warnings (advisory).
Error conditions:
- Missing required fields:
name,category,value,type type: instancewithoutinstance:blocktype: instancewithoutinstance.internal_porttype: instancewithoutinstance.connectiontype: dynamicwithoutextra.initial
Warning conditions:
- Unknown YAML keys (captured in
unknown_yaml_keysby scanner) type: instancewithflag_mode: randombut also has staticflags:listtype: instance, backend: composewith nocompose_servicesettype: instance, flag_delivery: filewithoutflag_file_pathvalue: 0on non-dynamic challenges- Missing
state/author/versionfields - Empty
flagslist - Duplicate challenge names
RENDERED constant: set of field paths that are expected (suppresses unknown-key warnings for known fields like instance.flag_delivery, instance.flag_file_path, etc.).
Scans all challenge.yml for missing state, author, version fields.
Uses has_field() (top-level key detection, column-0 check) and inject_field()
(inject after specific sibling key, with fallback).
DirectoryScanner::new(base_path) — scans base_path/challenges/<category>/<challenge>/challenge.yml.
Constants:
const CHALLENGE_PATTERNS: &[&str] = &["challenge.yml", "challenge.yaml", "challenge.json"];
const CHALLENGE_EXTENSIONS: &[&str] = &["yml", "yaml", "json"];Max depth: 5. Does not follow symlinks.
Uses serde_yaml::Value for initial parse to extract unknown keys, then re-parses as Challenge.
source_path and unknown_yaml_keys are injected after parse (both marked #[serde(skip)]).
Minimal example:
name: My Challenge
category: web
value: 100
type: standard
version: '0.3'
author: Author Name
state: visible
flags:
- flag{example}
description: |
Find the flag hidden in the page source.Instance challenge example:
name: My Container Challenge
category: pwn
value: 0 # 0 for dynamic scoring
type: instance
version: '0.3'
author: Author Name
state: visible
description: Connect to the service and exploit it.
extra:
initial: 500
decay: 50
minimum: 100
instance:
backend: docker
image: . # "." = local build from challenge dir; or registry image
internal_port: 1337
connection: nc
flag_mode: random
flag_prefix: "CTF{"
flag_suffix: "}"
random_flag_length: 16
timeout_minutes: 45
max_renewals: 3Compose backend example:
instance:
backend: compose
compose_file: docker-compose.yml # relative to challenge dir
compose_service: app # service name that exposes the port
internal_port: 8080
connection: http
flag_mode: random
flag_delivery: env # default: FLAG env var for ${FLAG} substitution
# flag_delivery: file # alternative: write flag to file in container
# flag_file_path: /challenge/flag # required for file delivery
# flag_service: flag-receiver # optional: service receiving flag file (defaults to compose_service)
timeout_minutes: 60- Hints
valuevscost: CTFd API usescost; old code sentvalue→ hints had 0 cost - Flag
datafield optional: CTFd returns omitteddata; required field caused parse failures - Requirements untagged enum: supports simple list
["name"], int list[1,2], and advanced object{prerequisites: [...], anonymize: bool} - FlagData serialization: must be
snake_case("case_insensitive"), not "caseinsensitive" - CTFd pagination:
GET /api/v1/challengesreturns 20/page max; must loop viameta.pagination.next - File uploads: all files must be in one multipart request
- Compose files not reaching server: CLI must upload tar.gz via
build-composeendpoint; monitor wipes existing dir before extraction to prevent Docker placeholder directories (Docker creates empty dirs at bind-mount source paths when they don't exist at startup) - Docker placeholder dirs blocking tar: if Docker ran before files existed, certs/etc.
become root-owned dirs; fixed by
remove_dir_allbeforecreate_dir_allin build handler - Hardcoded
container_name:in compose files: prevents multi-team instances; challenge authors must not setcontainer_name:in their docker-compose.yml
clap 4(derive) — CLIreqwest 0.11(blocking, multipart, json, rustls-tls) — HTTP clienttokio 1(full) — async runtimeserde 1,serde_json,serde_yaml— serialisationanyhow 1— error handlingwalkdir 2— directory traversaldialoguer— interactive prompts (setup/fix)tempfile 3— temp dir for Ansible assets
axum 0.7(multipart) — HTTP serverreqwest 0.11— outbound HTTP (CTFd proxy + token validation)tokio 1(full) — async runtimeserde 1,serde_json— serialisationrusqlite 0.31(bundled) — SQLiterand 0.8— random flag generation + port selectionanyhow 1— error handlingtower-http 0.4(trace) — request tracing middlewaretracing 0.1,tracing-subscriber 0.3(env-filter) — structured loggingshlex 1— shell argument quotingtempfile 3— temp files for docker build
# Dev build (both crates)
nix develop .# --command cargo build
# Release build for Linux musl (deployable to server without NixOS interpreter)
nix develop .# --command cargo build --release --target x86_64-unknown-linux-musl -p remote-monitor
# Run tests
nix develop .# --command cargo test
# Run tests for one crate
nix develop .# --command cargo test -p nervctfDeployment of remote-monitor:
- Build musl release binary
- Run
nervctf setup— playbook copies binary, builds Docker image, starts service - Or manually:
scp target/x86_64-unknown-linux-musl/release/remote-monitor user@host:~then trigger playbook withmonitor_binaryextra-var
- Vagrant backend is a stub (returns error);
lxc::destroynot called on cleanup (onlylxc::delete) - Per-team instance cap is global (
MAX_INSTANCES_PER_TEAMenv var), not per-challenge synccommand asks for confirmation interactively; not scriptable- No authentication on player HTML page (
GET /instance/:name) — token entered client-side - Challenge requirements comparison is shallow (presence only, not prerequisite identity)
- CTFd API key for dynamic scoring challenges:
extra.initialtriggers Dynamic type but CTFd's Dynamic plugin must be installed; base CTFd does not include it by default