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:
root committed 2026-09-26 16:37:40 +08:00
1 parent 0a56906b18
commit 50cb782ded
15 files changed
+820 -28

No files matched your search

+8
View File
@@ -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)