feat: challenge registry-driven platform + XVI/XVII imports + admin toggle + domain rename

- Rename repo/domain: attack-defense-platform / attackdefense.imrnes.team (all refs replaced)
- challenge_registry.json: single source of truth (28 challs across gemastik18/xvi/xvii)
- teams.py: registry-driven CHALLENGES, set_challenge_enabled, sync_challenge_runtime
  (apply enable/disable to live teams: build/up or stop/remove + receiver restart)
- compose_gen.py: render per-team compose from canonical per-challenge templates
  (image reuse, per-team ports 30xxx, flag mounts, passwords)
- gen_canonical_composes.py: canonical docker-compose.yml for all services
- import_new_challenges.py: import XVI/XVII services + EOL base image fixes
  (debian:buster→bookworm, node:14→20, python:3.7-slim→3.11)
- receiver: xvi package (10 checkers) + xvii package (12 generic checkers),
  Challenge base reads PASSWORD_<team_port> from env; gen_receiver_main.py
  generates per-team main.py from registry
- main.py: /api/challenges returns full registry; PATCH /api/challenges/<name>
  toggles enabled + applies to live teams
- index.html: 🏗️ Challenge Manager tab (toggle per challenge, grouped by set)
- SLA bonus now dynamic (all enabled challenges, not hardcoded 6)
This commit is contained in:
MythEclipse committed 2026-09-25 14:04:33 +08:00
1 parent c35b23a37f
commit c6fd9ec268
1317 files changed
+306586 -128

No files matched your search

