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:
+199
-8
@@ -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
|
||||
Reference in New Issue
Block a user