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__
This commit is contained in:
root
2026-09-23 15:14:52 +08:00
parent 6eb0dabb58
commit 1ca963b2b7
13 changed files with 1037 additions and 186 deletions
+170 -1
View File
@@ -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:]}
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()]}