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,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)
|
||||
Reference in new issue
Block a user