优化连接过期验证与戒色信息-1

This commit is contained in:
yml2213
2026-05-14 14:42:30 +08:00
parent c7a9787814
commit 486ba14dc4
18 changed files with 815 additions and 15 deletions
@@ -27,6 +27,16 @@ export function resolveCloudtentaclesConfig(overrides = {}) {
vnBindUrlPath: '/vn/bind_url',
vnBindInfoPath: '/vn/bind_info',
vnBackPath: '/vn/back',
bindUrlTtlSeconds: 300,
bindUrlProbeTimeoutMs: 5000,
bindUrlProbeUserAgent: '',
bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/',
bindUrlProbeChartId: '323794',
bindUrlProbeSubChartId: '323794',
bindUrlProbeIdeToken: 'z90Syo',
bindUrlProbeActivityUrl: 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F',
bindUrlProbeReferer: 'https://gp.qq.com/',
bindUrlProbeExtraCookie: '',
publicKeyPem: '',
clientSource: 'ct-client',
deviceId: '-',
@@ -60,6 +70,16 @@ export function resolveCloudtentaclesConfig(overrides = {}) {
vnBindUrlPath: normalizePath(overrides.vnBindUrlPath || baseConfig.vnBindUrlPath, '/vn/bind_url'),
vnBindInfoPath: normalizePath(overrides.vnBindInfoPath || baseConfig.vnBindInfoPath, '/vn/bind_info'),
vnBackPath: normalizePath(overrides.vnBackPath || baseConfig.vnBackPath, '/vn/back'),
bindUrlTtlSeconds: normalizePositiveInteger(overrides.bindUrlTtlSeconds || baseConfig.bindUrlTtlSeconds, 300),
bindUrlProbeTimeoutMs: normalizePositiveInteger(overrides.bindUrlProbeTimeoutMs || baseConfig.bindUrlProbeTimeoutMs, 5000),
bindUrlProbeUserAgent: String(overrides.bindUrlProbeUserAgent || baseConfig.bindUrlProbeUserAgent || '').trim(),
bindUrlProbeEndpoint: String(overrides.bindUrlProbeEndpoint || baseConfig.bindUrlProbeEndpoint || 'https://comm.ams.game.qq.com/ide/').trim(),
bindUrlProbeChartId: String(overrides.bindUrlProbeChartId || baseConfig.bindUrlProbeChartId || '323794').trim(),
bindUrlProbeSubChartId: String(overrides.bindUrlProbeSubChartId || baseConfig.bindUrlProbeSubChartId || '323794').trim(),
bindUrlProbeIdeToken: String(overrides.bindUrlProbeIdeToken || baseConfig.bindUrlProbeIdeToken || 'z90Syo').trim(),
bindUrlProbeActivityUrl: String(overrides.bindUrlProbeActivityUrl || baseConfig.bindUrlProbeActivityUrl || 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F').trim(),
bindUrlProbeReferer: String(overrides.bindUrlProbeReferer || baseConfig.bindUrlProbeReferer || 'https://gp.qq.com/').trim(),
bindUrlProbeExtraCookie: String(overrides.bindUrlProbeExtraCookie || baseConfig.bindUrlProbeExtraCookie || '').trim(),
publicKeyPem: normalizePem(overrides.publicKeyPem || baseConfig.publicKeyPem),
clientSource: String(overrides.clientSource || baseConfig.clientSource || 'ct-client').trim() || 'ct-client',
deviceId: String(overrides.deviceId ?? baseConfig.deviceId ?? '-').trim() || '-',
@@ -1,9 +1,15 @@
// @ts-check
import http from 'node:http'
import https from 'node:https'
import { createHttpError } from '../../../utils/http.js'
import { cloudtentaclesRequest } from './http-client.js'
import { resolveCloudtentaclesConfig } from './shared.js'
const BIND_URL_PROBE_MAX_BODY_BYTES = 64 * 1024
const AMS_SIGNATURE_EXPIRED_CODE = '99998'
export async function listCloudtentaclesVirtualNumbers(payload = {}) {
const token = requireToken(payload.token, 'cloudtentacles 虚拟号列表缺少 token', 'cloudtentacles_vn_list_missing_token')
const key = requireKey(payload.key, 'cloudtentacles 虚拟号列表缺少 key', 'cloudtentacles_vn_list_missing_key')
@@ -159,6 +165,80 @@ export async function getCloudtentaclesBindUrl(payload = {}) {
}
}
export async function probeCloudtentaclesBindUrl(payload = {}) {
const bindUrl = String(payload.bindUrl || '').trim()
if (!bindUrl) {
return {
valid: false,
status: 0,
finalUrl: '',
reason: 'empty_url',
}
}
const config = resolveCloudtentaclesConfig(payload)
const signatureParams = extractBindUrlSignatureParams(bindUrl)
if (!signatureParams) {
return {
valid: false,
expired: false,
status: 0,
finalUrl: bindUrl,
reason: 'missing_signature_params',
roleInfo: normalizeAmsBindRoleInfo(null),
raw: null,
}
}
const timeoutMs = Number(payload.timeoutMs || config.bindUrlProbeTimeoutMs || 5000)
const userAgent = String(payload.userAgent || config.bindUrlProbeUserAgent || '').trim()
const endpoint = String(payload.endpoint || config.bindUrlProbeEndpoint || 'https://comm.ams.game.qq.com/ide/').trim()
const startedAt = Date.now()
try {
const response = await requestAmsIdeProbe(endpoint, {
timeoutMs,
body: buildAmsIdeProbeBody(signatureParams, config, payload),
headers: buildAmsIdeProbeHeaders(signatureParams, config, {
userAgent,
}),
})
const raw = tryParseJson(response.bodyText)
const expired = isAmsSignatureExpired(raw)
const bindInfo = extractAmsBindInfo(raw)
const roleInfo = normalizeAmsBindRoleInfo(bindInfo)
const ok = response.status >= 200
&& response.status < 300
&& !expired
&& isAmsBusinessOk(raw)
return {
valid: ok,
expired,
status: response.status,
finalUrl: endpoint,
ret: String(raw?.ret ?? raw?.iRet ?? ''),
iRet: String(raw?.iRet ?? raw?.jData?.iRet ?? ''),
message: String(raw?.sMsg || raw?.jData?.sMsg || ''),
reason: expired ? 'signature_expired' : (ok ? 'ok' : 'ams_business_not_ok'),
roleInfo,
raw,
durationMs: Date.now() - startedAt,
}
} catch (error) {
return {
valid: false,
expired: false,
status: 0,
finalUrl: bindUrl,
reason: error instanceof Error ? error.message : String(error || 'probe_failed'),
roleInfo: normalizeAmsBindRoleInfo(null),
raw: null,
durationMs: Date.now() - startedAt,
}
}
}
export async function getCloudtentaclesBindInfo(payload = {}) {
const token = requireToken(payload.token, 'cloudtentacles 获取绑定信息缺少 token', 'cloudtentacles_vn_bind_info_missing_token')
const key = requireKey(payload.key, 'cloudtentacles 获取绑定信息缺少 key', 'cloudtentacles_vn_bind_info_missing_key')
@@ -275,6 +355,242 @@ function parseBindInfo(value) {
return tryParseJson(bindInfoText) || bindInfoText || null
}
async function requestAmsIdeProbe(endpoint, options = {}) {
const url = new URL(endpoint)
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('unsupported_ams_probe_protocol')
}
const timeoutMs = Number(options.timeoutMs || 5000)
const isHttps = url.protocol === 'https:'
const transport = isHttps ? https : http
const body = options.body instanceof URLSearchParams ? options.body.toString() : String(options.body || '')
return new Promise((resolve, reject) => {
const request = transport.request(url, {
method: 'POST',
rejectUnauthorized: false,
headers: {
...normalizeHeaderMap(options.headers),
'content-length': Buffer.byteLength(body),
'accept-encoding': 'identity',
},
}, (response) => {
const chunks = []
let receivedBytes = 0
response.on('data', (chunk) => {
if (receivedBytes >= BIND_URL_PROBE_MAX_BODY_BYTES) {
return
}
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
const remainingBytes = BIND_URL_PROBE_MAX_BODY_BYTES - receivedBytes
chunks.push(buffer.length > remainingBytes ? buffer.subarray(0, remainingBytes) : buffer)
receivedBytes += Math.min(buffer.length, remainingBytes)
})
response.on('end', () => {
clearTimeout(timer)
resolve({
status: Number(response.statusCode || 0),
headers: createHeaderAdapter(response.headers),
bodyText: Buffer.concat(chunks).toString('utf8'),
})
})
})
const timer = setTimeout(() => {
request.destroy(new Error('bind_url_probe_timeout'))
}, timeoutMs)
request.on('error', (error) => {
clearTimeout(timer)
reject(error)
})
request.write(body)
request.end()
})
}
function createHeaderAdapter(headers) {
const normalized = new Map()
for (const [key, value] of Object.entries(headers || {})) {
if (Array.isArray(value)) {
normalized.set(String(key || '').toLowerCase(), value.join(', '))
continue
}
if (typeof value === 'string') {
normalized.set(String(key || '').toLowerCase(), value)
}
}
return {
get(name) {
return normalized.get(String(name || '').toLowerCase()) || null
},
}
}
function buildAmsIdeProbeBody(signatureParams, config, payload = {}) {
const body = new URLSearchParams()
const chartId = String(payload.chartId || config.bindUrlProbeChartId || '323794').trim()
const subChartId = String(payload.subChartId || config.bindUrlProbeSubChartId || chartId).trim()
body.set('iChartId', chartId)
body.set('iSubChartId', subChartId)
body.set('sIdeToken', String(payload.ideToken || config.bindUrlProbeIdeToken || 'z90Syo').trim())
body.set('e_code', '0')
body.set('g_code', '0')
body.set('eas_url', String(payload.easUrl || config.bindUrlProbeActivityUrl || 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F').trim())
body.set('eas_refer', String(payload.easRefer || `http%3A%2F%2Fnoreferrer%2F%3Freqid%3D${Date.now()}%26version%3D27`).trim())
body.set('sMiloTag', String(payload.miloTag || `AMS-gp-${Date.now()}`).trim())
body.set('userId', signatureParams.userId)
body.set('timestamp', signatureParams.timestamp)
body.set('nonce', signatureParams.nonce)
body.set('sign', signatureParams.sign)
return body
}
function buildAmsIdeProbeHeaders(signatureParams, config, options = {}) {
const userAgent = String(options.userAgent || '').trim()
const referer = String(config.bindUrlProbeReferer || 'https://gp.qq.com/').trim()
return {
accept: 'application/json, text/plain, */*',
'content-type': 'application/x-www-form-urlencoded',
'accept-language': 'zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6',
'cache-control': 'no-cache',
origin: 'https://gp.qq.com',
pragma: 'no-cache',
referer,
'sec-ch-ua': '""',
'sec-ch-ua-mobile': '?1',
'sec-ch-ua-platform': '""',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
...(userAgent ? { 'user-agent': userAgent } : {}),
cookie: buildAmsIdeProbeCookie(signatureParams, config),
}
}
function buildAmsIdeProbeCookie(signatureParams, config) {
const tokenParams = `?userId=${signatureParams.userId}&timestamp=${signatureParams.timestamp}&nonce=${signatureParams.nonce}&sign=${signatureParams.sign}`
const cookies = [
`tokenParams=${encodeURIComponent(tokenParams)}`,
'gpqqcomrouteLine=a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc',
String(config.bindUrlProbeExtraCookie || '').trim(),
].filter(Boolean)
return cookies.join('; ')
}
function extractBindUrlSignatureParams(bindUrl) {
try {
const url = new URL(bindUrl)
const params = new URLSearchParams(url.search)
const hashQueryIndex = String(url.hash || '').indexOf('?')
if (hashQueryIndex >= 0) {
const hashParams = new URLSearchParams(String(url.hash || '').slice(hashQueryIndex + 1))
for (const [key, value] of hashParams.entries()) {
if (!params.has(key)) {
params.set(key, value)
}
}
}
const tokenParams = String(params.get('tokenParams') || '').trim()
if (tokenParams) {
const nested = new URLSearchParams(tokenParams.startsWith('?') ? tokenParams.slice(1) : tokenParams)
for (const [key, value] of nested.entries()) {
if (!params.has(key)) {
params.set(key, value)
}
}
}
const userId = String(params.get('userId') || '').trim()
const timestamp = String(params.get('timestamp') || '').trim()
const nonce = String(params.get('nonce') || '').trim()
const sign = String(params.get('sign') || '').trim()
if (!userId || !timestamp || !nonce || !sign) {
return null
}
return {
userId,
timestamp,
nonce,
sign,
}
} catch {
return null
}
}
function isAmsBusinessOk(raw) {
const ret = String(raw?.ret ?? '').trim()
const iRet = String(raw?.iRet ?? raw?.jData?.iRet ?? '').trim()
return ret === '0' || iRet === '0'
}
function isAmsSignatureExpired(raw) {
const codes = [
raw?.ret,
raw?.iRet,
raw?.jData?.ret,
raw?.jData?.iRet,
raw?.jData?.jData?.iRet,
raw?.arrErrNodeInfo?.errorCode,
raw?.jData?.arrErrNodeInfo?.errorCode,
].map((value) => String(value ?? '').trim())
const messages = [
raw?.sMsg,
raw?.jData?.sMsg,
raw?.jData?.jData?.sMsg,
].map((value) => String(value ?? '').trim())
return codes.includes(AMS_SIGNATURE_EXPIRED_CODE) || messages.some((message) => message.includes('签名已过期'))
}
function extractAmsBindInfo(raw) {
const candidates = [
raw?.jData?.sBindInfo,
raw?.jData?.jData?.sBindInfo,
raw?.sBindInfo,
]
return candidates.find((item) => isPlainObject(item)) || null
}
function normalizeAmsBindRoleInfo(bindInfo) {
const source = isPlainObject(bindInfo) ? bindInfo : {}
return {
name: String(source.sRoleName || source.roleName || source.name || '').trim(),
rid: String(source.sRoleId || source.roleId || source.rid || '').trim(),
rawInfo: isPlainObject(bindInfo) ? bindInfo : null,
}
}
function normalizeHeaderMap(headers) {
if (!headers || typeof headers !== 'object') {
return {}
}
return Object.fromEntries(
Object.entries(headers)
.map(([key, value]) => [String(key || '').trim().toLowerCase(), String(value || '').trim()])
.filter(([key, value]) => key && value),
)
}
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}