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:
SashegDev
2026-08-18 22:19:55 +00:00
parent ecb49e1eb7
commit a2073044e9
40 changed files with 2233 additions and 252 deletions
+9
View File
@@ -383,6 +383,7 @@ async def register(body: RegisterRequest, request: Request):
allowed, wait = check_rate_limit(ip)
if not allowed:
logger.warning("register rate limited", username=body.username, client_ip=ip)
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
with get_db() as conn:
@@ -392,6 +393,7 @@ async def register(body: RegisterRequest, request: Request):
).fetchone()
if existing:
logger.warning("register failed: username taken", username=body.username, client_ip=ip)
raise HTTPException(409, "Пользователь с таким именем уже существует")
uuid = generate_uuid()
@@ -405,6 +407,7 @@ async def register(body: RegisterRequest, request: Request):
)
user_id = cursor.lastrowid
logger.info("register ok", username=body.username, user_id=user_id, client_ip=ip)
# Создаем сессию
session_token = secrets.token_urlsafe(32)
@@ -456,6 +459,7 @@ async def login(body: LoginRequest, request: Request):
allowed, wait = check_rate_limit(ip)
if not allowed:
logger.warning("login rate limited", username=body.username, client_ip=ip)
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
with get_db() as conn:
@@ -465,15 +469,20 @@ async def login(body: LoginRequest, request: Request):
).fetchone()
if not user or not verify_password(body.password, user["password_hash"]):
logger.warning("login failed: bad credentials", username=body.username, client_ip=ip)
record_login_attempt(ip, False)
raise HTTPException(401, "Неверное имя пользователя или пароль")
if not user["is_active"]:
logger.warning("login failed: account deactivated", username=body.username, client_ip=ip)
raise HTTPException(403, "Аккаунт деактивирован")
if user["banned_until"] and user["banned_until"] > time.time():
logger.warning("login failed: account banned", username=body.username, client_ip=ip)
raise HTTPException(403, "Аккаунт забанен")
logger.info("login ok", username=user["username"], user_id=user["id"], client_ip=ip)
record_login_attempt(ip, True)
now = time.time()
+89 -10
View File
@@ -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)