feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复
- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token) - 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期 - request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node) - 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度 - ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
This commit is contained in:
1 parent
800871ef6e
commit
dfc06bf4de
182 files changed
+26620
No files matched your search
@@ -0,0 +1 @@
|
||||
"""服务端横切能力:错误、鉴权、限流、流签名、咪咕解密代理。"""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""API 业务错误 —— 路由处理中抛出,由全局异常处理器转换为对应 HTTP 状态码的 JSON 响应。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""业务错误:status 同时作为响应体中的 code。"""
|
||||
|
||||
def __init__(self, status: int, msg: str, detail: Any = None) -> None:
|
||||
super().__init__(msg)
|
||||
self.status = status
|
||||
self.code = status
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def not_found(msg: str, detail: Any = None) -> ApiError:
|
||||
"""资源不存在(未知路由 / 不支持的平台)。"""
|
||||
return ApiError(404, msg, detail)
|
||||
|
||||
|
||||
def upstream(msg: str, detail: Any = None) -> ApiError:
|
||||
"""上游平台请求失败。"""
|
||||
return ApiError(502, msg, detail)
|
||||
|
||||
|
||||
def bad_request(msg: str, detail: Any = None) -> ApiError:
|
||||
return ApiError(400, msg, detail)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""全局调用秘钥鉴权 —— AUTH_KEY 环境变量启用(未设置则完全不鉴权)。
|
||||
|
||||
验证头统一为 X-Server-Key;全部路径(含文档页/健康检查/404)都要求秘钥,
|
||||
未授权请求一律 401,避免被扫描器区分出接口存在性(404/401 差异)。
|
||||
CORS 预检(OPTIONS)无响应体、不泄露内容,豁免。
|
||||
|
||||
/stream/* 流代理例外:播放器/下载器直接 GET 媒体地址、无法携带自定义头,
|
||||
改用短时签名(?exp=&sig=,见 stream_sign.py)——不落裸秘钥到 URL/日志,
|
||||
签名泄露仅能在时效窗口内拉当条流;能带头的客户端仍可用 X-Server-Key。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.http.stream_sign import verify_stream_sign
|
||||
|
||||
|
||||
def _safe_equal(a: str, b: str) -> bool:
|
||||
return hmac.compare_digest(a.encode(), b.encode())
|
||||
|
||||
|
||||
async def auth_gate(request: Request, call_next):
|
||||
if request.method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
auth_key = getattr(request.app.state, "auth_key", "") or ""
|
||||
if not auth_key: # 未配置秘钥:完全不鉴权
|
||||
return await call_next(request)
|
||||
|
||||
pathname = request.url.path
|
||||
header = request.headers.get("x-server-key", "")
|
||||
|
||||
if pathname.startswith("/stream/"):
|
||||
query = request.query_params
|
||||
ok = (header and _safe_equal(header, auth_key)) or (
|
||||
not header and verify_stream_sign(auth_key, pathname, query.get("exp"), query.get("sig"))
|
||||
)
|
||||
if ok:
|
||||
return await call_next(request)
|
||||
return JSONResponse(
|
||||
{"code": 401, "msg": "无效或缺少调用秘钥:X-Server-Key 头,或 /url 返回流地址自带的 exp/sig 签名"},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
if not header or not _safe_equal(header, auth_key):
|
||||
# 统一 401 文案(不区分路径存在与否,防扫描探测)
|
||||
return JSONResponse(
|
||||
{"code": 401, "msg": "无效或缺少调用秘钥:请在 X-Server-Key 请求头携带"},
|
||||
status_code=401,
|
||||
)
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""统一成功信封 { code: 0, data }。
|
||||
|
||||
独立成模块是为了避免 routes → main → routes 的循环导入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class Envelope(JSONResponse):
|
||||
"""成功响应:{ "code": 0, "data": <payload> }。"""
|
||||
|
||||
def render(self, content: Any) -> bytes:
|
||||
return json.dumps({"code": 0, "data": content}, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def ok(data: Any, status_code: int = 200) -> Envelope:
|
||||
return Envelope(data, status_code=status_code)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""咪咕加密流 —— 票据登记与 Z3D 流式解密(对应原 src/http/mg-stream.ts + sdk/mg/util.ts 解密部分)。
|
||||
|
||||
统一入口为 /stream/:source/:id,source=mg 时 id 即此处签发的短票据。
|
||||
不把上游 URL 塞进播放地址(path 参数限长,且 URL 含时效签名与中文路径),
|
||||
票据短时有效(10 分钟,够播放器起播),进程内存储、不落盘。
|
||||
|
||||
Z3D 解密算法(与原始 TS 一致):
|
||||
streamKey = MD5(AES128CBC(AndroidAppKey, ENCRYPTED_APP_CODE) + fileKey) 的大写十六进制(32 字节 ASCII)
|
||||
plain[i] = (cipher[i] - streamKey[i % 32]) & 0xFF
|
||||
密文可能以 3 字节 `Z3D` 魔数开头,需先剥离;已解密的数据(音频魔数开头)原样透传。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import aiohttp
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
TTL_SECONDS = 10 * 60
|
||||
STREAM_CACHE_LIMIT = 200
|
||||
MIGU_STREAM_UA = "Android_migu/7.41.13 okhttp/3.12.13"
|
||||
|
||||
# 咪咕 Android 客户端的 app key 与加密后的 app code(服务端下发,非机密凭据)
|
||||
_ANDROID_APP_KEY = b"308202333082019c"
|
||||
_ENCRYPTED_APP_CODE = bytes(
|
||||
[
|
||||
0xAE, 0xEA, 0xC7, 0x17, 0x05, 0xA6, 0x89, 0x55,
|
||||
0xA9, 0x31, 0xA6, 0xFE, 0x01, 0xE7, 0x28, 0xFF,
|
||||
0xF6, 0x49, 0x71, 0xE6, 0x1C, 0xEA, 0xF0, 0x0A,
|
||||
0xE3, 0xEA, 0x43, 0xC3, 0xE0, 0x58, 0x33, 0x83,
|
||||
]
|
||||
) # fmt: skip
|
||||
_Z3D_IV = bytes(range(16))
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiguStreamTicket:
|
||||
url: str
|
||||
file_key: str
|
||||
created_at: float
|
||||
|
||||
|
||||
_tickets: dict[str, MiguStreamTicket] = {}
|
||||
|
||||
|
||||
def _prune() -> None:
|
||||
now = time.time()
|
||||
for key in [k for k, t in _tickets.items() if now - t.created_at > TTL_SECONDS]:
|
||||
_tickets.pop(key, None)
|
||||
# 超出容量时按插入顺序淘汰最旧票据
|
||||
while len(_tickets) > STREAM_CACHE_LIMIT:
|
||||
oldest = next(iter(_tickets), None)
|
||||
if oldest is None:
|
||||
break
|
||||
_tickets.pop(oldest, None)
|
||||
|
||||
|
||||
def register_migu_stream(url: str, file_key: str) -> str:
|
||||
"""登记一条加密流,返回可直接放入 /stream/mg/:id 的短票据 id。"""
|
||||
_prune()
|
||||
ticket_id = uuid.uuid4().hex
|
||||
_tickets[ticket_id] = MiguStreamTicket(url=url, file_key=file_key, created_at=time.time())
|
||||
return ticket_id
|
||||
|
||||
|
||||
def take_migu_stream(ticket_id: str) -> MiguStreamTicket | None:
|
||||
"""取出票据(过期返回 None)。"""
|
||||
ticket = _tickets.get(ticket_id)
|
||||
if not ticket:
|
||||
return None
|
||||
if time.time() - ticket.created_at > TTL_SECONDS:
|
||||
_tickets.pop(ticket_id, None)
|
||||
return None
|
||||
return ticket
|
||||
|
||||
|
||||
def is_migu_audio_magic(data: bytes) -> bool:
|
||||
"""判断是否明文音频:fLaC / RIFF-WAVE / OggS / ID3 / MP4(ftyp) / MP3 帧同步。"""
|
||||
if len(data) < 4:
|
||||
return False
|
||||
head4 = data[:4]
|
||||
if head4 in (b"fLaC", b"OggS"):
|
||||
return True
|
||||
if head4 == b"RIFF" and len(data) >= 12 and data[8:12] == b"WAVE":
|
||||
return True
|
||||
if data[:3] == b"ID3":
|
||||
return True
|
||||
if len(data) >= 12 and data[4:8] == b"ftyp":
|
||||
return True
|
||||
return data[0] == 0xFF and (data[1] & 0xE0) == 0xE0
|
||||
|
||||
|
||||
def migu_z3d_stream_key(file_key: str) -> bytes:
|
||||
"""streamKey = MD5(解密后的 appCode + fileKey) 的大写十六进制(32 字节 ASCII)。"""
|
||||
key = str(file_key or "").strip()
|
||||
if not key:
|
||||
raise RuntimeError("空的 z3d fileKey")
|
||||
# Go 的 CryptBlocks 按块解密不做 PKCS#7 去填充,这里同样禁用填充,否则尾块校验失败
|
||||
decryptor = Cipher(algorithms.AES(_ANDROID_APP_KEY), modes.CBC(_Z3D_IV)).decryptor()
|
||||
app_code = decryptor.update(_ENCRYPTED_APP_CODE) + decryptor.finalize()
|
||||
digest = hashlib.md5(app_code + key.encode("utf-8")).hexdigest().upper()
|
||||
return digest.encode("ascii")
|
||||
|
||||
|
||||
def decrypt_z3d(data: bytes, file_key: str) -> bytes:
|
||||
"""整段解密(非流式场景;已是明文则原样返回)。"""
|
||||
if not data:
|
||||
raise RuntimeError("空的 z3d 数据")
|
||||
if is_migu_audio_magic(data):
|
||||
return data
|
||||
payload = data[3:] if data[:3] == b"Z3D" else data
|
||||
if not payload:
|
||||
raise RuntimeError("剥离 Z3D 魔数后无数据")
|
||||
stream_key = migu_z3d_stream_key(file_key)
|
||||
plain = bytes((byte - stream_key[index & 31]) & 0xFF for index, byte in enumerate(payload))
|
||||
if not is_migu_audio_magic(plain):
|
||||
raise RuntimeError("z3d 解密后不是有效音频")
|
||||
return plain
|
||||
|
||||
|
||||
class Z3DStreamDecoder:
|
||||
"""按块增量解密:跨块保持全局偏移,首块剥离 `Z3D` 魔数并校验结果。"""
|
||||
|
||||
def __init__(self, file_key: str) -> None:
|
||||
self._stream_key = migu_z3d_stream_key(file_key)
|
||||
self._offset = 0
|
||||
self._pending = b"" # 首块不足 3 字节时暂存,用于判断 `Z3D` 魔数
|
||||
self._checked = False
|
||||
|
||||
def feed(self, chunk: bytes) -> bytes:
|
||||
if not chunk:
|
||||
return b""
|
||||
data = self._pending + chunk
|
||||
self._pending = b""
|
||||
if self._offset == 0 and len(data) < 3:
|
||||
self._pending = data
|
||||
return b""
|
||||
if self._offset == 0 and data[:3] == b"Z3D":
|
||||
data = data[3:]
|
||||
base = self._offset
|
||||
self._offset += len(data)
|
||||
plain = bytes((byte - self._stream_key[(base + index) & 31]) & 0xFF for index, byte in enumerate(data))
|
||||
if not self._checked and plain:
|
||||
self._checked = True
|
||||
if not is_migu_audio_magic(plain):
|
||||
raise RuntimeError("z3d 解密后不是有效音频")
|
||||
return plain
|
||||
|
||||
|
||||
def migu_stream_content_type(url: str) -> str:
|
||||
"""按扩展名推断明文的 content-type。"""
|
||||
if ".flac" in url:
|
||||
return "audio/flac"
|
||||
if ".wav" in url:
|
||||
return "audio/wav"
|
||||
if ".m4a" in url:
|
||||
return "audio/mp4"
|
||||
return "audio/mpeg"
|
||||
|
||||
|
||||
async def stream_decrypted(
|
||||
url: str,
|
||||
feed: Callable[[bytes], bytes],
|
||||
on_chunk: Callable[[bytes], None],
|
||||
*,
|
||||
max_redirects: int = 3,
|
||||
) -> None:
|
||||
"""流式下载密流并逐块解密后交给 on_chunk。
|
||||
|
||||
整段音频可达上百 MB,因此按块处理而不读入内存;上游 URL 常带 302,
|
||||
这里手动跟随(默认不跟随重定向,与请求层语义一致)。
|
||||
"""
|
||||
timeout = aiohttp.ClientTimeout(total=300, sock_read=60)
|
||||
headers = {"User-Agent": MIGU_STREAM_UA, "Referer": "https://music.migu.cn/"}
|
||||
current = url
|
||||
for _ in range(max_redirects + 1):
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(current, headers=headers, allow_redirects=False) as resp:
|
||||
if 300 <= resp.status < 400 and (location := resp.headers.get("location")):
|
||||
current = urljoin(current, location)
|
||||
continue
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"上游返回 {resp.status}")
|
||||
async for chunk in resp.content.iter_any():
|
||||
plain = feed(chunk)
|
||||
if plain:
|
||||
on_chunk(plain)
|
||||
return
|
||||
raise RuntimeError("重定向次数过多")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""全局限流 —— per-IP 滑动窗口(内存实现,无额外依赖)。
|
||||
|
||||
RATE_LIMIT 环境变量:每 IP 每分钟允许的请求数(默认 120;设 0 关闭)。
|
||||
在鉴权之前计数(401 爆破秘钥同样受限);超限返回 429 + Retry-After。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
WINDOW_SECONDS = 60.0
|
||||
|
||||
|
||||
def make_rate_limiter(limit_per_minute: int):
|
||||
hits: dict[str, list[float]] = {}
|
||||
last_cleanup = 0.0
|
||||
|
||||
async def middleware(request: Request, call_next):
|
||||
nonlocal last_cleanup
|
||||
if request.method == "OPTIONS" or limit_per_minute <= 0:
|
||||
return await call_next(request)
|
||||
|
||||
now = time.monotonic()
|
||||
# 惰性清理:每窗口顺带删除已过期的 IP 记录,防内存无限增长
|
||||
if now - last_cleanup > WINDOW_SECONDS:
|
||||
threshold = now - WINDOW_SECONDS
|
||||
for ip in [ip for ip, times in hits.items() if not times or times[-1] <= threshold]:
|
||||
hits.pop(ip, None)
|
||||
last_cleanup = now
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
times = [t for t in hits.get(ip, []) if t > now - WINDOW_SECONDS]
|
||||
times.append(now)
|
||||
hits[ip] = times
|
||||
if len(times) > limit_per_minute:
|
||||
retry_after = max(1, int(times[0] + WINDOW_SECONDS - now) + 1)
|
||||
return JSONResponse(
|
||||
{"code": 429, "msg": f"请求过于频繁(限 {limit_per_minute} 次/分钟),请 {retry_after} 秒后重试"},
|
||||
status_code=429,
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
return middleware
|
||||
@@ -0,0 +1,38 @@
|
||||
"""流代理签名 —— /stream/* 的无头客户端兼容方案。
|
||||
|
||||
/url 返回流地址时附加 `?exp=<秒级时间戳>&sig=<HMAC-SHA256(authKey, "path:exp") 前 32 位>`。
|
||||
与裸 ?key= 的区别:签名值泄露只能在该时效窗口内拉这一条流,不能反推出秘钥、不能调用其它接口。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
|
||||
# 流地址有效期(秒),与 mg 流票据 TTL(10 分钟)对齐;过期后客户端重新 /url 即可
|
||||
STREAM_SIGN_TTL = 10 * 60
|
||||
|
||||
|
||||
def _sign(auth_key: str, data: str) -> str:
|
||||
return hmac.new(auth_key.encode(), data.encode(), hashlib.sha256).hexdigest()[:32]
|
||||
|
||||
|
||||
def sign_stream(auth_key: str, path: str) -> str:
|
||||
"""生成流地址的签名参数(path 为不含 query 的路径,如 /stream/mg/123)。"""
|
||||
exp = int(time.time() + STREAM_SIGN_TTL)
|
||||
return f"exp={exp}&sig={_sign(auth_key, f'{path}:{exp}')}"
|
||||
|
||||
|
||||
def verify_stream_sign(auth_key: str, path: str, exp: object, sig: object) -> bool:
|
||||
"""校验流地址签名(常量时间比较 + 时效窗口)。"""
|
||||
try:
|
||||
exp_num = int(float(str(exp)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
sig_str = str(sig or "")
|
||||
if not sig_str:
|
||||
return False
|
||||
if time.time() > exp_num: # 过期即拒(客户端重新 /url 取新地址)
|
||||
return False
|
||||
return hmac.compare_digest(_sign(auth_key, f"{path}:{exp_num}"), sig_str)
|
||||
Reference in new issue
Block a user