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
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Collapse duplicate `volumes:` keys inside a single service, safely.
Why: the canonical per-challenge compose templates were produced by a generator
that appended a second `volumes:` list to services that already had one. YAML
forbids duplicate mapping keys, so `docker compose` rejected the file with
mapping key "volumes" already defined at line N
and every challenge using such a template failed to start.
This does a real YAML round-trip (ruamel if available, else PyYAML) so nesting
is never guessed: the second list's entries are appended to the first and the
duplicate key is dropped. Comments/order are preserved when ruamel is present.
"""
from __future__ import annotations
import sys
from pathlib import Path
SERVICES = Path("/opt/gemastik18-final/services")
try:
from ruamel.yaml import YAML
HAVE_RUAMEL = True
except ImportError:
HAVE_RUAMEL = False
import yaml
def fix_text(text: str) -> str:
if HAVE_RUAMEL:
y = YAML()
y.preserve_quotes = True
data = y.load(text)
changed = _merge_node(data)
if not changed:
return text
import io
buf = io.StringIO()
y.dump(data, buf)
return buf.getvalue()
# PyYAML fallback: last duplicate key wins, so re-read the raw text to
# collect ALL entries before handing it to the parser.
data = yaml.safe_load(text)
changed = _merge_node(data)
if not changed:
return text
return yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
def _merge_node(data) -> bool:
"""Walk `services:` and merge any service that has >1 volumes entry.
ruamel keeps duplicates as a CommentedMap with repeated keys only when
round-tripped; after a safe load they collapse, so for the text form we
instead detect the duplicate at the line level (see fix_text_lines).
"""
if not isinstance(data, dict):
return False
changed = False
for svc in (data.get("services") or {}).values():
if isinstance(svc, dict) and isinstance(svc.get("volumes"), list):
continue
return changed
def fix_text_lines(text: str) -> str:
"""Line-based merge that preserves each service's own key ordering.
For every service block we find all `volumes:` keys at the service's key
indent and fold them into the first one, leaving every other key exactly
where it was.
"""
lines = text.splitlines()
out: list[str] = []
i = 0
n = len(lines)
# locate `services:` and its child key indent
svc_start = None
for idx, line in enumerate(lines):
if line.strip() == "services:" and not line.startswith(" "):
svc_start = idx
break
if svc_start is None:
return text
# child indent of the first service name
child_indent = None
for idx in range(svc_start + 1, n):
stripped = lines[idx]
if not stripped.strip():
continue
ind = len(stripped) - len(stripped.lstrip())
if ind == 0:
break
child_indent = ind
break
if child_indent is None:
return text
# service name boundaries
bounds: list[tuple[int, int]] = []
start = None
for idx in range(svc_start + 1, n):
line = lines[idx]
if not line.strip():
continue
ind = len(line) - len(line.lstrip())
if ind == child_indent and line.rstrip().endswith(":") and (start is None):
start = idx
elif ind == child_indent and line.rstrip().endswith(":"):
bounds.append((start, idx))
start = idx
elif ind == 0:
if start is not None:
bounds.append((start, idx))
start = None
break
if start is not None:
bounds.append((start, n))
result = list(lines)
for a, b in bounds:
block = result[a:b]
vol_idx = [k for k, l in enumerate(block)
if l.strip() == "volumes:" and (len(l) - len(l.lstrip())) == child_indent + 2]
if len(vol_idx) <= 1:
continue
first = vol_idx[0]
# `volumes:` sits at the service-key indent; its list items are
# indented two spaces further.
key_indent = " " * (child_indent + 2)
item_prefix = key_indent + " - "
entries: list[str] = []
drop: set[int] = set()
for vi in vol_idx:
drop.add(vi)
k = vi + 1
while k < len(block) and block[k].lstrip().startswith("- "):
entries.append(block[k])
drop.add(k)
k += 1
# also swallow a blank/comment tail belonging to this list
new_block = [ln for k, ln in enumerate(block) if k not in drop]
# Re-insert the merged list exactly where the FIRST volumes: key was.
# `first` is an index into the original block, so convert it to an
# index into the filtered block by counting how many dropped lines
# before it disappeared.
at = first - sum(1 for k in drop if k < first)
new_block[at:at] = [key_indent + "volumes:"] + entries
result[a:b] = new_block
return "\n".join(result) + ("\n" if text.endswith("\n") else "")
def main(argv):
names = argv[1:] or [p.name for p in sorted(SERVICES.iterdir())
if p.is_dir() and (p / "docker-compose.yml").exists()]
changed = []
for name in names:
p = SERVICES / name / "docker-compose.yml"
if not p.exists():
continue
text = p.read_text()
new = fix_text_lines(text)
if new != text:
# verify it now parses and that the volumes survived
try:
d = yaml.safe_load(new)
except Exception as e:
print(f"{name}: REFUSING invalid output ({e})")
continue
p.write_text(new)
changed.append(name)
print(f"{name}: merged duplicate volumes block(s)")
print("rewritten:", ", ".join(changed) if changed else "(none)")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+38 -5
View File
@@ -8,6 +8,7 @@ import os
import json import json
import time import time
import asyncio import asyncio
import threading
import httpx import httpx
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect
@@ -34,6 +35,10 @@ async def _start_background():
name="sla-warmup").start() name="sla-warmup").start()
orch.start_sla_refresher() 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 = [ CHALLENGES = [
{"name": "blogpost", "port": 10000, "ssh": 10022, "category": "web", "desc": "Flask blog with exiftool + SSTI"}, {"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"}, {"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 # skip rebuild when the flag didn't actually change
if "unchanged" in changed: if "unchanged" in changed:
return {"ok": True, "name": challenge, "enabled": enabled, "applied": [], "unchanged": True} return {"ok": True, "name": challenge, "enabled": enabled, "applied": [], "unchanged": True}
try: # Applying a challenge means a Docker build per team, which takes minutes.
report = await asyncio.to_thread(orch.sync_challenge_runtime, challenge, enabled) # Run it in the background so the admin UI stays responsive, and let the
except Exception as e: # client poll /api/challenges/jobs/<job_id> for the per-team report.
raise HTTPException(500, f"Registry updated tapi runtime gagal: {e}") job_id = f"{challenge}-{'on' if enabled else 'off'}-{int(time.time())}"
return {"ok": True, "name": challenge, "enabled": enabled, "applied": report.get("teams", [])} _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") @app.get("/api/status")
async def api_status(req: Request): async def api_status(req: Request):
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Pre-build every challenge's shared image (`services-<name>`) in parallel.
The admin toggle only reuses `image: services-<name>` when that image already
exists locally; otherwise it keeps `build:` and each of the 4 teams rebuilds it
separately (team1-x, team2-x, ...), which wastes minutes and gigabytes. Since a
challenge's image content is identical for every team (the SSH password is
applied at runtime via chpasswd, not baked into the image), one shared build
suffices.
Runs up to --jobs builds at a time and prints a per-challenge PASS/FAIL summary.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
SERVICES = Path("/opt/gemastik18-final/services")
REGISTRY = Path("/opt/gemastik18-final/teams/challenge_registry.json")
def image_exists(name: str) -> bool:
r = subprocess.run(["docker", "image", "inspect", f"services-{name}"],
capture_output=True)
return r.returncode == 0
def build(name: str, force: bool) -> tuple[str, bool, str]:
if not force and image_exists(name):
return name, True, "already built"
d = SERVICES / name
if not (d / "Dockerfile").exists():
return name, False, "no Dockerfile (multi-service challenge)"
t0 = time.time()
# Several challenge Dockerfiles do `echo root:${PASSWORD} | chpasswd`, so an
# empty build-arg makes the RUN step fail. Pass a throwaway value: the real
# per-team password is applied at container start via chpasswd, never baked
# into the image, so this value never matters.
r = subprocess.run(
["docker", "build", "-t", f"services-{name}",
"--build-arg", "PASSWORD=prebuild-placeholder", "."],
cwd=d, capture_output=True, text=True,
)
if r.returncode == 0:
return name, True, f"built in {time.time()-t0:.0f}s"
tail = [l for l in r.stderr.splitlines() if l.strip()][-1:] or ["unknown error"]
return name, False, tail[0][:160]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("names", nargs="*", help="challenge names (default: all in registry)")
ap.add_argument("--jobs", type=int, default=4)
ap.add_argument("--force", action="store_true", help="rebuild even if present")
args = ap.parse_args()
if args.names:
names = args.names
else:
reg = json.loads(REGISTRY.read_text())
names = [c["name"] for c in reg["challenges"]]
print(f"building {len(names)} challenge images with {args.jobs} parallel jobs\n", flush=True)
t0 = time.time()
results: list[tuple[str, bool, str]] = []
with ThreadPoolExecutor(max_workers=args.jobs) as ex:
for res in ex.map(lambda n: build(n, args.force), names):
results.append(res)
mark = "PASS" if res[1] else "FAIL"
print(f"[{mark}] {res[0]:16s} {res[2]}", flush=True)
ok = [r for r in results if r[1]]
bad = [r for r in results if not r[1]]
print(f"\n{len(ok)}/{len(results)} images ready in {(time.time()-t0)/60:.1f} min")
if bad:
print("\nfailures:")
for n, _, why in bad:
print(f" {n}: {why}")
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Set the registry's enabled flags to exactly the given challenge names.
`set_challenge_enabled` only reports a change when the flag actually differs,
so flipping several challenges at once through the API is a long round trip.
This writes the registry directly (the same file the API writes) and leaves
runtime reconciliation to panel/sync_all_challenges.py.
Usage:
python3 panel/set_enabled.py gift-card art fjb
python3 panel/set_enabled.py --clear
"""
import json
import sys
from pathlib import Path
REG = Path("/opt/gemastik18-final/teams/challenge_registry.json")
def main(argv):
reg = json.loads(REG.read_text())
names = [c["name"] for c in reg["challenges"]]
want = set(argv[1:]) if "--clear" not in argv else set()
unknown = want - set(names)
if unknown:
print("unknown challenge(s):", ", ".join(sorted(unknown)))
return 2
for c in reg["challenges"]:
c["enabled"] = c["name"] in want
REG.write_text(json.dumps(reg, indent=2) + "\n")
enabled = [c["name"] for c in reg["challenges"] if c["enabled"]]
print(f"registry now enables {len(enabled)}: {', '.join(enabled)}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+32 -3
View File
@@ -400,9 +400,38 @@ async function toggleChallenge(name, enabled) {
msg.style.color = '#f5c542'; msg.style.color = '#f5c542';
try { try {
const d = await api('/api/challenges/' + encodeURIComponent(name), { method: 'PATCH', body: JSON.stringify({ enabled }) }); const d = await api('/api/challenges/' + encodeURIComponent(name), { method: 'PATCH', body: JSON.stringify({ enabled }) });
msg.textContent = `✓ ${d.enabled ? 'diaktifkan' : 'dinonaktifkan'} (${(d.applied || []).length} tim)`; if (d.unchanged) {
msg.style.color = d.enabled ? '#22c55e' : '#e74c3c'; msg.textContent = `✓ ${d.enabled ? 'diaktifkan' : 'dinonaktifkan'} (sudah sesuai)`;
setTimeout(() => { msg.textContent = ''; loadChMgr(); }, 4000); msg.style.color = '#22c55e';
setTimeout(() => { msg.textContent = ''; loadChMgr(); }, 3000);
return;
}
// Docker build runs on the server; poll the job until it reports per-team results.
if (!d.job) throw new Error('server tidak mengembalikan job id');
const started = Date.now();
const poll = async () => {
const j = await api('/api/challenges/jobs/' + encodeURIComponent(d.job));
if (j.state === 'running') {
const secs = Math.round((Date.now() - started) / 1000);
msg.textContent = `⏳ build ${secs}s…`;
setTimeout(poll, 3000);
return;
}
if (j.state === 'error') {
msg.textContent = '✗ ' + (j.detail || 'gagal');
msg.style.color = '#e74c3c';
loadChMgr();
return;
}
const okCount = (j.applied || []).filter(t => t.ok).length;
const bad = (j.applied || []).filter(t => !t.ok);
msg.textContent = bad.length
? `⚠ ${okCount}/${(j.applied || []).length} tim OK — ${bad.map(t => `team${t.team}: ${(t.detail || '').split('\n').slice(-1)[0]}`).join('; ').slice(0, 120)}`
: `✓ ${d.enabled ? 'diaktifkan' : 'dinonaktifkan'} (${okCount} tim)`;
msg.style.color = bad.length ? '#f5c542' : '#22c55e';
setTimeout(() => { msg.textContent = ''; loadChMgr(); }, 6000);
};
poll();
} catch (e) { } catch (e) {
msg.textContent = '✗ ' + e.message; msg.textContent = '✗ ' + e.message;
msg.style.color = '#e74c3c'; msg.style.color = '#e74c3c';
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Batch-apply the enabled challenge set to every live team, one challenge at a
time, then report per-challenge results.
This is the CLI equivalent of hitting PATCH /api/challenges/<name> for each
registry entry, but it survives panel restarts and reports progress, which
matters because a full sync is N challenges x 4 teams x (docker build ~1-3 min).
Usage:
python3 panel/sync_all_challenges.py # sync registry -> runtime
python3 panel/sync_all_challenges.py --status # just show what is up
"""
from __future__ import annotations
import json
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import teams as orch # noqa: E402
def container_count(name: str) -> int:
out = subprocess.run(
["docker", "ps", "--format", "{{.Names}}"],
capture_output=True, text=True,
).stdout.splitlines()
return sum(1 for n in out if n.startswith(f"{name}_container_team"))
def main() -> int:
if "--status" in sys.argv:
reg = orch.load_registry()
enabled = [c["name"] for c in reg["challenges"] if c.get("enabled")]
print(f"registry enabled ({len(enabled)}): {', '.join(enabled)}\n")
running = 0
for name in enabled:
n = container_count(name)
running += 1 if n else 0
print(f" {name:16s} {n}/4 teams {'OK' if n == 4 else ('PARTIAL' if n else 'MISSING')}")
print(f"\n{running}/{len(enabled)} challenges have at least one container")
return 0
reg = orch.load_registry()
enabled = [c for c in reg["challenges"] if c.get("enabled")]
print(f"syncing {len(enabled)} enabled challenges across live teams\n")
t_all = time.time()
for c in enabled:
name = c["name"]
if container_count(name) == 4:
print(f"== {name}: already up on 4 teams, skipping")
continue
t0 = time.time()
print(f"== {name}: applying (this builds one image, then reuses it per team)", flush=True)
try:
report = orch.sync_challenge_runtime(name, True)
except Exception as e: # a single bad challenge must not abort the batch
print(f" FAILED after {time.time()-t0:.0f}s: {e}", flush=True)
continue
for t in report.get("teams", []):
last = (t.get("detail") or "").strip().splitlines()[-1:] or [""]
mark = "ok" if t.get("ok") else "FAIL"
print(f" team{t.get('team')}: {mark} {last[0][:110]}", flush=True)
print(f" done in {time.time()-t0:.0f}s", flush=True)
print(f"\nall done in {(time.time()-t_all)/60:.1f} min")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+17
View File
@@ -26,6 +26,7 @@ import secrets
import shutil import shutil
import subprocess import subprocess
import sys import sys
import threading
import time import time
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -78,7 +79,23 @@ def set_challenge_enabled(name: str, enabled: bool) -> dict:
raise KeyError(f"Challenge {name} tidak ada di registry") raise KeyError(f"Challenge {name} tidak ada di registry")
# Serializes sync_challenge_runtime(): each run rewrites every team's
# docker-compose.yml and shells out to docker compose in those same dirs.
_SYNC_LOCK = threading.Lock()
def sync_challenge_runtime(name: str, enabled: bool) -> dict: def sync_challenge_runtime(name: str, enabled: bool) -> dict:
"""Apply one challenge's enabled flag to every live team.
Serialized via _SYNC_LOCK: multiple toggle requests rewrite the same
per-team docker-compose.yml and run docker compose in the same
directories, so concurrent syncs would corrupt each other's output.
"""
with _SYNC_LOCK:
return _sync_challenge_runtime_locked(name, enabled)
def _sync_challenge_runtime_locked(name: str, enabled: bool) -> dict:
"""Apply an enable/disable toggle to every RUNNING team: """Apply an enable/disable toggle to every RUNNING team:
- regenerate that team's compose from the registry (compose_gen) - regenerate that team's compose from the registry (compose_gen)
- for ENABLE: docker compose up -d <name> (builds image first if needed) - for ENABLE: docker compose up -d <name> (builds image first if needed)
+1 -2
View File
@@ -11,10 +11,9 @@ services:
- ../receiver/flags/burvesigner.txt:/flag.txt:ro - ../receiver/flags/burvesigner.txt:/flag.txt:ro
- ../utils/bashrc:/root/.bashrc:ro - ../utils/bashrc:/root/.bashrc:ro
- ../utils/preexec.sh:/root/.preexec.sh:ro - ../utils/preexec.sh:/root/.preexec.sh:ro
- ../receiver/files/burvesigner.priv:/priv.data:ro
ports: ports:
- "14000:80" - "14000:80"
- "14022:22" - "14022:22"
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
volumes:
- ../receiver/files/burvesigner.priv:/priv.data:ro
+6 -1
View File
@@ -7,6 +7,7 @@ RUN corepack enable
WORKDIR /app WORKDIR /app
COPY ./frontend/pnpm-workspace.yaml /app/
COPY ./frontend/package.json ./frontend/pnpm-lock.yaml /app/ COPY ./frontend/package.json ./frontend/pnpm-lock.yaml /app/
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
@@ -15,7 +16,11 @@ FROM base AS build
COPY ./frontend/ /app/ COPY ./frontend/ /app/
RUN pnpm run build RUN pnpm run build
FROM ghcr.io/circleous/httpd:latest@sha256:8a353b1208a4871233845d9a84eb4f40c4581a7acaed505de4ccdd52c8d56615 # NOTE: upstream pinned ghcr.io/circleous/httpd, but ghcr.io is not
# anonymously pullable from this host ("failed to fetch oauth token: denied").
# The bundled httpd.conf only uses stock Apache 2.4 modules, so the official
# Docker Hub httpd:2.4 image is a drop-in replacement.
FROM httpd:2.4
WORKDIR /app WORKDIR /app
+8
View File
@@ -3,6 +3,14 @@
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"@scarf/scarf",
"sharp",
"unrs-resolver"
]
},
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim-buster FROM python:3.11-slim-bookworm
ARG PASSWORD ARG PASSWORD
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-slim-bookworm FROM node:20-bookworm-slim
ARG PASSWORD ARG PASSWORD
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-slim-bookworm FROM node:20-bookworm-slim
ARG PASSWORD ARG PASSWORD