fix: критичные баги — отсутствие auth на pack файлах, сломанный proxy fallback, race condition installInProgress, JSON парсинг, падение loadMetadata

This commit is contained in:
SashegDev
2026-06-30 10:58:24 +00:00
parent b493b3278b
commit 0a2b80ed06
5 changed files with 283 additions and 147 deletions
+254 -93
View File
@@ -46,8 +46,8 @@ VERSIONS_DIR = BUILDS_DIR / "versions"
# Mirror configuration
LAUNCHER_MIRRORS = {
"main": "http://87.120.187.36:1582",
"mirror-1": "http://212.22.82.243:1582",
"main": "https://api.zernmc.ru",
"mirror-1": "https://api.zernmc.online",
}
# Server role: "main" or "mirror"
@@ -57,12 +57,11 @@ SYNC_API_KEY = os.environ.get("SYNC_API_KEY", "changeme") # API key for mirror
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta") # Master key for admin/mirror
# IP Filtering Configuration
import os
import middleware as mw
# Only configure manually blocked IPs at import time
# Public blocklists are loaded in lifespan (once, not per-worker)
MANUAL_BLOCKED_IPS = set(os.environ.get("BLOCKED_IPS", "").split(",")) - {""}
MANUAL_BLOCKED_IPS = set(os.environ.get("BLOCKED_IPS", "").split(",")) - {""} # noqa: F811
# Cache file for blocklist (load once)
BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt")
@@ -169,49 +168,17 @@ async def lifespan(app: FastAPI):
logger.info("All packs ready. Server is running.")
# Mirror sync with main server
# Mirror sync with main server (full diff: hash check + delete stale)
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
logger.info(f"Mirror mode: syncing from {MAIN_SERVER_URL}")
try:
async with httpx.AsyncClient() as client:
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/version")
main_data = resp.json()
main_version = main_data.get("version")
logger.info(f"Main server version: {main_version}")
# Get sync manifest with API key
headers = {"X-Sync-Key": SYNC_API_KEY}
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/sync/{main_version}", headers=headers)
if resp.status_code != 200:
logger.warning(f"Sync failed: {resp.status_code} - {resp.text}")
raise Exception(f"Sync auth failed: {resp.status_code}")
sync_data = resp.json()
logger.info(f"Need to sync {len(sync_data.get('files', []))} files")
# Download each file
for f in sync_data.get("files", []):
file_path = BUILDS_DIR / f["path"]
if not file_path.exists():
logger.info(f"Syncing: {f['path']}")
file_path.parent.mkdir(parents=True, exist_ok=True)
# Download file
file_url = f"{MAIN_SERVER_URL}/launcher/sync/{main_version}/file/{f['path']}"
resp = await client.get(file_url, headers=headers)
file_path.write_bytes(resp.content)
logger.debug(f"Downloaded: {f['path']}")
# Delete removed files
for deleted_file in sync_data.get("delete", []):
del_path = BUILDS_DIR / deleted_file
if del_path.exists():
del_path.unlink()
logger.info(f"Deleted: {deleted_file}")
logger.info("Mirror sync complete")
result = await sync_mirror_full(client, headers, MAIN_SERVER_URL)
if result:
await sync_mirror_self_update(client, headers, MAIN_SERVER_URL)
except Exception as e:
logger.warning(f"Mirror sync failed: {e}")
logger.warning(f"Initial mirror sync failed: {e}")
# Scan launcher versions and generate meta
logger.info("Scanning launcher versions...")
@@ -237,49 +204,18 @@ async def lifespan(app: FastAPI):
global proxy_client
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
# Start background sync task for mirrors
# Start background sync + self-update task for mirrors
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
import asyncio
async def periodic_sync():
sync_interval = 7200 # 2 hours
sync_interval = 1800 # 30 minutes
while True:
await asyncio.sleep(sync_interval)
try:
logger.info("Periodic mirror sync started...")
headers = {"X-Sync-Key": SYNC_API_KEY}
resp = await proxy_client.get(f"{MAIN_SERVER_URL}/launcher/version")
main_data = resp.json()
main_version = main_data.get("version")
resp = await proxy_client.get(
f"{MAIN_SERVER_URL}/launcher/sync/{main_version}",
headers=headers
)
if resp.status_code != 200:
logger.warning(f"Periodic sync failed: {resp.status_code}")
continue
sync_data = resp.json()
logger.info(f"Periodic sync: {len(sync_data.get('files', []))} files")
for f in sync_data.get("files", []):
file_path = BUILDS_DIR / f["path"]
if not file_path.exists():
file_path.parent.mkdir(parents=True, exist_ok=True)
file_url = f"{MAIN_SERVER_URL}/launcher/sync/{main_version}/file/{f['path']}"
resp = await proxy_client.get(file_url, headers=headers)
file_path.write_bytes(resp.content)
logger.debug(f"Synced: {f['path']}")
for deleted_file in sync_data.get("delete", []):
del_path = BUILDS_DIR / deleted_file
if del_path.exists():
del_path.unlink()
logger.info(f"Deleted: {deleted_file}")
logger.info("Periodic mirror sync complete")
result = await sync_mirror_full(proxy_client, headers, MAIN_SERVER_URL)
if result:
await sync_mirror_self_update(proxy_client, headers, MAIN_SERVER_URL)
except Exception as e:
logger.warning(f"Periodic sync error: {e}")
@@ -294,6 +230,236 @@ async def lifespan(app: FastAPI):
logger.info("Server shutting down...")
# ====================== MIRROR SYNC HELPERS ======================
async def _calculate_file_hash_async(file_path: Path) -> Optional[str]:
"""Calculate SHA256 hash of a file asynchronously"""
try:
loop = asyncio.get_running_loop()
def _hash():
h = hashlib.sha256()
with open(file_path, "rb") as f:
while chunk := f.read(65536):
h.update(chunk)
return h.hexdigest()
return await loop.run_in_executor(None, _hash)
except Exception:
return None
async def sync_mirror_full(
client: httpx.AsyncClient,
headers: dict,
main_url: str,
) -> bool:
"""Full mirror sync: compare by hash, download new/changed, delete stale.
Returns True if sync completed without critical errors."""
try:
# Get latest version from main
resp = await client.get(f"{main_url}/launcher/version", timeout=15)
resp.raise_for_status()
main_data = resp.json()
main_version = main_data.get("version")
if not main_version:
logger.warning("sync_mirror_full: no version from main")
return False
logger.info(f"sync_mirror_full: main version = {main_version}")
# Get sync manifest (full file list with hashes)
resp = await client.get(
f"{main_url}/launcher/sync/{main_version}", headers=headers, timeout=30
)
if resp.status_code != 200:
logger.warning(f"sync_mirror_full: manifest returned {resp.status_code}")
return False
sync_data = resp.json()
server_files = sync_data.get("files", [])
logger.info(f"sync_mirror_full: {len(server_files)} files in manifest")
# Build server file map
server_map = {}
for f in server_files:
server_map[f["path"]] = f
# Scan local builds directory
local_files = {}
if BUILDS_DIR.exists():
for fpath in BUILDS_DIR.rglob("*"):
if fpath.is_file() and fpath.name not in ("meta.json", "blocklist_cache.txt"):
rel = str(fpath.relative_to(BUILDS_DIR))
local_files[rel] = ""
# Download new/changed files
downloaded = 0
for path, info in server_map.items():
local_path = BUILDS_DIR / path
need_download = False
if not local_path.exists():
need_download = True
logger.info(f" New: {path}")
else:
local_hash = await _calculate_file_hash_async(local_path)
server_hash = info.get("hash", "").replace("sha256:", "")
if local_hash and local_hash != server_hash:
need_download = True
logger.info(f" Changed: {path}")
if need_download:
local_path.parent.mkdir(parents=True, exist_ok=True)
file_url = f"{main_url}/launcher/sync/{main_version}/file/{path}"
try:
resp = await client.get(file_url, headers=headers, timeout=120)
resp.raise_for_status()
local_path.write_bytes(resp.content)
downloaded += 1
logger.debug(f" Downloaded: {path}")
except Exception as e:
logger.warning(f" Failed to download {path}: {e}")
# Delete files that exist locally but not on server
deleted = 0
for local_path in list(local_files.keys()):
if local_path not in server_map:
del_path = BUILDS_DIR / local_path
try:
del_path.unlink()
deleted += 1
logger.info(f" Deleted stale: {local_path}")
except Exception as e:
logger.warning(f" Failed to delete {local_path}: {e}")
# Clean empty directories
for fpath in sorted(BUILDS_DIR.rglob("*"), key=lambda p: str(p), reverse=True):
if fpath.is_dir() and fpath != BUILDS_DIR:
try:
if not any(fpath.iterdir()):
fpath.rmdir()
logger.debug(f" Removed empty dir: {fpath.relative_to(BUILDS_DIR)}")
except Exception:
pass
logger.info(f"sync_mirror_full: downloaded={downloaded}, deleted={deleted}")
return True
except httpx.HTTPStatusError as e:
logger.warning(f"sync_mirror_full HTTP error: {e.response.status_code} - {e.response.text[:200]}")
return False
except Exception as e:
logger.warning(f"sync_mirror_full error: {e}")
return False
async def sync_mirror_self_update(
client: httpx.AsyncClient,
headers: dict,
main_url: str,
) -> bool:
"""Check if the mirror's own launcher distribution needs updating.
Downloads new ZIP + extracts it, or replaces the bootstrap JAR."""
try:
# Get current launcher version info
resp = await client.get(f"{main_url}/launcher/version", timeout=15)
resp.raise_for_status()
version_info = resp.json()
server_version = version_info.get("version", "")
if not server_version:
return False
# Check current local version
local_version = "0.0.0"
build_version_file = BUILDS_DIR / "build.version"
if build_version_file.exists():
local_version = build_version_file.read_text().strip()
if local_version == server_version:
logger.debug("sync_mirror_self_update: already up to date")
return False
logger.info(f"sync_mirror_self_update: local={local_version}, server={server_version}")
# Parse versions
def parse_ver(v: str) -> tuple:
try:
return tuple(int(x) for x in v.split("."))
except Exception:
return (0, 0, 0)
if not (parse_ver(server_version) > parse_ver(local_version)):
logger.debug("sync_mirror_self_update: server version not newer")
return False
# Get latest ZIP info from the version endpoint
zip_filename = None
zip_info = version_info.get("all_zips", [])
if zip_info:
zip_filename = zip_info[0].get("filename")
if not zip_filename:
logger.warning("sync_mirror_self_update: no ZIP found in version info")
return False
# Download the new launcher ZIP to a temp file
logger.info(f"sync_mirror_self_update: downloading {zip_filename}")
zip_url = f"{main_url}/launcher/download/zip/{zip_filename}"
zip_path = BUILDS_DIR / f".sync_{zip_filename}"
resp = await client.get(zip_url, timeout=300)
resp.raise_for_status()
zip_path.write_bytes(resp.content)
logger.info(f"sync_mirror_self_update: downloaded {len(resp.content)} bytes")
# Extract to a temp directory
import zipfile
import tempfile
import shutil
with tempfile.TemporaryDirectory() as tmpdir:
extract_dir = Path(tmpdir) / "extracted"
extract_dir.mkdir(parents=True)
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(extract_dir)
# Move extracted files to builds directory, replacing old ones
moved = 0
for item in extract_dir.rglob("*"):
if item.is_file() and item.name not in ("meta.json", "build.version"):
rel = item.relative_to(extract_dir)
target = BUILDS_DIR / rel
target.parent.mkdir(parents=True, exist_ok=True)
try:
shutil.copy2(item, target)
moved += 1
except Exception as e:
logger.warning(f" Failed to copy {rel}: {e}")
logger.info(f"sync_mirror_self_update: moved {moved} files")
# Clean up temp ZIP
zip_path.unlink(missing_ok=True)
# Update version file
build_version_file.write_text(server_version)
logger.info(f"sync_mirror_self_update: updated to v{server_version}")
# Regenerate meta for the new version
generate_launcher_builds_meta()
extract_new_format_versions()
# Write a restart flag so the process manager can restart the server
restart_flag = BUILDS_DIR / ".restart-required"
restart_flag.write_text(server_version)
logger.warning(f"sync_mirror_self_update: restart flag written ({restart_flag})")
logger.warning("sync_mirror_self_update: server restart recommended to pick up new launcher files")
return True
except Exception as e:
logger.warning(f"sync_mirror_self_update error: {e}")
return False
# ====================== ШАБЛОН СТРАНИЦЫ АКТИВАЦИИ ======================
ACTIVATE_PASS_HTML = """
@@ -647,10 +813,12 @@ class CacheControlMiddleware:
await self.app(scope, receive, send)
return
async def send_wrapper(status, headers, *args, **kwargs):
cache_headers = [(b"cache-control", b"public, max-age=86400")]
headers = list(headers) + cache_headers
await send(status, headers, *args, **kwargs)
async def send_wrapper(message):
if message.get("type") == "http.response.start":
headers = message.get("headers", [])
cache_headers = [(b"cache-control", b"public, max-age=86400")]
message["headers"] = list(headers) + cache_headers
await send(message)
await self.app(scope, receive, send_wrapper)
@@ -840,7 +1008,7 @@ async def list_packs(
with open(meta_path, 'r', encoding='utf-8') as f:
meta = json.load(f)
updated_at = meta.get("updated_at")
if updated_at and isinstance(updated_at, datetime):
if isinstance(updated_at, datetime):
updated_at = updated_at.isoformat()
desc_path = pack_dir / "description.txt"
@@ -997,8 +1165,10 @@ async def get_pack_manifest(pack_name: str, request: Request, current_user: dict
@app.get("/pack/{pack_name}/file/{file_path:path}")
async def get_pack_file(pack_name: str, file_path: str, request: Request):
async def get_pack_file(pack_name: str, file_path: str, request: Request, current_user: dict = Depends(get_current_user)):
"""Get a file from a pack"""
if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK):
raise HTTPException(403, "Requires active pass")
full_path = PACKS_DIR / pack_name / file_path
client_ip = request.client.host if request.client else None
@@ -1044,8 +1214,6 @@ def get_current_launcher_version() -> str:
def parse_version(version_str: str) -> dict:
"""Parse version string to determine if it's new or legacy format"""
import re
match = re.match(r'^(\d+)\.(\d+)\.(\d+)(.*)$', version_str)
if not match:
return {"major": 0, "minor": 0, "patch": 0, "suffix": version_str, "is_legacy": True}
@@ -1073,7 +1241,6 @@ def is_new_format(filename: str) -> bool:
def calculate_file_hash(file_path: Path) -> str:
"""Calculate SHA256 hash of a file"""
import hashlib
hash_sha = hashlib.sha256()
with open(file_path, 'rb') as f:
while chunk := f.read(8192):
@@ -1083,8 +1250,6 @@ def calculate_file_hash(file_path: Path) -> str:
def generate_launcher_builds_meta():
"""Generate meta.json in builds/ directory for incremental updates"""
import hashlib
version = get_current_launcher_version()
if not version:
return
@@ -1167,7 +1332,6 @@ def scan_launcher_version(version: str) -> Optional[dict]:
# Check cache first
if meta_path.exists():
try:
import json
with open(meta_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
@@ -1195,7 +1359,6 @@ def scan_launcher_version(version: str) -> Optional[dict]:
# Save meta
try:
import json
with open(meta_path, 'w', encoding='utf-8') as f:
json.dump(meta, f, indent=2)
except Exception as e:
@@ -1614,7 +1777,7 @@ async def get_launcher_file(version: str, file_path: str, request: Request):
return await send_file_async(full_path, request, cache=True)
@app.get("/launcher/download/zip/{version}")
@app.get("/launcher/download/zip/version/{version}")
async def download_launcher_zip_version(version: str, request: Request = None):
"""Download full ZIP for specific version (for new installs)"""
zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip"
@@ -2142,10 +2305,8 @@ if __name__ == "__main__":
args = parse_args()
if args.test:
import asyncio
asyncio.run(run_test_mode())
elif args.sync:
import asyncio
asyncio.run(run_sync_mode())
elif args.dev:
run_development_mode(args.host, args.port, args.reload)