diff --git a/panel/main.py b/panel/main.py
index d47d634..672b634 100644
--- a/panel/main.py
+++ b/panel/main.py
@@ -223,6 +223,8 @@ async def api_team_targets(idx: int, req: Request):
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"],
})
@@ -521,9 +523,11 @@ async def api_reset_environment(req: Request):
@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}"
@@ -533,7 +537,18 @@ 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")
diff --git a/panel/static/index.html b/panel/static/index.html
index a541d7e..df74dce 100644
--- a/panel/static/index.html
+++ b/panel/static/index.html
@@ -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 @@
-
+
+
π‘ Geser node untuk mengatur layout β’ βοΈ garis merah = serangan (panah attacker β target) β’ garis putus-putus = serangan baru (60 detik terakhir)
@@ -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 += ` `;
if (e.label) {
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2 - 6;
- html += `${e.label} `;
+ html += `${esc(e.label)} `;
}
});
+ // ---- 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 += ` `;
+ const mx = (a.x + b.x) / 2 + 8, my = (a.y + b.y) / 2 - 10;
+ const icon = isHot ? 'βοΈ' : 'β';
+ html += `${icon} ${c.ok}β ${c.fail}β `;
+ }
+
// 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 += `
+ html += `
${esc(n.label)}
@@ -441,6 +483,41 @@ function renderTopo(nodes, edges) {
`;
});
svg.innerHTML = ` ` + 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 ----------
diff --git a/panel/static/team.html b/panel/static/team.html
index bbf345a..22f3c6b 100644
--- a/panel/static/team.html
+++ b/panel/static/team.html
@@ -138,7 +138,11 @@
sheesh (crypto)
warmup
- Flag
+ Target (tim musuh yang diserang)
+
+ Memuat targetβ¦
+
+ Flag (yang kamu curi dari target)
Submit Flag
@@ -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 = 'Tidak ada tim musuh ';
+ 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();