diff --git a/panel/fix_dup_volumes.py b/panel/fix_dup_volumes.py new file mode 100644 index 0000000..ee0ad9e --- /dev/null +++ b/panel/fix_dup_volumes.py @@ -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)) diff --git a/panel/main.py b/panel/main.py index 4a70d41..133909a 100644 --- a/panel/main.py +++ b/panel/main.py @@ -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/. +_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/ 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): diff --git a/panel/prebuild_images.py b/panel/prebuild_images.py new file mode 100644 index 0000000..91dfc57 --- /dev/null +++ b/panel/prebuild_images.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Pre-build every challenge's shared image (`services-`) in parallel. + +The admin toggle only reuses `image: services-` 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()) diff --git a/panel/set_enabled.py b/panel/set_enabled.py new file mode 100644 index 0000000..8488d8d --- /dev/null +++ b/panel/set_enabled.py @@ -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)) diff --git a/panel/static/index.html b/panel/static/index.html index 5dfd039..fe2fd78 100644 --- a/panel/static/index.html +++ b/panel/static/index.html @@ -400,9 +400,38 @@ async function toggleChallenge(name, enabled) { msg.style.color = '#f5c542'; try { const d = await api('/api/challenges/' + encodeURIComponent(name), { method: 'PATCH', body: JSON.stringify({ enabled }) }); - msg.textContent = `✓ ${d.enabled ? 'diaktifkan' : 'dinonaktifkan'} (${(d.applied || []).length} tim)`; - msg.style.color = d.enabled ? '#22c55e' : '#e74c3c'; - setTimeout(() => { msg.textContent = ''; loadChMgr(); }, 4000); + if (d.unchanged) { + msg.textContent = `✓ ${d.enabled ? 'diaktifkan' : 'dinonaktifkan'} (sudah sesuai)`; + 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) { msg.textContent = '✗ ' + e.message; msg.style.color = '#e74c3c'; diff --git a/panel/sync_all_challenges.py b/panel/sync_all_challenges.py new file mode 100644 index 0000000..bc11c2a --- /dev/null +++ b/panel/sync_all_challenges.py @@ -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/ 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()) diff --git a/panel/teams.py b/panel/teams.py index 4a12402..c36d129 100644 --- a/panel/teams.py +++ b/panel/teams.py @@ -26,6 +26,7 @@ import secrets import shutil import subprocess import sys +import threading import time import uuid 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") +# 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: + """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: - regenerate that team's compose from the registry (compose_gen) - for ENABLE: docker compose up -d (builds image first if needed) diff --git a/services/burvesigner/docker-compose.yml b/services/burvesigner/docker-compose.yml index 435c2e7..b85715f 100644 --- a/services/burvesigner/docker-compose.yml +++ b/services/burvesigner/docker-compose.yml @@ -11,10 +11,9 @@ services: - ../receiver/flags/burvesigner.txt:/flag.txt:ro - ../utils/bashrc:/root/.bashrc:ro - ../utils/preexec.sh:/root/.preexec.sh:ro + - ../receiver/files/burvesigner.priv:/priv.data:ro ports: - "14000:80" - "14022:22" extra_hosts: - "host.docker.internal:host-gateway" - volumes: - - ../receiver/files/burvesigner.priv:/priv.data:ro diff --git a/services/fjb/Dockerfile b/services/fjb/Dockerfile index 691b9b8..5db006a 100644 --- a/services/fjb/Dockerfile +++ b/services/fjb/Dockerfile @@ -7,6 +7,7 @@ RUN corepack enable WORKDIR /app +COPY ./frontend/pnpm-workspace.yaml /app/ COPY ./frontend/package.json ./frontend/pnpm-lock.yaml /app/ RUN pnpm install --frozen-lockfile @@ -15,7 +16,11 @@ FROM base AS build COPY ./frontend/ /app/ 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 diff --git a/services/fjb/frontend/package.json b/services/fjb/frontend/package.json index 8464f56..5786889 100644 --- a/services/fjb/frontend/package.json +++ b/services/fjb/frontend/package.json @@ -3,6 +3,14 @@ "private": true, "version": "0.0.0", "type": "module", + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild", + "@scarf/scarf", + "sharp", + "unrs-resolver" + ] + }, "scripts": { "dev": "vite", "build": "vite build", diff --git a/services/gift-voucher/Dockerfile b/services/gift-voucher/Dockerfile index db44af9..7391f98 100644 --- a/services/gift-voucher/Dockerfile +++ b/services/gift-voucher/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim-buster +FROM python:3.11-slim-bookworm ARG PASSWORD diff --git a/services/kode-viewer/Dockerfile b/services/kode-viewer/Dockerfile index 2d38b66..a3ad5a6 100644 --- a/services/kode-viewer/Dockerfile +++ b/services/kode-viewer/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-slim-bookworm +FROM node:20-bookworm-slim ARG PASSWORD diff --git a/services/xl/Dockerfile b/services/xl/Dockerfile index 20f98f7..4cc4159 100644 --- a/services/xl/Dockerfile +++ b/services/xl/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-slim-bookworm +FROM node:20-bookworm-slim ARG PASSWORD