#!/usr/bin/env python3 """ Gemastik A/D multi-team orchestrator. Each team gets an isolated stack: its own docker-compose (unique ports + SSH passwords), its own receiver (unique admin creds + ports), and its own flags. The orchestrator clones the base services dir, rewrites ports and passwords, and manages lifecycle via docker compose project per team. Team N layout: /opt/gemastik18-final/teams/team/ services/ (docker-compose.yml with team ports + passwords) receiver/ (.env with team admin creds + container overrides) receiver/flags/ (per-team flag files) state.json (team metadata: ports, admin user/pass, ssh creds, created ts) Port scheme (base offset per team index, index 1-based): team i: chall ports = 20000 + i*1000 + 0..5 (blogpost,carbeat,cdn,phew,sheesh,warmup) ssh ports = 20000 + i*1000 + 22..27 (10022-style) receiver = 20000 + i*1000 + 80 (receiver API, e.g. 21080, 22080) """ import json import os import re import secrets import shutil import subprocess import time import uuid from datetime import datetime from pathlib import Path BASE = Path("/opt/gemastik18-final") TEAMS_DIR = BASE / "teams" SERVICES_SRC = BASE / "services" RECEIVER_SRC = BASE / "receiver" # Challenge definitions: (service name, chall port offset 0-5, ssh offset 22-27) CHALLENGES = [ ("blogpost", 0, 22), ("carbeat", 1, 23), ("cdn", 2, 24), ("phew", 3, 25), ("sheesh", 4, 26), ("warmup", 5, 27), ] PORT_BASE = 20000 STEP = 1000 def team_ports(idx: int) -> dict: """Return {service_name: {'chall': port, 'ssh': port}} for 1-based team idx.""" base = PORT_BASE + idx * STEP out = {} for name, coff, soff in CHALLENGES: out[name] = {"chall": base + coff, "ssh": base + soff} out["receiver"] = base + 80 out["panel"] = base + 81 # reserved, not used return out def gen_password(n=16): return uuid.uuid4().hex[:n] def create_team(idx: int, label: str = None): """Build a full team stack dir with unique ports/passwords.""" ports = team_ports(idx) team_dir = TEAMS_DIR / f"team{idx}" label = label or f"Tim {idx}" state = { "index": idx, "label": label, "ports": ports, "admin_user": f"admin_team{idx}", "admin_pass": gen_password(20), "ssh_user": "ctfuser", "ssh_pass": gen_password(20), "chall_passwords": {name: gen_password(20) for name, *_ in CHALLENGES}, "container_suffix": f"team{idx}", "created": __import__("datetime").datetime.now().isoformat(), "status": "created", } # --- copy services with rewrite --- svc_dir = team_dir / "services" if svc_dir.exists(): shutil.rmtree(svc_dir) shutil.copytree(SERVICES_SRC, svc_dir, ignore=shutil.ignore_patterns("__pycache__", ".git", "exploits", "exploit")) # compose references ../utils/bashrc etc — copy utils next to services utils_src = BASE / "utils" utils_dst = team_dir / "utils" if utils_src.exists(): if utils_dst.exists(): shutil.rmtree(utils_dst) shutil.copytree(utils_src, utils_dst) # redirect preexec URL to the team's receiver port recv_port = ports["receiver"] bashrc = utils_dst / "bashrc" if bashrc.exists(): bashrc.write_text(bashrc.read_text().replace( "host.docker.internal:18080", f"host.docker.internal:{recv_port}")) compose = svc_dir / "docker-compose.yml" text = compose.read_text() # rewrite container names + ports per challenge for name, coff, soff in CHALLENGES: cont_old = f"{name}_container" cont_new = f"{name}_container_team{idx}" text = text.replace(f"container_name: {cont_old}", f"container_name: {cont_new}") text = text.replace(f"hostname: {name}", f"hostname: {name}_team{idx}") # ports mapping: "10000:8000" -> ":8000" old_chall = str(10000 + coff * 1000) # 10000,11000,12000,13000,14000,15000 old_ssh = str(10022 + coff * 1000) # 10022,11022,... text = re.sub(rf'"({old_chall}):', f'"{ports[name]["chall"]}:', text) text = re.sub(rf'"({old_ssh}):', f'"{ports[name]["ssh"]}:', text) # PASSWORD_* args -> team passwords for name, coff, soff in CHALLENGES: old_env = f"PASSWORD_{10000 + coff * 1000}" text = re.sub(rf"\${{{old_env}}}", state["chall_passwords"][name], text) # compose file references services/.env — we'll create it below compose.write_text(text) # team services/.env (PASSWORD_* in same shape as starter.py) env_lines = [f"ADMIN_USERNAME={state['admin_user']}", f"ADMIN_PASSWORD={state['admin_pass']}"] env_lines.append(f"COMPOSE_LOCATION={svc_dir}/docker-compose.yml") for i in range(20): env_lines.append(f"PASSWORD_{(i*1000)+10000}={gen_password(20)}") # force the six used passwords to team ones for name, coff, soff in CHALLENGES: for j, line in enumerate(env_lines): if line.startswith(f"PASSWORD_{10000+coff*1000}="): env_lines[j] = f"PASSWORD_{10000+coff*1000}={state['chall_passwords'][name]}" (svc_dir / ".env").write_text("\n".join(env_lines) + "\n") # --- copy receiver with team env --- recv_dir = team_dir / "receiver" if recv_dir.exists(): shutil.rmtree(recv_dir) shutil.copytree(RECEIVER_SRC, recv_dir, ignore=shutil.ignore_patterns("__pycache__", ".venv", ".env", "history")) (recv_dir / "history").mkdir(exist_ok=True) # receiver .env: same admin + passwords + container overrides recv_env = [f"ADMIN_USERNAME={state['admin_user']}", f"ADMIN_PASSWORD={state['admin_pass']}", f"COMPOSE_LOCATION={svc_dir}/docker-compose.yml"] for i in range(20): recv_env.append(f"PASSWORD_{(i*1000)+10000}={gen_password(20)}") for name, coff, soff in CHALLENGES: for j, line in enumerate(recv_env): if line.startswith(f"PASSWORD_{10000+coff*1000}="): recv_env[j] = f"PASSWORD_{10000+coff*1000}={state['chall_passwords'][name]}" recv_env.append(f"CHALLENGE_PORT_{name.upper()}={ports[name]['chall']}") recv_env.append(f"CHALLENGE_CONTAINER_{name.upper()}={name}_container_team{idx}") (recv_dir / ".env").write_text("\n".join(recv_env) + "\n") # --- flags (randomized per team so each team has unique flags) --- flags_dir = team_dir / "receiver" / "flags" flags_dir.mkdir(parents=True, exist_ok=True) flags_map = {} for name, coff, soff in CHALLENGES: flag = f"GEMASTIK18{{TEAM{idx}_{name.upper()}_{secrets.token_hex(6)}}}" (flags_dir / f"{name}.txt").write_text(flag) flags_map[name] = flag state["flags"] = flags_map (team_dir / "state.json").write_text(json.dumps(state, indent=2)) return state def start_team(idx: int): team_dir = TEAMS_DIR / f"team{idx}" if not (team_dir / "state.json").exists(): raise FileNotFoundError(f"Team {idx} not created") svc_dir = team_dir / "services" subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml", "up", "-d", "--build"], cwd=str(svc_dir), check=False, capture_output=True) _start_receiver(idx) st = json.loads((team_dir / "state.json").read_text()) st["status"] = "running" (team_dir / "state.json").write_text(json.dumps(st, indent=2)) return st def stop_team(idx: int): team_dir = TEAMS_DIR / f"team{idx}" svc_dir = team_dir / "services" subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml", "down"], cwd=str(svc_dir), check=False, capture_output=True) _stop_receiver(idx) st = json.loads((team_dir / "state.json").read_text()) st["status"] = "stopped" (team_dir / "state.json").write_text(json.dumps(st, indent=2)) return st def _start_receiver(idx: int): """Launch team's receiver as a detached uvicorn process with team env.""" team_dir = TEAMS_DIR / f"team{idx}" recv_dir = team_dir / "receiver" st = json.loads((team_dir / "state.json").read_text()) port = st["ports"]["receiver"] pidfile = team_dir / "receiver.pid" # kill existing _stop_receiver(idx) env = dict(os.environ) env["PYTHONPATH"] = str(recv_dir) env["COMPOSE_LOCATION"] = str(team_dir / "services" / "docker-compose.yml") # expose team ports/containers so challenge classes bind the right targets for ch in st["ports"]: if ch in ("receiver", "panel"): continue env[f"CHALLENGE_PORT_{ch.upper()}"] = str(st["ports"][ch]["chall"]) env[f"CHALLENGE_CONTAINER_{ch.upper()}"] = f"{ch}_container_team{idx}" proc = subprocess.Popen( [str(RECEIVER_SRC.parent / "receiver" / ".venv" / "bin" / "python"), "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", str(port)], cwd=str(recv_dir), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) pidfile.write_text(str(proc.pid)) def _stop_receiver(idx: int): team_dir = TEAMS_DIR / f"team{idx}" pidfile = team_dir / "receiver.pid" if pidfile.exists(): try: pid = int(pidfile.read_text().strip()) os.kill(pid, 15) except Exception: pass pidfile.unlink(missing_ok=True) def team_logs(idx: int, service: str = None, tail: int = 100): team_dir = TEAMS_DIR / f"team{idx}" svc_dir = team_dir / "services" data = {} services = [s for s, *_ in CHALLENGES] if service is None else [service] for s in services: try: out = subprocess.run( ["docker", "logs", "--tail", str(tail), f"{s}_container_team{idx}"], capture_output=True, text=True, timeout=15) data[s] = (out.stdout + out.stderr)[-4000:] except Exception as e: data[s] = f"ERR: {e}" return data def list_teams() -> list: out = [] if not TEAMS_DIR.exists(): return out for d in sorted(TEAMS_DIR.glob("team*")): if (d / "state.json").exists(): st = json.loads((d / "state.json").read_text()) # compute alive status quickly st["alive_count"] = 0 st["up_count"] = 0 out.append(st) return out # --------------------------------------------------------------------------- # Flag randomization + team submission tracking # --------------------------------------------------------------------------- def randomize_flags(idx: int) -> dict: """Generate fresh unique flags for every challenge of a team.""" team_dir = TEAMS_DIR / f"team{idx}" if not (team_dir / "state.json").exists(): raise FileNotFoundError(f"Team {idx} not created") flags_dir = team_dir / "receiver" / "flags" flags_dir.mkdir(parents=True, exist_ok=True) mapping = {} for name, coff, soff in CHALLENGES: token = secrets.token_hex(6) flag = f"GEMASTIK18{{TEAM{idx}_{name.upper()}_{token}}}" (flags_dir / f"{name}.txt").write_text(flag) mapping[name] = flag # rotate into containers: compose mounts the flag files read-only, so # drop+recreate the challenge containers to pick up new flags svc_dir = team_dir / "services" subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml", "down"], cwd=str(svc_dir), check=False, capture_output=True) subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml", "up", "-d"], cwd=str(svc_dir), check=False, capture_output=True) _start_receiver(idx) st = json.loads((team_dir / "state.json").read_text()) st["flags"] = mapping (team_dir / "state.json").write_text(json.dumps(st, indent=2)) return mapping def submit_flag(team_idx: int, chall: str, flag: str, team_name: str = "") -> dict: """Validate a submitted flag against the owning team's challenge flag.""" team_dir = TEAMS_DIR / f"team{team_idx}" if not (team_dir / "state.json").exists(): return {"success": False, "error": "unknown team"} flags_dir = team_dir / "receiver" / "flags" expected = (flags_dir / f"{chall}.txt").read_text().strip() if (flags_dir / f"{chall}.txt").exists() else None if not expected: return {"success": False, "error": "challenge not found"} if flag.strip() != expected: return {"success": False, "error": "wrong flag"} # record leaderboard entry lb_path = TEAMS_DIR / "leaderboard.json" lb = json.loads(lb_path.read_text()) if lb_path.exists() else {"solves": []} entry = { "team": team_idx, "team_name": team_name or f"Team {team_idx}", "challenge": chall, "flag": flag, "ts": time.time(), "ts_human": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } # avoid double-solve duplicates (same flag + same team) if not any(e["team"] == team_idx and e["challenge"] == chall for e in lb["solves"]): lb["solves"].append(entry) lb_path.write_text(json.dumps(lb, indent=2)) return {"success": True, "team": team_idx, "challenge": chall} if __name__ == "__main__": import sys cmd = sys.argv[1] if len(sys.argv) > 1 else "list" if cmd == "create" and len(sys.argv) > 2: st = create_team(int(sys.argv[2])) print(json.dumps(st, indent=2)) elif cmd == "list": print(json.dumps(list_teams(), indent=2)) elif cmd == "start" and len(sys.argv) > 2: print(json.dumps(start_team(int(sys.argv[2])), indent=2)) elif cmd == "stop" and len(sys.argv) > 2: print(json.dumps(stop_team(int(sys.argv[2])), indent=2))