draggable topology + attack visualizer + tools in containers

- topology nodes draggable (pointer events, SVG transform), layout hint shown
- attack visualizer: /api/attacks logs attacker->target events; red pulsing
  dashed arcs on recent attacks (60s hot), ⚔ counts ok/fail
- submit_flag now takes attacker_idx vs target_idx (A/D semantics); UI has
  target dropdown (enemy teams), leaderboard records target
- containers get vim+curl+wget+netcat+git+pip3 (Dockerfiles blogpost/cdn/
  phew/sheesh/warmup); warmup base ubuntu:20.04 EOL -> 24.04
- team portal: target dropdown refreshed after login (was empty pre-auth)
This commit is contained in:
root
2026-09-23 18:05:44 +08:00
parent 44dc1ac852
commit 24dbf0a662
9 changed files with 196 additions and 23 deletions
+85 -8
View File
@@ -101,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; }
@@ -137,7 +143,8 @@
<!-- 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-wrap"><svg id="topoSvg" class="topo-svg" height="520"></svg></div>
<div class="topo-hint">💡 Geser node untuk mengatur layout • ⚔️ garis merah = serangan (panah attacker → target) • garis <b>putus-putus</b> = serangan baru (60 detik terakhir)</div>
</div>
<!-- Teams view -->
@@ -283,7 +290,15 @@ function esc(s) {
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
}
let topoTimer = null;
function startTopoTimer() {
if (!topoTimer) topoTimer = setInterval(() => {
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
}, 10000);
}
startTopoTimer();
// ---------- Challenges ----------
async function refresh() {
@@ -371,9 +386,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); }
}
@@ -381,7 +398,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 = {};
@@ -398,15 +415,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
@@ -417,10 +445,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];
@@ -432,7 +474,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>
@@ -441,6 +483,41 @@ function renderTopo(nodes, edges) {
</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;
enableTopoDrag(svg, pos);
}
// ---- draggable topology ----
function enableTopoDrag(svg, pos) {
let dragEl = null, dx = 0, dy = 0;
const onMove = (ev) => {
if (!dragEl) return;
const ctm = svg.getScreenCTM();
if (!ctm) return;
const pt = svg.createSVGPoint();
pt.x = ev.clientX; pt.y = ev.clientY;
const p = pt.matrixTransform(ctm.inverse());
dragEl.setAttribute('transform', `translate(${p.x - dx}, ${p.y - dy})`);
};
const onUp = () => { dragEl = null; svg.style.cursor = ''; };
svg.addEventListener('mousedown', (ev) => {
const g = ev.target.closest('g[data-node]');
if (!g) return;
ev.preventDefault();
const ctm = svg.getScreenCTM();
const pt = svg.createSVGPoint();
pt.x = ev.clientX; pt.y = ev.clientY;
const p = pt.matrixTransform(ctm.inverse());
const c = g.querySelector('circle');
const cx = parseFloat(c.getAttribute('cx'));
const cy = parseFloat(c.getAttribute('cy'));
dx = p.x - cx; dy = p.y - cy;
dragEl = g;
svg.style.cursor = 'grabbing';
g.setPointerCapture && g.setPointerCapture(ev.pointerId);
});
svg.addEventListener('pointermove', onMove);
svg.addEventListener('pointerup', onUp);
svg.addEventListener('pointercancel', onUp);
}
// ---------- Teams ----------
+30 -1
View File
@@ -138,7 +138,11 @@
<option value="sheesh">sheesh (crypto)</option>
<option value="warmup">warmup</option>
</select>
<label>Flag</label>
<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>
@@ -229,6 +233,8 @@ async function doLogin() {
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';
@@ -305,6 +311,7 @@ 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(),
}),
@@ -313,6 +320,28 @@ async function submitFlag() {
else { res.className = 'err'; res.textContent = '❌ ' + (d.error || 'Gagal'); }
}
// 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 */ }
}
loadTargetDropdown();
checkSession();
</script>
</body>