feat: team-specific subdomains + portal tim
- create_team now takes label -> slug -> <slug>.gemastik.imrnes.team - ensure_team_domains() writes Traefik dynamic config (gemastik-teams.yaml) - team portal at /team/<idx> (public, shows chall ports, SSH, submit form) - /api/team/<idx>/info + /api/team/<idx>/status (server-side receiver auth) - UI: name inputs per team (set count -> labels), domain link in card - delete team endpoint drops its domain
This commit is contained in:
+58
-1
@@ -95,6 +95,60 @@ async def submit_page(req: Request):
|
||||
"""Public flag submission page for teams (no login)."""
|
||||
return HTMLResponse((BASE_DIR / "static" / "submit.html").read_text())
|
||||
|
||||
|
||||
# ============ Per-team portal (public via <slug>.gemastik.imrnes.team) ============
|
||||
|
||||
@app.get("/team/{idx}", response_class=HTMLResponse)
|
||||
async def team_portal(idx: int, req: Request):
|
||||
"""Public portal page for a team (served at <slug>.gemastik.imrnes.team/team/<idx>)."""
|
||||
html = (BASE_DIR / "static" / "team.html").read_text()
|
||||
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.get("/api/team/{idx}/info")
|
||||
async def api_team_info(idx: int):
|
||||
"""Public: basic team info for the portal (no secrets besides per-team SSH creds)."""
|
||||
td = orch.TEAMS_DIR / f"team{idx}"
|
||||
if not (td / "state.json").exists():
|
||||
raise HTTPException(404, "Team not found")
|
||||
st = json.loads((td / "state.json").read_text())
|
||||
# expose only what a team needs: label, domain, ports, ssh creds, status
|
||||
return {"team": {
|
||||
"index": st.get("index"),
|
||||
"label": st.get("label"),
|
||||
"slug": st.get("slug"),
|
||||
"domain": st.get("domain"),
|
||||
"status": st.get("status"),
|
||||
"ports": st.get("ports"),
|
||||
"ssh_user": st.get("ssh_user"),
|
||||
"ssh_pass": st.get("ssh_pass"),
|
||||
}}
|
||||
|
||||
|
||||
@app.get("/api/team/{idx}/status")
|
||||
async def api_team_status(idx: int):
|
||||
"""Public: SLA status for a team's challenges (no auth — read-only health)."""
|
||||
td = orch.TEAMS_DIR / f"team{idx}"
|
||||
if not (td / "state.json").exists():
|
||||
raise HTTPException(404, "Team not found")
|
||||
st = json.loads ((td / "state.json").read_text())
|
||||
recv_port = st["ports"]["receiver"]
|
||||
# use team's receiver admin creds (server-side only; never sent to browser)
|
||||
au, ap = st.get("admin_user", ""), st.get("admin_pass", "")
|
||||
results = []
|
||||
for name, coff, soff in orch.CHALLENGES:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
resp = await client.get(f"http://127.0.0.1:{recv_port}/check/{name}",
|
||||
auth=(au, ap))
|
||||
ok = bool(resp.json().get("success")) if resp.status_code == 200 else False
|
||||
except Exception:
|
||||
ok = False
|
||||
results.append({"name": name, "port": st["ports"][name]["chall"],
|
||||
"ssh": st["ports"][name]["ssh"], "alive": ok})
|
||||
return {"results": results}
|
||||
|
||||
@app.post("/api/login")
|
||||
async def api_login(req: Request):
|
||||
data = await req.json()
|
||||
@@ -214,12 +268,15 @@ async def api_teams_set(req: Request):
|
||||
n = int(data.get("count", 0))
|
||||
if n < 0 or n > 50:
|
||||
raise HTTPException(400, "Team count must be 0-50")
|
||||
labels = data.get("labels") or {} # { "1": "Tim Satu", ... }
|
||||
created = []
|
||||
for i in range(1, n + 1):
|
||||
td = orch.TEAMS_DIR / f"team{i}"
|
||||
if not td.exists():
|
||||
st = orch.create_team(i, data.get("label_prefix", "Tim"))
|
||||
label = labels.get(str(i)) or labels.get(i) or f"Tim {i}"
|
||||
st = orch.create_team(i, label)
|
||||
created.append(st["index"])
|
||||
orch.ensure_team_domains()
|
||||
return {"created": created, "total": len(orch.list_teams())}
|
||||
|
||||
@app.post("/api/teams/start")
|
||||
|
||||
+24
-4
@@ -132,12 +132,13 @@
|
||||
<div class="view" id="view-teams">
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="team-head">
|
||||
<div><b style="color:#5ad1ff">Jumlah Team</b> <span style="font-size:11px;color:#718096">(auto-create node per team)</span></div>
|
||||
<div><b style="color:#5ad1ff">Jumlah Team</b> <span style="font-size:11px;color:#718096">(auto-create node + domain per team)</span></div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="number" id="teamCount" min="0" max="50" value="0" style="width:90px">
|
||||
<input type="number" id="teamCount" min="0" max="50" value="0" style="width:90px" oninput="teamCountChanged()">
|
||||
<button class="primary" onclick="setTeams()">Set & Buat</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="teamNameInputs" style="margin-top:10px;display:flex;flex-direction:column;gap:6px"></div>
|
||||
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="primary" onclick="startAllTeams()">▶ Start CTF (semua)</button>
|
||||
<button class="danger" onclick="stopAllTeams()">⏹ Stop CTF (semua)</button>
|
||||
@@ -414,6 +415,7 @@ async function loadTeams() {
|
||||
let html = '';
|
||||
for (const t of d.teams) {
|
||||
const alive = t.status === 'running';
|
||||
const dom = t.domain || '';
|
||||
html += `<div class="card ${alive ? '' : 'dead'}">
|
||||
<div class="team-head">
|
||||
<span class="ch-name">${esc(t.label || ('Team ' + t.index))}</span>
|
||||
@@ -423,7 +425,7 @@ 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>
|
||||
<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>` : ''}
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="primary" onclick="startTeam(${t.index})">▶ Start</button>
|
||||
@@ -440,13 +442,31 @@ async function loadTeams() {
|
||||
|
||||
async function setTeams() {
|
||||
const n = parseInt(document.getElementById('teamCount').value || '0', 10);
|
||||
const labels = {};
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const inp = document.getElementById('teamNameInputs')?.querySelector(`input[data-idx="${i}"]`);
|
||||
if (inp && inp.value.trim()) labels[i] = inp.value.trim();
|
||||
}
|
||||
try {
|
||||
const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n})});
|
||||
const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n, labels})});
|
||||
toast(`Team dibuat: ${d.created.join(', ') || 'tidak ada yang baru'} · total ${d.total}`, false);
|
||||
loadTeams();
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
function teamCountChanged() {
|
||||
const n = parseInt(document.getElementById('teamCount').value || '0', 10);
|
||||
const box = document.getElementById('teamNameInputs');
|
||||
let html = '';
|
||||
for (let i = 1; i <= n; i++) {
|
||||
html += `<div style="display:flex;align-items:center;gap:8px">
|
||||
<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">
|
||||
</div>`;
|
||||
}
|
||||
box.innerHTML = html;
|
||||
}
|
||||
|
||||
async function startTeam(idx) {
|
||||
try { await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} start (build bisa makan waktu)`, false); setTimeout(loadTeams, 3000); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Portal Team — Gemastik A/D</title>
|
||||
<style>
|
||||
:root { --bg:#0b0e14; --panel:#151a23; --panel2:#1d2430; --line:#2a3444; --txt:#e6edf3; --dim:#8b98a9; --accent:#38bdf8; --green:#34d399; --red:#f87171; }
|
||||
* { 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; }
|
||||
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; }
|
||||
.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; }
|
||||
.kv b { color:var(--dim); font-weight:500; }
|
||||
.kv span { font-family:ui-monospace,monospace; }
|
||||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
th, td { text-align:left; padding:8px 6px; border-bottom:1px solid var(--line); }
|
||||
th { color:var(--dim); font-weight:500; }
|
||||
.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; }
|
||||
button:hover { filter:brightness(1.1); }
|
||||
#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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="logo">G</div>
|
||||
<div style="flex:1">
|
||||
<h1 id="teamTitle">Portal Tim</h1>
|
||||
<div class="sub" id="teamDomain"></div>
|
||||
</div>
|
||||
<span class="badge" id="teamStatus">…</span>
|
||||
</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.
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TEAM_ID = parseInt(document.querySelector('meta[name="team-id"]').content || '0', 10);
|
||||
|
||||
async function api(url, opts) { const r = await fetch(url, opts); return r.json(); }
|
||||
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitFlag() {
|
||||
const res = document.getElementById('result');
|
||||
res.className = '';
|
||||
const d = await api('/api/flag/submit', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
team: 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'); }
|
||||
}
|
||||
|
||||
load();
|
||||
setInterval(load, 15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -61,14 +61,23 @@ def team_ports(idx: int) -> dict:
|
||||
def gen_password(n=16):
|
||||
return uuid.uuid4().hex[:n]
|
||||
|
||||
def slugify(s: str) -> str:
|
||||
"""'Tim Satu Beta!' -> 'tim-satu-beta'"""
|
||||
s = re.sub(r"[^a-zA-Z0-9\s-]", "", s.lower()).strip()
|
||||
s = re.sub(r"[\s_]+", "-", s)
|
||||
return s or "team"
|
||||
|
||||
def create_team(idx: int, label: str = None):
|
||||
"""Build a full team stack dir with unique ports/passwords."""
|
||||
ports = team_ports(idx)
|
||||
team_dir = TEAMS_DIR / f"team{idx}"
|
||||
label = label or f"Tim {idx}"
|
||||
slug = slugify(label)
|
||||
state = {
|
||||
"index": idx,
|
||||
"label": label,
|
||||
"slug": slug,
|
||||
"domain": f"{slug}.gemastik.imrnes.team",
|
||||
"ports": ports,
|
||||
"admin_user": f"admin_team{idx}",
|
||||
"admin_pass": gen_password(20),
|
||||
@@ -267,6 +276,57 @@ def team_logs(idx: int, service: str = None, tail: int = 100):
|
||||
data[s] = f"ERR: {e}"
|
||||
return data
|
||||
|
||||
def ensure_team_domains() -> str:
|
||||
"""Write Traefik dynamic config for each team domain -> panel (:18081).
|
||||
|
||||
Each team gets <slug>.gemastik.imrnes.team. The panel routes
|
||||
/team/<idx> to that team's portal page (public, no panitia login),
|
||||
and /api/team/<idx>/* serves team-scoped API. Writing this into the
|
||||
same dynamic dir Traefik watches means domains appear automatically.
|
||||
Returns a status string for logging.
|
||||
"""
|
||||
traefik_dir = Path("/data/coolify/proxy/dynamic")
|
||||
traefik_dir.mkdir(parents=True, exist_ok=True)
|
||||
teams = list_teams()
|
||||
out = []
|
||||
changed = False
|
||||
for t in teams:
|
||||
slug = t.get("slug") or slugify(t.get("label", ""))
|
||||
if not slug:
|
||||
continue
|
||||
# backfill slug/domain for teams created before this feature
|
||||
if not t.get("slug"):
|
||||
td = TEAMS_DIR / f"team{t['index']}"
|
||||
st = json.loads((td / "state.json").read_text())
|
||||
st["slug"] = slug
|
||||
st["domain"] = f"{slug}.gemastik.imrnes.team"
|
||||
(td / "state.json").write_text(json.dumps(st, indent=2))
|
||||
changed = True
|
||||
host = f"{slug}.gemastik.imrnes.team"
|
||||
out.append(f""" team-{slug}-http:
|
||||
rule: Host(`{host}`)
|
||||
entryPoints:
|
||||
- http
|
||||
service: gemastik-panel-service
|
||||
middlewares:
|
||||
- redirect-to-https
|
||||
team-{slug}-https:
|
||||
rule: Host(`{host}`)
|
||||
entryPoints:
|
||||
- https
|
||||
service: gemastik-panel-service
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
domains:
|
||||
- main: {host}
|
||||
""")
|
||||
if not out:
|
||||
return "no teams to route"
|
||||
# Keep the team domain block in its own file so the main gemastik.yaml stays untouched
|
||||
(traefik_dir / "gemastik-teams.yaml").write_text("http:\n routers:\n" + "".join(out))
|
||||
return f"wrote {len(out)} team domain(s) in gemastik-teams.yaml"
|
||||
|
||||
|
||||
def list_teams() -> list:
|
||||
out = []
|
||||
if not TEAMS_DIR.exists():
|
||||
|
||||
Reference in New Issue
Block a user