feat: multi-team orchestrator - topology UI, team management, flag randomizer, public submit + leaderboard

- panel/teams.py: create/start/stop teams with isolated ports+creds, per-team receivers with CHALLENGE_PORT/CONTAINER env, flag randomization, submit validation + leaderboard
- panel/main.py: /api/teams, /api/teams/{idx}/randomize, /api/flag/submit, /api/leaderboard, /api/public/teams, /submit page
- panel/static/submit.html: public flag submission UI for teams
- receiver: _ch_port/_ch_container read .env per team, container name overrides
- .gitignore: exclude teams/, .venv, __pycache__
This commit is contained in:
root
2026-09-23 15:14:52 +08:00
parent 6eb0dabb58
commit 1ca963b2b7
13 changed files with 1037 additions and 186 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
import os
import io
import re
import time
@@ -29,7 +30,7 @@ class Blogpost(Challenge):
flag_location = 'flags/blogpost.txt' # Host copy (used by your orchestrator)
history_location = 'history/blogpost.txt'
container_flag_path = '/flag.txt'
container_name = 'blogpost_container' # <-- set to your actual container name
container_name = os.environ.get('CHALLENGE_CONTAINER_BLOGPOST', 'blogpost_container') # <-- set to your actual container name
# Heuristics to recognize ExifTool output
_exif_markers = (
+2 -1
View File
@@ -1,3 +1,4 @@
import os
import io
import re
import random
@@ -26,7 +27,7 @@ class CDN(Challenge):
flag_location = 'flags/cdn.txt' # host copy (written by your orchestrator)
history_location = 'history/cdn.txt'
container_flag_path = '/flag.txt'
container_name = 'cdn_container' # adjust if your container name differs
container_name = os.environ.get('CHALLENGE_CONTAINER_CDN', 'cdn_container') # adjust if your container name differs
# Canonical ExifTool markers (NO app fallbacks allowed)
_exif_must_have = (
+1 -1
View File
@@ -11,7 +11,7 @@ class Carbeat(Challenge):
flag_location = 'flags/carbeat.txt'
history_location = 'history/carbeat.txt'
_CONTAINER = "carbeat_container"
_CONTAINER = os.environ.get("CHALLENGE_CONTAINER_CARBEAT", "carbeat_container")
_SERVICE_CMD = ["docker", "exec", "-i", _CONTAINER, "/home/ctfuser/chall/mybini"]
_HEX_RE = re.compile(r'^[0-9a-fA-F]+$')
+1 -1
View File
@@ -9,7 +9,7 @@ class Phew(Challenge):
flag_location = 'flags/phew.txt'
history_location = 'history/phew.txt'
_CONTAINER = "phew_container"
_CONTAINER = os.environ.get("CHALLENGE_CONTAINER_PHEW", "phew_container")
_SERVICE_CMD = ["docker", "exec", "-i", _CONTAINER, "python3", "/home/ctfuser/chall/src/chall.py"]
_HEX_RE = re.compile(r'^[0-9a-fA-F]+$')
+1 -1
View File
@@ -10,7 +10,7 @@ class Sheesh(Challenge):
flag_location = 'flags/sheesh.txt'
history_location = 'history/sheesh.txt'
_CONTAINER = "sheesh_container"
_CONTAINER = os.environ.get("CHALLENGE_CONTAINER_SHEESH", "sheesh_container")
_SERVICE_CMD = ["docker", "exec", "-i", _CONTAINER, "python3", "/home/ctfuser/chall/src/chall.py"]
_HEX_RE = re.compile(r'^[0-9a-fA-F]+$')
+2 -1
View File
@@ -1,5 +1,6 @@
from .Challenge import Challenge
import os
import io
import requests
import random
@@ -30,7 +31,7 @@ class Warmup(Challenge):
with open(self.flag_location, 'r') as f:
host_flag = f.read().strip()
container_flag = subprocess.run([
"docker", "exec", "warmup_container", "cat", "/flag.txt"
"docker", "exec", os.environ.get("CHALLENGE_CONTAINER_WARMUP", "warmup_container"), "cat", "/flag.txt"
], capture_output=True, text=True).stdout.strip()
assert host_flag == container_flag, 'Flag mismatch between host and container'
+57 -154
View File
@@ -1,156 +1,59 @@
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from config import get_settings
from challenges.Blogpost import Blogpost
from challenges.Carbeat import Carbeat
from challenges.CDN import CDN
from challenges.Phew import Phew
from challenges.Sheesh import Sheesh
from challenges.Warmup import Warmup
import os
import asyncio
import logging
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
security = HTTPBasic()
settings = get_settings()
challenges = {
"blogpost": Blogpost(10000),
"carbeat": Carbeat(11000),
"cdn": CDN(12000),
"phew": Phew(13000),
"sheesh": Sheesh(14000),
"warmup": Warmup(15000),
}
async def run_challenge_checks():
"""Run check function on all challenges at startup"""
logger.info("\n" + "="*60)
logger.info("Running challenge checks...")
logger.info("="*60 + "\n")
results = {}
for name, challenge in challenges.items():
logger.info(f"\n[{name}] Starting check...")
1|from fastapi import Depends, FastAPI, HTTPException
2|from pydantic import BaseModel
3|from fastapi.security import HTTPBasic, HTTPBasicCredentials
4|from config import get_settings
5|
6|from challenges.Blogpost import Blogpost
7|from challenges.Carbeat import Carbeat
8|from challenges.CDN import CDN
9|from challenges.Phew import Phew
10|from challenges.Sheesh import Sheesh
11|from challenges.Warmup import Warmup
12|
13|import os
14|import asyncio
15|import logging
16|
17|# Setup logging
18|logging.basicConfig(level=logging.INFO)
19|logger = logging.getLogger(__name__)
20|
21|app = FastAPI()
22|security = HTTPBasic()
23|settings = get_settings()
24|
25|def _ch_port(name: str, default: int) -> int:
# read from .env manually (pydantic settings has fixed fields)
val = os.environ.get(f"CHALLENGE_PORT_{name.upper()}")
if not val:
try:
# Give service time between checks
await asyncio.sleep(2)
result = challenge.check()
results[name] = result
if result:
logger.info(f"[{name}] ✓ Check PASSED")
else:
logger.warning(f"[{name}] ✗ Check FAILED")
except Exception as e:
logger.error(f"[{name}] ✗ Check ERROR: {e}")
results[name] = False
# Print summary
logger.info("\n" + "="*60)
logger.info("Challenge Check Summary:")
logger.info("="*60)
passed = sum(1 for r in results.values() if r)
total = len(results)
for name, result in results.items():
status = "✓ PASS" if result else "✗ FAIL"
logger.info(f" {name:20} {status}")
logger.info(f"\nTotal: {passed}/{total} passed")
logger.info("="*60 + "\n")
return results
with open(os.path.join(os.path.dirname(__file__), ".env")) as f:
for line in f:
if line.startswith(f"CHALLENGE_PORT_{name.upper()}="):
val = line.strip().split("=", 1)[1]
except Exception:
pass
return int(val) if val else default
@app.on_event("startup")
async def startup_event():
"""Run challenge checks on application startup"""
asyncio.create_task(run_challenge_checks())
class Flag(BaseModel):
flag: str
challenge: str
class History(BaseModel):
log: str
@app.get("/")
def read_root():
return {"service": "receiver-service"}
@app.get("/restart/{challenge}")
def restart(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
os.system(f"docker compose -f {settings.COMPOSE_LOCATION} restart {challenge}")
return {"message": "Challenge restarted"}
@app.get("/rollback/{challenge}")
def rollback(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
os.system(f"docker compose -f {settings.COMPOSE_LOCATION} up -d --force-recreate {challenge}")
return {"message": "Challenge restarted"}
@app.get("/activate/{challenge}")
def activate(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
os.system(f"docker compose -f {settings.COMPOSE_LOCATION} up -d {challenge}")
return {"message": "Challenge activated"}
@app.get("/deactivate/{challenge}")
def deactive(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
os.system(f"docker compose -f {settings.COMPOSE_LOCATION} down {challenge}")
return {"message": "Challenge deactivated"}
@app.get("/credential/{challenge}")
def credential(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
return challenges[challenge].credentials()
@app.post("/flag")
def receive(data: Flag, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, data.challenge)
challenge = challenges[data.challenge]
if challenge.distribute(data.flag):
return {"message": "Flag received"}
raise HTTPException(status_code=500, detail="Error receiving flag")
@app.get("/check/{challenge}")
def check(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
return {"success": challenges[challenge].check()}
@app.post("/history")
def history(data: History):
with open('history/command.txt', 'a') as f:
f.write(data.log + '\n')
return {"message": "Command received"}
def is_admin(credentials):
if credentials.username != settings.ADMIN_USERNAME or credentials.password != settings.ADMIN_PASSWORD:
return False
return True
def validate(credentials, challenge):
if not is_admin(credentials):
raise HTTPException(status_code=401, detail="Invalid credentials")
if challenge not in challenges:
raise HTTPException(status_code=400, detail="Invalid challenge")
def _ch_container(name: str, default: str) -> str:
val = os.environ.get(f"CHALLENGE_CONTAINER_{name.upper()}")
if not val:
try:
with open(os.path.join(os.path.dirname(__file__), ".env")) as f:
for line in f:
if line.startswith(f"CHALLENGE_CONTAINER_{name.upper()}="):
val = line.strip().split("=", 1)[1]
except Exception:
pass
return val or default
challenges = {
32| "blogpost": Blogpost(_ch_port("blogpost", 10000)),
33| "carbeat": Carbeat(_ch_port("carbeat", 11000)),
34| "cdn": CDN(_ch_port("cdn", 12000)),
35| "phew": Phew(_ch_port("phew", 13000)),
36| "sheesh": Sheesh(_ch_port("sheesh", 14000)),
37| "warmup": Warmup(_ch_port("warmup", 15000)),
38|}
39|
40|async def run_challenge_checks():
41|