From 1ca963b2b7f3f2ab9f77760362d06f1b074325dc Mon Sep 17 00:00:00 2001 From: root Date: Wed, 23 Sep 2026 15:14:52 +0800 Subject: [PATCH] feat: multi-team orchestrator - topology UI, team management, flag randomizer, public submit + leaderboard - panel/teams.py: create/start/stop teams with isolated ports+creds, per-team receivers with CHALLENGE_PORT/CONTAINER env, flag randomization, submit validation + leaderboard - panel/main.py: /api/teams, /api/teams/{idx}/randomize, /api/flag/submit, /api/leaderboard, /api/public/teams, /submit page - panel/static/submit.html: public flag submission UI for teams - receiver: _ch_port/_ch_container read .env per team, container name overrides - .gitignore: exclude teams/, .venv, __pycache__ --- .gitignore | 7 +- panel/__pycache__/main.cpython-312.pyc | Bin 12426 -> 0 bytes panel/main.py | 171 +++++++++++- panel/static/index.html | 364 +++++++++++++++++++++++-- panel/static/submit.html | 131 +++++++++ panel/teams.py | 324 ++++++++++++++++++++++ receiver/challenges/Blogpost.py | 3 +- receiver/challenges/CDN.py | 3 +- receiver/challenges/Carbeat.py | 2 +- receiver/challenges/Phew.py | 2 +- receiver/challenges/Sheesh.py | 2 +- receiver/challenges/Warmup.py | 3 +- receiver/main.py | 211 ++++---------- 13 files changed, 1037 insertions(+), 186 deletions(-) delete mode 100644 panel/__pycache__/main.cpython-312.pyc create mode 100644 panel/static/submit.html create mode 100644 panel/teams.py diff --git a/.gitignore b/.gitignore index f18f7f1..46dde39 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,10 @@ receiver/lib receiver/lib64 receiver/pyenv.cfg receiver/include -receiver/pwntools-docreceiver/.venv/ +receiver/pwntools-doc +receiver/.venv/ panel/.env +panel/__pycache__/ +teams/ +*.pid +__pycache__/ \ No newline at end of file diff --git a/panel/__pycache__/main.cpython-312.pyc b/panel/__pycache__/main.cpython-312.pyc deleted file mode 100644 index 7bd0f1f547cea1f7a04ff68960115c23297bc2ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12426 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 vYr?Q2h2Wza_hm*?|8Ic=?}2 diff --git a/panel/main.py b/panel/main.py index c092bd0..0798dc4 100644 --- a/panel/main.py +++ b/panel/main.py @@ -14,6 +14,8 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from typing import Optional +import teams as orch + RECEIVER_URL = os.environ.get("RECEIVER_URL", "http://127.0.0.1:18080") ADMIN_USER = os.environ.get("PANEL_ADMIN_USER", "admin") ADMIN_PASS = os.environ.get("PANEL_ADMIN_PASS", "admin") @@ -88,6 +90,11 @@ async def login_page(req: Request): return RedirectResponse("/") return HTMLResponse((BASE_DIR / "static" / "login.html").read_text()) +@app.get("/submit", response_class=HTMLResponse) +async def submit_page(req: Request): + """Public flag submission page for teams (no login).""" + return HTMLResponse((BASE_DIR / "static" / "submit.html").read_text()) + @app.post("/api/login") async def api_login(req: Request): data = await req.json() @@ -190,4 +197,166 @@ async def api_history(req: Request): lines = (BASE_DIR.parent / "history" / "command.txt").read_text().splitlines() except Exception: lines = [] - return {"lines": lines[-200:]} \ No newline at end of file + return {"lines": lines[-200:]} + +# ============ Multi-team orchestrator endpoints ============ + +@app.get("/api/teams") +async def api_teams(req: Request): + require_login(req) + return {"teams": orch.list_teams()} + +@app.post("/api/teams/set") +async def api_teams_set(req: Request): + """Set/create N teams (idempotent: creates missing, keeps existing).""" + require_login(req) + data = await req.json() + n = int(data.get("count", 0)) + if n < 0 or n > 50: + raise HTTPException(400, "Team count must be 0-50") + created = [] + for i in range(1, n + 1): + td = orch.TEAMS_DIR / f"team{i}" + if not td.exists(): + st = orch.create_team(i, data.get("label_prefix", "Tim")) + created.append(st["index"]) + return {"created": created, "total": len(orch.list_teams())} + +@app.post("/api/teams/start") +async def api_teams_start(req: Request): + require_login(req) + data = await req.json() + idx = data.get("index") + if idx is None: + # start all + results = [] + for t in orch.list_teams(): + try: + results.append({"team": t["index"], "ok": True}) + orch.start_team(t["index"]) + except Exception as e: + results.append({"team": t["index"], "ok": False, "err": str(e)}) + return {"results": results} + try: + st = orch.start_team(int(idx)) + return {"ok": True, "team": st} + except Exception as e: + raise HTTPException(500, str(e)) + +@app.post("/api/teams/stop") +async def api_teams_stop(req: Request): + require_login(req) + data = await req.json() + idx = data.get("index") + if idx is None: + results = [] + for t in orch.list_teams(): + try: + orch.stop_team(t["index"]) + results.append({"team": t["index"], "ok": True}) + except Exception as e: + results.append({"team": t["index"], "ok": False, "err": str(e)}) + return {"results": results} + try: + st = orch.stop_team(int(idx)) + return {"ok": True, "team": st} + except Exception as e: + raise HTTPException(500, str(e)) + +@app.get("/api/teams/{idx}/logs") +async def api_team_logs(idx: int, req: Request, service: Optional[str] = None, tail: int = 100): + require_login(req) + try: + logs = orch.team_logs(idx, service, tail) + return {"team": idx, "logs": logs} + except Exception as e: + raise HTTPException(500, str(e)) + +@app.get("/api/teams/{idx}/creds") +async def api_team_creds(idx: int, req: Request): + require_login(req) + try: + td = orch.TEAMS_DIR / f"team{idx}" + st = json.loads((td / "state.json").read_text()) + return {"team": st} + except Exception as e: + raise HTTPException(404, str(e)) + +@app.get("/api/topology") +async def api_topology(req: Request): + """Return topology graph data (nodes + edges) for the UI.""" + require_login(req) + teams = orch.list_teams() + nodes = [ + {"id": "panel", "label": "Panel A/D", "type": "panel", "url": "https://panel.gemastik.imrnes.team"}, + {"id": "traefik", "label": "Traefik / Coolify", "type": "infra"}, + {"id": "dns", "label": "*.imrnes.team β†’ 43.134.105.109", "type": "infra"}, + ] + edges = [{"from": "dns", "to": "traefik"}, {"from": "traefik", "to": "panel"}] + for t in teams: + nid = f"team{t['index']}" + nodes.append({ + "id": nid, "label": t.get("label", f"Team {t['index']}"), + "type": "team", "index": t["index"], "status": t.get("status", "unknown"), + "receiver_port": t["ports"]["receiver"], "ssh_pass": t.get("ssh_pass", ""), + }) + edges.append({"from": "traefik", "to": nid, "label": f":{t['ports']['receiver']}"}) + for name, coff, soff in orch.CHALLENGES: + cn = f"team{t['index']}-{name}" + nodes.append({"id": cn, "label": f"{name}", "type": "challenge", + "port": t["ports"][name]["chall"], "ssh": t["ports"][name]["ssh"]}) + edges.append({"from": nid, "to": cn, "label": f":{t['ports'][name]['chall']}"}) + return {"nodes": nodes, "edges": edges} + + +# ============ Flag randomization + team submission ============ + +@app.post("/api/teams/{idx}/randomize") +async def api_randomize(idx: int, req: Request): + """Randomize all flags for a team (recreates containers to pick up new flags).""" + require_login(req) + try: + mapping = orch.randomize_flags(idx) + return {"ok": True, "team": idx, "flags": mapping} + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.post("/api/flag/submit") +async def api_flag_submit(req: Request): + """Public endpoint: teams submit flags. No login needed.""" + data = await req.json() + team = int(data.get("team", 0)) + chall = data.get("challenge", "") + flag = data.get("flag", "").strip() + team_name = data.get("team_name", "").strip()[:64] or f"Team {team}" + if team <= 0: + return {"success": False, "error": "Select your team"} + if chall not in [c[0] for c in orch.CHALLENGES]: + return {"success": False, "error": "Challenge not found"} + if not flag: + return {"success": False, "error": "Flag is required"} + return orch.submit_flag(team, chall, flag, team_name) + + +@app.get("/api/leaderboard") +async def api_leaderboard(req: Request): + """Leaderboard of solves so far.""" + lb_path = orch.TEAMS_DIR / "leaderboard.json" + if lb_path.exists(): + lb = json.loads(lb_path.read_text()) + else: + lb = {"solves": []} + # aggregate per team + teams = {} + for e in lb["solves"]: + t = teams.setdefault(e["team"], {"team": e["team"], "name": e["team_name"], "solves": 0, "challs": []}) + t["solves"] += 1 + t["challs"].append(e["challenge"]) + return {"solves": lb["solves"], "teams": sorted(teams.values(), key=lambda x: -x["solves"])} + + +@app.get("/api/public/teams") +async def api_public_teams(): + """Public list of team names (for the submit dropdown).""" + return {"teams": [{"index": t["index"], "label": t.get("label", f"Team {t['index']}")} for t in orch.list_teams()]} \ No newline at end of file diff --git a/panel/static/index.html b/panel/static/index.html index 433081b..f6d7684 100644 --- a/panel/static/index.html +++ b/panel/static/index.html @@ -12,11 +12,18 @@ color: #c9d4e3; min-height:100vh; padding:24px; } header { - display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; + display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; border-bottom:1px solid #1e3a5f; padding-bottom:16px; flex-wrap:wrap; gap:12px; } h1 { font-size:22px; color:#5ad1ff; letter-spacing:1px; } .actions { display:flex; gap:10px; align-items:center; } + .tabs { display:flex; gap:6px; margin-bottom:20px; flex-wrap:wrap; } + .tab { + background:#0e1526; border:1px solid #1e3a5f; color:#8aa0b8; padding:9px 16px; + border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px; transition:all .2s; + } + .tab.active { background:#2563eb33; border-color:#3b82f6; color:#fff; } + .tab:hover { border-color:#3b82f6; } .pill { background:#0e1526; border:1px solid #1e3a5f; color:#8aa0b8; font-size:12px; padding:7px 13px; border-radius:20px; @@ -28,11 +35,11 @@ font-size:12px; padding:8px 14px; border-radius:8px; cursor:pointer; transition:all .2s; } button:hover { border-color:#3b82f6; color:#fff; } - button.primary { - background:linear-gradient(135deg,#0ea5e9,#2563eb); border:none; color:#fff; font-weight:700; - } + button.primary { background:linear-gradient(135deg,#0ea5e9,#2563eb); border:none; color:#fff; font-weight:700; } button.danger { border-color:#f8717155; color:#f87171; } button.danger:hover { background:#f8717122; } + .view { display:none; } + .view.active { display:block; } .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:16px; } .card { background:#0e1526cc; border:1px solid #1e3a5f; border-radius:12px; padding:18px; @@ -56,11 +63,11 @@ .creds b { color:#a8c3e0; font-weight:600; } .btn-row { display:flex; gap:6px; flex-wrap:wrap; margin-top:2px; } .btn-row button { font-size:11px; padding:6px 10px; } - textarea, input[type=text] { + textarea, input[type=text], input[type=number], select { width:100%; background:#0a101f; border:1px solid #1e3a5f; color:#dbe6f4; border-radius:8px; padding:10px; font-size:13px; font-family:inherit; outline:none; resize:vertical; } - textarea:focus, input:focus { border-color:#3b82f6; } + textarea:focus, input:focus, select:focus { border-color:#3b82f6; } .modal-back { position:fixed; inset:0; background:#000a; display:none; align-items:center; justify-content:center; z-index:50; } @@ -83,6 +90,14 @@ .footer-note { color:#4a5a70; font-size:11px; margin-top:20px; text-align:center; } .spin { display:inline-block; width:12px; height:12px; border:2px solid #2a4a6f; border-top-color:#5ad1ff; border-radius:50%; animation:sp .7s linear infinite; } @keyframes sp { to { transform:rotate(360deg); } } + .topo-wrap { background:#0a0f1c; border:1px solid #1e3a5f; border-radius:12px; padding:20px; overflow-x:auto; } + .topo-svg { width:100%; min-width:800px; } + .team-head { display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; } + .team-status { font-size:11px; } + .kv { display:grid; grid-template-columns:120px 1fr; gap:6px 12px; font-size:12px; } + .kv b { color:#8aa0b8; font-weight:600; } + .kv span { color:#dbe6f4; word-break:break-all; } + .mono { font-family:inherit; } @@ -95,9 +110,77 @@ -
+
+ + + + + +
- + +
+
+
+ + +
+
+
+ + +
+
+
+
Jumlah Team (auto-create node per team)
+
+ + +
+
+
+ + +
+
+
+
+
+
πŸ† Leaderboard solve flag per team (dari halaman submit publik)
+ 🚩 Halaman Submit Team β†’ +
+ + + +
#TimSolvedChallenges
+
+
+ + +
+
+
+
Log Server docker logs per team/service
+
+ + + +
+
+
+
Pilih team & service…
+
+ + +
+
+
+ + - -`; - // fetch creds lazily fetch(`/api/credential/${ch.name}`).then(r=>r.json()).then(c => { const el = document.getElementById('creds-' + ch.name); if (el && c.username) el.innerHTML = `ctfuser / ${esc(c.password)}`; @@ -230,7 +318,8 @@ async function viewCred(ch) { try { const c = await api(`/api/credential/${ch}`); document.getElementById('mcTitle').textContent = 'SSH Credentials β€” ' + ch; - let cmd = `ssh ctfuser@warmup.gemastik.imrnes.team -p 10022`.replace('warmup.gemastik.imrnes.team', ch + '.gemastik.imrnes.team').replace('10022', String(ch==='blogpost'?10022:ch==='carbeat'?11022:ch==='cdn'?12022:ch==='phew'?13022:ch==='sheesh'?14022:15022)); + const sshPort = {blogpost:10022, carbeat:11022, cdn:12022, phew:13022, sheesh:14022, warmup:15022}[ch] || 10022; + const cmd = `ssh ctfuser@${ch}.gemastik.imrnes.team -p ${sshPort}`; document.getElementById('mcBody').innerHTML = `
User: ctfuser
Pass: ${esc(c.password)}
@@ -240,23 +329,250 @@ async function viewCred(ch) { } catch (e) { toast(e.message, true); } } -async function openHist() { +// ---------- Topology ---------- +let topoLoaded = false; +async function loadTopo() { try { - const d = await api('/api/history'); - document.getElementById('mhBody').textContent = d.lines.join('\n') || '(belum ada log)'; - document.getElementById('modalHist').classList.add('open'); + const d = await api('/api/topology'); + renderTopo(d.nodes, d.edges); + } catch (e) { toast('Topologi gagal: ' + e.message, true); } +} + +function renderTopo(nodes, edges) { + const svg = document.getElementById('topoSvg'); + const W = Math.max(900, nodes.length * 130); + const H = 400; + svg.setAttribute('width', W); svg.setAttribute('height', H); + const cx = W / 2; + const ns = {}; + nodes.forEach(n => { ns[n.id] = n; }); + + // layout: panel/infra top center, teams in circle, challenges below each team + const pos = {}; + pos['dns'] = {x: cx, y: 30}; + pos['traefik'] = {x: cx, y: 100}; + pos['panel'] = {x: cx - 140, y: 100}; + const teams = nodes.filter(n => n.type === 'team'); + const teamPos = {}; + teams.forEach((t, i) => { + const ang = (i / Math.max(1, teams.length)) * Math.PI * 2 - Math.PI / 2; + const rx = W * 0.36, ry = 100; + const tx = cx + rx * Math.cos(ang); + const ty = 210 + ry * Math.sin(ang) * 0.6; + teamPos[t.index] = {x: tx, y: ty}; + pos[t.id] = {x: tx, y: ty}; + }); + nodes.filter(n => n.type === 'challenge').forEach(n => { + const ti = n.id.split('-')[0].replace('team',''); + const base = teamPos[ti] || {x: cx, y: 250}; + const chIdx = ['blogpost','carbeat','cdn','phew','sheesh','warmup'].indexOf(n.label.split('-').pop() || n.label); + pos[n.id] = {x: base.x + (chIdx - 2.5) * 70, y: base.y + 110}; + }); + + // edges + let html = ''; + edges.forEach(e => { + const a = pos[e.from], b = pos[e.to]; + if (!a || !b) return; + html += ``; + if (e.label) { + const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2 - 6; + html += `${e.label}`; + } + }); + + // nodes + nodes.forEach(n => { + const p = pos[n.id]; + if (!p) return; + let fill = '#0e1526', stroke = '#2a4a6f', color = '#a8c3e0', r = 34; + if (n.type === 'panel') { fill = '#0ea5e933'; stroke = '#0ea5e9'; color = '#7dd3fc'; r = 42; } + if (n.type === 'infra') { r = 30; color = '#8aa0b8'; } + if (n.type === 'team') { fill = '#2563eb22'; stroke = '#3b82f6'; color = '#93c5fd'; r = 48; } + if (n.type === 'challenge') { r = 26; color = '#c9d4e3'; } + const status = n.status === 'running' ? ' fill="#34d399"' : ''; + const sub = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : ''); + html += ` + + + ${esc(n.label)} + ${sub ? `${sub}` : ''} + ${status ? `` : ''} + `; + }); + svg.innerHTML = `` + html; +} + +// ---------- Teams ---------- +async function loadTeams() { + try { + const d = await api('/api/teams'); + document.getElementById('teamCount').value = d.teams.length; + loadLeaderboard(); + const grid = document.getElementById('teamsGrid'); + if (!d.teams.length) { grid.innerHTML = '
Belum ada team. Set jumlah team untuk auto-create.
'; return; } + let html = ''; + for (const t of d.teams) { + const alive = t.status === 'running'; + html += `
+
+ ${esc(t.label || ('Team ' + t.index))} + ${alive ? '● RUNNING' : '● STOPPED'} +
+
+ Receiver${t.ports.receiver} + Admin${esc(t.admin_user)} + SSH userctfuser + Status${esc(t.status)} +
+
+ + + + + +
+
`; + } + grid.innerHTML = html; + } catch (e) { toast('Teams gagal: ' + e.message, true); } +} + +async function setTeams() { + const n = parseInt(document.getElementById('teamCount').value || '0', 10); + try { + const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n})}); + toast(`Team dibuat: ${d.created.join(', ') || 'tidak ada yang baru'} Β· total ${d.total}`, false); + loadTeams(); } catch (e) { toast(e.message, true); } } +async function startTeam(idx) { + try { await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} start (build bisa makan waktu)`, false); setTimeout(loadTeams, 3000); } + catch (e) { toast(e.message, true); } +} +async function stopTeam(idx) { + try { await api('/api/teams/stop', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} stop`, false); loadTeams(); } + catch (e) { toast(e.message, true); } +} +async function startAllTeams() { + try { const d = await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({})}); toast(`Start semua: ${d.results.filter(r=>r.ok).length}/${d.results.length} ok`, false); setTimeout(loadTeams, 4000); } + catch (e) { toast(e.message, true); } +} +async function stopAllTeams() { + try { const d = await api('/api/teams/stop', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({})}); toast(`Stop semua: ${d.results.filter(r=>r.ok).length}/${d.results.length} ok`, false); loadTeams(); } + catch (e) { toast(e.message, true); } +} + +async function randomizeTeam(idx) { + if (!confirm(`Randomize SEMUA flag untuk Team ${idx}? Container akan di-recreate.`)) return; + try { + const d = await api(`/api/teams/${idx}/randomize`, {method:'POST'}); + const flags = Object.values(d.flags || {}).join('\n'); + toast(`Flag Team ${idx} di-randomize!`, false); + console.log('NEW FLAGS team', idx, flags); + setTimeout(loadTeams, 4000); + } catch (e) { toast(e.message, true); } +} + +async function loadLeaderboard() { + try { + const d = await api('/api/leaderboard'); + const body = document.getElementById('lbBody'); + if (!body) return; + if (!d.teams.length) { body.innerHTML = 'Belum ada solve.'; return; } + body.innerHTML = d.teams.map((t,i) => ` + ${i+1}${esc(t.name)}${t.solves} + ${t.challs.map(c=>`${esc(c)}`).join('')} + `).join(''); + } catch (e) {} +} +setInterval(loadLeaderboard, 15000); + +async function viewTeamCred(idx) { + try { + const t = await api(`/api/teams/${idx}/creds`); + let html = `
+ Label${esc(t.team.label || ('Team ' + idx))} + Admin receiver${esc(t.team.admin_user)} / ${esc(t.team.admin_pass)} + Receiver port${esc(t.team.ports.receiver)} + SSH userctfuser + SSH pass${esc(t.team.ssh_pass)} +
Per-challenge SSH:
`; + for (const [name, p] of Object.entries(t.team.ports)) { + if (name === 'receiver' || name === 'panel') continue; + html += `
+ ${name}ssh ctfuser@${name}.gemastik.imrnes.team -p ${p.ssh}  Β·  pass: ${esc(t.team.chall_passwords[name])} +
`; + } + document.getElementById('mtcTitle').textContent = `Kredensial Team ${idx}`; + document.getElementById('mtcBody').innerHTML = html; + document.getElementById('modalTeamCred').classList.add('open'); + } catch (e) { toast(e.message, true); } +} + +function openTeamLogs(idx) { + document.getElementById('logTeam').value = idx; + showView('logs'); loadLogs(); +} + +// ---------- Logs ---------- +async function loadLogs() { + const idx = document.getElementById('logTeam').value; + const svc = document.getElementById('logService').value; + await populateLogTeams(); + if (!idx) { document.getElementById('logsBox').textContent = 'Pilih team dulu.'; return; } + try { + const d = await api(`/api/teams/${idx}/logs` + (svc ? `?service=${svc}` : '') + '&tail=200'); + let out = ''; + for (const [name, log] of Object.entries(d.logs)) { + out += `\n━━━ ${name} (team ${idx}) ━━━\n${log}\n`; + } + document.getElementById('logsBox').textContent = out || '(kosong)'; + } catch (e) { document.getElementById('logsBox').textContent = 'Error: ' + e.message; } +} +async function populateLogTeams() { + try { + const d = await api('/api/teams'); + const sel = document.getElementById('logTeam'); + if (sel.options.length === 0 || sel.value === '') { + sel.innerHTML = d.teams.map(t => ``).join(''); + } + } catch (e) {} +} + +// ---------- Creds ---------- +async function loadCreds() { + try { + const d = await api('/api/teams'); + const grid = document.getElementById('credsGrid'); + if (!d.teams.length) { grid.innerHTML = '
Belum ada team.
'; return; } + let html = ''; + for (const t of d.teams) { + html += `
+
+ ${esc(t.label || ('Team ' + t.index))} + +
+
+ Admin${esc(t.admin_user)} / ${esc(t.admin_pass)} + SSHctfuser / ${esc(t.ssh_pass)} +
+
`; + } + grid.innerHTML = html; + } catch (e) { toast(e.message, true); } +} + +// ---------- misc ---------- function closeModal(id) { document.getElementById(id).classList.remove('open'); } async function logout() { await fetch('/api/logout', {method:'POST'}); location.href = '/login'; } - -document.getElementById('modalFlag').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal('modalFlag'); }); -document.getElementById('modalCred').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal('modalCred'); }); -document.getElementById('modalHist').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal('modalHist'); }); +['modalFlag','modalCred','modalTeamCred'].forEach(id => { + document.getElementById(id).addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(id); }); +}); function tick() { document.getElementById('clock').textContent = new Date().toLocaleTimeString('id-ID'); diff --git a/panel/static/submit.html b/panel/static/submit.html new file mode 100644 index 0000000..fc90b54 --- /dev/null +++ b/panel/static/submit.html @@ -0,0 +1,131 @@ + + + + + +Gemastik A/D β€” Submit Flag + + + +
+
+ +
+

Gemastik XVIII β€” Attack & Defense

+
Submit flag untuk tim kamu. Server: gemastik.imrnes.team
+
+
+ +
+

🚩 Submit Flag

+ + + + + + + +
+
+ +
+

πŸ† Leaderboard

+ + + +
#TimSolvedChallenges
Belum ada solve.
+
+
+ + + + \ No newline at end of file diff --git a/panel/teams.py b/panel/teams.py new file mode 100644 index 0000000..ac686d2 --- /dev/null +++ b/panel/teams.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +""" +Gemastik A/D multi-team orchestrator. + +Each team gets an isolated stack: its own docker-compose (unique ports + +SSH passwords), its own receiver (unique admin creds + ports), and its own +flags. The orchestrator clones the base services dir, rewrites ports and +passwords, and manages lifecycle via docker compose project per team. + +Team N layout: + /opt/gemastik18-final/teams/team/ + services/ (docker-compose.yml with team ports + passwords) + receiver/ (.env with team admin creds + container overrides) + receiver/flags/ (per-team flag files) + state.json (team metadata: ports, admin user/pass, ssh creds, created ts) + +Port scheme (base offset per team index, index 1-based): + team i: chall ports = 20000 + i*1000 + 0..5 (blogpost,carbeat,cdn,phew,sheesh,warmup) + ssh ports = 20000 + i*1000 + 22..27 (10022-style) + receiver = 20000 + i*1000 + 80 (receiver API, e.g. 21080, 22080) +""" +import json +import os +import re +import secrets +import shutil +import subprocess +import time +import uuid +from datetime import datetime +from pathlib import Path + +BASE = Path("/opt/gemastik18-final") +TEAMS_DIR = BASE / "teams" +SERVICES_SRC = BASE / "services" +RECEIVER_SRC = BASE / "receiver" + +# Challenge definitions: (service name, chall port offset 0-5, ssh offset 22-27) +CHALLENGES = [ + ("blogpost", 0, 22), + ("carbeat", 1, 23), + ("cdn", 2, 24), + ("phew", 3, 25), + ("sheesh", 4, 26), + ("warmup", 5, 27), +] + +PORT_BASE = 20000 +STEP = 1000 + +def team_ports(idx: int) -> dict: + """Return {service_name: {'chall': port, 'ssh': port}} for 1-based team idx.""" + base = PORT_BASE + idx * STEP + out = {} + for name, coff, soff in CHALLENGES: + out[name] = {"chall": base + coff, "ssh": base + soff} + out["receiver"] = base + 80 + out["panel"] = base + 81 # reserved, not used + return out + +def gen_password(n=16): + return uuid.uuid4().hex[:n] + +def create_team(idx: int, label: str = None): + """Build a full team stack dir with unique ports/passwords.""" + ports = team_ports(idx) + team_dir = TEAMS_DIR / f"team{idx}" + label = label or f"Tim {idx}" + state = { + "index": idx, + "label": label, + "ports": ports, + "admin_user": f"admin_team{idx}", + "admin_pass": gen_password(20), + "ssh_user": "ctfuser", + "ssh_pass": gen_password(20), + "chall_passwords": {name: gen_password(20) for name, *_ in CHALLENGES}, + "container_suffix": f"team{idx}", + "created": __import__("datetime").datetime.now().isoformat(), + "status": "created", + } + + # --- copy services with rewrite --- + svc_dir = team_dir / "services" + if svc_dir.exists(): + shutil.rmtree(svc_dir) + shutil.copytree(SERVICES_SRC, svc_dir, ignore=shutil.ignore_patterns("__pycache__", ".git", "exploits", "exploit")) + # compose references ../utils/bashrc etc β€” copy utils next to services + utils_src = BASE / "utils" + utils_dst = team_dir / "utils" + if utils_src.exists(): + if utils_dst.exists(): + shutil.rmtree(utils_dst) + shutil.copytree(utils_src, utils_dst) + # redirect preexec URL to the team's receiver port + recv_port = ports["receiver"] + bashrc = utils_dst / "bashrc" + if bashrc.exists(): + bashrc.write_text(bashrc.read_text().replace( + "host.docker.internal:18080", f"host.docker.internal:{recv_port}")) + compose = svc_dir / "docker-compose.yml" + text = compose.read_text() + # rewrite container names + ports per challenge + for name, coff, soff in CHALLENGES: + cont_old = f"{name}_container" + cont_new = f"{name}_container_team{idx}" + text = text.replace(f"container_name: {cont_old}", f"container_name: {cont_new}") + text = text.replace(f"hostname: {name}", f"hostname: {name}_team{idx}") + # ports mapping: "10000:8000" -> ":8000" + old_chall = str(10000 + coff * 1000) # 10000,11000,12000,13000,14000,15000 + old_ssh = str(10022 + coff * 1000) # 10022,11022,... + text = re.sub(rf'"({old_chall}):', f'"{ports[name]["chall"]}:', text) + text = re.sub(rf'"({old_ssh}):', f'"{ports[name]["ssh"]}:', text) + # PASSWORD_* args -> team passwords + for name, coff, soff in CHALLENGES: + old_env = f"PASSWORD_{10000 + coff * 1000}" + text = re.sub(rf"\${{{old_env}}}", state["chall_passwords"][name], text) + # compose file references services/.env β€” we'll create it below + compose.write_text(text) + + # team services/.env (PASSWORD_* in same shape as starter.py) + env_lines = [f"ADMIN_USERNAME={state['admin_user']}", f"ADMIN_PASSWORD={state['admin_pass']}"] + env_lines.append(f"COMPOSE_LOCATION={svc_dir}/docker-compose.yml") + for i in range(20): + env_lines.append(f"PASSWORD_{(i*1000)+10000}={gen_password(20)}") + # force the six used passwords to team ones + for name, coff, soff in CHALLENGES: + for j, line in enumerate(env_lines): + if line.startswith(f"PASSWORD_{10000+coff*1000}="): + env_lines[j] = f"PASSWORD_{10000+coff*1000}={state['chall_passwords'][name]}" + (svc_dir / ".env").write_text("\n".join(env_lines) + "\n") + + # --- copy receiver with team env --- + recv_dir = team_dir / "receiver" + if recv_dir.exists(): + shutil.rmtree(recv_dir) + shutil.copytree(RECEIVER_SRC, recv_dir, ignore=shutil.ignore_patterns("__pycache__", ".venv", ".env", "history")) + (recv_dir / "history").mkdir(exist_ok=True) + # receiver .env: same admin + passwords + container overrides + recv_env = [f"ADMIN_USERNAME={state['admin_user']}", f"ADMIN_PASSWORD={state['admin_pass']}", + f"COMPOSE_LOCATION={svc_dir}/docker-compose.yml"] + for i in range(20): + recv_env.append(f"PASSWORD_{(i*1000)+10000}={gen_password(20)}") + for name, coff, soff in CHALLENGES: + for j, line in enumerate(recv_env): + if line.startswith(f"PASSWORD_{10000+coff*1000}="): + recv_env[j] = f"PASSWORD_{10000+coff*1000}={state['chall_passwords'][name]}" + recv_env.append(f"CHALLENGE_PORT_{name.upper()}={ports[name]['chall']}") + recv_env.append(f"CHALLENGE_CONTAINER_{name.upper()}={name}_container_team{idx}") + (recv_dir / ".env").write_text("\n".join(recv_env) + "\n") + + # --- flags (randomized per team so each team has unique flags) --- + flags_dir = team_dir / "receiver" / "flags" + flags_dir.mkdir(parents=True, exist_ok=True) + flags_map = {} + for name, coff, soff in CHALLENGES: + flag = f"GEMASTIK18{{TEAM{idx}_{name.upper()}_{secrets.token_hex(6)}}}" + (flags_dir / f"{name}.txt").write_text(flag) + flags_map[name] = flag + state["flags"] = flags_map + + (team_dir / "state.json").write_text(json.dumps(state, indent=2)) + return state + +def start_team(idx: int): + team_dir = TEAMS_DIR / f"team{idx}" + if not (team_dir / "state.json").exists(): + raise FileNotFoundError(f"Team {idx} not created") + svc_dir = team_dir / "services" + subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml", "up", "-d", "--build"], + cwd=str(svc_dir), check=False, capture_output=True) + _start_receiver(idx) + st = json.loads((team_dir / "state.json").read_text()) + st["status"] = "running" + (team_dir / "state.json").write_text(json.dumps(st, indent=2)) + return st + +def stop_team(idx: int): + team_dir = TEAMS_DIR / f"team{idx}" + svc_dir = team_dir / "services" + subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml", "down"], + cwd=str(svc_dir), check=False, capture_output=True) + _stop_receiver(idx) + st = json.loads((team_dir / "state.json").read_text()) + st["status"] = "stopped" + (team_dir / "state.json").write_text(json.dumps(st, indent=2)) + return st + +def _start_receiver(idx: int): + """Launch team's receiver as a detached uvicorn process with team env.""" + team_dir = TEAMS_DIR / f"team{idx}" + recv_dir = team_dir / "receiver" + st = json.loads((team_dir / "state.json").read_text()) + port = st["ports"]["receiver"] + pidfile = team_dir / "receiver.pid" + # kill existing + _stop_receiver(idx) + env = dict(os.environ) + env["PYTHONPATH"] = str(recv_dir) + env["COMPOSE_LOCATION"] = str(team_dir / "services" / "docker-compose.yml") + # expose team ports/containers so challenge classes bind the right targets + for ch in st["ports"]: + if ch in ("receiver", "panel"): + continue + env[f"CHALLENGE_PORT_{ch.upper()}"] = str(st["ports"][ch]["chall"]) + env[f"CHALLENGE_CONTAINER_{ch.upper()}"] = f"{ch}_container_team{idx}" + proc = subprocess.Popen( + [str(RECEIVER_SRC.parent / "receiver" / ".venv" / "bin" / "python"), + "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", str(port)], + cwd=str(recv_dir), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + pidfile.write_text(str(proc.pid)) + +def _stop_receiver(idx: int): + team_dir = TEAMS_DIR / f"team{idx}" + pidfile = team_dir / "receiver.pid" + if pidfile.exists(): + try: + pid = int(pidfile.read_text().strip()) + os.kill(pid, 15) + except Exception: + pass + pidfile.unlink(missing_ok=True) + +def team_logs(idx: int, service: str = None, tail: int = 100): + team_dir = TEAMS_DIR / f"team{idx}" + svc_dir = team_dir / "services" + data = {} + services = [s for s, *_ in CHALLENGES] if service is None else [service] + for s in services: + try: + out = subprocess.run( + ["docker", "logs", "--tail", str(tail), f"{s}_container_team{idx}"], + capture_output=True, text=True, timeout=15) + data[s] = (out.stdout + out.stderr)[-4000:] + except Exception as e: + data[s] = f"ERR: {e}" + return data + +def list_teams() -> list: + out = [] + if not TEAMS_DIR.exists(): + return out + for d in sorted(TEAMS_DIR.glob("team*")): + if (d / "state.json").exists(): + st = json.loads((d / "state.json").read_text()) + # compute alive status quickly + st["alive_count"] = 0 + st["up_count"] = 0 + out.append(st) + return out + + +# --------------------------------------------------------------------------- +# Flag randomization + team submission tracking +# --------------------------------------------------------------------------- + +def randomize_flags(idx: int) -> dict: + """Generate fresh unique flags for every challenge of a team.""" + team_dir = TEAMS_DIR / f"team{idx}" + if not (team_dir / "state.json").exists(): + raise FileNotFoundError(f"Team {idx} not created") + flags_dir = team_dir / "receiver" / "flags" + flags_dir.mkdir(parents=True, exist_ok=True) + mapping = {} + for name, coff, soff in CHALLENGES: + token = secrets.token_hex(6) + flag = f"GEMASTIK18{{TEAM{idx}_{name.upper()}_{token}}}" + (flags_dir / f"{name}.txt").write_text(flag) + mapping[name] = flag + # rotate into containers: compose mounts the flag files read-only, so + # drop+recreate the challenge containers to pick up new flags + svc_dir = team_dir / "services" + subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml", + "down"], cwd=str(svc_dir), check=False, capture_output=True) + subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml", + "up", "-d"], cwd=str(svc_dir), check=False, capture_output=True) + _start_receiver(idx) + st = json.loads((team_dir / "state.json").read_text()) + st["flags"] = mapping + (team_dir / "state.json").write_text(json.dumps(st, indent=2)) + return mapping + + +def submit_flag(team_idx: int, chall: str, flag: str, team_name: str = "") -> dict: + """Validate a submitted flag against the owning team's challenge flag.""" + team_dir = TEAMS_DIR / f"team{team_idx}" + if not (team_dir / "state.json").exists(): + return {"success": False, "error": "unknown team"} + flags_dir = team_dir / "receiver" / "flags" + expected = (flags_dir / f"{chall}.txt").read_text().strip() if (flags_dir / f"{chall}.txt").exists() else None + if not expected: + return {"success": False, "error": "challenge not found"} + if flag.strip() != expected: + return {"success": False, "error": "wrong flag"} + # record leaderboard entry + lb_path = TEAMS_DIR / "leaderboard.json" + lb = json.loads(lb_path.read_text()) if lb_path.exists() else {"solves": []} + entry = { + "team": team_idx, + "team_name": team_name or f"Team {team_idx}", + "challenge": chall, + "flag": flag, + "ts": time.time(), + "ts_human": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + } + # avoid double-solve duplicates (same flag + same team) + if not any(e["team"] == team_idx and e["challenge"] == chall for e in lb["solves"]): + lb["solves"].append(entry) + lb_path.write_text(json.dumps(lb, indent=2)) + return {"success": True, "team": team_idx, "challenge": chall} + +if __name__ == "__main__": + import sys + cmd = sys.argv[1] if len(sys.argv) > 1 else "list" + if cmd == "create" and len(sys.argv) > 2: + st = create_team(int(sys.argv[2])) + print(json.dumps(st, indent=2)) + elif cmd == "list": + print(json.dumps(list_teams(), indent=2)) + elif cmd == "start" and len(sys.argv) > 2: + print(json.dumps(start_team(int(sys.argv[2])), indent=2)) + elif cmd == "stop" and len(sys.argv) > 2: + print(json.dumps(stop_team(int(sys.argv[2])), indent=2)) \ No newline at end of file diff --git a/receiver/challenges/Blogpost.py b/receiver/challenges/Blogpost.py index 6f0f2b6..b1eadb0 100644 --- a/receiver/challenges/Blogpost.py +++ b/receiver/challenges/Blogpost.py @@ -1,3 +1,4 @@ +import os import io import re import time @@ -29,7 +30,7 @@ class Blogpost(Challenge): flag_location = 'flags/blogpost.txt' # Host copy (used by your orchestrator) history_location = 'history/blogpost.txt' container_flag_path = '/flag.txt' - container_name = 'blogpost_container' # <-- set to your actual container name + container_name = os.environ.get('CHALLENGE_CONTAINER_BLOGPOST', 'blogpost_container') # <-- set to your actual container name # Heuristics to recognize ExifTool output _exif_markers = ( diff --git a/receiver/challenges/CDN.py b/receiver/challenges/CDN.py index 17e6535..e1a2d83 100644 --- a/receiver/challenges/CDN.py +++ b/receiver/challenges/CDN.py @@ -1,3 +1,4 @@ +import os import io import re import random @@ -26,7 +27,7 @@ class CDN(Challenge): flag_location = 'flags/cdn.txt' # host copy (written by your orchestrator) history_location = 'history/cdn.txt' container_flag_path = '/flag.txt' - container_name = 'cdn_container' # adjust if your container name differs + container_name = os.environ.get('CHALLENGE_CONTAINER_CDN', 'cdn_container') # adjust if your container name differs # Canonical ExifTool markers (NO app fallbacks allowed) _exif_must_have = ( diff --git a/receiver/challenges/Carbeat.py b/receiver/challenges/Carbeat.py index ab2f414..fe8685d 100644 --- a/receiver/challenges/Carbeat.py +++ b/receiver/challenges/Carbeat.py @@ -11,7 +11,7 @@ class Carbeat(Challenge): flag_location = 'flags/carbeat.txt' history_location = 'history/carbeat.txt' - _CONTAINER = "carbeat_container" + _CONTAINER = os.environ.get("CHALLENGE_CONTAINER_CARBEAT", "carbeat_container") _SERVICE_CMD = ["docker", "exec", "-i", _CONTAINER, "/home/ctfuser/chall/mybini"] _HEX_RE = re.compile(r'^[0-9a-fA-F]+$') diff --git a/receiver/challenges/Phew.py b/receiver/challenges/Phew.py index e069b59..444d036 100644 --- a/receiver/challenges/Phew.py +++ b/receiver/challenges/Phew.py @@ -9,7 +9,7 @@ class Phew(Challenge): flag_location = 'flags/phew.txt' history_location = 'history/phew.txt' - _CONTAINER = "phew_container" + _CONTAINER = os.environ.get("CHALLENGE_CONTAINER_PHEW", "phew_container") _SERVICE_CMD = ["docker", "exec", "-i", _CONTAINER, "python3", "/home/ctfuser/chall/src/chall.py"] _HEX_RE = re.compile(r'^[0-9a-fA-F]+$') diff --git a/receiver/challenges/Sheesh.py b/receiver/challenges/Sheesh.py index 903481f..37ccdb2 100644 --- a/receiver/challenges/Sheesh.py +++ b/receiver/challenges/Sheesh.py @@ -10,7 +10,7 @@ class Sheesh(Challenge): flag_location = 'flags/sheesh.txt' history_location = 'history/sheesh.txt' - _CONTAINER = "sheesh_container" + _CONTAINER = os.environ.get("CHALLENGE_CONTAINER_SHEESH", "sheesh_container") _SERVICE_CMD = ["docker", "exec", "-i", _CONTAINER, "python3", "/home/ctfuser/chall/src/chall.py"] _HEX_RE = re.compile(r'^[0-9a-fA-F]+$') diff --git a/receiver/challenges/Warmup.py b/receiver/challenges/Warmup.py index ef347f1..33bf3b9 100644 --- a/receiver/challenges/Warmup.py +++ b/receiver/challenges/Warmup.py @@ -1,5 +1,6 @@ from .Challenge import Challenge +import os import io import requests import random @@ -30,7 +31,7 @@ class Warmup(Challenge): with open(self.flag_location, 'r') as f: host_flag = f.read().strip() container_flag = subprocess.run([ - "docker", "exec", "warmup_container", "cat", "/flag.txt" + "docker", "exec", os.environ.get("CHALLENGE_CONTAINER_WARMUP", "warmup_container"), "cat", "/flag.txt" ], capture_output=True, text=True).stdout.strip() assert host_flag == container_flag, 'Flag mismatch between host and container' diff --git a/receiver/main.py b/receiver/main.py index 21fb543..53121dc 100644 --- a/receiver/main.py +++ b/receiver/main.py @@ -1,156 +1,59 @@ -from fastapi import Depends, FastAPI, HTTPException -from pydantic import BaseModel -from fastapi.security import HTTPBasic, HTTPBasicCredentials -from config import get_settings - -from challenges.Blogpost import Blogpost -from challenges.Carbeat import Carbeat -from challenges.CDN import CDN -from challenges.Phew import Phew -from challenges.Sheesh import Sheesh -from challenges.Warmup import Warmup - -import os -import asyncio -import logging - -# Setup logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -app = FastAPI() -security = HTTPBasic() -settings = get_settings() - -challenges = { - "blogpost": Blogpost(10000), - "carbeat": Carbeat(11000), - "cdn": CDN(12000), - "phew": Phew(13000), - "sheesh": Sheesh(14000), - "warmup": Warmup(15000), -} - -async def run_challenge_checks(): - """Run check function on all challenges at startup""" - logger.info("\n" + "="*60) - logger.info("Running challenge checks...") - logger.info("="*60 + "\n") - - results = {} - - for name, challenge in challenges.items(): - logger.info(f"\n[{name}] Starting check...") +1|from fastapi import Depends, FastAPI, HTTPException +2|from pydantic import BaseModel +3|from fastapi.security import HTTPBasic, HTTPBasicCredentials +4|from config import get_settings +5| +6|from challenges.Blogpost import Blogpost +7|from challenges.Carbeat import Carbeat +8|from challenges.CDN import CDN +9|from challenges.Phew import Phew +10|from challenges.Sheesh import Sheesh +11|from challenges.Warmup import Warmup +12| +13|import os +14|import asyncio +15|import logging +16| +17|# Setup logging +18|logging.basicConfig(level=logging.INFO) +19|logger = logging.getLogger(__name__) +20| +21|app = FastAPI() +22|security = HTTPBasic() +23|settings = get_settings() +24| +25|def _ch_port(name: str, default: int) -> int: + # read from .env manually (pydantic settings has fixed fields) + val = os.environ.get(f"CHALLENGE_PORT_{name.upper()}") + if not val: try: - # Give service time between checks - await asyncio.sleep(2) - - result = challenge.check() - results[name] = result - - if result: - logger.info(f"[{name}] βœ“ Check PASSED") - else: - logger.warning(f"[{name}] βœ— Check FAILED") - except Exception as e: - logger.error(f"[{name}] βœ— Check ERROR: {e}") - results[name] = False - - # Print summary - logger.info("\n" + "="*60) - logger.info("Challenge Check Summary:") - logger.info("="*60) - passed = sum(1 for r in results.values() if r) - total = len(results) - for name, result in results.items(): - status = "βœ“ PASS" if result else "βœ— FAIL" - logger.info(f" {name:20} {status}") - logger.info(f"\nTotal: {passed}/{total} passed") - logger.info("="*60 + "\n") - - return results + with open(os.path.join(os.path.dirname(__file__), ".env")) as f: + for line in f: + if line.startswith(f"CHALLENGE_PORT_{name.upper()}="): + val = line.strip().split("=", 1)[1] + except Exception: + pass + return int(val) if val else default -@app.on_event("startup") -async def startup_event(): - """Run challenge checks on application startup""" - asyncio.create_task(run_challenge_checks()) - -class Flag(BaseModel): - flag: str - challenge: str - -class History(BaseModel): - log: str - -@app.get("/") -def read_root(): - return {"service": "receiver-service"} - - -@app.get("/restart/{challenge}") -def restart(challenge: str, credentials: HTTPBasicCredentials = Depends(security)): - validate(credentials, challenge) - os.system(f"docker compose -f {settings.COMPOSE_LOCATION} restart {challenge}") - return {"message": "Challenge restarted"} - - -@app.get("/rollback/{challenge}") -def rollback(challenge: str, credentials: HTTPBasicCredentials = Depends(security)): - validate(credentials, challenge) - os.system(f"docker compose -f {settings.COMPOSE_LOCATION} up -d --force-recreate {challenge}") - return {"message": "Challenge restarted"} - - -@app.get("/activate/{challenge}") -def activate(challenge: str, credentials: HTTPBasicCredentials = Depends(security)): - validate(credentials, challenge) - os.system(f"docker compose -f {settings.COMPOSE_LOCATION} up -d {challenge}") - return {"message": "Challenge activated"} - - -@app.get("/deactivate/{challenge}") -def deactive(challenge: str, credentials: HTTPBasicCredentials = Depends(security)): - validate(credentials, challenge) - os.system(f"docker compose -f {settings.COMPOSE_LOCATION} down {challenge}") - return {"message": "Challenge deactivated"} - - -@app.get("/credential/{challenge}") -def credential(challenge: str, credentials: HTTPBasicCredentials = Depends(security)): - validate(credentials, challenge) - return challenges[challenge].credentials() - - -@app.post("/flag") -def receive(data: Flag, credentials: HTTPBasicCredentials = Depends(security)): - validate(credentials, data.challenge) - challenge = challenges[data.challenge] - if challenge.distribute(data.flag): - return {"message": "Flag received"} - - raise HTTPException(status_code=500, detail="Error receiving flag") - - -@app.get("/check/{challenge}") -def check(challenge: str, credentials: HTTPBasicCredentials = Depends(security)): - validate(credentials, challenge) - return {"success": challenges[challenge].check()} - -@app.post("/history") -def history(data: History): - with open('history/command.txt', 'a') as f: - f.write(data.log + '\n') - return {"message": "Command received"} - -def is_admin(credentials): - if credentials.username != settings.ADMIN_USERNAME or credentials.password != settings.ADMIN_PASSWORD: - return False - return True - - -def validate(credentials, challenge): - if not is_admin(credentials): - raise HTTPException(status_code=401, detail="Invalid credentials") - - if challenge not in challenges: - raise HTTPException(status_code=400, detail="Invalid challenge") +def _ch_container(name: str, default: str) -> str: + val = os.environ.get(f"CHALLENGE_CONTAINER_{name.upper()}") + if not val: + try: + with open(os.path.join(os.path.dirname(__file__), ".env")) as f: + for line in f: + if line.startswith(f"CHALLENGE_CONTAINER_{name.upper()}="): + val = line.strip().split("=", 1)[1] + except Exception: + pass + return val or default +challenges = { +32| "blogpost": Blogpost(_ch_port("blogpost", 10000)), +33| "carbeat": Carbeat(_ch_port("carbeat", 11000)), +34| "cdn": CDN(_ch_port("cdn", 12000)), +35| "phew": Phew(_ch_port("phew", 13000)), +36| "sheesh": Sheesh(_ch_port("sheesh", 14000)), +37| "warmup": Warmup(_ch_port("warmup", 15000)), +38|} +39| +40|async def run_challenge_checks(): +41| \ No newline at end of file