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
2026-09-25 14:04:33 +08:00
parent c35b23a37f
commit c6fd9ec268
1317 changed files with 306586 additions and 128 deletions
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""
compose_gen — render a team's docker-compose.yml from the challenge registry.
For every ENABLED challenge, a canonical per-challenge compose template lives
at services/<name>/docker-compose.yml (see gen_canonical_composes.py). The
renderer:
- replaces `build:` blocks with `image: services-<name>` for the MAIN
service (sidecars keep their images/builds),
- rewrites container_name / hostname to the per-team suffix,
- rewrites host ports (<ORG>:<INT>, <ORG+22>:22) to the team's ports,
- replaces PASSWORD_<ORG> placeholders with the team's challenge password,
- normalizes flag volume to ../receiver/flags/<name>.txt.
Multi-container challenges (gemas-notes, gemas-fetcher, kode-viewer,
anti-alchemy, tempest-poc) keep their sidecar services.
"""
import json
import re
from pathlib import Path
BASE = Path("/opt/gemastik18-final")
TEAMS_DIR = BASE / "teams"
SERVICES_SRC = BASE / "services"
# Challenges whose compose has multiple top-level services; the FIRST service
# listed is the MAIN challenge service (gets image: reuse + challenge ports),
# the rest are sidecars.
SIDECAR_NAMES = {
"anti-alchemy": ["anti-alchemy-db"],
"gemas-fetcher": ["mongodb"],
"gemas-notes": ["database", "validation-service"],
"kode-viewer": ["redis"],
"tempest-poc": ["backend"],
}
def _load_registry() -> dict:
try:
return json.loads((TEAMS_DIR / "challenge_registry.json").read_text())
except Exception:
return {"sets": {}, "challenges": []}
def read_template(name: str) -> str:
p = SERVICES_SRC / name / "docker-compose.yml"
if not p.exists():
raise FileNotFoundError(f"Tidak ada template compose untuk {name} di {p}")
return p.read_text()
def _parse_services(text: str):
names = []
for line in text.splitlines():
m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)
if m and not line.startswith(" "):
names.append(m.group(1))
return names
def render_team_compose(idx: int, state: dict) -> str:
ports = state["ports"]
passwords = state["chall_passwords"]
blocks = []
for ch in enabled_challenges():
name = ch["name"]
text = read_template(name)
main = _parse_services(text)
main = main[0] if main else name
sidecars = set(SIDECAR_NAMES.get(name, []))
org = int(ch.get("org_port", 10000))
tc = ports[name]["chall"]
ts = ports[name]["ssh"]
# --- rewrite container_name / hostname per team ---
out_lines = []
for line in text.splitlines():
s = line.strip()
if s.startswith("container_name:"):
cname = s.split(":", 1)[1].strip()
line = f" container_name: {cname}_team{idx}"
elif s.startswith("hostname:"):
hname = s.split(":", 1)[1].strip()
if hname == name:
line = f" hostname: {name}_team{idx}"
else:
# sidecar hostname also suffixed to keep per-team network unique
line = f" hostname: {hname}_team{idx}"
out_lines.append(line)
text = "\n".join(out_lines)
# --- ports: rewrite ONLY the main challenge service's ports ---
# The main service is the one whose ports map to org/org+22.
# (sidecar "ports_chall" cases: gemas-notes validation-service exposes
# 12000:80 — handled by rewriting ANY "<org>:" / "<org+22>:" occurrence.)
text = re.sub(rf'"({org}):', f'"{tc}:', text)
text = re.sub(rf'"({org + 22}):', f'"{ts}:', text)
# --- build: -> image for MAIN only ---
# Replace the main service's build block with image: services-<name>.
# NB we process by service names, not generic removal, so sidecar
# builds survive.
lines = text.splitlines()
i = 0
in_main = False
cur = None
out = []
while i < len(lines):
line = lines[i]
m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)
if m and not line.startswith(" "):
cur = m.group(1)
in_main = (cur == main)
out.append(line)
i += 1
continue
# inside a service block
if in_main and line.strip() == "build:":
# skip build block (context/args/dockerfile...) until next key at same indent
out.append(f" image: services-{name}")
i += 1
while i < len(lines) and (lines[i].startswith(" ") or lines[i].strip() == ""):
i += 1
continue
out.append(line)
i += 1
text = "\n".join(out)
# --- PASSWORD placeholder -> team password ---
text = re.sub(r"\$PASSWORD_" + str(org) + r"\b", passwords[name], text)
text = re.sub(r"PASSWORD_" + str(org) + r"\b", passwords[name], text)
# --- flag volume normalization ---
# Replace any ./flag.txt / ../receiver/flags/<name>.txt with the per-team flag mount
text = re.sub(r"\./flag\.txt(:\w+)?", f"../receiver/flags/{name}.txt", text)
blocks.append(text)
header = "version: '3.8'\nservices:\n"
body = []
for b in blocks:
if not b.strip():
continue
# strip a leading "services:" header from each block (they are fragments)
b = re.sub(r"^services:\n", "", b)
body.append(b)
return header + "\n".join(body) + "\n"
def enabled_challenges() -> list:
return [c for c in _load_registry().get("challenges", []) if c.get("enabled")]
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""Generate canonical docker-compose.yml templates under services/<name>/ for
every challenge in the registry (gemastik18 + imported XVI/XVII).
The generated per-challenge file is the SOURCE for compose_gen — i.e. the
per-team compose is rendered from these templates. Uses:
services/<name>/docker-compose.yml (canonical, uniform)
If a template already exists for gemastik18 challenges (from the shared
compose), keep it. For imported challenges, build one from the registry.
"""
import json
import re
from pathlib import Path
BASE = Path("/opt/gemastik18-final")
SVC = BASE / "services"
TEAMS_DIR = BASE / "teams"
def load_registry():
return json.loads((TEAMS_DIR / "challenge_registry.json").read_text())
def internal_port_for(ch: dict) -> int:
"""Best-effort: the container's internal listening port."""
name = ch["name"]
known = {
# web-ish default 8000, others from upstream compose
"art": 8080, "xl": 3000, "gemas-notes": 80, "pasta": 8000,
"burvesigner": 80, "hirnfick": 8000, "gemas-fetcher": 8000,
"s3": 80, "crawlback": 80, "back-to-basic": 8000,
"anti-alchemy": 5000, "asmr": 8000, "bit-canvas": 8000,
"fjb": 80, "gift-card": 5000, "gift-voucher": 5000,
"gleam-drive": 8000, "go-green": 8000, "kode-viewer": 3000,
"more-less": 8000, "tempest-poc": 80, "ticketer": 5000,
}
return known.get(name, 8000)
def sidecar_for(ch: dict) -> dict:
"""Return {sidecar_name: {image, env, volumes, cmd...}} or {}."""
name = ch["name"]
if name == "gemas-notes":
return {
"database": {
"image": "mysql:8",
"container_name": None, # per-team
"environment": ["MYSQL_ROOT_PASSWORD=why_my_random_string_password_doesnot_working",
"MYSQL_DATABASE=gemasnotes"],
"volumes": ["./gemas-notes/src/db/init.sql:/docker-entrypoint-initdb.d/init.sql"],
},
"validation-service": {
"build": "./gemas-notes/src/validation-service",
"container_name": None,
"depends_on": ["database"],
"ports_chall": 80, # challenge port exposed here (REST API)
},
}
if name == "gemas-fetcher":
return {
"mongodb": {
"image": "mongo:4.4",
"container_name": None,
"environment": ["MONGO_INITDB_ROOT_USERNAME=ctf",
"MONGO_INITDB_ROOT_PASSWORD=asjdkjk23j1k3dsdn2h233j3jj",
"MONGO_INITDB_DATABASE=web_fetcher"],
}
}
if name == "kode-viewer":
return {
"redis": {
"image": "redis:alpine",
"container_name": None,
"volumes": ["./redis-data:/data"],
}
}
if name == "anti-alchemy":
return {
"anti-alchemy-db": {
"image": "postgres:16.3-alpine",
"container_name": None,
"environment": ["POSTGRES_USER=postgres", "POSTGRES_PASSWORD=password"],
"volumes": ["./db/dump.sql:/docker-entrypoint-initdb.d/init.sql"],
}
}
if name == "tempest-poc":
return {
"backend": {
"build": "./tempest-poc/backend",
"container_name": None,
"ports_ssh": 22,
"extra_hosts": True,
},
"frontend": {
"build": "./tempest-poc/frontend",
"container_name": None,
"ports_chall": 80,
"extra_hosts": True,
}
}
return {}
def render(ch: dict) -> str:
name = ch["name"]
org = int(ch.get("org_port", 10000))
internal = internal_port_for(ch)
sidecars = sidecar_for(ch)
# For tempest-poc the main (first) service is frontend; compose_gen's
# SIDECAR_NAMES marks backend as sidecar. Order matters: put frontend
# before backend in the generated file so compose_gen picks frontend as main.
if name == "tempest-poc":
sidecars = {"frontend": sidecars.pop("frontend"), **sidecars}
lines = ["services:"]
# main service
cont = f"{name}_container"
lines += [
f" {name}:",
f" container_name: {cont}",
f" hostname: {name}",
" restart: always",
" build:",
" context: .",
" args:",
f" - PASSWORD=$PASSWORD_{org}",
]
# volumes: flag + bashrc + preexec (uniform unless challenge differs)
flag_path = f"../receiver/flags/{name}.txt:/flag.txt:ro"
# gift-card/gift-voucher mount to /ctf/<name>/flag.txt
if name in ("gift-card", "gift-voucher"):
flag_path = f"../receiver/flags/{name}.txt:/ctf/{name}/flag.txt:ro"
if name == "pasta":
flag_path = f"../receiver/flags/{name}.txt:/ctf/pasta/flag.txt:ro"
lines += [
" volumes:",
f" - {flag_path}",
" - ../utils/bashrc:/root/.bashrc:ro",
" - ../utils/preexec.sh:/root/.preexec.sh:ro",
]
# ports: external (org / org+22) but rewritten per team by composer
if name == "gemas-notes":
# main service = note-service (Go, ssh only). The challenge port is
# exposed by validation-service (the REST API the checker hits).
pass
elif name == "tempest-poc":
# main (frontend) exposes chall port; backend (sidecar) has ssh.
pass
else:
lines += [
" ports:",
f" - \"{org}:{internal}\"",
f" - \"{org + 22}:22\"",
]
lines.append(" extra_hosts:")
lines.append(' - "host.docker.internal:host-gateway"')
# env (challenge-specific)
if name == "carbeat":
lines.append(" environment:")
lines.append(" - FLAG=GEMASTIK18{local_flag}")
if name == "phew":
lines.append(" environment:")
lines.append(" - FLAG=GEMASTIK18{local_flag}")
if name == "sheesh":
lines.append(" environment:")
lines.append(" - FLAG=GEMASTIK18{local_flag}")
if name == "anti-alchemy":
lines.append(" environment:")
lines.append(" - DB_NAME=postgres")
lines.append(" - DB_USER=postgres")
lines.append(" - DB_PASS=password")
lines.append(" - DB_HOST=anti-alchemy-db")
lines.append(" - DB_PORT=5432")
lines.append(f" - SECRET_KEY=$PASSWORD_{org}")
lines.append(" depends_on:")
lines.append(" - anti-alchemy-db")
if name == "gemas-fetcher":
lines.append(" environment:")
lines.append(" - MONGO_URI=mongodb://ctf:asjdkjk23j1k3dsdn2h233j3jj@mongodb:27017/web_fetcher?authSource=admin")
lines.append(f" - APP_URI=http://0.0.0.0:{org}")
lines.append(" depends_on:")
lines.append(" - mongodb")
if name == "kode-viewer":
lines.append(" environment:")
lines.append(" - REDIS_HOST=redis")
lines.append(" - REDIS_PORT=6379")
lines.append(" depends_on:")
lines.append(" - redis")
if name == "gemas-notes":
lines.append(" ports:")
lines.append(f" - \"{org + 22}:22\"")
lines.append(" depends_on:")
lines.append(" - database")
lines.append(" - validation-service")
# note: gemas-notes internal port differs (validation-service is the 8000 listener?)
# upstream maps validation-service:12000:80 and gemas-notes has no port except ssh.
# Our single main service is gemas-notes which exposes ssh on 22.
if name == "burvesigner":
lines.append(" volumes:")
lines.append(" - ../receiver/files/burvesigner.priv:/priv.data:ro")
# sidecars
for sname, sc in sidecars.items():
cont_s = f"{sname}_container"
lines.append(f" {sname}:")
if sc.get("build"):
lines += [" build:",
f" context: {sc['build']}",
f" dockerfile: Dockerfile"]
elif sc.get("image"):
lines.append(f" image: {sc['image']}")
if sc.get("container_name") is not None:
lines.append(f" container_name: {cont_s}")
if sc.get("environment"):
lines.append(" environment:")
for e in sc["environment"]:
lines.append(f" - {e}")
if sc.get("volumes"):
lines.append(" volumes:")
for v in sc["volumes"]:
lines.append(f" - {v}")
if sc.get("depends_on"):
lines.append(" depends_on:")
for dep in sc["depends_on"]:
lines.append(f" - {dep}")
if sc.get("ports_chall"):
lines.append(" ports:")
lines.append(f" - \"{org}:{sc['ports_chall']}\"")
if sc.get("ports_ssh"):
lines.append(" ports:")
lines.append(f" - \"{org + 22}:{sc['ports_ssh']}\"")
if sc.get("extra_hosts"):
lines.append(" extra_hosts:")
lines.append(' - "host.docker.internal:host-gateway"')
if sname == "frontend" and name == "tempest-poc":
# tempest frontend already handled via ports_chall
pass
elif sname == "frontend":
lines += [" ports:", f" - \"{org}:80\""]
lines.append(" extra_hosts:")
lines.append(' - "host.docker.internal:host-gateway"')
return "\n".join(lines) + "\n"
def main():
reg = load_registry()
for ch in reg["challenges"]:
name = ch["name"]
dst = SVC / name / "docker-compose.yml"
if name in ("blogpost", "carbeat", "cdn", "phew", "sheesh", "warmup"):
# keep existing split template (from shared compose)
tpl = SVC / name / "compose.template.yml"
if tpl.exists() and not dst.exists():
text = tpl.read_text()
lines = [l for l in text.splitlines() if l.strip() and not l.strip().startswith("#")]
out = []
for l in lines:
if l.strip().startswith("# ---") and out:
break
out.append(l)
dst.write_text("\n".join(out).rstrip() + "\n")
print(f"kept template: {name}")
continue
if name == "tempest-poc":
dst.write_text(render_tempest(ch))
print(f"wrote canonical compose (tempest): {name}")
continue
dst.write_text(render(ch))
print(f"wrote canonical compose: {name}")
def render_tempest(ch: dict) -> str:
"""tempest-poc: frontend (nginx) is the MAIN challenge service — it gets
the challenge container name + SSH + challenge port; backend is a sidecar
service that frontend proxies to (no host port needed)."""
name = ch["name"]
org = int(ch.get("org_port", 10000))
return f"""services:
{name}:
container_name: {name}_container
hostname: {name}
restart: always
build:
context: ./tempest-poc/frontend
dockerfile: Dockerfile
ports:
- "{org}:80"
- "{org + 22}:22"
extra_hosts:
- "host.docker.internal:host-gateway"
backend:
build:
context: ./tempest-poc/backend
dockerfile: Dockerfile
"""
if __name__ == "__main__":
main()
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""Build the per-team receiver main.py from the challenge registry.
The receiver's `challenges` dict is generated from ENABLED registry entries so
the SLA checker pool exactly matches the distributed challenges. Imports come
from three packages:
- challenges.<Name> (gemastik18 native checkers)
- challenges.xvi.<Name> (GEMASTIK XVI checkers)
- challenges.xvii.checkers (GEMASTIK XVII generic checkers)
"""
from __future__ import annotations
import json
from pathlib import Path
from teams import TEAMS_DIR, load_registry
def checker_class_for(ch: dict) -> str:
"""Return Python import path + class name for a registry challenge."""
name = ch["name"]
s = ch["set"]
# gemastik18 native challenges have their own checker class (capitalized)
if s == "gemastik18":
cls = {
"blogpost": "Blogpost",
"carbeat": "Carbeat",
"cdn": "CDN",
"phew": "Phew",
"sheesh": "Sheesh",
"warmup": "Warmup",
}.get(name)
if cls:
return f"from challenges.{cls} import {cls}", cls
raise KeyError(f"no native checker for {name}")
if s == "xvi":
cls = {
"art": "Art",
"xl": "XL",
"gemas-notes": "GemasNotes",
"pasta": "Pasta",
"burvesigner": "Burvesigner",
"hirnfick": "Hirnfick",
"gemas-fetcher": "GemasFetcher",
"s3": "S3",
"crawlback": "Crawlback",
"back-to-basic": "BackToBasic",
}.get(name)
if cls:
return f"from challenges.xvi.{cls} import {cls}", cls
raise KeyError(f"no xvi checker for {name}")
if s == "xvii":
cls = {
"anti-alchemy": "AntiAlchemy",
"asmr": "Asmr",
"bit-canvas": "BitCanvas",
"fjb": "Fjb",
"gift-card": "GiftCard",
"gift-voucher": "GiftVoucher",
"gleam-drive": "GleamDrive",
"go-green": "GoGreen",
"kode-viewer": "KodeViewer",
"more-less": "MoreLess",
"tempest-poc": "TempestPoc",
"ticketer": "Ticketer",
}.get(name)
if cls:
return f"from challenges.xvii.checkers import {cls}", cls
raise KeyError(f"no xvii checker for {name}")
raise KeyError(f"unknown set {s}")
def render_receiver_main(enabled: list[dict], port_defaults: dict) -> str:
imports = []
entries = []
for ch in enabled:
name = ch["name"]
imp, cls = checker_class_for(ch)
imports.append(imp)
default = port_defaults.get(name, 10000)
entries.append(f' "{name}": {cls}(_ch_port("{name}", {default})),')
return f"""from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from config import get_settings
{chr(10).join(imports)}
import os
import asyncio
import logging
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
security = HTTPBasic()
settings = get_settings()
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:
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
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 = {{
{chr(10).join(entries)}
}}
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...")
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
@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 sync_team_receivers() -> None:
"""Regenerate main.py for every existing team from its state + registry."""
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"]
enabled = [c for c in load_registry()["challenges"] if c.get("enabled")]
text = render_receiver_main(enabled, {c["name"]: st["ports"][c["name"]]["chall"] for c in enabled})
(d / "receiver" / "main.py").write_text(text)
print(f"team{idx}: receiver main.py regenerated ({len(enabled)} challenges)")
if __name__ == "__main__":
sync_team_receivers()
+11
View File
@@ -25,6 +25,11 @@ def write_unit(idx: int, st: dict):
continue
env[f"CHALLENGE_PORT_{ch.upper()}"] = str(st["ports"][ch]["chall"])
env[f"CHALLENGE_CONTAINER_{ch.upper()}"] = f"{ch}_container_team{idx}"
# SSH passwords: checker Challenge.credentials() reads PASSWORD_<self.port>
# where self.port is the team challenge port.
pwd = st.get("chall_passwords", {}).get(ch)
if pwd:
env[f"PASSWORD_{st['ports'][ch]['chall']}"] = pwd
env["COMPOSE_LOCATION"] = str(TEAMS_DIR / f"team{idx}" / "services" / "docker-compose.yml")
env_lines = "\n".join(f'Environment="{k}={v}"' for k, v in env.items())
unit = f"""[Unit]
@@ -48,6 +53,12 @@ WantedBy=multi-user.target
def main():
action = sys.argv[1] if len(sys.argv) > 1 else "start"
# Regenerate receiver main.py for existing teams from the registry
try:
from gen_receiver_main import sync_team_receivers
sync_team_receivers()
except Exception as e:
print(f"WARN: receiver main.py regen failed: {e}")
for d in sorted(TEAMS_DIR.glob("team*")):
sf = d / "state.json"
if not sf.exists():
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""One-time importer: copy XVI/XVII challenge sources into the platform
services/ tree, with EOL base-image fixes and a canonical docker-compose.yml
(per-challenge template) that the compose generator can render per team.
Run after cloning the upstream repos:
python3 import_new_challenges.py <xvi_dir> <xvii_dir>
For each imported challenge it writes services/<name>/:
- source files (Dockerfile, src, start.sh, requirements, db dumps...)
- docker-compose.yml (canonical template: single main service + sidecars)
- apt-insecure.conf (2026-clock GPG fix)
"""
import re
import shutil
import sys
from pathlib import Path
BASE = Path("/opt/gemastik18-final")
SVC = BASE / "services"
# ---------------------------------------------------------------- helpers
def copy_tree(src: Path, dst: Path, ignore=None):
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst, ignore=ignore)
def add_apt_insecure(d: Path):
conf = "Acquire::AllowInsecureRepositories \"true\";\nAcquire::AllowDowngradeToInsecureRepositories \"true\";\nApt::Get::AllowUnauthenticated \"true\";\n"
(d / "apt-insecure.conf").write_text(conf)
def fix_base_image(d: Path, old: str, new: str):
"""Swap the FROM line in a Dockerfile (EOL base -> supported)."""
df = d / "Dockerfile"
if not df.exists():
return False
t = df.read_text()
if old in t:
df.write_text(t.replace(old, new, 1))
print(f" [fix base] {d.name}: {old} -> {new}")
return True
return False
DROP_FROM = [
(r"public\.ecr\.aws/docker/library/(python:3\.11-slim-buster|python:3\.11-slim-bullseye)\b", "python:3.11-slim-bookworm"),
(r"public\.ecr\.aws/docker/library/ruby:2\.7\.2\b", "ruby:3.2-slim-bookworm"),
(r"public\.ecr\.aws/docker/library/php:8\.0-apache\b", "php:8.2-apache-bookworm"),
(r"public\.ecr\.aws/docker/library/golang:bullseye\b", "golang:1.22-bookworm"),
(r"public\.ecr\.aws/docker/library/ubuntu:20\.04\b", "ubuntu:24.04"),
(r"public\.ecr\.aws/docker/library/ubuntu:22\.04\b", "ubuntu:24.04"),
(r"public\.ecr\.aws/docker/library/node(:[0-9]+)?\b", "node:20-slim-bookworm"),
(r"public\.ecr\.aws/docker/library/python:3\.10\.6\b", "python:3.10-slim-bookworm"),
]
def fix_dockerfile(d: Path):
df = d / "Dockerfile"
if not df.exists():
return
t = df.read_text()
for pat, new in DROP_FROM:
t2 = re.sub(pat, new, t)
if t2 != t:
print(f" [fix base] {d.name}: {pat} -> {new}")
t = t2
# apt-insecure for every apt-get run
if "apt-get" in t and "99gemastik-insecure" not in t:
t = t.replace(
"RUN apt-get update",
"COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure\nRUN apt-get -o Acquire::AllowInsecureRepositories=true update",
1)
t = t.replace(
"RUN apt-get install",
"RUN apt-get -y --allow-unauthenticated install",
1)
# ensure openssh + ctfuser-ish (the standard template)
df.write_text(t)
# ---------------------------------------------------------------- import
def import_xvi(src: Path):
print(f"=== Importing XVI from {src} ===")
services = src / "services"
for d in sorted(services.iterdir()):
if not d.is_dir() or d.name == ".git":
continue
name = d.name
dst = SVC / name
print(f" - {name}")
copy_tree(d, dst, ignore=shutil.ignore_patterns("__pycache__", ".git"))
add_apt_insecure(dst)
fix_dockerfile(dst)
(dst / "docker-compose.yml").unlink(missing_ok=True)
# special: gemas-notes, gemas-fetcher need their src subdirs — already copied whole dir.
def import_xvii(src: Path):
print(f"=== Importing XVII from {src} ===")
for d in sorted(src.iterdir()):
if not d.is_dir() or d.name.startswith("."):
continue
name = d.name
dst = SVC / name
print(f" - {name}")
copy_tree(d, dst, ignore=shutil.ignore_patterns("__pycache__", ".git", "docker-compose.yml", "README.md", "test"))
add_apt_insecure(dst)
fix_dockerfile(dst)
(dst / "docker-compose.yml").unlink(missing_ok=True)
if name == "tempest-poc":
# compose is frontend+backend split; keep both Dockerfiles
for sub in ("backend", "frontend"):
subd = dst / sub
if subd.exists():
if (subd / "Dockerfile").exists():
fix_dockerfile(subd)
add_apt_insecure(subd)
if __name__ == "__main__":
xvi = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/opt/gemastik-xvi-final")
xvii = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("/opt/gemastik-xvii-final")
import_xvi(xvi)
import_xvii(xvii)
print("Done. Next: write canonical docker-compose.yml templates per challenge.")
+45 -9
View File
@@ -90,8 +90,8 @@ async def _proxy(method: str, path: str, body: dict = None):
@app.get("/", response_class=HTMLResponse)
async def index(req: Request):
host = (req.headers.get("host") or "").split(":")[0]
# Team portal domains: <slug>.gemastik.imrnes.team -> their team portal (no admin login)
if host.endswith(".gemastik.imrnes.team") and host != "gemastik.imrnes.team" and host != "panel.gemastik.imrnes.team":
# Team portal domains: <slug>.attackdefense.imrnes.team -> their team portal (no admin login)
if host.endswith(".attackdefense.imrnes.team") and host != "attackdefense.imrnes.team" and host != "panel.attackdefense.imrnes.team":
slug = host.split(".")[0]
for t in orch.list_teams():
if t.get("slug") == slug:
@@ -117,7 +117,7 @@ async def submit_page(req: Request):
return HTMLResponse((BASE_DIR / "static" / "submit.html").read_text())
# ============ Per-team portal (public via <slug>.gemastik.imrnes.team) ============
# ============ Per-team portal (public via <slug>.attackdefense.imrnes.team) ============
@app.get("/team/{idx}", response_class=HTMLResponse)
async def team_portal(idx: int, req: Request):
@@ -194,13 +194,13 @@ def _team_authorized(req: Request, idx: int) -> bool:
def _check_team_host(req: Request, st: dict) -> bool:
"""IDOR guard: host must be this team's own domain (or localhost).
panel.gemastik / gemastik.imrnes.team only allowed with a valid ADMIN session."""
panel.gemastik / attackdefense.imrnes.team only allowed with a valid ADMIN session."""
host = (req.headers.get("host") or "").split(":")[0]
if host == st.get("domain"):
return True
if host.startswith("127.0.0.1") or host.startswith("localhost"):
return True
if host in ("panel.gemastik.imrnes.team", "gemastik.imrnes.team"):
if host in ("panel.attackdefense.imrnes.team", "attackdefense.imrnes.team"):
return _authorized(req) # admin preview only
return False
@@ -234,7 +234,7 @@ async def api_team_targets(idx: int, req: Request):
out.append({
"team_idx": st.get("index"),
"team_label": st.get("label", f"Team {st.get('index')}"),
"domain": st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".gemastik.imrnes.team"),
"domain": st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".attackdefense.imrnes.team"),
"port": p["chall"],
})
return {"targets": out}
@@ -357,7 +357,43 @@ def require_login(req: Request):
@app.get("/api/challenges")
async def api_challenges(req: Request):
require_login(req)
return {"challenges": CHALLENGES}
# Full registry (all sets) with enabled status, plus count of live teams
reg = orch.load_registry()
challs = []
for c in reg.get("challenges", []):
challs.append({
"name": c["name"],
"set": c.get("set"),
"category": c.get("category"),
"desc": c.get("desc"),
"enabled": bool(c.get("enabled")),
"chall_offset": c.get("chall_offset"),
"ssh_offset": c.get("ssh_offset"),
"org_port": c.get("org_port"),
"service_dir": c.get("service_dir"),
})
return {"challenges": challs, "hint": "PATCH /api/challenges/<name> with {\"enabled\": bool} to toggle"}
@app.patch("/api/challenges/{challenge}")
async def api_challenge_toggle(challenge: str, req: Request):
require_login(req)
data = await req.json()
enabled = bool(data.get("enabled"))
reg = orch.load_registry()
found = any(c.get("name") == challenge for c in reg.get("challenges", []))
if not found:
raise HTTPException(404, "Unknown challenge")
changed = orch.set_challenge_enabled(challenge, enabled)
# apply to live teams (build/up or stop/remove + receiver restart);
# skip rebuild when the flag didn't actually change
if "unchanged" in changed:
return {"ok": True, "name": challenge, "enabled": enabled, "applied": [], "unchanged": True}
try:
report = await asyncio.to_thread(orch.sync_challenge_runtime, challenge, enabled)
except Exception as e:
raise HTTPException(500, f"Registry updated tapi runtime gagal: {e}")
return {"ok": True, "name": challenge, "enabled": enabled, "applied": report.get("teams", [])}
@app.get("/api/status")
async def api_status(req: Request):
@@ -549,7 +585,7 @@ async def api_topology(req: Request):
require_login(req)
teams = orch.list_teams()
nodes = [
{"id": "panel", "label": "Panel A/D", "type": "panel", "url": "https://panel.gemastik.imrnes.team"},
{"id": "panel", "label": "Panel A/D", "type": "panel", "url": "https://panel.attackdefense.imrnes.team"},
{"id": "traefik", "label": "Traefik / Coolify", "type": "infra"},
{"id": "dns", "label": "*.imrnes.team → 43.134.105.109", "type": "infra"},
]
@@ -687,7 +723,7 @@ async def api_admin_targets(req: Request):
if not (d / "state.json").exists():
continue
st = json.loads((d / "state.json").read_text())
dom = st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".gemastik.imrnes.team")
dom = st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".attackdefense.imrnes.team")
for name, coff, soff in orch.CHALLENGES:
p = st["ports"].get(name)
if not p:
+68 -6
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gemastik A/D Panel</title>
<title>Attack Defense Platform</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
@@ -117,7 +117,7 @@
</head>
<body>
<header>
<h1>⚔️ GEMASTIK A/D — NODE CONTROL</h1>
<h1>⚔️ ATTACK DEFENSE PLATFORM — NODE CONTROL</h1>
<div class="actions">
<span class="pill" id="clock">--:--:--</span>
<button onclick="refresh()">🔄 Refresh</button>
@@ -135,6 +135,7 @@
<button class="tab" data-view="creds" onclick="showView('creds'); loadCreds()">🔑 Creds</button>
<button class="tab" data-view="targets" onclick="showView('targets'); loadTargetMatrix()">🎯 Target Matrix</button>
<button class="tab" data-view="sla" onclick="showView('sla'); loadSla()">📊 SLA & Skor</button>
<button class="tab" data-view="chmgr" onclick="showView('chmgr'); loadChMgr()">🏗️ Challenge Manager</button>
</div>
<!-- Challenges view -->
@@ -229,7 +230,19 @@
</div>
</div>
<div class="footer-note">Receiver: https://gemastik.imrnes.team · Panel: https://panel.gemastik.imrnes.team · Auto-refresh tiap 15 detik</div>
<!-- Challenge Manager view -->
<div class="view" id="view-chmgr">
<div class="card">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<b style="color:#5ad1ff">🏗️ Challenge Manager</b>
<span style="font-size:11px;color:#718096">Semua challenge dari registry (GEMASTIK 18, XVI, XVII). Toggle untuk aktif/nonaktif global di semua tim — diterapkan langsung (build/up atau stop/remove + restart receiver).</span>
<button class="primary" onclick="loadChMgr()" style="padding:4px 10px;font-size:12px">🔄 Muat ulang</button>
</div>
<div id="chMgrList" style="margin-top:14px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.9;color:#dbe6f4;background:#0a101f;border:1px solid #1e3a5f;border-radius:10px;padding:14px;overflow-x:auto">Memuat…</div>
</div>
</div>
<div class="footer-note">Receiver: https://attackdefense.imrnes.team · Panel: https://panel.attackdefense.imrnes.team · Auto-refresh tiap 15 detik</div>
<!-- modal flag -->
<div class="modal-back" id="modalFlag">
@@ -310,6 +323,7 @@ function showView(v) {
document.querySelectorAll('.view').forEach(x => x.classList.toggle('active', x.id === 'view-' + v));
if (v === 'topo') loadTopo(); // refresh attacks immediately on tab switch + every 10s
if (v === 'sla') loadSla();
if (v === 'chmgr') loadChMgr();
}
let topoTimer = null;
function startTopoTimer() {
@@ -347,6 +361,54 @@ async function loadSla() {
}
}
// ---------- Challenge Manager ----------
async function loadChMgr() {
const box = document.getElementById('chMgrList');
if (!box) return;
box.innerHTML = 'Memuat…';
try {
const d = await api('/api/challenges');
const list = d.challenges || [];
const setLabels = { 'gemastik18': 'GEMASTIK 18', 'xvi': 'GEMASTIK XVI', 'xvii': 'GEMASTIK XVII' };
const bySet = {};
for (const c of list) (bySet[c.set] = bySet[c.set] || []).push(c);
let html = '';
for (const [set, chs] of Object.entries(bySet)) {
html += `<div style="margin:8px 0 4px;color:#5ad1ff;font-weight:700">📦 ${escapeHtml(setLabels[set] || set)} (${chs.length})</div>`;
for (const c of chs) {
const on = c.enabled ? 'checked' : '';
html += `<div style="display:flex;align-items:center;gap:10px;padding:6px 8px;border-bottom:1px solid #13233d;border-radius:6px">
<input type="checkbox" id="chmgr-${escapeHtml(c.name)}" ${on} onchange="toggleChallenge('${escapeHtml(c.name)}', this.checked)" style="width:16px;height:16px;accent-color:#22c55e">
<b style="min-width:150px">${escapeHtml(c.name)}</b>
<span style="color:#718096;font-size:11px">${escapeHtml(c.set)} · ${escapeHtml(c.category || '-')}</span>
<span style="color:#3d5a80;font-size:11px">chall+${c.chall_offset}/ssh+${c.ssh_offset}</span>
<span style="margin-left:auto;font-size:11px;color:${c.enabled ? '#22c55e' : '#e74c3c'}">${c.enabled ? '● AKTIF' : '○ NONAKTIF'}</span>
<span id="chmgr-msg-${escapeHtml(c.name)}" style="font-size:11px;color:#718096"></span>
</div>`;
}
}
box.innerHTML = html || '<div style="color:#718096">Tidak ada challenge di registry.</div>';
} catch (e) {
box.innerHTML = `<div style="color:#e74c3c">Gagal memuat: ${escapeHtml(e.message)}</div>`;
}
}
async function toggleChallenge(name, enabled) {
const msg = document.getElementById('chmgr-msg-' + name);
if (!msg) return;
msg.textContent = '⏳ menerapkan…';
msg.style.color = '#f5c542';
try {
const d = await api('/api/challenges/' + encodeURIComponent(name), { method: 'PATCH', body: JSON.stringify({ enabled }) });
msg.textContent = `✓ ${d.enabled ? 'diaktifkan' : 'dinonaktifkan'} (${(d.applied || []).length} tim)`;
msg.style.color = d.enabled ? '#22c55e' : '#e74c3c';
setTimeout(() => { msg.textContent = ''; loadChMgr(); }, 4000);
} catch (e) {
msg.textContent = '✗ ' + e.message;
msg.style.color = '#e74c3c';
loadChMgr();
}
}
// ---------- Challenges ----------
async function refresh() {
const grid = document.getElementById('grid');
@@ -421,7 +483,7 @@ async function viewCred(ch) {
const c = await api(`/api/credential/${ch}`);
document.getElementById('mcTitle').textContent = 'SSH Credentials — ' + ch;
const sshPort = {blogpost:10022, carbeat:11022, cdn:12022, phew:13022, sheesh:14022, warmup:15022}[ch] || 10022;
const cmd = `ssh ctfuser@${ch}.gemastik.imrnes.team -p ${sshPort}`;
const cmd = `ssh ctfuser@${ch}.attackdefense.imrnes.team -p ${sshPort}`;
document.getElementById('mcBody').innerHTML =
`<div>User: <b>ctfuser</b></div>
<div>Pass: <b>${esc(c.password)}</b></div>
@@ -686,7 +748,7 @@ function teamCountChanged() {
<span style="color:#718096;font-size:12px;width:60px">Team ${i}</span>
<input data-idx="${i}" data-field="name" placeholder="Nama team (contoh: Cyber Warriors)" style="flex:1;min-width:120px">
<input data-idx="${i}" data-field="domain" placeholder="domain custom (contoh: team-cyber)" style="flex:1;min-width:120px">
<span style="color:#3b4a63;font-size:11px">.gemastik.imrnes.team</span>
<span style="color:#3b4a63;font-size:11px">.attackdefense.imrnes.team</span>
</div>`;
}
box.innerHTML = html;
@@ -795,7 +857,7 @@ async function viewTeamCred(idx) {
for (const [name, p] of Object.entries(t.team.ports)) {
if (name === 'receiver' || name === 'panel') continue;
html += `<div class="kv" style="margin-top:6px">
<b>${name}</b><span>ssh ctfuser@${name}.gemastik.imrnes.team -p ${p.ssh} &nbsp;·&nbsp; pass: ${esc(t.team.chall_passwords[name])}</span>
<b>${name}</b><span>ssh ctfuser@${name}.attackdefense.imrnes.team -p ${p.ssh} &nbsp;·&nbsp; pass: ${esc(t.team.chall_passwords[name])}</span>
</div>`;
}
document.getElementById('mtcTitle').textContent = `Kredensial Team ${idx}`;
+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gemastik A/D Panel — Login</title>
<title>Attack Defense Platform — Login</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
@@ -34,7 +34,7 @@
</head>
<body>
<div class="card">
<h1>⚔️ GEMASTIK A/D</h1>
<h1>⚔️ ATTACK DEFENSE</h1>
<div class="sub">Node Control Panel — imrnes</div>
<form id="f">
<label>Username</label>
+1 -1
View File
@@ -41,7 +41,7 @@
<div class="logo">G</div>
<div>
<h1>Gemastik XVIII — Attack &amp; Defense</h1>
<div class="sub">Submit flag untuk tim kamu. Server: gemastik.imrnes.team</div>
<div class="sub">Submit flag untuk tim kamu. Server: attackdefense.imrnes.team</div>
</div>
</header>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="team-id" content="0">
<title>Portal Tim — Gemastik A/D</title>
<title>Portal Tim — Attack Defense Platform</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<style>
+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: