- tools/mssdk-sign/:C++ 实现的签名服务,附 mssdk 内部结构记录、MinGW 与 MSVC 两套构建脚本、调用示例 client.mjs 与自检入口 - signer/server.mjs:Node 实现的等价签名服务,面向「主服务不在 Windows 上」 的场景——bdms.node 是 Windows PE 原生模块,只能由 Windows 版 Node 加载, 故把「加载官方模块」关进独立进程,主服务经 HTTP 取 X-Helios / X-Medusa 两者都不被 src/sdk 引用:运行期走 sdk/qs/mssdk.ts 直接加载官方 bdms.node, 它们是组件升级、签名失效时重新定位入口并验证的手段,放在仓库里才能跟着 代码一起维护。 专有二进制(mssdk-sign.exe、metasecml.dll)由 tools/mssdk-sign/.gitignore 排除,不随仓库分发。
248 lines
9.9 KiB
JavaScript
248 lines
9.9 KiB
JavaScript
#!/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>)。未设 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()
|
||
})
|