Dynamic challenges & labs¶
A dynamic challenge is one where each team (or solo player) gets their own isolated container instance. The player exploits the live service to retrieve a flag that is unique to their team.
How it works¶
Setting is_dynamic: true in a challenge YAML does three things:
- The Dispatch spawn flow is enabled for that challenge.
- When a player clicks Spawn, the platform derives their per-team flag and
starts a container with
FLAG=<derived-flag>in its environment. - The instance is recorded in the database with a TTL; the reaper destroys it when it expires (or when the player clicks Destroy).
The platform routes spawn requests through two paths depending on configuration:
- VPN lab path (
LAB_ENABLED=true+ a validlab_stackor container spec): containers receive stable IP addresses reachable over WireGuard; each team's network is isolated at the IP level. See the WireGuard setup guide for player instructions. - Host-port path (development / no VPN): the container is started on the
local Docker daemon with a random published host port. This path is blocked
in production (
APP_ENV=production) by a hard gate in the dispatch code.
Challenge YAML fields¶
Shorthand (single service)¶
is_dynamic: true
container_image: ecbeast:latest
container_port: 5337
container_scheme: tcp # default: http
container_transport: tcp # default: tcp
The platform promotes this to a single-service lab_stack automatically with
cpu_units: 512 and memory_mb: 256.
Explicit lab_stack (multi-service)¶
is_dynamic: true
lab_stack:
ttl_seconds: 3600
services:
- name: web
image: rand-web:latest
internal_port: 1337
protocol: http
transport: tcp
cpu_units: 512
memory_mb: 256
inject_flag: true
- name: db
image: postgres:16-alpine
internal_port: 5432
protocol: tcp
transport: tcp
cpu_units: 256
memory_mb: 256
inject_flag: false
See challenge-format.md for the full field reference.
TTL and instance reaping¶
Each instance has an expires_at timestamp set at spawn time:
- The default TTL is controlled by
INSTANCE_TTL_SECONDS(default:7200, i.e. 2 hours). - The
lab_stack.ttl_secondsfield in the YAML overrides the default for that specific challenge (range: 60–604800 seconds). - Instances that reach
expires_atare not destroyed automatically during normal web traffic — you must run the reaper.
Running the reaper¶
The reaper calls reconcile_expired_lab_instances, which destroys all
instances whose expires_at has passed and updates their status in the
database. Run this on a cron schedule in production.
Configuration requirements¶
Labs require explicit enablement
Dynamic challenges can be imported at any time, but spawning instances requires all of the following to be true:
| Requirement | Config key / condition |
|---|---|
| Lab orchestration enabled | LAB_ENABLED=true |
| Docker daemon reachable | LAB_DOCKER_HOST (or default socket) |
| Container image built and available | Image present in daemon or registry |
| VPN mode (production) | LAB_ISSUE_WG_CONFIG=true + WireGuard configured |
In development (APP_ENV=development or APP_ENV=testing), the platform
falls back to the local Docker daemon with random published ports instead of
VPN. This fallback is blocked in production.
Spawn / reset / destroy flow¶
Players interact with instances through /dispatch:
| Action | URL | What happens |
|---|---|---|
| Spawn | POST /dispatch/spawn/<challenge_id> |
Derives per-team flag; starts container; records Instance row. |
| Destroy | POST /dispatch/destroy/<challenge_id> |
Stops and removes container; deletes Instance row. |
| Reset | POST /dispatch/reset/<challenge_id> |
Destroy + immediate respawn. Subject to a cooldown enforced by the Dispatch service. |
The player also sees a list of active instances at /dispatch/instances.
Per-team isolation¶
Every spawned instance receives the per-team flag as the FLAG environment
variable:
team_secretis 32 bytes of random entropy generated once per principal and never changed.ADMIN_KEYis the server-side secret (ADMIN_KEYenv var).- The flag is unique per principal per challenge. A flag leaked from one team is cryptographically invalid for any other team.
On the VPN path, network access to each container is further restricted to
the WireGuard peer whose team_id matches the instance owner. A team cannot
reach another team's container even if they discover its IP address.
CLI instance management¶
Administrators can manage instances without going through the UI:
Spawn an instance¶
# By team ID
uv run python cli.py instance spawn ecbeast --team-id 3
# By user ID (solo player)
uv run python cli.py instance spawn ecbeast --user-id 7
# By principal ID
uv run python cli.py instance spawn ecbeast --principal-id 12
Destroy an instance¶
Reset (destroy + respawn)¶
Reap all expired instances¶
All instance commands accept --app-env <env> and --json for
machine-readable output.
Admin panel instance management¶
From the admin dashboard, individual instances can have their TTL extended via
Admin → Instances → Extend. This calls extend_instance_window which adds
additional time to expires_at without restarting the container.
Extending the dispatch remote (production)¶
In production, the web process delegates spawning to a separate Dispatch service over an authenticated internal HTTP API:
When DISPATCH_USE_REMOTE=false or the URL is empty, the platform uses the
local Docker daemon directly (development only; blocked in production by
Invariant 2 of the platform security model).