feat: target matrix + reset scores/environment buttons

- /api/targets (admin): matrix of all teams' domain:port targets
- /api/reset/scores: wipe leaderboard (admin, confirm dialog)
- /api/reset/environment: stop all teams, remove containers+receivers+
  team dirs+systemd units, wipe scores, drop domains (admin, confirm)
- portal targets now only domain+port (no ssh/labels)
- UI: tab Target Matrix, header buttons Reset Skor / Reset Environment
  with confirm() alerts 'apakah anda yakin ingin mereset...'
This commit is contained in:
root
2026-09-23 17:14:42 +08:00
parent b66530e7fd
commit 44dc1ac852
4 changed files with 140 additions and 10 deletions
+38 -6
View File
@@ -202,7 +202,7 @@ async def api_team_session(idx: int, req: Request):
@app.get("/api/team/{idx}/targets") @app.get("/api/team/{idx}/targets")
async def api_team_targets(idx: int, req: Request): async def api_team_targets(idx: int, req: Request):
"""Team targets — requires team login + own host.""" """Team targets — requires team login + own host. Only domain + port."""
td = orch.TEAMS_DIR / f"team{idx}" td = orch.TEAMS_DIR / f"team{idx}"
if not (td / "state.json").exists(): if not (td / "state.json").exists():
raise HTTPException(404, "Team not found") raise HTTPException(404, "Team not found")
@@ -223,12 +223,8 @@ async def api_team_targets(idx: int, req: Request):
if not p: if not p:
continue continue
out.append({ out.append({
"team": st.get("label", f"Team {st.get('index')}"), "domain": st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".gemastik.imrnes.team"),
"team_idx": st.get("index"),
"domain": st.get("domain"),
"challenge": name,
"port": p["chall"], "port": p["chall"],
"ssh": p["ssh"],
}) })
return {"targets": out} return {"targets": out}
@@ -510,6 +506,19 @@ async def api_randomize(idx: int, req: Request):
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@app.post("/api/reset/scores")
async def api_reset_scores(req: Request):
"""Admin: wipe leaderboard (all solves)."""
require_login(req)
return orch.reset_scores()
@app.post("/api/reset/environment")
async def api_reset_environment(req: Request):
"""Admin: full environment reset (stop all, remove teams/containers/receivers)."""
require_login(req)
return orch.reset_environment()
@app.post("/api/flag/submit") @app.post("/api/flag/submit")
async def api_flag_submit(req: Request): async def api_flag_submit(req: Request):
"""Public endpoint: teams submit flags. No login needed.""" """Public endpoint: teams submit flags. No login needed."""
@@ -549,6 +558,29 @@ async def api_public_teams():
"""Public list of team names (for the submit dropdown).""" """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()]} return {"teams": [{"index": t["index"], "label": t.get("label", f"Team {t['index']}")} for t in orch.list_teams()]}
@app.get("/api/targets")
async def api_admin_targets(req: Request):
"""Admin: matrix of every team's service domain:port (public targets)."""
require_login(req)
out = []
for d in sorted(orch.TEAMS_DIR.glob("team*")):
if not (d / "state.json").exists():
continue
st = json.loads((d / "state.json").read_text())
dom = st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".gemastik.imrnes.team")
for name, coff, soff in orch.CHALLENGES:
p = st["ports"].get(name)
if not p:
continue
out.append({
"team": st.get("index"),
"team_label": st.get("label", f"Team {st.get('index')}"),
"challenge": name,
"domain": dom,
"port": p["chall"],
})
return {"targets": out}
# ============ WebSocket SSH terminal (team portal) ============ # ============ WebSocket SSH terminal (team portal) ============
import paramiko import paramiko
+57
View File
@@ -115,6 +115,8 @@
<div class="actions"> <div class="actions">
<span class="pill" id="clock">--:--:--</span> <span class="pill" id="clock">--:--:--</span>
<button onclick="refresh()">🔄 Refresh</button> <button onclick="refresh()">🔄 Refresh</button>
<button class="danger" onclick="resetScores()" style="background:#dc2626;border:none;border-radius:8px;padding:8px 14px;color:#fff;font-weight:600;cursor:pointer">🧹 Reset Skor</button>
<button class="danger" onclick="resetEnv()" style="background:#7f1d1d;border:none;border-radius:8px;padding:8px 14px;color:#fff;font-weight:600;cursor:pointer">💥 Reset Environment</button>
<button onclick="logout()">Keluar</button> <button onclick="logout()">Keluar</button>
</div> </div>
</header> </header>
@@ -125,6 +127,7 @@
<button class="tab" data-view="teams" onclick="showView('teams'); loadTeams()">👥 Teams</button> <button class="tab" data-view="teams" onclick="showView('teams'); loadTeams()">👥 Teams</button>
<button class="tab" data-view="logs" onclick="showView('logs'); loadLogs()">📜 Logs</button> <button class="tab" data-view="logs" onclick="showView('logs'); loadLogs()">📜 Logs</button>
<button class="tab" data-view="creds" onclick="showView('creds'); loadCreds()">🔑 Creds</button> <button class="tab" data-view="creds" onclick="showView('creds'); loadCreds()">🔑 Creds</button>
<button class="tab" data-view="targets" onclick="showView('targets'); loadTargetMatrix()">🎯 Target Matrix</button>
</div> </div>
<!-- Challenges view --> <!-- Challenges view -->
@@ -190,6 +193,17 @@
<div class="grid" id="credsGrid"></div> <div class="grid" id="credsGrid"></div>
</div> </div>
<!-- Target Matrix view -->
<div class="view" id="view-targets">
<div class="card">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<b style="color:#5ad1ff">🎯 Target Matrix</b>
<span style="font-size:11px;color:#718096">Domain:port semua service semua tim — buat dibagikan ke peserta sebagai daftar target</span>
</div>
<div id="targetMatrix" style="margin-top:14px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.9;color:#dbe6f4;background:#0a101f;border:1px solid #1e3a5f;border-radius:10px;padding:14px;overflow-x:auto">Memuat…</div>
</div>
</div>
<div class="footer-note">Receiver: https://gemastik.imrnes.team · Panel: https://panel.gemastik.imrnes.team · Auto-refresh tiap 15 detik</div> <div class="footer-note">Receiver: https://gemastik.imrnes.team · Panel: https://panel.gemastik.imrnes.team · Auto-refresh tiap 15 detik</div>
<!-- modal flag --> <!-- modal flag -->
@@ -519,6 +533,28 @@ async function stopAllTeams() {
finally { hideLoading(); } finally { hideLoading(); }
} }
async function resetScores() {
if (!confirm('⚠️ Apakah anda yakin ingin mereset skor?\n\nSemua poin & solve di leaderboard akan DIHAPUS.\nTindakan ini tidak bisa dibatalkan.')) return;
showLoading('Menghapus semua skor…');
try {
const d = await api('/api/reset/scores', {method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'});
toast('Skor direset: semua solve dihapus', false);
} catch (e) { toast(e.message, true); }
finally { hideLoading(); }
}
async function resetEnv() {
if (!confirm('⚠️ Apakah anda yakin ingin mereset environment?\n\nSEMUA team akan DISTOP:\n• Container challenge dihapus\n• Receiver team dihapus\n• Folder team dihapus (perlu create ulang)\n• Skor leaderboard direset\n\nTindakan ini TIDAK BISA dibatalkan.')) return;
showLoading('Reset environment…\nMenghentikan semua team + menghapus container/receiver (beberapa menit)');
try {
const d = await api('/api/reset/environment', {method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'});
const n = (d.stopped || []).length;
toast(`Environment direset: ${n} team dihentikan & dihapus`, false);
setTimeout(() => location.reload(), 1200);
} catch (e) { toast(e.message, true); }
finally { hideLoading(); }
}
async function randomizeTeam(idx) { async function randomizeTeam(idx) {
if (!confirm(`Randomize SEMUA flag untuk Team ${idx}? Container akan di-recreate.`)) return; if (!confirm(`Randomize SEMUA flag untuk Team ${idx}? Container akan di-recreate.`)) return;
try { try {
@@ -619,6 +655,27 @@ async function loadCreds() {
} catch (e) { toast(e.message, true); } } catch (e) { toast(e.message, true); }
} }
// ---------- Target Matrix ----------
async function loadTargetMatrix() {
const box = document.getElementById('targetMatrix');
try {
const d = await api('/api/targets');
const targets = d.targets || [];
if (!targets.length) { box.textContent = 'Belum ada team.'; return; }
// group by team
const rows = [];
let curTeam = null;
for (const t of targets) {
if (t.team !== curTeam) {
rows.push(`\n[${esc(t.team_label || ('Team ' + t.team))}]`);
curTeam = t.team;
}
rows.push(` ${esc(t.domain)}:${t.port} (${esc(t.challenge)})`);
}
box.textContent = rows.join('\n');
} catch (e) { box.textContent = 'Gagal: ' + e.message; }
}
// ---------- misc ---------- // ---------- misc ----------
function closeModal(id) { document.getElementById(id).classList.remove('open'); } function closeModal(id) { document.getElementById(id).classList.remove('open'); }
async function logout() { async function logout() {
+1 -4
View File
@@ -264,10 +264,7 @@ async function loadTargets() {
if (!targets.length) { box.textContent = 'Belum ada tim musuh.'; return; } if (!targets.length) { box.textContent = 'Belum ada tim musuh.'; return; }
let html = '=== TARGET MUSUH ===\n\n'; let html = '=== TARGET MUSUH ===\n\n';
for (const t of targets) { for (const t of targets) {
html += `[${esc(t.team)}] — ${esc(t.challenge)}\n`; html += `${esc(t.domain)}:${t.port}\n`;
if (t.domain) html += ` domain : https://${esc(t.domain)}\n`;
html += ` service: ${esc(window.location.hostname)}:${t.port}\n`;
html += ` ssh : ${esc(window.location.hostname)}:${t.ssh}\n\n`;
} }
box.textContent = html; box.textContent = html;
} }
+44
View File
@@ -402,6 +402,50 @@ def submit_flag(team_idx: int, chall: str, flag: str, team_name: str = "") -> di
lb_path.write_text(json.dumps(lb, indent=2)) lb_path.write_text(json.dumps(lb, indent=2))
return {"success": True, "team": team_idx, "challenge": chall} return {"success": True, "team": team_idx, "challenge": chall}
def reset_scores() -> dict:
"""Wipe the leaderboard (all solves removed)."""
lb_path = TEAMS_DIR / "leaderboard.json"
lb_path.write_text(json.dumps({"solves": []}, indent=2))
return {"ok": True, "cleared": True}
def reset_environment() -> dict:
"""Full env reset: stop all teams, remove containers + receiver services,
delete team dirs (fresh for re-create). Keeps panel + images."""
results = []
for d in sorted(TEAMS_DIR.glob("team*")):
sf = d / "state.json"
if not sf.exists():
continue
st = json.loads(sf.read_text())
idx = st["index"]
try:
stop_team(idx) # compose down + stop receiver service + set status
except Exception as e:
results.append({"team": idx, "ok": False, "err": str(e)})
continue
# remove the per-team systemd receiver unit
subprocess.run(["systemctl", "disable", f"gemastik-receiver-team{idx}.service"],
check=False, capture_output=True)
subprocess.run(["systemctl", "stop", f"gemastik-receiver-team{idx}.service"],
check=False, capture_output=True)
unit = Path("/etc/systemd/system") / f"gemastik-receiver-team{idx}.service"
if unit.exists():
unit.unlink()
results.append({"team": idx, "ok": True})
subprocess.run(["systemctl", "daemon-reload"], check=False)
# remove team dirs entirely (fresh for re-create)
for d in sorted(TEAMS_DIR.glob("team*")):
shutil.rmtree(d, ignore_errors=True)
# wipe leaderboard too
reset_scores()
# drop team domains from Traefik (regenerate — no teams left)
try:
ensure_team_domains()
except Exception:
pass
return {"ok": True, "stopped": results, "teams_dir": str(TEAMS_DIR)}
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
cmd = sys.argv[1] if len(sys.argv) > 1 else "list" cmd = sys.argv[1] if len(sys.argv) > 1 else "list"