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:
+193
@@ -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:]}
|
||||
Reference in New Issue
Block a user