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 ; }
. 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 >
< 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 >
</ 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" >
< 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" >
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 >
< 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 ));
}
// ---------- 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 ;
async function loadTopo () {
2026-09-23 14:26:26 +08:00
try {
2026-09-23 15:14:52 +08:00
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' ;
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>
<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 16:18:11 +08:00
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 ();
}
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 16:18:11 +08:00
const d = await api ( '/api/teams/set' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON . stringify ({ count : n , labels })});
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' );
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 ;
}
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
}
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 ----------
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 >