fix: make challenge toggle actually work end-to-end (5 root-cause bugs)

Found by testing a real enable/disable cycle (art, fjb, gift-card):

1. compose_gen always swapped build->image, so a never-built challenge
   produced 'pull access denied for services-<name>'. Now it only reuses
   the image when it exists locally, otherwise keeps build: so
   'docker compose up --build' builds it.
2. Canonical templates use 'build: context: .' (written for the shared
   services/ tree). In the per-team compose that resolves to the team dir
   which has no Dockerfile -> 'failed to read dockerfile'. The renderer
   now rewrites the main service's context to ./<name>.
3. Teams created before the XVI/XVII import had no xvi/xvii subpackages
   under their local challenges/ dir, so the regenerated receiver main.py
   crash-looped on import. gen_receiver_main now mirrors ALL shared
   checkers (native + xvi + xvii) into every team receiver on each sync.
4. systemd Environment= keys can't contain hyphens, so
   CHALLENGE_PORT_GIFT-CARD was silently dropped. Keys are now
   normalized to underscores on both the writer and reader side.
5. Several checkers called 'docker exec' with no timeout; against a
   container with accumulated chall.py zombies that blocks forever and
   stalls the whole SLA loop. Added mandatory timeouts (Phew, Sheesh,
   Carbeat, Poke, Warmup).

Also: enabling a challenge now copies its source tree into each team's
services/ dir (team dirs only held challenges enabled at create_team
time), and the XVII checkers were rewritten to be protocol-aware
(gift-card/gift-voucher are socat TCP, not HTTP) with strict timeouts.
This commit is contained in:
MythEclipse committed 2026-09-25 15:36:09 +08:00
1 parent c6fd9ec268
commit d4c741926d
10 files changed
+176 -106

No files matched your search

+39 -4
View File
@@ -11,6 +11,7 @@ from three packages:
from __future__ import annotations
import json
import shutil
from pathlib import Path
from teams import TEAMS_DIR, load_registry
@@ -98,26 +99,33 @@ app = FastAPI()
security = HTTPBasic()
settings = get_settings()
def _envkey(name: str) -> str:
# systemd Environment= keys can't contain hyphens; gen_receiver_services
# writes CHALLENGE_PORT_GIFT_CARD for the challenge "gift-card".
return name.upper().replace("-", "_")
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()}}")
key = _envkey(name)
val = os.environ.get(f"CHALLENGE_PORT_{{key}}")
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()}}="):
if line.startswith(f"CHALLENGE_PORT_{{key}}="):
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()}}")
key = _envkey(name)
val = os.environ.get(f"CHALLENGE_CONTAINER_{{key}}")
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()}}="):
if line.startswith(f"CHALLENGE_CONTAINER_{{key}}="):
val = line.strip().split("=", 1)[1]
except Exception:
pass
@@ -253,6 +261,32 @@ def validate(credentials, challenge):
"""
def _sync_checker_packages(recv_dir) -> None:
"""Mirror the shared receiver's challenge checkers into a team receiver.
Two reasons this must run on every sync, not just at create_team time:
1. Teams created before the XVI/XVII import have no xvi/xvii subpackages,
so a regenerated main.py that imports them would crash-loop the receiver.
2. Native checkers (Blogpost/Phew/...) get bug fixes (e.g. mandatory
subprocess timeouts); a team copy made earlier keeps the stale version.
"""
shared_challenges = Path("/opt/gemastik18-final/receiver/challenges")
dst_root = recv_dir / "challenges"
dst_root.mkdir(parents=True, exist_ok=True)
for src_file in sorted(shared_challenges.glob("*.py")):
if src_file.name == "__init__.py":
continue
shutil.copy2(src_file, dst_root / src_file.name)
for pkg in ("xvi", "xvii"):
src = shared_challenges / pkg
if not src.exists():
continue
dst = dst_root / pkg
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
def sync_team_receivers() -> None:
"""Regenerate main.py for every existing team from its state + registry."""
for d in sorted(TEAMS_DIR.glob("team*")):
@@ -262,6 +296,7 @@ def sync_team_receivers() -> None:
st = json.loads(sf.read_text())
idx = st["index"]
enabled = [c for c in load_registry()["challenges"] if c.get("enabled")]
_sync_checker_packages(d / "receiver")
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)")