feat: team-specific subdomains + portal tim

- create_team now takes label -> slug -> <slug>.gemastik.imrnes.team
- ensure_team_domains() writes Traefik dynamic config (gemastik-teams.yaml)
- team portal at /team/<idx> (public, shows chall ports, SSH, submit form)
- /api/team/<idx>/info + /api/team/<idx>/status (server-side receiver auth)
- UI: name inputs per team (set count -> labels), domain link in card
- delete team endpoint drops its domain
This commit is contained in:
root
2026-09-23 16:18:11 +08:00
parent 265159e9d8
commit 5e44a70049
4 changed files with 271 additions and 5 deletions
+58 -1
View File
@@ -95,6 +95,60 @@ async def submit_page(req: Request):
"""Public flag submission page for teams (no login)."""
return HTMLResponse((BASE_DIR / "static" / "submit.html").read_text())
# ============ Per-team portal (public via <slug>.gemastik.imrnes.team) ============
@app.get("/team/{idx}", response_class=HTMLResponse)
async def team_portal(idx: int, req: Request):
"""Public portal page for a team (served at <slug>.gemastik.imrnes.team/team/<idx>)."""
html = (BASE_DIR / "static" / "team.html").read_text()
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
return HTMLResponse(html)
@app.get("/api/team/{idx}/info")
async def api_team_info(idx: int):
"""Public: basic team info for the portal (no secrets besides per-team SSH creds)."""
td = orch.TEAMS_DIR / f"team{idx}"
if not (td / "state.json").exists():
raise HTTPException(404, "Team not found")
st = json.loads((td / "state.json").read_text())
# expose only what a team needs: label, domain, ports, ssh creds, status
return {"team": {
"index": st.get("index"),
"label": st.get("label"),
"slug": st.get("slug"),
"domain": st.get("domain"),
"status": st.get("status"),
"ports": st.get("ports"),
"ssh_user": st.get("ssh_user"),
"ssh_pass": st.get("ssh_pass"),
}}
@app.get("/api/team/{idx}/status")
async def api_team_status(idx: int):
"""Public: SLA status for a team's challenges (no auth — read-only health)."""
td = orch.TEAMS_DIR / f"team{idx}"
if not (td / "state.json").exists():
raise HTTPException(404, "Team not found")
st = json.loads ((td / "state.json").read_text())
recv_port = st["ports"]["receiver"]
# use team's receiver admin creds (server-side only; never sent to browser)
au, ap = st.get("admin_user", ""), st.get("admin_pass", "")
results = []
for name, coff, soff in orch.CHALLENGES:
try:
async with httpx.AsyncClient(timeout=8) as client:
resp = await client.get(f"http://127.0.0.1:{recv_port}/check/{name}",
auth=(au, ap))
ok = bool(resp.json().get("success")) if resp.status_code == 200 else False
except Exception:
ok = False
results.append({"name": name, "port": st["ports"][name]["chall"],
"ssh": st["ports"][name]["ssh"], "alive": ok})
return {"results": results}
@app.post("/api/login")
async def api_login(req: Request):
data = await req.json()
@@ -214,12 +268,15 @@ async def api_teams_set(req: Request):
n = int(data.get("count", 0))
if n < 0 or n > 50:
raise HTTPException(400, "Team count must be 0-50")
labels = data.get("labels") or {} # { "1": "Tim Satu", ... }
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"))
label = labels.get(str(i)) or labels.get(i) or f"Tim {i}"
st = orch.create_team(i, label)
created.append(st["index"])
orch.ensure_team_domains()
return {"created": created, "total": len(orch.list_teams())}
@app.post("/api/teams/start")