- guide link now server-side replaced to /team/<idx>/guide (no /team/0 403) - _check_team_host() applied to ALL team endpoints (login, info, targets, status, guide, portal, ssh-ws): host must match team domain; panel/gemastik host only with admin session. Cross-domain session reuse -> 403. - host check BEFORE auth on info/targets (no team-existence oracle) - loading overlay (spinner + text) on start/stop all-team/set; JS util showLoading/hideLoading
640 lines
28 KiB
HTML
640 lines
28 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Gemastik A/D Panel</title>
|
|
<style>
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
body {
|
|
font-family: 'JetBrains Mono','Fira Code',monospace;
|
|
background: radial-gradient(ellipse at top, #0d1b2a 0%, #0a0f1c 55%, #05070d 100%);
|
|
color: #c9d4e3; min-height:100vh; padding:24px;
|
|
}
|
|
header {
|
|
display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;
|
|
border-bottom:1px solid #1e3a5f; padding-bottom:16px; flex-wrap:wrap; gap:12px;
|
|
}
|
|
h1 { font-size:22px; color:#5ad1ff; letter-spacing:1px; }
|
|
.actions { display:flex; gap:10px; align-items:center; }
|
|
.tabs { display:flex; gap:6px; margin-bottom:20px; flex-wrap:wrap; }
|
|
.tab {
|
|
background:#0e1526; border:1px solid #1e3a5f; color:#8aa0b8; padding:9px 16px;
|
|
border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px; transition:all .2s;
|
|
}
|
|
.tab.active { background:#2563eb33; border-color:#3b82f6; color:#fff; }
|
|
.tab:hover { border-color:#3b82f6; }
|
|
.pill {
|
|
background:#0e1526; border:1px solid #1e3a5f; color:#8aa0b8; font-size:12px;
|
|
padding:7px 13px; border-radius:20px;
|
|
}
|
|
.pill.green { color:#34d399; border-color:#34d39955; background:#34d39911; }
|
|
.pill.red { color:#f87171; border-color:#f8717155; background:#f8717111; }
|
|
button {
|
|
background:#0e1526; border:1px solid #1e3a5f; color:#a8c3e0; font-family:inherit;
|
|
font-size:12px; padding:8px 14px; border-radius:8px; cursor:pointer; transition:all .2s;
|
|
}
|
|
button:hover { border-color:#3b82f6; color:#fff; }
|
|
button.primary { background:linear-gradient(135deg,#0ea5e9,#2563eb); border:none; color:#fff; font-weight:700; }
|
|
button.danger { border-color:#f8717155; color:#f87171; }
|
|
button.danger:hover { background:#f8717122; }
|
|
.view { display:none; }
|
|
.view.active { display:block; }
|
|
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:16px; }
|
|
.card {
|
|
background:#0e1526cc; border:1px solid #1e3a5f; border-radius:12px; padding:18px;
|
|
display:flex; flex-direction:column; gap:10px; position:relative;
|
|
}
|
|
.card.dead { border-color:#f8717155; background:#1a0f16cc; }
|
|
.card-top { display:flex; justify-content:space-between; align-items:center; }
|
|
.ch-name { font-size:16px; font-weight:700; color:#e2ecfa; }
|
|
.ch-cat {
|
|
font-size:10px; text-transform:uppercase; letter-spacing:1px; padding:3px 8px;
|
|
border-radius:10px; background:#1e3a5f44; color:#8aa0b8;
|
|
}
|
|
.status { font-size:11px; font-weight:700; letter-spacing:.5px; text-transform:uppercase; }
|
|
.status.up { color:#34d399; }
|
|
.status.down { color:#f87171; }
|
|
.flag {
|
|
background:#0a101f; border:1px dashed #2a4a6f; border-radius:8px; padding:10px 12px;
|
|
font-size:12px; color:#7dd3fc; word-break:break-all; min-height:18px;
|
|
}
|
|
.creds { font-size:11px; color:#718096; }
|
|
.creds b { color:#a8c3e0; font-weight:600; }
|
|
.btn-row { display:flex; gap:6px; flex-wrap:wrap; margin-top:2px; }
|
|
.btn-row button { font-size:11px; padding:6px 10px; }
|
|
textarea, input[type=text], input[type=number], select {
|
|
width:100%; background:#0a101f; border:1px solid #1e3a5f; color:#dbe6f4;
|
|
border-radius:8px; padding:10px; font-size:13px; font-family:inherit; outline:none; resize:vertical;
|
|
}
|
|
textarea:focus, input:focus, select:focus { border-color:#3b82f6; }
|
|
.modal-back {
|
|
position:fixed; inset:0; background:#000a; display:none; align-items:center; justify-content:center; z-index:50;
|
|
}
|
|
.modal-back.open { display:flex; }
|
|
.modal {
|
|
background:#0d1526; border:1px solid #2a4a6f; border-radius:14px; padding:26px; width:440px; max-width:92vw;
|
|
}
|
|
.modal h2 { font-size:16px; color:#5ad1ff; margin-bottom:16px; }
|
|
.toast {
|
|
position:fixed; bottom:24px; right:24px; background:#0e1526; border:1px solid #3b82f6;
|
|
color:#dbe6f4; padding:13px 18px; border-radius:10px; font-size:13px; z-index:80;
|
|
opacity:0; transform:translateY(10px); transition:all .3s;
|
|
}
|
|
.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;
|
|
}
|
|
.footer-note { color:#4a5a70; font-size:11px; margin-top:20px; text-align:center; }
|
|
.spin { display:inline-block; width:12px; height:12px; border:2px solid #2a4a6f; border-top-color:#5ad1ff; border-radius:50%; animation:sp .7s linear infinite; }
|
|
@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; }
|
|
.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; }
|
|
.kv b { color:#8aa0b8; font-weight:600; }
|
|
.kv span { color:#dbe6f4; word-break:break-all; }
|
|
.mono { font-family:inherit; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>⚔️ GEMASTIK A/D — NODE CONTROL</h1>
|
|
<div class="actions">
|
|
<span class="pill" id="clock">--:--:--</span>
|
|
<button onclick="refresh()">🔄 Refresh</button>
|
|
<button onclick="logout()">Keluar</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="tabs">
|
|
<button class="tab active" data-view="challs" onclick="showView('challs')">🎯 Challenges</button>
|
|
<button class="tab" data-view="topo" onclick="showView('topo'); loadTopo()">🗺️ Topology</button>
|
|
<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>
|
|
</div>
|
|
|
|
<!-- Challenges view -->
|
|
<div class="view active" id="view-challs">
|
|
<div class="grid" id="grid"></div>
|
|
</div>
|
|
|
|
<!-- Topology view -->
|
|
<div class="view" id="view-topo">
|
|
<div class="topo-wrap"><svg id="topoSvg" class="topo-svg" height="420"></svg></div>
|
|
</div>
|
|
|
|
<!-- Teams view -->
|
|
<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 + 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" 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>
|
|
</div>
|
|
</div>
|
|
<div class="grid" id="teamsGrid"></div>
|
|
<div class="card" style="margin-top:16px">
|
|
<div class="team-head">
|
|
<div><b style="color:#5ad1ff">🏆 Leaderboard</b> <span style="font-size:11px;color:#718096">solve flag per team (dari halaman submit publik)</span></div>
|
|
<a href="/submit" target="_blank" class="primary" style="text-decoration:none;padding:8px 14px;border-radius:8px">🚩 Halaman Submit Team →</a>
|
|
</div>
|
|
<table style="width:100%;border-collapse:collapse;margin-top:10px;font-size:13px">
|
|
<thead><tr style="color:#718096;text-align:left"><th style="padding:6px">#</th><th>Tim</th><th>Solved</th><th>Challenges</th></tr></thead>
|
|
<tbody id="lbBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Logs view -->
|
|
<div class="view" id="view-logs">
|
|
<div class="card" style="margin-bottom:16px">
|
|
<div class="team-head">
|
|
<div><b style="color:#5ad1ff">Log Server</b> <span style="font-size:11px;color:#718096">docker logs per team/service</span></div>
|
|
<div style="display:flex;gap:8px;align-items:center">
|
|
<select id="logTeam" style="width:130px"></select>
|
|
<select id="logService" style="width:150px">
|
|
<option value="">semua service</option>
|
|
<option>blogpost</option><option>carbeat</option><option>cdn</option>
|
|
<option>phew</option><option>sheesh</option><option>warmup</option>
|
|
</select>
|
|
<button class="primary" onclick="loadLogs()">Muat</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="history-box" id="logsBox" style="max-height:70vh">Pilih team & service…</div>
|
|
</div>
|
|
|
|
<!-- Creds view -->
|
|
<div class="view" id="view-creds">
|
|
<div class="grid" id="credsGrid"></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 -->
|
|
<div class="modal-back" id="modalFlag">
|
|
<div class="modal">
|
|
<h2 id="mfTitle">Rotate Flag</h2>
|
|
<input type="text" id="mfFlag" placeholder="GEMASTIK18{...}" style="margin-bottom:12px">
|
|
<div class="btn-row" style="justify-content:flex-end">
|
|
<button onclick="closeModal('modalFlag')">Batal</button>
|
|
<button class="primary" onclick="submitFlag()">Submit Flag</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- modal creds -->
|
|
<div class="modal-back" id="modalCred">
|
|
<div class="modal">
|
|
<h2 id="mcTitle">SSH Credentials</h2>
|
|
<div id="mcBody" style="font-size:13px;color:#dbe6f4;line-height:1.8"></div>
|
|
<div class="btn-row" style="justify-content:flex-end;margin-top:14px">
|
|
<button onclick="closeModal('modalCred')">Tutup</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- modal team creds -->
|
|
<div class="modal-back" id="modalTeamCred">
|
|
<div class="modal" style="width:520px">
|
|
<h2 id="mtcTitle">Kredensial Team</h2>
|
|
<div id="mtcBody" style="font-size:12px;color:#dbe6f4;line-height:1.9"></div>
|
|
<div class="btn-row" style="justify-content:flex-end;margin-top:14px">
|
|
<button onclick="closeModal('modalTeamCred')">Tutup</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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;
|
|
|
|
async function api(path, opts={}) {
|
|
const r = await fetch(path, opts);
|
|
if (r.status === 401) { location.href = '/login'; throw new Error('unauth'); }
|
|
const data = await r.json().catch(() => ({}));
|
|
if (!r.ok) throw new Error(data.detail || data.receiver_body || r.statusText);
|
|
return data;
|
|
}
|
|
|
|
function toast(msg, err=false) {
|
|
const t = document.getElementById('toast');
|
|
t.textContent = msg; t.className = 'toast show' + (err ? ' err' : '');
|
|
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 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));
|
|
}
|
|
|
|
// ---------- Challenges ----------
|
|
async function refresh() {
|
|
const grid = document.getElementById('grid');
|
|
try {
|
|
const d = await api('/api/status');
|
|
const chs = await api('/api/challenges');
|
|
const name2cat = Object.fromEntries((chs.challenges||[]).map(c => [c.name, c]));
|
|
let html = '';
|
|
for (const ch of d.results) {
|
|
const meta = name2cat[ch.name] || {};
|
|
const alive = ch.alive;
|
|
html += `<div class="card ${alive ? '' : 'dead'}">
|
|
<div class="card-top">
|
|
<span class="ch-name">${esc(ch.name)}</span>
|
|
<span class="ch-cat">${esc(meta.category || ch.category || '')}</span>
|
|
</div>
|
|
<div><span class="status ${alive ? 'up' : 'down'}">${alive ? '● SLA OK' : '● SLA DOWN'}</span>
|
|
<span style="font-size:11px;color:#718096;margin-left:8px">
|
|
${esc(ch.port)} / ssh ${esc(ch.ssh)}</span></div>
|
|
<div class="flag" title="Flag saat ini">🏳️ ${esc(ch.flag || '(kosong — belum di-set)')}</div>
|
|
<div class="creds" id="creds-${esc(ch.name)}"><span class="spin"></span> ambil kredensial…</div>
|
|
<div class="btn-row">
|
|
<button class="primary" onclick="openFlag('${esc(ch.name)}')">Rotate Flag</button>
|
|
<button onclick="act('restart','${esc(ch.name)}')">Restart</button>
|
|
<button onclick="act('rollback','${esc(ch.name)}')">Rollback</button>
|
|
<button onclick="act('activate','${esc(ch.name)}')">Activate</button>
|
|
<button class="danger" onclick="act('deactivate','${esc(ch.name)}')">Deactivate</button>
|
|
<button onclick="viewCred('${esc(ch.name)}')">SSH</button>
|
|
</div>
|
|
</div>`;
|
|
fetch(`/api/credential/${ch.name}`).then(r=>r.json()).then(c => {
|
|
const el = document.getElementById('creds-' + ch.name);
|
|
if (el && c.username) el.innerHTML = `<b>ctfuser</b> / <b>${esc(c.password)}</b>`;
|
|
}).catch(()=>{});
|
|
}
|
|
grid.innerHTML = html;
|
|
} catch (e) {
|
|
toast('Gagal refresh: ' + e.message, true);
|
|
}
|
|
}
|
|
|
|
async function act(action, challenge) {
|
|
try {
|
|
const d = await api(`/api/${action}/${challenge}`, {method:'POST'});
|
|
toast(`${action} ${challenge} → receiver ${d.receiver_status}`, false);
|
|
setTimeout(refresh, 2500);
|
|
} catch (e) { toast(e.message, true); }
|
|
}
|
|
|
|
let flagChallenge = null;
|
|
function openFlag(ch) {
|
|
flagChallenge = ch;
|
|
document.getElementById('mfTitle').textContent = 'Rotate Flag — ' + ch;
|
|
document.getElementById('mfFlag').value = '';
|
|
document.getElementById('modalFlag').classList.add('open');
|
|
document.getElementById('mfFlag').focus();
|
|
}
|
|
|
|
async function submitFlag() {
|
|
const flag = document.getElementById('mfFlag').value.trim();
|
|
if (!flag) return toast('Flag kosong', true);
|
|
try {
|
|
const d = await api('/api/flag', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({challenge: flagChallenge, flag})});
|
|
toast(`Flag ${flagChallenge} di-rotate → receiver ${d.receiver_status}`, false);
|
|
closeModal('modalFlag');
|
|
setTimeout(refresh, 1500);
|
|
} catch (e) { toast(e.message, true); }
|
|
}
|
|
|
|
async function viewCred(ch) {
|
|
try {
|
|
const c = await api(`/api/credential/${ch}`);
|
|
document.getElementById('mcTitle').textContent = 'SSH Credentials — ' + ch;
|
|
const sshPort = {blogpost:10022, carbeat:11022, cdn:12022, phew:13022, sheesh:14022, warmup:15022}[ch] || 10022;
|
|
const cmd = `ssh ctfuser@${ch}.gemastik.imrnes.team -p ${sshPort}`;
|
|
document.getElementById('mcBody').innerHTML =
|
|
`<div>User: <b>ctfuser</b></div>
|
|
<div>Pass: <b>${esc(c.password)}</b></div>
|
|
<div style="margin-top:10px">SSH:</div>
|
|
<div class="flag" style="margin-top:6px">${esc(cmd)}</div>`;
|
|
document.getElementById('modalCred').classList.add('open');
|
|
} catch (e) { toast(e.message, true); }
|
|
}
|
|
|
|
// ---------- Topology ----------
|
|
let topoLoaded = false;
|
|
async function loadTopo() {
|
|
try {
|
|
const d = await api('/api/topology');
|
|
renderTopo(d.nodes, d.edges);
|
|
} catch (e) { toast('Topologi gagal: ' + e.message, true); }
|
|
}
|
|
|
|
function renderTopo(nodes, edges) {
|
|
const svg = document.getElementById('topoSvg');
|
|
const W = Math.max(900, nodes.length * 130);
|
|
const H = 400;
|
|
svg.setAttribute('width', W); svg.setAttribute('height', H);
|
|
const cx = W / 2;
|
|
const ns = {};
|
|
nodes.forEach(n => { ns[n.id] = n; });
|
|
|
|
// layout: panel/infra top center, teams in circle, challenges below each team
|
|
const pos = {};
|
|
pos['dns'] = {x: cx, y: 30};
|
|
pos['traefik'] = {x: cx, y: 100};
|
|
pos['panel'] = {x: cx - 140, y: 100};
|
|
const teams = nodes.filter(n => n.type === 'team');
|
|
const teamPos = {};
|
|
teams.forEach((t, i) => {
|
|
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;
|
|
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 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};
|
|
});
|
|
|
|
// edges
|
|
let html = '';
|
|
edges.forEach(e => {
|
|
const a = pos[e.from], b = pos[e.to];
|
|
if (!a || !b) return;
|
|
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>`;
|
|
}
|
|
});
|
|
|
|
// nodes
|
|
nodes.forEach(n => {
|
|
const p = pos[n.id];
|
|
if (!p) return;
|
|
let fill = '#0e1526', stroke = '#2a4a6f', color = '#a8c3e0', r = 34;
|
|
if (n.type === 'panel') { fill = '#0ea5e933'; stroke = '#0ea5e9'; color = '#7dd3fc'; r = 42; }
|
|
if (n.type === 'infra') { r = 30; color = '#8aa0b8'; }
|
|
if (n.type === 'team') { fill = '#2563eb22'; stroke = '#3b82f6'; color = '#93c5fd'; r = 48; }
|
|
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>
|
|
<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>
|
|
${sub ? `<text x="${p.x}" y="${p.y + 12}" fill="#5a7a9f" font-size="9" text-anchor="middle">${sub}</text>` : ''}
|
|
${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;
|
|
}
|
|
|
|
// ---------- Teams ----------
|
|
async function loadTeams() {
|
|
try {
|
|
const d = await api('/api/teams');
|
|
document.getElementById('teamCount').value = d.teams.length;
|
|
loadLeaderboard();
|
|
const grid = document.getElementById('teamsGrid');
|
|
if (!d.teams.length) { grid.innerHTML = '<div class="card"><span style="color:#718096">Belum ada team. Set jumlah team untuk auto-create.</span></div>'; return; }
|
|
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>
|
|
<span class="status ${alive ? 'up' : 'down'}">${alive ? '● RUNNING' : '● STOPPED'}</span>
|
|
</div>
|
|
<div class="kv">
|
|
<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)}" 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="openTeamLogs(${t.index})">📜 Logs</button>
|
|
<button onclick="randomizeTeam(${t.index})">🎲 Randomize Flag</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
grid.innerHTML = html;
|
|
} catch (e) { toast('Teams gagal: ' + e.message, true); }
|
|
}
|
|
|
|
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 {
|
|
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})});
|
|
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 = '';
|
|
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) {
|
|
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 randomizeTeam(idx) {
|
|
if (!confirm(`Randomize SEMUA flag untuk Team ${idx}? Container akan di-recreate.`)) return;
|
|
try {
|
|
const d = await api(`/api/teams/${idx}/randomize`, {method:'POST'});
|
|
const flags = Object.values(d.flags || {}).join('\n');
|
|
toast(`Flag Team ${idx} di-randomize!`, false);
|
|
console.log('NEW FLAGS team', idx, flags);
|
|
setTimeout(loadTeams, 4000);
|
|
} catch (e) { toast(e.message, true); }
|
|
}
|
|
|
|
async function loadLeaderboard() {
|
|
try {
|
|
const d = await api('/api/leaderboard');
|
|
const body = document.getElementById('lbBody');
|
|
if (!body) return;
|
|
if (!d.teams.length) { body.innerHTML = '<tr><td colspan="4" style="color:#718096">Belum ada solve.</td></tr>'; return; }
|
|
body.innerHTML = d.teams.map((t,i) => `<tr>
|
|
<td>${i+1}</td><td>${esc(t.name)}</td><td>${t.solves}</td>
|
|
<td>${t.challs.map(c=>`<span class="chip">${esc(c)}</span>`).join('')}</td>
|
|
</tr>`).join('');
|
|
} catch (e) {}
|
|
}
|
|
setInterval(loadLeaderboard, 15000);
|
|
|
|
async function viewTeamCred(idx) {
|
|
try {
|
|
const t = await api(`/api/teams/${idx}/creds`);
|
|
let html = `<div class="kv">
|
|
<b>Label</b><span>${esc(t.team.label || ('Team ' + idx))}</span>
|
|
<b>Admin receiver</b><span>${esc(t.team.admin_user)} / ${esc(t.team.admin_pass)}</span>
|
|
<b>Receiver port</b><span>${esc(t.team.ports.receiver)}</span>
|
|
<b>SSH user</b><span>ctfuser</span>
|
|
<b>SSH pass</b><span>${esc(t.team.ssh_pass)}</span>
|
|
</div><div style="margin-top:10px"><b style="color:#5ad1ff">Per-challenge SSH:</b></div>`;
|
|
for (const [name, p] of Object.entries(t.team.ports)) {
|
|
if (name === 'receiver' || name === 'panel') continue;
|
|
html += `<div class="kv" style="margin-top:6px">
|
|
<b>${name}</b><span>ssh ctfuser@${name}.gemastik.imrnes.team -p ${p.ssh} · pass: ${esc(t.team.chall_passwords[name])}</span>
|
|
</div>`;
|
|
}
|
|
document.getElementById('mtcTitle').textContent = `Kredensial Team ${idx}`;
|
|
document.getElementById('mtcBody').innerHTML = html;
|
|
document.getElementById('modalTeamCred').classList.add('open');
|
|
} catch (e) { toast(e.message, true); }
|
|
}
|
|
|
|
function openTeamLogs(idx) {
|
|
document.getElementById('logTeam').value = idx;
|
|
showView('logs'); loadLogs();
|
|
}
|
|
|
|
// ---------- Logs ----------
|
|
async function loadLogs() {
|
|
const idx = document.getElementById('logTeam').value;
|
|
const svc = document.getElementById('logService').value;
|
|
await populateLogTeams();
|
|
if (!idx) { document.getElementById('logsBox').textContent = 'Pilih team dulu.'; return; }
|
|
try {
|
|
const d = await api(`/api/teams/${idx}/logs` + (svc ? `?service=${svc}` : '') + '&tail=200');
|
|
let out = '';
|
|
for (const [name, log] of Object.entries(d.logs)) {
|
|
out += `\n━━━ ${name} (team ${idx}) ━━━\n${log}\n`;
|
|
}
|
|
document.getElementById('logsBox').textContent = out || '(kosong)';
|
|
} catch (e) { document.getElementById('logsBox').textContent = 'Error: ' + e.message; }
|
|
}
|
|
async function populateLogTeams() {
|
|
try {
|
|
const d = await api('/api/teams');
|
|
const sel = document.getElementById('logTeam');
|
|
if (sel.options.length === 0 || sel.value === '') {
|
|
sel.innerHTML = d.teams.map(t => `<option value="${t.index}">Team ${t.index}</option>`).join('');
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
// ---------- Creds ----------
|
|
async function loadCreds() {
|
|
try {
|
|
const d = await api('/api/teams');
|
|
const grid = document.getElementById('credsGrid');
|
|
if (!d.teams.length) { grid.innerHTML = '<div class="card"><span style="color:#718096">Belum ada team.</span></div>'; return; }
|
|
let html = '';
|
|
for (const t of d.teams) {
|
|
html += `<div class="card">
|
|
<div class="team-head">
|
|
<span class="ch-name">${esc(t.label || ('Team ' + t.index))}</span>
|
|
<button style="font-size:11px" onclick="viewTeamCred(${t.index})">Lihat Semua</button>
|
|
</div>
|
|
<div class="kv">
|
|
<b>Admin</b><span>${esc(t.admin_user)} / ${esc(t.admin_pass)}</span>
|
|
<b>SSH</b><span>ctfuser / ${esc(t.ssh_pass)}</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
grid.innerHTML = html;
|
|
} catch (e) { toast(e.message, true); }
|
|
}
|
|
|
|
// ---------- misc ----------
|
|
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
|
async function logout() {
|
|
await fetch('/api/logout', {method:'POST'});
|
|
location.href = '/login';
|
|
}
|
|
['modalFlag','modalCred','modalTeamCred'].forEach(id => {
|
|
document.getElementById(id).addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(id); });
|
|
});
|
|
|
|
function tick() {
|
|
document.getElementById('clock').textContent = new Date().toLocaleTimeString('id-ID');
|
|
}
|
|
setInterval(tick, 1000); tick();
|
|
refresh();
|
|
setInterval(refresh, REFRESH_MS);
|
|
</script>
|
|
</body>
|
|
</html> |