From 6eb0dabb5888e948d724d06d94df60d6bc10dbf3 Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 23 Sep 2026 14:26:26 +0800 Subject: [PATCH] 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 --- .gitignore | 1 + panel/__pycache__/main.cpython-312.pyc | Bin 0 -> 12426 bytes panel/main.py | 193 ++++++++++++++++++ panel/static/index.html | 269 +++++++++++++++++++++++++ panel/static/login.html | 64 ++++++ 5 files changed, 527 insertions(+) create mode 100644 panel/__pycache__/main.cpython-312.pyc create mode 100644 panel/main.py create mode 100644 panel/static/index.html create mode 100644 panel/static/login.html diff --git a/.gitignore b/.gitignore index 0db129e..f18f7f1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ receiver/lib64 receiver/pyenv.cfg receiver/include receiver/pwntools-docreceiver/.venv/ +panel/.env diff --git a/panel/__pycache__/main.cpython-312.pyc b/panel/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7bd0f1f547cea1f7a04ff68960115c23297bc2ce GIT binary patch literal 12426 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 vYr?Q2h2Wza_hm*?|8Ic=?}2 literal 0 HcmV?d00001 diff --git a/panel/main.py b/panel/main.py new file mode 100644 index 0000000..c092bd0 --- /dev/null +++ b/panel/main.py @@ -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:]} \ No newline at end of file diff --git a/panel/static/index.html b/panel/static/index.html new file mode 100644 index 0000000..433081b --- /dev/null +++ b/panel/static/index.html @@ -0,0 +1,269 @@ + + + + + +Gemastik A/D Panel + + + +
+

βš”οΈ GEMASTIK A/D β€” NODE CONTROL

+
+ --:--:-- + + +
+
+ +
+ + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/panel/static/login.html b/panel/static/login.html new file mode 100644 index 0000000..fa2bb18 --- /dev/null +++ b/panel/static/login.html @@ -0,0 +1,64 @@ + + + + + +Gemastik A/D Panel β€” Login + + + +
+

βš”οΈ GEMASTIK A/D

+
Node Control Panel β€” imrnes
+
+ + + + + +
+
+
+ + + \ No newline at end of file