A standalone stress tester for single-node pwn.college dojo deployments. Measures concurrent challenge container capacity, infrastructure limits, and CTFd database performance under load.
| File | Purpose |
|---|---|
dojo_stress.py |
Main stress tester |
register_users.py |
Bulk-register test accounts via the web form |
README.md |
This file |
pip install aiohttpThat is the only dependency. Everything else is Python stdlib.
# Get your dojo IP
export DOJO_CONTAINER=dojo
export DOJO_IP=$(docker inspect dojo | jq -r '.[0].NetworkSettings.Networks.bridge.IPAddress')
# Run smoke test first (1 user, ~10 seconds)
python dojo_stress.py \
--url "http://localhost.pwn.college" \
--dojo YOUR_DOJO_SLUG \
--module YOUR_MODULE_SLUG \
--challenge YOUR_CHALLENGE_SLUG \
--admin-user admin \
--admin-pass admin \
--scenarios smokeBefore running any stress test, verify all of these pass.
docker exec dojo dojo logs
docker exec dojo dojo compose psAll services (db, cache, ctfd, nginx, sshd, homefs, workspacefs,
prometheus, grafana) should show as Up.
curl -s -o /dev/null -w "%{http_code}" http://localhost.pwn.college/
# Should return 200 or 302# PostgreSQL
docker exec dojo docker exec db psql -U ctfd ctfd -c \
"SELECT count(*) FROM pg_stat_activity WHERE state='active';"
# Redis
docker exec dojo docker exec cache redis-cli INFO memory | grep used_memory_human
# btrfs
docker exec dojo btrfs filesystem usage /data/homesOpen http://localhost.pwn.college in a browser, navigate to a challenge,
and read the URL:
http://localhost.pwn.college/DOJO_SLUG/MODULE_SLUG/CHALLENGE_SLUG
Or query the database directly:
docker exec dojo docker exec db psql -U ctfd ctfd -c \
"SELECT id, name FROM dojos;"The dojo must be marked official OR users must be joined to it. The easiest fix for a test environment:
# Mark official (all users can access without joining)
docker exec dojo docker exec db psql -U ctfd ctfd -c \
"UPDATE dojos SET official=true WHERE id='YOUR_DOJO_SLUG';"The web registration form has a CTFd rate limit of 10 requests per 60 seconds per IP. Use the curl loop below with a 2-second delay between registrations:
COMMITMENT="I have read the ground rules and commit to not publish pwn.college writeups on the internet."
for i in $(seq 1 20); do
NONCE=$(curl -s -c /tmp/reg_$i.txt http://localhost.pwn.college/register \
| grep -oP "'csrfNonce': \"\K[^\"]+")
curl -s -c /tmp/reg_$i.txt -b /tmp/reg_$i.txt \
-d "name=testuser${i}&email=testuser${i}@stress.test&password=testpass${i}&_submit=Submit&nonce=${NONCE}&commitment_verified=I+have+read+the+ground+rules+and+commit+to+not+publish+pwn.college+writeups+on+the+internet." \
-L http://localhost.pwn.college/register -o /dev/null
echo "Registered testuser${i}"
sleep 2
doneThen promote to admin and join the dojo:
docker exec dojo docker exec db psql -U ctfd ctfd -c \
"UPDATE users SET type='admin' WHERE name LIKE 'testuser%';"
docker exec dojo docker exec db psql -U ctfd ctfd -c "
INSERT INTO dojo_users (dojo_id, user_id, type)
SELECT (SELECT dojo_id FROM dojos WHERE id='YOUR_DOJO_SLUG'), id, 'student'
FROM users WHERE name LIKE 'testuser%'
ON CONFLICT DO NOTHING;"Verify:
docker exec dojo docker exec db psql -U ctfd ctfd -c \
"SELECT count(*) FROM users WHERE name LIKE 'testuser%';"python dojo_stress.py \
--url "http://localhost.pwn.college" \
--dojo example \
--module world \
--challenge earth \
--admin-user admin \
--admin-pass admin \
--user-prefix testuser \
--pass-prefix testpass \
--scenarios smoke baseline ramp_10 spike_10 repeat_10| Flag | Default | Description |
|---|---|---|
--url |
http://localhost.pwn.college |
Dojo base URL |
--dojo |
example |
Dojo slug |
--module |
world |
Module slug |
--challenge |
earth |
Challenge slug |
--admin-user |
admin |
Admin username (used for DB load phase) |
--admin-pass |
admin |
Admin password |
--user-prefix |
— | If set, workers log in as prefix1, prefix2, ... |
--pass-prefix |
— | Password prefix matched to user index |
--scenarios |
all | Space-separated list of scenarios to run |
--hold-secs |
3.0 |
How long each worker holds its container open |
--cooldown-secs |
15 |
Seconds between scenarios |
--db-workers |
auto | Concurrent workers for DB load phase |
--db-reqs |
20 |
Requests per DB load worker |
--dojo-container |
dojo |
Name of the outer Docker container |
--output |
./results |
Output directory for reports |
| Scenario | Users | Mode | What it tests |
|---|---|---|---|
smoke |
1 | single | Sanity check — does one container start? |
baseline |
5 | simultaneous | Light concurrent load, reference numbers |
ramp_10 |
10 | 0.5s apart | Gradual load, tests staggered arrival |
spike_10 |
10 | simultaneous | Hard spike, tests peak concurrent capacity |
repeat_10 |
10 | simultaneous | Recovery test — does system bounce back? |
Each scenario runs three phases:
All N workers log in simultaneously but staggered by 0.15 seconds to stay
under CTFd's login rate limiter (10 requests per 60 seconds per IP, discovered
during testing). Sessions and CSRF tokens are cached. Workers that fail login
are recorded as login_failed and excluded from Phase 2.
All authenticated workers fire container starts simultaneously. Each worker:
- POSTs to
/pwncollege_api/v1/dockerto start a challenge container - Holds the container open for
--hold-secsseconds - DELETEs
/pwncollege_api/v1/dockerto stop and remove it
Measures spin-up latency (POST → 200 OK) and teardown latency per user.
After all containers close, N concurrent sessions hammer CTFd's read-only API endpoints to measure database and cache performance under load:
/api/v1/scoreboard/api/v1/challenges/api/v1/users?page=1/api/v1/statistics/users/api/v1/statistics/submissions
Every 4 seconds throughout Phase 2, the test probes the inner infrastructure:
| What | Command used |
|---|---|
| PostgreSQL active connections | dojo db → pg_stat_activity |
| PostgreSQL db size | dojo db → pg_database_size |
| Redis memory + ops/sec | docker exec cache redis-cli INFO |
| btrfs free space | btrfs filesystem usage /data/homes |
| btrfs subvolume count | btrfs subvolume list /data/homes |
| Workspace container count | docker ps --filter label=pwn.college.user |
| Prometheus health | curl http://prometheus:9090/-/ready |
After each run, ./results/ contains:
results/
├── stress_TIMESTAMP.json # full per-user timings + infra timeline
└── stress_TIMESTAMP.txt # detailed report + summary table
The txt report includes per-user spin-up and teardown times, infra timeline sampled every 4 seconds, DB latency broken down by endpoint, and a summary table across all scenarios.
| Error tag | Meaning |
|---|---|
login_failed |
CTFd rejected the login — rate limited or wrong credentials |
forbidden |
403 from the docker API — user not joined to dojo |
rate_limited |
429 — CTFd rate limit on container start endpoint |
server_500:<msg> |
Inner Docker daemon error — OOM, OverlayFS, btrfs issue |
http_<N> |
Unexpected HTTP status from the API |
timeout_90s |
Container didn't start within 90 seconds |
conn_refused |
nginx or CTFd process died |
Hard cap of 10 logins per 60 seconds per IP. At 20+ concurrent users, ~half get rate-limited before any container starts. This is a CTFd configuration issue, not a container capacity issue. To disable for testing:
# Find the rate limit decorator
docker exec dojo docker exec ctfd grep -n "ratelimit" \
/opt/CTFd/CTFd/views/auth.py | head -5
# Comment it out, then restart CTFd
docker exec dojo dojo compose restart ctfdEach container start creates a btrfs subvolume under /data/homes/ for the
user's home directory. At 10 simultaneous starts, the btrfs driver serializes
some operations causing spin-up to climb from ~3s (idle) to ~10s (10 users).
This is the primary container-layer bottleneck. Staggering starts (ramp mode)
reduces this significantly.
pgbouncer handles connection pooling. At 10 concurrent users the pool shows 1-2 active connections — nowhere near saturation. Would become a bottleneck at 50+ concurrent users making simultaneous DB-heavy requests.
Memory stays flat at ~2.7MB across all scenarios. The rate limiter itself is Redis-backed (ops/sec spikes when login rate limiting fires) but the cache layer has enormous headroom.
Each workspace container runs code-server (~150MB RSS) plus the challenge binary. At 10 concurrent containers that's ~1.5GB just for workspaces. Becomes relevant at 20-30+ concurrent containers on an 8GB host.
| Scenario | Users | OK% | p50 spin | p50 teardown | DB p50 |
|---|---|---|---|---|---|
| smoke | 1 | 100% | 3.05s | 3.78s | 0.035s |
| baseline | 5 | 100% | 6.33s | 4.43s | 0.158s |
| ramp_10 | 10 | 100% | 5.40s | 3.96s | 0.285s |
| spike_10 | 10 | 100% | 10.52s | 4.12s | 0.296s |
| repeat_10 | 10 | 100% | 6.61s | 3.83s | 0.342s |
Key takeaway: This single-node deployment handles 10 concurrent challenge starts with 100% success rate. Spin-up degrades from 3s (idle) to 10.5s (10 simultaneous) due to btrfs contention, but the system is self-healing — after a 15s cooldown, performance recovers to 6.6s p50.
# Watch CTFd logs live
docker exec dojo docker logs ctfd -f 2>&1 | grep -v "GET /"
# Check all inner services
docker exec dojo dojo compose ps
# See running workspace containers
docker exec dojo docker ps --format "table {{.Names}}\t{{.Status}}"
# Query DB directly
docker exec dojo docker exec db psql -U ctfd ctfd -c \
"SELECT name, type FROM users LIMIT 10;"
# Check btrfs subvolume count
docker exec dojo btrfs subvolume list /data/homes | wc -l
# Check inner docker stats
docker exec dojo docker stats --no-stream