fix(portal): per-challenge SSH user in web terminal + credential API
The web SSH terminal and the credential API reported `ctfuser` for all 16
challenges, but only the 6 native GEMASTIK XVIII images provision ctfuser.
Every imported XVI/XVII image does `RUN echo root:${PASSWORD} | chpasswd`,
so 10 of 16 participant logins were refused with "Permission denied".
Root causes (all the same class of bug - login hardcoded in the wrong layer):
- main.py websocket ssh handler read st["ssh_user"], a single team-wide value
defaulting to ctfuser, instead of the per-challenge registry field
- /api/credential proxied the global receiver on :18080, which only knows the
6 native challenges, so the other 10 returned "Invalid challenge"
- team.html hardcoded the challenge picker to those same 6 challenges, making
the other 10 unreachable from the terminal entirely
- index.html rendered `<b>ctfuser</b>` and a stale hardcoded SSH port table
Fixes:
- orch.challenge_credential()/all_teams() read the TEAM's state.json, which
holds the same per-challenge password the panel chpasswds
- gen_receiver_services.py injects SSH_USER_<port> from the registry so the
receiver's /credential endpoint agrees with the panel
- receiver Challenge.credentials() honours SSH_USER_<port> (ctfuser fallback)
- new /api/team/{idx}/own-challenges feeds the picker; targets now carry
challenge + ssh_user
- UI takes user and port from the server instead of hardcoding them
Verified: 32/32 credential payloads correct across teams 1-2, and 32/32 real
paramiko SSH logins succeed with whoami confirming the expected account.
Also adds bulk team delete: POST /api/teams/bulk-delete runs one background
thread and is polled via GET /api/teams/bulk-delete/{job_id}, plus per-team
checkboxes with select-all/clear in the UI. Deletion must stay sequential
because delete_team() regenerates shared artifacts at the end.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bulk-delete regression test: create two scratch teams, then delete BOTH in a
|
||||
# single API call and poll the job until it settles.
|
||||
#
|
||||
# Proves the endpoint exists, validates input, runs teams sequentially inside
|
||||
# one background job, and leaves the real teams untouched.
|
||||
set -uo pipefail
|
||||
BASE=/opt/gemastik18-final
|
||||
cd "$BASE"
|
||||
CJ=/tmp/bulk_cj
|
||||
|
||||
U=$(grep -oP '^PANEL_ADMIN_USER=\K.*' panel/.env)
|
||||
P=$(grep -oP '^PANEL_ADMIN_PASS=\K.*' panel/.env)
|
||||
curl -sS -c "$CJ" -X POST -H 'Content-Type: application/json' \
|
||||
-d "{\"user\":\"$U\",\"pass\":\"$P\"}" http://127.0.0.1:18081/api/login -o /dev/null
|
||||
|
||||
echo "=== validation ==="
|
||||
printf " empty list -> "
|
||||
curl -sS -b "$CJ" -X POST -H 'Content-Type: application/json' -d '{"indices":[]}' \
|
||||
http://127.0.0.1:18081/api/teams/bulk-delete -w ' [%{http_code}]\n'
|
||||
printf " missing tms -> "
|
||||
curl -sS -b "$CJ" -X POST -H 'Content-Type: application/json' -d '{"indices":[99,98]}' \
|
||||
http://127.0.0.1:18081/api/teams/bulk-delete -w ' [%{http_code}]\n'
|
||||
|
||||
echo "=== BEFORE ==="
|
||||
for i in 5 6; do
|
||||
printf " team%s dir=%s\n" "$i" "$([ -d "teams/team$i" ] && echo present || echo GONE)"
|
||||
done
|
||||
|
||||
echo "=== BULK DELETE [5,6] ==="
|
||||
S=$(date +%s)
|
||||
curl -sS -b "$CJ" -X POST -H 'Content-Type: application/json' \
|
||||
-d '{"indices":[5,6],"purge_scores":true}' \
|
||||
http://127.0.0.1:18081/api/teams/bulk-delete -o /tmp/bulk.json -w ' HTTP %{http_code}\n'
|
||||
cat /tmp/bulk.json; echo
|
||||
JOB=$(python3 -c "import json;print(json.load(open('/tmp/bulk.json'))['job'])")
|
||||
echo " job: $JOB"
|
||||
|
||||
while :; do
|
||||
curl -sS -b "$CJ" "http://127.0.0.1:18081/api/teams/bulk-delete/$JOB" -o /tmp/bulkjob.json
|
||||
read -r ST DET <<<"$(python3 -c "
|
||||
import json;d=json.load(open('/tmp/bulkjob.json'));print(d['state'],d.get('detail',''))")"
|
||||
echo " [$(( $(date +%s) - S ))s] $ST — $DET"
|
||||
[ "$ST" != "running" ] && break
|
||||
sleep 15
|
||||
done
|
||||
echo " elapsed: $(( $(date +%s) - S ))s"
|
||||
python3 -c "
|
||||
import json;d=json.load(open('/tmp/bulkjob.json'))
|
||||
print(' state:',d['state'],' errors:',d.get('errors'))
|
||||
for x in d.get('done',[]): print(' deleted team',x['team'],x['label'],'->',x['steps'])"
|
||||
|
||||
echo "=== AFTER ==="
|
||||
for i in 5 6; do
|
||||
printf " team%s dir=%s\n" "$i" "$([ -d "teams/team$i" ] && echo present || echo GONE)"
|
||||
done
|
||||
echo " real teams: $(curl -sS -b "$CJ" http://127.0.0.1:18081/api/teams \
|
||||
| python3 -c "import json,sys;print([t['index'] for t in json.load(sys.stdin)['teams']])")"
|
||||
@@ -0,0 +1,25 @@
|
||||
// Syntax-check every inline <script> block across all panel static pages.
|
||||
const fs = require('fs');
|
||||
const { execFileSync } = require('child_process');
|
||||
const files = ['static/index.html', 'static/team.html', 'static/guide.html'];
|
||||
const base = '/opt/gemastik18-final/panel/';
|
||||
let bad = 0, total = 0;
|
||||
for (const f of files) {
|
||||
const html = fs.readFileSync(base + f, 'utf8');
|
||||
const re = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
|
||||
let m, i = 0;
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
i++; total++;
|
||||
const p = `/tmp/chk_${f.replace(/\W/g, '_')}_${i}.js`;
|
||||
fs.writeFileSync(p, m[1]);
|
||||
try {
|
||||
execFileSync(process.execPath, ['--check', p], { stdio: 'pipe' });
|
||||
console.log(` OK ${f} block#${i}`);
|
||||
} catch (e) {
|
||||
bad++;
|
||||
console.log(` FAIL ${f} block#${i}\n${e.stderr.toString().split('\n').slice(0, 5).join('\n')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(bad === 0 ? `\nAll ${total} script block(s) parse cleanly.` : `\n${bad}/${total} FAILED.`);
|
||||
process.exit(bad ? 1 : 0);
|
||||
@@ -0,0 +1,20 @@
|
||||
// Extract every <script> block from index.html and syntax-check each with node.
|
||||
const fs = require('fs');
|
||||
const { execFileSync } = require('child_process');
|
||||
const html = fs.readFileSync('/opt/gemastik18-final/panel/static/index.html', 'utf8');
|
||||
const re = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
|
||||
let m, i = 0, bad = 0;
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
i++;
|
||||
const code = m[1];
|
||||
const f = `/tmp/blk_${i}.js`;
|
||||
fs.writeFileSync(f, code);
|
||||
try {
|
||||
execFileSync(process.execPath, ['--check', f], { stdio: 'pipe' });
|
||||
console.log(` script #${i}: OK (${code.split('\n').length} lines)`);
|
||||
} catch (e) {
|
||||
bad++;
|
||||
console.log(` script #${i}: SYNTAX ERROR -> ${e.stderr.toString().split('\n').slice(0, 6).join('\n')}`);
|
||||
}
|
||||
}
|
||||
console.log(bad === 0 ? `\nAll ${i} inline script block(s) parse cleanly.` : `\n${bad} block(s) FAILED.`);
|
||||
@@ -33,6 +33,12 @@ def write_unit(idx: int, st: dict):
|
||||
# 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", [])}
|
||||
# SSH login user per challenge. The panel already chpasswds the right
|
||||
# account from this field (teams.challenge_ssh_users); the receiver must
|
||||
# report the SAME user via /credential/<name> or participants get a login
|
||||
# that cannot work. Registry is the single source of truth for both sides.
|
||||
ssh_users = {c["name"]: c.get("ssh_user", "ctfuser")
|
||||
for c in load_registry().get("challenges", [])}
|
||||
for ch in st["ports"]:
|
||||
if ch in ("receiver", "panel"):
|
||||
continue
|
||||
@@ -41,6 +47,8 @@ def write_unit(idx: int, st: dict):
|
||||
env[f"CHALLENGE_CONTAINER_{key}"] = f"{ch}_container_team{idx}"
|
||||
if schemes.get(ch):
|
||||
env[f"CHALLENGE_SCHEME_{key}"] = schemes[ch]
|
||||
if ssh_users.get(ch):
|
||||
env[f"SSH_USER_{st['ports'][ch]['chall']}"] = ssh_users[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,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inject SSH_USER_<port> env into the GLOBAL receiver unit (gemastik-receiver).
|
||||
|
||||
Why: the panel's /api/credential proxy targets the global receiver on :18080,
|
||||
not the per-team receivers. That unit had no Environment= lines at all, so
|
||||
Challenge.credentials() fell back to the hardcoded 'ctfuser' literal and every
|
||||
imported XVI/XVII challenge reported a login that could never work.
|
||||
|
||||
The registry's `ssh_user` per challenge is the single source of truth (the
|
||||
panel already chpasswds that same account). Read the port each challenge runs
|
||||
on from the global receiver's own config so the keys line up with self.port.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BASE = Path("/opt/gemastik18-final")
|
||||
UNIT = Path("/etc/systemd/system/gemastik-receiver.service")
|
||||
REGISTRY = BASE / "teams/challenge_registry.json"
|
||||
RECV_CONFIG = BASE / "receiver/config.py"
|
||||
|
||||
reg = json.loads(REGISTRY.read_text())
|
||||
ssh_users = {c["name"]: c.get("ssh_user", "ctfuser") for c in reg.get("challenges", [])}
|
||||
|
||||
# Challenge -> port used by the GLOBAL receiver. main.py builds the challenge
|
||||
# objects from CHALLENGE_PORT_* / PASSWORD_* env; with none set it falls back to
|
||||
# the pydantic settings PASSWORD_<port> in config.py, so mirror those ports.
|
||||
text = RECV_CONFIG.read_text()
|
||||
ports = {}
|
||||
for m in re.finditer(r"PASSWORD_(\d+)\s*[:=]", text):
|
||||
ports.setdefault(m.group(1), int(m.group(1)))
|
||||
# name -> port needs the CHALLENGE_PORT mapping; take it from registry order +
|
||||
# the receiver main.py template, else fall back to PASSWORD_<port> keys.
|
||||
name_to_port = {}
|
||||
for m in re.finditer(r'"PASSWORD_(\d+)"', text):
|
||||
name_to_port.setdefault(m.group(1), m.group(1))
|
||||
print(f"registry challenges: {len(ssh_users)}")
|
||||
|
||||
# Build Environment lines for every challenge whose port we can resolve.
|
||||
env_lines = []
|
||||
resolved = 0
|
||||
for name, user in sorted(ssh_users.items()):
|
||||
# The global receiver uses the node-range ports from config.py; find the
|
||||
# port by matching the challenge name against the receiver's own mapping.
|
||||
port = None
|
||||
m = re.search(rf"{re.escape(name)}.*?(\d{{4,5}})", text)
|
||||
if m:
|
||||
port = m.group(1)
|
||||
if not port:
|
||||
continue
|
||||
env_lines.append(f'Environment="SSH_USER_{port}={user}"')
|
||||
resolved += 1
|
||||
|
||||
if not env_lines:
|
||||
print("ERROR: could not resolve any challenge port from config.py", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
body = UNIT.read_text()
|
||||
# Drop any SSH_USER_ lines we previously injected (idempotent re-runs).
|
||||
kept = [ln for ln in body.splitlines() if "SSH_USER_" not in ln]
|
||||
# Insert right after the existing Environment=PYTHONUNBUFFERED line.
|
||||
out = []
|
||||
for ln in kept:
|
||||
out.append(ln)
|
||||
if ln.startswith("Environment=PYTHONUNBUFFERED"):
|
||||
out.extend(env_lines)
|
||||
if not any(ln.startswith("Environment=PYTHONUNBUFFERED") for ln in out):
|
||||
out.extend(env_lines)
|
||||
UNIT.write_text("\n".join(out) + "\n")
|
||||
print(f"injected {resolved} SSH_USER_* lines into {UNIT}")
|
||||
|
||||
subprocess.run(["systemctl", "daemon-reload"], check=True)
|
||||
subprocess.run(["systemctl", "restart", "gemastik-receiver"], check=True)
|
||||
print("reloaded + restarted gemastik-receiver")
|
||||
+151
-2
@@ -39,6 +39,7 @@ async def _start_background():
|
||||
|
||||
# Toggle jobs: Docker builds take minutes, so PATCH /api/challenges runs the
|
||||
# work on a background thread and the client polls /api/challenges/jobs/<id>.
|
||||
_DELETE_JOBS = {}
|
||||
_TOGGLE_JOBS: dict[str, dict] = {}
|
||||
|
||||
CHALLENGES = [
|
||||
@@ -216,6 +217,32 @@ async def api_team_session(idx: int, req: Request):
|
||||
"""True when this browser has a valid team session for idx."""
|
||||
return {"authed": _team_authorized(req, idx)}
|
||||
|
||||
@app.get("/api/team/{idx}/own-challenges")
|
||||
async def api_team_own_challenges(idx: int, req: Request):
|
||||
"""The challenges THIS team runs, each with the SSH login it provisions.
|
||||
|
||||
The terminal's challenge picker used to be a hardcoded list of only the 6
|
||||
native GEMASTIK XVIII entries, so the 10 imported XVI/XVII challenges could
|
||||
not be selected at all. Names + per-challenge ssh_user only -- no passwords.
|
||||
"""
|
||||
td = orch.TEAMS_DIR / f"team{idx}"
|
||||
if not (td / "state.json").exists():
|
||||
raise HTTPException(404, "Team not found")
|
||||
st = json.loads((td / "state.json").read_text())
|
||||
if not _check_team_host(req, st):
|
||||
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
||||
if not _team_authorized(req, idx):
|
||||
raise HTTPException(401, "Login portal team dulu")
|
||||
users = orch.challenge_ssh_users()
|
||||
out = []
|
||||
for name, _coff, _soff in orch.CHALLENGES:
|
||||
p = st.get("ports", {}).get(name)
|
||||
if not p:
|
||||
continue
|
||||
out.append({"name": name, "ssh_user": users.get(name, "ctfuser"),
|
||||
"port": p.get("chall"), "ssh_port": p.get("ssh")})
|
||||
return {"challenges": out}
|
||||
|
||||
@app.get("/api/team/{idx}/targets")
|
||||
async def api_team_targets(idx: int, req: Request):
|
||||
"""Team targets — requires team login + own host. Only domain + port."""
|
||||
@@ -228,6 +255,7 @@ async def api_team_targets(idx: int, req: Request):
|
||||
if not _team_authorized(req, idx):
|
||||
raise HTTPException(401, "Login portal team dulu")
|
||||
out = []
|
||||
ssh_users = orch.challenge_ssh_users()
|
||||
for d in sorted(orch.TEAMS_DIR.glob("team*")):
|
||||
if not (d / "state.json").exists():
|
||||
continue
|
||||
@@ -241,8 +269,12 @@ async def api_team_targets(idx: int, req: Request):
|
||||
out.append({
|
||||
"team_idx": st.get("index"),
|
||||
"team_label": st.get("label", f"Team {st.get('index')}"),
|
||||
"challenge": name,
|
||||
"domain": st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".attackdefense.imrnes.team"),
|
||||
"port": p["chall"],
|
||||
# attackers need the same login the victim container provisions;
|
||||
# it is per-challenge, so surface it instead of assuming ctfuser
|
||||
"ssh_user": ssh_users.get(name, "ctfuser"),
|
||||
})
|
||||
return {"targets": out}
|
||||
|
||||
@@ -496,8 +528,26 @@ async def api_deactivate(challenge: str, req: Request):
|
||||
return {"receiver_status": resp.status_code, "receiver_body": resp.text}
|
||||
|
||||
@app.get("/api/credential/{challenge}")
|
||||
async def api_credential(challenge: str, req: Request):
|
||||
async def api_credential(challenge: str, req: Request, team: int = None):
|
||||
require_login(req)
|
||||
# The global receiver on :18080 only knows the 6 native GEMASTIK XVIII
|
||||
# challenges, so proxying everything there returns "Invalid challenge" for the
|
||||
# 10 imported XVI/XVII ones -- participants saw no SSH creds at all. A team's
|
||||
# state.json is the authority for BOTH the password and the SSH login user
|
||||
# (the same `ssh_user` the panel chpasswds), plus the team-specific port.
|
||||
# `?team=N` selects the team; team portal hosts imply their own index.
|
||||
idx = team
|
||||
if idx is None:
|
||||
host = (req.headers.get("host") or "").split(":")[0]
|
||||
for t in orch.all_teams():
|
||||
dom = (t.get("domain") or "").split(":")[0]
|
||||
if dom and dom == host:
|
||||
idx = t.get("index")
|
||||
break
|
||||
if idx is not None:
|
||||
cred = orch.challenge_credential(idx, challenge)
|
||||
if cred:
|
||||
return cred
|
||||
resp = await _proxy("GET", f"/credential/{challenge}")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
@@ -598,6 +648,98 @@ async def api_team_delete(idx: int, req: Request):
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/api/teams/bulk-delete")
|
||||
async def api_teams_bulk_delete(req: Request):
|
||||
"""Delete SEVERAL teams in one background job.
|
||||
|
||||
Body: {"indices": [5, 6], "purge_scores": true}
|
||||
|
||||
Why a job and not a loop of DELETE /api/teams/{idx}: a single team takes
|
||||
~100 s (compose down of 16 services), so deleting four teams inline would
|
||||
hold the request open for ~7 minutes and trip every proxy/browser timeout
|
||||
in front of the panel. Each team is therefore deleted sequentially inside
|
||||
ONE background thread, and the client polls a single job id.
|
||||
|
||||
Teams are processed one at a time on purpose. delete_team() runs
|
||||
`docker compose -p teamN down` and regenerates shared artifacts
|
||||
(receiver main.py, systemd units) afterwards, so running several in
|
||||
parallel would race on those shared files.
|
||||
|
||||
A team that fails does NOT abort the rest: the job records the error and
|
||||
moves on, because the point of a bulk delete is to clear stale teams and
|
||||
one broken compose shouldn't strand the others.
|
||||
"""
|
||||
require_login(req)
|
||||
data = await req.json()
|
||||
raw = data.get("indices") or data.get("indices[]") or []
|
||||
try:
|
||||
indices = sorted({int(i) for i in raw})
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "indices must be a list of team numbers")
|
||||
if not indices:
|
||||
raise HTTPException(400, "No team selected")
|
||||
if len(indices) > 20:
|
||||
raise HTTPException(400, "Refusing to delete more than 20 teams at once")
|
||||
|
||||
purge = bool(data.get("purge_scores", True))
|
||||
existing = [i for i in indices
|
||||
if (orch.TEAMS_DIR / f"team{i}" / "state.json").exists()]
|
||||
skipped = [i for i in indices if i not in existing]
|
||||
if not existing:
|
||||
raise HTTPException(404, "None of the selected teams exist")
|
||||
|
||||
job_id = f"bulkdel-{int(time.time())}-{len(existing)}"
|
||||
_DELETE_JOBS[job_id] = {
|
||||
"job": job_id, "indices": existing, "skipped": skipped,
|
||||
"state": "running", "done": [], "errors": {},
|
||||
"detail": "queued", "started": int(time.time()),
|
||||
}
|
||||
|
||||
def _worker():
|
||||
job = _DELETE_JOBS[job_id]
|
||||
try:
|
||||
for n, i in enumerate(existing, 1):
|
||||
job["detail"] = f"menghapus team {i} ({n}/{len(existing)})"
|
||||
try:
|
||||
# delete_team is blocking (subprocess + shutil), and this
|
||||
# runs in a plain thread, so call it directly.
|
||||
res = orch.delete_team(i, purge)
|
||||
job["done"].append({
|
||||
"team": i,
|
||||
"label": res.get("label", f"Team {i}"),
|
||||
"steps": res.get("steps", []),
|
||||
"purged": res.get("purged", {}),
|
||||
})
|
||||
except Exception as e:
|
||||
job["errors"][str(i)] = str(e)
|
||||
# regenerate shared artifacts once at the end, so the remaining
|
||||
# teams' receiver main.py / systemd units stop referencing deleted ones
|
||||
subprocess.run(
|
||||
[sys.executable, str(orch.BASE / "panel" / "gen_receiver_services.py"), "start"],
|
||||
check=False, capture_output=True)
|
||||
job["state"] = "done" if not job["errors"] else "partial"
|
||||
job["detail"] = "complete" if not job["errors"] else "completed with errors"
|
||||
except Exception as e:
|
||||
job["state"] = "error"
|
||||
job["detail"] = str(e)
|
||||
finally:
|
||||
job["finished"] = int(time.time())
|
||||
|
||||
threading.Thread(target=_worker, name=f"bulkdel-{job_id}", daemon=True).start()
|
||||
return {"ok": True, "job": job_id, "state": "running",
|
||||
"indices": existing, "skipped": skipped}
|
||||
|
||||
|
||||
@app.get("/api/teams/bulk-delete/{job_id}")
|
||||
async def api_teams_bulk_delete_job(job_id: str, req: Request):
|
||||
"""Poll a bulk team delete started by POST /api/teams/bulk-delete."""
|
||||
require_login(req)
|
||||
job = _DELETE_JOBS.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Unknown job")
|
||||
return job
|
||||
|
||||
|
||||
@app.post("/api/teams/{idx}/ufw")
|
||||
async def api_team_ufw(idx: int, req: Request):
|
||||
"""Reconcile UFW rules for a team's port block.
|
||||
@@ -858,7 +1000,14 @@ async def team_ssh_ws(ws: WebSocket, idx: int):
|
||||
await ws.close(code=4002, reason="unknown challenge")
|
||||
return
|
||||
recv_port = st["ports"][chall]["ssh"]
|
||||
user = st.get("ssh_user", "ctfuser")
|
||||
# The SSH login is PER-CHALLENGE, not per-team. Only the 6 native GEMASTIK
|
||||
# XVIII images provision `ctfuser`; every imported XVI/XVII image does
|
||||
# `RUN echo root:${PASSWORD} | chpasswd`. Reading st["ssh_user"] (a single
|
||||
# team-wide value, default ctfuser) made the web terminal log in as ctfuser
|
||||
# for all 16 challenges, so 10 of them always failed with "Permission denied".
|
||||
# challenge_ssh_users() reads the registry -- the same field set_ssh_passwords()
|
||||
# chpasswds -- so the login and the password can never drift apart.
|
||||
user = orch.challenge_ssh_users().get(chall) or st.get("ssh_user", "ctfuser")
|
||||
# each challenge container has its own password (chall_passwords);
|
||||
# ssh_pass is the portal login password (may differ).
|
||||
pw = st.get("chall_passwords", {}).get(chall) or st.get("ssh_pass", "")
|
||||
|
||||
@@ -51,10 +51,10 @@
|
||||
<li>Buka <b>portal tim kamu</b> (domain dari panitia).</li>
|
||||
<li>Login dengan <b>password SSH tim</b>.</li>
|
||||
<li>Tab <b>🖥️ Terminal SSH</b> → pilih challenge → <b>Sambung</b>.</li>
|
||||
<li>Langsung masuk sebagai <code>ctfuser</code> — tanpa perlu aplikasi SSH.</li>
|
||||
<li>Langsung masuk sebagai user yang tertera di dropdown — <code>ctfuser</code> untuk 6 challenge native GEMASTIK XVIII, <code>root</code> untuk challenge XVI/XVII import — tanpa perlu aplikasi SSH.</li>
|
||||
</ol>
|
||||
<h3>Cara 2 — SSH Client (opsional)</h3>
|
||||
<div class="sshbox">ssh ctfuser@43.134.105.109 -p <PORT_SSH></div>
|
||||
<div class="sshbox">ssh <USER>@43.134.105.109 -p <PORT_SSH></div>
|
||||
<p>Contoh: untuk challenge <code>blogpost</code> tim 1, port SSH = <code>31022</code>.</p>
|
||||
</div>
|
||||
|
||||
|
||||
+115
-8
@@ -171,6 +171,12 @@
|
||||
<button class="danger" onclick="stopAllTeams()">⏹ Stop CTF (semua)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bulkDelBar" style="display:none;margin:10px 0"></div>
|
||||
<div class="card" style="margin-bottom:10px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||
<button onclick="selectAllTeams(true)">☑️ Pilih semua</button>
|
||||
<button onclick="clearTeamSelection()">☐ Kosongkan</button>
|
||||
<span style="font-size:11px;color:#718096">Centang tim di kiri nama untuk hapus massal sekaligus</span>
|
||||
</div>
|
||||
<div class="grid" id="teamsGrid"></div>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="team-head">
|
||||
@@ -445,6 +451,11 @@ async function toggleChallenge(name, enabled) {
|
||||
}
|
||||
}
|
||||
// ---------- Challenges ----------
|
||||
// The admin challenge grid is a global node view with no team of its own, so it
|
||||
// asks for credentials without ?team= and the server falls back to the receiver.
|
||||
// Team-scoped pages pass their own index instead.
|
||||
function TEAM_Q() { return ''; }
|
||||
|
||||
async function refresh() {
|
||||
const grid = document.getElementById('grid');
|
||||
try {
|
||||
@@ -474,9 +485,11 @@ async function refresh() {
|
||||
<button onclick="viewCred('${esc(ch.name)}')">SSH</button>
|
||||
</div>
|
||||
</div>`;
|
||||
fetch(`/api/credential/${ch.name}`).then(r=>r.json()).then(c => {
|
||||
fetch(`/api/credential/${ch.name}${TEAM_Q()}`).then(r=>r.json()).then(c => {
|
||||
const el = document.getElementById('creds-' + ch.name);
|
||||
if (el && c.username) el.innerHTML = `<b>ctfuser</b> / <b>${esc(c.password)}</b>`;
|
||||
// Use the username the server reports: it is per-challenge (ctfuser for
|
||||
// the 6 native XVIII images, root for the imported XVI/XVII ones).
|
||||
if (el && c.username) el.innerHTML = `<b>${esc(c.username)}</b> / <b>${esc(c.password)}</b>`;
|
||||
}).catch(()=>{});
|
||||
}
|
||||
grid.innerHTML = html;
|
||||
@@ -515,12 +528,17 @@ async function submitFlag() {
|
||||
|
||||
async function viewCred(ch) {
|
||||
try {
|
||||
const c = await api(`/api/credential/${ch}`);
|
||||
const c = await api(`/api/credential/${ch}${TEAM_Q()}`);
|
||||
// user + port both come from the server: the SSH login is per-challenge and
|
||||
// the port is per-team, so a hardcoded table went stale for every imported
|
||||
// challenge and for any team other than the first.
|
||||
const user = c.username || 'ctfuser';
|
||||
const port = c.port || 10022;
|
||||
const host = `${ch}.attackdefense.imrnes.team`;
|
||||
document.getElementById('mcTitle').textContent = 'SSH Credentials — ' + ch;
|
||||
const sshPort = {blogpost:10022, carbeat:11022, cdn:12022, phew:13022, sheesh:14022, warmup:15022}[ch] || 10022;
|
||||
const cmd = `ssh ctfuser@${ch}.attackdefense.imrnes.team -p ${sshPort}`;
|
||||
const cmd = `ssh ${user}@${host} -p ${port}`;
|
||||
document.getElementById('mcBody').innerHTML =
|
||||
`<div>User: <b>ctfuser</b></div>
|
||||
`<div>User: <b>${esc(user)}</b></div>
|
||||
<div>Pass: <b>${esc(c.password)}</b></div>
|
||||
<div style="margin-top:10px">SSH:</div>
|
||||
<div class="flag" style="margin-top:6px">${esc(cmd)}</div>`;
|
||||
@@ -721,6 +739,87 @@ function topoReset() { topoScale = 1; topoPanX = 0; topoPanY = 0; applyTopoView(
|
||||
|
||||
// ---------- Teams ----------
|
||||
let TEAM_LABELS = {}; // idx -> label, filled by loadTeams (for confirm dialogs)
|
||||
let TEAM_SELECTED = new Set(); // idx ticked for bulk delete
|
||||
|
||||
function renderTeamSelectBar() {
|
||||
const bar = document.getElementById('bulkDelBar');
|
||||
if (!bar) return;
|
||||
const n = TEAM_SELECTED.size;
|
||||
if (!n) { bar.style.display = 'none'; bar.innerHTML = ''; return; }
|
||||
const names = [...TEAM_SELECTED].sort((a, b) => a - b)
|
||||
.map(i => `#${i} ${esc(TEAM_LABELS[i] || '')}`).join(', ');
|
||||
bar.style.display = 'block';
|
||||
bar.innerHTML =
|
||||
`<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<b style="color:#f6ad55">${n} tim dipilih</b>
|
||||
<span style="color:#a0aec0;font-size:12px">${esc(names)}</span>
|
||||
<button class="danger" onclick="bulkDeleteTeams()">🗑️ Hapus ${n} Tim Sekaligus</button>
|
||||
<button onclick="clearTeamSelection()">Batalkan pilihan</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function toggleTeamSelect(idx, on) {
|
||||
if (on) TEAM_SELECTED.add(idx); else TEAM_SELECTED.delete(idx);
|
||||
const cb = document.getElementById(`teamSel${idx}`);
|
||||
if (cb) cb.checked = on;
|
||||
renderTeamSelectBar();
|
||||
}
|
||||
|
||||
function selectAllTeams(on) {
|
||||
TEAM_SELECTED.clear();
|
||||
if (on) for (const k of Object.keys(TEAM_LABELS)) TEAM_SELECTED.add(Number(k));
|
||||
for (const k of Object.keys(TEAM_LABELS)) {
|
||||
const cb = document.getElementById(`teamSel${k}`);
|
||||
if (cb) cb.checked = on && TEAM_SELECTED.has(Number(k));
|
||||
}
|
||||
renderTeamSelectBar();
|
||||
}
|
||||
|
||||
function clearTeamSelection() { selectAllTeams(false); }
|
||||
|
||||
async function bulkDeleteTeams() {
|
||||
const idx = [...TEAM_SELECTED].sort((a, b) => a - b);
|
||||
if (!idx.length) { toast('Pilih minimal satu tim dulu', true); return; }
|
||||
const names = idx.map(i => `#${i} ${TEAM_LABELS[i] || ''}`).join(', ');
|
||||
if (!confirm(
|
||||
`🗑️ HAPUS ${idx.length} TIM SEKALIGUS?\n\n${names}\n\n` +
|
||||
`Yang akan dihapus (semua tim di atas):\n` +
|
||||
`• Semua container challenge\n• Receiver + systemd unit\n` +
|
||||
`• Folder team (port, flag, kredensial)\n• Port firewall UFW\n` +
|
||||
`• Domain Traefik\n• Skor & riwayat leaderboard\n\n` +
|
||||
`⚠️ TIDAK BISA dibatalkan.`)) return;
|
||||
showLoading(`Menghapus ${idx.length} tim (${names})…<br>Compose down 16 service per tim, bisa beberapa menit`);
|
||||
try {
|
||||
const d = await api('/api/teams/bulk-delete', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({indices: idx, purge_scores: true})
|
||||
});
|
||||
// One team takes ~100s, so poll a single job instead of blocking here.
|
||||
const final = await pollJob(`/api/teams/bulk-delete/${d.job}`, 1500);
|
||||
const done = final.done || [];
|
||||
const errs = final.errors || {};
|
||||
let msg = `Terhapus ${done.length}/${idx.length} tim`;
|
||||
if (Object.keys(errs).length) msg += ` · gagal: ${Object.keys(errs).join(', ')}`;
|
||||
const purged = done.reduce((a, x) =>
|
||||
a + Object.values(x.purged || {}).reduce((p, q) => p + q, 0), 0);
|
||||
if (purged) msg += ` · ${purged} skor dibersihkan`;
|
||||
toast(msg, Object.keys(errs).length > 0);
|
||||
TEAM_SELECTED.clear();
|
||||
loadTeams();
|
||||
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
|
||||
} catch (e) { toast('Bulk delete gagal: ' + e.message, true); }
|
||||
finally { hideLoading(); }
|
||||
}
|
||||
|
||||
async function pollJob(url, everyMs) {
|
||||
for (;;) {
|
||||
const j = await api(url);
|
||||
if (j.state !== 'running') return j;
|
||||
const el = document.querySelector('#loadingText');
|
||||
if (el && j.detail) el.innerHTML = `${esc(j.detail)}…<br><span style="font-size:11px">${esc(url.split('/').pop())}</span>`;
|
||||
await new Promise(r => setTimeout(r, everyMs));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTeams() {
|
||||
try {
|
||||
@@ -729,15 +828,22 @@ async function loadTeams() {
|
||||
loadLeaderboard();
|
||||
TEAM_LABELS = {};
|
||||
for (const t of d.teams) TEAM_LABELS[t.index] = t.label || ('Team ' + t.index);
|
||||
// drop selections for teams that no longer exist
|
||||
for (const i of [...TEAM_SELECTED]) if (!(i in TEAM_LABELS)) TEAM_SELECTED.delete(i);
|
||||
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; }
|
||||
if (!d.teams.length) { grid.innerHTML = '<div class="card"><span style="color:#718096">Belum ada team. Set jumlah team untuk auto-create.</span></div>'; renderTeamSelectBar(); return; }
|
||||
let html = '';
|
||||
for (const t of d.teams) {
|
||||
const alive = t.status === 'running';
|
||||
const dom = t.domain || '';
|
||||
const sel = TEAM_SELECTED.has(t.index);
|
||||
html += `<div class="card ${alive ? '' : 'dead'}">
|
||||
<div class="team-head">
|
||||
<span class="ch-name">${esc(t.label || ('Team ' + t.index))}</span>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none" title="Pilih untuk hapus massal">
|
||||
<input type="checkbox" id="teamSel${t.index}" ${sel ? 'checked' : ''}
|
||||
onchange="toggleTeamSelect(${t.index}, this.checked)">
|
||||
<span class="ch-name">${esc(t.label || ('Team ' + t.index))}</span>
|
||||
</label>
|
||||
<span class="status ${alive ? 'up' : 'down'}">${alive ? '● RUNNING' : '● STOPPED'}</span>
|
||||
</div>
|
||||
<div class="kv">
|
||||
@@ -758,6 +864,7 @@ async function loadTeams() {
|
||||
</div>`;
|
||||
}
|
||||
grid.innerHTML = html;
|
||||
renderTeamSelectBar();
|
||||
} catch (e) { toast('Teams gagal: ' + e.message, true); }
|
||||
}
|
||||
|
||||
|
||||
+47
-14
@@ -126,20 +126,13 @@
|
||||
<div class="card">
|
||||
<h2>🖥️ SSH Terminal — pilih challenge</h2>
|
||||
<div class="term-toolbar">
|
||||
<select id="termChall" style="width:200px" onchange="connectTerm()">
|
||||
<option value="blogpost">blogpost (web)</option>
|
||||
<option value="carbeat">carbeat (pwn)</option>
|
||||
<option value="cdn">cdn (web)</option>
|
||||
<option value="phew">phew (crypto)</option>
|
||||
<option value="sheesh">sheesh (crypto)</option>
|
||||
<option value="warmup">warmup</option>
|
||||
</select>
|
||||
<select id="termChall" style="width:200px" onchange="connectTerm()"></select>
|
||||
<button class="ghost" onclick="connectTerm()">🔄 Sambung</button>
|
||||
<span id="termStatus">Belum tersambung</span>
|
||||
</div>
|
||||
<div id="termWrap"><div id="term"></div></div>
|
||||
<div style="margin-top:10px;font-size:12px;color:var(--dim)">
|
||||
SSH otomatis login sebagai <code>ctfuser</code> ke container challenge timmu. Koneksi diputus setelah idle.
|
||||
SSH otomatis login ke container challenge timmu. User <b>ctfuser</b> untuk 6 challenge native GEMASTIK XVIII, <b>root</b> untuk challenge XVI/XVII hasil import — user shown di dropdown. Koneksi diputus setelah idle.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,12 +207,13 @@
|
||||
<h3>2. Login via Web Terminal</h3>
|
||||
<ol>
|
||||
<li>Buka tab <b>🖥️ Terminal SSH</b>.</li>
|
||||
<li>Pilih challenge (misal <code>blogpost</code>).</li>
|
||||
<li>Klik <b>Sambung</b> — otomatis login sebagai <code>ctfuser</code>.</li>
|
||||
<li>Pilih challenge (misal <code>blogpost</code>) — user SSH-nya tercetak di dropdown.</li>
|
||||
<li>Klik <b>Sambung</b> — otomatis login sebagai user yang tertera.</li>
|
||||
</ol>
|
||||
|
||||
<h3>3. Login via SSH biasa (opsional)</h3>
|
||||
<div class="sshbox">ssh ctfuser@43.134.105.109 -p <PORT_SSH>
|
||||
<div class="sshbox">ssh <USER>@43.134.105.109 -p <PORT_SSH>
|
||||
# user: ctfuser (XVIII native) atau root (XVI/XVII import)
|
||||
# password = password tim kamu</div>
|
||||
|
||||
<h3>4. Command yang berguna</h3>
|
||||
@@ -294,6 +288,7 @@ async function doLogin() {
|
||||
loadInfo();
|
||||
loadTargetDropdown(); // target dropdown needs auth — refresh after login
|
||||
loadTargets();
|
||||
loadTermChallenges(); // SSH picker: all challenges this team has, not just 6
|
||||
} else {
|
||||
err.textContent = 'Password salah. Coba lagi.';
|
||||
err.style.display = 'block';
|
||||
@@ -322,6 +317,40 @@ async function loadInfo() {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Populate the SSH challenge picker from the challenges THIS team actually has.
|
||||
// It used to be a hardcoded list of only the 6 native GEMASTIK XVIII entries,
|
||||
// so the 10 imported XVI/XVII challenges were unreachable from the terminal.
|
||||
async function loadTermChallenges() {
|
||||
const sel = document.getElementById('termChall');
|
||||
try {
|
||||
const d = await api(`/api/team/${TEAM_ID}/targets`);
|
||||
const all = (d.targets || []);
|
||||
// /targets lists OTHER teams; union it with this team's own challenge set so
|
||||
// the picker reflects the full local roster.
|
||||
const names = new Map();
|
||||
for (const t of all) if (t.challenge) names.set(t.challenge, t.ssh_user || 'ctfuser');
|
||||
const own = await api(`/api/team/${TEAM_ID}/own-challenges`);
|
||||
for (const c of (own.challenges || [])) {
|
||||
names.set(c.name, c.ssh_user || 'ctfuser');
|
||||
}
|
||||
const keep = sel.value;
|
||||
sel.innerHTML = '';
|
||||
for (const [name, user] of [...names.entries()].sort()) {
|
||||
const o = document.createElement('option');
|
||||
o.value = name;
|
||||
o.textContent = `${name} (${user})`;
|
||||
o.dataset.sshUser = user;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
if (keep && names.has(keep)) sel.value = keep;
|
||||
SSH_USERS = Object.fromEntries(names);
|
||||
} catch (e) {
|
||||
sel.innerHTML = '<option value="">gagal memuat challenge</option>';
|
||||
}
|
||||
}
|
||||
|
||||
let SSH_USERS = {};
|
||||
|
||||
async function loadTargets() {
|
||||
const box = document.getElementById('targetList');
|
||||
const d = await api(`/api/team/${TEAM_ID}/targets`);
|
||||
@@ -329,7 +358,10 @@ async function loadTargets() {
|
||||
if (!targets.length) { box.textContent = 'Belum ada tim musuh.'; return; }
|
||||
let html = '=== TARGET MUSUH ===\n\n';
|
||||
for (const t of targets) {
|
||||
html += `${esc(t.domain)}:${t.port}\n`;
|
||||
// The login is per-challenge: ctfuser for the 6 native XVIII images, root
|
||||
// for the imported XVI/XVII ones. Showing a fixed ctfuser sent attackers to
|
||||
// a login that could never work.
|
||||
html += `${esc(t.domain)}:${t.port} (${esc(t.ssh_user || 'ctfuser')})\n`;
|
||||
}
|
||||
box.textContent = html;
|
||||
}
|
||||
@@ -348,7 +380,8 @@ function connectTerm() {
|
||||
|
||||
ws = new WebSocket(url);
|
||||
status.textContent = 'Menghubungkan…';
|
||||
ws.onopen = () => { status.textContent = `● tersambung ke ${chall} (ctfuser)`; term.focus(); };
|
||||
const asUser = (SSH_USERS[chall] || 'ctfuser');
|
||||
ws.onopen = () => { status.textContent = `● tersambung ke ${chall} (${asUser})`; term.focus(); };
|
||||
ws.onmessage = ev => term.write(ev.data);
|
||||
ws.onclose = ev => { status.textContent = '✖ terputus (' + (ev.reason || 'closed') + ')'; };
|
||||
ws.onerror = () => { status.textContent = '✖ error koneksi'; };
|
||||
|
||||
@@ -607,6 +607,56 @@ def challenge_ssh_users() -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def all_teams() -> list:
|
||||
"""Every team that still has a state.json, newest index last."""
|
||||
out = []
|
||||
for d in sorted(TEAMS_DIR.glob("team*")):
|
||||
sf = d / "state.json"
|
||||
if not sf.exists():
|
||||
continue
|
||||
try:
|
||||
st = json.loads(sf.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
if "index" in st:
|
||||
out.append(st)
|
||||
return out
|
||||
|
||||
|
||||
def team_state(idx: int):
|
||||
"""state.json for one team, or None if that team doesn't exist."""
|
||||
sf = TEAMS_DIR / f"team{idx}" / "state.json"
|
||||
if not sf.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(sf.read_text())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def challenge_credential(idx: int, name: str):
|
||||
"""{username, password, port} a participant should actually use for SSH.
|
||||
|
||||
Reads the TEAM's state.json rather than the receiver: the global receiver on
|
||||
:18080 only knows the 6 native GEMASTIK XVIII challenges, so asking it for
|
||||
an imported XVI/XVII challenge returns "Invalid challenge" and the UI showed
|
||||
participants nothing at all. The registry `ssh_user` is the same field the
|
||||
panel chpasswds, so the two can never drift.
|
||||
"""
|
||||
st = team_state(idx)
|
||||
if not st:
|
||||
return None
|
||||
pwd = (st.get("chall_passwords") or {}).get(name)
|
||||
if not pwd:
|
||||
return None
|
||||
return {
|
||||
"username": challenge_ssh_users().get(name, "ctfuser"),
|
||||
"password": pwd,
|
||||
"port": ((st.get("ports") or {}).get(name) or {}).get("chall"),
|
||||
"team": idx,
|
||||
}
|
||||
|
||||
|
||||
def set_ssh_passwords(idx: int):
|
||||
"""Set the SSH password for the CORRECT login of each challenge container.
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Functional DOM test of the bulk-delete checkbox UI.
|
||||
// Loads the REAL index.html into jsdom, stubs fetch, and drives the actual
|
||||
// render + selection functions. Proves: checkboxes render, select-all works,
|
||||
// the bar appears only when a selection exists, and the payload is right.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { JSDOM } = require(path.join('/tmp/uitest/node_modules/jsdom'));
|
||||
|
||||
const html = fs.readFileSync('/opt/gemastik18-final/panel/static/index.html', 'utf8');
|
||||
|
||||
// Fake 4 teams, mirroring the real API shape.
|
||||
const FAKE_TEAMS = {
|
||||
teams: [
|
||||
{ index: 1, label: 'Max', status: 'running', ports: {}, domain: 'max.attackdefense.imrnes.team' },
|
||||
{ index: 2, label: 'Aryma', status: 'running', ports: {}, domain: 'aryma.attackdefense.imrnes.team' },
|
||||
{ index: 3, label: 'Ken', status: 'running', ports: {}, domain: 'ken.attackdefense.imrnes.team' },
|
||||
],
|
||||
};
|
||||
|
||||
const calls = [];
|
||||
const dom = new JSDOM(html, { runScripts: 'dangerously', url: 'https://attackdefense.imrnes.team/' });
|
||||
|
||||
dom.window.fetch = async (url, opts) => {
|
||||
calls.push({ url: String(url), method: (opts && opts.method) || 'GET', body: opts && opts.body });
|
||||
const u = String(url);
|
||||
if (u.endsWith('/api/teams') && !(opts && opts.method)) {
|
||||
return { ok: true, json: async () => FAKE_TEAMS };
|
||||
}
|
||||
if (u.includes('/api/teams/bulk-delete/') && !(opts && opts.method)) {
|
||||
return { ok: true, json: async () => ({ state: 'done', done: [{ team: 2 }], errors: {} }) };
|
||||
}
|
||||
return { ok: true, json: async () => ({ ok: true }) };
|
||||
};
|
||||
dom.window.confirm = () => true;
|
||||
|
||||
const w = dom.window, d = w.document;
|
||||
// jsdom keeps top-level `let` out of window, so read it through eval in the page.
|
||||
const sel_set = () => w.eval('TEAM_SELECTED');
|
||||
const call = (fn) => w.eval(`${fn}()`);
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (name, cond, extra) => {
|
||||
if (cond) { pass++; console.log(' PASS ' + name); }
|
||||
else { fail++; console.log(' FAIL ' + name + (extra ? ' -> ' + extra : '')); }
|
||||
};
|
||||
|
||||
(async () => {
|
||||
// Drive the real page init: it calls loadTeams on DOMContentLoaded.
|
||||
await w.loadTeams();
|
||||
|
||||
const boxes = () => Array.from(d.querySelectorAll('input[id^="teamSel"]'));
|
||||
ok('one checkbox per team rendered', boxes().length === 3, 'got ' + boxes().length);
|
||||
ok('checkbox id encodes the team index', boxes()[0] && boxes()[0].id === 'teamSel1',
|
||||
boxes()[0] && boxes()[0].id);
|
||||
ok('checkbox onchange passes the index', boxes()[0] && /toggleTeamSelect\(1,/.test(boxes()[0].getAttribute('onchange') || ''),
|
||||
boxes()[0] && boxes()[0].getAttribute('onchange'));
|
||||
|
||||
// Bar hidden with no selection.
|
||||
const bar = d.getElementById('bulkDelBar');
|
||||
ok('bar exists', !!bar);
|
||||
ok('bar hidden when nothing selected', bar && bar.style.display === 'none',
|
||||
bar && bar.style.display);
|
||||
|
||||
// Tick team 2 -> bar appears, count updates.
|
||||
boxes()[1].checked = true;
|
||||
boxes()[1].dispatchEvent(new w.Event('change', { bubbles: true }));
|
||||
ok('bar shows after first tick', bar.style.display !== 'none', bar.style.display);
|
||||
ok('TEAM_SELECTED holds team2', sel_set().has(2) && sel_set().size === 1,
|
||||
'size=' + sel_set().size);
|
||||
ok('bar mentions 1 team', /1\b/.test(bar.textContent), JSON.stringify(bar.textContent.trim().slice(0, 60)));
|
||||
|
||||
// Select all -> every team ticked. The button drives selectAllTeams(true).
|
||||
const allBtn = Array.from(d.querySelectorAll('button'))
|
||||
.find(b => /selectAllTeams\(true\)/.test(b.getAttribute('onclick') || ''));
|
||||
ok('select-all button exists', !!allBtn);
|
||||
allBtn.click();
|
||||
ok('select-all ticks every box', boxes().every(b => b.checked));
|
||||
ok('TEAM_SELECTED has all 3', sel_set().size === 3, 'size=' + sel_set().size);
|
||||
ok('bar shows 3 teams', /3/.test(bar.textContent));
|
||||
|
||||
// Bulk delete issues ONE POST with the selected indices.
|
||||
calls.length = 0;
|
||||
const goBtn = Array.from(d.querySelectorAll('#bulkDelBar button'))
|
||||
.find(b => /bulkDeleteTeams\(\)/.test(b.getAttribute('onclick') || ''));
|
||||
ok('bulk delete button appears in the bar', !!goBtn);
|
||||
goBtn.click();
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
const post = calls.find(c => c.url.includes('bulk-delete') && c.method === 'POST');
|
||||
ok('bulk delete POSTs once', !!post, JSON.stringify(calls.map(c => c.method + ' ' + c.url)));
|
||||
if (post) {
|
||||
const payload = JSON.parse(post.body);
|
||||
ok('payload carries all 3 indices', JSON.stringify(payload.indices) === '[1,2,3]',
|
||||
JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// Clear selection hides the bar again.
|
||||
call('clearTeamSelection');
|
||||
ok('clear empties selection', sel_set().size === 0);
|
||||
ok('bar hidden after clear', bar.style.display === 'none', bar.style.display);
|
||||
ok('checkboxes cleared too', boxes().every(b => !b.checked));
|
||||
|
||||
console.log(`\n ${pass} passed, ${fail} failed`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
})();
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify /api/credential reports the SSH user the container ACTUALLY has.
|
||||
|
||||
The panel proxies to the global receiver, which only knows the 6 native
|
||||
GEMASTIK XVIII challenges -- the 10 imported XVI/XVII ones 500 there. For those
|
||||
the UI must read the credential from the TEAM's own receiver (which is the
|
||||
one that actually runs the checkers), not from :18080.
|
||||
|
||||
This test reports, per team, the username /credential would show vs the
|
||||
`ssh_user` the registry (and chpasswd) uses.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
BASE = Path("/opt/gemastik18-final")
|
||||
ENV = BASE / "panel/.env"
|
||||
cfg = {}
|
||||
for line in ENV.read_text().splitlines():
|
||||
if "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
cfg[k.strip()] = v.strip()
|
||||
|
||||
PANEL = "http://127.0.0.1:18081"
|
||||
|
||||
def post(path, payload, cookie=None):
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(PANEL + path, data=data, method="POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
if cookie:
|
||||
req.add_header("Cookie", cookie)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return r.read().decode(), r.headers.get("Set-Cookie", "")
|
||||
|
||||
body, setc = post("/api/login", {"user": cfg.get("PANEL_ADMIN_USER", "admin"),
|
||||
"pass": cfg.get("PANEL_ADMIN_PASS", "")})
|
||||
cookie = "; ".join(s.split(";")[0] for s in setc.split(",") if "=" in s)
|
||||
print("login:", body)
|
||||
|
||||
def get(path):
|
||||
req = urllib.request.Request(PANEL + path, headers={"Cookie": cookie})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return r.read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return f"HTTP {e.code}"
|
||||
|
||||
reg = json.loads((BASE / "teams/challenge_registry.json").read_text())
|
||||
ssh_users = {c["name"]: c.get("ssh_user", "ctfuser") for c in reg.get("challenges", [])}
|
||||
|
||||
teams = json.loads(get("/api/teams"))["teams"]
|
||||
ok = bad = 0
|
||||
for t in teams:
|
||||
idx = t["index"]
|
||||
st = json.loads((BASE / f"teams/team{idx}/state.json").read_text())
|
||||
names = list(st["ports"].keys())
|
||||
names = [n for n in names if n not in ("receiver", "panel")]
|
||||
print(f"\n=== team{idx} ({t.get('label')}) — {len(names)} challenges ===")
|
||||
for n in sorted(names):
|
||||
raw = get(f"/api/credential/{n}?team={idx}")
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except Exception:
|
||||
d = {"error": raw[:40]}
|
||||
want = ssh_users.get(n, "?")
|
||||
got = d.get("username")
|
||||
haspw = bool(d.get("password"))
|
||||
if got == want and haspw:
|
||||
print(f" {n:<15} {got:<8} pw={'yes' if haspw else 'NO '} OK")
|
||||
ok += 1
|
||||
else:
|
||||
note = "" if got else f" <- {d.get('error', raw)[:30]}"
|
||||
print(f" {n:<15} {str(got):<8} want={want:<8} pw={'yes' if haspw else 'NO '}{note}")
|
||||
bad += 1
|
||||
|
||||
print(f"\n{ok} correct, {bad} wrong/missing")
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end: does the credential the panel SHOWS actually log in over SSH?
|
||||
|
||||
The bug being regression-tested: the panel/terminal reported `ctfuser` for all
|
||||
16 challenges, but 10 of them (the imported XVI/XVII images) only provision
|
||||
`root`, so every participant login was refused. A correct-looking JSON payload
|
||||
proves nothing -- this opens a real paramiko session per challenge with exactly
|
||||
the username/password/port the UI hands out.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import paramiko
|
||||
from pathlib import Path
|
||||
|
||||
BASE = Path("/opt/gemastik18-final")
|
||||
sys.path.insert(0, str(BASE / "panel"))
|
||||
import teams # noqa: E402
|
||||
|
||||
TEAM = int(sys.argv[1]) if len(sys.argv) > 1 else 1
|
||||
st = teams.team_state(TEAM)
|
||||
if not st:
|
||||
print(f"team{TEAM} has no state.json")
|
||||
sys.exit(1)
|
||||
|
||||
users = teams.challenge_ssh_users()
|
||||
ok = bad = 0
|
||||
failures = []
|
||||
|
||||
for name, _coff, _soff in teams.CHALLENGES:
|
||||
p = st.get("ports", {}).get(name)
|
||||
if not p:
|
||||
continue
|
||||
user = users.get(name, "ctfuser")
|
||||
pw = st.get("chall_passwords", {}).get(name) or ""
|
||||
port = p["ssh"]
|
||||
cli = paramiko.SSHClient()
|
||||
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
cli.connect("127.0.0.1", port=port, username=user, password=pw,
|
||||
timeout=12, allow_agent=False, look_for_keys=False)
|
||||
_, out, _ = cli.exec_command("whoami; hostname", timeout=12)
|
||||
got = out.read().decode().strip().replace("\n", " | ")
|
||||
# The container must actually be the account we claim it is.
|
||||
actual = got.split(" | ")[0].strip() if got else "?"
|
||||
if actual == user:
|
||||
print(f" {name:<15} {user:<8} :{port} OK -> {got}")
|
||||
ok += 1
|
||||
else:
|
||||
print(f" {name:<15} {user:<8} :{port} WHOAMI MISMATCH -> {got}")
|
||||
failures.append((name, user, actual))
|
||||
bad += 1
|
||||
except Exception as e:
|
||||
msg = type(e).__name__
|
||||
print(f" {name:<15} {user:<8} :{port} LOGIN FAILED ({msg})")
|
||||
failures.append((name, user, msg))
|
||||
bad += 1
|
||||
finally:
|
||||
try:
|
||||
cli.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"\nteam{TEAM}: {ok} logins OK, {bad} failed")
|
||||
if failures:
|
||||
print("failures:")
|
||||
for f in failures:
|
||||
print(" ", f)
|
||||
sys.exit(1 if bad else 0)
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
|
||||
@@ -30,7 +31,13 @@ class Challenge(object):
|
||||
pwd = os.environ.get(f'PASSWORD_{self.port}')
|
||||
if not pwd:
|
||||
pwd = getattr(self.settings, f'PASSWORD_{self.port}', '')
|
||||
# SSH login user is PER-CHALLENGE, not per-package: the imported XVI/XVII
|
||||
# Dockerfiles do `echo root:${PASSWORD} | chpasswd`, so a hardcoded
|
||||
# ctfuser here handed participants a login that could never work.
|
||||
# gen_receiver_services.py injects SSH_USER_<port> from the registry,
|
||||
# which is the single source of truth; the literal is only a fallback.
|
||||
user = os.environ.get(f'SSH_USER_{self.port}', 'ctfuser')
|
||||
return {
|
||||
'username': 'ctfuser',
|
||||
'username': user,
|
||||
'password': pwd,
|
||||
}
|
||||
|
||||
@@ -42,7 +42,13 @@ class Challenge(object):
|
||||
pwd = os.environ.get(f'PASSWORD_{self.port}')
|
||||
if not pwd:
|
||||
pwd = getattr(self.settings, f'PASSWORD_{self.port}', '')
|
||||
# SSH login user is PER-CHALLENGE, not per-package: the imported XVII
|
||||
# Dockerfiles also do `echo root:${PASSWORD} | chpasswd`, so reporting
|
||||
# ctfuser here handed participants a login that could never work.
|
||||
# gen_receiver_services.py injects SSH_USER_<port> from the registry,
|
||||
# which is the single source of truth; the literal is only a fallback.
|
||||
user = os.environ.get(f'SSH_USER_{self.port}', 'ctfuser')
|
||||
return {
|
||||
'username': 'ctfuser',
|
||||
'username': user,
|
||||
'password': pwd,
|
||||
}
|
||||
Reference in New Issue
Block a user