fix(ssh): per-challenge SSH login + phew buffering/leak/timeout

Passwords failed on 10/16 challenges while state.json looked correct:
- only the 6 native GEMASTIK XVIII images provision 'ctfuser'; every imported
  XVI/XVII image does 'echo root:${PASSWORD} | chpasswd' and logs in as root.
  set_ssh_passwords() hardcoded ctfuser, so chpasswd set a password on an
  account nobody uses -> 'Permission denied' everywhere.
  Registry gains a per-challenge 'ssh_user'; chpasswd now targets the real
  login (and ctfuser/ctf when present) and reports failures loudly.
- phew checker: chall.py block-buffers stdout through the docker exec pipe
  (PYTHONUNBUFFERED now set) and leaks chall.py inside the container on
  timeout (26 orphans, container saturated) -> reaps the whole exec process
  group. Startup does a fresh Pailier keygen (~12 s) so crypto reads need
  _CRYPTO_TIMEOUT, not the 5 s prompt default.

Adds panel/verify_ssh_creds.py (proves the state->container binding from
inside via a real login), audit_ssh_users.sh, reset_runtime.sh.
This commit is contained in:
Cyrene
2026-09-26 14:40:10 +08:00
parent ef385c3397
commit ae50acfe40
33 changed files with 1398 additions and 289 deletions
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Bring every team's containers in line with the registry's enabled set.
#
# For each team: re-render the compose from the registry, then `up -d`. Because
# the shared `services-<name>` images already exist, this is a start, not a
# rebuild, so it is fast. Containers of challenges that are no longer enabled are
# removed by `up --remove-orphans`.
set -uo pipefail
cd /opt/gemastik18-final/panel
python3 - <<'PY'
import json, sys
sys.path.insert(0, '.')
import teams as orch, compose_gen
orch.reconcile_team_state()
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 rendered")
PY
for i in 1 2 3 4; do
cd "/opt/gemastik18-final/teams/team$i/services"
echo "--- team$i ---"
docker compose -p "team$i" up -d --remove-orphans 2>&1 | tail -2
done
echo "=== result ==="
docker ps --format '{{.Names}}' | grep -c '_container_team'
df -h / | tail -1
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Which SSH user does each challenge's image actually provision?
# The imported XVI/XVII challenges do NOT all use `ctfuser`: art uses `root`,
# anti-alchemy uses `ctf`. set_ssh_passwords() only does
# `echo 'ctfuser:<pw>' | chpasswd`, so for those images it either fails or sets
# a password on an account nobody logs in as -> "Permission denied" for every
# team, while state.json looks perfectly correct.
set -uo pipefail
cd /opt/gemastik18-final
printf "%-18s %s\n" CHALLENGE "Dockerfile user provisioning"
for d in services/*/; do
name=$(basename "$d")
[ -f "$d/Dockerfile" ] || continue
line=$(grep -hE 'chpasswd|useradd|adduser' "$d/Dockerfile" 2>/dev/null | head -2 | tr '\n' ';' | cut -c1-110)
printf "%-18s %s\n" "$name" "${line:-<none>}"
done
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Delete the teams named as args, one at a time, via the panel API.
# Each per-team delete runs `docker compose down` for every challenge, which
# takes minutes for a 16-challenge team — so never do this in a foreground
# call that has to finish inside one tool timeout.
# Usage: delete_teams.sh <idx> [idx...]
set -uo pipefail
BASE=http://127.0.0.1:18081
JAR=/tmp/ejc
U=$(grep -oP '^PANEL_ADMIN_USER=\K.*' /opt/gemastik18-final/panel/.env)
P=$(grep -oP '^PANEL_ADMIN_PASS=\K.*' /opt/gemastik18-final/panel/.env)
curl -sS -c "$JAR" -X POST -H 'Content-Type: application/json' \
-d "{\"user\":\"$U\",\"pass\":\"$P\"}" "${BASE}/api/login" >/dev/null
for i in "$@"; do
echo "=== $(date -Is) delete team${i} ==="
curl -sS -b "$JAR" -X DELETE -H 'Content-Type: application/json' \
-d '{"purge_scores":true}' "${BASE}/api/teams/${i}" -w '\nHTTP %{http_code}\n'
done
echo "=== done ==="
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Replace hardcoded `localhost` URLs in the imported XVI checkers with self.url().
Why: the imported checkers connect to `http://localhost:{self.port}`. The
receiver does run on the same host as the published team ports, so this happens
to work, but it is fragile (breaks the moment a receiver runs in a container or
the port is bound to a specific interface). Challenge.url() builds the URL from
self.host (default 127.0.0.1, overridable with RECEIVER_HOST) plus self.port.
Idempotent; rewrites only the f-string form, leaving class-level constants that
pin a *fixed* port (GemasNotes/Pasta/S3) alone — those are handled separately.
"""
import re
import sys
from pathlib import Path
BASE = Path("/opt/gemastik18-final/receiver/challenges/xvi")
# f"http://localhost:{self.port}/path/{expr}" -> self.url(f"/path/{expr}")
# The leading f is part of the literal being matched and must be consumed.
PAT = re.compile(
r"""f?(?P<q>["'])http://localhost:\{self\.port\}(?P<path>/[^"']*)?(?P=q)"""
)
def repl(m: "re.Match[str]") -> str:
path = m.group("path") or ""
if not path:
return "self.url()"
# the path may itself contain {expr} placeholders — keep them as an f-string
return f"self.url(f{path!r})"
def main() -> int:
changed = []
for p in sorted(BASE.glob("*.py")):
if p.name in ("Challenge.py", "config.py", "__init__.py"):
continue
src = p.read_text()
if "localhost:{self.port}" not in src:
continue
new = PAT.sub(repl, src)
if new != src:
p.write_text(new)
changed.append(p.name)
print("rewritten:", ", ".join(changed) if changed else "(none)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+12
View File
@@ -14,6 +14,14 @@ from pathlib import Path
TEAMS_DIR = Path("/opt/gemastik18-final/teams")
RECEIVER_VENV = "/opt/gemastik18-final/receiver/.venv/bin/python"
UNIT_DIR = Path("/etc/systemd/system")
REGISTRY_PATH = TEAMS_DIR / "challenge_registry.json"
def load_registry() -> dict:
try:
return json.loads(REGISTRY_PATH.read_text())
except Exception:
return {"sets": {}, "challenges": []}
def write_unit(idx: int, st: dict):
port = st["ports"]["receiver"]
@@ -23,12 +31,16 @@ def write_unit(idx: int, st: dict):
# 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.
# Same normalization applies to CHALLENGE_SCHEME_<NAME>.
schemes = {c["name"]: c.get("scheme") for c in load_registry().get("challenges", [])}
for ch in st["ports"]:
if ch in ("receiver", "panel"):
continue
key = ch.upper().replace("-", "_")
env[f"CHALLENGE_PORT_{key}"] = str(st["ports"][ch]["chall"])
env[f"CHALLENGE_CONTAINER_{key}"] = f"{ch}_container_team{idx}"
if schemes.get(ch):
env[f"CHALLENGE_SCHEME_{key}"] = schemes[ch]
# SSH passwords: checker Challenge.credentials() reads PASSWORD_<self.port>
# where self.port is the team challenge port.
pwd = st.get("chall_passwords", {}).get(ch)
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Inspect per-team port footprint: UFW rules in the 3xxxx/4xxxx range and
# docker volumes/networks named after a team. Read-only.
set -uo pipefail
echo "=== UFW rules matching 3xxxx/4xxxx ==="
ufw status numbered 2>/dev/null | grep -E '\b(3[0-9]{4}|4[0-9]{4})/tcp' || echo "(none)"
echo
echo "=== docker networks team* ==="
docker network ls --format '{{.Name}}' | grep -i team || echo "(none)"
echo
echo "=== docker volumes team* ==="
docker volume ls --format '{{.Name}}' | grep -i team || echo "(none)"
echo
echo "=== all volumes ==="
docker volume ls --format '{{.Name}}' | head -40
+57 -1
View File
@@ -9,6 +9,8 @@ import json
import time
import asyncio
import threading
import subprocess
import sys
import httpx
from pathlib import Path
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect
@@ -530,6 +532,7 @@ async def api_teams_set(req: Request):
labels = data.get("labels") or {} # { "1": "Tim Satu", ... }
domains = data.get("domains") or {} # { "1": "mycustom", ... }
created = []
ufw = []
for i in range(1, n + 1):
td = orch.TEAMS_DIR / f"team{i}"
if not td.exists():
@@ -537,6 +540,9 @@ async def api_teams_set(req: Request):
dom = domains.get(str(i)) or domains.get(i) or None
st = orch.create_team(i, label, domain=dom)
created.append(st["index"])
# UFW defaults to deny(incoming) on this host: without these rules
# the team's challenge/SSH/receiver ports are silently blackholed.
ufw.append(orch.sync_team_ufw(i))
else:
# team exists: apply any label/domain overrides
label = labels.get(str(i)) or labels.get(i)
@@ -544,7 +550,7 @@ async def api_teams_set(req: Request):
if label or dom:
orch.update_team(i, label=label, domain=dom)
orch.ensure_team_domains()
return {"created": created, "total": len(orch.list_teams())}
return {"created": created, "total": len(orch.list_teams()), "ufw": ufw}
@app.put("/api/teams/{idx}")
async def api_team_update(idx: int, req: Request):
@@ -559,6 +565,56 @@ async def api_team_update(idx: int, req: Request):
except FileNotFoundError as e:
raise HTTPException(404, str(e))
@app.delete("/api/teams/{idx}")
async def api_team_delete(idx: int, req: Request):
"""Permanently delete ONE team: containers, network, receiver unit, ports,
directory, score records and its Traefik domain.
Body: {"purge_scores": true} (default) — set false to keep the team's
leaderboard/points history. Destructive and irreversible, so the UI gates
it behind a confirm dialog.
"""
require_login(req)
raw = {}
try:
raw = await req.json()
except Exception:
pass # DELETE with no body is fine
if not (orch.TEAMS_DIR / f"team{idx}" / "state.json").exists():
raise HTTPException(404, f"Team {idx} not found")
try:
# compose down for 16 services takes a while — keep the event loop free
result = await asyncio.to_thread(orch.delete_team, idx,
bool(raw.get("purge_scores", True)))
# refresh the remaining teams' generated artifacts (receiver main.py,
# systemd units) so nothing points at the deleted team
subprocess.run([sys.executable, str(orch.BASE / "panel" / "gen_receiver_services.py"), "start"],
check=False, capture_output=True)
return result
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/api/teams/{idx}/ufw")
async def api_team_ufw(idx: int, req: Request):
"""Reconcile UFW rules for a team's port block.
UFW defaults to deny(incoming) on this host, so a team whose ports were
never opened is blackholed. Use this after creating a team out-of-band, or
to close the ports of a team you just deleted by hand.
Body: {"remove": true} deletes the rules instead.
"""
require_login(req)
raw = {}
try:
raw = await req.json()
except Exception:
pass
return await asyncio.to_thread(orch.sync_team_ufw, idx, bool(raw.get("remove", False)))
@app.post("/api/teams/start")
async def api_teams_start(req: Request):
require_login(req)
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Remove containers + images for challenges that are no longer enabled.
#
# Why: the platform can host 28 challenges but the images are large (1.2 GB for
# pasta alone) and the host disk is 79 GB. Keeping every image resident filled
# it to 98% and started failing builds. Disabled challenges keep their source in
# services/ and can be re-enabled at any time — only the runtime artifacts go.
set -uo pipefail
cd /opt/gemastik18-final/panel
ENABLED=$(python3 - <<'PY'
import sys
sys.path.insert(0, '.')
import teams
print(" ".join(c["name"] for c in teams.enabled_challenges()))
PY
)
echo "enabled: $ENABLED"
echo "--- stopping containers of disabled challenges ---"
for name in $(docker ps -a --format '{{.Names}}' | grep '_container_team' || true); do
base=${name%%_container_team*}
keep=0
for e in $ENABLED; do
[ "$base" = "$e" ] && keep=1
done
[ "$keep" = "0" ] && docker rm -f "$name" >/dev/null 2>&1 && echo "removed container $name"
done
echo "--- removing images of disabled challenges ---"
for img in $(docker images --format '{{.Repository}}' | grep -E '^(services-|team[0-9]+-)' || true); do
base=${img#services-}
base=${base#team[0-9]-}
# only challenge-shaped names (services-blogpost, team2-art, ...)
case "$base" in
blogpost|carbeat|cdn|phew|sheesh|warmup|art|xl|pasta|gemas-fetcher|s3|crawlback|back-to-basic|anti-alchemy|asmr|bit-canvas|fjb|gift-card|gift-voucher|gleam-drive|go-green|kode-viewer|more-less|ticketer|hirnfick|burvesigner|back-to-basic|gemas-notes|tempest-poc) ;;
*) continue ;;
esac
keep=0
for e in $ENABLED; do
[ "$base" = "$e" ] && keep=1
done
[ "$keep" = "0" ] && docker rmi "$img" >/dev/null 2>&1 && echo "removed image $img"
done
echo "--- done ---"
docker images --format '{{.Repository}}' | grep -c '^services-' || true
df -h / | tail -1
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# FULL reset of the runtime — keeps ONLY the shared challenge images.
#
# Removes: every team container, every team docker network, every anonymous/
# named volume, every per-team receiver systemd unit, and all team directories
# (state, flags, credentials, compose). KEEPS: services-* images (the
# challenges themselves), the panel, the global receiver, the challenge sources
# in services/, and the registry.
#
# This is the "purge everything except the challenges" path the organiser asked
# for after passwords drifted: fresh containers get fresh /etc/shadow state, so
# no stale credential can survive.
set -uo pipefail
BASE=/opt/gemastik18-final
TEAMS=$BASE/teams
echo "=== [1/6] stop per-team receivers + remove units ==="
for u in $(systemctl list-unit-files 'gemastik-receiver-team*.service' 2>/dev/null \
| awk '/gemastik-receiver-team/{print $1}'); do
systemctl disable --now "$u" >/dev/null 2>&1
rm -f "/etc/systemd/system/$u"
echo " removed $u"
done
systemctl daemon-reload
echo "=== [2/6] compose down for every team (before deleting dirs) ==="
for d in "$TEAMS"/team*/services; do
[ -d "$d" ] || continue
idx=$(basename "$(dirname "$d")")
echo " compose down $idx"
(cd "$d" && docker compose -p "$idx" -f docker-compose.yml down -v --remove-orphans 2>&1 | tail -1)
done
echo "=== [3/6] force-remove any surviving team containers ==="
left=$(docker ps -aq --filter 'name=_container_team' | wc -l)
echo " found $left"
[ "$left" -gt 0 ] && docker rm -f $(docker ps -aq --filter 'name=_container_team') >/dev/null 2>&1
echo "=== [4/6] remove team networks + stray volumes ==="
for n in $(docker network ls --format '{{.Name}}' | grep -E '^team[0-9]+_default$' || true); do
docker network rm "$n" >/dev/null 2>&1 && echo " network $n"
done
# anonymous + team-scoped volumes (challenge DB state lives here)
anon=$(docker volume ls -q --filter dangling=true | wc -l)
echo " dangling volumes: $anon"
[ "$anon" -gt 0 ] && docker volume prune -f >/dev/null 2>&1 && echo " pruned"
for v in $(docker volume ls --format '{{.Name}}' | grep -E '^team[0-9]+' || true); do
docker volume rm "$v" >/dev/null 2>&1 && echo " volume $v"
done
echo "=== [5/6] delete team dirs + ledgers ==="
rm -rf "$TEAMS"/team*
rm -f "$TEAMS"/leaderboard.json "$TEAMS"/points.json "$TEAMS"/attacks.json
echo " team dirs now: $(ls -d "$TEAMS"/team* 2>/dev/null | wc -l)"
echo "=== [6/6] drop team domains from Traefik ==="
cd "$BASE/panel" && python3 -c "
import sys; sys.path.insert(0,'.')
import teams
print(' ', teams.ensure_team_domains())
"
# the platform-level receiver is separate and must keep running
systemctl restart gemastik-panel 2>/dev/null
echo " panel: $(systemctl is-active gemastik-panel)"
echo "=== done ==="
docker ps --format '{{.Names}}' | grep -c '_container_team' || echo " team containers: 0"
docker images --format '{{.Repository}}' | grep -c '^services-' || true
df -h / | tail -1
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Probe each team receiver's /check/<challenge> directly and report SLA.
Probes are SEQUENTIAL per team on purpose: the team receivers are sync Flask
apps, so 8 concurrent /check requests make them time out and report false
failures. Keep max_workers=1. This is still far faster than the panel's
/api/scoreboard, which probes every team on one shared refresher thread.
python3 panel/sla_probe.py # all teams
python3 panel/sla_probe.py 1 2 # specific teams
"""
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import teams as orch
def check(recv_port, au, ap, name):
url = f"http://127.0.0.1:{recv_port}/check/{name}"
tok = base64.b64encode(f"{au}:{ap}".encode()).decode()
req = urllib.request.Request(url, headers={"Authorization": f"Basic {tok}"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
body = json.loads(r.read().decode())
return name, bool(body.get("success")), ""
except urllib.error.HTTPError as e:
return name, False, f"HTTP {e.code}"
except Exception as e:
return name, False, str(e)[:60]
def main():
want = [int(a) for a in sys.argv[1:]]
teams = [t for t in orch.list_teams() if not want or t["index"] in want]
enabled = [c["name"] for c in orch.enabled_challenges()]
grand_ok = grand_all = 0
for t in teams:
idx = t["index"]
port = t["ports"]["receiver"]
au, ap = t.get("admin_user", ""), t.get("admin_pass", "")
res = [check(port, au, ap, n) for n in enabled]
up = [n for n, ok, _ in res if ok]
down = [(n, e) for n, ok, e in res if not ok]
grand_ok += len(up); grand_all += len(res)
print(f"team{idx} ({t.get('label')}) SLA {len(up)}/{len(res)}")
if down:
for n, e in down:
print(f" DOWN {n}: {e}")
print(f"\nTOTAL {grand_ok}/{grand_all} "
f"({100.0 * grand_ok / grand_all if grand_all else 0:.1f}%)")
if __name__ == "__main__":
main()
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Start one team's full stack in the BACKGROUND (safe for a 16-challenge team:
# `compose up` for 16 services takes minutes and would blow a foreground timeout).
# Usage: start_team_bg.sh <teamIdx>
set -uo pipefail
IDX="${1:?usage: start_team_bg.sh <teamIdx>}"
LOG="/tmp/start-team${IDX}.log"
TEAMDIR="/opt/gemastik18-final/teams/team${IDX}"
cd "${TEAMDIR}/services" || exit 1
{
echo "=== $(date -Is) start team${IDX} ==="
docker compose -p "team${IDX}" up -d --remove-orphans 2>&1
echo "compose rc=$?"
# independent systemd receiver (never a child of the panel)
python3 /opt/gemastik18-final/panel/gen_receiver_services.py start 2>&1
systemctl restart "gemastik-receiver-team${IDX}.service" 2>&1
echo "receiver: $(systemctl is-active gemastik-receiver-team${IDX}.service)"
python3 - "$IDX" <<'PY'
import sys, json, time
sys.path.insert(0, '/opt/gemastik18-final/panel')
import teams
idx = int(sys.argv[1])
sf = f"/opt/gemastik18-final/teams/team{idx}/state.json"
st = json.loads(open(sf).read()); st["status"] = "running"
open(sf, "w").write(json.dumps(st, indent=2))
# retry loop: chpasswd races container boot
teams.set_ssh_passwords(idx)
print("=== done team", idx, "===")
PY
df -h / | tail -1
} >"${LOG}" 2>&1
echo "started team${IDX} -> ${LOG}"
+43
View File
@@ -318,6 +318,12 @@ function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Alias used by the Challenge Manager render. Kept as a separate name so
// existing esc() call sites stay untouched, but it MUST exist: a missing
// helper throws ReferenceError mid-render and the tab hangs on "Memuat…"
// forever with no visible error.
function escapeHtml(s) { return esc(s); }
function showView(v) {
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.view === v));
document.querySelectorAll('.view').forEach(x => x.classList.toggle('active', x.id === 'view-' + v));
@@ -714,11 +720,15 @@ function topoZoom(factor) {
function topoReset() { topoScale = 1; topoPanX = 0; topoPanY = 0; applyTopoView(); }
// ---------- Teams ----------
let TEAM_LABELS = {}; // idx -> label, filled by loadTeams (for confirm dialogs)
async function loadTeams() {
try {
const d = await api('/api/teams');
document.getElementById('teamCount').value = d.teams.length;
loadLeaderboard();
TEAM_LABELS = {};
for (const t of d.teams) TEAM_LABELS[t.index] = t.label || ('Team ' + t.index);
const grid = document.getElementById('teamsGrid');
if (!d.teams.length) { grid.innerHTML = '<div class="card"><span style="color:#718096">Belum ada team. Set jumlah team untuk auto-create.</span></div>'; return; }
let html = '';
@@ -743,6 +753,7 @@ async function loadTeams() {
<button onclick="editTeam(${t.index})">✏️ Edit Nama/Domain</button>
<button onclick="openTeamLogs(${t.index})">📜 Logs</button>
<button onclick="randomizeTeam(${t.index})">🎲 Randomize Flag</button>
<button class="danger" onclick="deleteTeam(${t.index})">🗑️ Hapus Team</button>
</div>
</div>`;
}
@@ -859,6 +870,38 @@ async function randomizeTeam(idx) {
} catch (e) { toast(e.message, true); }
}
async function deleteTeam(idx) {
// Per-team delete. Deliberately NOT the same as resetEnv: this removes ONE
// team (containers, network, receiver unit, ports, dir, domain) and leaves
// the other teams untouched.
const label = TEAM_LABELS[idx] || ('Team ' + idx);
if (!confirm(
`🗑️ HAPUS PERMANEN Team ${idx} (${label})?\n\n` +
`Yang akan dihapus:\n` +
`• Semua container challenge team ini\n` +
`• Receiver team + systemd unit\n` +
`• Folder team (port, flag, kredensial)\n` +
`• Port firewall UFW team ini\n` +
`• Domain ${label}.attackdefense.imrnes.team\n` +
`• Skor & riwayat di leaderboard\n\n` +
`Tindakan ini TIDAK BISA dibatalkan.\n` +
`Team lain tidak terpengaruh.`)) return;
// second gate: type the team number to confirm
if (prompt(`Ketik angka ${idx} untuk konfirmasi hapus:`)?.trim() !== String(idx)) {
toast('Dibatalkan', false);
return;
}
showLoading(`Menghapus Team ${idx} (${label})…<br>Stop container + receiver, hapus port & domain`);
try {
const d = await api(`/api/teams/${idx}`, {method:'DELETE', headers:{'Content-Type':'application/json'}, body: JSON.stringify({purge_scores: true})});
const n = (d.purged ? Object.values(d.purged).reduce((a, b) => a + b, 0) : 0);
toast(`Team ${idx} (${label}) dihapus: ${(d.steps || []).join(' · ')}${n ? ` · ${n} skor dibersihkan` : ''}`, false);
loadTeams();
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
} catch (e) { toast('Hapus team gagal: ' + e.message, true); }
finally { hideLoading(); }
}
async function loadLeaderboard() {
try {
const d = await api('/api/leaderboard');
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Open (or close) UFW for every live team's port block.
The panel opens a team's ports at create time, but teams created out-of-band
(scripts, git checkout, a half-finished create_team) never got rules, and this
host's UFW defaults to deny(incoming) — so those teams are blackholed. Run
after any bulk team creation:
python3 panel/sync_all_team_ufw.py # open
python3 panel/sync_all_team_ufw.py --remove # close
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import teams as orch
def main():
remove = "--remove" in sys.argv
live = orch.list_teams()
if not live:
print("no teams")
return
for t in live:
idx = t["index"]
r = orch.sync_team_ufw(idx, remove=remove)
verb = "closed" if remove else "opened"
bad = f" FAILED={r['failed']}" if r.get("failed") else ""
print(f"team{idx} ({t.get('label')}): {verb} {len(r.get('closed' if remove else 'opened', []))}"
f"/{r['ports']} ports{bad}")
if __name__ == "__main__":
main()
+331 -9
View File
@@ -443,8 +443,15 @@ def create_team(idx: int, label: str = None, domain: str = None):
for j, line in enumerate(recv_env):
if line.startswith(f"PASSWORD_{10000+coff*1000}="):
recv_env[j] = f"PASSWORD_{10000+coff*1000}={state['chall_passwords'][name]}"
recv_env.append(f"CHALLENGE_PORT_{name.upper()}={ports[name]['chall']}")
recv_env.append(f"CHALLENGE_CONTAINER_{name.upper()}={name}_container_team{idx}")
# systemd EnvironmentFile keys must match [A-Za-z_][A-Za-z0-9_]* — a
# hyphen (gift-card, bit-canvas, ...) makes systemd log
# "Ignoring invalid environment assignment" and DROP the line, so the
# checker falls back to a default port/container and reports the
# service down. Normalize to underscores on the writer; the reader
# (gen_receiver_main._envkey) uses the same normalization.
key = name.upper().replace("-", "_")
recv_env.append(f"CHALLENGE_PORT_{key}={ports[name]['chall']}")
recv_env.append(f"CHALLENGE_CONTAINER_{key}={name}_container_team{idx}")
(recv_dir / ".env").write_text("\n".join(recv_env) + "\n")
# --- flags (randomized per team so each team has unique flags) ---
@@ -484,6 +491,47 @@ def update_team(idx: int, label: str = None, domain: str = None) -> dict:
ensure_team_domains()
return st
def missing_sidecars(idx: int) -> list[str]:
"""Sidecar services in the team's compose that are NOT running.
A multi-container challenge (anti-alchemy + its postgres, gemas-notes +
database/validation, kode-viewer + redis, ...) needs its sidecars to boot:
the main service's `create_db_conn()` retries in an infinite loop until the
DB hostname resolves, so a missing sidecar leaves the main container
"Up" with NO app listening and the checker reports it DOWN. Symptom class:
container is running, port is open at the docker level, but the app never
starts. Recover with `docker compose -p teamN up -d` (no service name).
"""
svc_dir = TEAMS_DIR / f"team{idx}" / "services"
compose = svc_dir / "docker-compose.yml"
if not compose.exists():
return []
declared = _compose_service_names(compose)
if not declared:
return []
want = {f"team{idx}-{n}-1" for n in declared}
running = set(subprocess.run(["docker", "ps", "--format", "{{.Names}}"],
capture_output=True, text=True).stdout.split())
return sorted(want - running)
def _compose_service_names(compose: Path) -> list[str]:
"""Top-level service names from a compose file, without needing PyYAML."""
names: list[str] = []
in_services = False
for line in compose.read_text().splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
if not line.startswith((" ", "\t")):
in_services = line.rstrip() == "services:"
continue
if in_services and line.startswith(" ") and not line.startswith(" "):
name = line.strip().rstrip(":")
if name and not name.startswith("-"):
names.append(name)
return names
def start_team(idx: int):
team_dir = TEAMS_DIR / f"team{idx}"
if not (team_dir / "state.json").exists():
@@ -491,6 +539,13 @@ def start_team(idx: int):
svc_dir = team_dir / "services"
subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml", "up", "-d", "--build"],
cwd=str(svc_dir), check=False, capture_output=True)
# Self-heal: a per-service `up` (challenge toggle) or a raced start can
# leave a sidecar behind while the main container looks fine. One plain
# `up -d` reconciles the whole project (already-running ones are no-ops).
gone = missing_sidecars(idx)
if gone:
subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml", "up", "-d"],
cwd=str(svc_dir), check=False, capture_output=True)
_start_receiver(idx)
st = json.loads((team_dir / "state.json").read_text())
st["status"] = "running"
@@ -498,19 +553,53 @@ def start_team(idx: int):
# Set per-team SSH passwords at runtime (images are shared across teams,
# so chpasswd ensures each team's containers have the team's own password).
set_ssh_passwords(idx)
if gone:
print(f"[start_team] team{idx}: started missing sidecar(s): {', '.join(gone)}")
return st
def challenge_ssh_users() -> dict:
"""{challenge_name: ssh login} from the registry.
Only the 6 native GEMASTIK XVIII images provision `ctfuser`. Every imported
XVI/XVII image does `RUN echo root:${PASSWORD} | chpasswd` and logs in as
`root` (some also create an unprivileged `ctf` for the app). Hardcoding
`ctfuser` made chpasswd either fail or set a password on an account nobody
uses, so SSH returned "Permission denied" for every team on 10 of 16
challenges while state.json still looked correct.
"""
out = {}
for c in registry_challenges():
out[c["name"]] = c.get("ssh_user") or "root"
return out
def set_ssh_passwords(idx: int):
"""Set SSH password for ctfuser in each challenge container of a team."""
"""Set the SSH password for the CORRECT login of each challenge container.
The login is per-challenge (registry `ssh_user`), not a global `ctfuser`.
Retries because right after `compose up` the container may still be booting.
Reports failures loudly instead of writing to a log nobody reads.
"""
team_dir = TEAMS_DIR / f"team{idx}"
st = json.loads((team_dir / "state.json").read_text())
users = challenge_ssh_users()
failures = []
for name, coff, soff in CHALLENGES:
cont = f"{name}_container_team{idx}"
pw = st["chall_passwords"][name]
cmd = f"echo 'ctfuser:{pw}' | chpasswd"
# retry a few times — right after `compose up`, container may still be booting
user = users.get(name, "root")
pw = st["chall_passwords"].get(name)
if not pw:
failures.append(f"{cont}: no password in state")
continue
# Set the password for the real login AND for ctfuser when that account
# exists, so either convention works during a transition.
cmd = (f"id {user} >/dev/null 2>&1 && echo '{user}:{pw}' | chpasswd; "
f"id ctfuser >/dev/null 2>&1 && echo 'ctfuser:{pw}' | chpasswd; "
f"id ctf >/dev/null 2>&1 && echo 'ctf:{pw}' | chpasswd; "
f"id {user} >/dev/null 2>&1")
ok = False
r = None
for attempt in range(5):
r = subprocess.run(["docker", "exec", cont, "sh", "-c", cmd],
capture_output=True, text=True, timeout=30)
@@ -519,9 +608,13 @@ def set_ssh_passwords(idx: int):
break
time.sleep(3)
if not ok:
print(f"[set_ssh_passwords] {cont}: FAILED after retries ({r.stderr.strip()[:100]})")
failures.append(f"{cont}: {(r.stderr or '').strip()[:100]}")
else:
print(f"[set_ssh_passwords] {cont}: OK")
print(f"[set_ssh_passwords] {cont} (login={user}): OK")
if failures:
print(f"[set_ssh_passwords] team{idx} FAILED {len(failures)}/{len(CHALLENGES)}: "
+ "; ".join(failures))
return failures
def stop_team(idx: int):
team_dir = TEAMS_DIR / f"team{idx}"
@@ -622,12 +715,241 @@ def ensure_team_domains() -> str:
- main: {host}
""")
if not out:
return "no teams to route"
# No teams left. The file MUST still be rewritten (to an empty router
# set) — returning early leaves the previous content on disk, so a
# DELETED team keeps its Traefik router and stays routable to the panel
# portal forever. That was the stale-domain bug.
(traefik_dir / "attackdefense-teams.yaml").write_text("http:\n routers: {}\n")
return "no teams to route (emptied attackdefense-teams.yaml)"
# Keep the team domain block in its own file so the main attackdefense.yaml stays untouched
(traefik_dir / "attackdefense-teams.yaml").write_text("http:\n routers:\n" + "".join(out))
return f"wrote {len(out)} team domain(s) in attackdefense-teams.yaml"
def team_port_block(idx: int) -> list[int]:
"""Every host port a team occupies: each challenge's chall+ssh port, the
receiver, and the reserved panel slot."""
st_path = TEAMS_DIR / f"team{idx}" / "state.json"
ports: set[int] = set()
if st_path.exists():
st = json.loads(st_path.read_text())
for name, pv in (st.get("ports") or {}).items():
if isinstance(pv, dict):
for k in ("chall", "ssh"):
if pv.get(k):
ports.add(int(pv[k]))
elif isinstance(pv, int):
ports.add(pv)
else:
# team dir already gone (mid-delete): derive from the port scheme
base = PORT_BASE + idx * STEP
for name, coff, soff in CHALLENGES:
ports.add(base + coff)
ports.add(base + soff)
ports.add(base + 80)
ports.add(base + 81)
return sorted(ports)
def sync_team_ufw(idx: int, remove: bool = False) -> dict:
"""Reconcile UFW rules for one team's port block.
UFW here defaults to deny(incoming), so a port that is not explicitly
allowed is blackholed — the team is unreachable from the internet AND from
its own containers. Team creation never opened these ports (they were
opened by hand earlier), so a new team silently gets no connectivity.
remove=True DELETES the rules instead of adding them (called from
delete_team). The two modes are mutually exclusive: never add-then-delete,
that would flap the rules and briefly re-open a dead team's ports.
"""
ports = team_port_block(idx)
if remove:
for p in ports:
subprocess.run(["ufw", "--force", "delete", "allow", f"{p}/tcp"],
check=False, capture_output=True)
return {"team": idx, "ports": len(ports), "closed": ports, "failed": []}
failed = []
for p in ports:
r = subprocess.run(["ufw", "allow", f"{p}/tcp"], check=False, capture_output=True, text=True)
# `ufw allow` on an existing rule prints "Skipping adding existing rule"
# and still exits 0; a non-zero rc with a skip message is not a failure.
if r.returncode != 0 and "Skipping" not in (r.stdout + r.stderr):
failed.append(p)
return {"team": idx, "ports": len(ports), "opened": [p for p in ports if p not in failed],
"failed": failed}
def _purge_team_records(idx: int) -> dict:
"""Drop every ledger row that references a team, so a deleted team leaves
no ghost entries on the leaderboard / points / attack topology."""
removed = {"leaderboard": 0, "points": 0, "attacks": 0}
lb_path = TEAMS_DIR / "leaderboard.json"
if lb_path.exists():
try:
lb = json.loads(lb_path.read_text())
before = len(lb.get("solves", []))
lb["solves"] = [e for e in lb.get("solves", [])
if e.get("team") != idx and e.get("target") != idx]
removed["leaderboard"] = before - len(lb["solves"])
lb_path.write_text(json.dumps(lb, indent=2))
except Exception:
pass
p_path = TEAMS_DIR / "points.json"
if p_path.exists():
try:
data = json.loads(p_path.read_text())
if str(idx) in data.get("teams", {}):
data["teams"].pop(str(idx))
removed["points"] = 1
p_path.write_text(json.dumps(data, indent=2))
except Exception:
pass
a_path = TEAMS_DIR / "attacks.json"
if a_path.exists():
try:
log = json.loads(a_path.read_text())
before = len(log.get("events", []))
log["events"] = [e for e in log.get("events", [])
if e.get("attacker") != idx and e.get("target") != idx]
removed["attacks"] = before - len(log["events"])
a_path.write_text(json.dumps(log, indent=2))
except Exception:
pass
return removed
def repair_team_receiver_env(idx: int) -> dict:
"""Rewrite a team receiver .env with systemd-legal keys.
Teams created before the hyphen fix have `CHALLENGE_PORT_GIFT-CARD=` in
their .env. systemd logs "Ignoring invalid environment assignment" and drops
the line, so every hyphenated challenge (gift-card, bit-canvas, gleam-drive,
more-less, anti-alchemy, gift-voucher) reports DOWN even though its
container is up. This rewrites the file in place, fixing existing teams
without forcing a re-create.
"""
env_path = TEAMS_DIR / f"team{idx}" / "receiver" / ".env"
if not env_path.exists():
raise FileNotFoundError(f"team{idx} receiver .env not found")
st = json.loads((TEAMS_DIR / f"team{idx}" / "state.json").read_text())
lines = env_path.read_text().splitlines()
kept, fixed, dropped = [], [], []
for line in lines:
if line.startswith("CHALLENGE_PORT_") or line.startswith("CHALLENGE_CONTAINER_"):
k, _, v = line.partition("=")
nk = k.replace("-", "_")
if nk != k:
fixed.append(f"{k}->{nk}")
else:
kept.append(line)
continue
kept.append(line)
# re-append authoritative values for every challenge in state
for name in (st.get("ports") or {}):
if name in ("receiver", "panel"):
continue
key = name.upper().replace("-", "_")
kept.append(f"CHALLENGE_PORT_{key}={st['ports'][name]['chall']}")
kept.append(f"CHALLENGE_CONTAINER_{key}={name}_container_team{idx}")
env_path.write_text("\n".join(kept) + "\n")
# also refresh the shared checker packages (team1 was missing xvi.Art)
try:
sys.path.insert(0, str(BASE / "panel"))
from gen_receiver_main import _sync_checker_packages
_sync_checker_packages(TEAMS_DIR / f"team{idx}" / "receiver")
except Exception as e:
dropped.append(f"checker sync failed: {e}")
return {"team": idx, "fixed_keys": fixed, "notes": dropped}
def delete_team(idx: int, purge_scores: bool = True) -> dict:
"""Permanently remove ONE team and free everything it owns.
Teardown order matters — do the teardown against the OLD compose before
removing the directory, or `docker compose` has no file to read and the
containers survive as orphans:
1. stop the per-team receiver systemd unit and remove the unit file
2. `docker compose -p teamN down -v` against the team compose
(also drops the teamN_default network)
3. force-remove any surviving <chall>_container_teamN container
4. delete the team's UFW rules
5. rmtree teams/teamN (compose, receiver, flags, state.json)
6. purge leaderboard / points / attacks rows for that team
7. rewrite the Traefik team-domain file so the domain stops resolving
Images (services-*) are SHARED across teams and are never removed here.
purge_scores=False keeps the team's leaderboard/points history.
"""
team_dir = TEAMS_DIR / f"team{idx}"
if not (team_dir / "state.json").exists():
raise FileNotFoundError(f"Team {idx} not created")
st = json.loads((team_dir / "state.json").read_text())
label = st.get("label", f"Team {idx}")
steps: list[str] = []
# 1. receiver unit
unit = f"gemastik-receiver-team{idx}.service"
subprocess.run(["systemctl", "disable", unit], check=False, capture_output=True)
subprocess.run(["systemctl", "stop", unit], check=False, capture_output=True)
unit_path = Path("/etc/systemd/system") / f"{unit}"
if unit_path.exists():
unit_path.unlink()
steps.append("removed receiver unit")
subprocess.run(["systemctl", "daemon-reload"], check=False, capture_output=True)
# 2. compose down (containers + network) while the compose file still exists
svc_dir = team_dir / "services"
compose = svc_dir / "docker-compose.yml"
if compose.exists():
r = subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", str(compose),
"down", "-v", "--remove-orphans"],
cwd=str(svc_dir), check=False, capture_output=True, text=True,
timeout=600)
steps.append("compose down" if r.returncode == 0 else f"compose down rc={r.returncode}")
# 3. force-remove leftovers (a half-written compose can leave orphans)
left = subprocess.run(["docker", "ps", "-aq", "--filter", f"name=_container_team{idx}"],
capture_output=True, text=True).stdout.split()
if left:
subprocess.run(["docker", "rm", "-f", *left], check=False, capture_output=True)
steps.append(f"force-removed {len(left)} container(s)")
net_rm = subprocess.run(["docker", "network", "rm", f"team{idx}_default"],
check=False, capture_output=True, text=True)
if net_rm.returncode == 0:
steps.append("removed docker network")
# 4. UFW
ufw = sync_team_ufw(idx, remove=True)
steps.append(f"closed {len(ufw.get('closed', []))} ufw port(s)")
# 5. directory
shutil.rmtree(team_dir, ignore_errors=True)
if team_dir.exists():
raise RuntimeError(f"team{idx} directory could not be removed")
steps.append("deleted team directory")
# 6. ledgers
purged = _purge_team_records(idx) if purge_scores else {}
if purge_scores:
steps.append("purged score records")
# 7. Traefik: drop the dead domain
try:
ensure_team_domains()
steps.append("rewrote team domains")
except Exception as e:
steps.append(f"traefik rewrite failed: {e}")
return {"ok": True, "team": idx, "label": label, "domain": st.get("domain", ""),
"steps": steps, "purged": purged}
def list_teams() -> list:
out = []
if not TEAMS_DIR.exists():
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Verify each team's SSH passwords actually work in the live containers.
state.json can look perfect while the container holds a different password —
set_ssh_passwords() races container boot and its failures are easy to miss.
This proves the binding from the INSIDE (per the skill rule: never trust
config, prove it with a real login).
python3 panel/verify_ssh_creds.py [teamIdx ...]
"""
import json
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import teams as orch
def probe(user, pw, port, host="127.0.0.1"):
r = subprocess.run(
["sshpass", "-p", pw, "ssh",
"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=8", "-o", "LogLevel=ERROR",
"-p", str(port), f"{user}@{host}", "whoami; hostname"],
capture_output=True, text=True, timeout=30)
out = (r.stdout or "").strip().splitlines()
return (r.returncode == 0 and len(out) >= 2, out, (r.stderr or "").strip()[:80])
def main():
want = [int(a) for a in sys.argv[1:]]
teams = [t for t in orch.list_teams() if not want or t["index"] in want]
for t in teams:
idx = t["index"]
names = [c["name"] for c in orch.enabled_challenges()]
jobs = []
for n in names:
if n not in (t.get("ports") or {}):
continue
jobs.append((n, t["ports"][n]["ssh"], t.get("chall_passwords", {}).get(n)))
ok = bad = 0
details = []
with ThreadPoolExecutor(max_workers=6) as ex:
futs = {ex.submit(probe, t.get("ssh_user", "ctfuser"), pw, port): n
for n, port, pw in jobs if pw}
for fut, n in futs.items():
good, out, err = fut.result()
if good:
ok += 1
# hostname must be <challenge>_teamN — proves the binding
details.append((n, out[1] if len(out) > 1 else "?"))
else:
bad += 1
details.append((n, f"FAIL {err}"))
print(f"team{idx} ({t.get('label')}): ssh {ok} ok / {bad} fail (user={t.get('ssh_user')})")
for n, info in details:
if info == "FAIL" or info.startswith("FAIL"):
print(f" {n}: {info}")
hosts = [i for n, i in details if not i.startswith("FAIL")]
mism = [(n, i) for n, i in details
if not i.startswith("FAIL") and not i.endswith(f"_team{idx}")]
if mism:
print(f" !! hostname mismatch (not _team{idx}): {mism}")
else:
print(f" all hostnames correct (e.g. {hosts[0] if hosts else '-'})")
if __name__ == "__main__":
main()
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Fleet health check: per-team container count vs registry enabled count,
# per-team receiver systemd state, and host disk. Read-only, safe to run any time.
set -uo pipefail
cd /opt/gemastik18-final/panel
EXPECTED=$(python3 -c "
import sys; sys.path.insert(0,'.')
import teams
print(len(teams.enabled_challenges()))
")
TEAMS=$(ls -d /opt/gemastik18-final/teams/team* 2>/dev/null | sed 's/.*team//' | sort -n)
echo "enabled challenges: $EXPECTED"
echo "teams: ${TEAMS:-none}"
echo
for i in $TEAMS; do
up=$(docker ps --format '{{.Names}}' | grep -c "_container_team${i}\$" || true)
all=$(docker ps -a --format '{{.Names}}' | grep -c "_container_team${i}\$" || true)
recv=$(systemctl is-active "gemastik-receiver-team${i}.service" 2>/dev/null || echo none)
label=$(python3 -c "import json;print(json.load(open('/opt/gemastik18-final/teams/team${i}/state.json'))['label'])" 2>/dev/null)
echo "team${i} (${label}) up=${up}/${EXPECTED} total_ctr=${all} receiver=${recv}"
if [ "$up" != "$EXPECTED" ]; then
echo " missing: $(python3 - <<PY
import sys; sys.path.insert(0,'.')
import json, subprocess, teams
want={c['name'] for c in teams.enabled_challenges()}
have={n.split('_container_team${i}')[0] for n in subprocess.run(['docker','ps','--format','{{.Names}}'],capture_output=True,text=True).stdout.split() if n.endswith('_container_team${i}')}
print(' '.join(sorted(want-have)) or '-')
PY
)"
fi
done
echo
df -h / | tail -1