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
This commit is contained in:
+39
-3
@@ -25,6 +25,15 @@ BASE_DIR = Path(__file__).parent
|
|||||||
|
|
||||||
app = FastAPI(title="Gemastik A/D Panel")
|
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 = [
|
CHALLENGES = [
|
||||||
{"name": "blogpost", "port": 10000, "ssh": 10022, "category": "web", "desc": "Flask blog with exiftool + SSTI"},
|
{"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"},
|
{"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
|
ok = False
|
||||||
results.append({"name": name, "port": st["ports"][name]["chall"],
|
results.append({"name": name, "port": st["ports"][name]["chall"],
|
||||||
"ssh": st["ports"][name]["ssh"], "alive": ok})
|
"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")
|
@app.get("/api/team/{idx}/activity")
|
||||||
async def api_team_activity(idx: int, req: Request):
|
async def api_team_activity(idx: int, req: Request):
|
||||||
@@ -630,11 +646,31 @@ async def api_leaderboard(req: Request):
|
|||||||
teams = {}
|
teams = {}
|
||||||
for e in lb["solves"]:
|
for e in lb["solves"]:
|
||||||
name = team_name(e["team"])
|
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["name"] = name
|
||||||
t["solves"] += 1
|
t["solves"] += 1
|
||||||
t["challs"].append(e["challenge"])
|
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")
|
@app.get("/api/public/teams")
|
||||||
|
|||||||
@@ -134,6 +134,7 @@
|
|||||||
<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>
|
<button class="tab" data-view="targets" onclick="showView('targets'); loadTargetMatrix()">🎯 Target Matrix</button>
|
||||||
|
<button class="tab" data-view="sla" onclick="showView('sla'); loadSla()">📊 SLA & Skor</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Challenges view -->
|
<!-- Challenges view -->
|
||||||
@@ -217,6 +218,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- SLA & Scoreboard view -->
|
||||||
|
<div class="view" id="view-sla">
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||||
|
<b style="color:#5ad1ff">📊 SLA & Skor Tim</b>
|
||||||
|
<span style="font-size:11px;color:#718096">Status service tiap tim (dicek otomatis tiap ~30 detik) + poin: +100/flag dicuri, +50 bonus SLA saat 6/6 UP. Auto-refresh 30 detik.</span>
|
||||||
|
</div>
|
||||||
|
<div id="slaAdminList" 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 -->
|
||||||
@@ -297,6 +309,7 @@ function showView(v) {
|
|||||||
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.view === 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));
|
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 === 'topo') loadTopo(); // refresh attacks immediately on tab switch + every 10s
|
||||||
|
if (v === 'sla') loadSla();
|
||||||
}
|
}
|
||||||
let topoTimer = null;
|
let topoTimer = null;
|
||||||
function startTopoTimer() {
|
function startTopoTimer() {
|
||||||
@@ -305,6 +318,34 @@ function startTopoTimer() {
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
startTopoTimer();
|
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 = `<span style="display:inline-block;vertical-align:middle;width:120px;height:8px;background:#1e3a5f;border-radius:4px;margin:0 8px;overflow:hidden"><span style="display:block;height:100%;width:${pct}%;background:${color}"></span></span>`;
|
||||||
|
return `<div style="padding:8px 4px;border-bottom:1px solid #16233a">
|
||||||
|
<b>${t.badge ? t.badge + ' ' : '#' + t.rank + ' '}</b>${esc(t.label)} ${bar}<span style="color:${color}">${t.alive}/${t.total} (${pct}%)</span>
|
||||||
|
<span style="float:right;color:#5ad1ff">⚑ ${t.solves} flag · 🏆 ${t.points} pts</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
box.innerHTML = rows || '<div style="color:#718096">Belum ada tim.</div>';
|
||||||
|
} catch (e) {
|
||||||
|
box.innerHTML = '<div style="color:#e74c3c">Gagal memuat SLA.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Challenges ----------
|
// ---------- Challenges ----------
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
|
|||||||
+48
-1
@@ -118,6 +118,7 @@
|
|||||||
<button data-view="submit" onclick="showView('submit')">🚩 Submit Flag</button>
|
<button data-view="submit" onclick="showView('submit')">🚩 Submit Flag</button>
|
||||||
<button data-view="activity" onclick="showView('activity')">📡 Aktivitas</button>
|
<button data-view="activity" onclick="showView('activity')">📡 Aktivitas</button>
|
||||||
<button data-view="leaderboard" onclick="showView('leaderboard')">🏆 Leaderboard</button>
|
<button data-view="leaderboard" onclick="showView('leaderboard')">🏆 Leaderboard</button>
|
||||||
|
<button data-view="sla" onclick="showView('sla'); loadSla()">📊 SLA & Skor</button>
|
||||||
<button data-view="guide" onclick="showView('guide')">📖 Panduan SSH</button>
|
<button data-view="guide" onclick="showView('guide')">📖 Panduan SSH</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -194,6 +195,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="view" id="view-sla">
|
||||||
|
<div class="card">
|
||||||
|
<h2>📊 SLA & Skor Tim</h2>
|
||||||
|
<p style="font-size:13px;color:var(--dim);margin-bottom:12px">
|
||||||
|
💚 <b>SLA</b> = service timmu hidup (dicek tiap ~30 detik) · 🏆 <b>poin</b> = dapat dari mencuri flag lawan (+100/flag) & bonus SLA saat seluruh service UP (+50).
|
||||||
|
</p>
|
||||||
|
<div id="slaList"><div class="lb-empty">Memuat…</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="view" id="view-guide">
|
<div class="view" id="view-guide">
|
||||||
<div class="card guide">
|
<div class="card guide">
|
||||||
<h2>📖 Panduan SSH & Attack</h2>
|
<h2>📖 Panduan SSH & Attack</h2>
|
||||||
@@ -254,6 +265,7 @@ function showView(v) {
|
|||||||
if (v === 'targets') loadTargets();
|
if (v === 'targets') loadTargets();
|
||||||
if (v === 'activity') loadActivity();
|
if (v === 'activity') loadActivity();
|
||||||
if (v === 'leaderboard') loadLeaderboard();
|
if (v === 'leaderboard') loadLeaderboard();
|
||||||
|
if (v === 'sla') loadSla();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- auth ----------
|
// ---------- 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 = '<div class="lb-empty">Belum ada data SLA.</div>';
|
||||||
|
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 = `<div style="height:8px;background:#1e2a3a;border-radius:4px;margin:6px 0;overflow:hidden"><div style="height:100%;width:${pct}%;background:${color};transition:width .5s"></div></div>`;
|
||||||
|
return `<div class="lb-row ${isMe ? 'me' : ''}">
|
||||||
|
<span class="lb-rank" style="width:34px">${esc(t.badge || t.rank)}</span>
|
||||||
|
<span class="lb-name">${esc(t.label)} ${isMe ? '<small>(kamu)</small>' : ''}</span>
|
||||||
|
<span class="lb-solves" style="width:170px;text-align:right">
|
||||||
|
<b style="color:${color}">${t.alive}/${t.total} (${pct}%)</b>
|
||||||
|
<br><small style="color:var(--dim)">⚑ ${t.solves} flag · 🏆 ${t.points} pts</small>
|
||||||
|
</span>
|
||||||
|
</div>${bar}`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
box.innerHTML = '<div class="lb-empty">Gagal memuat SLA.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) ----------
|
// ---------- leaderboard (public endpoint) ----------
|
||||||
async function loadLeaderboard() {
|
async function loadLeaderboard() {
|
||||||
const box = document.getElementById('lbList');
|
const box = document.getElementById('lbList');
|
||||||
@@ -442,7 +489,7 @@ async function loadLeaderboard() {
|
|||||||
return `<div class="lb-row ${isMe ? 'me' : ''}">
|
return `<div class="lb-row ${isMe ? 'me' : ''}">
|
||||||
<span class="lb-rank">${rank}</span>
|
<span class="lb-rank">${rank}</span>
|
||||||
<span class="lb-name">${esc(t.name)} ${isMe ? '<small>(kamu)</small>' : ''}</span>
|
<span class="lb-name">${esc(t.name)} ${isMe ? '<small>(kamu)</small>' : ''}</span>
|
||||||
<span class="lb-solves">⚑ ${t.solves} solve${t.solves > 1 ? 's' : ''}</span>
|
<span class="lb-solves">⚑ ${t.solves} solve${t.solves > 1 ? 's' : ''} · 🏆 ${t.points ?? 0} pts</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
const found = teams.some(t => t.team === TEAM_ID);
|
const found = teams.some(t => t.team === TEAM_ID);
|
||||||
|
|||||||
+214
-2
@@ -46,6 +46,88 @@ CHALLENGES = [
|
|||||||
("warmup", 5, 27),
|
("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
|
PORT_BASE = 30000
|
||||||
STEP = 1000
|
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"]):
|
and e.get("target") == target_idx for e in lb["solves"]):
|
||||||
lb["solves"].append(entry)
|
lb["solves"].append(entry)
|
||||||
lb_path.write_text(json.dumps(lb, indent=2))
|
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):
|
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)."""
|
"""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
|
pass
|
||||||
|
|
||||||
def reset_scores() -> dict:
|
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 = TEAMS_DIR / "leaderboard.json"
|
||||||
lb_path.write_text(json.dumps({"solves": []}, indent=2))
|
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}
|
return {"ok": True, "cleared": True}
|
||||||
|
|
||||||
def reset_environment() -> dict:
|
def reset_environment() -> dict:
|
||||||
@@ -512,6 +600,130 @@ def reset_environment() -> dict:
|
|||||||
return {"ok": True, "stopped": results, "teams_dir": str(TEAMS_DIR)}
|
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__":
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user