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)
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)
ssh ports = 20000 + i*1000 + 22..27 (10022-style)
receiver = 20000 + i*1000 + 80 (receiver API, e.g. 21080, 22080)
team i: chall ports = 30000 + i*1000 + 0..5 (blogpost,carbeat,cdn,phew,sheesh,warmup)
ssh ports = 30000 + i*1000 + 22..27 (10022-style)
receiver = 30000 + i*1000 + 80 (receiver API, e.g. 31080, 32080)
"""
import json
import os
@@ -45,7 +45,7 @@ CHALLENGES = [
("warmup", 5, 27),
]
PORT_BASE = 20000
PORT_BASE = 30000
STEP = 1000
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}"))
compose = svc_dir / "docker-compose.yml"
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
for name, coff, soff in CHALLENGES:
cont_old = f"{name}_container"
@@ -167,7 +180,7 @@ def start_team(idx: int):
if not (team_dir / "state.json").exists():
raise FileNotFoundError(f"Team {idx} not created")
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)
_start_receiver(idx)
st = json.loads((team_dir / "state.json").read_text())
@@ -178,7 +191,7 @@ def start_team(idx: int):
def stop_team(idx: int):
team_dir = TEAMS_DIR / f"team{idx}"
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)
_stop_receiver(idx)
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
# drop+recreate the challenge containers to pick up new flags
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)
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)
_start_receiver(idx)
st = json.loads((team_dir / "state.json").read_text())
+157 -35
View File
@@ -1,28 +1,28 @@
1|from fastapi import Depends, FastAPI, HTTPException
2|from pydantic import BaseModel
3|from fastapi.security import HTTPBasic, HTTPBasicCredentials
4|from config import get_settings
5|
6|from challenges.Blogpost import Blogpost
7|from challenges.Carbeat import Carbeat
8|from challenges.CDN import CDN
9|from challenges.Phew import Phew
10|from challenges.Sheesh import Sheesh
11|from challenges.Warmup import Warmup
12|
13|import os
14|import asyncio
15|import logging
16|
17|# Setup logging
18|logging.basicConfig(level=logging.INFO)
19|logger = logging.getLogger(__name__)
20|
21|app = FastAPI()
22|security = HTTPBasic()
23|settings = get_settings()
24|
25|def _ch_port(name: str, default: int) -> int:
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from config import get_settings
from challenges.Blogpost import Blogpost
from challenges.Carbeat import Carbeat
from challenges.CDN import CDN
from challenges.Phew import Phew
from challenges.Sheesh import Sheesh
from challenges.Warmup import Warmup
import os
import asyncio
import logging
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
security = HTTPBasic()
settings = get_settings()
def _ch_port(name: str, default: int) -> int:
# read from .env manually (pydantic settings has fixed fields)
val = os.environ.get(f"CHALLENGE_PORT_{name.upper()}")
if not val:
@@ -46,14 +46,136 @@ def _ch_container(name: str, default: str) -> str:
except Exception:
pass
return val or default
challenges = {
32| "blogpost": Blogpost(_ch_port("blogpost", 10000)),
33| "carbeat": Carbeat(_ch_port("carbeat", 11000)),
34| "cdn": CDN(_ch_port("cdn", 12000)),
35| "phew": Phew(_ch_port("phew", 13000)),
36| "sheesh": Sheesh(_ch_port("sheesh", 14000)),
37| "warmup": Warmup(_ch_port("warmup", 15000)),
38|}
39|
40|async def run_challenge_checks():
41|
"blogpost": Blogpost(_ch_port("blogpost", 10000)),
"carbeat": Carbeat(_ch_port("carbeat", 11000)),
"cdn": CDN(_ch_port("cdn", 12000)),
"phew": Phew(_ch_port("phew", 13000)),
"sheesh": Sheesh(_ch_port("sheesh", 14000)),
"warmup": Warmup(_ch_port("warmup", 15000)),
}
async def run_challenge_checks():
"""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")