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:
1 parent
0a56906b18
commit
50cb782ded
15 files changed
+820
-28
No files matched your search
@@ -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")
|
||||
Reference in new issue
Block a user