Browse Source

✨ feat: 适配隧道代理

Pchen0 5 hours ago
parent
commit
d38f24f3e5

+ 89 - 27
lib/Lepao/QgProxyManager.js

@@ -5,12 +5,12 @@ const db = require('../../plugin/DataBase/db')
 const Redis = require('../../plugin/DataBase/Redis')
 const Logger = require('../Logger')
 
-const QG_GET_URL = 'https://share.proxy.qg.net/get'
+const QG_POOL_URL = 'https://share.proxy.qg.net/pool'
 const QG_RESOURCES_URL = 'https://share.proxy.qg.net/resources'
 const REDIS_CURRENT = 'lepao:qg:current'
 const REDIS_LOCK = 'lepao:qg:fetch_lock'
-/** 早于 deadline 提前刷新,尽量减少「边检边过期」(参考 [文档](https://www.qg.net/doc/1850.html)) */
-const DEADLINE_MARGIN_MS = 18_000
+/** 隧道池入口地址缓存时长(服务商后台自动换出口,不依赖本地 deadline 刷新) */
+const REDIS_SERVER_TTL_SEC = 6 * 60 * 60
 /** 仅覆盖单次 /get(含 axios 超时),避免长持锁阻塞其它任务 */
 const LOCK_TTL_SEC = 45
 const LOCK_WAIT_ROUNDS = 40
@@ -30,7 +30,8 @@ function getQgConfig() {
     return {
         extractKey: String(q.extractKey || '').trim(),
         authUser: String(q.authUser || '').trim(),
-        authPassword: String(q.authPassword || '').trim()
+        authPassword: String(q.authPassword || '').trim(),
+        tunnelServer: String(q.tunnelServer || q.server || '').trim()
     }
 }
 
@@ -44,6 +45,11 @@ function hasProxyAuth() {
     return authUser.length > 0 && authPassword.length > 0
 }
 
+function hasTunnelServer() {
+    const { tunnelServer } = getQgConfig()
+    return tunnelServer.length > 0
+}
+
 async function ensureSettingsRow() {
     const now = Date.now()
     await db.query(
@@ -88,6 +94,33 @@ function axiosProxyOptsFromServer(server, useAuth) {
     return opt
 }
 
+function isAreaCodeLike(s) {
+    return /^\d{4,9}$/.test(String(s || '').trim())
+}
+
+function buildTunnelProxyOpts(settings) {
+    const { tunnelServer, authUser, authPassword } = getQgConfig()
+    if (!tunnelServer) return null
+    const base = axiosProxyOptsFromServer(tunnelServer, false)
+    if (!base.proxy) return null
+
+    if (authUser && authPassword) {
+        let password = authPassword
+        const area = String(settings?.area || '').trim()
+        const areaEx = String(settings?.area_ex || '').trim()
+        /**
+         * 参考隧道代理接入:普通模式指定地区通过 user:password:A<area>@server。
+         * 仅当 area 为单个纯数字编码且未配置 area_ex 时尝试附加,避免把旧的多地区逗号表达式拼坏。
+         */
+        if (area && !areaEx && isAreaCodeLike(area)) {
+            password = `${password}:A${area}`
+        }
+        base.proxy.auth = { username: authUser, password }
+    }
+
+    return base
+}
+
 async function recordLog({ event, server = null, deadline = null, detail = null }) {
     try {
         const detailStr =
@@ -114,8 +147,10 @@ async function getCachedParsed() {
 function cacheStillValid(parsed) {
     if (!parsed || !parsed.server) return false
     const ms = parsed.deadlineMs || parseDeadlineMs(parsed.deadline)
-    if (!ms) return false
-    return Date.now() + DEADLINE_MARGIN_MS < ms
+    if (ms) return Date.now() < ms
+    const fetchedAt = Number(parsed.fetchedAt || 0)
+    if (!Number.isFinite(fetchedAt) || fetchedAt <= 0) return true
+    return Date.now() - fetchedAt < REDIS_SERVER_TTL_SEC * 1000
 }
 
 async function acquireFetchLock() {
@@ -161,7 +196,7 @@ function buildGetParams(settings) {
 
 async function extractOnce(settings) {
     const params = buildGetParams(settings)
-    const res = await axios.get(QG_GET_URL, {
+    const res = await axios.get(QG_POOL_URL, {
         params,
         timeout: 20000,
         proxy: false,
@@ -215,34 +250,49 @@ async function fetchResourceAreas() {
  * 是否应在业务层尝试青果(DB 开关 + config 里存在 extractKey)
  */
 async function isOutboundProxyEnabled() {
-    if (!hasExtractCredentials()) return false
+    if (!hasExtractCredentials() && !(hasTunnelServer() && hasProxyAuth())) return false
     const row = await loadSettings()
     return row && Number(row.proxy_enabled) === 1
 }
 
 /**
- * 对外:得到可合并进 axios 的代理段;未启用则 { proxy: false }
+ * 对外:得到可合并进 axios 的代理段;未启用则 { proxy: false }。
+ * 隧道池模式:服务商后台自动切换出口,本地仅缓存入口 server,不做频繁刷新。
  * @param {{ forceRefresh?: boolean }} opt
  */
 async function getOutboundAxiosFragment(opt = {}) {
     const forceRefresh = opt.forceRefresh === true
     warnIfTlsVerifyDisabled()
-    if (!hasExtractCredentials()) return { proxy: false }
+    if (!hasExtractCredentials() && !(hasTunnelServer() && hasProxyAuth())) return { proxy: false }
 
     const settings = await loadSettings()
     if (!settings || Number(settings.proxy_enabled) !== 1) return { proxy: false }
 
-    if (!forceRefresh) {
-        const cached = await getCachedParsed()
-        if (cacheStillValid(cached)) {
-            return axiosProxyOptsFromServer(cached.server, hasProxyAuth())
+    // 隧道代理模式:直接使用隧道入口地址,不需要 /pool 提取。
+    const tunnelFrag = buildTunnelProxyOpts(settings)
+    if (tunnelFrag?.proxy) {
+        const payload = {
+            server: `${tunnelFrag.proxy.host}:${tunnelFrag.proxy.port}`,
+            deadline: '',
+            deadlineMs: null,
+            proxyIp: null,
+            requestId: null,
+            fetchedAt: Date.now(),
+            mode: 'tunnel'
         }
-        if (cached?.server) {
-            const ms = cached.deadlineMs || parseDeadlineMs(cached.deadline)
-            logger.info(
-                `[QgProxy] 缓存代理已失效或临近截止,将重新提取。原节点 server=${cached.server} proxy_ip=${cached.proxyIp ?? '—'} deadline_ms=${ms ?? '—'}`
-            )
+        await Redis.set(REDIS_CURRENT, JSON.stringify(payload), { EX: Math.max(60, REDIS_SERVER_TTL_SEC) })
+        return tunnelFrag
+    }
+
+    const cachedFast = await getCachedParsed()
+    if (cacheStillValid(cachedFast)) {
+        if (forceRefresh) {
+            logger.info(`[QgProxy] 隧道池忽略 forceRefresh,复用入口 server=${cachedFast.server}`)
         }
+        return axiosProxyOptsFromServer(cachedFast.server, hasProxyAuth())
+    }
+    if (cachedFast?.server) {
+        logger.info(`[QgProxy] 缓存入口失效,将重新提取。原server=${cachedFast.server}`)
     }
 
     const maxAttempts = 5
@@ -263,14 +313,14 @@ async function getOutboundAxiosFragment(opt = {}) {
 
         let shouldBackoff = false
         try {
-            if (!forceRefresh) {
+            {
                 const cached = await getCachedParsed()
                 if (cacheStillValid(cached)) {
                     return axiosProxyOptsFromServer(cached.server, hasProxyAuth())
                 }
             }
 
-            logger.info(`[QgProxy] 调用青果 /get 拉取新代理... forceRefresh=${forceRefresh}`)
+            logger.info(`[QgProxy] 调用青果 /pool 提取代理资源... forceRefresh=${forceRefresh}`)
             const { body, item, code } = await extractOnce(settings)
             const server = item.server
             const deadline = item.deadline
@@ -279,9 +329,7 @@ async function getOutboundAxiosFragment(opt = {}) {
             if (!server) throw new Error('青果返回无 server 字段')
 
             const deadlineMs = parseDeadlineMs(deadline)
-            const ttlSec = deadlineMs
-                ? Math.max(15, Math.floor((deadlineMs - Date.now()) / 1000) - 2)
-                : 55
+            const ttlSec = REDIS_SERVER_TTL_SEC
 
             const payload = {
                 server,
@@ -291,14 +339,14 @@ async function getOutboundAxiosFragment(opt = {}) {
                 requestId: requestId || null,
                 fetchedAt: Date.now()
             }
-            await Redis.set(REDIS_CURRENT, JSON.stringify(payload), { EX: Math.min(120, Math.max(15, ttlSec)) })
+            await Redis.set(REDIS_CURRENT, JSON.stringify(payload), { EX: Math.max(60, ttlSec) })
 
             if (attempt > 1) {
                 logger.info(`[QgProxy] 第 ${attempt} 次 /get 成功`)
             }
 
             logger.info(
-                `[QgProxy] 已获取代理节点 server=${server} proxy_ip=${proxyIp ?? '—'} deadline=${deadline || '—'} request_id=${requestId ?? '—'}`
+                `[QgProxy] 已获取隧道入口 server=${server} proxy_ip=${proxyIp ?? '—'} deadline=${deadline || '—'} request_id=${requestId ?? '—'}`
             )
 
             await recordLog({
@@ -314,7 +362,7 @@ async function getOutboundAxiosFragment(opt = {}) {
             shouldBackoff = e.retryable === true && attempt < maxAttempts
             if (shouldBackoff) {
                 const backoff = Math.min(2000, 280 * attempt * attempt)
-                logger.warn(`[QgProxy] /get 将重试 (${attempt}/${maxAttempts}) ${e.message},等待 ${backoff}ms`)
+                logger.warn(`[QgProxy] /pool 将重试 (${attempt}/${maxAttempts}) ${e.message},等待 ${backoff}ms`)
             } else {
                 logger.error(`青果拉取异常: ${e.stack || e}`)
                 throw e
@@ -333,6 +381,19 @@ async function getOutboundAxiosFragment(opt = {}) {
 }
 
 async function invalidateCurrent(reason, detail) {
+    if (reason === 'request_fail' || reason === 'retry_round_post_fail' || reason === 'extra_round_fail') {
+        const detailObj =
+            typeof detail === 'object' && detail !== null ? detail : { message: String(detail || '') }
+        logger.info(
+            `[QgProxy] 隧道池模式忽略入口作废 reason=${reason} detail=${JSON.stringify(detailObj)}`
+        )
+        await recordLog({
+            event: 'invalidate',
+            detail: { reason, ignored_in_tunnel_pool: true, ...detailObj }
+        })
+        return
+    }
+
     let prev = null
     try {
         prev = await getCachedParsed()
@@ -384,6 +445,7 @@ async function getStatusSnapshot() {
         distinct_extract: row ? Number(row.distinct_extract) === 1 : true,
         updated_at: row?.updated_at ?? 0,
         extract_key_configured: hasExtractCredentials(),
+        tunnel_server_configured: hasTunnelServer(),
         proxy_auth_configured: hasProxyAuth(),
         redis_current: cached && cacheStillValid(cached) ? cached : cached,
         last_fetch_log: lastFetch

+ 14 - 53
lib/Lepao/lepaoSchoolHttp.js

@@ -134,7 +134,7 @@ async function logSchoolOutbound(logger, phase, url, axiosMerge, opts = {}) {
 }
 
 /**
- * 对 lepao.ctbu.edu.cn 的 POST:优先青果代理;失败轮换一次;再失败直连并记日志
+ * 对 lepao.ctbu.edu.cn 的 POST:优先隧道代理;失败快速直连并记日志(隧道池出口由服务商后台切换)
  */
 async function postLepaoSchool(url, data, options = {}) {
     const { headers = {}, timeout = 15000, logger = null } = options
@@ -196,71 +196,32 @@ async function postLepaoSchool(url, data, options = {}) {
     } catch (e1) {
         if (!isQgProxyEligibleFailure(e1)) throw e1
         logger?.warn?.(
-            `[lepaoSchoolHttp] 经代理首次请求失败,将作废IP并换新。err=${e1.message || e1} ${JSON.stringify(summarizeAxiosError(e1))}`
+            `[lepaoSchoolHttp] 经代理首次请求失败,将直接回退直连。err=${e1.message || e1} ${JSON.stringify(
+                summarizeAxiosError(e1)
+            )}`
         )
-        await QgProxyManager.invalidateCurrent('request_fail', summarizeAxiosError(e1))
 
         const tls1 = isProxyTlsHandshakeReset(e1)
-        /**
-         * TLS 握手前即 ECONNRESET:换出口 IP 往往仍失败;此时立即再 forceRefresh /get 容易与并发任务
-         * 抢单通道配额,大面积 NO_AVAILABLE_CHANNEL。线上「脚本测主页 GET OK、接口 POST 仍失败」也属同类。
-         * 作废缓存后直接直连,省配额且与用户最终成功路径一致。
-         */
         if (tls1) {
             logger?.warn?.(
-                '[lepaoSchoolHttp] TLS 握手前经代理断开,跳过二次青果提取,直接直连以避免 NO_AVAILABLE_CHANNEL 与无谓重试'
+                '[lepaoSchoolHttp] TLS 握手前经代理断开,隧道池模式直接直连(由服务商后台自动切换出口)'
             )
-            await logSchoolOutbound(logger, '(TLS隧道异常→跳过换新→直连)', url, { proxy: false })
+            await logSchoolOutbound(logger, '(TLS隧道异常→直连)', url, { proxy: false })
             await QgProxyManager.recordFallbackDirect({
-                reason: 'tls_prefinish_reset_direct_skip_qg_refresh',
+                reason: 'tls_prefinish_reset_direct',
                 path: briefUrlPath(url),
                 ...summarizeAxiosError(e1)
             })
             return doPost({ proxy: false })
         }
 
-        await sleep(800)
-
-        let frag2
-        try {
-            frag2 = await getOutboundWithBackoff({ forceRefresh: true }, 2)
-        } catch (eFetch) {
-            logger?.warn?.(`[lepaoSchoolHttp] 换新 IP 提取失败:${eFetch.message || eFetch}`)
-            await logSchoolOutbound(logger, '(换新提取失败→直连)', url, { proxy: false })
-            await QgProxyManager.recordFallbackDirect({
-                reason: 'qg_extract_after_proxy_fail',
-                ...summarizeAxiosError(eFetch)
-            })
-            return doPost({ proxy: false })
-        }
-
-        if (frag2.proxy === false) {
-            await logSchoolOutbound(logger, '(换新无节点→直连)', url, { proxy: false })
-            await QgProxyManager.recordFallbackDirect({ reason: 'no_proxy_after_invalidate', tls_tunnel: false })
-            return doPost({ proxy: false })
-        }
-
-        await logSchoolOutbound(logger, '换新IP重试', url, frag2)
-        try {
-            return await doPost(frag2)
-        } catch (e2) {
-            if (!isQgProxyEligibleFailure(e2)) throw e2
-            const tls2 = isProxyTlsHandshakeReset(e2)
-            logger?.warn?.(
-                `[lepaoSchoolHttp] 换新IP后仍失败,将回退直连。err=${e2.message || e2} ${JSON.stringify(
-                    summarizeAxiosError(e2)
-                )}`
-            )
-            await QgProxyManager.invalidateCurrent('retry_round_post_fail', summarizeAxiosError(e2))
-            await logSchoolOutbound(logger, tls2 ? '(二次仍TLS隧道失败→直连)' : '(二次代理失败→直连)', url, {
-                proxy: false
-            })
-            await QgProxyManager.recordFallbackDirect({
-                reason: tls2 ? 'tls_tunnel_broken_twice' : 'proxy_post_retry_failed',
-                ...summarizeAxiosError(e2)
-            })
-            return doPost({ proxy: false })
-        }
+        await logSchoolOutbound(logger, '(代理失败→直连)', url, { proxy: false })
+        await QgProxyManager.recordFallbackDirect({
+            reason: 'proxy_post_failed_then_direct',
+            path: briefUrlPath(url),
+            ...summarizeAxiosError(e1)
+        })
+        return doPost({ proxy: false })
     }
 }
 

+ 3 - 3
scripts/lepao-proxy-tls-test.js

@@ -28,7 +28,7 @@ const fs = require('fs')
 const axios = require('axios')
 const HttpsProxyAgent = require('https-proxy-agent')
 
-const QG_GET_URL = 'https://share.proxy.qg.net/get'
+const QG_POOL_URL = 'https://share.proxy.qg.net/pool'
 
 function loadConfig() {
     const p = path.join(__dirname, '..', 'config.json')
@@ -99,7 +99,7 @@ async function fetchOneProxyFromQg(config) {
     if (process.env.QG_DISTINCT === '0') params.distinct = false
     else params.distinct = true
 
-    const res = await axios.get(QG_GET_URL, {
+    const res = await axios.get(QG_POOL_URL, {
         params,
         timeout: 20000,
         proxy: false,
@@ -108,7 +108,7 @@ async function fetchOneProxyFromQg(config) {
     const body = res.data
     const code = body && body.code
     if (code !== 'SUCCESS' || !Array.isArray(body.data) || !body.data[0]) {
-        throw new Error(`青果 /get 失败: ${code || JSON.stringify(body).slice(0, 240)}`)
+        throw new Error(`青果 /pool 失败: ${code || JSON.stringify(body).slice(0, 240)}`)
     }
     const item = body.data[0]
     return {

+ 3 - 3
scripts/proxy-u-xxoo365-test.js

@@ -22,7 +22,7 @@ const path = require('path')
 const axios = require('axios')
 const HttpsProxyAgent = require('https-proxy-agent')
 
-const QG_GET_URL = 'https://share.proxy.qg.net/get'
+const QG_POOL_URL = 'https://share.proxy.qg.net/pool'
 const DEFAULT_BROWSER_UA =
     'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'
 
@@ -101,7 +101,7 @@ async function fetchProxyFromQg(cfg) {
     }
     params.distinct = String(process.env.QG_DISTINCT || '1') !== '0'
 
-    const res = await axios.get(QG_GET_URL, {
+    const res = await axios.get(QG_POOL_URL, {
         params,
         timeout: 20000,
         proxy: false,
@@ -109,7 +109,7 @@ async function fetchProxyFromQg(cfg) {
     })
     const body = res.data || {}
     if (body.code !== 'SUCCESS' || !Array.isArray(body.data) || !body.data[0]?.server) {
-        throw new Error(`青果 /get 失败: ${body.code || JSON.stringify(body).slice(0, 180)}`)
+        throw new Error(`青果 /pool 失败: ${body.code || JSON.stringify(body).slice(0, 180)}`)
     }
     return {
         server: body.data[0].server,