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:
@@ -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
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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())
|
||||
@@ -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 <name> (builds image first if needed)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11-slim-buster
|
||||
FROM python:3.11-slim-bookworm
|
||||
|
||||
ARG PASSWORD
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-slim-bookworm
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
ARG PASSWORD
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-slim-bookworm
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
ARG PASSWORD
|
||||
|
||||
|
||||
Reference in New Issue
Block a user