fix(ssh): per-challenge SSH login + phew buffering/leak/timeout
Passwords failed on 10/16 challenges while state.json looked correct:
- only the 6 native GEMASTIK XVIII images provision 'ctfuser'; every imported
XVI/XVII image does 'echo root:${PASSWORD} | chpasswd' and logs in as root.
set_ssh_passwords() hardcoded ctfuser, so chpasswd set a password on an
account nobody uses -> 'Permission denied' everywhere.
Registry gains a per-challenge 'ssh_user'; chpasswd now targets the real
login (and ctfuser/ctf when present) and reports failures loudly.
- phew checker: chall.py block-buffers stdout through the docker exec pipe
(PYTHONUNBUFFERED now set) and leaks chall.py inside the container on
timeout (26 orphans, container saturated) -> reaps the whole exec process
group. Startup does a fresh Pailier keygen (~12 s) so crypto reads need
_CRYPTO_TIMEOUT, not the 5 s prompt default.
Adds panel/verify_ssh_creds.py (proves the state->container binding from
inside via a real login), audit_ssh_users.sh, reset_runtime.sh.
This commit is contained in:
+57
-1
@@ -9,6 +9,8 @@ import json
|
||||
import time
|
||||
import asyncio
|
||||
import threading
|
||||
import subprocess
|
||||
import sys
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect
|
||||
@@ -530,6 +532,7 @@ async def api_teams_set(req: Request):
|
||||
labels = data.get("labels") or {} # { "1": "Tim Satu", ... }
|
||||
domains = data.get("domains") or {} # { "1": "mycustom", ... }
|
||||
created = []
|
||||
ufw = []
|
||||
for i in range(1, n + 1):
|
||||
td = orch.TEAMS_DIR / f"team{i}"
|
||||
if not td.exists():
|
||||
@@ -537,6 +540,9 @@ async def api_teams_set(req: Request):
|
||||
dom = domains.get(str(i)) or domains.get(i) or None
|
||||
st = orch.create_team(i, label, domain=dom)
|
||||
created.append(st["index"])
|
||||
# UFW defaults to deny(incoming) on this host: without these rules
|
||||
# the team's challenge/SSH/receiver ports are silently blackholed.
|
||||
ufw.append(orch.sync_team_ufw(i))
|
||||
else:
|
||||
# team exists: apply any label/domain overrides
|
||||
label = labels.get(str(i)) or labels.get(i)
|
||||
@@ -544,7 +550,7 @@ async def api_teams_set(req: Request):
|
||||
if label or dom:
|
||||
orch.update_team(i, label=label, domain=dom)
|
||||
orch.ensure_team_domains()
|
||||
return {"created": created, "total": len(orch.list_teams())}
|
||||
return {"created": created, "total": len(orch.list_teams()), "ufw": ufw}
|
||||
|
||||
@app.put("/api/teams/{idx}")
|
||||
async def api_team_update(idx: int, req: Request):
|
||||
@@ -559,6 +565,56 @@ async def api_team_update(idx: int, req: Request):
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
@app.delete("/api/teams/{idx}")
|
||||
async def api_team_delete(idx: int, req: Request):
|
||||
"""Permanently delete ONE team: containers, network, receiver unit, ports,
|
||||
directory, score records and its Traefik domain.
|
||||
|
||||
Body: {"purge_scores": true} (default) — set false to keep the team's
|
||||
leaderboard/points history. Destructive and irreversible, so the UI gates
|
||||
it behind a confirm dialog.
|
||||
"""
|
||||
require_login(req)
|
||||
raw = {}
|
||||
try:
|
||||
raw = await req.json()
|
||||
except Exception:
|
||||
pass # DELETE with no body is fine
|
||||
if not (orch.TEAMS_DIR / f"team{idx}" / "state.json").exists():
|
||||
raise HTTPException(404, f"Team {idx} not found")
|
||||
try:
|
||||
# compose down for 16 services takes a while — keep the event loop free
|
||||
result = await asyncio.to_thread(orch.delete_team, idx,
|
||||
bool(raw.get("purge_scores", True)))
|
||||
# refresh the remaining teams' generated artifacts (receiver main.py,
|
||||
# systemd units) so nothing points at the deleted team
|
||||
subprocess.run([sys.executable, str(orch.BASE / "panel" / "gen_receiver_services.py"), "start"],
|
||||
check=False, capture_output=True)
|
||||
return result
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/api/teams/{idx}/ufw")
|
||||
async def api_team_ufw(idx: int, req: Request):
|
||||
"""Reconcile UFW rules for a team's port block.
|
||||
|
||||
UFW defaults to deny(incoming) on this host, so a team whose ports were
|
||||
never opened is blackholed. Use this after creating a team out-of-band, or
|
||||
to close the ports of a team you just deleted by hand.
|
||||
Body: {"remove": true} deletes the rules instead.
|
||||
"""
|
||||
require_login(req)
|
||||
raw = {}
|
||||
try:
|
||||
raw = await req.json()
|
||||
except Exception:
|
||||
pass
|
||||
return await asyncio.to_thread(orch.sync_team_ufw, idx, bool(raw.get("remove", False)))
|
||||
|
||||
@app.post("/api/teams/start")
|
||||
async def api_teams_start(req: Request):
|
||||
require_login(req)
|
||||
|
||||
Reference in New Issue
Block a user