+151 -58
View File
@@ -36,15 +36,133 @@ 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),
]
# ---------------------------------------------------------------------------
# Challenge registry — single source of truth across all distributed sets.
# Loaded from teams/challenge_registry.json (admin can toggle enabled).
#
# CHALLENGES below is a DERIVED list: (name, chall_offset, ssh_offset) for
# every ENABLED challenge, in registry order. All team logic uses this.
# ---------------------------------------------------------------------------
_REGISTRY_PATH = TEAMS_DIR / "challenge_registry.json"
def _default_registry() -> dict:
return {"sets": {}, "challenges": []}
def load_registry() -> dict:
try:
return json.loads(_REGISTRY_PATH.read_text())
except Exception:
return _default_registry()
def save_registry(reg: dict):
_REGISTRY_PATH.write_text(json.dumps(reg, indent=2))
def registry_challenges() -> list:
"""All challenge dicts from the registry (enabled or not), in order."""
return load_registry().get("challenges", [])
def enabled_challenges() -> list:
return [c for c in registry_challenges() if c.get("enabled")]
def set_challenge_enabled(name: str, enabled: bool) -> dict:
"""Flip a challenge's enabled flag in the registry (global toggle)."""
reg = load_registry()
for c in reg.get("challenges", []):
if c["name"] == name:
if bool(c.get("enabled")) == bool(enabled):
return {"unchanged": True, **c}
c["enabled"] = bool(enabled)
save_registry(reg)
return c
raise KeyError(f"Challenge {name} tidak ada di registry")
def sync_challenge_runtime(name: str, enabled: bool) -> dict:
"""Apply an enable/disable toggle to every RUNNING team:
- regenerate that team's compose from the registry (compose_gen)
- for ENABLE: docker compose up -d <name> (builds image first if needed)
- for DISABLE: docker compose rm -sf <name> (stop+remove the container)
- restart the team receiver so its challenge dict matches (main.py)
Returns a per-team report.
"""
import compose_gen
reg = load_registry()
ch = next((c for c in reg.get("challenges", []) if c["name"] == name), None)
if not ch:
raise KeyError(f"Challenge {name} tidak ada di registry")
# rebuild the derived CHALLENGES for fresh creates
global CHALLENGES
CHALLENGES = [(c["name"], c["chall_offset"], c["ssh_offset"]) for c in enabled_challenges()]
report = {"challenge": name, "enabled": bool(enabled), "teams": []}
for d in sorted(TEAMS_DIR.glob("team*")):
sf = d / "state.json"
if not sf.exists():
continue
st = json.loads(sf.read_text())
idx = st["index"]
svc_dir = d / "services"
try:
if enabled:
# fresh flag file for this challenge
flags_dir = d / "receiver" / "flags"
flags_dir.mkdir(parents=True, exist_ok=True)
flag = f"GEMASTIK18{{TEAM{idx}_{name.upper()}_{secrets.token_hex(6)}}}"
(flags_dir / f"{name}.txt").write_text(flag)
st.setdefault("flags", {})[name] = flag
st["ports"][name] = {"chall": 30000 + idx*1000 + ch["chall_offset"],
"ssh": 30000 + idx*1000 + ch["ssh_offset"]}
st.setdefault("chall_passwords", {})[name] = st["chall_passwords"].get(
name) or f"chall{idx}_{name}_{secrets.token_hex(4)}"
# write state BEFORE rendering (render needs ports[name])
(sf).write_text(json.dumps(st, indent=2))
# regenerate whole compose (so enabled challenge included),
# then bring up just this service
new_text = compose_gen.render_team_compose(idx, st)
(svc_dir / "docker-compose.yml").write_text(new_text)
# inject the team-specific password env if compose uses ${PASSWORD_*}
envp = svc_dir / ".env"
if not envp.exists():
env_lines = [f"ADMIN_USERNAME={st['admin_user']}", f"ADMIN_PASSWORD={st['admin_pass']}",
f"COMPOSE_LOCATION={svc_dir}/docker-compose.yml"]
for i in range(20):
env_lines.append(f"PASSWORD_{(i*1000)+10000}=placeholder")
(envp).write_text("\n".join(env_lines) + "\n")
r = subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml",
"up", "-d", "--build", name],
cwd=str(svc_dir), capture_output=True, text=True, timeout=1800)
ok = r.returncode == 0
report["teams"].append({"team": idx, "ok": ok, "detail": (r.stdout or r.stderr)[-300:]})
if ok:
# set the per-team SSH password after container boot
try:
pw = st["chall_passwords"][name]
subprocess.run(["docker", "exec", f"{name}_container_team{idx}", "sh", "-c",
f"echo 'ctfuser:{pw}' | chpasswd"], capture_output=True, timeout=60)
except Exception:
pass
else:
# stop + remove the container FIRST (old compose), then regenerate
r = subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml",
"rm", "-sf", name],
cwd=str(svc_dir), capture_output=True, text=True, timeout=120)
# remove from state ports/flags
st["ports"].pop(name, None)
st.setdefault("flags", {}).pop(name, None)
(sf).write_text(json.dumps(st, indent=2))
# regenerate compose WITHOUT this challenge
new_text = compose_gen.render_team_compose(idx, st)
(svc_dir / "docker-compose.yml").write_text(new_text)
report["teams"].append({"team": idx, "ok": True,
"detail": (r.stdout or r.stderr)[-300:] or "removed"})
# restart receiver so its challenge set matches
_start_receiver(idx)
except Exception as e:
report["teams"].append({"team": idx, "ok": False, "detail": str(e)[-300:]})
return report
# Derived list used everywhere (keeps old API: tuples of name, coff, soff)
CHALLENGES = [(c["name"], c["chall_offset"], c["ssh_offset"]) for c in enabled_challenges()]
# Points system: base points earned by stealing a flag from another team's
# challenge. The SLA bonus is earned by keeping your OWN services alive.
@@ -96,7 +214,8 @@ def add_sla_bonus(team_idx: int, alive: int, total: int = 6) -> dict:
me = data["teams"].setdefault(tid, {"points": 0, "events": []})
now = time.time()
last = me.get("last_sla_bonus", 0)
if alive >= SLA_BONUS_MIN_ALIVE and total >= SLA_BONUS_MIN_ALIVE:
min_alive = max(1, len(enabled_challenges()))
if alive >= min_alive and total >= min_alive:
if now - last > 300: # 5 min window
me["points"] = int(me.get("points", 0)) + SLA_BONUS_POINTS
me["last_sla_bonus"] = now
@@ -155,18 +274,18 @@ def create_team(idx: int, label: str = None, domain: str = None):
label -> team display name (leaderboard/topology)
domain -> custom subdomain host, e.g. "cyber-warriors" or
"cyber-warriors.gemastik.imrnes.team" (full host accepted).
Defaults to slugify(label).gemastik.imrnes.team.
"cyber-warriors.attackdefense.imrnes.team" (full host accepted).
Defaults to slugify(label).attackdefense.imrnes.team.
"""
ports = team_ports(idx)
team_dir = TEAMS_DIR / f"team{idx}"
label = label or f"Tim {idx}"
slug = slugify(label)
# normalize custom domain -> host under *.gemastik.imrnes.team
host = (domain or f"{slug}.gemastik.imrnes.team").strip().lower()
# normalize custom domain -> host under *.attackdefense.imrnes.team
host = (domain or f"{slug}.attackdefense.imrnes.team").strip().lower()
host = host.replace("https://", "").replace("http://", "").rstrip("/")
if not host.endswith(".gemastik.imrnes.team"):
host = f"{host}.gemastik.imrnes.team"
if not host.endswith(".attackdefense.imrnes.team"):
host = f"{host}.attackdefense.imrnes.team"
slug = host.split(".")[0]
state = {
"index": idx,
@@ -188,8 +307,8 @@ def create_team(idx: int, label: str = None, domain: str = None):
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
svc_dir.mkdir(parents=True, exist_ok=True)
# copy utils next to services (compose references ../utils/bashrc)
utils_src = BASE / "utils"
utils_dst = team_dir / "utils"
if utils_src.exists():
@@ -202,45 +321,19 @@ def create_team(idx: int, label: str = None, domain: str = None):
if bashrc.exists():
bashrc.write_text(bashrc.read_text().replace(
"host.docker.internal:18080", f"host.docker.internal:{recv_port}"))
# generate compose from the challenge registry (compose_gen renders the
# per-team file: image: services-<name>, team ports, team passwords)
import compose_gen
compose_text = compose_gen.render_team_compose(idx, state)
compose = svc_dir / "docker-compose.yml"
text = compose.read_text()
# --- replace build: blocks with image: so teams reuse the base images (no rebuild) ---
# Each service's build block looks like:
# build:
# context: <name>
# args:
# - PASSWORD=$PASSWORD_XXXXX
# Replace the whole block with " image: services-<name>".
for name, coff, soff in CHALLENGES:
text = re.sub(
rf" build:\n context: {name}\n args:\n - PASSWORD=\$PASSWORD_[0-9]+\n",
f" image: services-{name}\n",
text)
compose.write_text(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" -> "<team_chall>: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)
compose.write_text(compose_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
# force the 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}="):
@@ -294,8 +387,8 @@ def update_team(idx: int, label: str = None, domain: str = None) -> dict:
st["label"] = str(label).strip()[:48] or st["label"]
if domain:
host = domain.strip().lower().replace("https://", "").replace("http://", "").rstrip("/")
if not host.endswith(".gemastik.imrnes.team"):
host = f"{host}.gemastik.imrnes.team"
if not host.endswith(".attackdefense.imrnes.team"):
host = f"{host}.attackdefense.imrnes.team"
st["domain"] = host
st["slug"] = host.split(".")[0]
(team_dir / "state.json").write_text(json.dumps(st, indent=2))
@@ -399,7 +492,7 @@ def team_logs(idx: int, service: str = None, tail: int = 100):
def ensure_team_domains() -> str:
"""Write Traefik dynamic config for each team domain -> panel (:18081).
Each team gets <slug>.gemastik.imrnes.team. The panel routes
Each team gets <slug>.attackdefense.imrnes.team. The panel routes
/team/<idx> to that team's portal page (public, no panitia login),
and /api/team/<idx>/* serves team-scoped API. Writing this into the
same dynamic dir Traefik watches means domains appear automatically.
@@ -419,10 +512,10 @@ def ensure_team_domains() -> str:
td = TEAMS_DIR / f"team{t['index']}"
st = json.loads((td / "state.json").read_text())
st["slug"] = slug
st["domain"] = f"{slug}.gemastik.imrnes.team"
st["domain"] = f"{slug}.attackdefense.imrnes.team"
(td / "state.json").write_text(json.dumps(st, indent=2))
changed = True
host = f"{slug}.gemastik.imrnes.team"
host = f"{slug}.attackdefense.imrnes.team"
out.append(f""" team-{slug}-http:
rule: Host(`{host}`)
entryPoints:
@@ -442,9 +535,9 @@ def ensure_team_domains() -> str:
""")
if not out:
return "no teams to route"
# Keep the team domain block in its own file so the main gemastik.yaml stays untouched
(traefik_dir / "gemastik-teams.yaml").write_text("http:\n routers:\n" + "".join(out))
return f"wrote {len(out)} team domain(s) in gemastik-teams.yaml"
# Keep the team domain block in its own file so the main attackdefense.yaml stays untouched
(traefik_dir / "attackdefense-teams.yaml").write_text("http:\n routers:\n" + "".join(out))
return f"wrote {len(out)} team domain(s) in attackdefense-teams.yaml"
def list_teams() -> list: