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:
1 parent
c35b23a37f
commit
c6fd9ec268
1317 files changed
+306586
-128
No files matched your search
+151
-58
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user