fix: bulk challenge enable path (compose YAML, base images, async toggle, tooling)

- fix_dup_volumes.py: 4 canonical templates had TWO volumes: keys inside one
  service (invalid YAML -> 'mapping key volumes already defined'), which broke
  every enable for anti-alchemy/burvesigner/gemas-notes/kode-viewer.
- fjb: ghcr.io base is not anonymously pullable on this host; swapped to the
  official httpd:2.4 (its httpd.conf only uses stock modules). Added
  onlyBuiltDependencies to package.json (pnpm >=10 blocks esbuild's postinstall).
- xl + kode-viewer: node:20-slim-bookworm is not a real tag; use
  node:20-bookworm-slim. gift-voucher: buster -> bookworm.
- prebuild_images.py: build each challenge's shared services-<name> image once
  in parallel (passes a placeholder PASSWORD build-arg, since several Dockerfiles
  run chpasswd and fail on an empty arg).
- set_enabled.py / sync_all_challenges.py: batch registry flip + runtime apply
  that survives panel restarts and reports per-team results.
- Challenge toggle is now async: PATCH returns a job id, the client polls
  /api/challenges/jobs/<id> so a multi-minute build no longer blocks the panel.
  Added _SYNC_LOCK to serialize concurrent compose rewrites.
This commit is contained in:
MythEclipse
2026-09-25 17:01:42 +08:00
parent d4c741926d
commit 6d1ede8c2b
13 changed files with 483 additions and 14 deletions
+38 -5
View File
@@ -8,6 +8,7 @@ import os
import json
import time
import asyncio
import threading
import httpx
from pathlib import Path
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect
@@ -34,6 +35,10 @@ async def _start_background():
name="sla-warmup").start()
orch.start_sla_refresher()
# Toggle jobs: Docker builds take minutes, so PATCH /api/challenges runs the
# work on a background thread and the client polls /api/challenges/jobs/<id>.
_TOGGLE_JOBS: dict[str, dict] = {}
CHALLENGES = [
{"name": "blogpost", "port": 10000, "ssh": 10022, "category": "web", "desc": "Flask blog with exiftool + SSTI"},
{"name": "carbeat", "port": 11000, "ssh": 11022, "category": "pwn", "desc": "Binary exploitation menu"},
@@ -389,11 +394,39 @@ async def api_challenge_toggle(challenge: str, req: Request):
# skip rebuild when the flag didn't actually change
if "unchanged" in changed:
return {"ok": True, "name": challenge, "enabled": enabled, "applied": [], "unchanged": True}
try:
report = await asyncio.to_thread(orch.sync_challenge_runtime, challenge, enabled)
except Exception as e:
raise HTTPException(500, f"Registry updated tapi runtime gagal: {e}")
return {"ok": True, "name": challenge, "enabled": enabled, "applied": report.get("teams", [])}
# Applying a challenge means a Docker build per team, which takes minutes.
# Run it in the background so the admin UI stays responsive, and let the
# client poll /api/challenges/jobs/<job_id> for the per-team report.
job_id = f"{challenge}-{'on' if enabled else 'off'}-{int(time.time())}"
_TOGGLE_JOBS[job_id] = {
"job": job_id, "name": challenge, "enabled": enabled,
"state": "running", "applied": [], "detail": "queued",
"started": int(time.time()),
}
def _worker():
try:
report = orch.sync_challenge_runtime(challenge, enabled)
_TOGGLE_JOBS[job_id].update(
state="done", applied=report.get("teams", []), detail="complete")
except Exception as e: # surfaced to the client via the job record
_TOGGLE_JOBS[job_id].update(state="error", detail=str(e))
finally:
_TOGGLE_JOBS[job_id]["finished"] = int(time.time())
threading.Thread(target=_worker, name=f"toggle-{job_id}", daemon=True).start()
return {"ok": True, "name": challenge, "enabled": enabled,
"job": job_id, "state": "running"}
@app.get("/api/challenges/jobs/{job_id}")
async def api_challenge_job(job_id: str, req: Request):
"""Poll the result of an async challenge toggle started by PATCH above."""
require_login(req)
job = _TOGGLE_JOBS.get(job_id)
if not job:
raise HTTPException(404, "Unknown job")
return job
@app.get("/api/status")
async def api_status(req: Request):