Compare commits
3
Commits
c6fd9ec268
...
ef385c3397
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef385c3397 | ||
|
|
6d1ede8c2b | ||
|
|
d4c741926d |
No files matched your search
+29
-5
@@ -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)
|
||||||
|
|||||||
Executable
+54
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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))
|
||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,4 +1,4 @@
|
|||||||
FROM python:3.11-slim-buster
|
FROM python:3.11-slim-bookworm
|
||||||
|
|
||||||
ARG PASSWORD
|
ARG PASSWORD
|
||||||
|
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
FROM node:20-slim-bookworm
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
ARG PASSWORD
|
ARG PASSWORD
|
||||||
|
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
FROM node:20-slim-bookworm
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
ARG PASSWORD
|
ARG PASSWORD
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user