diff --git a/.gitignore b/.gitignore index 1bf747f..88217ab 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,3 @@ coverage/ # 以下规则仅作保险,防止旧的发射产物被误生成后提交。 src/**/*.js sdk/**/*.js - -# 汽水官方签名原生模块(专有二进制,不入库;QS_MSSDK_DIR 亦可指向别处) -soda-mssdk/ diff --git a/README.md b/README.md index e5b3e25..0b25910 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,19 @@ X-Server-Key: # 统一验证头(全部路径) per-IP 滑动窗口限流,`RATE_LIMIT` 为每 IP 每分钟允许的请求数(默认 120,设 0 关闭)。超限返回 429 + Retry-After;鉴权失败的请求同样计数(防秘钥爆破)。 +## 汽水 PC 签名(可选增强) + +汽水(`qs`)的 PC 通道(搜索优先走、取流降级走)依赖 `X-Helios`/`X-Medusa` 两个加密签名头,按优先级两种供给方式;都没有时 PC 通道自动关闭,只走安卓自签通道(匿名搜索/试听不受影响): + +1. **签名服务器**:环境变量 `QS_SIGN_SERVER`(如 `http://127.0.0.1:8899`,可选 `QS_SIGN_TOKEN` 配 Bearer 鉴权)。契约兼容独立仓库 **qs-signer**(`gitea.4i.hk/hhhge/qs-signer`,本地与 MusicServer 同级)的两个服务——`server.mjs`(Wine + bdms.node)与 `mssdk-sign`(metasecml.dll 独立进程,见其 README),跨平台部署无需在本机安装官方客户端。 +2. **固定值**:无签名服务器时,两个加密头直接用抓包固定值,写入 `data/qs-sign.json`(已 gitignore): + + ```json + { "helios": "…", "medusa": "…", "device_id": "…", "install_id": "…" } + ``` + + `device_id`/`install_id` 必须与抓包时同一套(签名与设备绑定,会自动对齐 URL 参数);也可用环境变量 `QS_HELIOS`/`QS_MEDUSA`(配套 `QS_SIGN_DEVICE_ID`/`QS_SIGN_INSTALL_ID`)。固定值时效自负,被风控时搜索/取流自动回落安卓通道。 + ## 外部音源 设置 `SOURCE_ENABLED=1` 后取流优先走外部音源(默认未设置时直接走 SDK 官方直连接口;是否启用由服务端在 `src/server.ts` 决策)。两种脚本都放 `sources/` 目录(不入库),按文件名混排成候选链:取流失败自动切换下一个,某平台连续失败 3 次的脚本降权冷却 5 分钟;整条音源链失败后回落 SDK 直连、再失败走酷我兜底(见上文取流链)。音源候选链只覆盖 kw/kg/tx/wy(音源脚本协议无 qs 平台,`/url/qs` 始终走 SDK)。音源模式下登录态不参与取流,登录自动续期不会启动(手动 `POST /login/refresh` 仍可用)。详见 [docs/source-ivm.md](docs/source-ivm.md) 与 [docs/source-simple.md](docs/source-simple.md)。 diff --git a/scripts/qs-local-login.mjs b/scripts/qs-local-login.mjs deleted file mode 100644 index 98321c8..0000000 --- a/scripts/qs-local-login.mjs +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env node -// 汽水音乐「本机客户端登录」独立脚本 —— 不参与服务运行,需要时手动跑一次 -// -// 做什么:官方 SodaMusic PC 客户端是 Chromium,登录态就存在本机 -// %APPDATA%\SodaMusic\Network\Cookies(SQLite)里,设备身份在 %APPDATA%\SodaMusic\DeviceV1 -// (gzip JSON,含 did/iid)。本脚本直接读出来写进 data/auth.json 的 qs 字段, -// 免去扫码登录。参考实现:YYF1337/PopDownloader(SaKongA/PopDownloader 的 fork)。 -// -// 用法: -// node scripts/qs-local-login.mjs # 读取 + 校验 + 写入 data/auth.json -// node scripts/qs-local-login.mjs --print # 只打印 sessionid,不落盘 -// node scripts/qs-local-login.mjs --no-verify # 跳过 /luna/pc/me 校验 -// node scripts/qs-local-login.mjs --dir <目录> # 指定客户端数据目录(默认 %APPDATA%\SodaMusic) -// node scripts/qs-local-login.mjs --auth <文件> # 指定 auth.json(默认 <仓库>/data/auth.json) -// -// 校验需要官方签名模块(soda-mssdk/bdms.node 或 QS_MSSDK_DIR),没有就跳过校验只写入。 -// 注意:服务在跑时 auth.json 已缓存,写完需重启服务才生效(脚本会提示)。 -import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { gunzipSync } from 'node:zlib' - -const require = createRequire(import.meta.url) -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') - -const argv = process.argv.slice(2) -const flag = (name) => argv.includes(name) -const value = (name) => { - const i = argv.indexOf(name) - return i >= 0 ? argv[i + 1] : '' -} - -const dataDir = resolve(value('--dir') || process.env.QS_SODA_DATA_DIR || join(process.env.APPDATA || '', 'SodaMusic')) -const authPath = resolve(value('--auth') || join(root, 'data', 'auth.json')) -const printOnly = flag('--print') -const skipVerify = flag('--no-verify') -const DEVICE_FILE = join(dataDir, 'DeviceV1') -const COOKIE_DB = join(dataDir, 'Network', 'Cookies') - -const die = (msg) => { - console.error(`✗ ${msg}`) - process.exit(1) -} - -// —— 1. 客户端设备身份(DeviceV1:gzip JSON 的 {did,iid})—— -const readDevice = () => { - if (!existsSync(DEVICE_FILE)) return null - const raw = readFileSync(DEVICE_FILE) - let text - try { - text = gunzipSync(raw).toString('utf8') - } catch { - text = raw.toString('utf8') - } - const data = JSON.parse(text) - return data?.did && data?.iid ? { did: String(data.did), iid: String(data.iid) } : null -} - -// —— 2. 客户端登录态(Chromium cookie 库,复制副本后只读查询)—— -const readSessionId = () => { - if (!existsSync(COOKIE_DB)) die(`未找到客户端登录数据:${COOKIE_DB}\n 请先安装并登录官方汽水音乐 PC 客户端,或用 --dir 指定数据目录`) - const tempDir = mkdtempSync(join(tmpdir(), 'qs-cookies-')) - try { - const copy = join(tempDir, 'Cookies') - copyFileSync(COOKIE_DB, copy) - for (const side of ['-wal', '-shm']) { - if (existsSync(COOKIE_DB + side)) copyFileSync(COOKIE_DB + side, copy + side) - } - const { DatabaseSync } = require('node:sqlite') - const db = new DatabaseSync(copy, { readOnly: true }) - try { - const row = db.prepare( - "SELECT value FROM cookies WHERE host_key IN ('.qishui.com', 'qishui.com') AND name = 'sessionid' LIMIT 1", - ).get() - return String(row?.value ?? '').trim() - } finally { - db.close() - } - } catch (err) { - if (/EBUSY|locked|busy|unable to open/i.test(String(err?.message))) { - die('客户端正在运行且 cookie 库被占用,请退出汽水音乐客户端后重试') - } - die(`读取 cookie 库失败: ${err?.message ?? err}`) - } finally { - rmSync(tempDir, { recursive: true, force: true }) - } -} - -// —— 3. 可选:签名打 /luna/pc/me 校验(status_code === 0 视为有效)—— -const findSigner = () => { - const dirs = [process.env.QS_MSSDK_DIR, join(root, 'soda-mssdk')].filter(Boolean) - for (const dir of dirs) { - if (existsSync(join(dir, 'bdms.node'))) return join(dir, 'bdms.node') - } - return '' -} - -const verify = async (sessionid, device) => { - const modulePath = findSigner() - if (!modulePath) return null - try { - const bdms = require(modulePath) - bdms.init({ deviceId: device.did }) - const query = new URLSearchParams({ - aid: '386088', app_name: 'luna_pc', region: 'cn', geo_region: 'cn', os_region: 'cn', sim_region: '', - device_id: device.did, cdid: '', iid: device.iid, version_name: '3.7.0', version_code: '30070000', - channel: 'official', build_mode: 'master', network_carrier: '', ac: 'wifi', tz_name: 'Asia/Shanghai', - resolution: '', device_platform: 'windows', device_type: 'Windows', os_version: 'Windows 11', fp: device.did, - }) - const url = `https://api.qishui.com/luna/pc/me?${query}` - const headers = { 'user-agent': 'LunaPC/3.7.0(452316191)', cookie: `sessionid=${sessionid}` } - const lines = Object.entries(headers).map(([k, v]) => `${k}\r\n${v}`).join('\r\n') - const parts = String(bdms.generateHttpSignatureHeaders(url, lines)).split('\r\n').filter(t => t.trim()) - for (let i = 0; i + 1 < parts.length; i += 2) headers[parts[i]] = parts[i + 1] - const res = await fetch(url, { headers }) - if (res.status !== 200) return null - const body = await res.json().catch(() => null) - const code = body?.status_code - if (code === 0) return true - return code == null ? null : false - } catch (err) { - console.log(`! 校验失败(按未校验处理): ${err?.message ?? err}`) - return null - } -} - -const main = async () => { - const device = readDevice() - if (!device) console.log(`! 未读到客户端设备身份(${DEVICE_FILE}),校验时将跳过`) - else console.log(`· 客户端设备: did=${device.did} iid=${device.iid}`) - - const sessionid = readSessionId() - if (!sessionid) die('客户端未登录(cookie 库中没有 .qishui.com 的 sessionid),请先在客户端登录') - - let verified = null - if (!skipVerify && device) { - verified = await verify(sessionid, device) - if (verified === false) die('客户端登录态已失效,请在汽水音乐客户端重新登录后再跑本脚本') - } - console.log(`· sessionid: ${sessionid.slice(0, 6)}…${sessionid.slice(-4)}(${sessionid.length} 字符)`) - console.log(`· 校验: ${verified === true ? '有效' : verified === false ? '已失效' : skipVerify ? '已跳过(--no-verify)' : '无法判定(缺签名模块或网络异常)'}`) - - if (printOnly) { - console.log(sessionid) - return - } - - const auth = existsSync(authPath) ? JSON.parse(readFileSync(authPath, 'utf8')) : {} - auth.qs = { ...(auth.qs || {}), sessionid, cookie: `sessionid=${sessionid}`, source: 'local-client', updatedAt: new Date().toISOString() } - writeFileSync(authPath, `${JSON.stringify(auth, null, 2)}\n`) - console.log(`✓ 已写入 ${authPath}(qs.sessionid)`) - console.log(' 服务在运行的话需要重启才生效(凭据已缓存);之后 /login/status 的 qs.logged 应为 true') -} - -main().catch(err => die(err?.message ?? String(err))) diff --git a/sdk/apis/qs.ts b/sdk/apis/qs.ts index 1a0177c..145d062 100644 --- a/sdk/apis/qs.ts +++ b/sdk/apis/qs.ts @@ -8,7 +8,7 @@ // 统一接口约定:apis('qs').getMusicUrl(songInfo, type) → { promise, cancelHttp } // promise resolve → { type, url } -import { httpFetch } from '../request.ts' +import { httpFetch } from '../common/request.ts' import { createHash, randomBytes } from 'node:crypto' import musicInfo from '../qs/musicInfo.ts' import { getDevice } from '../qs/device.ts' @@ -156,10 +156,10 @@ const fetchTrackPlayerByApp = async (trackId: string) => { }, requestBody) } -// 通道 B(降级):官方 PC 客户端签名 X-Helios/X-Medusa(需 sdk/qs/mssdk.ts 的签名模块) +// 通道 B(降级):官方 PC 客户端签名 X-Helios/X-Medusa(签名服务器或固定值,见 sdk/qs/mssdk.ts) const fetchTrackPlayerByPc = async (trackId: string) => { const sessionId = getSessionId() - if (!mssdkAvailable()) throw new Error('官方签名模块不可用') + if (!mssdkAvailable()) throw new Error('PC 签名不可用') const url = `${TRACK_V2_URL}?${new URLSearchParams(pcCommonParams()).toString()}` const requestBody = TRACK_V2_BODY(trackId) const headers: Record = { @@ -172,7 +172,7 @@ const fetchTrackPlayerByPc = async (trackId: string) => { 'x-ss-stub': createHash('md5').update(requestBody).digest('hex').toUpperCase(), ...(sessionId ? { cookie: getCookie() || `sessionid=${sessionId}` } : {}), } - const signed = signQishuiRequest(url, headers) + const signed = await signQishuiRequest(url, headers, requestBody) if (!signed) throw new Error('官方签名失败') return fetchTrackPlayer(url, { ...headers, ...signed }, requestBody) } diff --git a/sdk/qs/mssdk.ts b/sdk/qs/mssdk.ts index d8812b3..c7ab600 100644 --- a/sdk/qs/mssdk.ts +++ b/sdk/qs/mssdk.ts @@ -1,107 +1,141 @@ -// 汽水官方 PC 客户端签名(X-Helios / X-Medusa)—— 可选增强,缺原生模块时整体降级 -// -// 原理:直接复用官方客户端自带的原生模块 bdms.node(官方 main 进程就是 -// bdms.init({ deviceId }) → generateHttpSignatureHeaders(url, "名字\r\n值\r\n…"), -// 把返回的 name/value 成对写回请求头)。与 sodahub-org/libmssdk 同一套做法, -// 区别是我们进程内加载,不另起 HTTP 签名服务。 +// 汽水 PC 通道签名(X-Helios / X-Medusa)—— 两种供给方式,按优先级: +// 1) 签名服务器:环境变量 QS_SIGN_SERVER 指向签名服务(如 http://127.0.0.1:8899, +// 可选 QS_SIGN_TOKEN 配 Bearer 鉴权),HTTP POST /sign。 +// 契约同时兼容独立仓库 qs-signer(gitea.4i.hk/hhhge/qs-signer,本地与 MusicServer 同级) +// 的两个服务(请求 {url, headers, body?},响应 {ok, headers, added?}): +// - server.mjs (Wine + bdms.node) +// - mssdk-sign/ (metasecml.dll 独立进程,见其 README) +// 2) 固定值兜底:无签名服务器时,两个加密头直接用抓包固定值 —— +// 环境变量 QS_HELIOS / QS_MEDUSA,或 data/qs-sign.json: +// { "helios": "…", "medusa": "…", "device_id": "…", "install_id": "…" } +// 签名与设备/URL 绑定:固定值必须搭配抓包时的同一套 device_id/install_id +// (json 里带上即自动对齐 URL 参数),时效自负,被风控时走安卓通道兜底。 +// 两者都没有:PC 通道不可用,搜索/取流自动走安卓自签通道。 // 官方只对 qishui.com 且 **非** /passport/、非 /ttwid/ 的请求签名 —— 登录链路用不到本模块。 // 签名覆盖 URL + 全部请求头;POST 的 x-ss-stub(body 的 MD5 大写)既是签名输入也要随请求发出。 -// -// 原生文件(官方客户端二进制,专有、不入库、不再分发)放置位置任选其一: -// 1) 环境变量 QS_MSSDK_DIR 指向同时含 bdms.node 与 metasecml.dll 的目录 -// 2) 仓库根 soda-mssdk/(已 gitignore) -// 3) 本机已安装的官方客户端:%LOCALAPPDATA%\Programs\Soda Music\<版本>\resources\app.asar.unpacked -// 从安装包提取:官方 SodaMusic-v*-official-win32_x64.exe → Bandizip 解 NSIS → -// $Var31/app.7z → resources/app.asar.unpacked/{bdms.node, metasecml.dll} -import { createRequire } from 'node:module' -import { existsSync, readdirSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { getDeviceIds } from './device.ts' -const require = createRequire(import.meta.url) - -const MODULE_NAME = 'bdms.node' -const CLIENT_DIR_NAMES = ['Soda Music', 'SodaMusic', '汽水音乐'] // 仓库根(本文件在 sdk/qs/ 下)——不依赖 process.cwd(),从任何目录启动都能找到 const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const FIXED_SIGN_FILE = join(REPO_ROOT, 'data', 'qs-sign.json') +const SIGN_TIMEOUT = 5000 -const findModuleDir = (): string | null => { - const candidates: string[] = [] - if (process.env.QS_MSSDK_DIR) candidates.push(process.env.QS_MSSDK_DIR) - candidates.push(join(REPO_ROOT, 'soda-mssdk')) - const localAppData = process.env.LOCALAPPDATA - if (localAppData) { - for (const name of CLIENT_DIR_NAMES) { - const root = join(localAppData, 'Programs', name) - if (!existsSync(root)) continue - try { - for (const ver of readdirSync(root)) candidates.push(join(root, ver, 'resources', 'app.asar.unpacked')) - } catch { - // 读不到目录就跳过 +/** 固定值配置(helios/medusa 必填;device_id/install_id 应与抓包时同一套) */ +interface FixedSignConfig { + helios: string + medusa: string + deviceId: string + installId: string +} + +let fixedConfigWarned = false + +/** 读固定值:环境变量 QS_HELIOS/QS_MEDUSA 优先,其次 data/qs-sign.json;没有返回 null */ +const readFixedConfig = (): FixedSignConfig | null => { + const envHelios = process.env.QS_HELIOS?.trim() + const envMedusa = process.env.QS_MEDUSA?.trim() + if (envHelios && envMedusa) { + return { + helios: envHelios, + medusa: envMedusa, + deviceId: process.env.QS_SIGN_DEVICE_ID?.trim() ?? '', + installId: process.env.QS_SIGN_INSTALL_ID?.trim() ?? '', + } + } + if (!existsSync(FIXED_SIGN_FILE)) return null + try { + const data = JSON.parse(readFileSync(FIXED_SIGN_FILE, 'utf8')) as Record + const helios = String(data.helios ?? '').trim() + const medusa = String(data.medusa ?? '').trim() + if (helios && medusa) { + return { + helios, + medusa, + deviceId: String(data.device_id ?? '').trim(), + installId: String(data.install_id ?? '').trim(), } } - } - return candidates.find(dir => dir && existsSync(join(dir, MODULE_NAME))) ?? null -} - -interface BdmsModule { - init: (options: { deviceId: string }) => unknown - generateHttpSignatureHeaders: (url: string, headersLines: string) => string -} - -let signer: { module: BdmsModule; deviceId: string; dir: string } | null | undefined - -/** 懒加载官方签名模块(失败只提示一次,之后一律按不可用处理) */ -const loadSigner = () => { - if (signer !== undefined) return signer - signer = null - try { - const dir = findModuleDir() - if (!dir) return signer - const mod = require(join(dir, MODULE_NAME)) as BdmsModule - if (typeof mod?.init !== 'function' || typeof mod?.generateHttpSignatureHeaders !== 'function') { - console.log(`[qs/mssdk] ${MODULE_NAME} 导出不符合预期,按不可用处理`) - return signer + if (!fixedConfigWarned) { + fixedConfigWarned = true + console.log(`[qs/mssdk] ${FIXED_SIGN_FILE} 缺少 helios/medusa 字段,固定值模式不启用`) } - const { deviceId } = getDeviceIds() - mod.init({ deviceId }) - signer = { module: mod, deviceId, dir } - console.log(`[qs/mssdk] 官方签名模块已加载: ${dir} (device_id=${deviceId})`) } catch (err: any) { - console.log(`[qs/mssdk] 官方签名模块不可用,降级: ${err?.message ?? err}`) + if (!fixedConfigWarned) { + fixedConfigWarned = true + console.log(`[qs/mssdk] 固定值配置解析失败: ${err?.message ?? err}`) + } } - return signer + return null } -/** 官方签名是否可用(不影响主流程,仅决定走 PC 通道还是安卓通道) */ -export const mssdkAvailable = (): boolean => Boolean(loadSigner()) +type SignMode = + | { kind: 'server'; server: string; token: string } + | { kind: 'fixed'; fixed: FixedSignConfig } -/** 签名时绑定的设备(与请求 URL 里的 device_id/iid 必须一致) */ -export const mssdkDeviceId = (): string => loadSigner()?.deviceId ?? '' +/** 当前签名供给方式:签名服务器 > 固定值 > 不可用(null) */ +const loadSignMode = (): SignMode | null => { + const server = process.env.QS_SIGN_SERVER?.trim().replace(/\/+$/, '') + if (server) return { kind: 'server', server, token: process.env.QS_SIGN_TOKEN?.trim() ?? '' } + const fixed = readFixedConfig() + return fixed ? { kind: 'fixed', fixed } : null +} + +/** PC 签名是否可用(只看配置;服务器宕机等运行期失败由签名调用返回 null、调用方降级处理) */ +export const mssdkAvailable = (): boolean => Boolean(loadSignMode()) + +const signViaServer = async ( + server: string, + token: string, + url: string, + headers: Record, + body?: string, +): Promise | null> => { + try { + const res = await fetch(`${server}/sign`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ url, headers, body }), + signal: AbortSignal.timeout(SIGN_TIMEOUT), + }) + const json = await res.json().catch(() => null) as any + if (!res.ok || !json?.ok) { + console.log(`[qs/mssdk] 签名服务返回异常: HTTP ${res.status} ${json?.error ?? ''}`.trim()) + return null + } + // signer/server.mjs 把 x-ss-stub 并进 headers,mssdk-sign 放 added —— 合并即可 + const signed: Record = { ...(json.headers ?? {}), ...(json.added ?? {}) } + if (!signed['X-Helios'] && !signed['x-helios']) { + console.log('[qs/mssdk] 签名结果缺少 X-Helios') + return null + } + return signed + } catch (err: any) { + console.log(`[qs/mssdk] 签名服务不可达: ${err?.message ?? err}`) + return null + } +} /** * 给一个 qishui.com 请求签名 * @param headers 必须是「真实要发出去的那一份」(含 cookie / x-ss-stub) - * @returns 需追加的头;不可用时返回 null(调用方走原通道) + * @param body POST body(给了且 headers 没带 x-ss-stub 时服务端会自动补) + * @returns 需追加的头;不可用时返回 null(调用方走安卓通道) */ -export const signQishuiRequest = (url: string, headers: Record): Record | null => { - const current = loadSigner() - if (!current) return null - try { - const lines = Object.entries(headers) - .filter(([name, value]) => name && value != null) - .map(([name, value]) => `${name}\r\n${value}`) - .join('\r\n') - const fields = String(current.module.generateHttpSignatureHeaders(url, lines)).split('\r\n').filter(Boolean) - const signed: Record = {} - for (let i = 0; i + 1 < fields.length; i += 2) signed[fields[i]] = fields[i + 1] - if (!signed['X-Helios'] && !signed['x-helios']) return null - return signed - } catch (err: any) { - console.log(`[qs/mssdk] 签名失败,本次降级: ${err?.message ?? err}`) - return null - } +export const signQishuiRequest = async ( + url: string, + headers: Record, + body?: string, +): Promise | null> => { + const mode = loadSignMode() + if (!mode) return null + if (mode.kind === 'fixed') return { 'X-Helios': mode.fixed.helios, 'X-Medusa': mode.fixed.medusa } + return signViaServer(mode.server, mode.token, url, headers, body) } /** 官方 PC 端 UA(签名与 UA 绑定同一客户端版本) */ @@ -109,7 +143,12 @@ export const PC_UA = 'LunaPC/3.7.0(452316191)' /** 官方 PC 端公共查询参数(与签名设备成套;track_v2 / search 等 luna_pc 接口共用) */ export const pcCommonParams = (): Record => { - const { deviceId, installId } = getDeviceIds() + const mode = loadSignMode() + // 固定值是针对抓包设备签的,必须用配套设备;服务器现签则用本地设备(URL 带什么签什么) + const fixedDevice = mode?.kind === 'fixed' && mode.fixed.deviceId ? mode.fixed : null + const { deviceId, installId } = fixedDevice + ? { deviceId: fixedDevice.deviceId, installId: fixedDevice.installId } + : getDeviceIds() return { aid: '386088', app_name: 'luna_pc', @@ -135,4 +174,4 @@ export const pcCommonParams = (): Record => { } } -export default { mssdkAvailable, mssdkDeviceId, signQishuiRequest, pcCommonParams, PC_UA } +export default { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } diff --git a/sdk/qs/musicSearch.ts b/sdk/qs/musicSearch.ts index 5052e1d..74fe695 100644 --- a/sdk/qs/musicSearch.ts +++ b/sdk/qs/musicSearch.ts @@ -1,11 +1,12 @@ // 汽水音乐搜索(App 搜索接口直连) // 接口: https://api.qishui.com/luna/search/track?<公共参数>&q=<关键词>&cursor= // 两条通道(同一接口,差在公共参数与签名,返回结构一致): -// 官方 PC 通道: 装好官方签名模块(sdk/qs/mssdk.ts)时优先——luna_pc 参数 + X-Helios/X-Medusa; -// 安卓指纹通道: 未装签名模块时回退——静态安卓设备参数,缺参数或用 PC 参数不签名会被静默风控 -// (200 + 空 body);被风控时需换新指纹。 +// 官方 PC 通道: PC 签名可用(签名服务器或固定值,见 sdk/qs/mssdk.ts)时优先—— +// luna_pc 参数 + X-Helios/X-Medusa;签名失败/被风控自动降级安卓通道; +// 安卓指纹通道: 静态安卓设备参数,缺参数或用 PC 参数不签名会被静默风控(200 + 空 body); +// 被风控时需换新指纹。 import { sizeFormate, formatPlayTime } from '../index.ts' -import { httpFetch } from '../request.ts' +import { httpFetch } from '../common/request.ts' import { coverUrl } from './musicInfo.ts' import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from './mssdk.ts' @@ -59,9 +60,9 @@ const SEARCH_DEVICE_PARAMS = { const SEARCH_UA = 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80' const PC_PAGE_SIZE = 20 -// 有官方签名模块时走官方 PC 通道(PC 参数必须配 X-Helios/X-Medusa,否则上游 200 + 空 body) -const buildSearchUrl = (str: string, cursor: number, count: number) => { - const params = mssdkAvailable() +// PC 通道必须配 X-Helios/X-Medusa(否则上游 200 + 空 body);安卓通道用静态设备指纹 +const buildSearchUrl = (str: string, cursor: number, count: number, pcChannel: boolean) => { + const params = pcChannel ? new URLSearchParams({ ...pcCommonParams(), q: str, @@ -118,34 +119,56 @@ export default { } }).filter(Boolean) }, - search(str, page = 1, limit, retryNum = 0) { + handleSearchBody(rawBody, cursor, page) { + const group = rawBody.result_groups.find(g => g?.data?.length) || { data: [], has_more: false } + const list = this.handleResult(group.data) + const total = group.has_more ? cursor + list.length + 1 : cursor + list.length + this.total = total + this.page = page + this.allPage = group.has_more ? page + 1 : page + return { + list, + allPage: this.allPage, + limit: this.limit, + total, + source: 'qs', + } + }, + async search(str, page = 1, limit, retryNum = 0, forceAndroid = false) { if (++retryNum > 3) return Promise.reject(new Error('try max num')) - const pcChannel = mssdkAvailable() - // PC 通道每页固定 20 条(上游默认、不接受 count),cursor 必须按 20 步进,否则翻页与 limit 错位重叠 - const pageSize = pcChannel ? PC_PAGE_SIZE : (limit ?? this.limit) - const cursor = pageSize * (page - 1) - const url = buildSearchUrl(str, cursor, pageSize) - // 签名覆盖真实发出的头:先定头、再签、再发 - const headers: Record = { 'User-Agent': pcChannel ? PC_UA : SEARCH_UA } - const signed = signQishuiRequest(url, headers) - if (signed) Object.assign(headers, signed) - const searchRequest = httpFetch(url, { method: 'get', headers, timeout: 15000 }) - return searchRequest.promise.then(({ body }) => { - // 被风控/无结果时上游不返回 result_groups(200 + {"extra":{"empty_search":1}}),重试 - if (!body?.result_groups) return this.search(str, page, limit, retryNum) - const group = body.result_groups.find(g => g?.data?.length) || { data: [], has_more: false } - const list = this.handleResult(group.data) - const total = group.has_more ? cursor + list.length + 1 : cursor + list.length - this.total = total - this.page = page - this.allPage = group.has_more ? page + 1 : page - return { - list, - allPage: this.allPage, - limit: this.limit, - total, - source: 'qs', + const pcChannel = !forceAndroid && mssdkAvailable() + if (pcChannel) { + // PC 通道每页固定 20 条(上游默认、不接受 count),cursor 必须按 20 步进,否则翻页与 limit 错位重叠 + const pageSize = PC_PAGE_SIZE + const cursor = pageSize * (page - 1) + const url = buildSearchUrl(str, cursor, pageSize, true) + // 签名覆盖真实发出的头:先定头、再签、再发 + const headers: Record = { 'User-Agent': PC_UA } + const fallbackToAndroid = (reason: string) => { + console.log(`[qs/search] PC 通道${reason},降级安卓通道`) + return this.search(str, page, limit, retryNum, true) } - }) + let body: any = null + try { + // 签名失败(服务器宕机/固定值被风控)与请求异常都降级安卓通道 + const signed = await signQishuiRequest(url, headers) + if (!signed) return fallbackToAndroid('签名不可用') + const searchRequest = await httpFetch(url, { method: 'get', headers: { ...headers, ...signed }, timeout: 15000 }).promise + body = searchRequest.body + } catch (err: any) { + return fallbackToAndroid(`请求失败: ${err?.message ?? err}`) + } + // 被风控/无结果时上游不返回 result_groups(200 + {"extra":{"empty_search":1}}) + if (!body?.result_groups) return fallbackToAndroid('空结果(疑似风控)') + return this.handleSearchBody(body, cursor, page) + } + // 安卓指纹通道:无签名直发,空结果时换 _rticket 重试 + const pageSize = limit ?? this.limit + const cursor = pageSize * (page - 1) + const url = buildSearchUrl(str, cursor, pageSize, false) + const headers: Record = { 'User-Agent': SEARCH_UA } + const { body } = await httpFetch(url, { method: 'get', headers, timeout: 15000 }).promise + if (!body?.result_groups) return this.search(str, page, limit, retryNum) + return this.handleSearchBody(body, cursor, page) }, } diff --git a/signer/server.mjs b/signer/server.mjs deleted file mode 100644 index 33e2c1a..0000000 --- a/signer/server.mjs +++ /dev/null @@ -1,247 +0,0 @@ -#!/usr/bin/env node -// 汽水音乐外部签名服务 —— 在 Linux 上用 Wine 跑官方 bdms.node,对外只提供签名 -// -// 为什么需要它:bdms.node 是 Windows PE 原生模块,只能由 Windows 版 Node/Electron 加载。 -// 把「加载官方模块」这件事单独关进一个容器(Wine + Windows Node),主服务就能跑在任何平台, -// 通过 HTTP 拿 X-Helios / X-Medusa —— 契约与 sodahub-org/libmssdk、libresoda、 -// Meting-API 的远程签名服务互通(三者 JSON 可互换)。 -// -// 路由: -// GET /healthz → { ok, service, version, bdmsAvailable, deviceId(脱敏), auth, platform } -// GET /config → 需鉴权:设备身份(调用方据此对齐 URL 里的 device_id/iid) -// POST /sign → { url, method?, headers, body?, device_id? } → { ok, headers:{X-Helios,X-Medusa}, device_id, elapsed_ms } -// POST /selftest → 用样例请求自检(不联网) -// -// 鉴权:QS_SIGNER_TOKEN(Authorization: Bearer )。未设 token 时除非 -// QS_SIGNER_ALLOW_ANONYMOUS=1,否则拒绝启动 —— 签名能力等价于账号取流能力,别裸奔。 -// 环境变量:QS_SIGNER_BIND(127.0.0.1) / QS_SIGNER_PORT(8899) / QS_SIGNER_TOKEN / -// QS_SIGNER_DEVICE_ID / QS_SIGNER_BDMS_MODULE(默认 ./vendor/bdms.node) / QS_SIGNER_MAX_BODY -// 日志只记请求形状与耗时:Cookie、X-Helios、X-Medusa 一律不落盘。 -'use strict' - -const http = require('node:http') -const crypto = require('node:crypto') -const fs = require('node:fs') -const path = require('node:path') - -const VERSION = '0.1.0' -const SERVICE = 'qs-signer' -const BIND = process.env.QS_SIGNER_BIND || '127.0.0.1' -const PORT = Number(process.env.QS_SIGNER_PORT) || 8899 -const TOKEN = process.env.QS_SIGNER_TOKEN || '' -const ALLOW_ANONYMOUS = process.env.QS_SIGNER_ALLOW_ANONYMOUS === '1' -const DEFAULT_DEVICE_ID = process.env.QS_SIGNER_DEVICE_ID || '' -const MAX_BODY = Number(process.env.QS_SIGNER_MAX_BODY) || 256 * 1024 -const MODULE_PATH = path.resolve(process.env.QS_SIGNER_BDMS_MODULE || path.join(__dirname, 'vendor', 'bdms.node')) - -if (!TOKEN && !ALLOW_ANONYMOUS) { - console.error('[qs-signer] 拒绝启动:未设置 QS_SIGNER_TOKEN(本机调试可显式 QS_SIGNER_ALLOW_ANONYMOUS=1)') - process.exit(1) -} - -const maskDevice = (id) => { - const value = String(id || '') - return value.length <= 6 ? value : `${value.slice(0, 3)}***${value.slice(-3)}` -} - -// —— 官方模块加载:一次 require,按设备 init(换设备要重新 init)—— -let bdms = null -let loadError = '' -let initedDevice = '' -const loadBdms = () => { - if (bdms) return bdms - if (loadError) return null - try { - if (!fs.existsSync(MODULE_PATH)) throw new Error(`找不到 ${MODULE_PATH}`) - const mod = require(MODULE_PATH) - if (typeof mod.init !== 'function' || typeof mod.generateHttpSignatureHeaders !== 'function') { - throw new Error('导出不符合预期(需要 init / generateHttpSignatureHeaders)') - } - bdms = mod - console.log(`[qs-signer] bdms 模块已加载: ${MODULE_PATH}`) - return bdms - } catch (err) { - loadError = String((err && err.message) || err) - console.error(`[qs-signer] bdms 模块加载失败: ${loadError}(/sign 将返回 503,/healthz 仍可用)`) - return null - } -} - -const deviceIdFromRequest = (url, headers, explicit) => { - if (explicit) return String(explicit).trim() - try { - const parsed = new URL(String(url)) - for (const key of ['device_id', 'did', 'fp']) { - const value = parsed.searchParams.get(key) - if (value) return value.trim() - } - } catch { - // URL 不合法就继续往下找 - } - for (const name of ['x-device-id', 'device-id', 'device_id']) { - if (headers && headers[name]) return String(headers[name]).trim() - } - return DEFAULT_DEVICE_ID -} - -const ensureInit = (deviceId) => { - const mod = loadBdms() - if (!mod) throw new Error(loadError || 'bdms 模块不可用') - const target = String(deviceId || DEFAULT_DEVICE_ID || '').trim() - if (!target) throw new Error('缺少 device_id:请求里带 device_id(或 URL 查询参数 device_id/did/fp),或设置 QS_SIGNER_DEVICE_ID') - if (initedDevice !== target) { - mod.init({ deviceId: target }) - initedDevice = target - console.log(`[qs-signer] 已绑定设备 ${maskDevice(target)}`) - } - return mod -} - -const flattenHeaders = (headers) => { - const lines = [] - for (const [name, value] of Object.entries(headers || {})) { - if (!name || value === undefined || value === null) continue - const items = Array.isArray(value) ? value : [value] - for (const item of items) lines.push(`${name}\r\n${item}`) - } - return lines.join('\r\n') -} - -const parseSignaturePairs = (raw) => { - const fields = String(raw || '').split('\r\n').filter((t) => t.trim()) - const signed = {} - for (let i = 0; i + 1 < fields.length; i += 2) signed[fields[i]] = fields[i + 1] - return signed -} - -/** 签名一次请求:返回需追加/覆盖的请求头(含服务端补的 x-ss-stub) */ -const signRequest = ({ url, headers = {}, body, deviceId }) => { - if (!url) throw new Error('url 不能为空') - const target = ensureInit(deviceIdFromRequest(url, headers, deviceId)) - const outgoing = { ...headers } - // 有 body 却没给 x-ss-stub 时补上(它是签名输入,也要随请求发出),大小写保持官方写法 - if (body && !Object.keys(outgoing).some((k) => k.toLowerCase() === 'x-ss-stub')) { - outgoing['x-ss-stub'] = crypto.createHash('md5').update(String(body)).digest('hex').toUpperCase() - } - const signed = parseSignaturePairs(target.generateHttpSignatureHeaders(String(url), flattenHeaders(outgoing))) - if (!signed['X-Helios'] && !signed['x-helios']) throw new Error('签名结果缺少 X-Helios') - return { signed: { ...signed, ...(body ? { 'x-ss-stub': outgoing['x-ss-stub'] } : {}) }, deviceId: initedDevice } -} - -const json = (res, status, payload) => { - const text = JSON.stringify(payload) - res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text) }) - res.end(text) -} - -const readBody = (req) => new Promise((resolve, reject) => { - let size = 0 - const chunks = [] - req.on('data', (chunk) => { - size += chunk.length - if (size > MAX_BODY) { - reject(Object.assign(new Error('请求体过大'), { status: 413 })) - req.destroy() - return - } - chunks.push(chunk) - }) - req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) - req.on('error', reject) -}) - -const authorized = (req) => { - if (!TOKEN) return true - const header = String(req.headers.authorization || '') - const bearer = header.toLowerCase().startsWith('bearer ') ? header.slice(7).trim() : '' - const alt = String(req.headers['x-signer-token'] || '') - return bearer === TOKEN || alt === TOKEN -} - -const server = http.createServer(async (req, res) => { - const started = Date.now() - const url = String(req.url || '') - const pathname = url.split('?')[0] - - const log = (status) => console.log(`[qs-signer] ${req.method} ${pathname} → ${status} (${Date.now() - started}ms)`) - - try { - if (pathname === '/healthz') { - log(200) - return json(res, 200, { - ok: true, - service: SERVICE, - version: VERSION, - bdmsAvailable: Boolean(bdms) || (!loadError && fs.existsSync(MODULE_PATH)), - deviceId: maskDevice(initedDevice || DEFAULT_DEVICE_ID), - auth: Boolean(TOKEN), - platform: `${process.platform} ${process.arch}`, - node: process.version, - }) - } - - if (!authorized(req)) { - log(401) - return json(res, 401, { ok: false, error: 'unauthorized', code: 'unauthorized' }) - } - - if (pathname === '/config') { - log(200) - return json(res, 200, { - ok: true, - service: SERVICE, - version: VERSION, - bdmsModule: MODULE_PATH, - bdmsLoaded: Boolean(bdms), - loadError, - deviceId: maskDevice(initedDevice || DEFAULT_DEVICE_ID), - identity: { device_id: initedDevice || DEFAULT_DEVICE_ID, source: initedDevice ? 'request' : (DEFAULT_DEVICE_ID ? 'env' : 'unset') }, - }) - } - - if (pathname === '/selftest') { - try { - const sampleUrl = 'https://api.qishui.com/luna/pc/track_v2?aid=386088&device_id=1&iid=2' - const { signed } = signRequest({ url: sampleUrl, headers: { 'user-agent': 'LunaPC/selftest' }, deviceId: DEFAULT_DEVICE_ID || '1' }) - log(200) - return json(res, 200, { ok: true, headers: Object.keys(signed), lengths: Object.fromEntries(Object.entries(signed).map(([k, v]) => [k, String(v).length])) }) - } catch (err) { - log(503) - return json(res, 503, { ok: false, error: String((err && err.message) || err), code: 'signer_error' }) - } - } - - if (pathname === '/sign' && req.method === 'POST') { - const raw = await readBody(req) - let payload - try { - payload = JSON.parse(raw || '{}') - } catch { - log(400) - return json(res, 400, { ok: false, error: 'invalid JSON', code: 'bad_request' }) - } - const { url: target, headers = {}, body = '', device_id: deviceId } = payload - try { - const { signed } = signRequest({ url: target, headers, body, deviceId }) - log(200) - // headers 嵌套给标准调用方;顶层扁平给 Meting 风格调用方 - return json(res, 200, { ok: true, headers: signed, ...signed, device_id: initedDevice, elapsed_ms: Date.now() - started }) - } catch (err) { - log(503) - return json(res, 503, { ok: false, error: String((err && err.message) || err), code: 'signer_error' }) - } - } - - log(404) - return json(res, 404, { ok: false, error: 'not found' }) - } catch (err) { - const status = err && err.status ? err.status : 500 - log(status) - return json(res, status, { ok: false, error: String((err && err.message) || err) }) - } -}) - -server.listen(PORT, BIND, () => { - console.log(`[qs-signer] 监听 http://${BIND}:${PORT}(鉴权: ${TOKEN ? 'Bearer token' : '匿名(仅调试)'})`) - loadBdms() -}) diff --git a/tools/mssdk-sign/.gitignore b/tools/mssdk-sign/.gitignore deleted file mode 100644 index fb70a73..0000000 --- a/tools/mssdk-sign/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# 专有二进制与构建产物不入库 -metasecml.dll -*.exe -*.log -*.obj diff --git a/tools/mssdk-sign/README.md b/tools/mssdk-sign/README.md deleted file mode 100644 index c383631..0000000 --- a/tools/mssdk-sign/README.md +++ /dev/null @@ -1,186 +0,0 @@ -# mssdk-sign —— 汽水音乐 metasec 签名 HTTP 服务 - -把汽水 PC 客户端自带的 `metasecml.dll`(字节 metasec,官方签名主体 *Douyin Vision Co., Ltd.*) -关在一个进程里,对外只暴露一个 HTTP `/sign`。于是: - -- **Windows**:直接跑。 -- **Linux**:丢进 **Wine** 跑,宿主机的服务(MusicServer 等)用 HTTP 调它 —— 专有 DLL 不出 Wine 前缀,宿主机不需要 Windows 运行时。 - -对应地,MusicServer 侧 `sdk/qs/mssdk.ts` 里那套「进程内 require bdms.node」可以换成「HTTP 调本服务」, -跨平台部署就不用装官方客户端了(二进制仍需自己从官方客户端里取,不入库、不再分发)。 - -## 文件 - -| 文件 | 说明 | -|---|---| -| `mssdk-sign.cpp` | 全部实现(单文件,无第三方依赖:Win32 + Winsock + 自带 MD5/JSON) | -| `build.bat` | Windows/MSVC 构建 | -| `build-mingw.sh` | Linux 交叉编译成 exe(给 Wine 跑) | -| `client.mjs` | Node 客户端示例(可直接 `node client.mjs --send` 验证) | -| `REVERSE.md` | **逆向记录**:DLL 身份/壳/真实调用 ABI/行为规则/升级后如何重新定位偏移 | - -## 构建 - -Windows(MSVC,需要 VS BuildTools): - -```bat -build.bat -:: 生成 mssdk-sign.exe -mssdk-sign.exe --selftest --dll .\metasecml.dll -``` - -Linux → Wine(MinGW-w64 交叉编译,静态链接,不依赖 libstdc++/winpthread DLL): - -```bash -sudo apt install mingw-w64 -./build-mingw.sh # 生成 mssdk-sign.exe -``` - -> 注意:源码含中文注释/字符串,**MSVC 必须带 `/utf-8`**(`build.bat` 已带),否则会报一堆莫名其妙的语法错误。 - -## 运行 - -Windows: - -```bat -mssdk-sign.exe --port 16889 --dll .\metasecml.dll --token 你的口令 -v -``` - -Wine(建议放在自己的 prefix 里,和 DLL 同目录): - -```bash -mkdir -p /opt/mssdk-sign && cd /opt/mssdk-sign -cp mssdk-sign.exe metasecml.dll . -WINEPREFIX=/opt/wine-mssdk wine mssdk-sign.exe --selftest --dll ./metasecml.dll # 先自检 -WINEPREFIX=/opt/wine-mssdk wine mssdk-sign.exe --port 16889 --bind 0.0.0.0 --token 你的口令 -v -``` - -systemd 单元示例: - -```ini -[Unit] -Description=mssdk-sign (metasec signer in Wine) -After=network.target - -[Service] -Environment=WINEPREFIX=/opt/wine-mssdk -Environment=WINEDEBUG=-all -WorkingDirectory=/opt/mssdk-sign -ExecStart=/usr/bin/wine /opt/mssdk-sign/mssdk-sign.exe --port 16889 --bind 127.0.0.1 --token CHANGE_ME -Restart=always -RestartSec=3 - -[Install] -WantedBy=multi-user.target -``` - -参数: - -| 参数 | 说明 | -|---|---| -| `--port ` | 监听端口,默认 16889 | -| `--bind ` | 默认 `127.0.0.1`;容器/跨机调用才用 `0.0.0.0`(务必配 token) | -| `--dll ` | `metasecml.dll` 路径,默认 exe 同目录 | -| `--token ` | 启用鉴权(`Authorization: Bearer ` 或 `X-Auth-Token: `) | -| `--sign-rva ` | 手动指定签名函数偏移(默认按 DLL 版本表,见下) | -| `--selftest` | 只做「加载 DLL + 签名自检」后退出(部署前必跑) | -| `-v` | 打印每个请求的签名耗时 | - -## 接口 - -### `GET /healthz`(免鉴权) - -```json -{"ok":true,"service":"mssdk-sign","version":"0.1.0", - "signer":"D:/MusicServer/soda-mssdk/metasecml.dll", - "dllVersion":"4.6.1.0","signRva":"0x52af0","auth":true} -``` - -### `POST /sign` - -```jsonc -{ - "url": "https://api.qishui.com/luna/pc/track_v2?aid=386088&…", // 必填,带完整 query - "body": "{\"track_id\":\"…\"}", // 可选:给了且没带 x-ss-stub 时,服务端自动补 - "headers": { // 必填:真实要发出去的那一份 - "content-type": "application/json; charset=utf-8", - "user-agent": "LunaPC/3.7.0(452316191)", - "x-luna-background-type": "foreground", - "x-tt-trace-id": "00-…-…-01" - } -} -``` - -```json -{ - "ok": true, - "headers": {"X-Helios":"…","X-Medusa":"…","X-Neptune":"…"}, - "added": {"x-ss-stub":"DBA20AB94302CB03AC25A216DD953258"}, - "elapsed_ms": 43 -} -``` - -失败时:`{"ok":false,"error":"…"}` + 400(入参错)/ 401(token)/ 500(签名失败)。 - -### 三条硬性约定(踩过坑) - -1. **`headers` 必须是真实要发出的那一份**,一个都不能少(尤其 `cookie`、`x-ss-stub`)。签名覆盖 URL + 全部请求头, - 少一个服务端就回 `HTTP 200 + 空 body`。 -2. **`x-ss-stub` = body 的 MD5(大写)**,既是签名输入也要随请求发出。本服务在你传了 `body` 时会自动算并回传 - (`added.x-ss-stub`),记得把它一起发出去。 -3. **设备要成套**:URL 里的 `device_id` / `iid` / `fp` 必须是你自己的设备;签名和 URL 里的 `device_id` 绑死, - 改了 URL 设备号 → 服务端 `200 + 空 body`(实测)。 - -### 客户端(Node) - -```js -import { signRequest } from './client.mjs' - -const { headers, added } = await signRequest(url, headersToSend, body) -await fetch(url, { method: 'POST', headers: { ...headersToSend, ...headers, ...added }, body }) -``` - -集成到 MusicServer 时,把 `sdk/qs/mssdk.ts` 的 `signQishuiRequest()` 换成上面这次 HTTP 调用即可 -(签名覆盖的 headers 与请求实际发出的 headers 保持同一份、同一顺序)。 - -自检: - -```bash -node client.mjs # healthz + 签一次(不发真实请求) -node client.mjs --send # 顺带发给 api.qishui.com 验证签名被接受 -``` - -## 它是怎么调 DLL 的(逆向结论,2026-10-02) - -- 导出表里的 `MSBridgeML` / `MSBridgeOV` / `o0o0` 都是幌子(桩 / 线程状态初始化), - **真正的签名函数在 `image base + 0x52af0`**,原型: - - ```c - char* sign(const char* url, const char* headers_lines); // headers: "名\r\n值\r\n…" - ``` - -- 返回 `"名\r\n值\r\n…"` 的签名头串,由 DLL 自己的释放函数(`base + 0x1d8c0`)回收。 -- **不需要 init**;DLL 版本(本机样本)= `metasecbaseml 4.6.1.0`。 -- 调用是**串行**的(内部有全局/TLS 状态),服务端用互斥锁包住,实测 20 并发全部成功。 - -## 客户端升级了怎么办(DLL 换了,偏移会变) - -`--selftest` 会**大声失败**,不会静默出错。定位新偏移: - -1. 从新版客户端里取 `metasecml.dll`(解包方式见 MusicServer 的 `sdk/qs/mssdk.ts` 注释)。 -2. 拿真实请求自检,确认签名是否被接受(`node client.mjs --send`)。 -3. 找新偏移: - - 先在 `KNOWN_VERSIONS` 里加版本,用旧偏移试;不行就: - - 用 MusicServer 仓库里的脚本脱壳 + 定位:`tmp/metasec-rebuild.py`(内存脱壳 + 重建 PE)、 - `tmp/metasec-call-test.py`(直接调用试偏移)、`tmp/bdms-abi.py`(读 `bdms.node` 运行时 IAT 槽, - 那里躺着真实地址)。找到后用 `--sign-rva 0xXXXXX` 验证,再写进 `KNOWN_VERSIONS`。 - -## 已知限制 / 未验证项 - -- **Wine 下未实测**(本机没有 Wine):DLL 依赖的都是 Wine 常规实现的 Win32 API(kernel32/advapi32/ole32/ - winhttp/ws2_32/imm32/…),但 metasec 有设备指纹采集,行为可能和原生不同。部署前请先在目标机跑 - `wine mssdk-sign.exe --selftest` 和 `node client.mjs --send`。若加载失败,先看 `WINEDEBUG=+loaddll` - 的缺失依赖,必要时 `winetricks corefonts vcrun2019`、`winecfg` 里把 Windows 版本设为 Win10。 -- `metasecml.dll` 是**官方专有二进制**(签名主体抖音视界),不入库、不再分发;本目录只放源码与构建脚本, - DLL 自己从官方客户端取,放 exe 同目录即可。 -- 签名服务本身不联网、不存 cookie;但仍建议只监听 `127.0.0.1` 并设置 `--token`,不要暴露公网。 diff --git a/tools/mssdk-sign/REVERSE.md b/tools/mssdk-sign/REVERSE.md deleted file mode 100644 index 9e75a7e..0000000 --- a/tools/mssdk-sign/REVERSE.md +++ /dev/null @@ -1,97 +0,0 @@ -# metasec 签名组件逆向记录 - -> 面向维护者:**汽水客户端升级后签名失效时,先看这里。** -> 相关代码:`mssdk-sign.cpp`(签名服务);构建与接口用法见同目录 `README.md`。 -> 记录时间:2026-10-02,样本:`soda-mssdk/{metasecml.dll, bdms.node}`。 - -## 1. 速览 - -| 项 | 结论 | -|---|---| -| 组件身份 | 字节 metasec 官方组件,内部名 `metasecbaseml` **4.6.1.0**(2024-09-04) | -| 数字签名 | Authenticode 主体 **Douyin Vision Co., Ltd.**(DigiCert G4 Code Signing) | -| 保护 | 自解密壳 + 运行期解密字符串表;`LoadLibrary` 后内存里即为明文镜像 | -| 真实签名入口 | `metasecml.dll + 0x52af0`(**本版本**),`char* sign(const char* url, const char* headers)` | -| 需要 init 吗 | **不需要**,冷进程直接调即可出有效签名 | -| 有效性 | 已端到端验证:签名被 `api.qishui.com` 接受;不签名 / 设备不匹配 → `HTTP 200 + 0 字节` | -| 产出能力 | `X-Helios` + `X-Medusa`(部分场景附 `X-Neptune`);**不含** Argus/Ladon/Khronos/Gorgon/a_bogus | - -## 2. 两个文件的关系 - -- `metasecml.dll`(8.8MB):算法本体。导出表只有 3 个符号,且**都是幌子**: - - `o0o0`(0x191c0):线程局部状态初始化(读 TEB→TLS 槽,条件成立时 memset 一个 0x148 字节结构)。 - - `MSBridgeML` / `MSBridgeOV`(同为 0x1d010):同一个桩 —— 调一次内部函数后**无条件返回 true**,签名结果不经它返回。 -- `bdms.node`(164KB):官方客户端的 N-API 胶水。对 JS 暴露 `init` / `generateHttpSignatureHeaders` / `report`; - 通过**延迟导入** `metasecml.dll` 的 `o0o0`、`MSBridgeML` 来用 DLL。 - -## 3. 真实调用 ABI(核心) - -```c -// headers: 官方展平形式 —— 每行 "名字\r\n值",行间也用 "\r\n" 连接 -char* sign(const char* url, const char* headers_lines); -``` - -- 地址:`image base + 0x52af0`(该处是 `jmp` 跳板,实体在 0x52b00)。 -- 返回:`"名字\r\n值\r\n…"` 的签名头串,堆分配;释放函数 = `image base + 0x1d8c0`。 -- 线程:内部有全局/TLS 状态,**串行调用**(服务端已加互斥锁)。 -- addon 是怎么拿到这个地址的:运行时 DLL 改写了它的 IAT 槽(`bdms.node` 的 `.data` 偏移 `0x25e38`), - 槽里躺着的就是 `base+0x52af0` —— 这解释了「导出表说是 0x1d010,实际却在 0x52af0」的矛盾。 -- 壳的形态:磁盘上只有 PE 头 + 单个 8.8MB 加密节 `.xs2`(熵≈8.0),`.text/.rdata/.data/.xs0` 均无 raw 数据; - 加载时解密进内存 → **内存 dump 即得完整镜像**(重定位已修好)。 - -## 4. 行为规则(实测) - -| 场景 | 服务端反应 | -|---|---| -| 不带签名 | `HTTP 200 + 0 字节` | -| 带正确签名 | `HTTP 200` + 正常 JSON(曲名/时长/档位齐全) | -| 签名后改 URL 里的 `device_id` | `HTTP 200 + 0 字节` → **签名与 URL 设备参数绑死** | -| headers 少一个(尤其 `cookie`、`x-ss-stub`) | `HTTP 200 + 0 字节` → 签名覆盖**全部**请求头 | - -- **`x-ss-stub` = body 的 MD5(大写)**,既是签名输入也必须随请求发出。 -- **场景决定头集合**:传完整真实头 → `X-Helios` + `X-Medusa`(有时附 `X-Neptune`); - 只传一个 `user-agent` 这类极简头 → **只给 `X-Neptune`**(网页兜底场景)。 - 所以调用方必须把「真实要发出的那一份头」整套传进来。 -- 输出含时间戳/nonce,**每次不同**属正常。 -- 签名有效 ≠ 能拿整曲:匿名仍只有 30 秒试听(实测 236.6s 曲目 `stream_duration=30`), - **瓶颈是登录 sessionid,不是签名**。 - -## 5. 内部结构(RTTI 符号泄漏) - -| 类 / 符号 | 作用 | -|---|---| -| `IMSHttpReqSignModule` / `MSHttpReqSignModule` | HTTP 请求签名(本服务的调用目标) | -| `IMSWebsocketReqSignModule` | WebSocket 请求签名 | -| `MSBlackBox` / `IMSBlackBoxItem` | 黑盒数据采集 | -| `IMSSecDeviceIDModule` | 设备指纹(device id) | -| `IMSRiskInspectModule` / `IMSRiskInspector` | 风控检测 | -| `IMSReportModule` | 数据上报 | -| `MSPBSInt32/64`、`MSPBString(List)`(`__ms__`) | protobuf 序列化 | -| `MSNetInterceptor::FuncInterceptor` + `Cronet_UrlRequest_Create`、`Cronet_Engine_SetMD5Header` | 注入 Chromium Cronet 网络栈(`x-ss-stub` 的 MD5 由它算) | -| `MSConfig` / `MSJSON` / `MSHttpClient` / `MSTaskManager` | 基础设施;日志用 spdlog,源路径 `utils/matrix/os/windows/ms_log.cc` | - -## 6. 保护强度评估 - -- 壳是「自解密 + 字符串按需解密」,**没有代码虚拟化**;脱壳后是正常的 MSVC x64 代码,可读可跟。 -- 敏感字符串(如 `X-Helios`)静态搜不到,运行期才解密;镜像里没找到明文标准 AES S-box - (只有 CRC32 表、Base64 表、TEA/Blowfish 类常量)。 -- 结论:**算法级复刻不划算**——黑盒调用的集成成本≈0,而且能随官方升级同步。 - -## 7. 客户端升级后怎么重新定位偏移 - -`--selftest` 会**大声失败**,不会静默签出无效头。定位步骤: - -1. 从新版客户端取 `metasecml.dll`(解包方式见 `sdk/qs/mssdk.ts` 头部注释)。 -2. 跑 `mssdk-sign.exe --selftest --dll <新dll>`:会打印它读到的 DLL 版本;失败即偏移变了。 -3. 找新偏移(仓库 `tmp/` 下现成脚本): - - `tmp/metasec-rebuild.py` —— 内存脱壳 + 重建可分析 PE; - - `tmp/bdms-abi.py` —— 读 `bdms.node` 运行时 IAT 槽,直接拿到真实签名函数地址(**最省事**); - - `tmp/metasec-call-test.py` —— 用候选偏移直接调用,看返回是否形如 `X-…\r\n…`。 -4. 用 `--sign-rva 0xXXXXX` 验证,通过后写进 `mssdk-sign.cpp` 的 `KNOWN_VERSIONS`。 -5. 复验:`node client.mjs --send` 应得到 `HTTP 200` + 非空 body。 - -## 8. 边界 - -- `metasecml.dll` / `bdms.node` 是**官方专有二进制**,不入库、不随代码分发;各自从自己的官方客户端取,放 exe 同目录。 -- 用途限于个人自用/互操作性研究;不要对外提供签名服务,也不要用于批量刷接口。 -- 签名服务本身不联网、不落 cookie;部署仍建议只监听 `127.0.0.1` 并设置 `--token`。 diff --git a/tools/mssdk-sign/build-mingw.sh b/tools/mssdk-sign/build-mingw.sh deleted file mode 100644 index fa1075e..0000000 --- a/tools/mssdk-sign/build-mingw.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# Linux 上交叉编译成 Windows exe(给 Wine 跑)。 -# 依赖: apt install mingw-w64 (Debian/Ubuntu) -set -euo pipefail -CXX=${CXX:-x86_64-w64-mingw32-g++} -command -v "$CXX" >/dev/null || { echo "[x] 找不到 $CXX,请先装 mingw-w64"; exit 1; } - -"$CXX" -std=c++17 -O2 -Wno-unknown-pragmas \ - -static -static-libgcc -static-libstdc++ \ - -o mssdk-sign.exe mssdk-sign.cpp \ - -pthread -lws2_32 -lversion - -echo "[ok] 生成 mssdk-sign.exe(静态链接,不依赖 libstdc++/winpthread DLL)" -echo " 自检: wine ./mssdk-sign.exe --selftest --dll ./metasecml.dll" diff --git a/tools/mssdk-sign/build.bat b/tools/mssdk-sign/build.bat deleted file mode 100644 index 57f0902..0000000 --- a/tools/mssdk-sign/build.bat +++ /dev/null @@ -1,16 +0,0 @@ -@echo off -rem MSVC native build (Windows). Source has UTF-8 Chinese comments -> /utf-8 is required. -setlocal -set VCVARS=C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat -if not exist "%VCVARS%" ( - echo [x] vcvars64.bat not found, edit VCVARS in this script - exit /b 1 -) -call "%VCVARS%" >nul -cl /nologo /O2 /EHsc /std:c++17 /utf-8 /W3 mssdk-sign.cpp /link /out:mssdk-sign.exe ws2_32.lib version.lib -if errorlevel 1 ( - echo [x] build failed - exit /b 1 -) -echo [ok] built mssdk-sign.exe -echo selftest: mssdk-sign.exe --selftest --dll .\metasecml.dll diff --git a/tools/mssdk-sign/client.mjs b/tools/mssdk-sign/client.mjs deleted file mode 100644 index 94b75ba..0000000 --- a/tools/mssdk-sign/client.mjs +++ /dev/null @@ -1,73 +0,0 @@ -// mssdk-sign 的 Node 客户端示例(免依赖,Node 18+) -// -// 用法: -// node client.mjs # 自检:打 /healthz + 签一次 track_v2(不发真实请求) -// node client.mjs --send # 顺带把签名后的请求发给 api.qishui.com 验证 -// -// 集成到 MusicServer 时,把 signQishuiRequest() 换成这个 HTTP 调用即可 -// (签名输入必须与真实发出的头完全一致,含 x-ss-stub)。 - -const BASE = process.env.MSSDK_SIGN_URL ?? 'http://127.0.0.1:16889' -const TOKEN = process.env.MSSDK_SIGN_TOKEN ?? '' - -const headers0 = (extra = {}) => (TOKEN ? { authorization: `Bearer ${TOKEN}`, ...extra } : extra) - -/** 调本地签名服务;返回需要追加到请求上的头 */ -export async function signRequest(url, headers, body) { - const res = await fetch(`${BASE}/sign`, { - method: 'POST', - headers: headers0({ 'content-type': 'application/json' }), - body: JSON.stringify({ url, headers, body }), - }) - const json = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` })) - if (!json.ok) throw new Error(json.error ?? `签名失败 HTTP ${res.status}`) - return { headers: json.headers, added: json.added ?? {}, elapsed_ms: json.elapsed_ms } -} - -export async function healthz() { - const res = await fetch(`${BASE}/healthz`) - return res.json() -} - -import { pathToFileURL } from 'node:url' - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const health = await healthz() - console.log('healthz:', health) - - // 与 sdk/qs/mssdk.ts 的 PC 通道同构:URL 带 device_id,headers 是真实要发出的那份 - const deviceId = process.env.QS_DEVICE_ID ?? '1219822735468811019' - const iid = process.env.QS_IID ?? '1219822735468829019' - const body = JSON.stringify({ track_id: '7501674235158431760', media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' }) - const q = new URLSearchParams({ - aid: '386088', app_name: 'luna_pc', region: 'cn', geo_region: 'cn', os_region: 'cn', sim_region: '', - device_id: deviceId, cdid: '', iid, version_name: '3.7.0', version_code: '30070000', - channel: 'official', build_mode: 'master', network_carrier: '', ac: 'wifi', tz_name: 'Asia/Shanghai', - resolution: '', device_platform: 'windows', device_type: 'Windows', os_version: 'Windows 11', fp: deviceId, - }) - const url = `https://api.qishui.com/luna/pc/track_v2?${q}` - const headers = { - 'content-type': 'application/json; charset=utf-8', - 'user-agent': 'LunaPC/3.7.0(452316191)', - 'x-luna-background-type': 'foreground', - 'x-luna-is-background-req': '0', - 'x-luna-is-local-user': '0', - 'x-tt-trace-id': `00-${'0'.repeat(16)}-${'0'.repeat(16)}-01`, - } - - const signed = await signRequest(url, headers, body) - console.log('签名头:', Object.keys(signed.headers), `(${signed.elapsed_ms}ms)`, signed.added) - - if (process.argv.includes('--send')) { - const r = await fetch(url, { method: 'POST', headers: { ...headers, ...signed.headers, ...signed.added }, body }) - const t = await r.text() - console.log(`真实请求: HTTP ${r.status} ${t.length} 字节`) - if (t) { - const j = JSON.parse(t) - const vm = typeof j?.track_player?.video_model === 'string' ? JSON.parse(j.track_player.video_model) : j?.track_player?.video_model - console.log(` 曲目: ${j?.track?.name} 曲长 ${j?.track?.duration}ms 试听 ${vm?.video_duration}s`) - } else { - console.log(' 空 body = 签名没被接受') - } - } -} diff --git a/tools/mssdk-sign/mssdk-sign.cpp b/tools/mssdk-sign/mssdk-sign.cpp deleted file mode 100644 index e7213ae..0000000 --- a/tools/mssdk-sign/mssdk-sign.cpp +++ /dev/null @@ -1,754 +0,0 @@ -// mssdk-sign —— 汽水音乐(metasec)签名 HTTP 服务 -// -// 为什么是它:汽水 PC 客户端自带的 metasecml.dll 提供应用级签名(X-Helios/X-Medusa, -// 部分场景附 X-Neptune)。本程序把该 DLL 关在一个进程里,对外只暴露一个 HTTP /sign, -// 于是:Windows 上直接跑;Linux 上丢进 Wine 跑,宿主机的服务(如 MusicServer)用 HTTP 调。 -// -// 关于 DLL 的调用方式(2026-10-02 逆向确认,版本 metasecbaseml 4.6.1.0): -// * 导出表里的 MSBridgeML / MSBridgeOV / o0o0 都是幌子(桩/初始化), -// 真正的签名函数在 image base + 0x52af0,原型 char* f(const char* url, const char* headers)。 -// * headers 是官方展平形式:每个头 "名字\r\n值",头与头之间也用 "\r\n" 连接。 -// * 返回 "名字\r\n值\r\n..." 的签名头串,由 DLL 自己的释放函数(base + 0x1d8c0)回收。 -// * 不需要 init;签名覆盖 URL + 全部请求头,且与 URL 里的 device_id 绑死 -// (改 URL 设备号 → 服务端 200 + 空 body)。 -// * 偏移随客户端版本变化:内置版本表 + 启动自检,不匹配会明确报错(见 README「升级怎么办」)。 -// -// 构建(任选其一): -// Windows/MSVC : build.bat -// Linux/MinGW : x86_64-w64-mingw32-g++ -std=c++17 -O2 -static -static-libgcc -static-libstdc++ \ -// -o mssdk-sign.exe mssdk-sign.cpp -lws2_32 -lversion -lwinpthread -// 运行: -// mssdk-sign.exe --port 16889 --dll .\metasecml.dll -// wine mssdk-sign.exe --port 16889 --dll ./metasecml.dll -// -// 依赖:仅 Win32 API + Winsock(无第三方库,无 C++ 异常跨模块问题)。 - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef _MSC_VER -#pragma comment(lib, "ws2_32.lib") -#pragma comment(lib, "version.lib") -#endif - -// ---------------------------------------------------------------- 工具 - -static std::string toLower(std::string s) { - for (auto &c : s) c = (char)tolower((unsigned char)c); - return s; -} - -static std::string trim(const std::string &s) { - size_t a = s.find_first_not_of(" \t\r\n"); - if (a == std::string::npos) return ""; - size_t b = s.find_last_not_of(" \t\r\n"); - return s.substr(a, b - a + 1); -} - -static std::string jsonEscape(const std::string &s) { - std::string o; - o.reserve(s.size() + 8); - for (unsigned char c : s) { - switch (c) { - case '"': o += "\\\""; break; - case '\\': o += "\\\\"; break; - case '\n': o += "\\n"; break; - case '\r': o += "\\r"; break; - case '\t': o += "\\t"; break; - default: - if (c < 0x20) { - char b[8]; - snprintf(b, sizeof b, "\\u%04x", c); - o += b; - } else { - o += (char)c; - } - } - } - return o; -} - -// ---------------------------------------------------------------- MD5(用于 x-ss-stub = body 的 MD5 大写) - -namespace md5impl { -struct Ctx { - uint32_t a = 0x67452301, b = 0xefcdab89, c = 0x98badcfe, d = 0x10325476; - uint64_t len = 0; - uint8_t buf[64]; - size_t n = 0; -}; - -static const uint32_t K[64] = { - 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, - 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, - 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, - 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, - 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, - 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, - 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, - 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391}; -static const uint8_t S[64] = {7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, - 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, - 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, - 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21}; - -static inline uint32_t rotl(uint32_t x, int c) { return (x << c) | (x >> (32 - c)); } - -static void block(Ctx &ctx, const uint8_t *p) { - uint32_t m[16]; - for (int i = 0; i < 16; i++) - m[i] = (uint32_t)p[i * 4] | ((uint32_t)p[i * 4 + 1] << 8) | ((uint32_t)p[i * 4 + 2] << 16) | - ((uint32_t)p[i * 4 + 3] << 24); - uint32_t a = ctx.a, b = ctx.b, c = ctx.c, d = ctx.d; - for (int i = 0; i < 64; i++) { - uint32_t f, g; - if (i < 16) { f = (b & c) | (~b & d); g = (uint32_t)i; } - else if (i < 32) { f = (d & b) | (~d & c); g = (uint32_t)(5 * i + 1) & 15; } - else if (i < 48) { f = b ^ c ^ d; g = (uint32_t)(3 * i + 5) & 15; } - else { f = c ^ (b | ~d); g = (uint32_t)(7 * i) & 15; } - uint32_t t = d; - d = c; - c = b; - b = b + rotl(a + f + K[i] + m[g], S[i]); - a = t; - } - ctx.a += a; ctx.b += b; ctx.c += c; ctx.d += d; -} - -static void update(Ctx &ctx, const void *data, size_t len) { - const uint8_t *p = (const uint8_t *)data; - ctx.len += len; - while (len) { - size_t take = 64 - ctx.n; - if (take > len) take = len; - memcpy(ctx.buf + ctx.n, p, take); - ctx.n += take; p += take; len -= take; - if (ctx.n == 64) { block(ctx, ctx.buf); ctx.n = 0; } - } -} - -static std::string hex(const Ctx &in) { - Ctx ctx = in; - uint64_t bits = ctx.len * 8; - uint8_t pad = 0x80; - update(ctx, &pad, 1); - uint8_t zero = 0; - while (ctx.n != 56) update(ctx, &zero, 1); - uint8_t lenb[8]; - for (int i = 0; i < 8; i++) lenb[i] = (uint8_t)(bits >> (8 * i)); - update(ctx, lenb, 8); - uint32_t v[4] = {ctx.a, ctx.b, ctx.c, ctx.d}; - static const char *H = "0123456789abcdef"; - std::string out; - out.reserve(32); - for (int i = 0; i < 4; i++) - for (int j = 0; j < 4; j++) { - out += H[(v[i] >> (8 * j + 4)) & 0xf]; - out += H[(v[i] >> (8 * j)) & 0xf]; - } - return out; -} -} // namespace md5impl - -static std::string md5HexUpper(const std::string &data) { - md5impl::Ctx ctx; - md5impl::update(ctx, data.data(), data.size()); - std::string h = md5impl::hex(ctx); - for (auto &c : h) c = (char)toupper((unsigned char)c); - return h; -} - -// ---------------------------------------------------------------- 极简 JSON(只支持本服务需要的子集) - -struct JVal { - enum T { Nul, Bool, Num, Str, Arr, Obj } t = Nul; - std::string s; // Str / Num / Bool 的原始文本 - std::vector a; // Arr - std::vector> o; // Obj(保序) - const JVal *get(const std::string &k) const { - for (auto &kv : o) - if (kv.first == k) return &kv.second; - return nullptr; - } -}; - -namespace json { -struct P { - const std::string &s; - size_t i = 0; - explicit P(const std::string &src) : s(src) {} - void ws() { while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) i++; } - bool lit(const char *w) { - size_t n = strlen(w); - if (s.compare(i, n, w) == 0) { i += n; return true; } - return false; - } - void utf8(uint32_t cp, std::string &out) { - if (cp < 0x80) out += (char)cp; - else if (cp < 0x800) { out += (char)(0xC0 | (cp >> 6)); out += (char)(0x80 | (cp & 0x3F)); } - else if (cp < 0x10000) { - out += (char)(0xE0 | (cp >> 12)); - out += (char)(0x80 | ((cp >> 6) & 0x3F)); - out += (char)(0x80 | (cp & 0x3F)); - } else { - out += (char)(0xF0 | (cp >> 18)); - out += (char)(0x80 | ((cp >> 12) & 0x3F)); - out += (char)(0x80 | ((cp >> 6) & 0x3F)); - out += (char)(0x80 | (cp & 0x3F)); - } - } - bool hex4(uint32_t &v) { - v = 0; - for (int k = 0; k < 4; k++) { - if (i >= s.size()) return false; - char c = s[i++]; - v <<= 4; - if (c >= '0' && c <= '9') v |= (uint32_t)(c - '0'); - else if (c >= 'a' && c <= 'f') v |= (uint32_t)(c - 'a' + 10); - else if (c >= 'A' && c <= 'F') v |= (uint32_t)(c - 'A' + 10); - else return false; - } - return true; - } - bool str(std::string &out) { - if (i >= s.size() || s[i] != '"') return false; - i++; - out.clear(); - while (i < s.size()) { - char c = s[i++]; - if (c == '"') return true; - if (c != '\\') { out += c; continue; } - if (i >= s.size()) return false; - char e = s[i++]; - switch (e) { - case '"': out += '"'; break; - case '\\': out += '\\'; break; - case '/': out += '/'; break; - case 'b': out += '\b'; break; - case 'f': out += '\f'; break; - case 'n': out += '\n'; break; - case 'r': out += '\r'; break; - case 't': out += '\t'; break; - case 'u': { - uint32_t cp; - if (!hex4(cp)) return false; - if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < s.size() && s[i] == '\\' && s[i + 1] == 'u') { - size_t save = i; - i += 2; - uint32_t lo; - if (hex4(lo) && lo >= 0xDC00 && lo <= 0xDFFF) - cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); - else - i = save; - } - utf8(cp, out); - break; - } - default: return false; - } - } - return false; - } - bool val(JVal &v) { - ws(); - if (i >= s.size()) return false; - char c = s[i]; - if (c == '{') { - v.t = JVal::Obj; - i++; - ws(); - if (i < s.size() && s[i] == '}') { i++; return true; } - while (true) { - ws(); - std::string k; - if (!str(k)) return false; - ws(); - if (i >= s.size() || s[i] != ':') return false; - i++; - JVal cv; - if (!val(cv)) return false; - v.o.emplace_back(std::move(k), std::move(cv)); - ws(); - if (i < s.size() && s[i] == ',') { i++; continue; } - if (i < s.size() && s[i] == '}') { i++; return true; } - return false; - } - } - if (c == '[') { - v.t = JVal::Arr; - i++; - ws(); - if (i < s.size() && s[i] == ']') { i++; return true; } - while (true) { - JVal cv; - if (!val(cv)) return false; - v.a.push_back(std::move(cv)); - ws(); - if (i < s.size() && s[i] == ',') { i++; continue; } - if (i < s.size() && s[i] == ']') { i++; return true; } - return false; - } - } - if (c == '"') { v.t = JVal::Str; return str(v.s); } - if (lit("true")) { v.t = JVal::Bool; v.s = "true"; return true; } - if (lit("false")) { v.t = JVal::Bool; v.s = "false"; return true; } - if (lit("null")) { v.t = JVal::Nul; return true; } - size_t b = i; - while (i < s.size() && (isdigit((unsigned char)s[i]) || s[i] == '-' || s[i] == '+' || s[i] == '.' || - s[i] == 'e' || s[i] == 'E')) - i++; - if (i == b) return false; - v.t = JVal::Num; - v.s = s.substr(b, i - b); - return true; - } -}; -} // namespace json - -// ---------------------------------------------------------------- 签名器 - -typedef char *(*SignFn)(const char *, const char *); -typedef void (*FreeFn)(void *); - -// SEH 必须放在不含 C++ 析构对象的函数里(否则 MSVC 报 C2712) -#ifdef _MSC_VER -static char *callSignRaw(SignFn fn, const char *url, const char *headers) { - __try { - return fn(url, headers); - } __except (EXCEPTION_EXECUTE_HANDLER) { - return nullptr; - } -} -#endif - -struct RvaPair { - uint64_t sign; - uint64_t freefn; -}; - -// 已知版本 → 偏移(客户端升级后新增一行;见 README) -static const struct { - const char *version; - RvaPair rva; -} KNOWN_VERSIONS[] = { - {"4.6.1.0", {0x52af0, 0x1d8c0}}, -}; -static const RvaPair DEFAULT_RVA = {0x52af0, 0x1d8c0}; - -class Signer { - public: - bool load(const std::string &dllPath, uint64_t rvaOverride, std::string &err) { - dllPath_ = dllPath; - HMODULE m = LoadLibraryExW(toWide(dllPath).c_str(), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH); - if (!m) { - char b[256]; - snprintf(b, sizeof b, "LoadLibraryExW 失败(错误码 %lu): %s", GetLastError(), dllPath.c_str()); - err = b; - return false; - } - mod_ = m; - version_ = readVersion(dllPath); - - RvaPair use = DEFAULT_RVA; - if (rvaOverride) { - use = {rvaOverride, rvaOverride + (DEFAULT_RVA.freefn - DEFAULT_RVA.sign)}; - err.clear(); - } else { - bool found = false; - for (auto &kv : KNOWN_VERSIONS) { - if (version_ == kv.version) { use = kv.rva; found = true; break; } - } - if (!found) { - char b[256]; - snprintf(b, sizeof b, - "DLL 版本 %s 不在已知表内(已知: 4.6.1.0)。将按默认偏移 %#llx 试跑," - "失败请用 --sign-rva 指定(见 README)", - version_.empty() ? "(读不到)" : version_.c_str(), (unsigned long long)DEFAULT_RVA.sign); - warn_ = b; - } - } - - sign_ = (SignFn)((uint8_t *)mod_ + use.sign); - free_ = (FreeFn)((uint8_t *)mod_ + use.freefn); - rva_ = use; - - // 启动自检:签一个探针,确认偏移正确(不发网络请求) - if (!selfTest()) { - err = "签名自检失败:偏移 " + hexStr(use.sign) + " 处不是有效的签名函数。" - "客户端升级后请重新定位(README「升级怎么办」)"; - sign_ = nullptr; - free_ = nullptr; - return false; - } - return true; - } - - bool ready() const { return sign_ != nullptr; } - std::string version() const { return version_; } - std::string warn() const { return warn_; } - std::string dllPath() const { return dllPath_; } - uint64_t signRva() const { return rva_.sign; } - - // 返回 "名字\r\n值\r\n..." 的签名头串;失败返回空 - std::string sign(const std::string &url, const std::string &headersFlat) { - if (!sign_) return ""; - std::lock_guard lk(mu_); // DLL 内部有全局/TLS 状态,串行调用最稳 -#ifdef _MSC_VER - char *r = callSignRaw(sign_, url.c_str(), headersFlat.c_str()); -#else - // MinGW 无 MSVC 式 __try/__except;调用异常会直接终止进程(签名偏移错时会立刻暴露) - char *r = sign_(url.c_str(), headersFlat.c_str()); -#endif - if (!r) return ""; - std::string out = r; - if (free_) free_(r); - return out; - } - - private: - static std::wstring toWide(const std::string &s) { - int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0); - std::wstring w(n > 0 ? n - 1 : 0, L'\0'); - if (n > 0) MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, &w[0], n); - return w; - } - static std::string hexStr(uint64_t v) { - char b[32]; - snprintf(b, sizeof b, "0x%llx", (unsigned long long)v); - return b; - } - static std::string readVersion(const std::string &path) { - DWORD dummy = 0; - DWORD sz = GetFileVersionInfoSizeW(toWide(path).c_str(), &dummy); - if (!sz) return ""; - std::vector buf(sz); - if (!GetFileVersionInfoW(toWide(path).c_str(), 0, sz, buf.data())) return ""; - VS_FIXEDFILEINFO *fi = nullptr; - UINT len = 0; - if (!VerQueryValueW(buf.data(), L"\\", (void **)&fi, &len) || !fi) return ""; - char b[64]; - snprintf(b, sizeof b, "%u.%u.%u.%u", HIWORD(fi->dwFileVersionMS), LOWORD(fi->dwFileVersionMS), - HIWORD(fi->dwFileVersionLS), LOWORD(fi->dwFileVersionLS)); - return b; - } - bool selfTest() { - const char *probeUrl = - "https://api.qishui.com/luna/pc/track_v2?aid=386088&app_name=luna_pc&device_id=1"; - const char *probeHdr = "user-agent\r\nLunaPC/3.7.0(452316191)"; - std::string r = sign(probeUrl, probeHdr); - return r.find("X-") != std::string::npos && r.find("\r\n") != std::string::npos; - } - - HMODULE mod_ = nullptr; - SignFn sign_ = nullptr; - FreeFn free_ = nullptr; - RvaPair rva_{}; - std::string dllPath_, version_, warn_; - std::mutex mu_; -}; - -// ---------------------------------------------------------------- HTTP 服务 - -struct Config { - std::string bind = "127.0.0.1"; - int port = 16889; - std::string dll; - std::string token; // 非空则要求 Bearer / X-Auth-Token - uint64_t signRva = 0; // 0 = 用版本表 - bool selftestOnly = false; - bool verbose = false; -}; - -static std::string httpDate() { - SYSTEMTIME st; - GetSystemTime(&st); - char b[64]; - snprintf(b, sizeof b, "%04d-%02d-%02dT%02d:%02d:%02dZ", st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, - st.wSecond); - return b; -} - -static std::string respJson(int code, const std::string &body) { - const char *reason = code == 200 ? "OK" : (code == 400 ? "Bad Request" : (code == 401 ? "Unauthorized" : (code == 404 ? "Not Found" : "Internal Server Error"))); - char head[512]; - snprintf(head, sizeof head, - "HTTP/1.1 %d %s\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: %zu\r\n" - "Connection: close\r\nServer: mssdk-sign/0.1.0\r\n\r\n", - code, reason, body.size()); - return std::string(head) + body; -} - -static std::string errBody(const std::string &msg) { return "{\"ok\":false,\"error\":\"" + jsonEscape(msg) + "\"}"; } - -static bool sendAll(SOCKET s, const std::string &data) { - size_t sent = 0; - while (sent < data.size()) { - int n = send(s, data.data() + sent, (int)(data.size() - sent), 0); - if (n <= 0) return false; - sent += (size_t)n; - } - return true; -} - -// 读一个完整请求(headers + body) -static bool readRequest(SOCKET s, std::string &method, std::string &path, std::string &headersRaw, - std::string &body) { - std::string buf; - char tmp[4096]; - size_t headEnd = std::string::npos; - while ((headEnd = buf.find("\r\n\r\n")) == std::string::npos) { - int n = recv(s, tmp, sizeof tmp, 0); - if (n <= 0) return false; - buf.append(tmp, n); - if (buf.size() > 256 * 1024) return false; - } - headersRaw = buf.substr(0, headEnd); - size_t lineEnd = headersRaw.find("\r\n"); - std::string reqLine = headersRaw.substr(0, lineEnd == std::string::npos ? headersRaw.size() : lineEnd); - size_t sp1 = reqLine.find(' '), sp2 = reqLine.rfind(' '); - if (sp1 == std::string::npos || sp2 == std::string::npos || sp2 <= sp1) return false; - method = reqLine.substr(0, sp1); - path = reqLine.substr(sp1 + 1, sp2 - sp1 - 1); - - size_t contentLength = 0; - { - std::string h = toLower(headersRaw); - size_t p = h.find("content-length:"); - if (p != std::string::npos) contentLength = (size_t)strtoul(headersRaw.c_str() + p + 15, nullptr, 10); - } - if (contentLength > 4 * 1024 * 1024) return false; - body = buf.substr(headEnd + 4); - while (body.size() < contentLength) { - int n = recv(s, tmp, sizeof tmp, 0); - if (n <= 0) break; - body.append(tmp, n); - } - if (body.size() > contentLength) body.resize(contentLength); - return true; -} - -static bool authorized(const Config &cfg, const std::string &headersRaw) { - if (cfg.token.empty()) return true; - std::string h = toLower(headersRaw); - std::string want = toLower(cfg.token); - if (h.find("authorization: bearer " + want) != std::string::npos) return true; - if (h.find("x-auth-token: " + want) != std::string::npos) return true; - if (h.find("?token=" + want) != std::string::npos) return true; - return false; -} - -static std::string handleSign(Signer &signer, const std::string &body, bool verbose) { - JVal root; - json::P p(body); - if (!p.val(root) || root.t != JVal::Obj) return respJson(400, errBody("请求体不是 JSON 对象")); - - const JVal *jurl = root.get("url"); - if (!jurl || jurl->t != JVal::Str || jurl->s.empty()) return respJson(400, errBody("缺少 url 字段")); - - // 保序收集 headers - std::vector> headers; - bool hasStub = false; - if (const JVal *jh = root.get("headers")) { - if (jh->t != JVal::Obj) return respJson(400, errBody("headers 必须是对象")); - for (auto &kv : jh->o) { - if (kv.second.t != JVal::Str) continue; // 只签名字符串头 - headers.emplace_back(kv.first, kv.second.s); - if (toLower(kv.first) == "x-ss-stub") hasStub = true; - } - } - if (headers.empty()) return respJson(400, errBody("headers 不能为空(签名覆盖全部请求头,必须传真实要发出的那一份)")); - - // 给了 body 且没带 x-ss-stub → 补上(body 的 MD5 大写),与官方客户端一致 - std::string addedStub; - if (const JVal *jb = root.get("body")) { - if (jb->t == JVal::Str && !hasStub) { - addedStub = md5HexUpper(jb->s); - headers.emplace_back("x-ss-stub", addedStub); - } - } - - std::string flat; - for (size_t i = 0; i < headers.size(); i++) { - if (i) flat += "\r\n"; - flat += headers[i].first + "\r\n" + headers[i].second; - } - - auto t0 = std::chrono::steady_clock::now(); - std::string raw = signer.sign(jurl->s, flat); - auto ms = std::chrono::duration_cast(std::chrono::steady_clock::now() - t0).count(); - - if (raw.empty()) - return respJson(500, errBody("签名失败(DLL 未返回内容;检查 headers 是否与将要发出的完全一致)")); - - // 解析 "名字\r\n值\r\n..." 成对 - std::vector f; - { - size_t b = 0; - while (b <= raw.size()) { - size_t e = raw.find("\r\n", b); - if (e == std::string::npos) { f.push_back(raw.substr(b)); break; } - f.push_back(raw.substr(b, e - b)); - b = e + 2; - } - while (!f.empty() && f.back().empty()) f.pop_back(); - } - if (f.size() < 2 || (f.size() % 2) != 0) return respJson(500, errBody("DLL 返回格式异常")); - - std::string out = "{\"ok\":true,\"headers\":{"; - for (size_t i = 0; i + 1 < f.size(); i += 2) { - if (i) out += ","; - out += "\"" + jsonEscape(f[i]) + "\":\"" + jsonEscape(f[i + 1]) + "\""; - } - out += "},\"elapsed_ms\":" + std::to_string(ms); - if (!addedStub.empty()) out += ",\"added\":{\"x-ss-stub\":\"" + addedStub + "\"}"; - out += "}"; - if (verbose) printf("[sign] %s -> %zu 个签名头, %lldms\n", jurl->s.substr(0, 80).c_str(), f.size() / 2, (long long)ms); - return respJson(200, out); -} - -static void handleConn(SOCKET s, const Config &cfg, Signer &signer) { - std::string method, path, headersRaw, body; - if (!readRequest(s, method, path, headersRaw, body)) { closesocket(s); return; } - std::string route = path; - size_t q = route.find('?'); - if (q != std::string::npos) route = route.substr(0, q); - - std::string resp; - if (route == "/healthz") { - std::string j = "{\"ok\":true,\"service\":\"mssdk-sign\",\"version\":\"0.1.0\",\"signer\":\"" + - jsonEscape(signer.dllPath()) + "\",\"dllVersion\":\"" + jsonEscape(signer.version()) + - "\",\"signRva\":\"" + [&] { - char b[32]; - snprintf(b, sizeof b, "0x%llx", (unsigned long long)signer.signRva()); - return std::string(b); - }() + "\",\"auth\":" + (cfg.token.empty() ? "false" : "true") + "}"; - resp = respJson(200, j); - } else if (route == "/config") { - if (!authorized(cfg, headersRaw)) { - resp = respJson(401, errBody("缺少或错误的 token")); - } else { - resp = respJson(200, "{\"ok\":true,\"signRva\":\"" + std::string("see /healthz") + - "\",\"headersOrder\":\"按请求里的顺序签名,响应里的 headers 需原样发出\"}"); - } - } else if (route == "/sign") { - if (method != "POST") resp = respJson(400, errBody("请用 POST /sign")); - else if (!authorized(cfg, headersRaw)) resp = respJson(401, errBody("缺少或错误的 token")); - else resp = handleSign(signer, body, cfg.verbose); - } else if (route == "/") { - resp = respJson(200, "{\"ok\":true,\"usage\":\"POST /sign {url,headers,body?} | GET /healthz\"}"); - } else { - resp = respJson(404, errBody("未知路径")); - } - sendAll(s, resp); - closesocket(s); -} - -static void printUsage() { - printf( - "mssdk-sign —— 汽水音乐 metasec 签名服务\n" - "用法: mssdk-sign [选项]\n" - " --port 监听端口(默认 16889)\n" - " --bind 绑定地址(默认 127.0.0.1;容器内跨机调用用 0.0.0.0)\n" - " --dll metasecml.dll 路径(默认 exe 同目录 metasecml.dll)\n" - " --token 启用鉴权(Bearer / X-Auth-Token)\n" - " --sign-rva 手动指定签名函数偏移(默认按 DLL 版本表)\n" - " --selftest 只做加载+签名自检后退出\n" - " -v 打印每个请求的签名日志\n" - "接口:\n" - " GET /healthz\n" - " POST /sign {\"url\":\"https://...\",\"body\":\"可选\",\"headers\":{\"user-agent\":\"...\"}}\n" - " → {\"ok\":true,\"headers\":{\"X-Helios\":\"...\",\"X-Medusa\":\"...\"}}\n"); -} - -static std::string exeDir() { - char b[MAX_PATH]; - DWORD n = GetModuleFileNameA(nullptr, b, MAX_PATH); - std::string p(b, n); - size_t s = p.find_last_of("\\/"); - return s == std::string::npos ? "." : p.substr(0, s); -} - -int main(int argc, char **argv) { - Config cfg; - for (int i = 1; i < argc; i++) { - std::string a = argv[i]; - auto next = [&]() -> std::string { return (i + 1 < argc) ? argv[++i] : ""; }; - if (a == "--port") cfg.port = atoi(next().c_str()); - else if (a == "--bind") cfg.bind = next(); - else if (a == "--dll") cfg.dll = next(); - else if (a == "--token") cfg.token = next(); - else if (a == "--sign-rva") cfg.signRva = strtoull(next().c_str(), nullptr, 16); - else if (a == "--selftest") cfg.selftestOnly = true; - else if (a == "-v") cfg.verbose = true; - else if (a == "-h" || a == "--help") { printUsage(); return 0; } - } - if (cfg.dll.empty()) cfg.dll = exeDir() + "\\metasecml.dll"; - - // MD5 自检(防止 x-ss-stub 算错) - if (md5HexUpper("abc") != "900150983CD24FB0D6963F7D28E17F72") { - fprintf(stderr, "MD5 自检失败,拒绝启动\n"); - return 2; - } - - Signer signer; - std::string err; - if (!signer.load(cfg.dll, cfg.signRva, err)) { - fprintf(stderr, "签名器加载失败: %s\n", err.c_str()); - return 3; - } - if (!signer.warn().empty()) fprintf(stderr, "警告: %s\n", signer.warn().c_str()); - printf("签名器就绪: %s (DLL 版本 %s, 签名偏移 0x%llx)\n", cfg.dll.c_str(), - signer.version().empty() ? "未知" : signer.version().c_str(), (unsigned long long)signer.signRva()); - if (cfg.selftestOnly) { - printf("自检通过。\n"); - return 0; - } - - WSADATA wsa; - if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { - fprintf(stderr, "WSAStartup 失败\n"); - return 4; - } - SOCKET srv = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); - if (srv == INVALID_SOCKET) { - fprintf(stderr, "socket 失败\n"); - return 4; - } - BOOL yes = TRUE; - setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, (const char *)&yes, sizeof yes); - sockaddr_in addr{}; - addr.sin_family = AF_INET; - addr.sin_port = htons((u_short)cfg.port); - inet_pton(AF_INET, cfg.bind.c_str(), &addr.sin_addr); - if (bind(srv, (sockaddr *)&addr, sizeof addr) != 0 || listen(srv, 32) != 0) { - fprintf(stderr, "bind/listen %s:%d 失败(错误码 %d)\n", cfg.bind.c_str(), cfg.port, WSAGetLastError()); - return 4; - } - printf("监听 http://%s:%d (/healthz, POST /sign)%s\n", cfg.bind.c_str(), cfg.port, - cfg.token.empty() ? "" : " [已启用 token 鉴权]"); - fflush(stdout); - - for (;;) { - SOCKET c = accept(srv, nullptr, nullptr); - if (c == INVALID_SOCKET) continue; - DWORD tv = 15000; - setsockopt(c, SOL_SOCKET, SO_RCVTIMEO, (const char *)&tv, sizeof tv); - setsockopt(c, SOL_SOCKET, SO_SNDTIMEO, (const char *)&tv, sizeof tv); - std::thread(handleConn, c, std::cref(cfg), std::ref(signer)).detach(); - } - return 0; -}