A CLI and server for running CTF competitions on CTFd. You write challenges as YAML, deploy them with one command, and NervCTF manages per-team container instances and detects flag sharing. Nothing in the workflow requires the CTFd API by hand.
Tested against CTFd 3.7.3. Run
nervctf probeafter any CTFd upgrade to check compatibility.
NervCTF has three parts. You deploy them once and then leave them running.
Your machine CTFd host (single-machine mode)
───────────── ─────────────────────────────────────────────────
┌─ Docker Compose stack ────────────────────────┐
nervctf CLI ─── Token ──▶ │ remote-monitor:33133 ─── SQL ──▶ MariaDB │
│ │ └──▶ uploads dir │
│ instance manager │
│ (docker daemon, local) │
│ CTFd (nginx+gunicorn)│
│ nervctf plugin │
└───────────────────────────────────────────────┘
The CLI (nervctf) runs on your machine. It reads challenge.yml files, validates them, and syncs them to the remote monitor. It also provides nervctf setup, which provisions the server.
The remote monitor (remote-monitor) runs inside Docker on the CTFd host. It writes to CTFd's MariaDB directly (no CTFd API key), manages container instances for per-team challenges, and serves an admin dashboard.
The CTFd plugin (nervctf_instance) is deployed by nervctf setup. It plugs into CTFd's challenge system so players can request, renew, and stop containers from the CTFd UI.
In split-machine mode, containers run on a separate worker node. The CLI rsyncs challenge files to the runner, and the monitor controls containers over SSH.
Your machine CTFd host Runner node
──────────── ────────────────────────────── ──────────────────
nervctf CLI ──▶ remote-monitor ─── SSH ──────▶ docker daemon
│ │
└── rsync ──────┘ (challenge files)
Download from GitHub Releases:
nervctf-linux-x86_64-static— CLI for Linux (static, no dependencies)nervctf-linux-aarch64— CLI for ARM64nervctf-windows-x86_64.exe— CLI for Windowsremote-monitor-linux-x86_64-static— server binary (deploy to the CTFd host)
Rename them to nervctf and remote-monitor, and put them in your PATH.
The nervctf binary embeds the CTFd plugin (ctfd-plugin/) and extracts it on demand, so no extra files are needed. nervctf setup still needs Ansible (ansible-playbook) on the machine you run it from; Ansible's control node must be Linux or macOS, not Windows. To use a binary that is not in PATH, set ANSIBLE_PLAYBOOK=/path/to/ansible-playbook.
# With Nix (provides all dependencies)
nix develop .# --command cargo build --release
# Without Nix (Debian/Ubuntu)
sudo apt install build-essential pkg-config libssl-dev
cargo build --release
# Static musl builds (what the releases use)
nix develop .# --command cargo build --release --target x86_64-unknown-linux-muslCross-compile targets: make release-musl (static), make release-arm64, make release-windows. Run make help for the full list.
# 1. Run the setup wizard. It provisions Docker, CTFd, the plugin, and the monitor.
nervctf setup
# 2. Write your challenges
mkdir -p challenges/web/sqli challenges/pwn/overflow
cat > challenges/web/sqli/challenge.yml <<'EOF'
name: SQL Injection 101
category: web
value: 100
type: standard
description: Find the flag in the database.
flags:
- flag{sql_is_fun}
EOF
# 3. Validate locally (no network required)
nervctf validate
# 4. Deploy to CTFd
nervctf deploy
# 5. Check compatibility with the live CTFd instance
nervctf probenervctf setup creates this file. The CLI searches for it upward from the working directory, all the way to the filesystem root, so you can keep it at the repository root and run nervctf from any subdirectory.
# ── Monitor connection ─────────────────────────────────────────────────────────
monitor_ip: 1.2.3.4 # IP of the CTFd/monitor host
monitor_port: 33133 # default: 33133
# ── Authentication ────────────────────────────────────────────────────────────
monitor_token: <hex> # 64-char hex token; auto-generated by nervctf setup
# This is the remote-monitor token, not a CTFd token.
# ── Deployment (used only by nervctf setup / setup --upgrade) ─────────────────
monitor_user: root # SSH user on the CTFd host (must have sudo/docker)
monitor_ctfd_path: /home/root/CTFd # CTFd install path on the remote host
# default: /home/<monitor_user>/CTFd
ssh_key_path: ~/.ssh/id_rsa # private key for SSH access to the monitor and runner hosts
# ── Local challenges ──────────────────────────────────────────────────────────
challenges_path: ./challenges # where nervctf looks for challenge.yml files
# ── Tuning (applied at setup time; change with setup --upgrade) ────────────────
max_concurrent_provisions: 4 # max parallel container provisions
max_instances_per_team: 3 # max active instances per team across all challenges
# 0 = unlimited
# ── Display ───────────────────────────────────────────────────────────────────
ctfd_domain: ctfd.example.com # CTFd URL shown in admin dashboard links
# defaults to http://<monitor_ip>
# ── Split-machine mode (optional) ─────────────────────────────────────────────
# Set runner_ip to run containers on a separate node.
# Leave it unset to run containers on the same machine as CTFd.
runner_ip: 192.168.1.50
runner_user: docker
runner_domain: challenges.example.com # hostname shown to players in connection strings
# defaults to runner_ip
# ── Event settings (optional) ─────────────────────────────────────────────────
# Applied with `nervctf event`. `nervctf setup` also uses these to complete
# CTFd's first-run setup.
event:
name: "ACME CTF 2026"
description: "Annual capture-the-flag event."
start: "2026-10-01T09:00:00Z"
end: "2026-10-02T09:00:00Z"
user_mode: teams # teams | users
challenge_visibility: public
registration_visibility: public
score_visibility: public
account_visibility: public
verify_emails: false
# ── First CTFd admin (written by nervctf setup) ───────────────────────────────
# This file then contains the admin password; it is written with mode 0600.
admin:
user: admin
email: admin@example.com
password: change-meThe file can contain the admin password, so nervctf setup writes it with mode 0600. Keep that in mind if you copy it between machines.
CLI flags > environment variables > .nervctf.yml
| CLI flag | Environment variable | Description |
|---|---|---|
--monitor-url |
MONITOR_URL |
Full URL of the remote monitor (http://host:port) |
--monitor-token |
MONITOR_TOKEN |
Monitor auth token |
--monitor-url overrides both monitor_ip and monitor_port from the config file.
Global flags come before the subcommand name:
nervctf [GLOBAL FLAGS] <subcommand> [SUBCOMMAND FLAGS]| Flag | Default | Description |
|---|---|---|
-c, --challenges-dir <path> |
. (current directory) |
Root directory to search for challenge.yml files |
-v, --verbose |
false | Enable verbose output |
--monitor-url <url> |
from config/env | Override the monitor URL for this run |
--monitor-token <token> |
from config/env | Override the monitor token for this run |
Provisions the server environment on the remote host. Run it once per competition.
nervctf setup
nervctf setup --upgradeFirst run:
- Prompts for the host IP, SSH user, CTFd path, monitor port, token, SSH key, and the first CTFd admin username, email, and password.
- Saves everything to
.nervctf.yml. - Runs an embedded Ansible playbook that:
- installs Docker and Docker Compose if they are missing;
- clones CTFd 3.7.3 and starts it;
- builds and starts the
remote-monitorcontainer; - rsyncs the
nervctf_instanceplugin into CTFd's plugin directory; - writes
docker-compose.override.ymlwith the monitor's environment; - restarts CTFd so the plugin loads;
- polls the health endpoints until everything is up.
- On first start, the plugin creates the admin account and writes the event name, description, user mode, and
setupflag from theadmin:andevent:blocks. CTFd's setup wizard does not appear. - Prints the admin dashboard URL and token.
Before it runs the playbook, setup checks the controller for ansible-playbook, ssh, and rsync, and the target hosts for passwordless sudo, python3, rsync, and git. If any are missing, it stops with a message naming the fix.
--upgrade:
- Pushes a new
remote-monitorbinary and plugin files to the server. - Rebuilds the monitor Docker image.
- Restarts CTFd and the monitor.
- Checks whether the installed CTFd version matches the tested version (3.7.3).
- Does not re-provision CTFd or regenerate the token.
- Prompts for the first-admin username, email, and password when the existing config predates the
admin:block, then applies it on the next CTFd restart.
| Flag | Description |
|---|---|
--upgrade |
Upgrade the plugin and monitor binary on an existing deployment |
Validates all challenges locally, diffs them against CTFd, and applies the changes.
nervctf deploy
nervctf deploy --dry-run
nervctf deploy --recreate
nervctf deploy --recreate --pruneDeploy runs in four ordered phases:
| Phase | What happens |
|---|---|
| 1 — Cores | Creates new challenges and updates changed ones. Uploads flags, tags, topics, and hints. Detects changed files and marks them for phase 2. |
| 2 — Files | Uploads attachment files for new and changed challenges. |
| 3 — Requirements | Resolves prerequisite challenge names to CTFd IDs and patches requirements. Runs after phase 1 so every ID exists. |
| 4 — Next pointers | Patches next_id links. Same reason as phase 3. |
Before phase 1, the CLI fetches the stored compatibility probe and:
- aborts if dynamic challenge scoring is in a broken state (partial CTFd migration);
- warns if CTFd is in user-mode, where player instance auth will fail;
- notes any degraded capabilities (for example, Redis cache not invalidated);
- warns if the live CTFd version differs from the tested version (3.7.3).
A challenge counts as changed when any of these differ from the remote: category, description, state, connection_info, attempts, extra fields, flags (content, type, data), hints (content, cost), tags, topics, or files.
A type change (for example, standard to dynamic) always deletes and recreates the challenge. CTFd's PATCH endpoint does not create the required join-table rows.
| Flag | Description |
|---|---|
-d, --dry-run |
Print what would change without applying anything |
--recreate |
Re-deploy all challenges even if up to date; re-syncs files to the runner and rebuilds images |
--prune |
Delete remote challenges that no longer exist locally (runs after phase 4) |
Validates challenge YAML files without connecting to the server. Exits 1 if it finds errors.
nervctf validate
nervctf validate --debugIt checks every field for each challenge type and applies cross-challenge rules such as duplicate names and self-referencing requirements. The full rule table is in validation rules.
| Flag | Description |
|---|---|
--debug |
Print the parsed field dictionary for every challenge |
Queries the remote monitor for its CTFd compatibility status and displays a capability matrix.
nervctf probe
nervctf probe --refresh
nervctf probe --jsonThe monitor stores a probe result at startup and refreshes it on demand. The probe queries CTFd's MariaDB to detect the installed version, team versus user mode, which optional schema columns exist, and whether the plugin table is installed.
Example output:
NervCTF <-> CTFd Compatibility Report
══════════════════════════════════════
CTFd version : 3.7.3 (source: configs_table)
CTFd mode : team
Probed at : 2026-05-26 14:32:11 UTC
Capability Status
────────────────────────── ──────────
Challenge CRUD [ok]
Dynamic scoring [ok]
Player authentication [ok]
Instance flag lifecycle [ok]
Redis cache invalidation [DEGRADED]
Schema fingerprint:
challenges : attribution, category, connection_info, decay, description,
function, id, initial, logic, max_attempts, minimum, name,
next_id, position, requirements, state, type, value
dynamic_challenge: id (stub-only — scoring is inline in challenges)
Warnings:
• Direct MariaDB writes bypass CTFd Redis cache — stale data may be served
until CTFd restart or TTL expiry
Capability statuses:
| Status | Meaning |
|---|---|
[ok] |
Fully supported |
[DEGRADED] |
Works with known limitations; the operator is warned |
[BROKEN] |
Will not work; deploy is blocked or severely impaired |
Exit code: 0 when every capability is ok or degraded; 1 when any is broken. This makes it usable as a CI gate.
Redis cache invalidation is always DEGRADED. NervCTF writes to MariaDB directly and cannot invalidate CTFd's Redis cache. Restart CTFd or raise CACHE_DEFAULT_TIMEOUT after a bulk deploy.
| Flag | Description |
|---|---|
--refresh |
Force a fresh probe from MariaDB instead of the cached result |
--json |
Output raw JSON instead of the formatted table |
Applies the declarative event: block from .nervctf.yml to CTFd's settings table (configs, or config on builds that use the singular name) through the monitor.
nervctf event
nervctf event --showThe managed keys are an allowlist: ctf_name, ctf_description, start, end, user_mode, challenge_visibility, registration_visibility, score_visibility, account_visibility, and verify_emails. The CLI maps the friendly YAML names (name, description) to those keys. The monitor detects whether the installed CTFd stores settings in configs or config.
This command does not create an admin account. nervctf setup completes CTFd's first-run wizard when an admin: block is configured: the plugin creates the first admin and sets setup = "true". On a database with no admin, nervctf event alone cannot remove the wizard.
| Flag | Description |
|---|---|
--show |
Print the mapped settings without applying them |
CTFd caches some settings. Restart CTFd after a bulk change if a value does not appear, or run nervctf setup --upgrade, which restarts the containers.
Lists all challenges under --challenges-dir.
nervctf list
nervctf list --detailed| Flag | Description |
|---|---|
-d, --detailed |
Show full field values for each challenge |
Scans the challenges directory and prints statistics: counts by type and category, total points, and so on.
nervctf scan
nervctf scan --detailed| Flag | Description |
|---|---|
-d, --detailed |
Show a per-challenge breakdown |
Scans challenge YAML files and offers to patch common missing fields (state, version, description, and others).
nervctf fix
nervctf fix --dry-run| Flag | Description |
|---|---|
-d, --dry-run |
Show what would be patched without changing files |
Challenges live in files named challenge.yml (or challenge.yaml). NervCTF searches recursively under --challenges-dir to a depth of 5. The directory layout is arbitrary; use whatever suits your competition.
challenges/
├── web/
│ └── sqli/
│ ├── challenge.yml
│ └── dist/source.py ← referenced in files:
└── pwn/
└── overflow/
└── challenge.yml
A fixed-point challenge: a submitted flag either matches or it does not.
# ── Identity ──────────────────────────────────────────────────────────────────
name: SQL Injection 101 # required; must be unique across all challenges
category: web # required
version: "0.3" # optional; local metadata only, not sent to CTFd
author: alice # optional; local metadata only
# ── Scoring ───────────────────────────────────────────────────────────────────
type: standard # standard | dynamic | instance
value: 100 # required for standard; must be > 0
state: visible # visible (default) | hidden
# ── Content ───────────────────────────────────────────────────────────────────
description: |
Find the vulnerability and retrieve the flag.
The server is at http://challenge.example.com
connection_info: "http://challenge.example.com" # optional; shown on the challenge page
attempts: 5 # max wrong guesses; 0 or omitted = unlimited
# ── Flags (at least one required for non-instance challenges) ─────────────────
flags:
- flag{simple_string} # shorthand: static type, case-sensitive
- type: static
content: "flag{alternate}"
data: case_insensitive # optional modifier; default case-sensitive
- type: regex
content: "flag\\{[a-z]+\\}" # regex pattern
# ── Organisation ──────────────────────────────────────────────────────────────
tags: [web, sql-injection, beginner]
topics: [owasp-top-10, database] # freeform labels, separate from tags in CTFd
# ── Hints ─────────────────────────────────────────────────────────────────────
hints:
- "Try single-quote injection" # free hint
- content: "The login form is vulnerable"
cost: 50 # costs 50 points to unlock
# ── Files ─────────────────────────────────────────────────────────────────────
files:
- dist/source.py # path relative to challenge.yml
- dist/Dockerfile
# ── Prerequisites ─────────────────────────────────────────────────────────────
requirements:
- "Warmup" # another challenge name; must exist locally or on CTFd
- "Web Intro"
# ── Navigation ────────────────────────────────────────────────────────────────
next: "Advanced SQLi" # the challenge shown as "next" in the CTFd UIPoints decrease as more teams solve the challenge.
name: Crypto Hard
category: crypto
type: dynamic
value: 0 # ignored for dynamic; set initial/minimum below
extra:
initial: 500 # starting value (required, must be > 0)
decay: 50 # number of solves at which the value reaches minimum (required, must be > 0)
minimum: 100 # floor value (optional; defaults to 0)
decay_function: linear # linear (default) | logarithmic
flags:
- flag{crypto_hard}Provisions an ephemeral container for each team. Players click "Request Instance" in CTFd, receive a host and port, and interact with their own environment.
name: Pwn Me
category: pwn
type: instance
value: 0
extra: # optional dynamic scoring on top of instance
initial: 500
decay: 50
minimum: 100
decay_function: linear
description: |
Connect to your instance and get root.
instance:
# ── Backend ─────────────────────────────────────────────────────────────────
backend: docker # docker | compose | lxc | vagrant
# ── Docker backend ───────────────────────────────────────────────────────────
image: . # "." builds from the Dockerfile in the challenge directory
# or use a registry image: "ubuntu:22.04"
# ── Compose backend (mutually exclusive with docker image) ───────────────────
# compose_file: docker-compose.yml
# compose_service: app # which service exposes the port
# ── LXC backend ──────────────────────────────────────────────────────────────
# lxc_image: ubuntu:22.04
# ── Ports ────────────────────────────────────────────────────────────────────
# Single port (most common):
internal_ports: [1337]
# Multi-port: each gets its own randomly allocated host port.
# The first port is the "primary" shown in the connection string.
# internal_ports: [80, 443]
# Compose multi-service (mutually exclusive with internal_ports):
# service_ports:
# app: [80]
# admin: [8080]
connection: nc # nc | http | ssh
# controls the connection string shown to players
# ── Lifecycle ────────────────────────────────────────────────────────────────
timeout_minutes: 45 # instance expires after this many minutes
max_renewals: 3 # how many times players can extend the timer
# ── Flag ─────────────────────────────────────────────────────────────────────
flag_mode: random # random | static
# random: a unique flag per instance, registered in
# CTFd's flags table at provision time
# static: uses the flags: list above; one flag shared by all teams
flag_prefix: "CTF{" # optional; wraps the random part
flag_suffix: "}"
random_flag_length: 16 # characters in the random part (default: 16)
# How the flag reaches the container:
flag_delivery: env # env (default) | file
# env: injected as a $FLAG environment variable
# file: written to a bind-mounted file at flag_file_path
# flag_file_path: /challenge/flag # required when flag_delivery: file
# flag_service: app # compose only: which service gets the file mount
# ── Optional ─────────────────────────────────────────────────────────────────
command: null # override the container's CMD/entrypointUse flag_mode: static (or omit it) and define flags in the top-level flags: list. All teams share one flag.
type: instance
instance:
backend: docker
image: .
internal_ports: [1337]
connection: nc
flag_mode: static
flags:
- flag{shared_static_flag}| Check | Severity |
|---|---|
instance block required |
Error |
instance.internal_ports must have at least one entry (unless service_ports is set) |
Error |
| Port values must be 1–65535 | Error |
service_ports and internal_ports are mutually exclusive |
Warning |
instance.connection must not be empty |
Error |
instance.timeout_minutes == 0 |
Warning |
Docker backend: image required |
Error |
| Docker backend: local path missing a Dockerfile | Warning |
Compose backend: compose_file not found on disk |
Warning |
LXC backend: lxc_image required |
Error |
flag_mode: random without flag_prefix/flag_suffix |
— (defaults used) |
flag_delivery: file without flag_file_path |
Error |
flag_file_path not an absolute path |
Warning |
flag_mode: static without any flags defined |
Error |
| Field | Check | Severity |
|---|---|---|
name |
Empty or whitespace | Error |
name |
Duplicate across challenge files | Error |
category |
Empty or whitespace | Error |
value |
== 0 for standard type |
Error |
extra |
Missing for dynamic type |
Error |
extra.initial |
Missing or == 0 for dynamic |
Error |
extra.decay |
Missing or == 0 for dynamic |
Error |
extra.minimum |
Not set for dynamic |
Warning |
extra.decay_function |
Not "linear" or "logarithmic" when set |
Error |
flags |
At least one required (non-instance, non-random-flag) | Error |
flags[].content |
Empty or whitespace | Error |
flags |
Duplicate content values | Warning |
hints[].content |
Empty or whitespace | Error |
files |
Referenced file not found on disk | Error |
requirements |
Challenge lists itself | Error |
requirements |
Named challenge not found locally | Warning |
next |
Points to itself | Error |
next |
Named challenge not found locally | Warning |
attempts |
Set to 0 (same as unlimited — likely a typo) |
Warning |
| Unknown YAML keys | Not in the spec | Warning |
The monitor runs as a Docker container on the CTFd host. nervctf setup writes the docker-compose.override.yml that connects it.
http://<monitor_ip>:<monitor_port>/admin?token=<MONITOR_TOKEN>
Or log in once at http://<monitor_ip>:<monitor_port>/; the session cookie is set.
The dashboard shows:
- all active container instances per team;
- flag submission attempts, including flag-sharing alerts;
- the correct-solve log;
- runtime config (public host, runner mode, base directories);
- the CTFd compatibility probe result.
nervctf setup sets these in docker-compose.override.yml. They rarely need manual editing.
| Variable | Required | Default | Description |
|---|---|---|---|
CTFD_DB_URL |
yes | — | MariaDB URL: mysql://user:pass@host/db |
MONITOR_TOKEN |
yes | — | Admin token (SHA-256 hashed on startup; plaintext never stored) |
PUBLIC_HOST |
yes | — | Hostname or IP returned to players in connection strings |
MONITOR_PORT |
no | 33133 |
TCP port to listen on |
MONITOR_BIND |
no | 0.0.0.0 |
Bind address |
DB_PATH |
no | ./monitor.db |
Path to the SQLite database |
CHALLENGES_BASE_DIR |
no | /opt/nervctf/challenges |
Where challenge files are extracted on the server |
CTFD_UPLOADS_DIR |
no | "" |
Absolute path to CTFd's uploads directory (for file attachment writes) |
RUNNER_SSH_TARGET |
no | "" |
Split-machine SSH target, for example docker@192.168.1.50 |
MAX_CONCURRENT_PROVISIONS |
no | 4 |
Parallel container provisions (semaphore) |
MAX_INSTANCES_PER_TEAM |
no | 0 |
Max active instances per team across all challenges (0 = unlimited) |
CTFD_DB_SYNC_INTERVAL |
no | 30 |
Seconds between CTFd solve/user sync cycles |
CTFD_DOMAIN |
no | http://<PUBLIC_HOST> |
CTFd URL shown in admin dashboard links |
The monitor runs two loops independently of HTTP traffic.
Sync loop (every CTFD_DB_SYNC_INTERVAL seconds):
- reads CTFd
submissionsand caches correct solves in SQLite; - reads CTFd
teamsandusersfor name display; - reverts an instance to running if a CTFd admin deleted a solve record;
- purges stale flag attempt records.
Expiry and health loop (every 30 seconds):
- tears down instances whose
expires_athas passed; - kills stuck provisioning stubs older than 30 minutes;
- detects and tears down orphaned Docker Compose projects not tracked in SQLite;
- health-checks tracked instances and cleans up externally killed containers.
When a player submits a flag, the CTFd plugin calls the monitor's /api/v1/plugin/attempt endpoint. The monitor checks whether the flag was issued to a different team. If it was, it records a flag-sharing alert, which appears in the admin dashboard under "Attempts (alerts only)".
Flag ownership is stored permanently in the team_flags SQLite table, so the monitor remembers which team owned a flag even after the instance expires.
The monitor appends instance lifecycle events to its SQLite events table: requested, running, stopped, solved, and expired. Read them through the admin endpoint:
curl -s -H "Authorization: Token $MONITOR_TOKEN" \
"http://<monitor_ip>:<monitor_port>/api/v1/admin/events?limit=50"Filters: type, challenge, team, since (SQLite datetime), limit (default 200, max 5000), and offset. Flag submissions are recorded separately in flag_attempts.
Set runner_ip and runner_user in .nervctf.yml to run containers on a separate node.
nervctf deployrsyncs each challenge's build context from your machine torunner_user@runner_ip:~/challenges/<name>/usingssh_key_path.- The monitor SSHes to the runner to build images and start containers.
- The monitor generates a dedicated SSH keypair at setup time (
monitor_ssh_key), bind-mounted into the monitor container.
Requirements on the runner node:
- Docker and Docker Compose installed.
- SSH access for the monitor (its public key in
~/.ssh/authorized_keys). - No CTFd; it stays on the CTFd host.
| Symptom | Cause | Fix |
|---|---|---|
| No challenges found | Files not named challenge.yml, or deeper than 5 directories |
Check the path; max depth is 5 |
state: Field may not be null |
CTFd requires the state field |
Run nervctf fix |
| File upload 500 | Wrong ownership on the uploads directory | chown -R 1001:1001 <ctfd_path>/.data/CTFd/uploads |
| Monitor 401 | Token mismatch between CLI and server | Make monitor_token in .nervctf.yml match MONITOR_TOKEN on the server |
ansible-playbook: not found |
Tool not in PATH |
Run inside nix develop .#, install Ansible, or set ANSIBLE_PLAYBOOK=/path/to/ansible-playbook |
Preflight failed ... passwordless-sudo=FAILED |
The deploy user lacks passwordless sudo (the playbooks use become: yes) |
On that host: echo '<user> ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/nervctf && sudo chmod 0440 /etc/sudoers.d/nervctf |
Preflight failed ... rsync=FAILED or git=FAILED |
Target host is missing a tool Ansible needs | apt install rsync git on the target |
nervctf_instance_challenge doesn't exist |
Plugin not loaded by CTFd | Re-run nervctf setup --upgrade (the plugin is embedded) and check the CTFd logs |
| Players get 403 on instance request | CTFd is in user-mode | Switch CTFd to team mode; nervctf probe confirms |
| Dynamic challenges fail with 500 in the CTFd admin | Partial CTFd schema migration | Run nervctf probe --refresh to diagnose |
| rsync permission denied | SSH key not passed to rsync | Set ssh_key_path in .nervctf.yml |
| Instance shows the wrong host to players | runner_domain or PUBLIC_HOST is wrong |
Set runner_domain in .nervctf.yml and re-run nervctf setup --upgrade |
| Flag submission says "Incorrect" but the flag matches | CTFd attempt() return type mismatch |
Upgrade NervCTF; fixed in v2.4.0 |
# All dev commands use the Nix devshell
nix develop .# --command cargo check
nix develop .# --command cargo test
nix develop .# --command cargo fmt
# Build static release binaries
nix develop .# --command cargo build --release --target x86_64-unknown-linux-musl
# Copy to dist/ after build (required by CLAUDE.md)
cp target/x86_64-unknown-linux-musl/release/remote-monitor dist/remote-monitor-linux-x86_64-static
cp target/x86_64-unknown-linux-musl/release/nervctf dist/nervctf-linux-x86_64-staticSee docs/ for per-module documentation:
| Document | Contents |
|---|---|
docs/instance-challenges.md |
Full instance challenge YAML reference, backend details, multi-port setup |
docs/remote-monitor.md |
All API routes, environment variables, schema detection, compatibility probe |
docs/challenge_manager.md |
Sync logic, needs_update fields, sub-resource strategies |
docs/validator.md |
All validation rules with severities |
docs/ctfd_api.md |
HTTP API surface, version compatibility table |
docs/ctfd-dependency-audit.md |
CTFd dependency map and risk register |
docs/dev-notes.md |
Build environment, cross-compilation, architectural notes |
See ARCHITECTURE.md for the full system reference.
MIT License. See LICENSE for details.