v1.0.16.13 — TSPU/SNI-obkhod: domain selection at login + dynamic mirrors
- add DomainSelector: probe every API candidate at launcher startup, pick
fastest reachable (primary api.zern.cc, legacy .ru/.online, geo pl/swe);
integrate into CLI start and JFX network init
- Config.setServerUrl() + persist chosen domain in launcher.properties
- ZHttpClient: default BASE_URL and ZERN_SERVER health-check follow the
selected domain (runtime failover instead of hardcoded api.zernmc.ru)
- server: LAUNCHER_MIRRORS main=api.zern.cc (primary), legacy .ru/.online,
geo-pl api.pl.zern.cc, geo-swe api.swe.zern.cc; /launcher/mirrors exposes them
- diag: check all TLD+geo API hosts (api.{pl,ru,swe}.zern.cc, .ru, .online)
for DNS/TCP/HTTP, known server IPs for all 4 geo nodes
- reverse-proxy geo boxes (pl, swe) -> main:1582 so clients bypass TSPU via
an unblocked SNI; ru left as-is (VLESS VPN box, not proxying API)
This commit is contained in:
+89
-10
@@ -48,8 +48,11 @@ WHITELIST_DIR = Path(__file__).parent / "whitelist"
|
||||
|
||||
# Mirror configuration
|
||||
LAUNCHER_MIRRORS = {
|
||||
"main": "https://api.zernmc.ru",
|
||||
"mirror-1": "https://api.zernmc.online",
|
||||
"main": "https://api.zern.cc", # primary
|
||||
"mirror-1": "https://api.zernmc.ru", # legacy
|
||||
"mirror-2": "https://api.zernmc.online", # legacy
|
||||
"geo-pl": "https://api.pl.zern.cc",
|
||||
"geo-swe": "https://api.swe.zern.cc",
|
||||
}
|
||||
|
||||
# Server role: "main" or "mirror"
|
||||
@@ -71,6 +74,9 @@ BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt")
|
||||
# Crash reports directory
|
||||
CRASH_REPORTS_DIR = Path("data/crash_reports")
|
||||
|
||||
# Network diagnostics reports directory
|
||||
DIAG_REPORTS_DIR = Path("data/diag_reports")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -1350,6 +1356,14 @@ def generate_launcher_builds_meta():
|
||||
# CLI exe is not shipped to users
|
||||
if rel_path == "zernmc-cli.exe":
|
||||
continue
|
||||
# zernmc.exe embeds the launcher version, so its hash changes on
|
||||
# every build even when the bootstrap code is unchanged. Exclude it
|
||||
# from the incremental update: a new exe is only delivered via a
|
||||
# full ZIP reinstall. Without this the bootstrap would re-download
|
||||
# and re-stage its own exe on every launcher version bump, and the
|
||||
# detached .cmd helper fails with "file not found".
|
||||
if rel_path == "zernmc.exe":
|
||||
continue
|
||||
stat = file_path.stat()
|
||||
|
||||
# Calculate hash
|
||||
@@ -1391,6 +1405,8 @@ def generate_version_meta(version_path: Path, version: str) -> dict:
|
||||
for file_path in version_path.rglob("*"):
|
||||
if file_path.is_file() and file_path.name != "meta.json":
|
||||
rel_path = str(file_path.relative_to(version_path))
|
||||
if rel_path in ("zernmc.exe", "zernmc-cli.exe"):
|
||||
continue
|
||||
stat = file_path.stat()
|
||||
file_hash = calculate_file_hash(file_path)
|
||||
files.append({
|
||||
@@ -1488,13 +1504,18 @@ def extract_new_format_versions():
|
||||
# Find all ZernMC-win-*.zip files
|
||||
new_format_zips = list(BUILDS_DIR.glob("ZernMC-win-*.zip"))
|
||||
|
||||
total = len(new_format_zips)
|
||||
extracted = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
for zip_file in new_format_zips:
|
||||
version = zip_file.stem.replace("ZernMC-win-", "")
|
||||
extract_dir = VERSIONS_DIR / version
|
||||
|
||||
# Skip if already extracted and meta exists
|
||||
if extract_dir.exists() and (extract_dir / "meta.json").exists():
|
||||
logger.debug(f"Version {version} already extracted")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
logger.info(f"Extracting {zip_file.name} to versions/{version}/...")
|
||||
@@ -1511,9 +1532,13 @@ def extract_new_format_versions():
|
||||
# writes versions/<v>/meta.json, so every scan would re-extract the zip.
|
||||
generate_version_meta(extract_dir, version)
|
||||
|
||||
extracted += 1
|
||||
logger.info(f"Extracted {zip_file.name} successfully")
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.error(f"Failed to extract {zip_file.name}: {e}")
|
||||
|
||||
logger.info(f"Version scan complete: {total} total, {extracted} extracted, {skipped} skipped (already present), {failed} failed")
|
||||
|
||||
|
||||
# ====================== END ЛАУНЧЕР МЕТА СИСТЕМА ======================
|
||||
@@ -1594,6 +1619,14 @@ def get_legacy_zips() -> list:
|
||||
return zips
|
||||
|
||||
|
||||
@app.get("/launcher/ip")
|
||||
async def get_launcher_client_ip(request: Request):
|
||||
"""Return the public IP that the server sees for this client."""
|
||||
from middleware import get_client_ip
|
||||
client_ip = get_client_ip(request)
|
||||
return {"ip": client_ip}
|
||||
|
||||
|
||||
@app.get("/launcher/version")
|
||||
async def get_launcher_version():
|
||||
"""Return launcher version information"""
|
||||
@@ -1965,6 +1998,36 @@ async def receive_crash_report(request: Request):
|
||||
return {"status": "ok", "id": report_id}
|
||||
|
||||
|
||||
@app.post("/diag/upload")
|
||||
async def receive_diag_report(request: Request):
|
||||
"""Receive and store network diagnostics reports from the diag utility"""
|
||||
try:
|
||||
body = await request.body()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Failed to read body")
|
||||
|
||||
if not body:
|
||||
raise HTTPException(status_code=400, detail="Empty body")
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
name = request.query_params.get("name", "").strip()
|
||||
if not name or "/" in name or "\\" in name or ".." in name:
|
||||
name = f"diag_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_{ip}.log"
|
||||
|
||||
report_path = DIAG_REPORTS_DIR / name
|
||||
|
||||
try:
|
||||
DIAG_REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
async with aiofiles.open(report_path, "w", encoding="utf-8") as f:
|
||||
await f.write(body.decode("utf-8", errors="replace"))
|
||||
logger.info(f"Diag report saved: {report_path.name} from {ip}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save diag report: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to save report")
|
||||
|
||||
return {"status": "ok", "name": report_path.name}
|
||||
|
||||
|
||||
# ====================== НОВОСТИ ======================
|
||||
|
||||
NEWS_DIR = Path(__file__).parent / "news"
|
||||
@@ -2195,19 +2258,34 @@ async def proxy_mojang_version(version_id: str, request: Request):
|
||||
# Сначала получаем манифест, чтобы найти URL версии
|
||||
manifest_url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
|
||||
|
||||
cache_key = f"version_url_{version_id}"
|
||||
version_url = proxy_cache.get(cache_key)
|
||||
|
||||
# Cache the final version JSON, not just the version URL. piston-meta is
|
||||
# slow/unreliable from some regions, so full serialization of the version
|
||||
# JSON (plus a warm-up) keeps installs from stalling on install time.
|
||||
cache_key = f"version_json_{version_id}"
|
||||
cached = proxy_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
logger.info(f"Proxy served from cache: Mojang version {version_id}")
|
||||
return JSONResponse(content=cached)
|
||||
|
||||
version_url = proxy_cache.get(f"version_url_{version_id}")
|
||||
|
||||
if not version_url:
|
||||
try:
|
||||
response = await proxy_client.get(manifest_url)
|
||||
response.raise_for_status()
|
||||
manifest = response.json()
|
||||
# Reuse the manifest already cached by /proxy/mojang/version_manifest
|
||||
manifest = proxy_cache.get(manifest_url)
|
||||
if manifest is None:
|
||||
response = await proxy_client.get(manifest_url)
|
||||
response.raise_for_status()
|
||||
manifest = response.json()
|
||||
proxy_cache[manifest_url] = manifest
|
||||
logger.info("Proxy success: Mojang manifest")
|
||||
else:
|
||||
logger.info("Proxy served manifest from cache")
|
||||
|
||||
for version in manifest.get("versions", []):
|
||||
if version.get("id") == version_id:
|
||||
version_url = version.get("url")
|
||||
proxy_cache[cache_key] = version_url
|
||||
proxy_cache[f"version_url_{version_id}"] = version_url
|
||||
break
|
||||
|
||||
if not version_url:
|
||||
@@ -2222,6 +2300,7 @@ async def proxy_mojang_version(version_id: str, request: Request):
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
proxy_cache[cache_key] = data
|
||||
logger.info(f"Proxy success: Mojang version {version_id}")
|
||||
return JSONResponse(content=data)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user