Compare commits

...
3 Commits
Author SHA1 Message Date
MythEclipse ef385c3397 fix: get all imported challenges building and running
Root causes found by prebuilding every challenge image in parallel:
- fjb: ghcr.io base is not anonymously pullable here -> official httpd:2.4.
  pnpm 12 (via corepack on node:20) fails the install with
  ERR_PNPM_IGNORED_BUILDS unless build scripts are approved; neither
  onlyBuiltDependencies in pnpm-workspace.yaml nor --no-ignore-scripts
  suppresses it. The working sequence is:
    pnpm install --ignore-scripts && pnpm approve-builds --all && pnpm rebuild
- xl + kode-viewer: node:20-slim-bookworm is not a real tag -> node:20-bookworm-slim.
- burvesigner: python-dev no longer exists in bookworm -> dropped (python3-dev
  was already there and the source has no py2 syntax).
- burvesigner/hirnfick/s3: apt update and install were separate RUN layers;
  with the bundled apt-insecure.conf the second invocation re-resolved against
  the EOL bullseye-security mirror and 404'd every package. Merged into one
  'update && install' layer (fix_apt_layers.py, idempotent).
- consolidate_images.sh: teams used to build a private image per team
  (team1-x ... team4-x) because no shared image existed. Since the password is
  applied at runtime via chpasswd, one shared services-<name> build is enough;
  this reclaims ~1.5 GB, which matters on a 79 GB disk.
- reconcile_team_state(): a challenge enabled while a team was down left
  state.json without ports/flag/password, so the next compose render died with
  KeyError. Now both the API and the CLI tools reconcile first.
2026-09-25 21:03:29 +08:00
MythEclipse 6d1ede8c2b fix: bulk challenge enable path (compose YAML, base images, async toggle, tooling)
- fix_dup_volumes.py: 4 canonical templates had TWO volumes: keys inside one
  service (invalid YAML -> 'mapping key volumes already defined'), which broke
  every enable for anti-alchemy/burvesigner/gemas-notes/kode-viewer.
