|
@@ -0,0 +1,166 @@
|
|
|
|
|
+"""Decode the launcher's detailed list and resolve CQ2 TCP endpoints."""
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import base64
|
|
|
|
|
+import binascii
|
|
|
|
|
+import json
|
|
|
|
|
+import struct
|
|
|
|
|
+from typing import Any
|
|
|
|
|
+
|
|
|
|
|
+import httpx
|
|
|
|
|
+
|
|
|
|
|
+from .models import ServerInfo
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+SERVER_LIST_BASE = "https://list-box.dhsf.xqhuyu.com"
|
|
|
|
|
+SERVER_LIST_MARKER = b"4meJnPyl"
|
|
|
|
|
+SERVER_LIST_KEY = b"34UFXMgtkz".ljust(16, b"\0")
|
|
|
|
|
+_DELTA = 0x9E3779B9
|
|
|
|
|
+_MASK = 0xFFFFFFFF
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class ServerListError(ValueError):
|
|
|
|
|
+ """The detailed server list cannot be decoded or resolved."""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _xxtea_decrypt(ciphertext: bytes) -> bytes:
|
|
|
|
|
+ if len(ciphertext) < 8 or len(ciphertext) % 4:
|
|
|
|
|
+ raise ServerListError("invalid XXTEA ciphertext length")
|
|
|
|
|
+
|
|
|
|
|
+ values = list(struct.unpack(f"<{len(ciphertext) // 4}I", ciphertext))
|
|
|
|
|
+ key = struct.unpack("<4I", SERVER_LIST_KEY)
|
|
|
|
|
+ rounds = 6 + 52 // len(values)
|
|
|
|
|
+ total = (rounds * _DELTA) & _MASK
|
|
|
|
|
+ y = values[0]
|
|
|
|
|
+
|
|
|
|
|
+ while total:
|
|
|
|
|
+ e = (total >> 2) & 3
|
|
|
|
|
+ for pos in range(len(values) - 1, 0, -1):
|
|
|
|
|
+ z = values[pos - 1]
|
|
|
|
|
+ mix = (
|
|
|
|
|
+ (((z >> 5) ^ ((y << 2) & _MASK)) +
|
|
|
|
|
+ ((y >> 3) ^ ((z << 4) & _MASK)))
|
|
|
|
|
+ ^ ((total ^ y) + (key[(pos & 3) ^ e] ^ z))
|
|
|
|
|
+ ) & _MASK
|
|
|
|
|
+ values[pos] = (values[pos] - mix) & _MASK
|
|
|
|
|
+ y = values[pos]
|
|
|
|
|
+ z = values[-1]
|
|
|
|
|
+ mix = (
|
|
|
|
|
+ (((z >> 5) ^ ((y << 2) & _MASK)) +
|
|
|
|
|
+ ((y >> 3) ^ ((z << 4) & _MASK)))
|
|
|
|
|
+ ^ ((total ^ y) + (key[e] ^ z))
|
|
|
|
|
+ ) & _MASK
|
|
|
|
|
+ values[0] = (values[0] - mix) & _MASK
|
|
|
|
|
+ y = values[0]
|
|
|
|
|
+ total = (total - _DELTA) & _MASK
|
|
|
|
|
+
|
|
|
|
|
+ plaintext_length = values[-1]
|
|
|
|
|
+ available = (len(values) - 1) * 4
|
|
|
|
|
+ if plaintext_length <= 0 or plaintext_length > available:
|
|
|
|
|
+ raise ServerListError("invalid XXTEA plaintext length")
|
|
|
|
|
+ return struct.pack(f"<{len(values) - 1}I", *values[:-1])[:plaintext_length]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def decode_detailed_server_list(payload: bytes) -> dict[str, Any]:
|
|
|
|
|
+ """Decode one Base64 + marker + cocos XXTEA server-list response."""
|
|
|
|
|
+ try:
|
|
|
|
|
+ raw = base64.b64decode(payload.strip(), validate=True)
|
|
|
|
|
+ except (binascii.Error, ValueError) as exc:
|
|
|
|
|
+ raise ServerListError("invalid server-list Base64") from exc
|
|
|
|
|
+ if not raw.startswith(SERVER_LIST_MARKER):
|
|
|
|
|
+ raise ServerListError("invalid server-list marker")
|
|
|
|
|
+ plaintext = _xxtea_decrypt(raw[len(SERVER_LIST_MARKER):])
|
|
|
|
|
+ try:
|
|
|
|
|
+ result = json.loads(plaintext.decode("utf-8"))
|
|
|
|
|
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
|
|
|
+ raise ServerListError("invalid server-list JSON") from exc
|
|
|
|
|
+ if not isinstance(result, dict):
|
|
|
|
|
+ raise ServerListError("server-list JSON root must be an object")
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _integer(value: Any, *, default: int = 0) -> int:
|
|
|
|
|
+ if value in (None, ""):
|
|
|
|
|
+ return default
|
|
|
|
|
+ try:
|
|
|
|
|
+ return int(value)
|
|
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
|
|
+ raise ServerListError(f"invalid integer field: {value!r}") from exc
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def resolve_server(
|
|
|
|
|
+ data: dict[str, Any],
|
|
|
|
|
+ server_id: int,
|
|
|
|
|
+ *,
|
|
|
|
|
+ channel_id: str,
|
|
|
|
|
+ game_id: int,
|
|
|
|
|
+) -> ServerInfo:
|
|
|
|
|
+ """Expand a compact server record into the endpoint used by GameLogin."""
|
|
|
|
|
+ records = data.get("serverData")
|
|
|
|
|
+ if not isinstance(records, list):
|
|
|
|
|
+ raise ServerListError("server-list is missing serverData")
|
|
|
|
|
+
|
|
|
|
|
+ wanted = int(server_id)
|
|
|
|
|
+ record: dict[str, Any] | None = None
|
|
|
|
|
+ for candidate in records:
|
|
|
|
|
+ if not isinstance(candidate, dict):
|
|
|
|
|
+ continue
|
|
|
|
|
+ candidate_id = _integer(candidate.get("d", candidate.get("srvid")))
|
|
|
|
|
+ if candidate_id == wanted:
|
|
|
|
|
+ record = candidate
|
|
|
|
|
+ break
|
|
|
|
|
+ if record is None:
|
|
|
|
|
+ raise ServerListError(f"server {wanted} is not in the detailed list")
|
|
|
|
|
+
|
|
|
|
|
+ selected_id = _integer(record.get("d", record.get("srvid")))
|
|
|
|
|
+ main_id = _integer(
|
|
|
|
|
+ record.get("m", record.get("mainsrvid")), default=selected_id
|
|
|
|
|
+ ) or selected_id
|
|
|
|
|
+ port = _integer(record.get("p", record.get("port")))
|
|
|
|
|
+ if port <= 0:
|
|
|
|
|
+ raise ServerListError(f"server {wanted} has no valid login port")
|
|
|
|
|
+ explicit_host = str(record.get("i", record.get("srvip")) or "")
|
|
|
|
|
+ root = str(data.get("srvRoot") or "")
|
|
|
|
|
+ host = explicit_host or (f"s{main_id}-{root}" if root else "")
|
|
|
|
|
+ if not host:
|
|
|
|
|
+ raise ServerListError(f"server {wanted} has no resolvable host")
|
|
|
|
|
+ proxy_port = _integer(
|
|
|
|
|
+ record.get("x", record.get("proxyport")), default=0
|
|
|
|
|
+ ) or None
|
|
|
|
|
+
|
|
|
|
|
+ return ServerInfo(
|
|
|
|
|
+ server_id=selected_id,
|
|
|
|
|
+ main_server_id=main_id,
|
|
|
|
|
+ name=str(record.get("n", record.get("srvname")) or selected_id),
|
|
|
|
|
+ host=host,
|
|
|
|
|
+ port=port,
|
|
|
|
|
+ proxy_port=proxy_port,
|
|
|
|
|
+ channel_id=str(channel_id),
|
|
|
|
|
+ game_id=int(game_id),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def fetch_server_info(
|
|
|
|
|
+ server_id: int,
|
|
|
|
|
+ *,
|
|
|
|
|
+ channel_id: str,
|
|
|
|
|
+ game_id: int,
|
|
|
|
|
+ client: httpx.AsyncClient | None = None,
|
|
|
|
|
+) -> ServerInfo:
|
|
|
|
|
+ """Fetch and resolve one server from the channel-specific detailed list."""
|
|
|
|
|
+ url = f"{SERVER_LIST_BASE}/serverlist/{channel_id}/serverlist.txt"
|
|
|
|
|
+ owns_client = client is None
|
|
|
|
|
+ http = client or httpx.AsyncClient(timeout=15, verify=False)
|
|
|
|
|
+ try:
|
|
|
|
|
+ response = await http.get(url)
|
|
|
|
|
+ response.raise_for_status()
|
|
|
|
|
+ data = decode_detailed_server_list(response.content)
|
|
|
|
|
+ return resolve_server(
|
|
|
|
|
+ data,
|
|
|
|
|
+ server_id,
|
|
|
|
|
+ channel_id=channel_id,
|
|
|
|
|
+ game_id=game_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ finally:
|
|
|
|
|
+ if owns_client:
|
|
|
|
|
+ await http.aclose()
|