feat: team portal with own login, SSH web terminal, targets & guide

- Portal tim punya login sendiri (password = ssh_pass, session team_token)
- SSH Web GUI: /api/team/{idx}/ssh/ws (WebSocket+paramiko) → xterm.js terminal
  (fix: ssh_to_ws non-blocking poll, chall_passwords per-container auth)
- Root <slug>.gemastik.imrnes.team → portal tim (bukan login admin)
- Host validation: /team/{idx} & /team/{idx}/guide 403 kalau host != team domain
- /api/team/{idx}/targets: daftar service tim musuh (attack target)
- guide.html: panduan SSH/attack/defense untuk peserta
- fix esc() String(s) (bug: (s||'').replace is not a function saat port number)
- set_ssh_passwords retry loop (container boot race)
This commit is contained in:
root
2026-09-23 16:37:58 +08:00
parent 4a65f24af3
commit 43ed3df557
5 changed files with 567 additions and 69 deletions
+199 -8
View File
@@ -7,9 +7,10 @@ admin credentials stay out of the browser.
import os
import json
import time
import asyncio
import httpx
from pathlib import Path
from fastapi import FastAPI, Request, HTTPException
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from typing import Optional
@@ -79,6 +80,16 @@ async def _proxy(method: str, path: str, body: dict = None):
@app.get("/", response_class=HTMLResponse)
async def index(req: Request):
host = (req.headers.get("host") or "").split(":")[0]
# Team portal domains: <slug>.gemastik.imrnes.team -> their team portal (no admin login)
if host.endswith(".gemastik.imrnes.team") and host != "gemastik.imrnes.team" and host != "panel.gemastik.imrnes.team":
slug = host.split(".")[0]
for t in orch.list_teams():
if t.get("slug") == slug:
html = (BASE_DIR / "static" / "team.html").read_text()
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{t["index"]}"')
return HTMLResponse(html)
return HTMLResponse("<h2 style='font-family:sans-serif;color:#888;padding:40px'>Team tidak ditemukan: " + slug + "</h2>", status_code=404)
if not _authorized(req):
return RedirectResponse("/login")
html = (BASE_DIR / "static" / "index.html").read_text()
@@ -100,20 +111,109 @@ async def submit_page(req: Request):
@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>)."""
"""Public portal page for a team. Must be accessed via that team's own domain."""
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())
host = (req.headers.get("host") or "").split(":")[0]
# host check: allow panel domain (uses explicit /team/N link for admin preview)
# and the team's own <slug>.domain; block cross-team access.
if not (host == f"panel.gemastik.imrnes.team" or host == f"gemastik.imrnes.team"
or host == st.get("domain") or host.startswith("127.0.0.1") or host.startswith("localhost")):
raise HTTPException(403, "Akses team lain tidak diizinkan")
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)."""
@app.get("/team/{idx}/guide", response_class=HTMLResponse)
async def team_guide(idx: int, req: Request):
"""Public SSH/attack guide for a team. Must be accessed via that team's own domain."""
td = orch.TEAMS_DIR / f"team{idx}"
st = json.loads((td / "state.json").read_text()) if (td / "state.json").exists() else {}
host = (req.headers.get("host") or "").split(":")[0]
if not (host == f"panel.gemastik.imrnes.team" or host == f"gemastik.imrnes.team"
or host == st.get("domain") or host.startswith("127.0.0.1") or host.startswith("localhost")):
raise HTTPException(403, "Akses team lain tidak diizinkan")
html = (BASE_DIR / "static" / "guide.html").read_text()
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
return HTMLResponse(html)
# ---- Team portal login (separate from admin; password = team ssh_pass) ----
_team_sessions: dict[str, tuple[int, float]] = {} # token -> (idx, expiry)
@app.post("/api/team/{idx}/login")
async def api_team_login(idx: int, req: Request):
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())
data = await req.json()
pw = data.get("pass", "")
if pw != st.get("ssh_pass"):
raise HTTPException(401, "Password salah")
token = os.urandom(16).hex()
_team_sessions[token] = (idx, time.time() + 12 * 3600)
resp = JSONResponse({"ok": True, "team": idx})
resp.set_cookie("team_token", token, httponly=True, samesite="lax", max_age=12 * 3600)
return resp
@app.post("/api/team/logout")
async def api_team_logout(req: Request):
token = req.cookies.get("team_token")
if token:
_team_sessions.pop(token, None)
return {"ok": True}
def _team_authorized(req: Request, idx: int) -> bool:
token = req.cookies.get("team_token")
if not token:
return False
e = _team_sessions.get(token)
if not e or e[0] != idx or e[1] < time.time():
_team_sessions.pop(token, None)
return False
return True
@app.get("/api/team/{idx}/session")
async def api_team_session(idx: int, req: Request):
"""True when this browser has a valid team session for idx."""
return {"authed": _team_authorized(req, idx)}
@app.get("/api/team/{idx}/targets")
async def api_team_targets(idx: int, req: Request):
"""Public: list of enemy teams' services (attack targets) for this team's portal."""
out = []
for d in sorted(orch.TEAMS_DIR.glob("team*")):
if not (d / "state.json").exists():
continue
st = json.loads((d / "state.json").read_text())
if st.get("index") == idx:
continue # skip self
for name, coff, soff in orch.CHALLENGES:
p = st["ports"].get(name)
if not p:
continue
out.append({
"team": st.get("label", f"Team {st.get('index')}"),
"team_idx": st.get("index"),
"domain": st.get("domain"),
"challenge": name,
"port": p["chall"],
"ssh": p["ssh"],
})
return {"targets": out}
@app.get("/api/team/{idx}/info")
async def api_team_info(idx: int, req: Request):
"""Team portal info — requires team login; no admin secrets."""
if not _team_authorized(req, idx):
raise HTTPException(401, "Login portal team dulu")
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"),
@@ -122,7 +222,7 @@ async def api_team_info(idx: int):
"status": st.get("status"),
"ports": st.get("ports"),
"ssh_user": st.get("ssh_user"),
"ssh_pass": st.get("ssh_pass"),
"ssh_pass": st.get("ssh_pass"), # same password used to login portal + SSH
}}
@@ -416,4 +516,95 @@ async def api_leaderboard(req: Request):
@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()]}
return {"teams": [{"index": t["index"], "label": t.get("label", f"Team {t['index']}")} for t in orch.list_teams()]}
# ============ WebSocket SSH terminal (team portal) ============
import paramiko
import websockets
@app.websocket("/api/team/{idx}/ssh/ws")
async def team_ssh_ws(ws: WebSocket, idx: int):
chall = ws.query_params.get("chall", "")
# auth: team session cookie must match this team
token = ws.cookies.get("team_token")
e = _team_sessions.get(token or "")
if not e or e[0] != idx or e[1] < time.time():
await ws.close(code=4001, reason="unauthorized")
return
td = orch.TEAMS_DIR / f"team{idx}"
st = json.loads((td / "state.json").read_text())
if chall not in st.get("ports", {}):
await ws.close(code=4002, reason="unknown challenge")
return
recv_port = st["ports"][chall]["ssh"]
user = st.get("ssh_user", "ctfuser")
# each challenge container has its own password (chall_passwords);
# ssh_pass is the portal login password (may differ).
pw = st.get("chall_passwords", {}).get(chall) or st.get("ssh_pass", "")
await ws.accept()
chan = client = None
try:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None, lambda: client.connect("127.0.0.1", port=recv_port, username=user,
password=pw, timeout=10, allow_agent=False,
look_for_keys=False))
chan = client.invoke_shell(term="xterm-256color", width=120, height=32)
async def ws_to_ssh():
while True:
try:
msg = await ws.receive_text()
except Exception:
break
if msg.startswith("__resize__"):
try:
_, cols, rows = msg.split(":", 2)
chan.resize_pty(int(cols), int(rows))
except Exception:
pass
else:
try:
chan.send(msg)
except Exception:
break
async def ssh_to_ws():
# non-blocking poll: chan.recv() di dalam async task akan
# memblokir seluruh event loop (deadlock) — jadi poll recv_ready.
while True:
try:
if chan.recv_ready():
data = chan.recv(4096)
if not data:
break
await ws.send_text(data.decode("utf-8", "replace"))
elif chan.closed:
break
except Exception:
break
await asyncio.sleep(0.03)
t1 = asyncio.create_task(ws_to_ssh())
t2 = asyncio.create_task(ssh_to_ws())
done, pending = await asyncio.wait({t1, t2}, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
except Exception as e:
try:
await ws.send_text(f"\r\n[ssh error] {e}\r\n")
except Exception:
pass
finally:
try:
if chan: chan.close()
except Exception: pass
try:
if client: client.close()
except Exception: pass
try:
await ws.close()
except Exception: pass