feat: add Gemastik A/D control panel (web UI for receiver)

- FastAPI app at panel/ proxying receiver API server-side (admin creds stay server-side)
- Login-protected dashboard: SLA status, rotate flag, restart/rollback/activate/deactivate, SSH creds, command history
- Runs as systemd service gemastik-panel.service on :18081
- Published at https://panel.gemastik.imrnes.team via Traefik
This commit is contained in:
Hermes
2026-09-23 14:26:26 +08:00
parent 7d6258e94e
commit 6eb0dabb58
5 changed files with 527 additions and 0 deletions
Binary file not shown.
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""
Gemastik A/D Panel — web UI for the gemastik18-final receiver.
Serves a dashboard at / and proxies receiver API calls server-side so the
admin credentials stay out of the browser.
"""
import os
import json
import time
import httpx
from pathlib import Path
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from typing import Optional
RECEIVER_URL = os.environ.get("RECEIVER_URL", "http://127.0.0.1:18080")
ADMIN_USER = os.environ.get("PANEL_ADMIN_USER", "admin")
ADMIN_PASS = os.environ.get("PANEL_ADMIN_PASS", "admin")
BASE_DIR = Path(__file__).parent
app = FastAPI(title="Gemastik A/D Panel")
CHALLENGES = [
{"name": "blogpost", "port": 10000, "ssh": 10022, "category": "web", "desc": "Flask blog with exiftool + SSTI"},
{"name": "carbeat", "port": 11000, "ssh": 11022, "category": "pwn", "desc": "Binary exploitation menu"},
{"name": "cdn", "port": 12000, "ssh": 12022, "category": "web", "desc": "CDN/image proxy SSTI"},
{"name": "phew", "port": 13000, "ssh": 13022, "category": "crypto","desc": "Paillier crypto oracle"},
{"name": "sheesh", "port": 14000, "ssh": 14022, "category": "crypto","desc": "AES padding oracle"},
{"name": "warmup", "port": 15000, "ssh": 15022, "category": "warmup","desc": "Go file viewer (path traversal)"},
]
# Simple in-memory session tokens (good enough for a CTF ops panel)
_sessions = {}
def _check_basic(req: Request):
auth = req.headers.get("authorization", "")
if not auth.startswith("Basic "):
return None
import base64
try:
decoded = base64.b64decode(auth.split(" ", 1)[1]).decode()
user, _, pw = decoded.partition(":")
return (user, pw)
except Exception:
return None
def _authorized(req: Request) -> bool:
creds = _check_basic(req)
if creds and creds[0] == ADMIN_USER and creds[1] == ADMIN_PASS:
return True
# session token via cookie
token = req.cookies.get("panel_token")
return token in _sessions and _sessions[token] > time.time()
def _receiver_auth() -> tuple:
# Load receiver admin creds from its .env (single source of truth)
env_path = Path("/opt/gemastik18-final/receiver/.env")
u = p = ""
try:
for line in env_path.read_text().splitlines():
if line.startswith("ADMIN_USERNAME="):
u = line.split("=", 1)[1]
elif line.startswith("ADMIN_PASSWORD="):
p = line.split("=", 1)[1]
except Exception:
pass
return (u, p)
async def _proxy(method: str, path: str, body: dict = None):
u, p = _receiver_auth()
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.request(method, f"{RECEIVER_URL}{path}",
auth=(u, p), json=body if body is not None else None)
return resp
@app.get("/", response_class=HTMLResponse)
async def index(req: Request):
if not _authorized(req):
return RedirectResponse("/login")
html = (BASE_DIR / "static" / "index.html").read_text()
return HTMLResponse(html)
@app.get("/login", response_class=HTMLResponse)
async def login_page(req: Request):
if _authorized(req):
return RedirectResponse("/")
return HTMLResponse((BASE_DIR / "static" / "login.html").read_text())
@app.post("/api/login")
async def api_login(req: Request):
data = await req.json()
if data.get("user") == ADMIN_USER and data.get("pass") == ADMIN_PASS:
token = os.urandom(16).hex()
_sessions[token] = time.time() + 8 * 3600
resp = JSONResponse({"ok": True})
resp.set_cookie("panel_token", token, httponly=True, samesite="lax", max_age=8 * 3600)
return resp
raise HTTPException(401, "Invalid credentials")
@app.post("/api/logout")
async def api_logout(req: Request):
token = req.cookies.get("panel_token")
if token:
_sessions.pop(token, None)
return {"ok": True}
def require_login(req: Request):
if not _authorized(req):
raise HTTPException(401, "Not authorized")
# ---- receiver proxy endpoints (server-side, keeps admin creds secret) ----
@app.get("/api/challenges")
async def api_challenges(req: Request):
require_login(req)
return {"challenges": CHALLENGES}
@app.get("/api/status")
async def api_status(req: Request):
require_login(req)
results = []
for ch in CHALLENGES:
try:
resp = await _proxy("GET", f"/check/{ch['name']}")
ok = bool(resp.json().get("success")) if resp.status_code == 200 else False
except Exception as e:
ok = False
# read host flag file
flag = ""
try:
fp = Path(f"/opt/gemastik18-final/receiver/flags/{ch['name']}.txt")
if fp.exists():
flag = fp.read_text().strip()
except Exception:
pass
results.append({**ch, "alive": ok, "flag": flag})
return {"results": results, "ts": int(time.time())}
@app.post("/api/flag")
async def api_flag(req: Request):
require_login(req)
data = await req.json()
challenge = data.get("challenge", "")
flag = data.get("flag", "")
if challenge not in [c["name"] for c in CHALLENGES]:
raise HTTPException(400, "Unknown challenge")
if not flag:
raise HTTPException(400, "Flag is empty")
resp = await _proxy("POST", "/flag", {"challenge": challenge, "flag": flag})
return {"receiver_status": resp.status_code, "receiver_body": resp.text}
@app.post("/api/restart/{challenge}")
async def api_restart(challenge: str, req: Request):
require_login(req)
resp = await _proxy("GET", f"/restart/{challenge}")
return {"receiver_status": resp.status_code, "receiver_body": resp.text}
@app.post("/api/rollback/{challenge}")
async def api_rollback(challenge: str, req: Request):
require_login(req)
resp = await _proxy("GET", f"/rollback/{challenge}")
return {"receiver_status": resp.status_code, "receiver_body": resp.text}
@app.post("/api/activate/{challenge}")
async def api_activate(challenge: str, req: Request):
require_login(req)
resp = await _proxy("GET", f"/activate/{challenge}")
return {"receiver_status": resp.status_code, "receiver_body": resp.text}
@app.post("/api/deactivate/{challenge}")
async def api_deactivate(challenge: str, req: Request):
require_login(req)
resp = await _proxy("GET", f"/deactivate/{challenge}")
return {"receiver_status": resp.status_code, "receiver_body": resp.text}
@app.get("/api/credential/{challenge}")
async def api_credential(challenge: str, req: Request):
require_login(req)
resp = await _proxy("GET", f"/credential/{challenge}")
if resp.status_code == 200:
return resp.json()
return {"error": resp.text}
@app.get("/api/history")
async def api_history(req: Request):
require_login(req)
try:
lines = (BASE_DIR.parent / "history" / "command.txt").read_text().splitlines()
except Exception:
lines = []
return {"lines": lines[-200:]}
+269
View File
@@ -0,0 +1,269 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gemastik A/D Panel</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'JetBrains Mono','Fira Code',monospace;
background: radial-gradient(ellipse at top, #0d1b2a 0%, #0a0f1c 55%, #05070d 100%);
color: #c9d4e3; min-height:100vh; padding:24px;
}
header {
display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;
border-bottom:1px solid #1e3a5f; padding-bottom:16px; flex-wrap:wrap; gap:12px;
}
h1 { font-size:22px; color:#5ad1ff; letter-spacing:1px; }
.actions { display:flex; gap:10px; align-items:center; }
.pill {
background:#0e1526; border:1px solid #1e3a5f; color:#8aa0b8; font-size:12px;
padding:7px 13px; border-radius:20px;
}
.pill.green { color:#34d399; border-color:#34d39955; background:#34d39911; }
.pill.red { color:#f87171; border-color:#f8717155; background:#f8717111; }
button {
background:#0e1526; border:1px solid #1e3a5f; color:#a8c3e0; font-family:inherit;
font-size:12px; padding:8px 14px; border-radius:8px; cursor:pointer; transition:all .2s;
}
button:hover { border-color:#3b82f6; color:#fff; }
button.primary {
background:linear-gradient(135deg,#0ea5e9,#2563eb); border:none; color:#fff; font-weight:700;
}
button.danger { border-color:#f8717155; color:#f87171; }
button.danger:hover { background:#f8717122; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:16px; }
.card {
background:#0e1526cc; border:1px solid #1e3a5f; border-radius:12px; padding:18px;
display:flex; flex-direction:column; gap:10px; position:relative;
}
.card.dead { border-color:#f8717155; background:#1a0f16cc; }
.card-top { display:flex; justify-content:space-between; align-items:center; }
.ch-name { font-size:16px; font-weight:700; color:#e2ecfa; }
.ch-cat {
font-size:10px; text-transform:uppercase; letter-spacing:1px; padding:3px 8px;
border-radius:10px; background:#1e3a5f44; color:#8aa0b8;
}
.status { font-size:11px; font-weight:700; letter-spacing:.5px; text-transform:uppercase; }
.status.up { color:#34d399; }
.status.down { color:#f87171; }
.flag {
background:#0a101f; border:1px dashed #2a4a6f; border-radius:8px; padding:10px 12px;
font-size:12px; color:#7dd3fc; word-break:break-all; min-height:18px;
}
.creds { font-size:11px; color:#718096; }
.creds b { color:#a8c3e0; font-weight:600; }
.btn-row { display:flex; gap:6px; flex-wrap:wrap; margin-top:2px; }
.btn-row button { font-size:11px; padding:6px 10px; }
textarea, input[type=text] {
width:100%; background:#0a101f; border:1px solid #1e3a5f; color:#dbe6f4;
border-radius:8px; padding:10px; font-size:13px; font-family:inherit; outline:none; resize:vertical;
}
textarea:focus, input:focus { border-color:#3b82f6; }
.modal-back {
position:fixed; inset:0; background:#000a; display:none; align-items:center; justify-content:center; z-index:50;
}
.modal-back.open { display:flex; }
.modal {
background:#0d1526; border:1px solid #2a4a6f; border-radius:14px; padding:26px; width:440px; max-width:92vw;
}
.modal h2 { font-size:16px; color:#5ad1ff; margin-bottom:16px; }
.toast {
position:fixed; bottom:24px; right:24px; background:#0e1526; border:1px solid #3b82f6;
color:#dbe6f4; padding:13px 18px; border-radius:10px; font-size:13px; z-index:80;
opacity:0; transform:translateY(10px); transition:all .3s;
}
.toast.show { opacity:1; transform:translateY(0); }
.toast.err { border-color:#f87171; color:#fca5a5; }
.history-box {
background:#0a101f; border:1px solid #1e3a5f; border-radius:10px; padding:14px;
font-size:11px; color:#7dd3fc; max-height:300px; overflow-y:auto; white-space:pre-wrap;
}
.footer-note { color:#4a5a70; font-size:11px; margin-top:20px; text-align:center; }
.spin { display:inline-block; width:12px; height:12px; border:2px solid #2a4a6f; border-top-color:#5ad1ff; border-radius:50%; animation:sp .7s linear infinite; }
@keyframes sp { to { transform:rotate(360deg); } }
</style>
</head>
<body>
<header>
<h1>⚔️ GEMASTIK A/D — NODE CONTROL</h1>
<div class="actions">
<span class="pill" id="clock">--:--:--</span>
<button onclick="refresh()">🔄 Refresh</button>
<button onclick="logout()">Keluar</button>
</div>
</header>
<div class="grid" id="grid"></div>
<div class="footer-note">Receiver: https://gemastik.imrnes.team · Auto-refresh tiap 15 detik · Flags dirotate lewat panel ini</div>
<!-- modal flag -->
<div class="modal-back" id="modalFlag">
<div class="modal">
<h2 id="mfTitle">Rotate Flag</h2>
<input type="text" id="mfFlag" placeholder="GEMASTIK18{...}" style="margin-bottom:12px">
<div class="btn-row" style="justify-content:flex-end">
<button onclick="closeModal('modalFlag')">Batal</button>
<button class="primary" onclick="submitFlag()">Submit Flag</button>
</div>
</div>
</div>
<!-- modal creds -->
<div class="modal-back" id="modalCred">
<div class="modal">
<h2 id="mcTitle">SSH Credentials</h2>
<div id="mcBody" style="font-size:13px;color:#dbe6f4;line-height:1.8"></div>
<div class="btn-row" style="justify-content:flex-end;margin-top:14px">
<button onclick="closeModal('modalCred')">Tutup</button>
</div>
</div>
</div>
<!-- modal history -->
<div class="modal-back" id="modalHist">
<div class="modal" style="width:560px">
<h2>Command History (preexec)</h2>
<div class="history-box" id="mhBody">loading...</div>
<div class="btn-row" style="justify-content:flex-end;margin-top:14px">
<button onclick="closeModal('modalHist')">Tutup</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
let CURRENT = null;
let REFRESH_MS = 15000;
async function api(path, opts={}) {
const r = await fetch(path, opts);
if (r.status === 401) { location.href = '/login'; throw new Error('unauth'); }
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.detail || data.receiver_body || r.statusText);
return data;
}
function toast(msg, err=false) {
const t = document.getElementById('toast');
t.textContent = msg; t.className = 'toast show' + (err ? ' err' : '');
setTimeout(() => t.className = 'toast', 3000);
}
function esc(s) {
return (s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
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 creds lazily
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;
let cmd = `ssh ctfuser@warmup.gemastik.imrnes.team -p 10022`.replace('warmup.gemastik.imrnes.team', ch + '.gemastik.imrnes.team').replace('10022', String(ch==='blogpost'?10022:ch==='carbeat'?11022:ch==='cdn'?12022:ch==='phew'?13022:ch==='sheesh'?14022:15022));
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); }
}
async function openHist() {
try {
const d = await api('/api/history');
document.getElementById('mhBody').textContent = d.lines.join('\n') || '(belum ada log)';
document.getElementById('modalHist').classList.add('open');
} catch (e) { toast(e.message, true); }
}
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
async function logout() {
await fetch('/api/logout', {method:'POST'});
location.href = '/login';
}
document.getElementById('modalFlag').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal('modalFlag'); });
document.getElementById('modalCred').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal('modalCred'); });
document.getElementById('modalHist').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal('modalHist'); });
function tick() {
document.getElementById('clock').textContent = new Date().toLocaleTimeString('id-ID');
}
setInterval(tick, 1000); tick();
refresh();
setInterval(refresh, REFRESH_MS);
</script>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
<!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 — Login</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 60%, #05070d 100%);
color: #c9d4e3; height:100vh; display:flex; align-items:center; justify-content:center;
}
.card {
background:#0e1526cc; border:1px solid #1e3a5f; border-radius:14px;
padding:42px 46px; width:360px; box-shadow:0 0 60px #0a3d6b55;
}
h1 { font-size:20px; color:#5ad1ff; letter-spacing:1px; margin-bottom:6px; }
.sub { color:#718096; font-size:12px; margin-bottom:28px; }
label { display:block; font-size:11px; color:#7d8ba1; margin:14px 0 6px; text-transform:uppercase; letter-spacing:1px; }
input {
width:100%; background:#0a101f; border:1px solid #1e3a5f; color:#dbe6f4;
border-radius:8px; padding:11px 13px; font-size:14px; font-family:inherit; outline:none;
}
input:focus { border-color:#3b82f6; box-shadow:0 0 0 3px #3b82f633; }
button {
width:100%; margin-top:24px; background:linear-gradient(135deg,#0ea5e9,#2563eb);
border:none; color:#fff; font-family:inherit; font-weight:700; font-size:14px;
padding:12px; border-radius:8px; cursor:pointer; letter-spacing:.5px; transition:filter .2s;
}
button:hover { filter:brightness(1.15); }
.err { color:#f87171; font-size:12px; margin-top:14px; min-height:16px; }
</style>
</head>
<body>
<div class="card">
<h1>⚔️ GEMASTIK A/D</h1>
<div class="sub">Node Control Panel — imrnes</div>
<form id="f">
<label>Username</label>
<input id="u" autocomplete="username" required>
<label>Password</label>
<input id="p" type="password" autocomplete="current-password" required>
<button type="submit">MASUK</button>
<div class="err" id="err"></div>
</form>
</div>
<script>
const f = document.getElementById('f');
f.addEventListener('submit', async (e) => {
e.preventDefault();
const err = document.getElementById('err');
err.textContent = '';
const r = await fetch('/api/login', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({user: u.value, pass: p.value})
});
if (r.ok) location.href = '/';
else err.textContent = 'Kredensial salah!';
});
</script>
</body>
</html>