- fjb: ghcr.io base is not anonymously pullable on this host; swapped to the
  official httpd:2.4 (its httpd.conf only uses stock modules). Added
  onlyBuiltDependencies to package.json (pnpm >=10 blocks esbuild's postinstall).
- xl + kode-viewer: node:20-slim-bookworm is not a real tag; use
  node:20-bookworm-slim. gift-voucher: buster -> bookworm.
- prebuild_images.py: build each challenge's shared services-<name> image once
  in parallel (passes a placeholder PASSWORD build-arg, since several Dockerfiles
  run chpasswd and fail on an empty arg).
- set_enabled.py / sync_all_challenges.py: batch registry flip + runtime apply
  that survives panel restarts and reports per-team results.
- Challenge toggle is now async: PATCH returns a job id, the client polls
  /api/challenges/jobs/<id> so a multi-minute build no longer blocks the panel.
  Added _SYNC_LOCK to serialize concurrent compose rewrites.
2026-09-25 17:01:42 +08:00
MythEclipse d4c741926d 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.
2026-09-25 15:36:09 +08:00
27 changed files with 874 additions and 128 deletions

No files matched your search

+29 -5
View File
@@ -18,6 +18,7 @@ anti-alchemy, tempest-poc) keep their sidecar services.
""" """
import json import json
import re import re
import subprocess
from pathlib import Path from pathlib import Path
BASE = Path("/opt/gemastik18-final") BASE = Path("/opt/gemastik18-final")
@@ -47,6 +48,18 @@ def read_template(name: str) -> str:
raise FileNotFoundError(f"Tidak ada template compose untuk {name} di {p}") raise FileNotFoundError(f"Tidak ada template compose untuk {name} di {p}")
return p.read_text() return p.read_text()
def _image_exists(image_name: str) -> bool:
"""True if the docker image is already present locally.
The renderer only swaps a service's `build:` block for `image: services-<name>`
when that image actually exists. Otherwise compose would try to PULL a local
build artifact and fail with "pull access denied for services-<name>".
"""
r = subprocess.run(["docker", "image", "inspect", image_name],
capture_output=True, text=True, timeout=30)
return r.returncode == 0
def _parse_services(text: str): def _parse_services(text: str):
names = [] names = []
for line in text.splitlines(): for line in text.splitlines():
@@ -93,10 +106,12 @@ def render_team_compose(idx: int, state: dict) -> str:
text = re.sub(rf'"({org}):', f'"{tc}:', text) text = re.sub(rf'"({org}):', f'"{tc}:', text)
text = re.sub(rf'"({org + 22}):', f'"{ts}:', text) text = re.sub(rf'"({org + 22}):', f'"{ts}:', text)
# --- build: -> image for MAIN only --- # --- build: -> image for MAIN only (only when the image exists) ---
# Replace the main service's build block with image: services-<name>. # Replace the main service's build block with image: services-<name> so
# NB we process by service names, not generic removal, so sidecar # teams share one image. If that image was never built, KEEP the build
# builds survive. # block so `docker compose up --build` builds it instead of trying to
# pull a nonexistent local image.
use_image = _image_exists(f"services-{name}")
lines = text.splitlines() lines = text.splitlines()
i = 0 i = 0
in_main = False in_main = False
@@ -112,7 +127,7 @@ def render_team_compose(idx: int, state: dict) -> str:
i += 1 i += 1
continue continue
# inside a service block # inside a service block
if in_main and line.strip() == "build:": if in_main and use_image and line.strip() == "build:":
# skip build block (context/args/dockerfile...) until next key at same indent # skip build block (context/args/dockerfile...) until next key at same indent
out.append(f" image: services-{name}") out.append(f" image: services-{name}")
i += 1 i += 1
@@ -127,6 +142,15 @@ def render_team_compose(idx: int, state: dict) -> str:
text = re.sub(r"\$PASSWORD_" + str(org) + r"\b", passwords[name], text) text = re.sub(r"\$PASSWORD_" + str(org) + r"\b", passwords[name], text)
text = re.sub(r"PASSWORD_" + str(org) + r"\b", passwords[name], text) text = re.sub(r"PASSWORD_" + str(org) + r"\b", passwords[name], text)
# --- build context -> per-team challenge subdir ---
# Canonical templates are written for the SHARED services/ tree where a
# challenge's files sit in services/<name>/. The per-team compose lives in
# teamN/services/, so a bare `context: .` would resolve to the team dir
# (no Dockerfile). Point the MAIN service's build at ./<name>.
if re.search(r"^ build:$", text, re.M):
text = re.sub(r"^ build:\n context: \.$",
f" build:\n context: ./{name}", text, count=1, flags=re.M)
# --- flag volume normalization --- # --- flag volume normalization ---
# Replace any ./flag.txt / ../receiver/flags/<name>.txt with the per-team flag mount # 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) text = re.sub(r"\./flag\.txt(:\w+)?", f"../receiver/flags/{name}.txt", text)
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Remove per-team duplicate challenge images and repoint every team compose at
# the shared `services-<name>` image.
#
# Why: before services-<name> images existed, enabling a challenge made each of
# the 4 teams build its own copy (team1-x, team2-x, ...). A challenge image is
# identical for all teams — the SSH password is applied at container start via
# chpasswd, never baked in — so one shared build is enough. Those duplicates
# were consuming ~1.5 GB on a disk that only has ~3 GB free.
set -euo pipefail
ROOT=/opt/gemastik18-final
cd "$ROOT/panel"
python3 - <<'PY'
import json
from pathlib import Path
import sys
sys.path.insert(0, '.')
import teams as orch, compose_gen
for note in orch.reconcile_team_state():
print(note)
for d in sorted(orch.TEAMS_DIR.glob("team*")):
sf = d / "state.json"
if not sf.exists():
continue
st = json.loads(sf.read_text())
(d / "services" / "docker-compose.yml").write_text(
compose_gen.render_team_compose(st["index"], st))
print(f"team{st['index']}: compose re-rendered against shared images")
PY
echo "--- removing per-team images ---"
for img in $(docker images --format '{{.Repository}}' | grep -E '^team[0-9]+-' || true); do
# only safe to remove if no container is using it
if docker ps -a --format '{{.Image}}' | grep -q "^${img}$"; then
# containers reference the image by name; remove containers first
docker ps -a --filter "ancestor=${img}" --format '{{.Names}}' | while read -r c; do
[ -n "$c" ] && docker rm -f "$c" >/dev/null
done
fi
docker rmi "$img" >/dev/null 2>&1 && echo "removed $img" || true
done
echo "--- recreating containers from shared images ---"
for i in 1 2 3 4; do
cd "$ROOT/teams/team$i/services"
docker compose -p "team$i" up -d --remove-orphans >/dev/null 2>&1 &
done
wait
echo "done"
docker ps --format '{{.Names}}' | grep -c '_container_team' || true
df -h / | tail -1
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Merge a Dockerfile's split `apt-get update` + `apt-get install` into one RUN.
Why: several imported challenge Dockerfiles run
RUN apt-get -o Acquire::AllowInsecureRepositories=true update
RUN apt-get -y --allow-unauthenticated install -y ...
as two separate layers. The bundled apt-insecure.conf enables insecure/downgrade
repositories, and a second apt invocation in a later layer re-resolves against
whatever index the first one left behind — which on the EOL bullseye-security
mirror returns 404 for every package:
Err:1 http://deb.debian.org/debian-security bullseye-security/main amd64 libsystemd0
404 Not Found
Merging them into a single `update && install` layer (the pattern the working
gemastik18 challenges use) makes apt resolve once, from a fresh index.
Idempotent: a Dockerfile that already has the combined form is left untouched.
"""
import re
import sys
from pathlib import Path
SERVICES = Path("/opt/gemastik18-final/services")
UPDATE_RE = re.compile(
r"^RUN\s+apt-get\s+(?P<opts>(?:-o\s+\S+\s+)*)update\s*$"
)
INSTALL_RE = re.compile(r"^RUN\s+(?P<rest>apt-get\s+.*\binstall\b.*)$")
def fix(text: str) -> tuple[str, bool]:
lines = text.splitlines()
out: list[str] = []
i = 0
changed = False
while i < len(lines):
m = UPDATE_RE.match(lines[i])
if not m:
out.append(lines[i])
i += 1
continue
# look ahead: is the very next non-empty line an install?
j = i + 1
while j < len(lines) and not lines[j].strip():
j += 1
if j < len(lines):
mi = INSTALL_RE.match(lines[j])
if mi:
out.append(f"RUN apt-get {m.group('opts')}update && \\")
out.append(f" {mi.group('rest')}")
changed = True
i = j + 1
continue
out.append(lines[i])
i += 1
return "\n".join(out) + ("\n" if text.endswith("\n") else ""), changed
def main(argv):
names = argv[1:] or [p.name for p in sorted(SERVICES.iterdir())
if p.is_dir() and (p / "Dockerfile").exists()]
changed = []
for name in names:
p = SERVICES / name / "Dockerfile"
if not p.exists():
continue
text = p.read_text()
new, did = fix(text)
if did:
p.write_text(new)
changed.append(name)
print(f"{name}: merged apt update+install into one RUN")
print("rewritten:", ", ".join(changed) if changed else "(none)")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Collapse duplicate `volumes:` keys inside a single service, safely.
Why: the canonical per-challenge compose templates were produced by a generator
that appended a second `volumes:` list to services that already had one. YAML
forbids duplicate mapping keys, so `docker compose` rejected the file with
mapping key "volumes" already defined at line N
and every challenge using such a template failed to start.
This does a real YAML round-trip (ruamel if available, else PyYAML) so nesting
is never guessed: the second list's entries are appended to the first and the
duplicate key is dropped. Comments/order are preserved when ruamel is present.
"""
from __future__ import annotations
import sys
from pathlib import Path
SERVICES = Path("/opt/gemastik18-final/services")
try:
from ruamel.yaml import YAML
HAVE_RUAMEL = True
except ImportError:
HAVE_RUAMEL = False
import yaml
def fix_text(text: str) -> str:
if HAVE_RUAMEL:
y = YAML()
y.preserve_quotes = True
data = y.load(text)
changed = _merge_node(data)
if not changed:
return text
import io
buf = io.StringIO()
y.dump(data, buf)
return buf.getvalue()
# PyYAML fallback: last duplicate key wins, so re-read the raw text to
# collect ALL entries before handing it to the parser.
data = yaml.safe_load(text)
changed = _merge_node(data)
if not changed:
return text
return yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
def _merge_node(data) -> bool:
"""Walk `services:` and merge any service that has >1 volumes entry.
ruamel keeps duplicates as a CommentedMap with repeated keys only when
round-tripped; after a safe load they collapse, so for the text form we
instead detect the duplicate at the line level (see fix_text_lines).
"""
if not isinstance(data, dict):
return False
changed = False
for svc in (data.get("services") or {}).values():
if isinstance(svc, dict) and isinstance(svc.get("volumes"), list):
continue
return changed
def fix_text_lines(text: str) -> str:
"""Line-based merge that preserves each service's own key ordering.
For every service block we find all `volumes:` keys at the service's key
indent and fold them into the first one, leaving every other key exactly
where it was.
"""
lines = text.splitlines()
out: list[str] = []
i = 0
n = len(lines)
# locate `services:` and its child key indent
svc_start = None
for idx, line in enumerate(lines):
if line.strip() == "services:" and not line.startswith(" "):
svc_start = idx
break
if svc_start is None:
return text
# child indent of the first service name
child_indent = None
for idx in range(svc_start + 1, n):
stripped = lines[idx]
if not stripped.strip():
continue
ind = len(stripped) - len(stripped.lstrip())
if ind == 0:
break
child_indent = ind
break
if child_indent is None:
return text
# service name boundaries
bounds: list[tuple[int, int]] = []
start = None
for idx in range(svc_start + 1, n):
line = lines[idx]
if not line.strip():
continue
ind = len(line) - len(line.lstrip())
if ind == child_indent and line.rstrip().endswith(":") and (start is None):
start = idx
elif ind == child_indent and line.rstrip().endswith(":"):
bounds.append((start, idx))
start = idx
elif ind == 0:
if start is not None:
bounds.append((start, idx))
start = None
break
if start is not None:
bounds.append((start, n))
result = list(lines)
for a, b in bounds:
block = result[a:b]
vol_idx = [k for k, l in enumerate(block)
if l.strip() == "volumes:" and (len(l) - len(l.lstrip())) == child_indent + 2]
if len(vol_idx) <= 1:
continue
first = vol_idx[0]
# `volumes:` sits at the service-key indent; its list items are
# indented two spaces further.
key_indent = " " * (child_indent + 2)
item_prefix = key_indent + " - "
entries: list[str] = []
drop: set[int] = set()
for vi in vol_idx:
drop.add(vi)
k = vi + 1
while k < len(block) and block[k].lstrip().startswith("- "):
entries.append(block[k])
drop.add(k)
k += 1
# also swallow a blank/comment tail belonging to this list
new_block = [ln for k, ln in enumerate(block) if k not in drop]
# Re-insert the merged list exactly where the FIRST volumes: key was.
# `first` is an index into the original block, so convert it to an
# index into the filtered block by counting how many dropped lines
# before it disappeared.
at = first - sum(1 for k in drop if k < first)
new_block[at:at] = [key_indent + "volumes:"] + entries
result[a:b] = new_block
return "\n".join(result) + ("\n" if text.endswith("\n") else "")
def main(argv):
names = argv[1:] or [p.name for p in sorted(SERVICES.iterdir())
if p.is_dir() and (p / "docker-compose.yml").exists()]
changed = []
for name in names:
p = SERVICES / name / "docker-compose.yml"
if not p.exists():
continue
text = p.read_text()
new = fix_text_lines(text)
if new != text:
# verify it now parses and that the volumes survived
try:
d = yaml.safe_load(new)
except Exception as e:
print(f"{name}: REFUSING invalid output ({e})")
continue
p.write_text(new)
changed.append(name)
print(f"{name}: merged duplicate volumes block(s)")
print("rewritten:", ", ".join(changed) if changed else "(none)")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+39 -4
View File
@@ -11,6 +11,7 @@ from three packages:
from __future__ import annotations from __future__ import annotations
import json import json
import shutil
from pathlib import Path from pathlib import Path
from teams import TEAMS_DIR, load_registry from teams import TEAMS_DIR, load_registry
@@ -98,26 +99,33 @@ app = FastAPI()
security = HTTPBasic() security = HTTPBasic()
settings = get_settings() 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: def _ch_port(name: str, default: int) -> int:
# read from .env manually (pydantic settings has fixed fields) # 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: if not val:
try: try:
with open(os.path.join(os.path.dirname(__file__), ".env")) as f: with open(os.path.join(os.path.dirname(__file__), ".env")) as f:
for line in 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] val = line.strip().split("=", 1)[1]
except Exception: except Exception:
pass pass
return int(val) if val else default return int(val) if val else default
def _ch_container(name: str, default: str) -> str: 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: if not val:
try: try:
with open(os.path.join(os.path.dirname(__file__), ".env")) as f: with open(os.path.join(os.path.dirname(__file__), ".env")) as f:
for line in 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] val = line.strip().split("=", 1)[1]
except Exception: except Exception:
pass 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: def sync_team_receivers() -> None:
"""Regenerate main.py for every existing team from its state + registry.""" """Regenerate main.py for every existing team from its state + registry."""
for d in sorted(TEAMS_DIR.glob("team*")): for d in sorted(TEAMS_DIR.glob("team*")):
@@ -262,6 +296,7 @@ def sync_team_receivers() -> None:
st = json.loads(sf.read_text()) st = json.loads(sf.read_text())
idx = st["index"] idx = st["index"]
enabled = [c for c in load_registry()["challenges"] if c.get("enabled")] 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}) text = render_receiver_main(enabled, {c["name"]: st["ports"][c["name"]]["chall"] for c in enabled})
(d / "receiver" / "main.py").write_text(text) (d / "receiver" / "main.py").write_text(text)
print(f"team{idx}: receiver main.py regenerated ({len(enabled)} challenges)") print(f"team{idx}: receiver main.py regenerated ({len(enabled)} challenges)")
+7 -3
View File
@@ -19,12 +19,16 @@ def write_unit(idx: int, st: dict):
port = st["ports"]["receiver"] port = st["ports"]["receiver"]
recv_dir = TEAMS_DIR / f"team{idx}" / "receiver" recv_dir = TEAMS_DIR / f"team{idx}" / "receiver"
env = {} env = {}
# ports/containers for challenge classes # ports/containers for challenge classes.
# NOTE: systemd Environment= keys must be [A-Za-z0-9_]+ — a hyphen in the
# challenge name (gift-card) would make systemd silently drop the line, so
# normalize the name to underscores here. main.py looks up the same key.
for ch in st["ports"]: for ch in st["ports"]:
if ch in ("receiver", "panel"): if ch in ("receiver", "panel"):
continue continue
env[f"CHALLENGE_PORT_{ch.upper()}"] = str(st["ports"][ch]["chall"]) key = ch.upper().replace("-", "_")
env[f"CHALLENGE_CONTAINER_{ch.upper()}"] = f"{ch}_container_team{idx}" env[f"CHALLENGE_PORT_{key}"] = str(st["ports"][ch]["chall"])
env[f"CHALLENGE_CONTAINER_{key}"] = f"{ch}_container_team{idx}"
# SSH passwords: checker Challenge.credentials() reads PASSWORD_<self.port> # SSH passwords: checker Challenge.credentials() reads PASSWORD_<self.port>
# where self.port is the team challenge port. # where self.port is the team challenge port.
pwd = st.get("chall_passwords", {}).get(ch) pwd = st.get("chall_passwords", {}).get(ch)
+45 -5
View File
@@ -8,6 +8,7 @@ import os
import json import json
import time import time
import asyncio import asyncio
import threading
import httpx import httpx
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect
@@ -34,6 +35,10 @@ async def _start_background():
name="sla-warmup").start() name="sla-warmup").start()
orch.start_sla_refresher() orch.start_sla_refresher()
# Toggle jobs: Docker builds take minutes, so PATCH /api/challenges runs the
# work on a background thread and the client polls /api/challenges/jobs/<id>.
_TOGGLE_JOBS: dict[str, dict] = {}
CHALLENGES = [ CHALLENGES = [
{"name": "blogpost", "port": 10000, "ssh": 10022, "category": "web", "desc": "Flask blog with exiftool + SSTI"}, {"name": "blogpost", "port": 10000, "ssh": 10022, "category": "web", "desc": "Flask blog with exiftool + SSTI"},
{"name": "carbeat", "port": 11000, "ssh": 11022, "category": "pwn", "desc": "Binary exploitation menu"}, {"name": "carbeat", "port": 11000, "ssh": 11022, "category": "pwn", "desc": "Binary exploitation menu"},
@@ -385,15 +390,50 @@ async def api_challenge_toggle(challenge: str, req: Request):
if not found: if not found:
raise HTTPException(404, "Unknown challenge") raise HTTPException(404, "Unknown challenge")
changed = orch.set_challenge_enabled(challenge, enabled) changed = orch.set_challenge_enabled(challenge, enabled)
# The registry is the source of truth, but a team's state.json must also
# carry ports/password/flag for the new challenge before the compose can be
# rendered. reconcile_team_state() fills in anything missing.
try:
orch.reconcile_team_state()
except Exception as e:
raise HTTPException(500, f"Rekonsiliasi state tim gagal: {e}")
# apply to live teams (build/up or stop/remove + receiver restart); # apply to live teams (build/up or stop/remove + receiver restart);
# skip rebuild when the flag didn't actually change # skip rebuild when the flag didn't actually change
if "unchanged" in changed: if "unchanged" in changed:
return {"ok": True, "name": challenge, "enabled": enabled, "applied": [], "unchanged": True} return {"ok": True, "name": challenge, "enabled": enabled, "applied": [], "unchanged": True}
try: # Applying a challenge means a Docker build per team, which takes minutes.
report = await asyncio.to_thread(orch.sync_challenge_runtime, challenge, enabled) # Run it in the background so the admin UI stays responsive, and let the
except Exception as e: # client poll /api/challenges/jobs/<job_id> for the per-team report.
raise HTTPException(500, f"Registry updated tapi runtime gagal: {e}") job_id = f"{challenge}-{'on' if enabled else 'off'}-{int(time.time())}"
return {"ok": True, "name": challenge, "enabled": enabled, "applied": report.get("teams", [])} _TOGGLE_JOBS[job_id] = {
"job": job_id, "name": challenge, "enabled": enabled,
"state": "running", "applied": [], "detail": "queued",
"started": int(time.time()),
}
def _worker():
try:
report = orch.sync_challenge_runtime(challenge, enabled)
_TOGGLE_JOBS[job_id].update(
state="done", applied=report.get("teams", []), detail="complete")
except Exception as e: # surfaced to the client via the job record
_TOGGLE_JOBS[job_id].update(state="error", detail=str(e))
finally:
_TOGGLE_JOBS[job_id]["finished"] = int(time.time())
threading.Thread(target=_worker, name=f"toggle-{job_id}", daemon=True).start()
return {"ok": True, "name": challenge, "enabled": enabled,
"job": job_id, "state": "running"}
@app.get("/api/challenges/jobs/{job_id}")
async def api_challenge_job(job_id: str, req: Request):
"""Poll the result of an async challenge toggle started by PATCH above."""
require_login(req)
job = _TOGGLE_JOBS.get(job_id)
if not job:
raise HTTPException(404, "Unknown job")
return job
@app.get("/api/status") @app.get("/api/status")
async def api_status(req: Request): async def api_status(req: Request):
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Pre-build every challenge's shared image (`services-<name>`) in parallel.
The admin toggle only reuses `image: services-<name>` when that image already
exists locally; otherwise it keeps `build:` and each of the 4 teams rebuilds it
separately (team1-x, team2-x, ...), which wastes minutes and gigabytes. Since a
challenge's image content is identical for every team (the SSH password is
applied at runtime via chpasswd, not baked into the image), one shared build
suffices.
Runs up to --jobs builds at a time and prints a per-challenge PASS/FAIL summary.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
SERVICES = Path("/opt/gemastik18-final/services")
REGISTRY = Path("/opt/gemastik18-final/teams/challenge_registry.json")
def image_exists(name: str) -> bool:
r = subprocess.run(["docker", "image", "inspect", f"services-{name}"],
capture_output=True)
return r.returncode == 0
def build(name: str, force: bool) -> tuple[str, bool, str]:
if not force and image_exists(name):
return name, True, "already built"
d = SERVICES / name
if not (d / "Dockerfile").exists():
return name, False, "no Dockerfile (multi-service challenge)"
t0 = time.time()
# Several challenge Dockerfiles do `echo root:${PASSWORD} | chpasswd`, so an
# empty build-arg makes the RUN step fail. Pass a throwaway value: the real
# per-team password is applied at container start via chpasswd, never baked
# into the image, so this value never matters.
r = subprocess.run(
["docker", "build", "-t", f"services-{name}",
"--build-arg", "PASSWORD=prebuild-placeholder", "."],
cwd=d, capture_output=True, text=True,
)
if r.returncode == 0:
return name, True, f"built in {time.time()-t0:.0f}s"
tail = [l for l in r.stderr.splitlines() if l.strip()][-1:] or ["unknown error"]
return name, False, tail[0][:160]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("names", nargs="*", help="challenge names (default: all in registry)")
ap.add_argument("--jobs", type=int, default=4)
ap.add_argument("--force", action="store_true", help="rebuild even if present")
args = ap.parse_args()
if args.names:
names = args.names
else:
reg = json.loads(REGISTRY.read_text())
names = [c["name"] for c in reg["challenges"]]
print(f"building {len(names)} challenge images with {args.jobs} parallel jobs\n", flush=True)
t0 = time.time()
results: list[tuple[str, bool, str]] = []
with ThreadPoolExecutor(max_workers=args.jobs) as ex:
for res in ex.map(lambda n: build(n, args.force), names):
results.append(res)
mark = "PASS" if res[1] else "FAIL"
print(f"[{mark}] {res[0]:16s} {res[2]}", flush=True)
ok = [r for r in results if r[1]]
bad = [r for r in results if not r[1]]
print(f"\n{len(ok)}/{len(results)} images ready in {(time.time()-t0)/60:.1f} min")
if bad:
print("\nfailures:")
for n, _, why in bad:
print(f" {n}: {why}")
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Set the registry's enabled flags to exactly the given challenge names.
`set_challenge_enabled` only reports a change when the flag actually differs,
so flipping several challenges at once through the API is a long round trip.
This writes the registry directly (the same file the API writes) and leaves
runtime reconciliation to panel/sync_all_challenges.py.
Usage:
python3 panel/set_enabled.py gift-card art fjb
python3 panel/set_enabled.py --clear
"""
import json
import sys
from pathlib import Path
REG = Path("/opt/gemastik18-final/teams/challenge_registry.json")
def main(argv):
reg = json.loads(REG.read_text())
names = [c["name"] for c in reg["challenges"]]
want = set(argv[1:]) if "--clear" not in argv else set()
unknown = want - set(names)
if unknown:
print("unknown challenge(s):", ", ".join(sorted(unknown)))
return 2
for c in reg["challenges"]:
c["enabled"] = c["name"] in want
REG.write_text(json.dumps(reg, indent=2) + "\n")
enabled = [c["name"] for c in reg["challenges"] if c["enabled"]]
print(f"registry now enables {len(enabled)}: {', '.join(enabled)}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+32 -3
View File
@@ -400,9 +400,38 @@ async function toggleChallenge(name, enabled) {
msg.style.color = '#f5c542'; msg.style.color = '#f5c542';
try { try {
const d = await api('/api/challenges/' + encodeURIComponent(name), { method: 'PATCH', body: JSON.stringify({ enabled }) }); const d = await api('/api/challenges/' + encodeURIComponent(name), { method: 'PATCH', body: JSON.stringify({ enabled }) });
msg.textContent = `✓ ${d.enabled ? 'diaktifkan' : 'dinonaktifkan'} (${(d.applied || []).length} tim)`; if (d.unchanged) {
msg.style.color = d.enabled ? '#22c55e' : '#e74c3c'; msg.textContent = `✓ ${d.enabled ? 'diaktifkan' : 'dinonaktifkan'} (sudah sesuai)`;
setTimeout(() => { msg.textContent = ''; loadChMgr(); }, 4000); msg.style.color = '#22c55e';
setTimeout(() => { msg.textContent = ''; loadChMgr(); }, 3000);
return;
}
// Docker build runs on the server; poll the job until it reports per-team results.
if (!d.job) throw new Error('server tidak mengembalikan job id');
const started = Date.now();
const poll = async () => {
const j = await api('/api/challenges/jobs/' + encodeURIComponent(d.job));
if (j.state === 'running') {
const secs = Math.round((Date.now() - started) / 1000);
msg.textContent = `⏳ build ${secs}s…`;
setTimeout(poll, 3000);
return;
}
if (j.state === 'error') {
msg.textContent = '✗ ' + (j.detail || 'gagal');
msg.style.color = '#e74c3c';
loadChMgr();
return;
}
const okCount = (j.applied || []).filter(t => t.ok).length;
const bad = (j.applied || []).filter(t => !t.ok);
msg.textContent = bad.length
? `⚠ ${okCount}/${(j.applied || []).length} tim OK — ${bad.map(t => `team${t.team}: ${(t.detail || '').split('\n').slice(-1)[0]}`).join('; ').slice(0, 120)}`
: `✓ ${d.enabled ? 'diaktifkan' : 'dinonaktifkan'} (${okCount} tim)`;
msg.style.color = bad.length ? '#f5c542' : '#22c55e';
setTimeout(() => { msg.textContent = ''; loadChMgr(); }, 6000);
};
poll();
} catch (e) { } catch (e) {
msg.textContent = '✗ ' + e.message; msg.textContent = '✗ ' + e.message;
msg.style.color = '#e74c3c'; msg.style.color = '#e74c3c';
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Batch-apply the enabled challenge set to every live team, one challenge at a
time, then report per-challenge results.
This is the CLI equivalent of hitting PATCH /api/challenges/<name> for each
registry entry, but it survives panel restarts and reports progress, which
matters because a full sync is N challenges x 4 teams x (docker build ~1-3 min).
Usage:
python3 panel/sync_all_challenges.py # sync registry -> runtime
python3 panel/sync_all_challenges.py --status # just show what is up
"""
from __future__ import annotations
import json
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import teams as orch # noqa: E402
def container_count(name: str) -> int:
out = subprocess.run(
["docker", "ps", "--format", "{{.Names}}"],
capture_output=True, text=True,
).stdout.splitlines()
return sum(1 for n in out if n.startswith(f"{name}_container_team"))
def main() -> int:
if "--status" in sys.argv:
reg = orch.load_registry()
enabled = [c["name"] for c in reg["challenges"] if c.get("enabled")]
print(f"registry enabled ({len(enabled)}): {', '.join(enabled)}\n")
running = 0
for name in enabled:
n = container_count(name)
running += 1 if n else 0
print(f" {name:16s} {n}/4 teams {'OK' if n == 4 else ('PARTIAL' if n else 'MISSING')}")
print(f"\n{running}/{len(enabled)} challenges have at least one container")
return 0
reg = orch.load_registry()
enabled = [c for c in reg["challenges"] if c.get("enabled")]
print(f"syncing {len(enabled)} enabled challenges across live teams\n")
t_all = time.time()
for c in enabled:
name = c["name"]
if container_count(name) == 4:
print(f"== {name}: already up on 4 teams, skipping")
continue
t0 = time.time()
print(f"== {name}: applying (this builds one image, then reuses it per team)", flush=True)
try:
report = orch.sync_challenge_runtime(name, True)
except Exception as e: # a single bad challenge must not abort the batch
print(f" FAILED after {time.time()-t0:.0f}s: {e}", flush=True)
continue
for t in report.get("teams", []):
last = (t.get("detail") or "").strip().splitlines()[-1:] or [""]
mark = "ok" if t.get("ok") else "FAIL"
print(f" team{t.get('team')}: {mark} {last[0][:110]}", flush=True)
print(f" done in {time.time()-t0:.0f}s", flush=True)
print(f"\nall done in {(time.time()-t_all)/60:.1f} min")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+88
View File
@@ -26,6 +26,7 @@ import secrets
import shutil import shutil
import subprocess import subprocess
import sys import sys
import threading
import time import time
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -78,7 +79,80 @@ def set_challenge_enabled(name: str, enabled: bool) -> dict:
raise KeyError(f"Challenge {name} tidak ada di registry") raise KeyError(f"Challenge {name} tidak ada di registry")
# Serializes sync_challenge_runtime(): each run rewrites every team's
# docker-compose.yml and shells out to docker compose in those same dirs.
_SYNC_LOCK = threading.Lock()
def reconcile_team_state() -> list[str]:
"""Make every team's state.json agree with the registry's enabled set.
Needed whenever the registry is edited directly (panel/set_enabled.py) or
a challenge is enabled but a team was offline/stopped while it happened:
state.json must carry ports + a chall password + a flag for every enabled
challenge, otherwise render_team_compose() raises KeyError on ports[name].
Returns a list of human-readable actions taken.
"""
reg = load_registry()
enabled = enabled_challenges()
# rebuild the derived CHALLENGES for fresh creates
global CHALLENGES
CHALLENGES = [(c["name"], c["chall_offset"], c["ssh_offset"]) for c in enabled]
notes: list[str] = []
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"]
dirty = False
st.setdefault("ports", {})
st.setdefault("chall_passwords", {})
st.setdefault("flags", {})
for ch in enabled:
name = ch["name"]
if name not in st["ports"]:
st["ports"][name] = {"chall": 30000 + idx * 1000 + ch["chall_offset"],
"ssh": 30000 + idx * 1000 + ch["ssh_offset"]}
dirty = True
notes.append(f"team{idx}: allocated ports for {name}")
if not st["chall_passwords"].get(name):
st["chall_passwords"][name] = f"chall{idx}_{name}_{secrets.token_hex(4)}"
dirty = True
if not st["flags"].get(name):
flag = f"GEMASTIK18{{TEAM{idx}_{name.upper()}_{secrets.token_hex(6)}}}"
st["flags"][name] = flag
fd = d / "receiver" / "flags"
fd.mkdir(parents=True, exist_ok=True)
(fd / f"{name}.txt").write_text(flag)
dirty = True
notes.append(f"team{idx}: minted flag for {name}")
# make sure the source tree is present for a later `build:`
ts = d / "services" / name
if not ts.exists():
src = SERVICES_SRC / name
if src.exists():
shutil.copytree(src, ts,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".git"))
notes.append(f"team{idx}: copied source for {name}")
if dirty:
sf.write_text(json.dumps(st, indent=2))
return notes
def sync_challenge_runtime(name: str, enabled: bool) -> dict: def sync_challenge_runtime(name: str, enabled: bool) -> dict:
"""Apply one challenge's enabled flag to every live team.
Serialized via _SYNC_LOCK: multiple toggle requests rewrite the same
per-team docker-compose.yml and run docker compose in the same
directories, so concurrent syncs would corrupt each other's output.
"""
with _SYNC_LOCK:
return _sync_challenge_runtime_locked(name, enabled)
def _sync_challenge_runtime_locked(name: str, enabled: bool) -> dict:
"""Apply an enable/disable toggle to every RUNNING team: """Apply an enable/disable toggle to every RUNNING team:
- regenerate that team's compose from the registry (compose_gen) - regenerate that team's compose from the registry (compose_gen)
- for ENABLE: docker compose up -d <name> (builds image first if needed) - for ENABLE: docker compose up -d <name> (builds image first if needed)
@@ -116,6 +190,20 @@ def sync_challenge_runtime(name: str, enabled: bool) -> dict:
name) or f"chall{idx}_{name}_{secrets.token_hex(4)}" name) or f"chall{idx}_{name}_{secrets.token_hex(4)}"
# write state BEFORE rendering (render needs ports[name]) # write state BEFORE rendering (render needs ports[name])
(sf).write_text(json.dumps(st, indent=2)) (sf).write_text(json.dumps(st, indent=2))
# Copy the challenge source into the team's services tree so a
# `build: context: .` resolves (team dirs only hold challenges
# that were enabled at create_team time).
team_svc_src = svc_dir / name
if not team_svc_src.exists():
src = SERVICES_SRC / name
if not src.exists():
raise FileNotFoundError(f"sumber service tidak ada: {src}")
shutil.copytree(src, team_svc_src,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".git"))
# apt-insecure.conf is needed by every challenge build
shared_apt = SERVICES_SRC / "apt-insecure.conf"
if shared_apt.exists() and not (team_svc_src / "apt-insecure.conf").exists():
shutil.copy2(shared_apt, team_svc_src / "apt-insecure.conf")
# regenerate whole compose (so enabled challenge included), # regenerate whole compose (so enabled challenge included),
# then bring up just this service # then bring up just this service
new_text = compose_gen.render_team_compose(idx, st) new_text = compose_gen.render_team_compose(idx, st)
+2 -1
View File
@@ -26,8 +26,9 @@ class Carbeat(Challenge):
def _read_container_flag(self) -> str: def _read_container_flag(self) -> str:
# timeout is mandatory: docker exec can block forever on a saturated container
out = subprocess.run(["docker", "exec", self._CONTAINER, "cat", "/flag.txt"], out = subprocess.run(["docker", "exec", self._CONTAINER, "cat", "/flag.txt"],
capture_output=True, text=True) capture_output=True, text=True, timeout=30)
if out.returncode != 0 or not out.stdout.strip(): if out.returncode != 0 or not out.stdout.strip():
raise FileNotFoundError("Flag not found in container (/flag.txt)") raise FileNotFoundError("Flag not found in container (/flag.txt)")
return out.stdout.strip() return out.stdout.strip()
+8 -2
View File
@@ -14,8 +14,14 @@ class Phew(Challenge):
_HEX_RE = re.compile(r'^[0-9a-fA-F]+$') _HEX_RE = re.compile(r'^[0-9a-fA-F]+$')
def _read_container_flag(self) -> str: def _read_container_flag(self) -> str:
out = subprocess.run(["docker", "exec", self._CONTAINER, "cat", "/flag.txt"], # NB: a timeout is mandatory here. `docker exec` against a container
capture_output=True, text=True) # whose process table is saturated (accumulated chall.py zombies) can
# block forever and take the whole SLA check loop down with it.
try:
out = subprocess.run(["docker", "exec", self._CONTAINER, "cat", "/flag.txt"],
capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired:
raise TimeoutError("docker exec cat /flag.txt timed out (container overloaded?)")
if out.returncode != 0 or not out.stdout.strip(): if out.returncode != 0 or not out.stdout.strip():
raise FileNotFoundError("Flag not found in container (/flag.txt)") raise FileNotFoundError("Flag not found in container (/flag.txt)")
return out.stdout.strip() return out.stdout.strip()
+2 -1
View File
@@ -52,7 +52,8 @@ class Poke(Challenge):
container_flag = subprocess.run( container_flag = subprocess.run(
["docker", "exec", "poke_container", "cat", "/flag.txt"], ["docker", "exec", "poke_container", "cat", "/flag.txt"],
capture_output=True, capture_output=True,
text=True text=True,
timeout=30
).stdout.strip() ).stdout.strip()
assert host_flag == container_flag, 'Flag mismatch between host and container' assert host_flag == container_flag, 'Flag mismatch between host and container'
+2 -1
View File
@@ -15,9 +15,10 @@ class Sheesh(Challenge):
_HEX_RE = re.compile(r'^[0-9a-fA-F]+$') _HEX_RE = re.compile(r'^[0-9a-fA-F]+$')
def _read_container_flag(self) -> str: def _read_container_flag(self) -> str:
# timeout is mandatory: docker exec can block forever on a saturated container
out = subprocess.run( out = subprocess.run(
["docker", "exec", self._CONTAINER, "cat", "/flag.txt"], ["docker", "exec", self._CONTAINER, "cat", "/flag.txt"],
capture_output=True, text=True capture_output=True, text=True, timeout=30
) )
if out.returncode != 0 or not out.stdout.strip(): if out.returncode != 0 or not out.stdout.strip():
raise FileNotFoundError("Flag not found in container (/flag.txt)") raise FileNotFoundError("Flag not found in container (/flag.txt)")
+1 -1
View File
@@ -32,7 +32,7 @@ class Warmup(Challenge):
host_flag = f.read().strip() host_flag = f.read().strip()
container_flag = subprocess.run([ container_flag = subprocess.run([
"docker", "exec", os.environ.get("CHALLENGE_CONTAINER_WARMUP", "warmup_container"), "cat", "/flag.txt" "docker", "exec", os.environ.get("CHALLENGE_CONTAINER_WARMUP", "warmup_container"), "cat", "/flag.txt"
], capture_output=True, text=True).stdout.strip() ], capture_output=True, text=True, timeout=30).stdout.strip()
assert host_flag == container_flag, 'Flag mismatch between host and container' assert host_flag == container_flag, 'Flag mismatch between host and container'
self.logger.info('Check passed for warmup') self.logger.info('Check passed for warmup')
+72 -88
View File
@@ -1,10 +1,17 @@
"""SLA checkers for GEMASTIK XVII challenges (imported from """SLA checkers for GEMASTIK XVII challenges (imported from
github.com/vidner/gemastik-xvii-final — no upstream receiver was provided). github.com/vidner/gemastik-xvii-final — no upstream receiver was provided).
Each checker validates liveness + flag presence in the container. Protocols: Each checker validates liveness (+ flag presence where the flag is a file) with
- TCP/netcat : asmr, bit-canvas, go-green (xinetd banner) & ticketer (socat) STRICT timeouts so a wedged service can never hang the receiver's check loop.
- HTTP GET : anti-alchemy, fjb, gift-card, gift-voucher, gleam-drive,
kode-viewer, more-less, tempest-poc Protocol classes:
- WEB : HTTP GET on the challenge port
- TCP : socat/xinetd line service — connect and expect a prompt/banner
- WEB+FLAG: WEB plus the flag must be mounted inside the container
Env-var convention: systemd Environment= keys are normalized to underscores
(CHALLENGE_PORT_GIFT_CARD for the challenge "gift-card"), so the lookup helper
does the same normalization.
""" """
import os import os
import socket import socket
@@ -15,6 +22,16 @@ import requests
from .Challenge import Challenge from .Challenge import Challenge
def _key(name: str) -> str:
return name.upper().replace("-", "_")
def _container(challenge: str) -> str:
return os.environ.get(
f"CHALLENGE_CONTAINER_{_key(challenge)}", f"{challenge}_container"
)
def _docker_exec(container: str, *args, timeout: int = 10): def _docker_exec(container: str, *args, timeout: int = 10):
try: try:
return subprocess.run(["docker", "exec", container, *args], return subprocess.run(["docker", "exec", container, *args],
@@ -24,35 +41,54 @@ def _docker_exec(container: str, *args, timeout: int = 10):
def _flag_in_container(container: str, path: str = "/flag.txt") -> bool: def _flag_in_container(container: str, path: str = "/flag.txt") -> bool:
r = _docker_exec(container, "sh", "-c", f"test -f {path} && cat {path} || echo MISSING") r = _docker_exec(container, "sh", "-c", f"test -s {path} && echo FLAG_OK || echo MISSING")
return bool(r and "MISSING" not in (r.stdout or "") and r.returncode == 0) return bool(r and "FLAG_OK" in (r.stdout or ""))
def _tcp_banner(port: int, timeout: float = 4.0, expect: bytes = None) -> bool: def _web_alive(port: int, timeout: float = 5.0) -> bool:
"""Any HTTP response (even 4xx/5xx) proves the listener is up."""
try: try:
s = socket.create_connection(("127.0.0.1", port), timeout=timeout) r = requests.get(f"http://127.0.0.1:{port}/", timeout=timeout, allow_redirects=False)
s.settimeout(timeout) return r.status_code < 600
data = s.recv(256) except requests.exceptions.RequestException:
s.close() return False
if expect:
return expect.lower() in data.lower()
return len(data) > 0
except Exception: except Exception:
return False return False
def _tcp_alive(port: int, timeout: float = 5.0, send: bytes = None) -> bool:
"""Connect to a line service and read a prompt/banner (or survive silence).
Some socat services wait for input before greeting, so a successful connect
with no data is also treated as alive; a refused connection is not.
"""
try:
s = socket.create_connection(("127.0.0.1", port), timeout=timeout)
except Exception:
return False
try:
s.settimeout(timeout)
if send:
s.sendall(send)
try:
data = s.recv(256)
except socket.timeout:
# connected but silent — service is accepting connections
return True
return True if data is not None else True
finally:
try:
s.close()
except Exception:
pass
class AntiAlchemy(Challenge): class AntiAlchemy(Challenge):
flag_location = "flags/anti-alchemy.txt" flag_location = "flags/anti-alchemy.txt"
history_location = "history/anti-alchemy.txt" history_location = "history/anti-alchemy.txt"
def check(self): def check(self):
try: return _web_alive(self.port) and _flag_in_container(_container("anti-alchemy"))
url = f"http://localhost:{self.port}/"
r = requests.get(url, timeout=6)
assert r.status_code in (200, 302, 500) or len(r.text) > 0
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_ANTI_ALCHEMY", "anti-alchemy_container"))
except Exception:
return False
class Asmr(Challenge): class Asmr(Challenge):
@@ -60,10 +96,7 @@ class Asmr(Challenge):
history_location = "history/asmr.txt" history_location = "history/asmr.txt"
def check(self): def check(self):
try: return _tcp_alive(self.port)
return _tcp_banner(self.port, expect=None)
except Exception:
return False
class BitCanvas(Challenge): class BitCanvas(Challenge):
@@ -71,10 +104,7 @@ class BitCanvas(Challenge):
history_location = "history/bit-canvas.txt" history_location = "history/bit-canvas.txt"
def check(self): def check(self):
try: return _tcp_alive(self.port)
return _tcp_banner(self.port, expect=None)
except Exception:
return False
class Fjb(Challenge): class Fjb(Challenge):
@@ -82,43 +112,27 @@ class Fjb(Challenge):
history_location = "history/fjb.txt" history_location = "history/fjb.txt"
def check(self): def check(self):
try: return _web_alive(self.port) and _flag_in_container(_container("fjb"))
url = f"http://localhost:{self.port}/"
r = requests.get(url, timeout=6)
assert r.status_code < 500
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_FJB", "fjb_container"))
except Exception:
return False
class GiftCard(Challenge): class GiftCard(Challenge):
"""socat TCP line service (python main.py on :5000), NOT http."""
flag_location = "flags/gift-card.txt" flag_location = "flags/gift-card.txt"
history_location = "history/gift-card.txt" history_location = "history/gift-card.txt"
def check(self): def check(self):
try: return _tcp_alive(self.port, send=b"1\n") and _flag_in_container(
url = f"http://localhost:{self.port}/" _container("gift-card"), "/ctf/gift-card/flag.txt")
r = requests.get(url, timeout=6)
assert r.status_code < 500
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_GIFT_CARD", "gift-card_container"),
"/ctf/gift-card/flag.txt")
except Exception:
return False
class GiftVoucher(Challenge): class GiftVoucher(Challenge):
"""socat TCP line service, NOT http."""
flag_location = "flags/gift-voucher.txt" flag_location = "flags/gift-voucher.txt"
history_location = "history/gift-voucher.txt" history_location = "history/gift-voucher.txt"
def check(self): def check(self):
try: return _tcp_alive(self.port, send=b"1\n") and _flag_in_container(
url = f"http://localhost:{self.port}/" _container("gift-voucher"), "/ctf/gift-voucher/flag.txt")
r = requests.get(url, timeout=6)
assert r.status_code < 500
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_GIFT_VOUCHER", "gift-voucher_container"),
"/ctf/gift-voucher/flag.txt")
except Exception:
return False
class GleamDrive(Challenge): class GleamDrive(Challenge):
@@ -126,13 +140,7 @@ class GleamDrive(Challenge):
history_location = "history/gleam-drive.txt" history_location = "history/gleam-drive.txt"
def check(self): def check(self):
try: return _web_alive(self.port) and _flag_in_container(_container("gleam-drive"))
url = f"http://localhost:{self.port}/"
r = requests.get(url, timeout=6)
assert r.status_code < 500
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_GLEAM_DRIVE", "gleam-drive_container"))
except Exception:
return False
class GoGreen(Challenge): class GoGreen(Challenge):
@@ -140,10 +148,7 @@ class GoGreen(Challenge):
history_location = "history/go-green.txt" history_location = "history/go-green.txt"
def check(self): def check(self):
try: return _tcp_alive(self.port)
return _tcp_banner(self.port, expect=None)
except Exception:
return False
class KodeViewer(Challenge): class KodeViewer(Challenge):
@@ -151,13 +156,7 @@ class KodeViewer(Challenge):
history_location = "history/kode-viewer.txt" history_location = "history/kode-viewer.txt"
def check(self): def check(self):
try: return _web_alive(self.port) and _flag_in_container(_container("kode-viewer"))
url = f"http://localhost:{self.port}/"
r = requests.get(url, timeout=6)
assert r.status_code < 500
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_KODE_VIEWER", "kode-viewer_container"))
except Exception:
return False
class MoreLess(Challenge): class MoreLess(Challenge):
@@ -165,13 +164,7 @@ class MoreLess(Challenge):
history_location = "history/more-less.txt" history_location = "history/more-less.txt"
def check(self): def check(self):
try: return _web_alive(self.port) and _flag_in_container(_container("more-less"))
url = f"http://localhost:{self.port}/"
r = requests.get(url, timeout=6)
assert r.status_code < 500
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_MORE_LESS", "more-less_container"))
except Exception:
return False
class TempestPoc(Challenge): class TempestPoc(Challenge):
@@ -179,13 +172,7 @@ class TempestPoc(Challenge):
history_location = "history/tempest-poc.txt" history_location = "history/tempest-poc.txt"
def check(self): def check(self):
try: return _web_alive(self.port) and _flag_in_container(_container("tempest-poc"))
url = f"http://localhost:{self.port}/"
r = requests.get(url, timeout=6)
assert r.status_code < 500
return _flag_in_container(os.environ.get("CHALLENGE_CONTAINER_TEMPEST_POC", "tempest-poc_container"))
except Exception:
return False
class Ticketer(Challenge): class Ticketer(Challenge):
@@ -193,7 +180,4 @@ class Ticketer(Challenge):
history_location = "history/ticketer.txt" history_location = "history/ticketer.txt"
def check(self): def check(self):
try: return _tcp_alive(self.port)
return _tcp_banner(self.port, expect=None)
except Exception:
return False
+2 -2
View File
@@ -5,8 +5,8 @@ ARG PASSWORD
WORKDIR /opt WORKDIR /opt
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
RUN apt-get -o Acquire::AllowInsecureRepositories=true update RUN apt-get -o Acquire::AllowInsecureRepositories=true update && \
RUN apt-get -y --allow-unauthenticated install -y nano openssh-server \ apt-get -y --allow-unauthenticated install -y nano openssh-server \
gcc python-dev python3-dev libgmp3-dev curl gcc python-dev python3-dev libgmp3-dev curl
RUN echo root:${PASSWORD} | chpasswd RUN echo root:${PASSWORD} | chpasswd
+1 -2
View File
@@ -11,10 +11,9 @@ services:
- ../receiver/flags/burvesigner.txt:/flag.txt:ro - ../receiver/flags/burvesigner.txt:/flag.txt:ro
- ../utils/bashrc:/root/.bashrc:ro - ../utils/bashrc:/root/.bashrc:ro
- ../utils/preexec.sh:/root/.preexec.sh:ro - ../utils/preexec.sh:/root/.preexec.sh:ro
- ../receiver/files/burvesigner.priv:/priv.data:ro
ports: ports:
- "14000:80" - "14000:80"
- "14022:22" - "14022:22"
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
volumes:
- ../receiver/files/burvesigner.priv:/priv.data:ro
+16 -3
View File
@@ -7,15 +7,28 @@ RUN corepack enable
WORKDIR /app WORKDIR /app
COPY ./frontend/package.json ./frontend/pnpm-lock.yaml /app/ # pnpm >=10 blocks dependency lifecycle scripts by default and then FAILS the
RUN pnpm install --frozen-lockfile # install with ERR_PNPM_IGNORED_BUILDS (pnpm 12 still does this even with
# onlyBuiltDependencies in pnpm-workspace.yaml, and --no-ignore-scripts does not
# suppress the error either). esbuild's postinstall places the platform binary
# the Vite build needs, so approve pending build scripts non-interactively.
# --frozen-lockfile is dropped because the added config keys change the lockfile
# hash and would fail the install outright.
COPY ./frontend/pnpm-workspace.yaml ./frontend/package.json ./frontend/pnpm-lock.yaml /app/
RUN pnpm install --ignore-scripts \
&& pnpm approve-builds --all \
&& pnpm rebuild
FROM base AS build FROM base AS build
COPY ./frontend/ /app/ COPY ./frontend/ /app/
RUN pnpm run build RUN pnpm run build
FROM ghcr.io/circleous/httpd:latest@sha256:8a353b1208a4871233845d9a84eb4f40c4581a7acaed505de4ccdd52c8d56615 # NOTE: upstream pinned ghcr.io/circleous/httpd, but ghcr.io is not
# anonymously pullable from this host ("failed to fetch oauth token: denied").
# The bundled httpd.conf only uses stock Apache 2.4 modules, so the official
# Docker Hub httpd:2.4 image is a drop-in replacement.
FROM httpd:2.4
WORKDIR /app WORKDIR /app
@@ -0,0 +1,8 @@
# pnpm 12 (shipped by corepack on node:20) blocks dependency lifecycle scripts
# by default and then FAILS the install with ERR_PNPM_IGNORED_BUILDS. Neither
# onlyBuiltDependencies nor --no-ignore-scripts suppresses that error in pnpm 12.
# The documented opt-out is `strictIgnoredBuiltDependencies: false`; esbuild's
# postinstall is what places the platform binary the Vite build needs.
strictIgnoredBuiltDependencies: false
allowBuilds:
esbuild: true
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim-buster FROM python:3.11-slim-bookworm
ARG PASSWORD ARG PASSWORD
+2 -2
View File
@@ -5,8 +5,8 @@ ARG PASSWORD
WORKDIR /opt WORKDIR /opt
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
RUN apt-get -o Acquire::AllowInsecureRepositories=true update RUN apt-get -o Acquire::AllowInsecureRepositories=true update && \
RUN apt-get -y --allow-unauthenticated install -y nano openssh-server \ apt-get -y --allow-unauthenticated install -y nano openssh-server \
gcc curl gcc curl
RUN echo root:${PASSWORD} | chpasswd RUN echo root:${PASSWORD} | chpasswd
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-slim-bookworm FROM node:20-bookworm-slim
ARG PASSWORD ARG PASSWORD
+2 -2
View File
@@ -5,8 +5,8 @@ ARG PASSWORD
WORKDIR /opt WORKDIR /opt
COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure COPY apt-insecure.conf /etc/apt/apt.conf.d/99gemastik-insecure
RUN apt-get -o Acquire::AllowInsecureRepositories=true update RUN apt-get -o Acquire::AllowInsecureRepositories=true update && \
RUN apt-get -y --allow-unauthenticated install -y nano openssh-server curl apt-get -y --allow-unauthenticated install -y nano openssh-server curl
RUN echo root:${PASSWORD} | chpasswd RUN echo root:${PASSWORD} | chpasswd
RUN echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config RUN echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-slim-bookworm FROM node:20-bookworm-slim
ARG PASSWORD ARG PASSWORD