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:
@@ -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")]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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():
|
||||
|
||||
@@ -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
@@ -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
@@ -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} · pass: ${esc(t.team.chall_passwords[name])}</span>
|
||||
<b>${name}</b><span>ssh ctfuser@${name}.attackdefense.imrnes.team -p ${p.ssh} · pass: ${esc(t.team.chall_passwords[name])}</span>
|
||||
</div>`;
|
||||
}
|
||||
document.getElementById('mtcTitle').textContent = `Kredensial Team ${idx}`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<div class="logo">G</div>
|
||||
<div>
|
||||
<h1>Gemastik XVIII — Attack & 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>
|
||||
|
||||
|
||||
@@ -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
@@ -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