From 50cb782ded9a557fde33b451798dd7abf38a6585 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 26 Sep 2026 16:37:40 +0800 Subject: [PATCH] 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 `ctfuser` 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_ from the registry so the receiver's /credential endpoint agrees with the panel - receiver Challenge.credentials() honours SSH_USER_ (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. --- panel/bulk_delete_test.sh | 58 ++++++++++ panel/check_all_js.js | 25 +++++ panel/check_ui_js.js | 20 ++++ panel/gen_receiver_services.py | 8 ++ panel/inject_receiver_ssh_users.py | 76 +++++++++++++ panel/main.py | 153 +++++++++++++++++++++++++- panel/static/guide.html | 4 +- panel/static/index.html | 123 +++++++++++++++++++-- panel/static/team.html | 61 +++++++--- panel/teams.py | 50 +++++++++ panel/test_bulk_delete_ui.js | 103 +++++++++++++++++ panel/verify_cred_users.py | 82 ++++++++++++++ panel/verify_ssh_e2e.py | 68 ++++++++++++ receiver/challenges/Challenge.py | 9 +- receiver/challenges/xvii/Challenge.py | 8 +- 15 files changed, 820 insertions(+), 28 deletions(-) create mode 100644 panel/bulk_delete_test.sh create mode 100644 panel/check_all_js.js create mode 100644 panel/check_ui_js.js create mode 100644 panel/inject_receiver_ssh_users.py create mode 100644 panel/test_bulk_delete_ui.js create mode 100644 panel/verify_cred_users.py create mode 100644 panel/verify_ssh_e2e.py diff --git a/panel/bulk_delete_test.sh b/panel/bulk_delete_test.sh new file mode 100644 index 0000000..799caeb --- /dev/null +++ b/panel/bulk_delete_test.sh @@ -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']])")" diff --git a/panel/check_all_js.js b/panel/check_all_js.js new file mode 100644 index 0000000..ef31c4b --- /dev/null +++ b/panel/check_all_js.js @@ -0,0 +1,25 @@ +// Syntax-check every inline