From c35b23a37f3690efde0c9e0193b959a7e73591aa Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 24 Sep 2026 00:49:05 +0800 Subject: [PATCH] feat: SLA dashboard per team + points system (100/flag, +50 SLA bonus) + badges juara/runner-up/3rd + scoreboard API public+admin Panel: /api/scoreboard + /api/public/scoreboard; teams.py: points.json ledger, probe_team_sla_fast, sla_status_all w/ background refresher; team.html: SLA & Skor tab; index.html: admin SLA tab; leaderboard shows points; Phew checker timeouts raised for slow Paillier keygen --- panel/main.py | 42 +++++++- panel/static/index.html | 41 ++++++++ panel/static/team.html | 49 ++++++++- panel/teams.py | 216 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 342 insertions(+), 6 deletions(-) diff --git a/panel/main.py b/panel/main.py index 837a299..ddddbd3 100644 --- a/panel/main.py +++ b/panel/main.py @@ -25,6 +25,15 @@ BASE_DIR = Path(__file__).parent app = FastAPI(title="Gemastik A/D Panel") + +@app.on_event("startup") +async def _start_background(): + """Warm the SLA scoreboard cache in the background.""" + import threading + threading.Thread(target=orch._build_scoreboard, daemon=True, + name="sla-warmup").start() + orch.start_sla_refresher() + 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"}, @@ -276,7 +285,14 @@ async def api_team_status(idx: int, req: Request): ok = False results.append({"name": name, "port": st["ports"][name]["chall"], "ssh": st["ports"][name]["ssh"], "alive": ok}) - return {"results": results} + # award SLA bonus when all services are UP (throttled, see add_sla_bonus) + alive = sum(1 for r in results if r["alive"]) + bonus = orch.add_sla_bonus(idx, alive, len(results)) + return {"results": results, "sla": {"alive": alive, "total": len(results), + "pct": round(100 * alive / max(len(results), 1), 1), + "points": orch.get_team_points(idx), + "rank": orch.team_rank(idx), "badge": orch.team_badge(idx), + "bonus": bonus}} @app.get("/api/team/{idx}/activity") async def api_team_activity(idx: int, req: Request): @@ -630,11 +646,31 @@ async def api_leaderboard(req: Request): teams = {} for e in lb["solves"]: name = team_name(e["team"]) - t = teams.setdefault(e["team"], {"team": e["team"], "name": name, "solves": 0, "challs": []}) + t = teams.setdefault(e["team"], {"team": e["team"], "name": name, "solves": 0, "challs": [], "points": 0}) t["name"] = name t["solves"] += 1 t["challs"].append(e["challenge"]) - return {"solves": lb["solves"], "teams": sorted(teams.values(), key=lambda x: -x["solves"])} + # attach live points from points.json + for tid, t in teams.items(): + t["points"] = orch.get_team_points(tid) + return {"solves": lb["solves"], "teams": sorted(teams.values(), key=lambda x: (-x["points"], -x["solves"]))} + + +@app.get("/api/scoreboard") +async def api_scoreboard(req: Request): + """Admin + team: full scoreboard with points, SLA, rank, badges.""" + require_login(req) + return orch.sla_status_all() + + +@app.get("/api/public/scoreboard") +async def api_public_scoreboard(): + """Public scoreboard (no login) β€” used by the team portal status tab.""" + d = orch.sla_status_all() + # strip receiver creds / ports internals for the public view + for t in d["teams"]: + t.pop("domain", None) + return d @app.get("/api/public/teams") diff --git a/panel/static/index.html b/panel/static/index.html index ca5f49f..3dd307b 100644 --- a/panel/static/index.html +++ b/panel/static/index.html @@ -134,6 +134,7 @@ + @@ -217,6 +218,17 @@ + +
+
+
+ πŸ“Š SLA & Skor Tim + Status service tiap tim (dicek otomatis tiap ~30 detik) + poin: +100/flag dicuri, +50 bonus SLA saat 6/6 UP. Auto-refresh 30 detik. +
+
Memuat…
+
+
+ @@ -297,6 +309,7 @@ function showView(v) { document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.view === v)); document.querySelectorAll('.view').forEach(x => x.classList.toggle('active', x.id === 'view-' + v)); if (v === 'topo') loadTopo(); // refresh attacks immediately on tab switch + every 10s + if (v === 'sla') loadSla(); } let topoTimer = null; function startTopoTimer() { @@ -305,6 +318,34 @@ function startTopoTimer() { }, 10000); } startTopoTimer(); +let slaTimer = null; +function startSlaTimer() { + if (!slaTimer) slaTimer = setInterval(() => { + if (document.getElementById('view-sla').classList.contains('active')) loadSla(); + }, 30000); +} +startSlaTimer(); + +async function loadSla() { + const box = document.getElementById('slaAdminList'); + if (!box) return; + box.innerHTML = 'Memuat…'; + try { + const d = await api('/api/scoreboard'); + const rows = (d.teams || []).map(t => { + const pct = Math.round(t.sla_pct || 0); + const color = pct === 100 ? '#2ecc71' : (pct >= 60 ? '#f1c40f' : '#e74c3c'); + const bar = ``; + return `
+ ${t.badge ? t.badge + ' ' : '#' + t.rank + ' '}${esc(t.label)} ${bar}${t.alive}/${t.total} (${pct}%) + βš‘ ${t.solves} flag Β· πŸ† ${t.points} pts +
`; + }).join(''); + box.innerHTML = rows || '
Belum ada tim.
'; + } catch (e) { + box.innerHTML = '
Gagal memuat SLA.
'; + } +} // ---------- Challenges ---------- async function refresh() { diff --git a/panel/static/team.html b/panel/static/team.html index e123a6a..4dde3bd 100644 --- a/panel/static/team.html +++ b/panel/static/team.html @@ -118,6 +118,7 @@ + @@ -194,6 +195,16 @@ +
+
+

πŸ“Š SLA & Skor Tim

+

+ πŸ’š SLA = service timmu hidup (dicek tiap ~30 detik) Β· πŸ† poin = dapat dari mencuri flag lawan (+100/flag) & bonus SLA saat seluruh service UP (+50). +

+
Memuat…
+
+
+

πŸ“– Panduan SSH & Attack

@@ -254,6 +265,7 @@ function showView(v) { if (v === 'targets') loadTargets(); if (v === 'activity') loadActivity(); if (v === 'leaderboard') loadLeaderboard(); + if (v === 'sla') loadSla(); } // ---------- auth ---------- @@ -425,6 +437,41 @@ async function loadActivity() { } } +// ---------- SLA & scoreboard (public endpoint) ---------- +async function loadSla() { + const box = document.getElementById('slaList'); + try { + const d = await api('/api/public/scoreboard'); + const teams = d.teams || []; + if (!teams.length) { + box.innerHTML = '
Belum ada data SLA.
'; + return; + } + box.innerHTML = teams.map(t => { + const isMe = t.team === TEAM_ID; + const pct = Math.round(t.sla_pct || 0); + const color = pct === 100 ? '#2ecc71' : (pct >= 60 ? '#f1c40f' : '#e74c3c'); + const bar = `
`; + return `
+ ${esc(t.badge || t.rank)} + ${esc(t.label)} ${isMe ? '(kamu)' : ''} + + ${t.alive}/${t.total} (${pct}%) +
βš‘ ${t.solves} flag Β· πŸ† ${t.points} pts +
+
${bar}`; + }).join(''); + } catch (e) { + box.innerHTML = '
Gagal memuat SLA.
'; + } +} + +// auto-refresh SLA while its tab is open (every 30s) +setInterval(async () => { + const s = document.getElementById('view-sla'); + if (s && s.classList.contains('active')) loadSla(); +}, 30000); + // ---------- leaderboard (public endpoint) ---------- async function loadLeaderboard() { const box = document.getElementById('lbList'); @@ -442,7 +489,7 @@ async function loadLeaderboard() { return `
${rank} ${esc(t.name)} ${isMe ? '(kamu)' : ''} - βš‘ ${t.solves} solve${t.solves > 1 ? 's' : ''} + βš‘ ${t.solves} solve${t.solves > 1 ? 's' : ''} Β· πŸ† ${t.points ?? 0} pts
`; }).join(''); const found = teams.some(t => t.team === TEAM_ID); diff --git a/panel/teams.py b/panel/teams.py index fc31491..b7448d2 100644 --- a/panel/teams.py +++ b/panel/teams.py @@ -46,6 +46,88 @@ CHALLENGES = [ ("warmup", 5, 27), ] +# Points system: base points earned by stealing a flag from another team's +# challenge. The SLA bonus is earned by keeping your OWN services alive. +POINTS_PER_FLAG = 100 +SLA_BONUS_POINTS = 50 +SLA_BONUS_MIN_ALIVE = 6 # bonus only when ALL 6 services are UP + +def _load_points() -> dict: + p = TEAMS_DIR / "points.json" + if p.exists(): + try: + return json.loads(p.read_text()) + except Exception: + pass + return {"teams": {}} + +def _save_points(data: dict): + (TEAMS_DIR / "points.json").write_text(json.dumps(data, indent=2)) + +def get_team_points(team_idx: int) -> int: + """Total attack points a team has earned (from flag steals).""" + data = _load_points() + return int(data.get("teams", {}).get(str(team_idx), {}).get("points", 0)) + +def add_attack_points(team_idx: int, points: int, chall: str = "", target: int = 0, + ts: float = None) -> dict: + """Award points to a team for stealing a flag. Returns updated tally.""" + data = _load_points() + tid = str(team_idx) + me = data["teams"].setdefault(tid, {"points": 0, "events": []}) + me["points"] = int(me.get("points", 0)) + points + me["events"].append({ + "type": "attack", + "challenge": chall, + "target": target, + "points": points, + "ts": ts if ts is not None else time.time(), + "ts_human": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + }) + me["events"] = me["events"][-200:] + _save_points(data) + return {"team": team_idx, "points": me["points"], "awarded": points} + +def add_sla_bonus(team_idx: int, alive: int, total: int = 6) -> dict: + """Award SLA bonus when all services are UP. Applies bonus at most once + per 5-minute window so online checks don't spam the ledger.""" + data = _load_points() + tid = str(team_idx) + me = data["teams"].setdefault(tid, {"points": 0, "events": []}) + now = time.time() + last = me.get("last_sla_bonus", 0) + if alive >= SLA_BONUS_MIN_ALIVE and total >= SLA_BONUS_MIN_ALIVE: + if now - last > 300: # 5 min window + me["points"] = int(me.get("points", 0)) + SLA_BONUS_POINTS + me["last_sla_bonus"] = now + me["events"].append({ + "type": "sla_bonus", + "alive": alive, + "total": total, + "points": SLA_BONUS_POINTS, + "ts": now, + "ts_human": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + }) + me["events"] = me["events"][-200:] + _save_points(data) + return {"team": team_idx, "points": me["points"], "awarded": SLA_BONUS_POINTS, "bonus": True} + return {"team": team_idx, "points": me["points"], "awarded": 0, "bonus": False} + +def team_rank(idx: int) -> int: + """1-based rank of team by total points.""" + data = _load_points()["teams"] + rows = [(int(t), int(v.get("points", 0))) for t, v in data.items() if int(t) > 0] + rows.sort(key=lambda x: -x[1]) + for i, (t, _) in enumerate(rows, 1): + if t == idx: + return i + return len(rows) + 1 # teams with 0 points rank after all scorers + +def team_badge(idx: int) -> str: + """Emoji badge for podium teams.""" + r = team_rank(idx) + return {1: "πŸ‘‘ Juara 1", 2: "πŸ₯ˆ Runner-up", 3: "πŸ₯‰ Peringkat 3"}.get(r, "") + PORT_BASE = 30000 STEP = 1000 @@ -446,7 +528,12 @@ def submit_flag(target_idx: int, chall: str, flag: str, and e.get("target") == target_idx for e in lb["solves"]): lb["solves"].append(entry) lb_path.write_text(json.dumps(lb, indent=2)) - return {"success": True, "team": attacker_idx, "target": target_idx, "challenge": chall} + # NEW: award attack points (first solve only) + pts = add_attack_points(attacker_idx, POINTS_PER_FLAG, chall=chall, target=target_idx) + else: + pts = {"team": attacker_idx, "points": get_team_points(attacker_idx), "awarded": 0} + return {"success": True, "team": attacker_idx, "target": target_idx, + "challenge": chall, "points": pts} def _log_attack(attacker_team: int, target_team: int, target_chall: str, flag_hint: str, success: bool): """Append a row to the attack log (used by the topology attack visualizer).""" @@ -469,9 +556,10 @@ def _log_attack(attacker_team: int, target_team: int, target_chall: str, flag_hi pass def reset_scores() -> dict: - """Wipe the leaderboard (all solves removed).""" + """Wipe the leaderboard (all solves removed) and the points ledger.""" lb_path = TEAMS_DIR / "leaderboard.json" lb_path.write_text(json.dumps({"solves": []}, indent=2)) + (TEAMS_DIR / "points.json").write_text(json.dumps({"teams": {}}, indent=2)) return {"ok": True, "cleared": True} def reset_environment() -> dict: @@ -512,6 +600,130 @@ def reset_environment() -> dict: return {"ok": True, "stopped": results, "teams_dir": str(TEAMS_DIR)} +# ---- SLA + scoring helpers ---- + +_SLA_CACHE = {"ts": 0, "data": None} + + +def _probe_one(recv_port: int, au: str, ap: str, name: str, st: dict) -> dict: + import urllib.request, urllib.error, base64 + url = f"http://127.0.0.1:{recv_port}/check/{name}" + ok = False + try: + req = urllib.request.Request(url) + token = base64.b64encode(f"{au}:{ap}".encode()).decode() + req.add_header("Authorization", f"Basic {token}") + with urllib.request.urlopen(req, timeout=25) as resp: + body = resp.read().decode() + import json as _j + ok = bool(_j.loads(body).get("success")) if resp.status == 200 else False + except Exception: + ok = False + return {"name": name, + "port": st["ports"][name]["chall"], + "ssh": st["ports"][name]["ssh"], + "alive": ok} + + +def probe_team_sla_fast(idx: int) -> dict: + """Probe one team's challenges. Sequential per challenge (receivers are + sync Flask; parallel probes overload them and cause false timeouts). + ~30-60s worst case for 6 challs; results are cached by the refresher.""" + td = TEAMS_DIR / f"team{idx}" + sf = td / "state.json" + if not sf.exists(): + return {"team": idx, "alive": 0, "total": 0, "per_challenge": [], "error": "no team"} + st = json.loads(sf.read_text()) + recv_port = st["ports"]["receiver"] + au, ap = st.get("admin_user", ""), st.get("admin_pass", "") + results = [] + for name, _, _ in CHALLENGES: + try: + results.append(_probe_one(recv_port, au, ap, name, st)) + except Exception: + results.append({"name": name, "port": 0, "ssh": 0, "alive": False}) + alive = sum(1 for r in results if r["alive"]) + return {"team": idx, "alive": alive, "total": len(results), + "per_challenge": results} + + +def _scoreboard_row(st: dict) -> dict: + """Build one scoreboard row for a team state (runs in a worker thread).""" + idx = st["index"] + sla = probe_team_sla_fast(idx) + pts = get_team_points(idx) + solves = 0 + lb_path = TEAMS_DIR / "leaderboard.json" + if lb_path.exists(): + try: + lb = json.loads(lb_path.read_text()) + solves = sum(1 for e in lb["solves"] if e["team"] == idx) + except Exception: + pass + return { + "team": idx, + "label": st.get("label") or f"Team {idx}", + "domain": st.get("domain") or "", + "alive": sla["alive"], + "total": sla["total"], + "sla_pct": round(100 * sla["alive"] / sla["total"], 1) if sla["total"] else 0, + "points": pts, + "solves": solves, + } + + +def sla_status_all() -> dict: + """Per-team SLA (own team view) + aggregate scoreboard with points. + + Reads a cache that a background thread keeps fresh (~every 30s), so the + HTTP endpoint is instant. First call (cold cache) blocks up to ~60s.""" + import time as _t + if _SLA_CACHE["data"] is not None and _t.time() - _SLA_CACHE["ts"] < 60: + return _SLA_CACHE["data"] + _build_scoreboard() + return _SLA_CACHE["data"] + + +def _build_scoreboard() -> dict: + """Build the scoreboard: probes teams 2-at-a-time (6 chall parallel per + team) to avoid overwhelming the receivers, then caches the result.""" + import time as _t + import concurrent.futures + states = [] + for d in sorted(TEAMS_DIR.glob("team*")): + sf = d / "state.json" + if sf.exists(): + states.append(json.loads(sf.read_text())) + teams = [] + # probe one team at a time (each team = 6 sequential chall checks) + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: + for row in ex.map(_scoreboard_row, states): + teams.append(row) + teams.sort(key=lambda x: (-x["points"], -x["solves"], x["team"])) + for i, t in enumerate(teams, 1): + t["rank"] = i + t["badge"] = {1: "πŸ‘‘ Juara 1", 2: "πŸ₯ˆ Runner-up", 3: "πŸ₯‰ Peringkat 3"}.get(i, "") + _SLA_CACHE["ts"] = _t.time() + _SLA_CACHE["data"] = {"teams": teams, "ts": _t.time()} + return _SLA_CACHE["data"] + + +def start_sla_refresher(): + """Background daemon thread: keeps the SLA scoreboard cache warm.""" + import threading + def _loop(): + import time as _t + while True: + try: + _build_scoreboard() + except Exception: + pass + _t.sleep(30) + t = threading.Thread(target=_loop, daemon=True, name="sla-refresher") + t.start() + return t + + if __name__ == "__main__": import sys cmd = sys.argv[1] if len(sys.argv) > 1 else "list"