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.
This commit is contained in:
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))
|
||||||
@@ -390,6 +390,13 @@ 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:
|
||||||
|
|||||||
@@ -84,6 +84,63 @@ def set_challenge_enabled(name: str, enabled: bool) -> dict:
|
|||||||
_SYNC_LOCK = threading.Lock()
|
_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.
|
"""Apply one challenge's enabled flag to every live team.
|
||||||
|
|
||||||
|
|||||||
@@ -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
-3
@@ -7,9 +7,17 @@ RUN corepack enable
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY ./frontend/pnpm-workspace.yaml /app/
|
# pnpm >=10 blocks dependency lifecycle scripts by default and then FAILS the
|
||||||
COPY ./frontend/package.json ./frontend/pnpm-lock.yaml /app/
|
# install with ERR_PNPM_IGNORED_BUILDS (pnpm 12 still does this even with
|
||||||
RUN pnpm install --frozen-lockfile
|
# 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
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"pnpm": {
|
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"esbuild",
|
|
||||||
"@scarf/scarf",
|
|
||||||
"sharp",
|
|
||||||
"unrs-resolver"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user