Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c35b23a37f | ||
|
|
3ef905b3bb | ||
|
|
877f14ecf3 | ||
|
|
029b0f809a | ||
|
|
24dbf0a662 | ||
|
|
44dc1ac852 | ||
|
|
b66530e7fd | ||
|
|
72382c43d0 | ||
|
|
01ffc05a11 | ||
|
|
43ed3df557 |
No files matched your search
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate + (re)start per-team receiver systemd services.
|
||||
|
||||
Each team receiver becomes gemastik-receiver-teamN.service, independent of
|
||||
the panel service. This fixes the bug where restarting gemastik-panel killed
|
||||
all team receivers (they were child processes of the panel systemd cgroup).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TEAMS_DIR = Path("/opt/gemastik18-final/teams")
|
||||
RECEIVER_VENV = "/opt/gemastik18-final/receiver/.venv/bin/python"
|
||||
UNIT_DIR = Path("/etc/systemd/system")
|
||||
|
||||
def write_unit(idx: int, st: dict):
|
||||
port = st["ports"]["receiver"]
|
||||
recv_dir = TEAMS_DIR / f"team{idx}" / "receiver"
|
||||
env = {}
|
||||
# ports/containers for challenge classes
|
||||
for ch in st["ports"]:
|
||||
if ch in ("receiver", "panel"):
|
||||
continue
|
||||
env[f"CHALLENGE_PORT_{ch.upper()}"] = str(st["ports"][ch]["chall"])
|
||||
env[f"CHALLENGE_CONTAINER_{ch.upper()}"] = f"{ch}_container_team{idx}"
|
||||
env["COMPOSE_LOCATION"] = str(TEAMS_DIR / f"team{idx}" / "services" / "docker-compose.yml")
|
||||
env_lines = "\n".join(f'Environment="{k}={v}"' for k, v in env.items())
|
||||
unit = f"""[Unit]
|
||||
Description=Gemastik A/D receiver for team {idx}
|
||||
After=docker.service
|
||||
Wants=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory={recv_dir}
|
||||
EnvironmentFile={recv_dir}/.env
|
||||
{env_lines}
|
||||
ExecStart={RECEIVER_VENV} -m uvicorn main:app --host 0.0.0.0 --port {port}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
(UNIT_DIR / f"gemastik-receiver-team{idx}.service").write_text(unit)
|
||||
|
||||
def main():
|
||||
action = sys.argv[1] if len(sys.argv) > 1 else "start"
|
||||
for d in sorted(TEAMS_DIR.glob("team*")):
|
||||
sf = d / "state.json"
|
||||
if not sf.exists():
|
||||
continue
|
||||
st = json.loads(sf.read_text())
|
||||
idx = st["index"]
|
||||
write_unit(idx, st)
|
||||
subprocess.run(["systemctl", "daemon-reload"], check=False)
|
||||
subprocess.run(["systemctl", "enable", f"gemastik-receiver-team{idx}.service"], check=False)
|
||||
if action == "stop":
|
||||
subprocess.run(["systemctl", "stop", f"gemastik-receiver-team{idx}.service"], check=False)
|
||||
else:
|
||||
subprocess.run(["systemctl", "restart", f"gemastik-receiver-team{idx}.service"], check=False)
|
||||
print(f"gemastik-receiver-team{idx}: {action} (port {st['ports']['receiver']})")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+403
-24
@@ -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
|
||||
@@ -24,6 +25,15 @@ BASE_DIR = Path(__file__).parent
|
||||
|
||||
app = FastAPI(title="Gemastik A/D Panel")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _start_background():
|
||||
"""Warm the SLA scoreboard cache in the background."""
|
||||
import threading
|
||||
threading.Thread(target=orch._build_scoreboard, daemon=True,
|
||||
name="sla-warmup").start()
|
||||
orch.start_sla_refresher()
|
||||
|
||||
CHALLENGES = [
|
||||
{"name": "blogpost", "port": 10000, "ssh": 10022, "category": "web", "desc": "Flask blog with exiftool + SSTI"},
|
||||
{"name": "carbeat", "port": 11000, "ssh": 11022, "category": "pwn", "desc": "Binary exploitation menu"},
|
||||
@@ -79,6 +89,17 @@ 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"]}"')
|
||||
html = html.replace('href="/team/0/guide"', f'href="/team/{t["index"]}/guide"')
|
||||
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 +121,135 @@ 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>)."""
|
||||
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)."""
|
||||
"""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())
|
||||
# expose only what a team needs: label, domain, ports, ssh creds, status
|
||||
if not _check_team_host(req, st):
|
||||
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
||||
if _team_authorized(req, idx): # logged in -> show portal directly
|
||||
html = (BASE_DIR / "static" / "team.html").read_text()
|
||||
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
|
||||
# server-side: fix guide link so non-JS / pre-JS clicks never hit /team/0
|
||||
html = html.replace('href="/team/0/guide"', f'href="/team/{idx}/guide"')
|
||||
return HTMLResponse(html)
|
||||
# not logged in -> show a stripped landing/login page (no admin info)
|
||||
html = (BASE_DIR / "static" / "team.html").read_text()
|
||||
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
|
||||
html = html.replace('href="/team/0/guide"', f'href="/team/{idx}/guide"')
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@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}"
|
||||
if not (td / "state.json").exists():
|
||||
raise HTTPException(404, "Team not found")
|
||||
st = json.loads((td / "state.json").read_text())
|
||||
if not _check_team_host(req, st):
|
||||
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())
|
||||
if not _check_team_host(req, st):
|
||||
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
||||
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
|
||||
|
||||
def _check_team_host(req: Request, st: dict) -> bool:
|
||||
"""IDOR guard: host must be this team's own domain (or localhost).
|
||||
panel.gemastik / gemastik.imrnes.team only allowed with a valid ADMIN session."""
|
||||
host = (req.headers.get("host") or "").split(":")[0]
|
||||
if host == st.get("domain"):
|
||||
return True
|
||||
if host.startswith("127.0.0.1") or host.startswith("localhost"):
|
||||
return True
|
||||
if host in ("panel.gemastik.imrnes.team", "gemastik.imrnes.team"):
|
||||
return _authorized(req) # admin preview only
|
||||
return False
|
||||
|
||||
@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):
|
||||
"""Team targets — requires team login + own host. Only domain + port."""
|
||||
td = orch.TEAMS_DIR / f"team{idx}"
|
||||
if not (td / "state.json").exists():
|
||||
raise HTTPException(404, "Team not found")
|
||||
st_self = json.loads((td / "state.json").read_text())
|
||||
if not _check_team_host(req, st_self):
|
||||
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
||||
if not _team_authorized(req, idx):
|
||||
raise HTTPException(401, "Login portal team dulu")
|
||||
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_idx": st.get("index"),
|
||||
"team_label": st.get("label", f"Team {st.get('index')}"),
|
||||
"domain": st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".gemastik.imrnes.team"),
|
||||
"port": p["chall"],
|
||||
})
|
||||
return {"targets": out}
|
||||
|
||||
@app.get("/api/team/{idx}/info")
|
||||
async def api_team_info(idx: int, req: Request):
|
||||
"""Team portal info — requires team login + own host; no admin secrets."""
|
||||
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())
|
||||
if not _check_team_host(req, st):
|
||||
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
||||
if not _team_authorized(req, idx):
|
||||
raise HTTPException(401, "Login portal team dulu")
|
||||
return {"team": {
|
||||
"index": st.get("index"),
|
||||
"label": st.get("label"),
|
||||
@@ -122,17 +258,19 @@ 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
|
||||
}}
|
||||
|
||||
|
||||
@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)."""
|
||||
async def api_team_status(idx: int, req: Request):
|
||||
"""SLA status for a team's challenges (read-only health, own-host only)."""
|
||||
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())
|
||||
if not _check_team_host(req, st):
|
||||
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
||||
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", "")
|
||||
@@ -147,7 +285,50 @@ async def api_team_status(idx: int):
|
||||
ok = False
|
||||
results.append({"name": name, "port": st["ports"][name]["chall"],
|
||||
"ssh": st["ports"][name]["ssh"], "alive": ok})
|
||||
return {"results": results}
|
||||
# award SLA bonus when all services are UP (throttled, see add_sla_bonus)
|
||||
alive = sum(1 for r in results if r["alive"])
|
||||
bonus = orch.add_sla_bonus(idx, alive, len(results))
|
||||
return {"results": results, "sla": {"alive": alive, "total": len(results),
|
||||
"pct": round(100 * alive / max(len(results), 1), 1),
|
||||
"points": orch.get_team_points(idx),
|
||||
"rank": orch.team_rank(idx), "badge": orch.team_badge(idx),
|
||||
"bonus": bonus}}
|
||||
|
||||
@app.get("/api/team/{idx}/activity")
|
||||
async def api_team_activity(idx: int, req: Request):
|
||||
"""Team-scoped activity feed: attack events where this team is attacker or
|
||||
target (i.e. "ada serangan ke service kita" / "kita menyerang"), plus a
|
||||
snapshot of own service health. Requires team login + own host."""
|
||||
td = orch.TEAMS_DIR / f"team{idx}"
|
||||
if not (td / "state.json").exists():
|
||||
raise HTTPException(404, "Team not found")
|
||||
st_self = json.loads((td / "state.json").read_text())
|
||||
if not _check_team_host(req, st_self):
|
||||
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
||||
if not _team_authorized(req, idx):
|
||||
raise HTTPException(401, "Login portal team dulu")
|
||||
|
||||
# live label lookup (rename team = activity updates everywhere)
|
||||
def label(i):
|
||||
try:
|
||||
s = json.loads((orch.TEAMS_DIR / f"team{i}" / "state.json").read_text())
|
||||
return s.get("label") or f"Team {i}"
|
||||
except Exception:
|
||||
return f"Team {i}"
|
||||
|
||||
ap = orch.TEAMS_DIR / "attacks.json"
|
||||
try:
|
||||
events = json.loads(ap.read_text()).get("events", []) if ap.exists() else []
|
||||
except Exception:
|
||||
events = []
|
||||
mine = []
|
||||
for e in reversed(events): # newest first
|
||||
if e.get("attacker") == idx or e.get("target") == idx:
|
||||
e = dict(e)
|
||||
e["attacker_label"] = label(e.get("attacker"))
|
||||
e["target_label"] = label(e.get("target"))
|
||||
mine.append(e)
|
||||
return {"events": mine[:100], "team_label": st_self.get("label") or f"Team {idx}"}
|
||||
|
||||
@app.post("/api/login")
|
||||
async def api_login(req: Request):
|
||||
@@ -262,23 +443,46 @@ async def api_teams(req: Request):
|
||||
|
||||
@app.post("/api/teams/set")
|
||||
async def api_teams_set(req: Request):
|
||||
"""Set/create N teams (idempotent: creates missing, keeps existing)."""
|
||||
"""Set/create N teams (idempotent: creates missing, keeps existing).
|
||||
|
||||
body: {count, labels: {"1": "Tim Satu"}, domains: {"1": "tim-satu"}}"""
|
||||
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")
|
||||
labels = data.get("labels") or {} # { "1": "Tim Satu", ... }
|
||||
domains = data.get("domains") or {} # { "1": "mycustom", ... }
|
||||
created = []
|
||||
for i in range(1, n + 1):
|
||||
td = orch.TEAMS_DIR / f"team{i}"
|
||||
if not td.exists():
|
||||
label = labels.get(str(i)) or labels.get(i) or f"Tim {i}"
|
||||
st = orch.create_team(i, label)
|
||||
dom = domains.get(str(i)) or domains.get(i) or None
|
||||
st = orch.create_team(i, label, domain=dom)
|
||||
created.append(st["index"])
|
||||
else:
|
||||
# team exists: apply any label/domain overrides
|
||||
label = labels.get(str(i)) or labels.get(i)
|
||||
dom = domains.get(str(i)) or domains.get(i)
|
||||
if label or dom:
|
||||
orch.update_team(i, label=label, domain=dom)
|
||||
orch.ensure_team_domains()
|
||||
return {"created": created, "total": len(orch.list_teams())}
|
||||
|
||||
@app.put("/api/teams/{idx}")
|
||||
async def api_team_update(idx: int, req: Request):
|
||||
"""Update a team's custom name and/or domain (applies live)."""
|
||||
require_login(req)
|
||||
if not (orch.TEAMS_DIR / f"team{idx}" / "state.json").exists():
|
||||
raise HTTPException(404, "Team not found")
|
||||
data = await req.json()
|
||||
try:
|
||||
st = orch.update_team(idx, label=data.get("label"), domain=data.get("domain"))
|
||||
return {"ok": True, "team": st}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
@app.post("/api/teams/start")
|
||||
async def api_teams_start(req: Request):
|
||||
require_login(req)
|
||||
@@ -379,11 +583,26 @@ async def api_randomize(idx: int, req: Request):
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/api/reset/scores")
|
||||
async def api_reset_scores(req: Request):
|
||||
"""Admin: wipe leaderboard (all solves)."""
|
||||
require_login(req)
|
||||
return orch.reset_scores()
|
||||
|
||||
@app.post("/api/reset/environment")
|
||||
async def api_reset_environment(req: Request):
|
||||
"""Admin: full environment reset (stop all, remove teams/containers/receivers)."""
|
||||
require_login(req)
|
||||
return orch.reset_environment()
|
||||
|
||||
|
||||
@app.post("/api/flag/submit")
|
||||
async def api_flag_submit(req: Request):
|
||||
"""Public endpoint: teams submit flags. No login needed."""
|
||||
"""Public endpoint: teams submit flags. No login needed.
|
||||
body: {team: attacker, target?: victim, challenge, flag}"""
|
||||
data = await req.json()
|
||||
team = int(data.get("team", 0))
|
||||
team = int(data.get("team", 0)) # attacker (tim yang submit)
|
||||
target = int(data.get("target") or 0) or team # victim team (flag dicuri dari sini)
|
||||
chall = data.get("challenge", "")
|
||||
flag = data.get("flag", "").strip()
|
||||
team_name = data.get("team_name", "").strip()[:64] or f"Team {team}"
|
||||
@@ -393,27 +612,187 @@ async def api_flag_submit(req: Request):
|
||||
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)
|
||||
return orch.submit_flag(target, chall, flag, attacker_idx=team, attacker_name=team_name)
|
||||
|
||||
@app.get("/api/attacks")
|
||||
async def api_attacks(req: Request):
|
||||
"""Admin: recent attack events (attacker -> target) for the topology visualizer."""
|
||||
require_login(req)
|
||||
ap = orch.TEAMS_DIR / "attacks.json"
|
||||
if ap.exists():
|
||||
log = json.loads(ap.read_text())
|
||||
else:
|
||||
log = {"events": []}
|
||||
return {"events": log.get("events", [])}
|
||||
|
||||
|
||||
@app.get("/api/leaderboard")
|
||||
async def api_leaderboard(req: Request):
|
||||
"""Leaderboard of solves so far."""
|
||||
"""Leaderboard of solves so far. Team names resolved LIVE from state.json
|
||||
so renaming a team updates leaderboard + topology everywhere."""
|
||||
lb_path = orch.TEAMS_DIR / "leaderboard.json"
|
||||
if lb_path.exists():
|
||||
lb = json.loads(lb_path.read_text())
|
||||
else:
|
||||
lb = {"solves": []}
|
||||
# live name lookup: state.json label fallback to snapshot
|
||||
def team_name(idx):
|
||||
try:
|
||||
st = json.loads((orch.TEAMS_DIR / f"team{idx}" / "state.json").read_text())
|
||||
return st.get("label") or f"Team {idx}"
|
||||
except Exception:
|
||||
return f"Team {idx}"
|
||||
# aggregate per team
|
||||
teams = {}
|
||||
for e in lb["solves"]:
|
||||
t = teams.setdefault(e["team"], {"team": e["team"], "name": e["team_name"], "solves": 0, "challs": []})
|
||||
name = team_name(e["team"])
|
||||
t = teams.setdefault(e["team"], {"team": e["team"], "name": name, "solves": 0, "challs": [], "points": 0})
|
||||
t["name"] = name
|
||||
t["solves"] += 1
|
||||
t["challs"].append(e["challenge"])
|
||||
return {"solves": lb["solves"], "teams": sorted(teams.values(), key=lambda x: -x["solves"])}
|
||||
# attach live points from points.json
|
||||
for tid, t in teams.items():
|
||||
t["points"] = orch.get_team_points(tid)
|
||||
return {"solves": lb["solves"], "teams": sorted(teams.values(), key=lambda x: (-x["points"], -x["solves"]))}
|
||||
|
||||
|
||||
@app.get("/api/scoreboard")
|
||||
async def api_scoreboard(req: Request):
|
||||
"""Admin + team: full scoreboard with points, SLA, rank, badges."""
|
||||
require_login(req)
|
||||
return orch.sla_status_all()
|
||||
|
||||
|
||||
@app.get("/api/public/scoreboard")
|
||||
async def api_public_scoreboard():
|
||||
"""Public scoreboard (no login) — used by the team portal status tab."""
|
||||
d = orch.sla_status_all()
|
||||
# strip receiver creds / ports internals for the public view
|
||||
for t in d["teams"]:
|
||||
t.pop("domain", None)
|
||||
return d
|
||||
|
||||
|
||||
@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()]}
|
||||
|
||||
@app.get("/api/targets")
|
||||
async def api_admin_targets(req: Request):
|
||||
"""Admin: matrix of every team's service domain:port (public targets)."""
|
||||
require_login(req)
|
||||
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())
|
||||
dom = st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".gemastik.imrnes.team")
|
||||
for name, coff, soff in orch.CHALLENGES:
|
||||
p = st["ports"].get(name)
|
||||
if not p:
|
||||
continue
|
||||
out.append({
|
||||
"team": st.get("index"),
|
||||
"team_label": st.get("label", f"Team {st.get('index')}"),
|
||||
"challenge": name,
|
||||
"domain": dom,
|
||||
"port": p["chall"],
|
||||
})
|
||||
return {"targets": out}
|
||||
|
||||
|
||||
# ============ 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())
|
||||
# host check (IDOR): only this team's domain can open its SSH
|
||||
host = (ws.headers.get("host") or "").split(":")[0]
|
||||
if not (host == st.get("domain") or host.startswith("127.0.0.1") or host.startswith("localhost")):
|
||||
await ws.close(code=4003, reason="wrong host")
|
||||
return
|
||||
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
|
||||
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="team-id" content="0">
|
||||
<title>Panduan SSH — Gemastik A/D</title>
|
||||
<style>
|
||||
:root { --bg:#0b0e14; --panel:#151a23; --panel2:#1d2430; --line:#2a3444; --txt:#e6edf3; --dim:#8b98a9; --accent:#38bdf8; --green:#34d399; --red:#f87171; --purple:#a78bfa; }
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { background:var(--bg); color:var(--txt); font-family:'Segoe UI',system-ui,sans-serif; min-height:100vh; }
|
||||
.wrap { max-width:800px; margin:0 auto; padding:24px 16px 60px; }
|
||||
header { display:flex; align-items:center; gap:12px; margin-bottom:24px; flex-wrap:wrap; }
|
||||
.logo { width:42px; height:42px; border-radius:10px; background:linear-gradient(135deg,#38bdf8,#6366f1); display:flex; align-items:center; justify-content:center; font-weight:800; font-size:20px; color:#fff; }
|
||||
h1 { font-size:22px; }
|
||||
.sub { color:var(--dim); font-size:13px; }
|
||||
.card { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:20px; margin-bottom:16px; }
|
||||
.card h2 { font-size:15px; margin-bottom:12px; color:var(--accent); }
|
||||
h3 { color:var(--purple); margin:18px 0 8px; font-size:14px; }
|
||||
p, li { font-size:13px; color:#c9d4e3; line-height:1.7; }
|
||||
ol, ul { padding-left:20px; }
|
||||
.sshbox { background:var(--panel2); border:1px solid var(--line); border-radius:8px; padding:10px 12px; font-family:ui-monospace,monospace; font-size:12px; overflow-x:auto; margin:6px 0; }
|
||||
code { background:var(--panel2); padding:2px 6px; border-radius:5px; font-size:12px; }
|
||||
.warn { background:#3a1414; border:1px solid var(--red); border-radius:8px; padding:12px; font-size:13px; color:#fca5a5; margin:10px 0; }
|
||||
a { color:var(--accent); }
|
||||
table { width:100%; border-collapse:collapse; font-size:13px; margin:8px 0; }
|
||||
th, td { text-align:left; padding:8px 6px; border-bottom:1px solid var(--line); }
|
||||
th { color:var(--dim); font-weight:500; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="logo">G</div>
|
||||
<div style="flex:1">
|
||||
<h1>📖 Panduan SSH & Attack</h1>
|
||||
<div class="sub">Gemastik 18 Final — Attack & Defense</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<h2>🎯 Konsep Attack & Defense</h2>
|
||||
<p>Setiap tim punya <b>6 challenge container</b> yang harus dijaga (<b>defense</b>) dan bisa menyerang container tim lain (<b>attack</b>). Setiap container menyimpan <b>flag</b> yang nilainya dicek panitia secara berkala (SLA). Kalau service mati atau flag berubah, tim kamu <b>kehilangan poin SLA</b>.</p>
|
||||
<p style="margin-top:8px">Kamu menang dengan: (1) menjaga service tetap hidup, (2) <b>mencuri flag</b> dari tim lawan dan submit, (3) mencegah tim lawan mencuri flag kamu.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>🔐 Login SSH</h2>
|
||||
<h3>Cara 1 — Web Terminal di Portal Tim</h3>
|
||||
<ol>
|
||||
<li>Buka <b>portal tim kamu</b> (domain dari panitia).</li>
|
||||
<li>Login dengan <b>password SSH tim</b>.</li>
|
||||
<li>Tab <b>🖥️ Terminal SSH</b> → pilih challenge → <b>Sambung</b>.</li>
|
||||
<li>Langsung masuk sebagai <code>ctfuser</code> — tanpa perlu aplikasi SSH.</li>
|
||||
</ol>
|
||||
<h3>Cara 2 — SSH Client (opsional)</h3>
|
||||
<div class="sshbox">ssh ctfuser@43.134.105.109 -p <PORT_SSH></div>
|
||||
<p>Contoh: untuk challenge <code>blogpost</code> tim 1, port SSH = <code>31022</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>🧭 Command Penting</h2>
|
||||
<table>
|
||||
<tr><th>Command</th><th>Fungsi</th></tr>
|
||||
<tr><td><code>cat /flag.txt</code></td><td>Lihat flag tim kamu sendiri</td></tr>
|
||||
<tr><td><code>ls /app /srv /opt /home</code></td><td>Cari file source/service</td></tr>
|
||||
<tr><td><code>ps aux</code></td><td>Lihat proses yang berjalan</td></tr>
|
||||
<tr><td><code>ss -tlnp</code></td><td>Lihat port yang dibuka service</td></tr>
|
||||
<tr><td><code>systemctl status</code></td><td>Cek service (kalau ada)</td></tr>
|
||||
<tr><td><code>cat /etc/passwd</code></td><td>Daftar user lokal</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>⚔️ Alur Attack</h2>
|
||||
<ol>
|
||||
<li>Buka tab <b>🎯 Target Musuh</b> di portal — lihat daftar domain/port tim lain.</li>
|
||||
<li>Analisa service lawan dari <b>source code di container kamu sendiri</b> (biasanya sama).</li>
|
||||
<li>Cari vuln (SSTI, path traversal, RCE, crypto oracle, dsb).</li>
|
||||
<li>Exploit service lawan → baca <code>/flag.txt</code> mereka.</li>
|
||||
<li>Submit flag di tab <b>🚩 Submit Flag</b> → <b>poin masuk ke leaderboard</b>.</li>
|
||||
</ol>
|
||||
<div class="warn">⚠️ Jangan sampai flag tim kamu bocor! Ganti password SSH rutin (via panitia), dan jangan tinggalkan flag di tempat umum.</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>🛡️ Alur Defense</h2>
|
||||
<ol>
|
||||
<li>Pastikan service <b>selalu hidup</b> — SLA dicek panitia berkala.</li>
|
||||
<li>Patch vuln yang bisa dipakai lawan (jika tahu).</li>
|
||||
<li>Jangan ubah <code>/flag.txt</code> — mount <b>read-only</b>, tidak bisa diubah dari dalam container.</li>
|
||||
<li>Pantau <code>ps aux</code> / log — kalau ada aktivitas mencurigakan, restart via panitia.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>📞 Butuh Bantuan?</h2>
|
||||
<p>Hubungi panitia untuk: reset password SSH, restart container, atau lapor service down. Portal ini untuk peserta — panel admin terpisah.</p>
|
||||
<p style="margin-top:8px"><a href="/team/0" data-portal-link>← Kembali ke portal tim</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TEAM_ID = parseInt(document.querySelector('meta[name="team-id"]').content || '0', 10);
|
||||
document.querySelectorAll('[data-portal-link]').forEach(a => a.href = `/team/${TEAM_ID}`);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+313
-19
@@ -83,6 +83,15 @@
|
||||
}
|
||||
.toast.show { opacity:1; transform:translateY(0); }
|
||||
.toast.err { border-color:#f87171; color:#fca5a5; }
|
||||
/* loading overlay */
|
||||
#loadingOverlay {
|
||||
display:none; position:fixed; inset:0; background:rgba(4,8,14,0.82); z-index:2000;
|
||||
align-items:center; justify-content:center; flex-direction:column; gap:18px;
|
||||
}
|
||||
#loadingOverlay.show { display:flex; }
|
||||
.loader { width:44px; height:44px; border:4px solid #2a3444; border-top-color:#38bdf8; border-radius:50%; animation:spin 0.9s linear infinite; }
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
#loadingText { color:#dbe6f4; font-size:14px; text-align:center; padding:0 24px; line-height:1.6; }
|
||||
.history-box {
|
||||
background:#0a101f; border:1px solid #1e3a5f; border-radius:10px; padding:14px;
|
||||
font-size:11px; color:#7dd3fc; max-height:300px; overflow-y:auto; white-space:pre-wrap;
|
||||
@@ -92,6 +101,12 @@
|
||||
@keyframes sp { to { transform:rotate(360deg); } }
|
||||
.topo-wrap { background:#0a0f1c; border:1px solid #1e3a5f; border-radius:12px; padding:20px; overflow-x:auto; }
|
||||
.topo-svg { width:100%; min-width:800px; }
|
||||
/* attack visualizer: recent attacks pulse */
|
||||
@keyframes attackPulse { 0%,100% { stroke-opacity:0.4; } 50% { stroke-opacity:1; } }
|
||||
@keyframes attackBlink { 0%,100% { opacity:1; } 50% { opacity:0.35; } }
|
||||
.attack-line { animation: attackPulse 1.2s ease-in-out infinite; }
|
||||
.attack-icon { animation: attackBlink 1s ease-in-out infinite; }
|
||||
.topo-hint { font-size:12px; color:var(--dim); margin-top:8px; }
|
||||
.team-head { display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.team-status { font-size:11px; }
|
||||
.kv { display:grid; grid-template-columns:120px 1fr; gap:6px 12px; font-size:12px; }
|
||||
@@ -106,6 +121,8 @@
|
||||
<div class="actions">
|
||||
<span class="pill" id="clock">--:--:--</span>
|
||||
<button onclick="refresh()">🔄 Refresh</button>
|
||||
<button class="danger" onclick="resetScores()" style="background:#dc2626;border:none;border-radius:8px;padding:8px 14px;color:#fff;font-weight:600;cursor:pointer">🧹 Reset Skor</button>
|
||||
<button class="danger" onclick="resetEnv()" style="background:#7f1d1d;border:none;border-radius:8px;padding:8px 14px;color:#fff;font-weight:600;cursor:pointer">💥 Reset Environment</button>
|
||||
<button onclick="logout()">Keluar</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -116,6 +133,8 @@
|
||||
<button class="tab" data-view="teams" onclick="showView('teams'); loadTeams()">👥 Teams</button>
|
||||
<button class="tab" data-view="logs" onclick="showView('logs'); loadLogs()">📜 Logs</button>
|
||||
<button class="tab" data-view="creds" onclick="showView('creds'); loadCreds()">🔑 Creds</button>
|
||||
<button class="tab" data-view="targets" onclick="showView('targets'); loadTargetMatrix()">🎯 Target Matrix</button>
|
||||
<button class="tab" data-view="sla" onclick="showView('sla'); loadSla()">📊 SLA & Skor</button>
|
||||
</div>
|
||||
|
||||
<!-- Challenges view -->
|
||||
@@ -125,7 +144,14 @@
|
||||
|
||||
<!-- Topology view -->
|
||||
<div class="view" id="view-topo">
|
||||
<div class="topo-wrap"><svg id="topoSvg" class="topo-svg" height="420"></svg></div>
|
||||
<div class="topo-toolbar" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">
|
||||
<button onclick="topoZoom(1.25)">🔍 + Zoom In</button>
|
||||
<button onclick="topoZoom(0.8)">🔎 Zoom Out</button>
|
||||
<button onclick="topoReset()">⟳ Reset</button>
|
||||
<span id="topoZoomLabel" style="font-size:12px;color:var(--dim);min-width:40px">100%</span>
|
||||
</div>
|
||||
<div class="topo-wrap"><svg id="topoSvg" class="topo-svg" height="520"></svg></div>
|
||||
<div class="topo-hint">💡 <b>Geser node</b> untuk atur layout • <b>scroll / ctrl+scroll</b> untuk zoom in-out • <b>drag area kosong</b> untuk geser canvas • ⚔️ garis merah = serangan (panah attacker → target) • garis <b>putus-putus</b> = serangan baru (60 detik terakhir)</div>
|
||||
</div>
|
||||
|
||||
<!-- Teams view -->
|
||||
@@ -181,6 +207,28 @@
|
||||
<div class="grid" id="credsGrid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Target Matrix view -->
|
||||
<div class="view" id="view-targets">
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<b style="color:#5ad1ff">🎯 Target Matrix</b>
|
||||
<span style="font-size:11px;color:#718096">Domain:port semua service semua tim — buat dibagikan ke peserta sebagai daftar target</span>
|
||||
</div>
|
||||
<div id="targetMatrix" style="margin-top:14px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.9;color:#dbe6f4;background:#0a101f;border:1px solid #1e3a5f;border-radius:10px;padding:14px;overflow-x:auto">Memuat…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SLA & Scoreboard view -->
|
||||
<div class="view" id="view-sla">
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<b style="color:#5ad1ff">📊 SLA & Skor Tim</b>
|
||||
<span style="font-size:11px;color:#718096">Status service tiap tim (dicek otomatis tiap ~30 detik) + poin: +100/flag dicuri, +50 bonus SLA saat 6/6 UP. Auto-refresh 30 detik.</span>
|
||||
</div>
|
||||
<div id="slaAdminList" style="margin-top:14px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.9;color:#dbe6f4;background:#0a101f;border:1px solid #1e3a5f;border-radius:10px;padding:14px;overflow-x:auto">Memuat…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-note">Receiver: https://gemastik.imrnes.team · Panel: https://panel.gemastik.imrnes.team · Auto-refresh tiap 15 detik</div>
|
||||
|
||||
<!-- modal flag -->
|
||||
@@ -219,6 +267,12 @@
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<!-- loading overlay -->
|
||||
<div id="loadingOverlay">
|
||||
<div class="loader"></div>
|
||||
<div id="loadingText">Memproses…</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let CURRENT = null;
|
||||
let REFRESH_MS = 15000;
|
||||
@@ -237,13 +291,60 @@ function toast(msg, err=false) {
|
||||
setTimeout(() => t.className = 'toast', 3000);
|
||||
}
|
||||
|
||||
function showLoading(html) {
|
||||
const ov = document.getElementById('loadingOverlay');
|
||||
document.getElementById('loadingText').innerHTML = html;
|
||||
ov.classList.add('show');
|
||||
return ov;
|
||||
}
|
||||
function hideLoading() {
|
||||
document.getElementById('loadingOverlay').classList.remove('show');
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return (s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function showView(v) {
|
||||
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.view === v));
|
||||
document.querySelectorAll('.view').forEach(x => x.classList.toggle('active', x.id === 'view-' + v));
|
||||
if (v === 'topo') loadTopo(); // refresh attacks immediately on tab switch + every 10s
|
||||
if (v === 'sla') loadSla();
|
||||
}
|
||||
let topoTimer = null;
|
||||
function startTopoTimer() {
|
||||
if (!topoTimer) topoTimer = setInterval(() => {
|
||||
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
|
||||
}, 10000);
|
||||
}
|
||||
startTopoTimer();
|
||||
let slaTimer = null;
|
||||
function startSlaTimer() {
|
||||
if (!slaTimer) slaTimer = setInterval(() => {
|
||||
if (document.getElementById('view-sla').classList.contains('active')) loadSla();
|
||||
}, 30000);
|
||||
}
|
||||
startSlaTimer();
|
||||
|
||||
async function loadSla() {
|
||||
const box = document.getElementById('slaAdminList');
|
||||
if (!box) return;
|
||||
box.innerHTML = 'Memuat…';
|
||||
try {
|
||||
const d = await api('/api/scoreboard');
|
||||
const rows = (d.teams || []).map(t => {
|
||||
const pct = Math.round(t.sla_pct || 0);
|
||||
const color = pct === 100 ? '#2ecc71' : (pct >= 60 ? '#f1c40f' : '#e74c3c');
|
||||
const bar = `<span style="display:inline-block;vertical-align:middle;width:120px;height:8px;background:#1e3a5f;border-radius:4px;margin:0 8px;overflow:hidden"><span style="display:block;height:100%;width:${pct}%;background:${color}"></span></span>`;
|
||||
return `<div style="padding:8px 4px;border-bottom:1px solid #16233a">
|
||||
<b>${t.badge ? t.badge + ' ' : '#' + t.rank + ' '}</b>${esc(t.label)} ${bar}<span style="color:${color}">${t.alive}/${t.total} (${pct}%)</span>
|
||||
<span style="float:right;color:#5ad1ff">⚑ ${t.solves} flag · 🏆 ${t.points} pts</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
box.innerHTML = rows || '<div style="color:#718096">Belum ada tim.</div>';
|
||||
} catch (e) {
|
||||
box.innerHTML = '<div style="color:#e74c3c">Gagal memuat SLA.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Challenges ----------
|
||||
@@ -332,9 +433,11 @@ async function viewCred(ch) {
|
||||
|
||||
// ---------- Topology ----------
|
||||
let topoLoaded = false;
|
||||
let topoAttacks = [];
|
||||
async function loadTopo() {
|
||||
try {
|
||||
const d = await api('/api/topology');
|
||||
const [d, a] = await Promise.all([api('/api/topology'), api('/api/attacks').catch(() => ({events: []}))]);
|
||||
topoAttacks = a.events || [];
|
||||
renderTopo(d.nodes, d.edges);
|
||||
} catch (e) { toast('Topologi gagal: ' + e.message, true); }
|
||||
}
|
||||
@@ -342,7 +445,7 @@ async function loadTopo() {
|
||||
function renderTopo(nodes, edges) {
|
||||
const svg = document.getElementById('topoSvg');
|
||||
const W = Math.max(900, nodes.length * 130);
|
||||
const H = 400;
|
||||
const H = 500;
|
||||
svg.setAttribute('width', W); svg.setAttribute('height', H);
|
||||
const cx = W / 2;
|
||||
const ns = {};
|
||||
@@ -359,15 +462,26 @@ function renderTopo(nodes, edges) {
|
||||
const ang = (i / Math.max(1, teams.length)) * Math.PI * 2 - Math.PI / 2;
|
||||
const rx = W * 0.36, ry = 100;
|
||||
const tx = cx + rx * Math.cos(ang);
|
||||
const ty = 210 + ry * Math.sin(ang) * 0.6;
|
||||
const ty = 250 + ry * Math.sin(ang) * 0.6;
|
||||
teamPos[t.index] = {x: tx, y: ty};
|
||||
pos[t.id] = {x: tx, y: ty};
|
||||
});
|
||||
nodes.filter(n => n.type === 'challenge').forEach(n => {
|
||||
const ti = n.id.split('-')[0].replace('team','');
|
||||
const base = teamPos[ti] || {x: cx, y: 250};
|
||||
const base = teamPos[ti] || {x: cx, y: 300};
|
||||
const chIdx = ['blogpost','carbeat','cdn','phew','sheesh','warmup'].indexOf(n.label.split('-').pop() || n.label);
|
||||
pos[n.id] = {x: base.x + (chIdx - 2.5) * 70, y: base.y + 110};
|
||||
pos[n.id] = {x: base.x + (chIdx - 2.5) * 70, y: base.y + 130};
|
||||
});
|
||||
|
||||
// ---- attack arcs (attacker team -> target team), recent only (last 10 min) ----
|
||||
const now = Date.now() / 1000;
|
||||
const recentAttacks = topoAttacks.filter(e => now - (e.ts || 0) < 600);
|
||||
const attackCounts = {}; // "attacker:target" -> {ok, fail, latest}
|
||||
recentAttacks.forEach(e => {
|
||||
const k = `${e.attacker}:${e.target}`;
|
||||
attackCounts[k] = attackCounts[k] || {ok: 0, fail: 0, latest: e.ts};
|
||||
attackCounts[k][e.success ? 'ok' : 'fail']++;
|
||||
attackCounts[k].latest = Math.max(attackCounts[k].latest, e.ts || 0);
|
||||
});
|
||||
|
||||
// edges
|
||||
@@ -378,10 +492,24 @@ function renderTopo(nodes, edges) {
|
||||
html += `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="#2a4a6f" stroke-width="1.5" stroke-dasharray="4 3"/>`;
|
||||
if (e.label) {
|
||||
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2 - 6;
|
||||
html += `<text x="${mx}" y="${my}" fill="#5a7a9f" font-size="9" text-anchor="middle">${e.label}</text>`;
|
||||
html += `<text x="${mx}" y="${my}" fill="#5a7a9f" font-size="9" text-anchor="middle">${esc(e.label)}</text>`;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- attack visualizer ----
|
||||
for (const [k, c] of Object.entries(attackCounts)) {
|
||||
const [atk, tgt] = k.split(':');
|
||||
const a = pos['team' + atk], b = pos['team' + tgt];
|
||||
if (!a || !b || atk === tgt) continue;
|
||||
const isHot = now - c.latest < 60;
|
||||
const color = c.ok > 0 ? '#f87171' : '#fb923c';
|
||||
const dash = isHot ? '6 3' : '4 4';
|
||||
html += `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${color}" stroke-width="${isHot ? 3 : 2}" stroke-dasharray="${dash}" opacity="0.9" class="attack-line"/>`;
|
||||
const mx = (a.x + b.x) / 2 + 8, my = (a.y + b.y) / 2 - 10;
|
||||
const icon = isHot ? '⚔️' : '⚔';
|
||||
html += `<text x="${mx}" y="${my}" fill="${color}" font-size="12" text-anchor="middle" class="attack-icon">${icon} ${c.ok}✓ ${c.fail}✗</text>`;
|
||||
}
|
||||
|
||||
// nodes
|
||||
nodes.forEach(n => {
|
||||
const p = pos[n.id];
|
||||
@@ -393,7 +521,7 @@ function renderTopo(nodes, edges) {
|
||||
if (n.type === 'challenge') { r = 26; color = '#c9d4e3'; }
|
||||
const status = n.status === 'running' ? ' fill="#34d399"' : '';
|
||||
const sub = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : '');
|
||||
html += `<g>
|
||||
html += `<g data-node="${esc(n.id)}" style="cursor:grab">
|
||||
<circle cx="${p.x}" cy="${p.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
|
||||
<circle cx="${p.x}" cy="${p.y}" r="${r-4}" fill="none" stroke="${stroke}" stroke-opacity="0.3"/>
|
||||
<text x="${p.x}" y="${p.y - (sub ? 0 : 4)}" fill="${color}" font-size="${n.type==='team'?12:10}" font-weight="bold" text-anchor="middle">${esc(n.label)}</text>
|
||||
@@ -401,9 +529,99 @@ function renderTopo(nodes, edges) {
|
||||
${status ? `<circle cx="${p.x + r - 8}" cy="${p.y - r + 8}" r="5" fill="#34d399"/>` : ''}
|
||||
</g>`;
|
||||
});
|
||||
svg.innerHTML = `<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2a4a6f"/></marker></defs>` + html;
|
||||
svg.innerHTML = `<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2a4a6f"/></marker></defs><g data-root>${html}</g>`;
|
||||
enableTopoDrag(svg, pos);
|
||||
applyTopoView();
|
||||
}
|
||||
|
||||
// ---- draggable topology + zoom/pan ----
|
||||
let topoScale = 1;
|
||||
let topoPanX = 0, topoPanY = 0;
|
||||
function enableTopoDrag(svg, pos) {
|
||||
const gMain = svg.querySelector('g[data-root]') || svg;
|
||||
let dragEl = null, dx = 0, dy = 0;
|
||||
let panning = false, px = 0, py = 0;
|
||||
|
||||
const toLocal = (ev) => {
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) return {x: 0, y: 0};
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = ev.clientX; pt.y = ev.clientY;
|
||||
return pt.matrixTransform(ctm.inverse());
|
||||
};
|
||||
|
||||
const onMove = (ev) => {
|
||||
if (dragEl) {
|
||||
const p = toLocal(ev);
|
||||
dragEl.setAttribute('transform', `translate(${p.x - dx}, ${p.y - dy})`);
|
||||
} else if (panning) {
|
||||
topoPanX += ev.clientX - px;
|
||||
topoPanY += ev.clientY - py;
|
||||
px = ev.clientX; py = ev.clientY;
|
||||
applyTopoView();
|
||||
}
|
||||
};
|
||||
const onUp = () => { dragEl = null; panning = false; svg.style.cursor = ''; };
|
||||
|
||||
svg.addEventListener('mousedown', (ev) => {
|
||||
const g = ev.target.closest('g[data-node]');
|
||||
if (g) {
|
||||
ev.preventDefault();
|
||||
const p = toLocal(ev);
|
||||
const c = g.querySelector('circle');
|
||||
dx = p.x - parseFloat(c.getAttribute('cx'));
|
||||
dy = p.y - parseFloat(c.getAttribute('cy'));
|
||||
dragEl = g;
|
||||
svg.style.cursor = 'grabbing';
|
||||
g.setPointerCapture && g.setPointerCapture(ev.pointerId);
|
||||
} else {
|
||||
// empty area -> pan the canvas
|
||||
panning = true;
|
||||
px = ev.clientX; py = ev.clientY;
|
||||
svg.style.cursor = 'move';
|
||||
}
|
||||
});
|
||||
svg.addEventListener('pointermove', onMove);
|
||||
svg.addEventListener('pointerup', onUp);
|
||||
svg.addEventListener('pointercancel', onUp);
|
||||
|
||||
// wheel zoom (ctrl+wheel or plain wheel on empty area)
|
||||
svg.addEventListener('wheel', (ev) => {
|
||||
ev.preventDefault();
|
||||
const factor = ev.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
const ns = Math.min(3, Math.max(0.3, topoScale * factor));
|
||||
// zoom around cursor
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
|
||||
const W = svg.clientWidth, H = svg.clientHeight;
|
||||
topoPanX = mx - (mx - topoPanX) * (ns / topoScale);
|
||||
topoPanY = my - (my - topoPanY) * (ns / topoScale);
|
||||
topoScale = ns;
|
||||
applyTopoView();
|
||||
}, {passive: false});
|
||||
}
|
||||
|
||||
function applyTopoView() {
|
||||
const svg = document.getElementById('topoSvg');
|
||||
const g = svg.querySelector('g[data-root]');
|
||||
if (!g) return;
|
||||
g.setAttribute('transform', `translate(${topoPanX}, ${topoPanY}) scale(${topoScale})`);
|
||||
// update hint + zoom % label
|
||||
const zl = document.getElementById('topoZoomLabel');
|
||||
if (zl) zl.textContent = Math.round(topoScale * 100) + '%';
|
||||
}
|
||||
|
||||
function topoZoom(factor) {
|
||||
const svg = document.getElementById('topoSvg');
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const ns = Math.min(3, Math.max(0.3, topoScale * factor));
|
||||
topoPanX = rect.width / 2 - (rect.width / 2 - topoPanX) * (ns / topoScale);
|
||||
topoPanY = rect.height / 2 - (rect.height / 2 - topoPanY) * (ns / topoScale);
|
||||
topoScale = ns;
|
||||
applyTopoView();
|
||||
}
|
||||
function topoReset() { topoScale = 1; topoPanX = 0; topoPanY = 0; applyTopoView(); }
|
||||
|
||||
// ---------- Teams ----------
|
||||
async function loadTeams() {
|
||||
try {
|
||||
@@ -425,12 +643,13 @@ async function loadTeams() {
|
||||
<b>Receiver</b><span>${t.ports.receiver}</span>
|
||||
<b>Admin</b><span>${esc(t.admin_user)}</span>
|
||||
<b>SSH user</b><span>ctfuser</span>
|
||||
<b>Status</b><span>${esc(t.status)}</span>${dom ? `<b>Domain</b><span><a href="https://${esc(dom)}/team/${t.index}" target="_blank" style="color:#5ad1ff">${esc(dom)} →</a></span>` : ''}
|
||||
<b>Status</b><span>${esc(t.status)}</span>${dom ? `<b>Domain</b><span><a href="https://${esc(dom)}" target="_blank" style="color:#5ad1ff">${esc(dom)} →</a></span>` : ''}
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="primary" onclick="startTeam(${t.index})">▶ Start</button>
|
||||
<button class="danger" onclick="stopTeam(${t.index})">⏹ Stop</button>
|
||||
<button onclick="viewTeamCred(${t.index})">🔑 SSH & Pass</button>
|
||||
<button onclick="editTeam(${t.index})">✏️ Edit Nama/Domain</button>
|
||||
<button onclick="openTeamLogs(${t.index})">📜 Logs</button>
|
||||
<button onclick="randomizeTeam(${t.index})">🎲 Randomize Flag</button>
|
||||
</div>
|
||||
@@ -442,46 +661,100 @@ async function loadTeams() {
|
||||
|
||||
async function setTeams() {
|
||||
const n = parseInt(document.getElementById('teamCount').value || '0', 10);
|
||||
const labels = {};
|
||||
const labels = {}, domains = {};
|
||||
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();
|
||||
const inpN = document.getElementById('teamNameInputs')?.querySelector(`input[data-idx="${i}"][data-field="name"]`);
|
||||
if (inpN && inpN.value.trim()) labels[i] = inpN.value.trim();
|
||||
const inpD = document.getElementById('teamNameInputs')?.querySelector(`input[data-idx="${i}"][data-field="domain"]`);
|
||||
if (inpD && inpD.value.trim()) domains[i] = inpD.value.trim();
|
||||
}
|
||||
try {
|
||||
const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n, labels})});
|
||||
showLoading(`Membuat ${n} team…<br>Generate compose, flags, domain, Traefik config`);
|
||||
const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n, labels, domains})});
|
||||
toast(`Team dibuat: ${d.created.join(', ') || 'tidak ada yang baru'} · total ${d.total}`, false);
|
||||
loadTeams();
|
||||
} catch (e) { toast(e.message, true); }
|
||||
finally { hideLoading(); }
|
||||
}
|
||||
|
||||
function teamCountChanged() {
|
||||
const n = parseInt(document.getElementById('teamCount').value || '0', 10);
|
||||
const box = document.getElementById('teamNameInputs');
|
||||
let html = '';
|
||||
let html = '<div style="font-size:11px;color:#718096;margin-bottom:4px">Nama team & domain custom (opsional — kosongkan untuk auto dari nama)</div>';
|
||||
for (let i = 1; i <= n; i++) {
|
||||
html += `<div style="display:flex;align-items:center;gap:8px">
|
||||
html += `<div style="display:flex;align-items:center;gap:6px">
|
||||
<span style="color:#718096;font-size:12px;width:60px">Team ${i}</span>
|
||||
<input data-idx="${i}" placeholder="Nama team (jd domain: nama.gemastik.imrnes.team)" style="flex:1">
|
||||
<input data-idx="${i}" data-field="name" placeholder="Nama team (contoh: Cyber Warriors)" style="flex:1;min-width:120px">
|
||||
<input data-idx="${i}" data-field="domain" placeholder="domain custom (contoh: team-cyber)" style="flex:1;min-width:120px">
|
||||
<span style="color:#3b4a63;font-size:11px">.gemastik.imrnes.team</span>
|
||||
</div>`;
|
||||
}
|
||||
box.innerHTML = html;
|
||||
}
|
||||
|
||||
async function editTeam(idx) {
|
||||
// fetch current team info
|
||||
let t = null;
|
||||
try { t = (await api(`/api/team/${idx}/creds`)).team; } catch (e) { toast('Gagal ambil data team', true); return; }
|
||||
const newLabel = prompt(`✏️ Nama team ${idx} (sekarang: ${t.label || ('Team ' + idx)}):`, t.label || '');
|
||||
if (newLabel === null) return;
|
||||
const newDomain = prompt(`🌐 Domain custom team ${idx} (sekarang: ${t.domain || ''} — ketik subdomain saja, contoh: team-cyber):`, (t.domain || '').split('.')[0]);
|
||||
if (newDomain === null) return;
|
||||
try {
|
||||
showLoading('Update nama/domain team…');
|
||||
const d = await api(`/api/teams/${idx}`, {method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({label: newLabel.trim(), domain: newDomain.trim()})});
|
||||
toast(`Team ${idx} diupdate: ${d.team.label} @ ${d.team.domain}`, false);
|
||||
loadTeams();
|
||||
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
|
||||
} catch (e) { toast(e.message, true); }
|
||||
finally { hideLoading(); }
|
||||
}
|
||||
|
||||
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); }
|
||||
const ov = showLoading(`Menyiapkan Team ${idx}…<br>Membangun & start container (bisa butuh 1-3 menit)`);
|
||||
try { await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} start`, false); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
finally { hideLoading(); setTimeout(loadTeams, 2500); }
|
||||
}
|
||||
async function stopTeam(idx) {
|
||||
const ov = showLoading(`Menghentikan Team ${idx}…`);
|
||||
try { await api('/api/teams/stop', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} stop`, false); loadTeams(); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
finally { hideLoading(); }
|
||||
}
|
||||
async function startAllTeams() {
|
||||
const ov = showLoading('Start SEMUA team…<br>Ini bisa butuh beberapa menit (build + boot + set password)');
|
||||
try { const d = await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({})}); toast(`Start semua: ${d.results.filter(r=>r.ok).length}/${d.results.length} ok`, false); setTimeout(loadTeams, 4000); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
finally { hideLoading(); }
|
||||
}
|
||||
async function stopAllTeams() {
|
||||
const ov = showLoading('Menghentikan SEMUA team…');
|
||||
try { const d = await api('/api/teams/stop', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({})}); toast(`Stop semua: ${d.results.filter(r=>r.ok).length}/${d.results.length} ok`, false); loadTeams(); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
finally { hideLoading(); }
|
||||
}
|
||||
|
||||
async function resetScores() {
|
||||
if (!confirm('⚠️ Apakah anda yakin ingin mereset skor?\n\nSemua poin & solve di leaderboard akan DIHAPUS.\nTindakan ini tidak bisa dibatalkan.')) return;
|
||||
showLoading('Menghapus semua skor…');
|
||||
try {
|
||||
const d = await api('/api/reset/scores', {method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'});
|
||||
toast('Skor direset: semua solve dihapus', false);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
finally { hideLoading(); }
|
||||
}
|
||||
|
||||
async function resetEnv() {
|
||||
if (!confirm('⚠️ Apakah anda yakin ingin mereset environment?\n\nSEMUA team akan DISTOP:\n• Container challenge dihapus\n• Receiver team dihapus\n• Folder team dihapus (perlu create ulang)\n• Skor leaderboard direset\n\nTindakan ini TIDAK BISA dibatalkan.')) return;
|
||||
showLoading('Reset environment…\nMenghentikan semua team + menghapus container/receiver (beberapa menit)');
|
||||
try {
|
||||
const d = await api('/api/reset/environment', {method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'});
|
||||
const n = (d.stopped || []).length;
|
||||
toast(`Environment direset: ${n} team dihentikan & dihapus`, false);
|
||||
setTimeout(() => location.reload(), 1200);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
finally { hideLoading(); }
|
||||
}
|
||||
|
||||
async function randomizeTeam(idx) {
|
||||
@@ -584,6 +857,27 @@ async function loadCreds() {
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
// ---------- Target Matrix ----------
|
||||
async function loadTargetMatrix() {
|
||||
const box = document.getElementById('targetMatrix');
|
||||
try {
|
||||
const d = await api('/api/targets');
|
||||
const targets = d.targets || [];
|
||||
if (!targets.length) { box.textContent = 'Belum ada team.'; return; }
|
||||
// group by team
|
||||
const rows = [];
|
||||
let curTeam = null;
|
||||
for (const t of targets) {
|
||||
if (t.team !== curTeam) {
|
||||
rows.push(`\n[${esc(t.team_label || ('Team ' + t.team))}]`);
|
||||
curTeam = t.team;
|
||||
}
|
||||
rows.push(` ${esc(t.domain)}:${t.port} (${esc(t.challenge)})`);
|
||||
}
|
||||
box.textContent = rows.join('\n');
|
||||
} catch (e) { box.textContent = 'Gagal: ' + e.message; }
|
||||
}
|
||||
|
||||
// ---------- misc ----------
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||||
async function logout() {
|
||||
|
||||
+442
-56
@@ -3,17 +3,21 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Portal Team — Gemastik A/D</title>
|
||||
<meta name="team-id" content="0">
|
||||
<title>Portal Tim — Gemastik A/D</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
||||
<style>
|
||||
:root { --bg:#0b0e14; --panel:#151a23; --panel2:#1d2430; --line:#2a3444; --txt:#e6edf3; --dim:#8b98a9; --accent:#38bdf8; --green:#34d399; --red:#f87171; }
|
||||
:root { --bg:#0b0e14; --panel:#151a23; --panel2:#1d2430; --line:#2a3444; --txt:#e6edf3; --dim:#8b98a9; --accent:#38bdf8; --green:#34d399; --red:#f87171; --purple:#a78bfa; }
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { background:var(--bg); color:var(--txt); font-family:'Segoe UI',system-ui,sans-serif; min-height:100vh; }
|
||||
.wrap { max-width:900px; margin:0 auto; padding:24px 16px 60px; }
|
||||
.wrap { max-width:1000px; margin:0 auto; padding:24px 16px 60px; }
|
||||
header { display:flex; align-items:center; gap:12px; margin-bottom:24px; flex-wrap:wrap; }
|
||||
.logo { width:42px; height:42px; border-radius:10px; background:linear-gradient(135deg,#38bdf8,#6366f1); display:flex; align-items:center; justify-content:center; font-weight:800; font-size:20px; color:#fff; }
|
||||
h1 { font-size:22px; }
|
||||
.sub { color:var(--dim); font-size:13px; }
|
||||
.badge { background:#06352a; border:1px solid var(--green); color:var(--green); border-radius:20px; padding:3px 12px; font-size:12px; font-weight:600; }
|
||||
.badge.stopped { background:#3a1414; border-color:var(--red); color:var(--red); }
|
||||
.card { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:20px; margin-bottom:16px; }
|
||||
.card h2 { font-size:15px; margin-bottom:12px; color:var(--accent); }
|
||||
.kv { display:grid; grid-template-columns:150px 1fr; gap:6px 12px; font-size:13px; margin-bottom:4px; }
|
||||
@@ -25,13 +29,60 @@
|
||||
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
|
||||
.dot.up { background:var(--green); } .dot.down { background:var(--red); }
|
||||
label { display:block; font-size:12px; color:var(--dim); margin:12px 0 5px; }
|
||||
select, input { width:100%; padding:10px 12px; border-radius:8px; border:1px solid var(--line); background:var(--panel2); color:var(--txt); font-size:14px; }
|
||||
button { margin-top:16px; width:100%; padding:12px; border:none; border-radius:8px; background:linear-gradient(135deg,#38bdf8,#6366f1); color:#fff; font-weight:600; font-size:15px; cursor:pointer; }
|
||||
select, input[type=text], input[type=password] { width:100%; padding:10px 12px; border-radius:8px; border:1px solid var(--line); background:var(--panel2); color:var(--txt); font-size:14px; }
|
||||
button { padding:12px 16px; border:none; border-radius:8px; background:linear-gradient(135deg,#38bdf8,#6366f1); color:#fff; font-weight:600; font-size:14px; cursor:pointer; }
|
||||
button:hover { filter:brightness(1.1); }
|
||||
button.ghost { background:var(--panel2); border:1px solid var(--line); color:var(--txt); }
|
||||
#result { margin-top:14px; padding:12px; border-radius:8px; display:none; font-size:14px; }
|
||||
#result.ok { display:block; background:#06352a; border:1px solid var(--green); color:var(--green); }
|
||||
#result.err { display:block; background:#3a1414; border:1px solid var(--red); color:var(--red); }
|
||||
.sshbox { background:var(--panel2); border:1px solid var(--line); border-radius:8px; padding:10px 12px; font-family:ui-monospace,monospace; font-size:12px; overflow-x:auto; }
|
||||
/* login */
|
||||
#loginScreen { max-width:420px; margin:80px auto; }
|
||||
#loginScreen .card { padding:32px; }
|
||||
#loginScreen h2 { text-align:center; margin-bottom:6px; }
|
||||
#loginScreen .hint { text-align:center; color:var(--dim); font-size:12px; margin-bottom:18px; }
|
||||
.login-err { color:var(--red); font-size:13px; margin-top:10px; display:none; }
|
||||
/* tabs */
|
||||
.tabs { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; }
|
||||
.tabs button { background:var(--panel2); border:1px solid var(--line); color:var(--dim); font-weight:500; }
|
||||
.tabs button.active { background:linear-gradient(135deg,#38bdf8,#6366f1); color:#fff; border-color:transparent; }
|
||||
.view { display:none; }
|
||||
.view.active { display:block; }
|
||||
/* terminal */
|
||||
#termWrap { background:#0d1117; border:1px solid var(--line); border-radius:10px; padding:8px; height:480px; }
|
||||
#term { height:100%; }
|
||||
.term-toolbar { display:flex; gap:8px; align-items:center; margin-bottom:10px; flex-wrap:wrap; }
|
||||
#termStatus { font-size:12px; color:var(--dim); }
|
||||
code { background:var(--panel2); padding:2px 6px; border-radius:5px; font-size:12px; }
|
||||
.guide h3 { color:var(--purple); margin:18px 0 8px; font-size:14px; }
|
||||
.guide p, .guide li { font-size:13px; color:#c9d4e3; line-height:1.7; }
|
||||
.guide ol, .guide ul { padding-left:20px; }
|
||||
.login-note { font-size:12px; color:var(--dim); margin-top:14px; text-align:center; }
|
||||
.login-note a { color:var(--accent); }
|
||||
/* activity feed */
|
||||
.feed { display:flex; flex-direction:column; gap:10px; max-height:420px; overflow-y:auto; }
|
||||
.feed-item { background:var(--panel2); border:1px solid var(--line); border-left:3px solid var(--line); border-radius:10px; padding:10px 14px; font-size:13px; line-height:1.5; }
|
||||
.feed-item.in { border-left-color:var(--red); }
|
||||
.feed-item.out { border-left-color:var(--green); }
|
||||
.feed-item.ok { border-left-color:var(--green); }
|
||||
.feed-item.fail { border-left-color:var(--red); }
|
||||
.feed-time { color:var(--dim); font-size:11px; margin-left:8px; }
|
||||
.feed-tag { font-size:11px; border-radius:5px; padding:1px 7px; margin-right:6px; font-weight:700; }
|
||||
.tag-in { background:#3a1414; color:var(--red); }
|
||||
.tag-out { background:#06352a; color:var(--green); }
|
||||
code { background:var(--panel2); padding:2px 6px; border-radius:5px; font-size:12px; }
|
||||
.lb-row { display:flex; align-items:center; gap:12px; padding:10px 12px; border-bottom:1px solid var(--line); font-size:14px; }
|
||||
.lb-row.me { background:rgba(56,189,248,.08); border-left:3px solid var(--accent); }
|
||||
.lb-rank { width:28px; font-weight:800; color:var(--dim); }
|
||||
.lb-name { flex:1; font-weight:600; }
|
||||
.lb-name small { color:var(--dim); font-weight:400; margin-left:6px; }
|
||||
.lb-solves { background:var(--panel2); border:1px solid var(--line); border-radius:20px; padding:3px 12px; font-size:12px; color:var(--green); }
|
||||
.lb-empty { color:var(--dim); font-size:13px; padding:20px; text-align:center; }
|
||||
.svc-health { display:flex; flex-wrap:wrap; gap:8px; margin-top:12px; }
|
||||
.svc-chip { border:1px solid var(--line); border-radius:8px; padding:4px 10px; font-size:12px; background:var(--panel2); }
|
||||
.svc-chip.up { border-color:var(--green); color:var(--green); }
|
||||
.svc-chip.down { border-color:var(--red); color:var(--red); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -43,70 +94,275 @@
|
||||
<div class="sub" id="teamDomain"></div>
|
||||
</div>
|
||||
<span class="badge" id="teamStatus">…</span>
|
||||
<button class="ghost" id="logoutBtn" style="display:none" onclick="logout()">Keluar</button>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<h2>🌐 Akses Server Tim Kamu</h2>
|
||||
<div id="accessBox" class="sshbox">Memuat…</div>
|
||||
<div style="margin-top:12px;font-size:12px;color:var(--dim)">
|
||||
Tiap challenge punya port sendiri. SSH login: <b>ctfuser</b> + password dari panel panitia.
|
||||
<!-- LOGIN -->
|
||||
<div id="loginScreen">
|
||||
<div class="card">
|
||||
<h2>🔐 Login Portal Tim</h2>
|
||||
<div class="hint" id="loginHint">Masukkan <b>password SSH tim</b> kamu (dari panitia)</div>
|
||||
<label>Password Tim</label>
|
||||
<input type="password" id="loginPass" placeholder="password SSH tim" autocomplete="off">
|
||||
<button onclick="doLogin()" style="width:100%;margin-top:16px">Masuk Portal</button>
|
||||
<div class="login-err" id="loginErr"></div>
|
||||
<div class="login-note">Belum punya password? Hubungi panitia.<br>Tutorial SSH: <a href="/team/0/guide" data-guide-link>baca panduan</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>📊 Status Challenge</h2>
|
||||
<table>
|
||||
<thead><tr><th>Challenge</th><th>Port</th><th>SSH</th><th>Status</th></tr></thead>
|
||||
<tbody id="statusBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- PORTAL -->
|
||||
<div id="portalScreen" style="display:none">
|
||||
<div class="tabs">
|
||||
<button data-view="term" onclick="showView('term')">🖥️ Terminal SSH</button>
|
||||
<button data-view="targets" onclick="showView('targets')">🎯 Target Musuh</button>
|
||||
<button data-view="submit" onclick="showView('submit')">🚩 Submit Flag</button>
|
||||
<button data-view="activity" onclick="showView('activity')">📡 Aktivitas</button>
|
||||
<button data-view="leaderboard" onclick="showView('leaderboard')">🏆 Leaderboard</button>
|
||||
<button data-view="sla" onclick="showView('sla'); loadSla()">📊 SLA & Skor</button>
|
||||
<button data-view="guide" onclick="showView('guide')">📖 Panduan SSH</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>🚩 Submit Flag</h2>
|
||||
<label>Challenge</label>
|
||||
<select id="challenge">
|
||||
<option value="blogpost">blogpost (web)</option>
|
||||
<option value="carbeat">carbeat (pwn)</option>
|
||||
<option value="cdn">cdn (web)</option>
|
||||
<option value="phew">phew (crypto)</option>
|
||||
<option value="sheesh">sheesh (crypto)</option>
|
||||
<option value="warmup">warmup</option>
|
||||
</select>
|
||||
<label>Flag</label>
|
||||
<input id="flag" placeholder="GEMASTIK18{...}" autocomplete="off">
|
||||
<button onclick="submitFlag()">Submit Flag</button>
|
||||
<div id="result"></div>
|
||||
<div class="view active" id="view-term">
|
||||
<div class="card">
|
||||
<h2>🖥️ SSH Terminal — pilih challenge</h2>
|
||||
<div class="term-toolbar">
|
||||
<select id="termChall" style="width:200px" onchange="connectTerm()">
|
||||
<option value="blogpost">blogpost (web)</option>
|
||||
<option value="carbeat">carbeat (pwn)</option>
|
||||
<option value="cdn">cdn (web)</option>
|
||||
<option value="phew">phew (crypto)</option>
|
||||
<option value="sheesh">sheesh (crypto)</option>
|
||||
<option value="warmup">warmup</option>
|
||||
</select>
|
||||
<button class="ghost" onclick="connectTerm()">🔄 Sambung</button>
|
||||
<span id="termStatus">Belum tersambung</span>
|
||||
</div>
|
||||
<div id="termWrap"><div id="term"></div></div>
|
||||
<div style="margin-top:10px;font-size:12px;color:var(--dim)">
|
||||
SSH otomatis login sebagai <code>ctfuser</code> ke container challenge timmu. Koneksi diputus setelah idle.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-targets">
|
||||
<div class="card">
|
||||
<h2>🎯 Target Musuh</h2>
|
||||
<p style="font-size:13px;color:var(--dim);margin-bottom:12px">Service challenge tim lain — inilah yang harus kamu attack & curi flagnya.</p>
|
||||
<div id="targetList" class="sshbox">Memuat…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-submit">
|
||||
<div class="card">
|
||||
<h2>🚩 Submit Flag</h2>
|
||||
<label>Challenge</label>
|
||||
<select id="challenge">
|
||||
<option value="blogpost">blogpost (web)</option>
|
||||
<option value="carbeat">carbeat (pwn)</option>
|
||||
<option value="cdn">cdn (web)</option>
|
||||
<option value="phew">phew (crypto)</option>
|
||||
<option value="sheesh">sheesh (crypto)</option>
|
||||
<option value="warmup">warmup</option>
|
||||
</select>
|
||||
<label>Target (tim musuh yang diserang)</label>
|
||||
<select id="target">
|
||||
<option value="">Memuat target…</option>
|
||||
</select>
|
||||
<label>Flag (yang kamu curi dari target)</label>
|
||||
<input type="text" id="flag" placeholder="GEMASTIK18{...}" autocomplete="off">
|
||||
<button onclick="submitFlag()" style="width:100%;margin-top:16px">Submit Flag</button>
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-activity">
|
||||
<div class="card">
|
||||
<h2>📡 Aktivitas & Serangan</h2>
|
||||
<p style="font-size:13px;color:var(--dim);margin-bottom:12px">
|
||||
🔴 <b>masuk</b> = ada yang menyerang service timmu · 🟢 <b>keluar</b> = kamu menyerang tim lain.
|
||||
Update otomatis tiap 15 detik.
|
||||
</p>
|
||||
<div id="activityFeed" class="feed"><div class="lb-empty">Memuat aktivitas…</div></div>
|
||||
<div class="svc-health" id="svcHealth"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-leaderboard">
|
||||
<div class="card">
|
||||
<h2>🏆 Leaderboard</h2>
|
||||
<p style="font-size:13px;color:var(--dim);margin-bottom:12px">Papan skor flag yang berhasil dicuri. Kamu = baris biru.</p>
|
||||
<div id="lbList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-sla">
|
||||
<div class="card">
|
||||
<h2>📊 SLA & Skor Tim</h2>
|
||||
<p style="font-size:13px;color:var(--dim);margin-bottom:12px">
|
||||
💚 <b>SLA</b> = service timmu hidup (dicek tiap ~30 detik) · 🏆 <b>poin</b> = dapat dari mencuri flag lawan (+100/flag) & bonus SLA saat seluruh service UP (+50).
|
||||
</p>
|
||||
<div id="slaList"><div class="lb-empty">Memuat…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-guide">
|
||||
<div class="card guide">
|
||||
<h2>📖 Panduan SSH & Attack</h2>
|
||||
<h3>1. Apa itu SSH di sini?</h3>
|
||||
<p>Setiap challenge punya service yang harus kamu <b>pertahankan</b> (defense) dan service tim lain yang harus kamu <b>serang</b> (attack). SSH container adalah akses administratif ke challenge tim kamu — untuk riset, patch, atau recovery.</p>
|
||||
|
||||
<h3>2. Login via Web Terminal</h3>
|
||||
<ol>
|
||||
<li>Buka tab <b>🖥️ Terminal SSH</b>.</li>
|
||||
<li>Pilih challenge (misal <code>blogpost</code>).</li>
|
||||
<li>Klik <b>Sambung</b> — otomatis login sebagai <code>ctfuser</code>.</li>
|
||||
</ol>
|
||||
|
||||
<h3>3. Login via SSH biasa (opsional)</h3>
|
||||
<div class="sshbox">ssh ctfuser@43.134.105.109 -p <PORT_SSH>
|
||||
# password = password tim kamu</div>
|
||||
|
||||
<h3>4. Command yang berguna</h3>
|
||||
<ul>
|
||||
<li><code>cat /flag.txt</code> — flag <b>tim kamu sendiri</b> (jangan bocorin!)</li>
|
||||
<li><code>ls /app /srv /opt</code> — cari source code challenge</li>
|
||||
<li><code>ps aux</code> — lihat proses service</li>
|
||||
<li><code>cat /flag.txt | nc <ip_tim_lawan> <port_chall></code> — kirim flag kamu 🤫</li>
|
||||
</ul>
|
||||
|
||||
<h3>5. Alur Attack</h3>
|
||||
<ol>
|
||||
<li>Cari vuln di challenge (SSTI, path traversal, crypto oracle, dll).</li>
|
||||
<li>Exploit service <b>tim lawan</b> — lihat tab <b>🎯 Target Musuh</b>.</li>
|
||||
<li>Baca flag dari container/service lawan.</li>
|
||||
<li>Submit flag di tab <b>🚩 Submit Flag</b> → dapat poin.</li>
|
||||
</ol>
|
||||
|
||||
<h3>6. Aturan</h3>
|
||||
<ul>
|
||||
<li>Jangan ubah flag tim sendiri (mount read-only).</li>
|
||||
<li>Jangan matikan service tim sendiri — SLA dicek panitia.</li>
|
||||
<li>Flag yang valid: <code>GEMASTIK18{...}</code>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TEAM_ID = parseInt(document.querySelector('meta[name="team-id"]').content || '0', 10);
|
||||
let term = null, ws = null;
|
||||
|
||||
// fix all guide links immediately (no auth needed) — /team/0 would 403
|
||||
document.querySelectorAll('[data-guide-link]').forEach(a => a.href = `/team/${TEAM_ID}/guide`);
|
||||
|
||||
async function api(url, opts) { const r = await fetch(url, opts); return r.json(); }
|
||||
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function showView(v) {
|
||||
document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('active', b.dataset.view === v));
|
||||
document.querySelectorAll('.view').forEach(x => x.classList.toggle('active', x.id === 'view-' + v));
|
||||
if (v === 'term' && term) term.focus();
|
||||
if (v === 'targets') loadTargets();
|
||||
if (v === 'activity') loadActivity();
|
||||
if (v === 'leaderboard') loadLeaderboard();
|
||||
if (v === 'sla') loadSla();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const t = await api(`/api/team/${TEAM_ID}/info`);
|
||||
document.getElementById('teamTitle').textContent = t.team.label || ('Team ' + TEAM_ID);
|
||||
document.getElementById('teamDomain').textContent = t.team.domain || '';
|
||||
document.getElementById('teamStatus').textContent = t.team.status === 'running' ? '● RUNNING' : '○ STOPPED';
|
||||
const access = [];
|
||||
for (const [name, p] of Object.entries(t.team.ports)) {
|
||||
if (name === 'receiver' || name === 'panel') continue;
|
||||
access.push(`${name}: ${window.location.hostname}:${p.chall} (ssh :${p.ssh})`);
|
||||
}
|
||||
document.getElementById('accessBox').textContent = access.join('\n');
|
||||
// status table
|
||||
const st = await api(`/api/team/${TEAM_ID}/status`);
|
||||
document.getElementById('statusBody').innerHTML = (st.results || []).map(c => `
|
||||
<tr><td>${esc(c.name)}</td><td>${c.port}</td><td>${c.ssh}</td>
|
||||
<td><span class="dot ${c.alive ? 'up' : 'down'}"></span>${c.alive ? 'UP' : 'DOWN'}</td></tr>`).join('');
|
||||
} catch (e) {
|
||||
document.getElementById('accessBox').textContent = 'Gagal memuat: ' + e.message;
|
||||
// ---------- auth ----------
|
||||
async function checkSession() {
|
||||
const s = await api(`/api/team/${TEAM_ID}/session`);
|
||||
if (s.authed) {
|
||||
document.getElementById('loginScreen').style.display = 'none';
|
||||
document.getElementById('portalScreen').style.display = 'block';
|
||||
document.getElementById('logoutBtn').style.display = '';
|
||||
loadInfo();
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
const pass = document.getElementById('loginPass').value;
|
||||
const err = document.getElementById('loginErr');
|
||||
err.style.display = 'none';
|
||||
const d = await api(`/api/team/${TEAM_ID}/login`, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({pass}),
|
||||
});
|
||||
if (d.ok) {
|
||||
document.getElementById('loginScreen').style.display = 'none';
|
||||
document.getElementById('portalScreen').style.display = 'block';
|
||||
document.getElementById('logoutBtn').style.display = '';
|
||||
loadInfo();
|
||||
loadTargetDropdown(); // target dropdown needs auth — refresh after login
|
||||
loadTargets();
|
||||
} else {
|
||||
err.textContent = 'Password salah. Coba lagi.';
|
||||
err.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api('/api/team/logout', {method:'POST'});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// ---------- info & targets ----------
|
||||
async function loadInfo() {
|
||||
try {
|
||||
const t = (await api(`/api/team/${TEAM_ID}/info`)).team;
|
||||
document.getElementById('teamTitle').textContent = t.label || ('Team ' + TEAM_ID);
|
||||
document.getElementById('teamDomain').textContent = t.domain || '';
|
||||
const st = document.getElementById('teamStatus');
|
||||
if (t.status === 'running') {
|
||||
st.textContent = '● RUNNING'; st.className = 'badge';
|
||||
} else {
|
||||
st.textContent = '○ STOPPED'; st.className = 'badge stopped';
|
||||
}
|
||||
document.getElementById('loginHint').innerHTML = 'Masukkan <b>password SSH tim</b> kamu (dari panitia)';
|
||||
document.getElementById('guideLink').href = `/team/${TEAM_ID}/guide`;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function loadTargets() {
|
||||
const box = document.getElementById('targetList');
|
||||
const d = await api(`/api/team/${TEAM_ID}/targets`);
|
||||
const targets = d.targets || [];
|
||||
if (!targets.length) { box.textContent = 'Belum ada tim musuh.'; return; }
|
||||
let html = '=== TARGET MUSUH ===\n\n';
|
||||
for (const t of targets) {
|
||||
html += `${esc(t.domain)}:${t.port}\n`;
|
||||
}
|
||||
box.textContent = html;
|
||||
}
|
||||
|
||||
// ---------- SSH terminal ----------
|
||||
function connectTerm() {
|
||||
const chall = document.getElementById('termChall').value;
|
||||
const status = document.getElementById('termStatus');
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}/api/team/${TEAM_ID}/ssh/ws?chall=${chall}`;
|
||||
|
||||
if (ws) { try { ws.close(); } catch(e){} }
|
||||
if (term) term.dispose();
|
||||
term = new Terminal({cursorBlink:true, fontSize:13, theme:{background:'#0d1117'}});
|
||||
term.open(document.getElementById('term'));
|
||||
|
||||
ws = new WebSocket(url);
|
||||
status.textContent = 'Menghubungkan…';
|
||||
ws.onopen = () => { status.textContent = `● tersambung ke ${chall} (ctfuser)`; term.focus(); };
|
||||
ws.onmessage = ev => term.write(ev.data);
|
||||
ws.onclose = ev => { status.textContent = '✖ terputus (' + (ev.reason || 'closed') + ')'; };
|
||||
ws.onerror = () => { status.textContent = '✖ error koneksi'; };
|
||||
|
||||
term.onData(d => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(d);
|
||||
});
|
||||
term.onResize(size => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(`__resize__:${size.cols}:${size.rows}`);
|
||||
});
|
||||
document.getElementById('view-term').querySelector('#termWrap').style.height = '480px';
|
||||
}
|
||||
|
||||
// ---------- submit ----------
|
||||
async function submitFlag() {
|
||||
const res = document.getElementById('result');
|
||||
res.className = '';
|
||||
@@ -114,16 +370,146 @@ async function submitFlag() {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
team: TEAM_ID,
|
||||
target: parseInt(document.getElementById('target').value || '0', 10) || TEAM_ID,
|
||||
challenge: document.getElementById('challenge').value,
|
||||
flag: document.getElementById('flag').value.trim(),
|
||||
}),
|
||||
});
|
||||
if (d.success) { res.className = 'ok'; res.innerHTML = '✅ Flag benar!'; }
|
||||
else { res.className = 'err'; res.innerHTML = '❌ ' + esc(d.error || 'Gagal'); }
|
||||
if (d.success) { res.className = 'ok'; res.textContent = '✅ Flag benar! Poin masuk.'; }
|
||||
else { res.className = 'err'; res.textContent = '❌ ' + (d.error || 'Gagal'); }
|
||||
}
|
||||
|
||||
load();
|
||||
setInterval(load, 15000);
|
||||
// target dropdown: all enemy teams (exclude own)
|
||||
async function loadTargetDropdown() {
|
||||
try {
|
||||
const d = await api(`/api/team/${TEAM_ID}/targets`);
|
||||
const sel = document.getElementById('target');
|
||||
const targets = (d.targets || []).filter(t => t.team_idx !== TEAM_ID);
|
||||
const seen = new Map();
|
||||
for (const t of targets) seen.set(t.team_idx, t.team_label || ('Team ' + t.team_idx));
|
||||
sel.innerHTML = '';
|
||||
if (!seen.size) {
|
||||
sel.innerHTML = '<option value="">Tidak ada tim musuh</option>';
|
||||
return;
|
||||
}
|
||||
for (const [idx, label] of seen) {
|
||||
const o = document.createElement('option');
|
||||
o.value = idx; o.textContent = `${label} (tim ${idx})`;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// ---------- activity feed (attacks involving this team) ----------
|
||||
async function loadActivity() {
|
||||
const box = document.getElementById('activityFeed');
|
||||
const healthBox = document.getElementById('svcHealth');
|
||||
try {
|
||||
const d = await api(`/api/team/${TEAM_ID}/activity`);
|
||||
const events = d.events || [];
|
||||
if (!events.length) {
|
||||
box.innerHTML = '<div class="lb-empty">Belum ada aktivitas serangan. Aman… untuk sekarang 😌</div>';
|
||||
} else {
|
||||
box.innerHTML = events.map(e => {
|
||||
const inbound = e.target === TEAM_ID;
|
||||
const cls = inbound ? 'in' : 'out';
|
||||
const tag = inbound
|
||||
? `<span class="feed-tag tag-in">⬇ MASUK</span>${esc(e.attacker_label)} menyerang`
|
||||
: `<span class="feed-tag tag-out">⬆ KELUAR</span>kamu menyerang ${esc(e.target_label)}`;
|
||||
const verdict = e.success
|
||||
? '<span style="color:var(--green)">✅ flag benar</span>'
|
||||
: '<span style="color:var(--red)">❌ flag salah</span>';
|
||||
return `<div class="feed-item ${cls}">${tag} <code>${esc(e.challenge || '')}</code> ${verdict}` +
|
||||
`<span class="feed-time">${esc(e.ts_human || '')}</span></div>`;
|
||||
}).join('');
|
||||
}
|
||||
// own service health chips (all values escaped above via esc())
|
||||
try {
|
||||
const st = await api(`/api/team/${TEAM_ID}/status`);
|
||||
if (st.results) {
|
||||
healthBox.innerHTML = st.results.map(r =>
|
||||
`<span class="svc-chip ${r.alive ? 'up' : 'down'}">${esc(r.name)} ${r.alive ? '● UP' : '○ DOWN'} :${r.port}</span>`
|
||||
).join('');
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
box.innerHTML = '<div class="lb-empty">Gagal memuat aktivitas.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SLA & scoreboard (public endpoint) ----------
|
||||
async function loadSla() {
|
||||
const box = document.getElementById('slaList');
|
||||
try {
|
||||
const d = await api('/api/public/scoreboard');
|
||||
const teams = d.teams || [];
|
||||
if (!teams.length) {
|
||||
box.innerHTML = '<div class="lb-empty">Belum ada data SLA.</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = teams.map(t => {
|
||||
const isMe = t.team === TEAM_ID;
|
||||
const pct = Math.round(t.sla_pct || 0);
|
||||
const color = pct === 100 ? '#2ecc71' : (pct >= 60 ? '#f1c40f' : '#e74c3c');
|
||||
const bar = `<div style="height:8px;background:#1e2a3a;border-radius:4px;margin:6px 0;overflow:hidden"><div style="height:100%;width:${pct}%;background:${color};transition:width .5s"></div></div>`;
|
||||
return `<div class="lb-row ${isMe ? 'me' : ''}">
|
||||
<span class="lb-rank" style="width:34px">${esc(t.badge || t.rank)}</span>
|
||||
<span class="lb-name">${esc(t.label)} ${isMe ? '<small>(kamu)</small>' : ''}</span>
|
||||
<span class="lb-solves" style="width:170px;text-align:right">
|
||||
<b style="color:${color}">${t.alive}/${t.total} (${pct}%)</b>
|
||||
<br><small style="color:var(--dim)">⚑ ${t.solves} flag · 🏆 ${t.points} pts</small>
|
||||
</span>
|
||||
</div>${bar}`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
box.innerHTML = '<div class="lb-empty">Gagal memuat SLA.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// auto-refresh SLA while its tab is open (every 30s)
|
||||
setInterval(async () => {
|
||||
const s = document.getElementById('view-sla');
|
||||
if (s && s.classList.contains('active')) loadSla();
|
||||
}, 30000);
|
||||
|
||||
// ---------- leaderboard (public endpoint) ----------
|
||||
async function loadLeaderboard() {
|
||||
const box = document.getElementById('lbList');
|
||||
try {
|
||||
const d = await api('/api/leaderboard');
|
||||
const teams = d.teams || [];
|
||||
if (!teams.length) {
|
||||
box.innerHTML = '<div class="lb-empty">Belum ada flag yang berhasil dicuri. Jadilah yang pertama! 🚀</div>';
|
||||
return;
|
||||
}
|
||||
const medal = ['🥇','🥈','🥉'];
|
||||
box.innerHTML = teams.map((t, i) => {
|
||||
const isMe = t.team === TEAM_ID;
|
||||
const rank = i < 3 ? medal[i] : `<span class="lb-rank" style="width:28px">${i+1}</span>`;
|
||||
return `<div class="lb-row ${isMe ? 'me' : ''}">
|
||||
<span class="lb-rank">${rank}</span>
|
||||
<span class="lb-name">${esc(t.name)} ${isMe ? '<small>(kamu)</small>' : ''}</span>
|
||||
<span class="lb-solves">⚑ ${t.solves} solve${t.solves > 1 ? 's' : ''} · 🏆 ${t.points ?? 0} pts</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const found = teams.some(t => t.team === TEAM_ID);
|
||||
if (!found) {
|
||||
box.innerHTML += `<div class="lb-row me"><span class="lb-rank">—</span><span class="lb-name">Tim kamu <small>(kamu)</small></span><span class="lb-solves">⚑ 0 solve</span></div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
box.innerHTML = '<div class="lb-empty">Gagal memuat leaderboard.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// auto-refresh activity while its tab is open (every 15s)
|
||||
setInterval(async () => {
|
||||
const a = document.getElementById('view-activity');
|
||||
if (a && a.classList.contains('active')) loadActivity();
|
||||
}, 15000);
|
||||
|
||||
loadTargetDropdown();
|
||||
|
||||
checkSession();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+366
-42
@@ -25,6 +25,7 @@ import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -45,6 +46,88 @@ CHALLENGES = [
|
||||
("warmup", 5, 27),
|
||||
]
|
||||
|
||||
# Points system: base points earned by stealing a flag from another team's
|
||||
# challenge. The SLA bonus is earned by keeping your OWN services alive.
|
||||
POINTS_PER_FLAG = 100
|
||||
SLA_BONUS_POINTS = 50
|
||||
SLA_BONUS_MIN_ALIVE = 6 # bonus only when ALL 6 services are UP
|
||||
|
||||
def _load_points() -> dict:
|
||||
p = TEAMS_DIR / "points.json"
|
||||
if p.exists():
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {"teams": {}}
|
||||
|
||||
def _save_points(data: dict):
|
||||
(TEAMS_DIR / "points.json").write_text(json.dumps(data, indent=2))
|
||||
|
||||
def get_team_points(team_idx: int) -> int:
|
||||
"""Total attack points a team has earned (from flag steals)."""
|
||||
data = _load_points()
|
||||
return int(data.get("teams", {}).get(str(team_idx), {}).get("points", 0))
|
||||
|
||||
def add_attack_points(team_idx: int, points: int, chall: str = "", target: int = 0,
|
||||
ts: float = None) -> dict:
|
||||
"""Award points to a team for stealing a flag. Returns updated tally."""
|
||||
data = _load_points()
|
||||
tid = str(team_idx)
|
||||
me = data["teams"].setdefault(tid, {"points": 0, "events": []})
|
||||
me["points"] = int(me.get("points", 0)) + points
|
||||
me["events"].append({
|
||||
"type": "attack",
|
||||
"challenge": chall,
|
||||
"target": target,
|
||||
"points": points,
|
||||
"ts": ts if ts is not None else time.time(),
|
||||
"ts_human": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
})
|
||||
me["events"] = me["events"][-200:]
|
||||
_save_points(data)
|
||||
return {"team": team_idx, "points": me["points"], "awarded": points}
|
||||
|
||||
def add_sla_bonus(team_idx: int, alive: int, total: int = 6) -> dict:
|
||||
"""Award SLA bonus when all services are UP. Applies bonus at most once
|
||||
per 5-minute window so online checks don't spam the ledger."""
|
||||
data = _load_points()
|
||||
tid = str(team_idx)
|
||||
me = data["teams"].setdefault(tid, {"points": 0, "events": []})
|
||||
now = time.time()
|
||||
last = me.get("last_sla_bonus", 0)
|
||||
if alive >= SLA_BONUS_MIN_ALIVE and total >= SLA_BONUS_MIN_ALIVE:
|
||||
if now - last > 300: # 5 min window
|
||||
me["points"] = int(me.get("points", 0)) + SLA_BONUS_POINTS
|
||||
me["last_sla_bonus"] = now
|
||||
me["events"].append({
|
||||
"type": "sla_bonus",
|
||||
"alive": alive,
|
||||
"total": total,
|
||||
"points": SLA_BONUS_POINTS,
|
||||
"ts": now,
|
||||
"ts_human": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
})
|
||||
me["events"] = me["events"][-200:]
|
||||
_save_points(data)
|
||||
return {"team": team_idx, "points": me["points"], "awarded": SLA_BONUS_POINTS, "bonus": True}
|
||||
return {"team": team_idx, "points": me["points"], "awarded": 0, "bonus": False}
|
||||
|
||||
def team_rank(idx: int) -> int:
|
||||
"""1-based rank of team by total points."""
|
||||
data = _load_points()["teams"]
|
||||
rows = [(int(t), int(v.get("points", 0))) for t, v in data.items() if int(t) > 0]
|
||||
rows.sort(key=lambda x: -x[1])
|
||||
for i, (t, _) in enumerate(rows, 1):
|
||||
if t == idx:
|
||||
return i
|
||||
return len(rows) + 1 # teams with 0 points rank after all scorers
|
||||
|
||||
def team_badge(idx: int) -> str:
|
||||
"""Emoji badge for podium teams."""
|
||||
r = team_rank(idx)
|
||||
return {1: "👑 Juara 1", 2: "🥈 Runner-up", 3: "🥉 Peringkat 3"}.get(r, "")
|
||||
|
||||
PORT_BASE = 30000
|
||||
STEP = 1000
|
||||
|
||||
@@ -67,17 +150,29 @@ def slugify(s: str) -> str:
|
||||
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."""
|
||||
def create_team(idx: int, label: str = None, domain: str = None):
|
||||
"""Build a full team stack dir with unique ports/passwords.
|
||||
|
||||
label -> team display name (leaderboard/topology)
|
||||
domain -> custom subdomain host, e.g. "cyber-warriors" or
|
||||
"cyber-warriors.gemastik.imrnes.team" (full host accepted).
|
||||
Defaults to slugify(label).gemastik.imrnes.team.
|
||||
"""
|
||||
ports = team_ports(idx)
|
||||
team_dir = TEAMS_DIR / f"team{idx}"
|
||||
label = label or f"Tim {idx}"
|
||||
slug = slugify(label)
|
||||
# normalize custom domain -> host under *.gemastik.imrnes.team
|
||||
host = (domain or f"{slug}.gemastik.imrnes.team").strip().lower()
|
||||
host = host.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
if not host.endswith(".gemastik.imrnes.team"):
|
||||
host = f"{host}.gemastik.imrnes.team"
|
||||
slug = host.split(".")[0]
|
||||
state = {
|
||||
"index": idx,
|
||||
"label": label,
|
||||
"slug": slug,
|
||||
"domain": f"{slug}.gemastik.imrnes.team",
|
||||
"domain": host,
|
||||
"ports": ports,
|
||||
"admin_user": f"admin_team{idx}",
|
||||
"admin_pass": gen_password(20),
|
||||
@@ -184,6 +279,30 @@ def create_team(idx: int, label: str = None):
|
||||
(team_dir / "state.json").write_text(json.dumps(state, indent=2))
|
||||
return state
|
||||
|
||||
def update_team(idx: int, label: str = None, domain: str = None) -> dict:
|
||||
"""Update a team's display name and/or custom domain (live re-route).
|
||||
|
||||
- label updates state.json['label'] (leaderboard/topology use this).
|
||||
- domain updates slug + domain and rewrites the Traefik team route.
|
||||
Returns updated state.
|
||||
"""
|
||||
team_dir = TEAMS_DIR / f"team{idx}"
|
||||
if not (team_dir / "state.json").exists():
|
||||
raise FileNotFoundError(f"Team {idx} not created")
|
||||
st = json.loads((team_dir / "state.json").read_text())
|
||||
if label:
|
||||
st["label"] = str(label).strip()[:48] or st["label"]
|
||||
if domain:
|
||||
host = domain.strip().lower().replace("https://", "").replace("http://", "").rstrip("/")
|
||||
if not host.endswith(".gemastik.imrnes.team"):
|
||||
host = f"{host}.gemastik.imrnes.team"
|
||||
st["domain"] = host
|
||||
st["slug"] = host.split(".")[0]
|
||||
(team_dir / "state.json").write_text(json.dumps(st, indent=2))
|
||||
# re-route domain in Traefik immediately
|
||||
ensure_team_domains()
|
||||
return st
|
||||
|
||||
def start_team(idx: int):
|
||||
team_dir = TEAMS_DIR / f"team{idx}"
|
||||
if not (team_dir / "state.json").exists():
|
||||
@@ -209,10 +328,19 @@ def set_ssh_passwords(idx: int):
|
||||
cont = f"{name}_container_team{idx}"
|
||||
pw = st["chall_passwords"][name]
|
||||
cmd = f"echo 'ctfuser:{pw}' | chpasswd"
|
||||
r = subprocess.run(["docker", "exec", cont, "sh", "-c", cmd],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
if r.returncode != 0:
|
||||
print(f"[set_ssh_passwords] {cont}: FAILED ({r.stderr.strip()[:100]})")
|
||||
# retry a few times — right after `compose up`, container may still be booting
|
||||
ok = False
|
||||
for attempt in range(5):
|
||||
r = subprocess.run(["docker", "exec", cont, "sh", "-c", cmd],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
if r.returncode == 0:
|
||||
ok = True
|
||||
break
|
||||
time.sleep(3)
|
||||
if not ok:
|
||||
print(f"[set_ssh_passwords] {cont}: FAILED after retries ({r.stderr.strip()[:100]})")
|
||||
else:
|
||||
print(f"[set_ssh_passwords] {cont}: OK")
|
||||
|
||||
def stop_team(idx: int):
|
||||
team_dir = TEAMS_DIR / f"team{idx}"
|
||||
@@ -226,40 +354,32 @@ def stop_team(idx: int):
|
||||
return st
|
||||
|
||||
def _start_receiver(idx: int):
|
||||
"""Launch team's receiver as a detached uvicorn process with team env."""
|
||||
"""Start team's receiver via systemd service (independent of panel cgroup)."""
|
||||
team_dir = TEAMS_DIR / f"team{idx}"
|
||||
recv_dir = team_dir / "receiver"
|
||||
st = json.loads((team_dir / "state.json").read_text())
|
||||
port = st["ports"]["receiver"]
|
||||
pidfile = team_dir / "receiver.pid"
|
||||
# kill existing
|
||||
_stop_receiver(idx)
|
||||
env = dict(os.environ)
|
||||
env["PYTHONPATH"] = str(recv_dir)
|
||||
env["COMPOSE_LOCATION"] = str(team_dir / "services" / "docker-compose.yml")
|
||||
# expose team ports/containers so challenge classes bind the right targets
|
||||
for ch in st["ports"]:
|
||||
if ch in ("receiver", "panel"):
|
||||
continue
|
||||
env[f"CHALLENGE_PORT_{ch.upper()}"] = str(st["ports"][ch]["chall"])
|
||||
env[f"CHALLENGE_CONTAINER_{ch.upper()}"] = f"{ch}_container_team{idx}"
|
||||
proc = subprocess.Popen(
|
||||
[str(RECEIVER_SRC.parent / "receiver" / ".venv" / "bin" / "python"),
|
||||
"-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", str(port)],
|
||||
cwd=str(recv_dir), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
pidfile.write_text(str(proc.pid))
|
||||
# ensure the per-team systemd unit exists with current env
|
||||
subprocess.run([sys.executable, str(BASE / "panel" / "gen_receiver_services.py"), "start"],
|
||||
check=False, capture_output=True)
|
||||
subprocess.run(["systemctl", "restart", f"gemastik-receiver-team{idx}.service"],
|
||||
check=False, capture_output=True)
|
||||
# best-effort pid bookkeeping for ps
|
||||
try:
|
||||
out = subprocess.run(["systemctl", "show", "-p", "MainPID", f"gemastik-receiver-team{idx}.service"],
|
||||
capture_output=True, text=True).stdout
|
||||
pid = out.strip().split("=")[1]
|
||||
pidfile.write_text(pid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _stop_receiver(idx: int):
|
||||
"""Stop team's receiver via systemd service."""
|
||||
team_dir = TEAMS_DIR / f"team{idx}"
|
||||
pidfile = team_dir / "receiver.pid"
|
||||
if pidfile.exists():
|
||||
try:
|
||||
pid = int(pidfile.read_text().strip())
|
||||
os.kill(pid, 15)
|
||||
except Exception:
|
||||
pass
|
||||
pidfile.unlink(missing_ok=True)
|
||||
subprocess.run(["systemctl", "stop", f"gemastik-receiver-team{idx}.service"],
|
||||
check=False, capture_output=True)
|
||||
pidfile.unlink(missing_ok=True)
|
||||
|
||||
def team_logs(idx: int, service: str = None, tail: int = 100):
|
||||
team_dir = TEAMS_DIR / f"team{idx}"
|
||||
@@ -372,9 +492,15 @@ def randomize_flags(idx: int) -> dict:
|
||||
return mapping
|
||||
|
||||
|
||||
def submit_flag(team_idx: int, chall: str, flag: str, team_name: str = "") -> dict:
|
||||
"""Validate a submitted flag against the owning team's challenge flag."""
|
||||
team_dir = TEAMS_DIR / f"team{team_idx}"
|
||||
def submit_flag(target_idx: int, chall: str, flag: str,
|
||||
attacker_idx: int = None, attacker_name: str = "") -> dict:
|
||||
"""Validate a submitted flag against the TARGET team's challenge flag.
|
||||
|
||||
A/D semantics: attacker_idx scores by stealing target_idx's flag.
|
||||
Back-compat: attacker_idx defaults to target_idx (self-submit).
|
||||
"""
|
||||
attacker_idx = attacker_idx if attacker_idx else target_idx
|
||||
team_dir = TEAMS_DIR / f"team{target_idx}"
|
||||
if not (team_dir / "state.json").exists():
|
||||
return {"success": False, "error": "unknown team"}
|
||||
flags_dir = team_dir / "receiver" / "flags"
|
||||
@@ -382,23 +508,221 @@ def submit_flag(team_idx: int, chall: str, flag: str, team_name: str = "") -> di
|
||||
if not expected:
|
||||
return {"success": False, "error": "challenge not found"}
|
||||
if flag.strip() != expected:
|
||||
_log_attack(attacker_idx, target_idx, chall, flag[:24], success=False)
|
||||
return {"success": False, "error": "wrong flag"}
|
||||
# record leaderboard entry
|
||||
_log_attack(attacker_idx, target_idx, chall, flag, success=True)
|
||||
# record leaderboard entry — score goes to the ATTACKER
|
||||
lb_path = TEAMS_DIR / "leaderboard.json"
|
||||
lb = json.loads(lb_path.read_text()) if lb_path.exists() else {"solves": []}
|
||||
entry = {
|
||||
"team": team_idx,
|
||||
"team_name": team_name or f"Team {team_idx}",
|
||||
"team": attacker_idx,
|
||||
"team_name": attacker_name or f"Team {attacker_idx}",
|
||||
"challenge": chall,
|
||||
"target": target_idx,
|
||||
"flag": flag,
|
||||
"ts": time.time(),
|
||||
"ts_human": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
# avoid double-solve duplicates (same flag + same team)
|
||||
if not any(e["team"] == team_idx and e["challenge"] == chall for e in lb["solves"]):
|
||||
# avoid double-solve duplicates (same flag + same attacker team)
|
||||
if not any(e["team"] == attacker_idx and e["challenge"] == chall
|
||||
and e.get("target") == target_idx for e in lb["solves"]):
|
||||
lb["solves"].append(entry)
|
||||
lb_path.write_text(json.dumps(lb, indent=2))
|
||||
return {"success": True, "team": team_idx, "challenge": chall}
|
||||
# NEW: award attack points (first solve only)
|
||||
pts = add_attack_points(attacker_idx, POINTS_PER_FLAG, chall=chall, target=target_idx)
|
||||
else:
|
||||
pts = {"team": attacker_idx, "points": get_team_points(attacker_idx), "awarded": 0}
|
||||
return {"success": True, "team": attacker_idx, "target": target_idx,
|
||||
"challenge": chall, "points": pts}
|
||||
|
||||
def _log_attack(attacker_team: int, target_team: int, target_chall: str, flag_hint: str, success: bool):
|
||||
"""Append a row to the attack log (used by the topology attack visualizer)."""
|
||||
try:
|
||||
ap = TEAMS_DIR / "attacks.json"
|
||||
log = json.loads(ap.read_text()) if ap.exists() else {"events": []}
|
||||
log["events"].append({
|
||||
"attacker": attacker_team,
|
||||
"target": target_team,
|
||||
"challenge": target_chall,
|
||||
"flag_hint": flag_hint,
|
||||
"success": success,
|
||||
"ts": time.time(),
|
||||
"ts_human": datetime.now().strftime("%H:%M:%S"),
|
||||
})
|
||||
# keep last 200
|
||||
log["events"] = log["events"][-200:]
|
||||
ap.write_text(json.dumps(log, indent=2))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def reset_scores() -> dict:
|
||||
"""Wipe the leaderboard (all solves removed) and the points ledger."""
|
||||
lb_path = TEAMS_DIR / "leaderboard.json"
|
||||
lb_path.write_text(json.dumps({"solves": []}, indent=2))
|
||||
(TEAMS_DIR / "points.json").write_text(json.dumps({"teams": {}}, indent=2))
|
||||
return {"ok": True, "cleared": True}
|
||||
|
||||
def reset_environment() -> dict:
|
||||
"""Full env reset: stop all teams, remove containers + receiver services,
|
||||
delete team dirs (fresh for re-create). Keeps panel + images."""
|
||||
results = []
|
||||
for d in sorted(TEAMS_DIR.glob("team*")):
|
||||
sf = d / "state.json"
|
||||
if not sf.exists():
|
||||
continue
|
||||
st = json.loads(sf.read_text())
|
||||
idx = st["index"]
|
||||
try:
|
||||
stop_team(idx) # compose down + stop receiver service + set status
|
||||
except Exception as e:
|
||||
results.append({"team": idx, "ok": False, "err": str(e)})
|
||||
continue
|
||||
# remove the per-team systemd receiver unit
|
||||
subprocess.run(["systemctl", "disable", f"gemastik-receiver-team{idx}.service"],
|
||||
check=False, capture_output=True)
|
||||
subprocess.run(["systemctl", "stop", f"gemastik-receiver-team{idx}.service"],
|
||||
check=False, capture_output=True)
|
||||
unit = Path("/etc/systemd/system") / f"gemastik-receiver-team{idx}.service"
|
||||
if unit.exists():
|
||||
unit.unlink()
|
||||
results.append({"team": idx, "ok": True})
|
||||
subprocess.run(["systemctl", "daemon-reload"], check=False)
|
||||
# remove team dirs entirely (fresh for re-create)
|
||||
for d in sorted(TEAMS_DIR.glob("team*")):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
# wipe leaderboard too
|
||||
reset_scores()
|
||||
# drop team domains from Traefik (regenerate — no teams left)
|
||||
try:
|
||||
ensure_team_domains()
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "stopped": results, "teams_dir": str(TEAMS_DIR)}
|
||||
|
||||
|
||||
# ---- SLA + scoring helpers ----
|
||||
|
||||
_SLA_CACHE = {"ts": 0, "data": None}
|
||||
|
||||
|
||||
def _probe_one(recv_port: int, au: str, ap: str, name: str, st: dict) -> dict:
|
||||
import urllib.request, urllib.error, base64
|
||||
url = f"http://127.0.0.1:{recv_port}/check/{name}"
|
||||
ok = False
|
||||
try:
|
||||
req = urllib.request.Request(url)
|
||||
token = base64.b64encode(f"{au}:{ap}".encode()).decode()
|
||||
req.add_header("Authorization", f"Basic {token}")
|
||||
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||
body = resp.read().decode()
|
||||
import json as _j
|
||||
ok = bool(_j.loads(body).get("success")) if resp.status == 200 else False
|
||||
except Exception:
|
||||
ok = False
|
||||
return {"name": name,
|
||||
"port": st["ports"][name]["chall"],
|
||||
"ssh": st["ports"][name]["ssh"],
|
||||
"alive": ok}
|
||||
|
||||
|
||||
def probe_team_sla_fast(idx: int) -> dict:
|
||||
"""Probe one team's challenges. Sequential per challenge (receivers are
|
||||
sync Flask; parallel probes overload them and cause false timeouts).
|
||||
~30-60s worst case for 6 challs; results are cached by the refresher."""
|
||||
td = TEAMS_DIR / f"team{idx}"
|
||||
sf = td / "state.json"
|
||||
if not sf.exists():
|
||||
return {"team": idx, "alive": 0, "total": 0, "per_challenge": [], "error": "no team"}
|
||||
st = json.loads(sf.read_text())
|
||||
recv_port = st["ports"]["receiver"]
|
||||
au, ap = st.get("admin_user", ""), st.get("admin_pass", "")
|
||||
results = []
|
||||
for name, _, _ in CHALLENGES:
|
||||
try:
|
||||
results.append(_probe_one(recv_port, au, ap, name, st))
|
||||
except Exception:
|
||||
results.append({"name": name, "port": 0, "ssh": 0, "alive": False})
|
||||
alive = sum(1 for r in results if r["alive"])
|
||||
return {"team": idx, "alive": alive, "total": len(results),
|
||||
"per_challenge": results}
|
||||
|
||||
|
||||
def _scoreboard_row(st: dict) -> dict:
|
||||
"""Build one scoreboard row for a team state (runs in a worker thread)."""
|
||||
idx = st["index"]
|
||||
sla = probe_team_sla_fast(idx)
|
||||
pts = get_team_points(idx)
|
||||
solves = 0
|
||||
lb_path = TEAMS_DIR / "leaderboard.json"
|
||||
if lb_path.exists():
|
||||
try:
|
||||
lb = json.loads(lb_path.read_text())
|
||||
solves = sum(1 for e in lb["solves"] if e["team"] == idx)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"team": idx,
|
||||
"label": st.get("label") or f"Team {idx}",
|
||||
"domain": st.get("domain") or "",
|
||||
"alive": sla["alive"],
|
||||
"total": sla["total"],
|
||||
"sla_pct": round(100 * sla["alive"] / sla["total"], 1) if sla["total"] else 0,
|
||||
"points": pts,
|
||||
"solves": solves,
|
||||
}
|
||||
|
||||
|
||||
def sla_status_all() -> dict:
|
||||
"""Per-team SLA (own team view) + aggregate scoreboard with points.
|
||||
|
||||
Reads a cache that a background thread keeps fresh (~every 30s), so the
|
||||
HTTP endpoint is instant. First call (cold cache) blocks up to ~60s."""
|
||||
import time as _t
|
||||
if _SLA_CACHE["data"] is not None and _t.time() - _SLA_CACHE["ts"] < 60:
|
||||
return _SLA_CACHE["data"]
|
||||
_build_scoreboard()
|
||||
return _SLA_CACHE["data"]
|
||||
|
||||
|
||||
def _build_scoreboard() -> dict:
|
||||
"""Build the scoreboard: probes teams 2-at-a-time (6 chall parallel per
|
||||
team) to avoid overwhelming the receivers, then caches the result."""
|
||||
import time as _t
|
||||
import concurrent.futures
|
||||
states = []
|
||||
for d in sorted(TEAMS_DIR.glob("team*")):
|
||||
sf = d / "state.json"
|
||||
if sf.exists():
|
||||
states.append(json.loads(sf.read_text()))
|
||||
teams = []
|
||||
# probe one team at a time (each team = 6 sequential chall checks)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
|
||||
for row in ex.map(_scoreboard_row, states):
|
||||
teams.append(row)
|
||||
teams.sort(key=lambda x: (-x["points"], -x["solves"], x["team"]))
|
||||
for i, t in enumerate(teams, 1):
|
||||
t["rank"] = i
|
||||
t["badge"] = {1: "👑 Juara 1", 2: "🥈 Runner-up", 3: "🥉 Peringkat 3"}.get(i, "")
|
||||
_SLA_CACHE["ts"] = _t.time()
|
||||
_SLA_CACHE["data"] = {"teams": teams, "ts": _t.time()}
|
||||
return _SLA_CACHE["data"]
|
||||
|
||||
|
||||
def start_sla_refresher():
|
||||
"""Background daemon thread: keeps the SLA scoreboard cache warm."""
|
||||
import threading
|
||||
def _loop():
|
||||
import time as _t
|
||||
while True:
|
||||
try:
|
||||
_build_scoreboard()
|
||||
except Exception:
|
||||
pass
|
||||
_t.sleep(30)
|
||||
t = threading.Thread(target=_loop, daemon=True, name="sla-refresher")
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Allow apt to work on hosts whose clock is past GPG key expiry (2026+)
|
||||
Acquire::AllowInsecureRepositories "true";
|
||||
Acquire::AllowDowngradeToInsecureRepositories "true";
|
||||
Apt::Get::AllowUnauthenticated "true";
|
||||
Apt::Get::force-yes "true";
|
||||
@@ -12,15 +12,24 @@ WORKDIR /app
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV DB_PATH=/data/app.db
|
||||
|
||||
# Allow apt on hosts whose clock is past GPG key expiry (2026+)
|
||||
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
|
||||
|
||||
# Install packages we need (exiftool, sqlite3, sshd, build tools, editor)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
RUN apt-get -o Acquire::AllowInsecureRepositories=true update && \
|
||||
apt-get -y --allow-unauthenticated install --no-install-recommends \
|
||||
libimage-exiftool-perl \
|
||||
sqlite3 \
|
||||
openssh-server \
|
||||
build-essential \
|
||||
bash \
|
||||
nano \
|
||||
vim \
|
||||
curl \
|
||||
wget \
|
||||
netcat-openbsd \
|
||||
git \
|
||||
python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create an unprivileged user for running the app / SSH access
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Allow apt to work on hosts whose clock is past GPG key expiry (2026+)
|
||||
Acquire::AllowInsecureRepositories "true";
|
||||
Acquire::AllowDowngradeToInsecureRepositories "true";
|
||||
Apt::Get::AllowUnauthenticated "true";
|
||||
Apt::Get::force-yes "true";
|
||||
@@ -6,8 +6,11 @@ ARG PASSWORD=root
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
WORKDIR /home/ctfuser/chall
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
# Allow apt on hosts whose clock is past GPG key expiry (2026+)
|
||||
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
|
||||
|
||||
RUN apt-get -o Acquire::AllowInsecureRepositories=true update && \
|
||||
apt-get -y --allow-unauthenticated install --no-install-recommends \
|
||||
make \
|
||||
g++ \
|
||||
socat \
|
||||
@@ -16,6 +19,10 @@ RUN apt-get update && \
|
||||
bash \
|
||||
nano \
|
||||
vim \
|
||||
curl \
|
||||
wget \
|
||||
netcat-openbsd \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -d /home/ctfuser -s /bin/bash ctfuser && echo "ctfuser:${PASSWORD}" | chpasswd
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Allow apt to work on hosts whose clock is past GPG key expiry (2026+)
|
||||
Acquire::AllowInsecureRepositories "true";
|
||||
Acquire::AllowDowngradeToInsecureRepositories "true";
|
||||
Apt::Get::AllowUnauthenticated "true";
|
||||
Apt::Get::force-yes "true";
|
||||
+11
-2
@@ -13,15 +13,24 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
# Allow apt on hosts whose clock is past GPG key expiry (2026+)
|
||||
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
|
||||
|
||||
# Install packages we need (exiftool, sqlite3, sshd, build tools, editor)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
RUN apt-get -o Acquire::AllowInsecureRepositories=true update && \
|
||||
apt-get -y --allow-unauthenticated install --no-install-recommends \
|
||||
libimage-exiftool-perl \
|
||||
sqlite3 \
|
||||
openssh-server \
|
||||
build-essential \
|
||||
bash \
|
||||
nano \
|
||||
vim \
|
||||
curl \
|
||||
wget \
|
||||
netcat-openbsd \
|
||||
git \
|
||||
python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create an unprivileged user for running the app / SSH access
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Allow apt to work on hosts whose clock is past GPG key expiry (2026+)
|
||||
Acquire::AllowInsecureRepositories "true";
|
||||
Acquire::AllowDowngradeToInsecureRepositories "true";
|
||||
Apt::Get::AllowUnauthenticated "true";
|
||||
Apt::Get::force-yes "true";
|
||||
@@ -5,13 +5,21 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV HOME=/home/ctfuser
|
||||
WORKDIR /home/ctfuser/chall
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Allow apt on hosts whose clock is past GPG key expiry (2026+)
|
||||
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
|
||||
|
||||
RUN apt-get -o Acquire::AllowInsecureRepositories=true update && apt-get -y --allow-unauthenticated install --no-install-recommends \
|
||||
openssh-server \
|
||||
build-essential \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
python3-dev \
|
||||
socat \
|
||||
vim \
|
||||
curl \
|
||||
wget \
|
||||
netcat-openbsd \
|
||||
git \
|
||||
bash \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Allow apt to work on hosts whose clock is past GPG key expiry (2026+)
|
||||
Acquire::AllowInsecureRepositories "true";
|
||||
Acquire::AllowDowngradeToInsecureRepositories "true";
|
||||
Apt::Get::AllowUnauthenticated "true";
|
||||
Apt::Get::force-yes "true";
|
||||
@@ -5,13 +5,21 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV HOME=/home/ctfuser
|
||||
WORKDIR /home/ctfuser/chall
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Allow apt on hosts whose clock is past GPG key expiry (2026+)
|
||||
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
|
||||
|
||||
RUN apt-get -o Acquire::AllowInsecureRepositories=true update && apt-get -y --allow-unauthenticated install --no-install-recommends \
|
||||
openssh-server \
|
||||
build-essential \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
python3-dev \
|
||||
socat \
|
||||
vim \
|
||||
curl \
|
||||
wget \
|
||||
netcat-openbsd \
|
||||
git \
|
||||
bash \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Allow apt to work on hosts whose clock is past GPG key expiry (2026+)
|
||||
Acquire::AllowInsecureRepositories "true";
|
||||
Acquire::AllowDowngradeToInsecureRepositories "true";
|
||||
Apt::Get::AllowUnauthenticated "true";
|
||||
Apt::Get::force-yes "true";
|
||||
@@ -6,14 +6,17 @@ COPY src/main.go .
|
||||
RUN go build -o challenge main.go
|
||||
|
||||
# Final stage
|
||||
FROM public.ecr.aws/docker/library/ubuntu:20.04
|
||||
FROM public.ecr.aws/docker/library/ubuntu:24.04
|
||||
|
||||
ARG PASSWORD
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Allow apt on hosts whose clock is past GPG key expiry (2026+)
|
||||
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
|
||||
|
||||
# Install necessary packages
|
||||
RUN apt-get update && apt-get install -y nano openssh-server python3 curl netcat-traditional wget sudo nginx golang-go && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get -o Acquire::AllowInsecureRepositories=true update && apt-get -y --allow-unauthenticated install nano vim git openssh-server python3 python3-pip curl netcat-traditional wget sudo nginx golang-go && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create ctfuser and set password
|
||||
RUN useradd -m -d /home/ctfuser ctfuser && echo ctfuser:${PASSWORD} | chpasswd
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Allow apt to work on hosts whose clock is past GPG key expiry (2026+)
|
||||
Acquire::AllowInsecureRepositories "true";
|
||||
Acquire::AllowDowngradeToInsecureRepositories "true";
|
||||
Apt::Get::AllowUnauthenticated "true";
|
||||
Apt::Get::force-yes "true";
|
||||
Reference in new issue
Block a user