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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ==="
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
@@ -318,6 +318,12 @@ function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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');
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
Executable
+35
@@ -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
|
||||
Reference in New Issue
Block a user