fix: port scheme 30000 (avoid syncthing 22000), reuse base images (no per-team rebuild), recover corrupted receiver/main.py, compose -p project isolation

- PORT_BASE 20000->30000: team1=31xxx team2=32xxx; syncthing owns 22000
- create_team replaces build: with image: services-<name> so teams reuse base images (was rebuilding 6 images per team, disk 100%)
- recovery: receiver/main.py was corrupted by bad patch (write_file with read_file format); restored from team1 copy + original GitHub
- docker compose -p teamN: project isolation so team compose doesn't overlap (was showing team1 containers for team2)
This commit is contained in:
root
2026-09-23 15:44:53 +08:00
parent 1ca963b2b7
commit 8c061ba1b7
2 changed files with 178 additions and 43 deletions
+21 -8
View File
@@ -15,9 +15,9 @@ Team N layout:
state.json (team metadata: ports, admin user/pass, ssh creds, created ts) state.json (team metadata: ports, admin user/pass, ssh creds, created ts)
Port scheme (base offset per team index, index 1-based): Port scheme (base offset per team index, index 1-based):
team i: chall ports = 20000 + i*1000 + 0..5 (blogpost,carbeat,cdn,phew,sheesh,warmup) team i: chall ports = 30000 + i*1000 + 0..5 (blogpost,carbeat,cdn,phew,sheesh,warmup)
ssh ports = 20000 + i*1000 + 22..27 (10022-style) ssh ports = 30000 + i*1000 + 22..27 (10022-style)
receiver = 20000 + i*1000 + 80 (receiver API, e.g. 21080, 22080) receiver = 30000 + i*1000 + 80 (receiver API, e.g. 31080, 32080)
""" """
import json import json
import os import os
@@ -45,7 +45,7 @@ CHALLENGES = [
("warmup", 5, 27), ("warmup", 5, 27),
] ]
PORT_BASE = 20000 PORT_BASE = 30000
STEP = 1000 STEP = 1000
def team_ports(idx: int) -> dict: def team_ports(idx: int) -> dict:
@@ -100,6 +100,19 @@ def create_team(idx: int, label: str = None):
"host.docker.internal:18080", f"host.docker.internal:{recv_port}")) "host.docker.internal:18080", f"host.docker.internal:{recv_port}"))
compose = svc_dir / "docker-compose.yml" compose = svc_dir / "docker-compose.yml"
text = compose.read_text() text = compose.read_text()
# --- replace build: blocks with image: so teams reuse the base images (no rebuild) ---
# Each service's build block looks like:
# build:
# context: <name>
# args:
# - PASSWORD=$PASSWORD_XXXXX
# Replace the whole block with " image: services-<name>".
for name, coff, soff in CHALLENGES:
text = re.sub(
rf" build:\n context: {name}\n args:\n - PASSWORD=\$PASSWORD_[0-9]+\n",
f" image: services-{name}\n",
text)
compose.write_text(text)
# rewrite container names + ports per challenge # rewrite container names + ports per challenge
for name, coff, soff in CHALLENGES: for name, coff, soff in CHALLENGES:
cont_old = f"{name}_container" cont_old = f"{name}_container"
@@ -167,7 +180,7 @@ def start_team(idx: int):
if not (team_dir / "state.json").exists(): if not (team_dir / "state.json").exists():
raise FileNotFoundError(f"Team {idx} not created") raise FileNotFoundError(f"Team {idx} not created")
svc_dir = team_dir / "services" svc_dir = team_dir / "services"
subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml", "up", "-d", "--build"], subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml", "up", "-d", "--build"],
cwd=str(svc_dir), check=False, capture_output=True) cwd=str(svc_dir), check=False, capture_output=True)
_start_receiver(idx) _start_receiver(idx)
st = json.loads((team_dir / "state.json").read_text()) st = json.loads((team_dir / "state.json").read_text())
@@ -178,7 +191,7 @@ def start_team(idx: int):
def stop_team(idx: int): def stop_team(idx: int):
team_dir = TEAMS_DIR / f"team{idx}" team_dir = TEAMS_DIR / f"team{idx}"
svc_dir = team_dir / "services" svc_dir = team_dir / "services"
subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml", "down"], subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml", "down"],
cwd=str(svc_dir), check=False, capture_output=True) cwd=str(svc_dir), check=False, capture_output=True)
_stop_receiver(idx) _stop_receiver(idx)
st = json.loads((team_dir / "state.json").read_text()) st = json.loads((team_dir / "state.json").read_text())
@@ -271,9 +284,9 @@ def randomize_flags(idx: int) -> dict:
# rotate into containers: compose mounts the flag files read-only, so # rotate into containers: compose mounts the flag files read-only, so
# drop+recreate the challenge containers to pick up new flags # drop+recreate the challenge containers to pick up new flags
svc_dir = team_dir / "services" svc_dir = team_dir / "services"
subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml", subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml",
"down"], cwd=str(svc_dir), check=False, capture_output=True) "down"], cwd=str(svc_dir), check=False, capture_output=True)
subprocess.run(["docker", "compose", "-f", svc_dir / "docker-compose.yml", subprocess.run(["docker", "compose", "-p", f"team{idx}", "-f", svc_dir / "docker-compose.yml",
"up", "-d"], cwd=str(svc_dir), check=False, capture_output=True) "up", "-d"], cwd=str(svc_dir), check=False, capture_output=True)
_start_receiver(idx) _start_receiver(idx)
st = json.loads((team_dir / "state.json").read_text()) st = json.loads((team_dir / "state.json").read_text())
+157 -35
View File
@@ -1,28 +1,28 @@
1|from fastapi import Depends, FastAPI, HTTPException from fastapi import Depends, FastAPI, HTTPException
2|from pydantic import BaseModel from pydantic import BaseModel
3|from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
4|from config import get_settings from config import get_settings
5|
6|from challenges.Blogpost import Blogpost from challenges.Blogpost import Blogpost
7|from challenges.Carbeat import Carbeat from challenges.Carbeat import Carbeat
8|from challenges.CDN import CDN from challenges.CDN import CDN
9|from challenges.Phew import Phew from challenges.Phew import Phew
10|from challenges.Sheesh import Sheesh from challenges.Sheesh import Sheesh
11|from challenges.Warmup import Warmup from challenges.Warmup import Warmup
12|
13|import os import os
14|import asyncio import asyncio
15|import logging import logging
16|
17|# Setup logging # Setup logging
18|logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
19|logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
20|
21|app = FastAPI() app = FastAPI()
22|security = HTTPBasic() security = HTTPBasic()
23|settings = get_settings() settings = get_settings()
24|
25|def _ch_port(name: str, default: int) -> int: def _ch_port(name: str, default: int) -> int:
# read from .env manually (pydantic settings has fixed fields) # read from .env manually (pydantic settings has fixed fields)
val = os.environ.get(f"CHALLENGE_PORT_{name.upper()}") val = os.environ.get(f"CHALLENGE_PORT_{name.upper()}")
if not val: if not val:
@@ -46,14 +46,136 @@ def _ch_container(name: str, default: str) -> str:
except Exception: except Exception:
pass pass
return val or default return val or default
challenges = { challenges = {
32| "blogpost": Blogpost(_ch_port("blogpost", 10000)), "blogpost": Blogpost(_ch_port("blogpost", 10000)),
33| "carbeat": Carbeat(_ch_port("carbeat", 11000)), "carbeat": Carbeat(_ch_port("carbeat", 11000)),
34| "cdn": CDN(_ch_port("cdn", 12000)), "cdn": CDN(_ch_port("cdn", 12000)),
35| "phew": Phew(_ch_port("phew", 13000)), "phew": Phew(_ch_port("phew", 13000)),
36| "sheesh": Sheesh(_ch_port("sheesh", 14000)), "sheesh": Sheesh(_ch_port("sheesh", 14000)),
37| "warmup": Warmup(_ch_port("warmup", 15000)), "warmup": Warmup(_ch_port("warmup", 15000)),
38|} }
39|
40|async def run_challenge_checks(): async def run_challenge_checks():
41| """Run check function on all challenges at startup"""
logger.info("\n" + "="*60)
logger.info("Running challenge checks...")
logger.info("="*60 + "\n")
results = {}
for name, challenge in challenges.items():
logger.info(f"\n[{name}] Starting check...")
try:
# Give service time between checks
await asyncio.sleep(2)
result = challenge.check()
results[name] = result
if result:
logger.info(f"[{name}] ✓ Check PASSED")
else:
logger.warning(f"[{name}] ✗ Check FAILED")
except Exception as e:
logger.error(f"[{name}] ✗ Check ERROR: {e}")
results[name] = False
# Print summary
logger.info("\n" + "="*60)
logger.info("Challenge Check Summary:")
logger.info("="*60)
passed = sum(1 for r in results.values() if r)
total = len(results)
for name, result in results.items():
status = "✓ PASS" if result else "✗ FAIL"
logger.info(f" {name:20} {status}")
logger.info(f"\nTotal: {passed}/{total} passed")
logger.info("="*60 + "\n")
return results
@app.on_event("startup")
async def startup_event():
"""Run challenge checks on application startup"""
asyncio.create_task(run_challenge_checks())
class Flag(BaseModel):
flag: str
challenge: str
class History(BaseModel):
log: str
@app.get("/")
def read_root():
return {"service": "receiver-service"}
@app.get("/restart/{challenge}")
def restart(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
os.system(f"docker compose -f {settings.COMPOSE_LOCATION} restart {challenge}")
return {"message": "Challenge restarted"}
@app.get("/rollback/{challenge}")
def rollback(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
os.system(f"docker compose -f {settings.COMPOSE_LOCATION} up -d --force-recreate {challenge}")
return {"message": "Challenge restarted"}
@app.get("/activate/{challenge}")
def activate(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
os.system(f"docker compose -f {settings.COMPOSE_LOCATION} up -d {challenge}")
return {"message": "Challenge activated"}
@app.get("/deactivate/{challenge}")
def deactive(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
os.system(f"docker compose -f {settings.COMPOSE_LOCATION} down {challenge}")
return {"message": "Challenge deactivated"}
@app.get("/credential/{challenge}")
def credential(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
return challenges[challenge].credentials()
@app.post("/flag")
def receive(data: Flag, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, data.challenge)
challenge = challenges[data.challenge]
if challenge.distribute(data.flag):
return {"message": "Flag received"}
raise HTTPException(status_code=500, detail="Error receiving flag")
@app.get("/check/{challenge}")
def check(challenge: str, credentials: HTTPBasicCredentials = Depends(security)):
validate(credentials, challenge)
return {"success": challenges[challenge].check()}
@app.post("/history")
def history(data: History):
with open('history/command.txt', 'a') as f:
f.write(data.log + '\n')
return {"message": "Command received"}
def is_admin(credentials):
if credentials.username != settings.ADMIN_USERNAME or credentials.password != settings.ADMIN_PASSWORD:
return False
return True
def validate(credentials, challenge):
if not is_admin(credentials):
raise HTTPException(status_code=401, detail="Invalid credentials")
if challenge not in challenges:
raise HTTPException(status_code=400, detail="Invalid challenge")