From 5e44a7004944c3d89112d26ce2c4b7e4ddcbe506 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 23 Sep 2026 16:18:11 +0800 Subject: [PATCH] feat: team-specific subdomains + portal tim - create_team now takes label -> slug -> .gemastik.imrnes.team - ensure_team_domains() writes Traefik dynamic config (gemastik-teams.yaml) - team portal at /team/ (public, shows chall ports, SSH, submit form) - /api/team//info + /api/team//status (server-side receiver auth) - UI: name inputs per team (set count -> labels), domain link in card - delete team endpoint drops its domain --- panel/main.py | 59 +++++++++++++++++- panel/static/index.html | 28 +++++++-- panel/static/team.html | 129 ++++++++++++++++++++++++++++++++++++++++ panel/teams.py | 60 +++++++++++++++++++ 4 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 panel/static/team.html diff --git a/panel/main.py b/panel/main.py index 0798dc4..dcaedcf 100644 --- a/panel/main.py +++ b/panel/main.py @@ -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 .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 .gemastik.imrnes.team/team/).""" + 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") diff --git a/panel/static/index.html b/panel/static/index.html index f6d7684..3124cd5 100644 --- a/panel/static/index.html +++ b/panel/static/index.html @@ -132,12 +132,13 @@
-
Jumlah Team (auto-create node per team)
+
Jumlah Team (auto-create node + domain per team)
- +
+
@@ -414,6 +415,7 @@ async function loadTeams() { let html = ''; for (const t of d.teams) { const alive = t.status === 'running'; + const dom = t.domain || ''; html += `
${esc(t.label || ('Team ' + t.index))} @@ -423,7 +425,7 @@ async function loadTeams() { Receiver${t.ports.receiver} Admin${esc(t.admin_user)} SSH userctfuser - Status${esc(t.status)} + Status${esc(t.status)}${dom ? `Domain${esc(dom)} →` : ''}
@@ -440,13 +442,31 @@ async function loadTeams() { async function setTeams() { const n = parseInt(document.getElementById('teamCount').value || '0', 10); + const labels = {}; + for (let i = 1; i <= n; i++) { + const inp = document.getElementById('teamNameInputs')?.querySelector(`input[data-idx="${i}"]`); + if (inp && inp.value.trim()) labels[i] = inp.value.trim(); + } try { - const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n})}); + const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n, labels})}); toast(`Team dibuat: ${d.created.join(', ') || 'tidak ada yang baru'} · total ${d.total}`, false); loadTeams(); } catch (e) { toast(e.message, true); } } +function teamCountChanged() { + const n = parseInt(document.getElementById('teamCount').value || '0', 10); + const box = document.getElementById('teamNameInputs'); + let html = ''; + for (let i = 1; i <= n; i++) { + html += `
+ Team ${i} + +
`; + } + box.innerHTML = html; +} + async function startTeam(idx) { try { await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} start (build bisa makan waktu)`, false); setTimeout(loadTeams, 3000); } catch (e) { toast(e.message, true); } diff --git a/panel/static/team.html b/panel/static/team.html new file mode 100644 index 0000000..dd0d751 --- /dev/null +++ b/panel/static/team.html @@ -0,0 +1,129 @@ + + + + + +Portal Team — Gemastik A/D + + + +
+
+ +
+

Portal Tim

+
+
+ … +
+ +
+

🌐 Akses Server Tim Kamu

+
Memuat…
+
+ Tiap challenge punya port sendiri. SSH login: ctfuser + password dari panel panitia. +
+
+ +
+

📊 Status Challenge

+ + + +
ChallengePortSSHStatus
+
+ +
+

🚩 Submit Flag

+ + + + + +
+
+
+ + + + \ No newline at end of file diff --git a/panel/teams.py b/panel/teams.py index 6f138f6..9836f1a 100644 --- a/panel/teams.py +++ b/panel/teams.py @@ -61,14 +61,23 @@ def team_ports(idx: int) -> dict: def gen_password(n=16): return uuid.uuid4().hex[:n] +def slugify(s: str) -> str: + """'Tim Satu Beta!' -> 'tim-satu-beta'""" + s = re.sub(r"[^a-zA-Z0-9\s-]", "", s.lower()).strip() + s = re.sub(r"[\s_]+", "-", s) + return s or "team" + def create_team(idx: int, label: str = None): """Build a full team stack dir with unique ports/passwords.""" ports = team_ports(idx) team_dir = TEAMS_DIR / f"team{idx}" label = label or f"Tim {idx}" + slug = slugify(label) state = { "index": idx, "label": label, + "slug": slug, + "domain": f"{slug}.gemastik.imrnes.team", "ports": ports, "admin_user": f"admin_team{idx}", "admin_pass": gen_password(20), @@ -267,6 +276,57 @@ def team_logs(idx: int, service: str = None, tail: int = 100): data[s] = f"ERR: {e}" return data +def ensure_team_domains() -> str: + """Write Traefik dynamic config for each team domain -> panel (:18081). + + Each team gets .gemastik.imrnes.team. The panel routes + /team/ to that team's portal page (public, no panitia login), + and /api/team//* serves team-scoped API. Writing this into the + same dynamic dir Traefik watches means domains appear automatically. + Returns a status string for logging. + """ + traefik_dir = Path("/data/coolify/proxy/dynamic") + traefik_dir.mkdir(parents=True, exist_ok=True) + teams = list_teams() + out = [] + changed = False + for t in teams: + slug = t.get("slug") or slugify(t.get("label", "")) + if not slug: + continue + # backfill slug/domain for teams created before this feature + if not t.get("slug"): + td = TEAMS_DIR / f"team{t['index']}" + st = json.loads((td / "state.json").read_text()) + st["slug"] = slug + st["domain"] = f"{slug}.gemastik.imrnes.team" + (td / "state.json").write_text(json.dumps(st, indent=2)) + changed = True + host = f"{slug}.gemastik.imrnes.team" + out.append(f""" team-{slug}-http: + rule: Host(`{host}`) + entryPoints: + - http + service: gemastik-panel-service + middlewares: + - redirect-to-https + team-{slug}-https: + rule: Host(`{host}`) + entryPoints: + - https + service: gemastik-panel-service + tls: + certResolver: letsencrypt + domains: + - main: {host} +""") + if not out: + return "no teams to route" + # Keep the team domain block in its own file so the main gemastik.yaml stays untouched + (traefik_dir / "gemastik-teams.yaml").write_text("http:\n routers:\n" + "".join(out)) + return f"wrote {len(out)} team domain(s) in gemastik-teams.yaml" + + def list_teams() -> list: out = [] if not TEAMS_DIR.exists():