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:
root
2026-09-23 16:18:11 +08:00
parent 265159e9d8
commit 5e44a70049
4 changed files with 271 additions and 5 deletions
+24 -4
View File
@@ -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); }