diff --git a/README.md b/README.md index fdd952f..4b414ae 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,256 @@ When a user clicks on a container challenge, a button labeled "Get Connection In ![Challenge dialog](dialog.png) A note, we used hidden teams as non-school teams in PCTF 2022 so if you want them to count for decreasing the dynamic challenge points, you need to remove the `Model.hidden == False,` line from the `calculate_value` function in `__init__.py`. + +## Deployment on Linux with Docker Compose + +This recipe matches a common self-hosted layout: a top-level directory holding `compose.yaml`, the CTFd source tree, and a Caddy reverse-proxy directory side-by-side: + +``` +~/ctfd-stack/ +├── compose.yaml +├── CTFd/ ← cloned from github.com/CTFd/CTFd +│ ├── Dockerfile ← upstream +│ ├── Dockerfile.ctfd ← you'll add this +│ └── CTFd/ +│ └── plugins/ +│ └── containers/ ← this plugin lives here +└── caddy/ + ├── Caddyfile + └── data/, config/ +``` + +Adapt to your own paths. Docker Engine 24+ with the `docker compose` v2 plugin is assumed. + +### 1. Clone CTFd and the plugin + +The plugin directory **must** be named exactly `containers` — that's where the URL prefix `/containers` is registered. + +```bash +cd ~/ctfd-stack + +git clone https://github.com/CTFd/CTFd.git +git clone https://github.com/therealcybermattlee/CTFd-Docker-Plugin.git \ + CTFd/CTFd/plugins/containers +``` + +After this you should have `~/ctfd-stack/CTFd/CTFd/plugins/containers/__init__.py`. + +### 2. Custom Dockerfile that installs the plugin's Python deps + +The stock `ctfd/ctfd` image doesn't include `azure-identity`, `azure-mgmt-containerinstance`, or `azure-containerregistry`. Create `~/ctfd-stack/CTFd/Dockerfile.ctfd`: + +```dockerfile +FROM ctfd/ctfd:latest +USER root +COPY CTFd/plugins/containers/requirements.txt /tmp/plugin-requirements.txt +RUN pip install --no-cache-dir -r /tmp/plugin-requirements.txt +USER 1001 +``` + +The build context will be the `CTFd/` directory, so the `COPY` path is relative to that. + +### 3. Wire it into `compose.yaml` + +The typical setup has two networks — `ctfd_frontend` (Caddy + CTFd, internet-egress) and `ctfd_backend` (DB + Redis, marked `internal: true` so the DB isn't reachable from the host network). **The CTFd service must attach to both** so it can reach the DB *and* reach `management.azure.com` for the ACI calls. + +**Replace** the pinned `image:` line in your existing `ctfd` service with a `build:` block (keep everything else — env vars, volumes, depends_on, networks). Before: + +```yaml + ctfd: + image: ctfd/ctfd:3.8.4 + # ... your existing env / volumes / depends_on / networks ... +``` + +After: + +```yaml + ctfd: + build: + context: ./CTFd + dockerfile: Dockerfile.ctfd + image: ctfd-with-containers:latest # tag for the locally-built image + restart: unless-stopped + depends_on: + - db + - cache + networks: + - ctfd_frontend + - ctfd_backend + environment: + # --- Existing CTFd env vars stay as-is --- + UPLOAD_FOLDER: /var/uploads + LOG_FOLDER: /var/log/CTFd + DATABASE_URL: mysql+pymysql://ctfd:${MARIADB_PASSWORD}@db/ctfd + REDIS_URL: redis://cache:6379 + WORKERS: "4" + ACCESS_LOG: "-" + ERROR_LOG: "-" + REVERSE_PROXY: "true" # trust X-Forwarded-* from Caddy + + # --- Azure SDK auth — pick ONE of the two options below --- + # + # Option A (recommended when CTFd runs on an Azure VM): leave these unset and assign + # the VM a system-assigned managed identity with Contributor on the challenge RG + + # AcrPull on the ACR. DefaultAzureCredential picks up the VM's IMDS endpoint + # (169.254.169.254) automatically — no secrets in env vars. + # + # Option B (CTFd not on Azure, or you prefer an explicit service principal): + # AZURE_TENANT_ID: ${AZURE_TENANT_ID} + # AZURE_CLIENT_ID: ${AZURE_CLIENT_ID} + # AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET} + volumes: + - ctfd_logs:/var/log/CTFd + - ctfd_uploads:/var/uploads + - ./CTFd/.ctfd_secret_key:/opt/CTFd/.ctfd_secret_key:ro + # --- For the Docker backend (challenges run on this host), also mount the host's Docker socket --- + # volumes: + # - /var/run/docker.sock:/var/run/docker.sock +``` + +**Important:** the `.ctfd_secret_key` file mount must exist as a regular file on the host before `docker compose up`. If it doesn't, Docker creates an empty *directory* there and CTFd fails to start. Pre-create it once and let CTFd populate it on first boot: + +```bash +touch ~/ctfd-stack/CTFd/.ctfd_secret_key +chmod 600 ~/ctfd-stack/CTFd/.ctfd_secret_key +``` + +If you go with **Option A (managed identity on the VM)** — which is the natural fit when CTFd runs on an Azure VM: + +```bash +# Enable system-assigned MI on the CTFd host VM (run on your workstation or in Cloud Shell) +az vm identity assign -g -n + +# Grab the principalId it printed and grant the roles it needs +PRINCIPAL_ID=$(az vm show -g -n --query identity.principalId -o tsv) +az role assignment create --assignee "$PRINCIPAL_ID" \ + --role "Contributor" \ + --scope "/subscriptions//resourceGroups/" +az role assignment create --assignee "$PRINCIPAL_ID" \ + --role "AcrPull" \ + --scope "/subscriptions//resourceGroups//providers/Microsoft.ContainerRegistry/registries/" +``` + +If you go with **Option B (service principal env vars)**, drop the values in `~/ctfd-stack/.env` next to `compose.yaml`: + +``` +AZURE_TENANT_ID=00000000-0000-0000-0000-000000000000 +AZURE_CLIENT_ID=00000000-0000-0000-0000-000000000000 +AZURE_CLIENT_SECRET=... +``` + +Lock it down: `chmod 600 .env` and add `.env` to `.gitignore`. + +### 4. Caddy in front of CTFd + +If you're using `ghcr.io/caddybuilds/caddy-cloudflare:2-alpine` (Caddy with Cloudflare DNS-01 ACME) or any other Caddy build, **no changes are required for this plugin**. A typical `caddy/Caddyfile` is just: + +``` +your-ctfd.example.com { + reverse_proxy ctfd:8000 +} +``` + +(plus your `tls { dns cloudflare {env.CF_API_TOKEN} }` block if you're using DNS-01.) + +The CTFd service listens on port 8000 inside its container; expose only Caddy on `:443`/`:80` to the public. With the async refactor in this plugin, `POST /containers/api/request` returns in under a second (HTTP 202) and `GET /containers/api/status/` is also fast — so the default Caddy and Cloudflare timeouts are fine. **No timeout bumps required**, including behind Cloudflare's 100s edge limit. + +### 5. Build and start + +```bash +cd ~/ctfd-stack +docker compose build ctfd +docker compose up -d +``` + +Confirm the plugin loaded: + +```bash +docker compose logs -f ctfd | head -50 +``` + +You shouldn't see `ImportError` or `ModuleNotFoundError`. Hit `https://your-ctfd.example.com/admin` and look for **Plugins → Containers** in the navbar dropdown. + +### 6. If you previously had the upstream plugin installed, drop its tables + +This fork's schema is incompatible with upstream (user mode instead of team mode, `hostname` column, async status columns, unique constraint on `(challenge_id, user_id)`). On a brand-new database this step is a no-op. On an upgrade, drop the tables once so SQLAlchemy can recreate them on next start: + +```bash +# MariaDB / MySQL — service name and creds must match your compose.yaml +docker compose exec db mariadb -uctfd -pctfd ctfd \ + -e "DROP TABLE IF EXISTS container_info; DROP TABLE IF EXISTS container_settings;" +``` + +Restart CTFd: `docker compose restart ctfd`. + +### 7. Configure via the admin UI + +1. Browse to `https://your-ctfd.example.com/admin` and log in. +2. **Plugins → Containers → Settings**. +3. Set **Backend** to **Azure Container Instances** and fill the Azure fields (Subscription ID, Resource Group, Region, UAMI Resource ID, ACR Login Server, DNS prefix). See [Plugin settings](#plugin-settings) below for what each field means. +4. Save. The dashboard badge flips to **Docker Connected** (green) — the label still says "Docker" but it's the same connection check. +5. **Admin → Challenges → New Challenge → container** to create your first challenge. The Image dropdown is populated from your ACR (CTFd's identity needs `AcrPull`). + +### 8. Verify end-to-end + +As a normal player (not admin), open the challenge and click **Get Connection Info**. You should see `Provisioning…` for ~30-60s, then a `hostname:port` line. From the host shell, confirm the container group exists: + +```bash +az container list -g -o table +``` + +Click **Stop** in the UI and watch the container group disappear within ~30s. + +### Updating the plugin later + +```bash +cd ~/ctfd-stack/CTFd/CTFd/plugins/containers +git pull +cd ~/ctfd-stack +docker compose build ctfd && docker compose up -d ctfd +``` + +If a future update introduces new schema columns, you'll see startup errors mentioning unknown columns — drop the affected tables (step 6) and CTFd recreates them. + +## Azure Container Instances backend + +This fork supports running challenge containers on **Azure Container Instances (ACI)** instead of (or alongside) a Docker daemon. Pick **Azure Container Instances** under Backend on the settings page to switch. + +### Azure prerequisites + +1. **Resource group** for challenge container groups, e.g. `ctfd-challenges`. +2. **Azure Container Registry** with your private images. +3. **User-assigned managed identity** (referred to as the *puller* UAMI) with the **AcrPull** role on the ACR. This UAMI is attached to every spawned container group so it can pull private images without storing registry credentials in CTFd. +4. **An identity for CTFd itself** that can call ARM. Two options: + - If CTFd runs on Azure (ACI, AKS, VM, App Service): assign it a managed identity with **Contributor** on the challenge resource group and **AcrPull** on the ACR. + - If CTFd runs elsewhere (e.g. Docker on a laptop): create a **service principal** and set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` in CTFd's environment. The plugin uses `DefaultAzureCredential` so any of these auth methods are picked up automatically. + +### Plugin settings + +Fill in on the Settings page (only the Azure section is required when the backend is `aci`): + +- **Subscription ID** — your Azure subscription +- **Resource Group** — where container groups are created (must already exist) +- **Region** — single region for all challenges, e.g. `eastus` +- **UAMI Resource ID** — full ARM ID of the puller identity, like `/subscriptions/.../userAssignedIdentities/ctfd-puller` +- **DNS Label Prefix** — used to name container groups and DNS labels, e.g. `ctfd` → `ctfd-abc12345.eastus.azurecontainer.io` +- **ACR Login Server** — e.g. `myregistry.azurecr.io` + +### Migrating from upstream / prior installs + +This fork changes the plugin's schema (user mode instead of team mode; per-container hostname). If you had the upstream plugin installed before, `db.create_all()` will **not** ALTER existing tables. Drop them before first start so SQLAlchemy can recreate cleanly: + +```sql +DROP TABLE IF EXISTS container_info; +DROP TABLE IF EXISTS container_settings; +DROP TABLE IF EXISTS container_challenge_model; +``` + +Connect to your CTFd database (MariaDB/MySQL or SQLite depending on your setup) and run those. You'll lose existing container-challenge records and admin Settings; reconfigure on the Settings page after restart. + +### Notes & caveats + +- Provisioning a container group takes ~30-60 seconds. The "Get Connection Info" button shows a `Provisioning…` state while the frontend polls `/containers/api/status/` every 3 seconds. The original POST returns immediately with HTTP 202, so this works behind reverse proxies that have short response timeouts (default Gunicorn 30s, default nginx 60s, Cloudflare free tier 100s). +- The volumes field on challenges is **ignored** in ACI mode — host-path mounts don't translate to ACI. +- Commands are parsed via `shlex.split`. For complex commands, use `sh -c "your full command line"`. +- The image dropdown is populated by listing repos/tags from the ACR. The CTFd identity needs **AcrPull** on the ACR for this to work. diff --git a/__init__.py b/__init__.py index 3c90152..c4edcb2 100644 --- a/__init__.py +++ b/__init__.py @@ -4,8 +4,10 @@ import json import datetime import math +import threading from flask import Blueprint, request, Flask, render_template, url_for, redirect, flash +from sqlalchemy.exc import IntegrityError from CTFd.models import db, Solves from CTFd.plugins import register_plugin_assets_directory @@ -14,8 +16,15 @@ from CTFd.utils.user import get_current_user from CTFd.utils.modes import get_model -from .models import ContainerChallengeModel, ContainerInfoModel, ContainerSettingsModel +from .models import ContainerChallengeModel, ContainerInfoModel, ContainerSettingsModel, resolve_size from .container_manager import ContainerManager, ContainerException +from .container_manager_aci import ACIContainerManager + + +def make_container_manager(settings, app): + if settings.get("backend", "docker") == "aci": + return ACIContainerManager(settings, app) + return ContainerManager(settings, app) class ContainerChallenge(BaseChallenge): @@ -26,7 +35,12 @@ class ContainerChallenge(BaseChallenge): "update": "/plugins/containers/assets/update.html", "view": "/plugins/containers/assets/view.html", } - scripts = { # Scripts that are loaded when a template is loaded + # Scripts loaded by CTFd's challenge-type editor. Plain paths only — + # appending a `?v=` cache-buster here breaks the load because CTFd + # URL-encodes the value, so the `?` ends up as `%3F` in the request path + # and Flask's static route returns 404. Hard-refresh after a plugin + # update if your browser is caching a stale copy. + scripts = { "create": "/plugins/containers/assets/create.js", "update": "/plugins/containers/assets/update.js", "view": "/plugins/containers/assets/view.js", @@ -51,6 +65,7 @@ def read(cls, challenge): "image": challenge.image, "port": challenge.port, "command": challenge.command, + "size": challenge.size, "initial": challenge.initial, "decay": challenge.decay, "minimum": challenge.minimum, @@ -71,6 +86,17 @@ def read(cls, challenge): @classmethod def calculate_value(cls, challenge): + # No decay curve configured — Static scoring. CTFd 3.8.x's unified + # Scoring Function selector submits `value` directly for Static + # challenges (the legacy `initial` column isn't editable from that UI), + # so trust whatever the form just set and don't clobber it with the + # stale `initial` from when the challenge was first created. We still + # need to commit so the setattr() changes from update() persist — + # CTFd's PATCH wrapper doesn't commit on its own. + if not challenge.decay: + db.session.commit() + return challenge + Model = get_model() solve_count = ( @@ -130,6 +156,33 @@ def solve(cls, user, team, challenge, request): ContainerChallenge.calculate_value(challenge) + # Tear down the player's container for this challenge — but do the slow + # backend kill (an ACI container-group delete can take many seconds) in + # a background thread so the player's flag-submit response isn't blocked + # on Azure. CTFd calls solve() inline within the attempt request, so a + # synchronous kill here makes the "Correct"/solved state wait on the + # teardown. We drop the tracking row now (fast, local) and fire the + # actual backend kill in a daemon thread — same pattern the provisioner + # uses. The container still dies on solve, just a beat after the player + # sees "Correct" instead of before. + manager = getattr(cls, "container_manager", None) + if manager is not None and user is not None: + info = ContainerInfoModel.query.filter_by( + challenge_id=challenge.id, user_id=user.id).first() + if info is not None: + container_id = info.container_id + db.session.delete(info) + db.session.commit() + if container_id: + def _teardown(cid): + try: + manager.kill_container(cid) + except Exception as e: + print(f"[CTFd] solve cleanup kill failed for {cid}: {e}") + threading.Thread( + target=_teardown, args=(container_id,), daemon=True + ).start() + def settings_to_dict(settings): return { @@ -137,15 +190,46 @@ def settings_to_dict(settings): } +def _ensure_size_column(): + """Add the per-challenge `size` column on installs that predate it. + + `db.create_all()` creates missing tables but never ALTERs existing ones, so + upgrading an instance that already has challenges needs this. The ADD COLUMN + ... DEFAULT 'small' also backfills existing rows on MySQL/MariaDB and SQLite, + so legacy challenges keep their current 1 vCPU / 1.5 GB behavior. Idempotent. + """ + from sqlalchemy import inspect as sa_inspect, text + + table = ContainerChallengeModel.__table__.name + try: + columns = [c["name"] for c in sa_inspect(db.engine).get_columns(table)] + except Exception as e: + print(f"[CTFd] could not inspect {table} for `size` column: {e}") + return + if "size" in columns: + return + try: + with db.engine.begin() as conn: + conn.execute( + text(f"ALTER TABLE {table} ADD COLUMN size VARCHAR(16) DEFAULT 'small'") + ) + print(f"[CTFd] added `size` column to {table}") + except Exception as e: + print(f"[CTFd] failed to add `size` column to {table}: {e}") + + def load(app: Flask): app.db.create_all() + _ensure_size_column() CHALLENGE_CLASSES["container"] = ContainerChallenge register_plugin_assets_directory( app, base_path="/plugins/containers/assets/" ) container_settings = settings_to_dict(ContainerSettingsModel.query.all()) - container_manager = ContainerManager(container_settings, app) + container_manager = make_container_manager(container_settings, app) + # Make the manager available to ContainerChallenge classmethods (e.g. solve()). + ContainerChallenge.container_manager = container_manager containers_bp = Blueprint( 'containers', __name__, template_folder='templates', static_folder='assets', url_prefix='/containers') @@ -165,12 +249,12 @@ def kill_container(container_id): except ContainerException: return {"error": "Docker is not initialized. Please check your settings."} - db.session.delete(container) - - db.session.commit() + if container is not None: + db.session.delete(container) + db.session.commit() return {"success": "Container killed"} - def renew_container(chal_id, team_id): + def renew_container(chal_id, user_id): # Get the requested challenge challenge = ContainerChallenge.challenge_model.query.filter_by( id=chal_id).first() @@ -180,7 +264,7 @@ def renew_container(chal_id, team_id): return {"error": "Challenge not found"}, 400 running_containers = ContainerInfoModel.query.filter_by( - challenge_id=challenge.id, team_id=team_id) + challenge_id=challenge.id, user_id=user_id) running_container = running_containers.first() if running_container is None: @@ -195,79 +279,103 @@ def renew_container(chal_id, team_id): return {"success": "Container renewed", "expires": running_container.expires} - def create_container(chal_id, team_id): - # Get the requested challenge - challenge = ContainerChallenge.challenge_model.query.filter_by( - id=chal_id).first() - - # Make sure the challenge exists and is a container challenge - if challenge is None: - return {"error": "Challenge not found"}, 400 - - # Check for any existing containers for the team - running_containers = ContainerInfoModel.query.filter_by( - challenge_id=challenge.id, team_id=team_id) - running_container = running_containers.first() + def _running_response(row: ContainerInfoModel): + return { + "status": "running", + "id": row.id, + "hostname": row.hostname or container_manager.settings.get("docker_hostname", ""), + "port": row.port, + "expires": row.expires, + } - # If a container is already running for the team, return it - if running_container: - # Check if Docker says the container is still running before returning it + def _provision_async(manager, row_id, image, internal_port, command, volumes, expiration_seconds, owner=None, cpu=None, memory=None): + with app.app_context(): + if ContainerInfoModel.query.get(row_id) is None: + return try: - if container_manager.is_container_running( - running_container.container_id): - return json.dumps({ - "status": "already_running", - "hostname": container_manager.settings.get("docker_hostname", ""), - "port": running_container.port, - "expires": running_container.expires - }) - else: - # Container is not running, it must have died or been killed, - # remove it from the database and create a new one - running_containers.delete() + created = manager.create_container(image, internal_port, command, volumes, owner=owner, cpu=cpu, memory=memory) + except ContainerException as e: + row = ContainerInfoModel.query.get(row_id) + if row is not None: + row.status = "failed" + row.error_message = str(e)[:1000] db.session.commit() - except ContainerException as err: - return {"error": str(err)}, 500 - - # TODO: Should insert before creating container, then update. That would avoid a TOCTOU issue - - # Run a new Docker container - try: - created_container = container_manager.create_container( - challenge.image, challenge.port, challenge.command, challenge.volumes) - except ContainerException as err: - return {"error": str(err)} - - # Fetch the random port Docker assigned - port = container_manager.get_container_port(created_container.id) - - # Port may be blank if the container failed to start - if port is None: - return json.dumps({ - "status": "error", - "error": "Could not get port" - }) - - expires = int(time.time() + container_manager.expiration_seconds) + print(f"[CTFd] provision failed for row {row_id}: {e}") + return + except Exception as e: + row = ContainerInfoModel.query.get(row_id) + if row is not None: + row.status = "failed" + row.error_message = str(e)[:1000] + db.session.commit() + print(f"[CTFd] provision exception for row {row_id}: {e}") + return + + # Re-fetch in case the user stopped/reset the request while we were + # blocked on the backend. If the row is gone, the user no longer + # wants this container — kill it so we don't leak (and pay for) it. + row = ContainerInfoModel.query.get(row_id) + if row is None: + try: + manager.kill_container(created.id) + except Exception as e: + print(f"[CTFd] orphan cleanup failed for {created.id}: {e}") + return + + row.container_id = created.id + row.hostname = getattr(created, "hostname", None) + host_port = None + try: + host_port = manager.get_container_port(created.id) + except Exception as e: + print(f"[CTFd] get_container_port failed for {created.id}: {e}") + if host_port is None: + row.port = internal_port + else: + try: + row.port = int(host_port) + except (TypeError, ValueError): + row.port = internal_port + if expiration_seconds > 0: + row.expires = int(time.time() + expiration_seconds) + row.status = "running" + row.error_message = None + db.session.commit() - # Insert the new container into the database - new_container = ContainerInfoModel( - container_id=created_container.id, + def _spawn_new(challenge, user_id, user_name=None): + now = int(time.time()) + initial_expires = now + (container_manager.expiration_seconds or 3600) + row = ContainerInfoModel( challenge_id=challenge.id, - team_id=team_id, - port=port, - timestamp=int(time.time()), - expires=expires + user_id=user_id, + status="provisioning", + timestamp=now, + expires=initial_expires, ) - db.session.add(new_container) - db.session.commit() - - return json.dumps({ - "status": "created", - "hostname": container_manager.settings.get("docker_hostname", ""), - "port": port, - "expires": expires - }) + db.session.add(row) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + existing = ContainerInfoModel.query.filter_by( + challenge_id=challenge.id, user_id=user_id).first() + if existing: + if existing.status == "running": + return _running_response(existing), 200 + return {"status": existing.status, "id": existing.id}, 202 + return {"error": "Concurrent request collision"}, 500 + + cpu, memory_mb = resolve_size(getattr(challenge, "size", None)) + threading.Thread( + target=_provision_async, + args=(container_manager, row.id, challenge.image, challenge.port, + challenge.command, challenge.volumes, + container_manager.expiration_seconds), + kwargs={"owner": user_name, "cpu": cpu, "memory": memory_mb}, + daemon=True, + ).start() + + return {"status": "provisioning", "id": row.id}, 202 @containers_bp.route('/api/request', methods=['POST']) @authed_only @@ -277,22 +385,86 @@ def create_container(chal_id, team_id): def route_request_container(): user = get_current_user() - # Validate the request if request.json is None: return {"error": "Invalid request"}, 400 - - if request.json.get("chal_id", None) is None: + chal_id = request.json.get("chal_id") + if chal_id is None: return {"error": "No chal_id specified"}, 400 - if user is None: return {"error": "User not found"}, 400 - if user.team is None: - return {"error": "User not a member of a team"}, 400 - try: - return create_container(request.json.get("chal_id"), user.team.id) - except ContainerException as err: - return {"error": str(err)}, 500 + challenge = ContainerChallenge.challenge_model.query.filter_by(id=chal_id).first() + if challenge is None: + return {"error": "Challenge not found"}, 400 + + existing = ContainerInfoModel.query.filter_by( + challenge_id=chal_id, user_id=user.id).first() + + if existing: + if existing.status == "running": + try: + if container_manager.is_container_running(existing.container_id): + return _running_response(existing), 200 + db.session.delete(existing) + db.session.commit() + except ContainerException as err: + return {"error": str(err)}, 500 + elif existing.status == "provisioning": + return {"status": "provisioning", "id": existing.id}, 202 + elif existing.status == "failed": + # Allow retry by clearing the failed row. + db.session.delete(existing) + db.session.commit() + + return _spawn_new(challenge, user.id, user_name=user.name) + + @containers_bp.route('/api/status/', methods=['GET']) + @authed_only + @during_ctf_time_only + @require_verified_emails + @ratelimit(method="GET", limit=120, interval=60) + def route_container_status(row_id): + user = get_current_user() + if user is None: + return {"error": "User not found"}, 400 + row = ContainerInfoModel.query.get(row_id) + if row is None: + return {"error": "Not found"}, 404 + if row.user_id != user.id: + return {"error": "Forbidden"}, 403 + if row.status == "running": + return _running_response(row), 200 + if row.status == "failed": + return {"status": "failed", "error": row.error_message or "Provisioning failed"}, 200 + return {"status": "provisioning", "id": row.id}, 202 + + @containers_bp.route('/api/running/', methods=['GET']) + @authed_only + @ratelimit(method="GET", limit=120, interval=60) + def route_running_container(chal_id): + user = get_current_user() + if user is None: + return {"error": "User not found"}, 400 + row = ContainerInfoModel.query.filter_by( + challenge_id=chal_id, user_id=user.id).first() + if row is None: + return {"status": "none"}, 200 + if row.status == "running": + try: + if container_manager.is_container_running(row.container_id): + return _running_response(row), 200 + except ContainerException: + pass + # Stale row — backend reports container is gone. Clean it up + # so the next request can spawn a fresh one. + db.session.delete(row) + db.session.commit() + return {"status": "none"}, 200 + if row.status == "provisioning": + return {"status": "provisioning", "id": row.id}, 202 + if row.status == "failed": + return {"status": "failed", "error": row.error_message or "Provisioning failed"}, 200 + return {"status": "none"}, 200 @containers_bp.route('/api/renew', methods=['POST']) @authed_only @@ -311,11 +483,9 @@ def route_renew_container(): if user is None: return {"error": "User not found"}, 400 - if user.team is None: - return {"error": "User not a member of a team"}, 400 try: - return renew_container(request.json.get("chal_id"), user.team.id) + return renew_container(request.json.get("chal_id"), user.id) except ContainerException as err: return {"error": str(err)}, 500 @@ -327,25 +497,31 @@ def route_renew_container(): def route_restart_container(): user = get_current_user() - # Validate the request if request.json is None: return {"error": "Invalid request"}, 400 - - if request.json.get("chal_id", None) is None: + chal_id = request.json.get("chal_id") + if chal_id is None: return {"error": "No chal_id specified"}, 400 - if user is None: return {"error": "User not found"}, 400 - if user.team is None: - return {"error": "User not a member of a team"}, 400 - running_container: ContainerInfoModel = ContainerInfoModel.query.filter_by( - challenge_id=request.json.get("chal_id"), team_id=user.team.id).first() + challenge = ContainerChallenge.challenge_model.query.filter_by(id=chal_id).first() + if challenge is None: + return {"error": "Challenge not found"}, 400 - if running_container: - kill_container(running_container.container_id) + existing = ContainerInfoModel.query.filter_by( + challenge_id=chal_id, user_id=user.id).first() - return create_container(request.json.get("chal_id"), user.team.id) + if existing: + if existing.container_id: + try: + container_manager.kill_container(existing.container_id) + except ContainerException as err: + print(f"[CTFd] reset: kill_container({existing.container_id}) failed: {err}") + db.session.delete(existing) + db.session.commit() + + return _spawn_new(challenge, user.id, user_name=user.name) @containers_bp.route('/api/stop', methods=['POST']) @authed_only @@ -364,16 +540,20 @@ def route_stop_container(): if user is None: return {"error": "User not found"}, 400 - if user.team is None: - return {"error": "User not a member of a team"}, 400 - - running_container: ContainerInfoModel = ContainerInfoModel.query.filter_by( - challenge_id=request.json.get("chal_id"), team_id=user.team.id).first() - if running_container: - return kill_container(running_container.container_id) + row: ContainerInfoModel = ContainerInfoModel.query.filter_by( + challenge_id=request.json.get("chal_id"), user_id=user.id).first() - return {"error": "No container found"}, 400 + if row is None: + return {"error": "No container found"}, 400 + if row.container_id: + try: + container_manager.kill_container(row.container_id) + except ContainerException as err: + return {"error": str(err)}, 500 + db.session.delete(row) + db.session.commit() + return {"success": "Container killed"} @containers_bp.route('/api/kill', methods=['POST']) @admins_only @@ -381,20 +561,43 @@ def route_kill_container(): if request.json is None: return {"error": "Invalid request"}, 400 - if request.json.get("container_id", None) is None: - return {"error": "No container_id specified"}, 400 + # Accept either the new `id` (row id) or legacy `container_id` (backend name). + row_id = request.json.get("id") + container_id = request.json.get("container_id") + row = None + if row_id is not None: + try: + row = ContainerInfoModel.query.get(int(row_id)) + except (ValueError, TypeError): + row = None + elif container_id is not None: + row = ContainerInfoModel.query.filter_by(container_id=container_id).first() + else: + return {"error": "No id or container_id specified"}, 400 - return kill_container(request.json.get("container_id")) + if row is None: + return {"error": "Not found"}, 404 + if row.container_id: + try: + container_manager.kill_container(row.container_id) + except ContainerException as err: + print(f"[CTFd] admin kill failed: {err}") + db.session.delete(row) + db.session.commit() + return {"success": "Container killed"} @containers_bp.route('/api/purge', methods=['POST']) @admins_only def route_purge_containers(): containers: "list[ContainerInfoModel]" = ContainerInfoModel.query.all() for container in containers: - try: - kill_container(container.container_id) - except ContainerException: - pass + if container.container_id: + try: + container_manager.kill_container(container.container_id) + except ContainerException as err: + print(f"[CTFd] purge: kill_container({container.container_id}) failed: {err}") + db.session.delete(container) + db.session.commit() return {"success": "Purged all containers"}, 200 @containers_bp.route('/api/images', methods=['GET']) @@ -410,99 +613,50 @@ def route_get_images(): @containers_bp.route('/api/settings/update', methods=['POST']) @admins_only def route_update_settings(): - if request.form.get("docker_base_url") is None: - return {"error": "Invalid request"}, 400 - - if request.form.get("docker_hostname") is None: - return {"error": "Invalid request"}, 400 - - if request.form.get("container_expiration") is None: - return {"error": "Invalid request"}, 400 - - if request.form.get("container_maxmemory") is None: - return {"error": "Invalid request"}, 400 - - if request.form.get("container_maxcpu") is None: - return {"error": "Invalid request"}, 400 - - docker_base_url = ContainerSettingsModel.query.filter_by( - key="docker_base_url").first() - - docker_hostname = ContainerSettingsModel.query.filter_by( - key="docker_hostname").first() - - container_expiration = ContainerSettingsModel.query.filter_by( - key="container_expiration").first() - - container_maxmemory = ContainerSettingsModel.query.filter_by( - key="container_maxmemory").first() - - container_maxcpu = ContainerSettingsModel.query.filter_by( - key="container_maxcpu").first() + nonlocal container_manager + + settable_keys = ( + "backend", + "docker_base_url", + "docker_hostname", + "container_expiration", + "container_maxmemory", + "container_maxcpu", + "azure_subscription_id", + "azure_resource_group", + "azure_region", + "azure_uami_resource_id", + "azure_dns_label_prefix", + "acr_login_server", + ) - # Create or update - if docker_base_url is None: - # Create - docker_base_url = ContainerSettingsModel( - key="docker_base_url", value=request.form.get("docker_base_url")) - db.session.add(docker_base_url) - else: - # Update - docker_base_url.value = request.form.get("docker_base_url") - - # Create or update - if docker_hostname is None: - # Create - docker_hostname = ContainerSettingsModel( - key="docker_hostname", value=request.form.get("docker_hostname")) - db.session.add(docker_hostname) - else: - # Update - docker_hostname.value = request.form.get("docker_hostname") - - # Create or update - if container_expiration is None: - # Create - container_expiration = ContainerSettingsModel( - key="container_expiration", value=request.form.get("container_expiration")) - db.session.add(container_expiration) - else: - # Update - container_expiration.value = request.form.get( - "container_expiration") - - # Create or update - if container_maxmemory is None: - # Create - container_maxmemory = ContainerSettingsModel( - key="container_maxmemory", value=request.form.get("container_maxmemory")) - db.session.add(container_maxmemory) - else: - # Update - container_maxmemory.value = request.form.get("container_maxmemory") - - # Create or update - if container_maxcpu is None: - # Create - container_maxcpu = ContainerSettingsModel( - key="container_maxcpu", value=request.form.get("container_maxcpu")) - db.session.add(container_maxcpu) - else: - # Update - container_maxcpu.value = request.form.get("container_maxcpu") + for key in settable_keys: + value = request.form.get(key) + if value is None: + continue + row = ContainerSettingsModel.query.filter_by(key=key).first() + if row is None: + db.session.add(ContainerSettingsModel(key=key, value=value)) + else: + row.value = value db.session.commit() - container_manager.settings = settings_to_dict( - ContainerSettingsModel.query.all()) + # Cleanly shut down the previous manager (stops its expiration scheduler) + # before replacing it, so we don't leak duplicate sweepers on every save. + try: + container_manager.shutdown() + except Exception as err: + print(f"[CTFd] previous manager shutdown failed: {err}") + + new_settings = settings_to_dict(ContainerSettingsModel.query.all()) + container_manager = make_container_manager(new_settings, app) - if container_manager.settings.get("docker_base_url") is not None: - try: - container_manager.initialize_connection( - container_manager.settings, app) - except ContainerException as err: - flash(str(err), "error") - return redirect(url_for(".route_containers_settings")) + try: + container_manager.initialize_connection(new_settings, app) + except ContainerException as err: + flash(str(err), "error") + return redirect(url_for(".route_containers_settings")) return redirect(url_for(".route_containers_dashboard")) @@ -525,7 +679,16 @@ def route_containers_dashboard(): except ContainerException: running_containers[i].is_running = False - return render_template('container_dashboard.html', containers=running_containers, connected=connected) + backend = container_manager.settings.get("backend", "docker") + backend_label = "Azure Container Instances" if backend == "aci" else "Docker" + + return render_template( + 'container_dashboard.html', + containers=running_containers, + connected=connected, + backend=backend, + backend_label=backend_label, + ) @containers_bp.route('/settings', methods=['GET']) @admins_only diff --git a/assets/create.html b/assets/create.html index 8893172..4e2b2e2 100644 --- a/assets/create.html +++ b/assets/create.html @@ -7,33 +7,10 @@ {% endblock %} {% block value %} -
- - - -
- -
- - -
- -
- - -
+{# Initial / Decay / Minimum are intentionally NOT rendered here. CTFd 3.8.x's + base admin/challenges/create.html provides its own unified Scoring Function + selector that handles those inputs when needed. Rendering ours too produced + dead duplicates that never submitted — see the matching note in update.html. #}
+
+ + +
+
-
- - -
- -
- - -
- -
- - -
+{# Initial / Decay / Minimum are intentionally NOT rendered here. CTFd 3.8.x's + base admin/challenges/update.html provides its own unified Scoring Function + selector and reveals matching Initial / Decay / Minimum inputs when the user + picks Linear or Logarithmic. Rendering ours too produced dead duplicates + that CTFd hid via display:none and stripped of their `name` attribute, so + they never submitted — pure noise in the DOM. #}
-{% endblock scripts %} \ No newline at end of file +{% endblock scripts %}