2026-09-23 14:26:26 +08:00
<!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 : 100 vh ; padding : 24 px ;
}
header {
2026-09-23 15:14:52 +08:00
display : flex ; justify-content : space-between ; align-items : center ; margin-bottom : 20 px ;
2026-09-23 14:26:26 +08:00
border-bottom : 1 px solid #1e3a5f ; padding-bottom : 16 px ; flex-wrap : wrap ; gap : 12 px ;
}
h1 { font-size : 22 px ; color : #5ad1ff ; letter-spacing : 1 px ; }
. actions { display : flex ; gap : 10 px ; align-items : center ; }
2026-09-23 15:14:52 +08:00
. tabs { display : flex ; gap : 6 px ; margin-bottom : 20 px ; flex-wrap : wrap ; }
. tab {
background : #0e1526 ; border : 1 px solid #1e3a5f ; color : #8aa0b8 ; padding : 9 px 16 px ;
border-radius : 8 px ; cursor : pointer ; font-family : inherit ; font-size : 13 px ; transition : all .2 s ;
}
. tab . active { background : #2563eb 33 ; border-color : #3b82f6 ; color : #fff ; }
. tab : hover { border-color : #3b82f6 ; }
2026-09-23 14:26:26 +08:00
. pill {
background : #0e1526 ; border : 1 px solid #1e3a5f ; color : #8aa0b8 ; font-size : 12 px ;
padding : 7 px 13 px ; border-radius : 20 px ;
}
. pill . green { color : #34d399 ; border-color : #34d399 55 ; background : #34d399 11 ; }
. pill . red { color : #f87171 ; border-color : #f87171 55 ; background : #f87171 11 ; }
button {
background : #0e1526 ; border : 1 px solid #1e3a5f ; color : #a8c3e0 ; font-family : inherit ;
font-size : 12 px ; padding : 8 px 14 px ; border-radius : 8 px ; cursor : pointer ; transition : all .2 s ;
}
button : hover { border-color : #3b82f6 ; color : #fff ; }
2026-09-23 15:14:52 +08:00
button . primary { background : linear-gradient ( 135 deg , #0ea5e9 , #2563eb ); border : none ; color : #fff ; font-weight : 700 ; }
2026-09-23 14:26:26 +08:00
button . danger { border-color : #f87171 55 ; color : #f87171 ; }
button . danger : hover { background : #f87171 22 ; }
2026-09-23 15:14:52 +08:00
. view { display : none ; }
. view . active { display : block ; }
2026-09-23 14:26:26 +08:00
. grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 340 px , 1 fr )); gap : 16 px ; }
. card {
background : #0e1526 cc ; border : 1 px solid #1e3a5f ; border-radius : 12 px ; padding : 18 px ;
display : flex ; flex-direction : column ; gap : 10 px ; position : relative ;
}
. card . dead { border-color : #f87171 55 ; background : #1a0f16 cc ; }
. card-top { display : flex ; justify-content : space-between ; align-items : center ; }
. ch-name { font-size : 16 px ; font-weight : 700 ; color : #e2ecfa ; }
. ch-cat {
font-size : 10 px ; text-transform : uppercase ; letter-spacing : 1 px ; padding : 3 px 8 px ;
border-radius : 10 px ; background : #1e3a5f 44 ; color : #8aa0b8 ;
}
. status { font-size : 11 px ; font-weight : 700 ; letter-spacing : .5 px ; text-transform : uppercase ; }
. status . up { color : #34d399 ; }
. status . down { color : #f87171 ; }
. flag {
background : #0a101f ; border : 1 px dashed #2a4a6f ; border-radius : 8 px ; padding : 10 px 12 px ;
font-size : 12 px ; color : #7dd3fc ; word-break : break-all ; min-height : 18 px ;
}
. creds { font-size : 11 px ; color : #718096 ; }
. creds b { color : #a8c3e0 ; font-weight : 600 ; }
. btn-row { display : flex ; gap : 6 px ; flex-wrap : wrap ; margin-top : 2 px ; }
. btn-row button { font-size : 11 px ; padding : 6 px 10 px ; }
2026-09-23 15:14:52 +08:00
textarea , input [ type = text ], input [ type = number ], select {
2026-09-23 14:26:26 +08:00
width : 100 % ; background : #0a101f ; border : 1 px solid #1e3a5f ; color : #dbe6f4 ;
border-radius : 8 px ; padding : 10 px ; font-size : 13 px ; font-family : inherit ; outline : none ; resize : vertical ;
}
2026-09-23 15:14:52 +08:00
textarea : focus , input : focus , select : focus { border-color : #3b82f6 ; }
2026-09-23 14:26:26 +08:00
. 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 : 1 px solid #2a4a6f ; border-radius : 14 px ; padding : 26 px ; width : 440 px ; max-width : 92 vw ;
}
. modal h2 { font-size : 16 px ; color : #5ad1ff ; margin-bottom : 16 px ; }
. toast {
position : fixed ; bottom : 24 px ; right : 24 px ; background : #0e1526 ; border : 1 px solid #3b82f6 ;
color : #dbe6f4 ; padding : 13 px 18 px ; border-radius : 10 px ; font-size : 13 px ; z-index : 80 ;
opacity : 0 ; transform : translateY ( 10 px ); transition : all .3 s ;
}
. toast . show { opacity : 1 ; transform : translateY ( 0 ); }
. toast . err { border-color : #f87171 ; color : #fca5a5 ; }
2026-09-23 17:02:52 +08:00
/* 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 : 18 px ;
}
# loadingOverlay . show { display : flex ; }
. loader { width : 44 px ; height : 44 px ; border : 4 px solid #2a3444 ; border-top-color : #38bdf8 ; border-radius : 50 % ; animation : spin 0.9 s linear infinite ; }
@ keyframes spin { to { transform : rotate ( 360 deg ); } }
# loadingText { color : #dbe6f4 ; font-size : 14 px ; text-align : center ; padding : 0 24 px ; line-height : 1.6 ; }
2026-09-23 14:26:26 +08:00
. history-box {
background : #0a101f ; border : 1 px solid #1e3a5f ; border-radius : 10 px ; padding : 14 px ;
font-size : 11 px ; color : #7dd3fc ; max-height : 300 px ; overflow-y : auto ; white-space : pre-wrap ;
}
. footer-note { color : #4a5a70 ; font-size : 11 px ; margin-top : 20 px ; text-align : center ; }
. spin { display : inline-block ; width : 12 px ; height : 12 px ; border : 2 px solid #2a4a6f ; border-top-color : #5ad1ff ; border-radius : 50 % ; animation : sp .7 s linear infinite ; }
@ keyframes sp { to { transform : rotate ( 360 deg ); } }
2026-09-23 15:14:52 +08:00
. topo-wrap { background : #0a0f1c ; border : 1 px solid #1e3a5f ; border-radius : 12 px ; padding : 20 px ; overflow-x : auto ; }
. topo-svg { width : 100 % ; min-width : 800 px ; }
2026-09-23 18:05:44 +08:00
/* 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.2 s ease-in-out infinite ; }
. attack-icon { animation : attackBlink 1 s ease-in-out infinite ; }
. topo-hint { font-size : 12 px ; color : var ( -- dim ); margin-top : 8 px ; }
2026-09-23 15:14:52 +08:00
. team-head { display : flex ; justify-content : space-between ; align-items : center ; gap : 10 px ; flex-wrap : wrap ; }
. team-status { font-size : 11 px ; }
. kv { display : grid ; grid-template-columns : 120 px 1 fr ; gap : 6 px 12 px ; font-size : 12 px ; }
. kv b { color : #8aa0b8 ; font-weight : 600 ; }
. kv span { color : #dbe6f4 ; word-break : break-all ; }
. mono { font-family : inherit ; }
2026-09-23 14:26:26 +08:00
</ 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 >
2026-09-23 17:14:42 +08:00
< button class = "danger" onclick = "resetScores()" style = "background:#dc2626;border:none;border-radius:8px;padding:8px 14px;color:#fff;font-weight:600;cursor:pointer" > 🧹 Reset Skor</ button >
< button class = "danger" onclick = "resetEnv()" style = "background:#7f1d1d;border:none;border-radius:8px;padding:8px 14px;color:#fff;font-weight:600;cursor:pointer" > 💥 Reset Environment</ button >
2026-09-23 14:26:26 +08:00
< button onclick = "logout()" > Keluar</ button >
</ div >
</ header >
2026-09-23 15:14:52 +08:00
< 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 >
2026-09-23 17:14:42 +08:00
< button class = "tab" data-view = "targets" onclick = "showView('targets'); loadTargetMatrix()" > 🎯 Target Matrix</ button >
2026-09-23 15:14:52 +08:00
</ div >
2026-09-23 14:26:26 +08:00
2026-09-23 15:14:52 +08:00
<!-- Challenges view -->
< div class = "view active" id = "view-challs" >
< div class = "grid" id = "grid" ></ div >
</ div >
<!-- Topology view -->
< div class = "view" id = "view-topo" >
2026-09-23 21:50:16 +08:00
< div class = "topo-toolbar" style = "display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap" >
< button onclick = "topoZoom(1.25)" > 🔍 + Zoom In</ button >
< button onclick = "topoZoom(0.8)" > 🔎 Zoom Out</ button >
< button onclick = "topoReset()" > ⟳ Reset</ button >
< span id = "topoZoomLabel" style = "font-size:12px;color:var(--dim);min-width:40px" > 100%</ span >
</ div >
2026-09-23 18:05:44 +08:00
< div class = "topo-wrap" >< svg id = "topoSvg" class = "topo-svg" height = "520" ></ svg ></ div >
2026-09-23 21:50:16 +08:00
< div class = "topo-hint" > 💡 < b > Geser node</ b > untuk atur layout • < b > scroll / ctrl+scroll</ b > untuk zoom in-out • < b > drag area kosong</ b > untuk geser canvas • ⚔️ garis merah = serangan (panah attacker → target) • garis < b > putus-putus</ b > = serangan baru (60 detik terakhir)</ div >
2026-09-23 15:14:52 +08:00
</ div >
<!-- Teams view -->
< div class = "view" id = "view-teams" >
< div class = "card" style = "margin-bottom:16px" >
< div class = "team-head" >
2026-09-23 16:18:11 +08:00
< div >< b style = "color:#5ad1ff" > Jumlah Team</ b > < span style = "font-size:11px;color:#718096" > (auto-create node + domain per team)</ span ></ div >
2026-09-23 15:14:52 +08:00
< div style = "display:flex;gap:8px;align-items:center" >
2026-09-23 16:18:11 +08:00
< input type = "number" id = "teamCount" min = "0" max = "50" value = "0" style = "width:90px" oninput = "teamCountChanged()" >
2026-09-23 15:14:52 +08:00
< button class = "primary" onclick = "setTeams()" > Set & Buat</ button >
</ div >
</ div >
2026-09-23 16:18:11 +08:00
< div id = "teamNameInputs" style = "margin-top:10px;display:flex;flex-direction:column;gap:6px" ></ div >
2026-09-23 15:14:52 +08:00
< 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 >
2026-09-23 17:14:42 +08:00
<!-- Target Matrix view -->
< div class = "view" id = "view-targets" >
< div class = "card" >
< div style = "display:flex;align-items:center;gap:10px;flex-wrap:wrap" >
< b style = "color:#5ad1ff" > 🎯 Target Matrix</ b >
< span style = "font-size:11px;color:#718096" > Domain:port semua service semua tim — buat dibagikan ke peserta sebagai daftar target</ span >
</ div >
< div id = "targetMatrix" style = "margin-top:14px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.9;color:#dbe6f4;background:#0a101f;border:1px solid #1e3a5f;border-radius:10px;padding:14px;overflow-x:auto" > Memuat…</ div >
</ div >
</ div >
2026-09-23 15:14:52 +08:00
< div class = "footer-note" > Receiver: https://gemastik.imrnes.team · Panel: https://panel.gemastik.imrnes.team · Auto-refresh tiap 15 detik</ div >
2026-09-23 14:26:26 +08:00
<!-- 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 >
2026-09-23 15:14:52 +08:00
<!-- 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 >
2026-09-23 14:26:26 +08:00
< div class = "btn-row" style = "justify-content:flex-end;margin-top:14px" >
2026-09-23 15:14:52 +08:00
< button onclick = "closeModal('modalTeamCred')" > Tutup</ button >
2026-09-23 14:26:26 +08:00
</ div >
</ div >
</ div >
< div class = "toast" id = "toast" ></ div >
2026-09-23 17:02:52 +08:00
<!-- loading overlay -->
< div id = "loadingOverlay" >
< div class = "loader" ></ div >
< div id = "loadingText" > Memproses…</ div >
</ div >
2026-09-23 14:26:26 +08:00
< 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 );
}
2026-09-23 17:02:52 +08:00
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' );
}
2026-09-23 14:26:26 +08:00
function esc ( s ) {
2026-09-23 16:37:58 +08:00
return String ( s ?? '' ). replace ( /[&<>"']/g , c => ({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' }[ c ]));
2026-09-23 14:26:26 +08:00
}
2026-09-23 15:14:52 +08:00
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 ));
2026-09-23 18:05:44 +08:00
if ( v === 'topo' ) loadTopo (); // refresh attacks immediately on tab switch + every 10s
2026-09-23 15:14:52 +08:00
}
2026-09-23 18:05:44 +08:00
let topoTimer = null ;
function startTopoTimer () {
if ( ! topoTimer ) topoTimer = setInterval (() => {
if ( document . getElementById ( 'view-topo' ). classList . contains ( 'active' )) loadTopo ();
}, 10000 );
}
startTopoTimer ();
2026-09-23 15:14:52 +08:00
// ---------- Challenges ----------
2026-09-23 14:26:26 +08:00
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 ;
2026-09-23 15:14:52 +08:00
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 } ` ;
2026-09-23 14:26:26 +08:00
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 ); }
}
2026-09-23 15:14:52 +08:00
// ---------- Topology ----------
let topoLoaded = false ;
2026-09-23 18:05:44 +08:00
let topoAttacks = [];
2026-09-23 15:14:52 +08:00
async function loadTopo () {
2026-09-23 14:26:26 +08:00
try {
2026-09-23 18:05:44 +08:00
const [ d , a ] = await Promise . all ([ api ( '/api/topology' ), api ( '/api/attacks' ). catch (() => ({ events : []}))]);
topoAttacks = a . events || [];
2026-09-23 15:14:52 +08:00
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 );
2026-09-23 18:05:44 +08:00
const H = 500 ;
2026-09-23 15:14:52 +08:00
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 );
2026-09-23 18:05:44 +08:00
const ty = 250 + ry * Math . sin ( ang ) * 0.6 ;
2026-09-23 15:14:52 +08:00
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' , '' );
2026-09-23 18:05:44 +08:00
const base = teamPos [ ti ] || { x : cx , y : 300 };
2026-09-23 15:14:52 +08:00
const chIdx = [ 'blogpost' , 'carbeat' , 'cdn' , 'phew' , 'sheesh' , 'warmup' ]. indexOf ( n . label . split ( '-' ). pop () || n . label );
2026-09-23 18:05:44 +08:00
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 );
2026-09-23 15:14:52 +08:00
});
// 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 ;
2026-09-23 18:05:44 +08:00
html += `<text x=" ${ mx } " y=" ${ my } " fill="#5a7a9f" font-size="9" text-anchor="middle"> ${ esc ( e . label ) } </text>` ;
2026-09-23 15:14:52 +08:00
}
});
2026-09-23 18:05:44 +08:00
// ---- 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>` ;
}
2026-09-23 15:14:52 +08:00
// 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 } ` : '' );
2026-09-23 18:05:44 +08:00
html += `<g data-node=" ${ esc ( n . id ) } " style="cursor:grab">
2026-09-23 15:14:52 +08:00
<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>` ;
});
2026-09-23 21:50:16 +08:00
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><g data-root> ${ html } </g>` ;
2026-09-23 18:05:44 +08:00
enableTopoDrag ( svg , pos );
2026-09-23 21:50:16 +08:00
applyTopoView ();
2026-09-23 18:05:44 +08:00
}
2026-09-23 21:50:16 +08:00
// ---- draggable topology + zoom/pan ----
let topoScale = 1 ;
let topoPanX = 0 , topoPanY = 0 ;
2026-09-23 18:05:44 +08:00
function enableTopoDrag ( svg , pos ) {
2026-09-23 21:50:16 +08:00
const gMain = svg . querySelector ( 'g[data-root]' ) || svg ;
2026-09-23 18:05:44 +08:00
let dragEl = null , dx = 0 , dy = 0 ;
2026-09-23 21:50:16 +08:00
let panning = false , px = 0 , py = 0 ;
const toLocal = ( ev ) => {
2026-09-23 18:05:44 +08:00
const ctm = svg . getScreenCTM ();
2026-09-23 21:50:16 +08:00
if ( ! ctm ) return { x : 0 , y : 0 };
2026-09-23 18:05:44 +08:00
const pt = svg . createSVGPoint ();
pt . x = ev . clientX ; pt . y = ev . clientY ;
2026-09-23 21:50:16 +08:00
return pt . matrixTransform ( ctm . inverse ());
2026-09-23 18:05:44 +08:00
};
2026-09-23 21:50:16 +08:00
const onMove = ( ev ) => {
if ( dragEl ) {
const p = toLocal ( ev );
dragEl . setAttribute ( 'transform' , `translate( ${ p . x - dx } , ${ p . y - dy } )` );
} else if ( panning ) {
topoPanX += ev . clientX - px ;
topoPanY += ev . clientY - py ;
px = ev . clientX ; py = ev . clientY ;
applyTopoView ();
}
};
const onUp = () => { dragEl = null ; panning = false ; svg . style . cursor = '' ; };
2026-09-23 18:05:44 +08:00
svg . addEventListener ( 'mousedown' , ( ev ) => {
const g = ev . target . closest ( 'g[data-node]' );
2026-09-23 21:50:16 +08:00
if ( g ) {
ev . preventDefault ();
const p = toLocal ( ev );
const c = g . querySelector ( 'circle' );
dx = p . x - parseFloat ( c . getAttribute ( 'cx' ));
dy = p . y - parseFloat ( c . getAttribute ( 'cy' ));
dragEl = g ;
svg . style . cursor = 'grabbing' ;
g . setPointerCapture && g . setPointerCapture ( ev . pointerId );
} else {
// empty area -> pan the canvas
panning = true ;
px = ev . clientX ; py = ev . clientY ;
svg . style . cursor = 'move' ;
}
2026-09-23 18:05:44 +08:00
});
svg . addEventListener ( 'pointermove' , onMove );
svg . addEventListener ( 'pointerup' , onUp );
svg . addEventListener ( 'pointercancel' , onUp );
2026-09-23 21:50:16 +08:00
// wheel zoom (ctrl+wheel or plain wheel on empty area)
svg . addEventListener ( 'wheel' , ( ev ) => {
ev . preventDefault ();
const factor = ev . deltaY < 0 ? 1.12 : 1 / 1.12 ;
const ns = Math . min ( 3 , Math . max ( 0.3 , topoScale * factor ));
// zoom around cursor
const rect = svg . getBoundingClientRect ();
const mx = ev . clientX - rect . left , my = ev . clientY - rect . top ;
const W = svg . clientWidth , H = svg . clientHeight ;
topoPanX = mx - ( mx - topoPanX ) * ( ns / topoScale );
topoPanY = my - ( my - topoPanY ) * ( ns / topoScale );
topoScale = ns ;
applyTopoView ();
}, { passive : false });
2026-09-23 15:14:52 +08:00
}
2026-09-23 21:50:16 +08:00
function applyTopoView () {
const svg = document . getElementById ( 'topoSvg' );
const g = svg . querySelector ( 'g[data-root]' );
if ( ! g ) return ;
g . setAttribute ( 'transform' , `translate( ${ topoPanX } , ${ topoPanY } ) scale( ${ topoScale } )` );
// update hint + zoom % label
const zl = document . getElementById ( 'topoZoomLabel' );
if ( zl ) zl . textContent = Math . round ( topoScale * 100 ) + '%' ;
}
function topoZoom ( factor ) {
const svg = document . getElementById ( 'topoSvg' );
const rect = svg . getBoundingClientRect ();
const ns = Math . min ( 3 , Math . max ( 0.3 , topoScale * factor ));
topoPanX = rect . width / 2 - ( rect . width / 2 - topoPanX ) * ( ns / topoScale );
topoPanY = rect . height / 2 - ( rect . height / 2 - topoPanY ) * ( ns / topoScale );
topoScale = ns ;
applyTopoView ();
}
function topoReset () { topoScale = 1 ; topoPanX = 0 ; topoPanY = 0 ; applyTopoView (); }
2026-09-23 15:14:52 +08:00
// ---------- 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' ;
2026-09-23 16:18:11 +08:00
const dom = t . domain || '' ;
2026-09-23 15:14:52 +08:00
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>
2026-09-23 16:38:30 +08:00
<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>` : '' }
2026-09-23 15:14:52 +08:00
</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>
2026-09-23 21:50:16 +08:00
<button onclick="editTeam( ${ t . index } )">✏️ Edit Nama/Domain</button>
2026-09-23 15:14:52 +08:00
<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 );
2026-09-23 21:50:16 +08:00
const labels = {}, domains = {};
2026-09-23 16:18:11 +08:00
for ( let i = 1 ; i <= n ; i ++ ) {
2026-09-23 21:50:16 +08:00
const inpN = document . getElementById ( 'teamNameInputs' ) ? . querySelector ( `input[data-idx=" ${ i } "][data-field="name"]` );
if ( inpN && inpN . value . trim ()) labels [ i ] = inpN . value . trim ();
const inpD = document . getElementById ( 'teamNameInputs' ) ? . querySelector ( `input[data-idx=" ${ i } "][data-field="domain"]` );
if ( inpD && inpD . value . trim ()) domains [ i ] = inpD . value . trim ();
2026-09-23 16:18:11 +08:00
}
2026-09-23 15:14:52 +08:00
try {
2026-09-23 17:02:52 +08:00
showLoading ( `Membuat ${ n } team…<br>Generate compose, flags, domain, Traefik config` );
2026-09-23 21:50:16 +08:00
const d = await api ( '/api/teams/set' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON . stringify ({ count : n , labels , domains })});
2026-09-23 15:14:52 +08:00
toast ( `Team dibuat: ${ d . created . join ( ', ' ) || 'tidak ada yang baru' } · total ${ d . total } ` , false );
loadTeams ();
2026-09-23 14:26:26 +08:00
} catch ( e ) { toast ( e . message , true ); }
2026-09-23 17:02:52 +08:00
finally { hideLoading (); }
2026-09-23 14:26:26 +08:00
}
2026-09-23 16:18:11 +08:00
function teamCountChanged () {
const n = parseInt ( document . getElementById ( 'teamCount' ). value || '0' , 10 );
const box = document . getElementById ( 'teamNameInputs' );
2026-09-23 21:50:16 +08:00
let html = '<div style="font-size:11px;color:#718096;margin-bottom:4px">Nama team & domain custom (opsional — kosongkan untuk auto dari nama)</div>' ;
2026-09-23 16:18:11 +08:00
for ( let i = 1 ; i <= n ; i ++ ) {
2026-09-23 21:50:16 +08:00
html += `<div style="display:flex;align-items:center;gap:6px">
2026-09-23 16:18:11 +08:00
<span style="color:#718096;font-size:12px;width:60px">Team ${ i } </span>
2026-09-23 21:50:16 +08:00
<input data-idx=" ${ i } " data-field="name" placeholder="Nama team (contoh: Cyber Warriors)" style="flex:1;min-width:120px">
<input data-idx=" ${ i } " data-field="domain" placeholder="domain custom (contoh: team-cyber)" style="flex:1;min-width:120px">
<span style="color:#3b4a63;font-size:11px">.gemastik.imrnes.team</span>
2026-09-23 16:18:11 +08:00
</div>` ;
}
box . innerHTML = html ;
}
2026-09-23 21:50:16 +08:00
async function editTeam ( idx ) {
// fetch current team info
let t = null ;
try { t = ( await api ( `/api/team/ ${ idx } /creds` )). team ; } catch ( e ) { toast ( 'Gagal ambil data team' , true ); return ; }
const newLabel = prompt ( `✏️ Nama team ${ idx } (sekarang: ${ t . label || ( 'Team ' + idx ) } ):` , t . label || '' );
if ( newLabel === null ) return ;
const newDomain = prompt ( `🌐 Domain custom team ${ idx } (sekarang: ${ t . domain || '' } — ketik subdomain saja, contoh: team-cyber):` , ( t . domain || '' ). split ( '.' )[ 0 ]);
if ( newDomain === null ) return ;
try {
showLoading ( 'Update nama/domain team…' );
const d = await api ( `/api/teams/ ${ idx } ` , { method : 'PUT' , headers : { 'Content-Type' : 'application/json' }, body : JSON . stringify ({ label : newLabel . trim (), domain : newDomain . trim ()})});
toast ( `Team ${ idx } diupdate: ${ d . team . label } @ ${ d . team . domain } ` , false );
loadTeams ();
if ( document . getElementById ( 'view-topo' ). classList . contains ( 'active' )) loadTopo ();
} catch ( e ) { toast ( e . message , true ); }
finally { hideLoading (); }
}
2026-09-23 15:14:52 +08:00
async function startTeam ( idx ) {
2026-09-23 17:02:52 +08:00
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 ); }
2026-09-23 15:14:52 +08:00
catch ( e ) { toast ( e . message , true ); }
2026-09-23 17:02:52 +08:00
finally { hideLoading (); setTimeout ( loadTeams , 2500 ); }
2026-09-23 15:14:52 +08:00
}
async function stopTeam ( idx ) {
2026-09-23 17:02:52 +08:00
const ov = showLoading ( `Menghentikan Team ${ idx } …` );
2026-09-23 15:14:52 +08:00
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 ); }
2026-09-23 17:02:52 +08:00
finally { hideLoading (); }
2026-09-23 15:14:52 +08:00
}
async function startAllTeams () {
2026-09-23 17:02:52 +08:00
const ov = showLoading ( 'Start SEMUA team…<br>Ini bisa butuh beberapa menit (build + boot + set password)' );
2026-09-23 15:14:52 +08:00
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 ); }
2026-09-23 17:02:52 +08:00
finally { hideLoading (); }
2026-09-23 15:14:52 +08:00
}
async function stopAllTeams () {
2026-09-23 17:02:52 +08:00
const ov = showLoading ( 'Menghentikan SEMUA team…' );
2026-09-23 15:14:52 +08:00
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 ); }
2026-09-23 17:02:52 +08:00
finally { hideLoading (); }
2026-09-23 15:14:52 +08:00
}
2026-09-23 17:14:42 +08:00
async function resetScores () {
if ( ! confirm ( '⚠️ Apakah anda yakin ingin mereset skor?\n\nSemua poin & solve di leaderboard akan DIHAPUS.\nTindakan ini tidak bisa dibatalkan.' )) return ;
showLoading ( 'Menghapus semua skor…' );
try {
const d = await api ( '/api/reset/scores' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : '{}' });
toast ( 'Skor direset: semua solve dihapus' , false );
} catch ( e ) { toast ( e . message , true ); }
finally { hideLoading (); }
}
async function resetEnv () {
if ( ! confirm ( '⚠️ Apakah anda yakin ingin mereset environment?\n\nSEMUA team akan DISTOP:\n• Container challenge dihapus\n• Receiver team dihapus\n• Folder team dihapus (perlu create ulang)\n• Skor leaderboard direset\n\nTindakan ini TIDAK BISA dibatalkan.' )) return ;
showLoading ( 'Reset environment…\nMenghentikan semua team + menghapus container/receiver (beberapa menit)' );
try {
const d = await api ( '/api/reset/environment' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : '{}' });
const n = ( d . stopped || []). length ;
toast ( `Environment direset: ${ n } team dihentikan & dihapus` , false );
setTimeout (() => location . reload (), 1200 );
} catch ( e ) { toast ( e . message , true ); }
finally { hideLoading (); }
}
2026-09-23 15:14:52 +08:00
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 ); }
}
2026-09-23 17:14:42 +08:00
// ---------- Target Matrix ----------
async function loadTargetMatrix () {
const box = document . getElementById ( 'targetMatrix' );
try {
const d = await api ( '/api/targets' );
const targets = d . targets || [];
if ( ! targets . length ) { box . textContent = 'Belum ada team.' ; return ; }
// group by team
const rows = [];
let curTeam = null ;
for ( const t of targets ) {
if ( t . team !== curTeam ) {
rows . push ( `\n[ ${ esc ( t . team_label || ( 'Team ' + t . team )) } ]` );
curTeam = t . team ;
}
rows . push ( ` ${ esc ( t . domain ) } : ${ t . port } ( ${ esc ( t . challenge ) } )` );
}
box . textContent = rows . join ( '\n' );
} catch ( e ) { box . textContent = 'Gagal: ' + e . message ; }
}
2026-09-23 15:14:52 +08:00
// ---------- misc ----------
2026-09-23 14:26:26 +08:00
function closeModal ( id ) { document . getElementById ( id ). classList . remove ( 'open' ); }
async function logout () {
await fetch ( '/api/logout' , { method : 'POST' });
location . href = '/login' ;
}
2026-09-23 15:14:52 +08:00
[ 'modalFlag' , 'modalCred' , 'modalTeamCred' ]. forEach ( id => {
document . getElementById ( id ). addEventListener ( 'click' , e => { if ( e . target === e . currentTarget ) closeModal ( id ); });
});
2026-09-23 14:26:26 +08:00
function tick () {
document . getElementById ( 'clock' ). textContent = new Date (). toLocaleTimeString ( 'id-ID' );
}
setInterval ( tick , 1000 ); tick ();
refresh ();
setInterval ( refresh , REFRESH_MS );
</ script >
</ body >
</ html >