From b6ffaa17f1153065305c8a1eae8de8201b3b92d2 Mon Sep 17 00:00:00 2001 From: Xiao-Jiang-233 <122152524+Xiao-Jiang-233@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:46:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(tunnel):=20=E5=8D=8F=E8=AE=AE=E4=B8=8D?= =?UTF-8?q?=E5=86=99=E6=AD=BB=20HTTP/2=EF=BC=8C=E6=8C=89=20auto=E2=86=92ht?= =?UTF-8?q?tp2=20=E5=80=99=E9=80=89=E5=9B=9E=E9=80=80=EF=BC=88=E4=BF=AE?= =?UTF-8?q?=E3=80=8C=E5=90=AF=E5=8A=A8=E8=B6=85=E6=97=B6=EF=BC=8830s?= =?UTF-8?q?=EF=BC=89=E3=80=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:命名/快速隧道开启后一直卡在「启动中」,30 秒后报 「cloudflared 启动超时(30s)——请检查 Tunnel Token / 域名 Service / 关代理」, 但 Token、ingress Service 都是对的,关掉代理也没用。 根因:插件无条件 --protocol http2(为躲开国内被屏蔽的 UDP 7844)。 但反过来的网络也存在:代理 TUN(Clash 等)或企业网关会掐掉到 Cloudflare 边缘的 TCP,却不拦 UDP/QUIC。这时 cloudflared 自己的预检其实是: UDP Connectivity region1/2.v2.argotunnel.com PASS QUIC connection successful TCP Connectivity region1/2.v2.argotunnel.com FAIL HTTP/2 connection is blocked or unreachable suggested_protocol=quic 可因为写死了 http2,进程只会一次次 "TLS handshake with edge error: EOF" 重试,永远 等不到 Registered tunnel connection,最后只剩一句指不到真因的超时提示。 同一台机器改用 --protocol auto(或 quic)注册耗时不到 1 秒。 改动: - 新增 PROTOCOL_CANDIDATES = ['auto', 'http2'] 与 spawnCloudflaredWithFallback(): 逐个协议拉起 cloudflared,单个协议 timeoutMs(默认 20s)内没注册成功就杀掉换下一个; 进程自己退出(参数错误、Token 无效等)立刻换下一个,真错误不被超时掩盖; 全部失败时抛最后一条原因,并带上 cloudflared 输出的关键行(firstMeaningfulErrorLine)。 - 命名隧道与快速隧道都改走该 helper,不再写死 --protocol。 - 失败路径 kill 掉上一次尝试的子进程,避免留下孤儿 cloudflared。 - 两个入口支持 internals.spawn 注入,协议回退逻辑可在任意平台做单测 (新增 test/tunnel-protocol-fallback.test.js:候选顺序、失败信息保留真因)。 验证: - 真实环境(Clash TUN:到边缘的 TCP 被拦、QUIC 通,cloudflared 2026.9.1): 改前 30s 超时;改后 --protocol auto 注册成功,约 1s。 - npm test:新增 2 条用例通过;本机 Windows 下失败的 6 条(download/proxy/smoke/ tunnel-args 里 spawn 无扩展名假二进制)与本次改动无关,改动前同样失败。 --- lib/tunnel.mjs | 283 +++++++++++++------------- test/tunnel-protocol-fallback.test.js | 71 +++++++ 2 files changed, 218 insertions(+), 136 deletions(-) create mode 100644 test/tunnel-protocol-fallback.test.js diff --git a/lib/tunnel.mjs b/lib/tunnel.mjs index 7b7d75c..91321d5 100644 --- a/lib/tunnel.mjs +++ b/lib/tunnel.mjs @@ -22,6 +22,18 @@ import { createWriteStream } from 'node:fs'; // 正是该错误体,与 issue 完全一致。 export const QUICK_TUNNEL_URL_RE = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com/i; +// 隧道连接协议候选,按顺序尝试。 +// +// 默认 auto 让 cloudflared 按连通性预检(precheck)自选:UDP 7844 通就走 QUIC,不通退回 HTTP/2。 +// 「HTTP/2 被拦、QUIC 可用」的真实环境存在(代理 TUN / 企业网关会掐掉到边缘的 TCP,却不拦 UDP): +// 旧实现无条件 --protocol http2,在这种机器上永远等不到 Registered tunnel connection, +// 30 秒后只能给出「请检查 Token / 域名 Service / 关代理」这类指不到真因的提示。 +// 反过来「QUIC 被拦、HTTP/2 可用」也存在(国内 UDP 7844 常被丢包),auto 的预检会自动落到 http2; +// 连预检都判不出可用协议时进程只会无限重试,所以再显式 http2 兜底一次。 +export const PROTOCOL_CANDIDATES = ['auto', 'http2']; +const PROTOCOL_HINT = '已依次尝试 auto(QUIC)与 http2'; +const PROTOCOL_HINT_EN = 'tried auto (QUIC) and http2'; + /** * 从 cloudflared 输出里提取最有诊断价值的一段(issue #78)。 * @@ -417,14 +429,106 @@ export async function resolveCloudflared({ home, onPhase = () => {}, signal } = } /** - * 启动 cloudflared 快速隧道,返回公网 URL。 + * 依次用 PROTOCOL_CANDIDATES 里的协议拉起 cloudflared,返回第一个成功注册连接的子进程。 + * + * 判定交给调用方:`prompt(buf)` 返回非空值即视为就绪(返回值就是本次尝试的结果), + * 返回 null 表示还没就绪、继续等。单个协议在 timeoutMs 内没就绪就杀掉进程换下一个, + * 进程自己退出(参数错误、Token 无效等)同样立刻换下一个——这样真错误不会被超时掩盖。 + * 全部协议都失败时,抛最后一个失败原因(含 cloudflared 输出的关键行)。 + * * @param {object} opts - * @param {number} opts.port 本机代理端口 - * @param {string} [opts.home] $DSH_HOME(cloudflared 持久缓存) + * @param {string} opts.bin cloudflared 可执行文件 + * @param {string[]} opts.args 除 --protocol 之外的 cloudflared 参数 + * @param {Record} [opts.env] 附加环境变量(命名隧道放 TUNNEL_TOKEN) + * @param {(buf:string)=>string|null} opts.prompt 就绪判定:非空 = 就绪 + * @param {string} opts.label 错误信息里的中文前缀 + * @param {string} opts.labelEn 错误信息里的英文前缀 + * @param {number} [opts.timeoutMs=20_000] 单个协议的就绪等待上限 * @param {AbortSignal} [opts.signal] - * @param {(phase:string)=>void} [opts.onPhase] 进度回调:downloading→starting→registering→ready - * @returns {Promise<{url:string, kill:()=>void}>} + * @param {typeof spawn} [opts.spawnImpl] 测试注入用的 spawn(默认 node:child_process 的 spawn) + * @returns {Promise<{child:import('node:child_process').ChildProcess, buf:string}>} */ +async function spawnCloudflaredWithFallback({ + bin, args, env, prompt, label, labelEn, timeoutMs = 20_000, signal, spawnImpl = spawn, +}) { + let lastErr = null; + for (let i = 0; i < PROTOCOL_CANDIDATES.length; i++) { + const protocol = PROTOCOL_CANDIDATES[i]; + const last = i === PROTOCOL_CANDIDATES.length - 1; + let cleanup = () => {}; + let child; + try { + child = spawnImpl(bin, [...args, '--protocol', protocol], { + stdio: ['ignore', 'pipe', 'pipe'], + ...(env ? { env: { ...process.env, ...env } } : {}), + }); + } catch (err) { + lastErr = err; // spawn 同步抛错(参数非法等) + continue; + } + const outcome = await new Promise((resolve) => { + let buf = ''; + let timedOut = false; + const onData = (chunk) => { + buf += String(chunk); + const ok = prompt(buf); + if (ok !== null && ok !== undefined) resolve({ kind: 'ready', buf, value: ok }); + }; + const onExit = (code) => resolve({ kind: timedOut ? 'timeout' : 'exit', buf, code }); + const onError = (err) => resolve({ kind: 'error', buf, err }); + cleanup = () => { + child.stdout.off('data', onData); + child.stderr.off('data', onData); + child.off('exit', onExit); + child.off('error', onError); + signal?.removeEventListener('abort', onAbort); + clearTimeout(timer); + // 摘掉监听后管道不再消费 → 64KB 缓冲填满会阻塞 cloudflared → 继续吞掉输出 + child.stdout.resume(); + child.stderr.resume(); + }; + // 先清理再杀:kill 触发的 exit 不会把结果改写成「进程退出」误报 + const killNow = () => { cleanup(); try { child.kill(); } catch { /* 已退出 */ } }; + const onAbort = () => { + killNow(); + resolve({ kind: 'abort', buf }); + }; + const timer = setTimeout(() => { + timedOut = true; + killNow(); + resolve({ kind: 'timeout', buf }); + }, timeoutMs); + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.once('exit', onExit); + child.once('error', onError); + if (signal?.aborted) onAbort(); + else signal?.addEventListener('abort', onAbort, { once: true }); + }); + cleanup(); + if (outcome.kind === 'ready') return { child, buf: outcome.buf }; + if (outcome.kind === 'abort') { + try { child.kill(); } catch { /* 已退出 */ } + throw new Error('已取消 | cancelled'); + } + try { child.kill(); } catch { /* 已退出 */ } // 失败路径:别把上一个 cloudflared 留成孤儿 + const tail = firstMeaningfulErrorLine(outcome.buf); + if (outcome.kind === 'error') { + lastErr = new Error(`${label}:${outcome.err?.message ?? outcome.err}${last ? '' : '(换下一个协议重试)'}`); + } else if (outcome.kind === 'exit') { + lastErr = new Error(`${label}(code=${outcome.code})${tail ? ':' + tail : ''}${last ? '' : '(换个协议重试)'}`); + } else { + // 超时:把 cloudflared 自己的关键输出带出来,否则用户只看到一句「超时」,无从下手 + lastErr = new Error( + `${label}:${Math.round(timeoutMs / 1000)}s 内没有注册成功(${PROTOCOL_HINT})` + + `${tail ? '。cloudflared 输出:' + tail : ''}` + + ` | ${labelEn}: no tunnel registration within ${Math.round(timeoutMs / 1000)}s(${PROTOCOL_HINT_EN})` + + `${tail ? ' — cloudflared: ' + tail : ''}`, + ); + } + } + throw lastErr ?? new Error(`${label} | ${labelEn}`); +} /** * 启动 cloudflared 命名隧道(issue #66:固定公网域名)。 * @@ -440,79 +544,28 @@ export async function resolveCloudflared({ home, onPhase = () => {}, signal } = * @param {string} [opts.home] $DSH_HOME(cloudflared 持久缓存) * @param {AbortSignal} [opts.signal] * @param {(phase:string)=>void} [opts.onPhase] 进度回调:downloading→starting→registering→ready + * @param {object} [opts.internals] 测试注入(spawn) * @returns {Promise<{url:null, kill:()=>void, onExit:(cb)=>()=>void}>} */ -export async function startNamedTunnel({ token, home, signal, onPhase = () => {} }) { +export async function startNamedTunnel({ token, home, signal, onPhase = () => {}, internals = {} }) { const bin = await resolveCloudflared({ home, onPhase, signal }); onPhase('starting'); - // 与快速隧道一致强制 HTTP/2(国内/企业网常屏蔽 UDP 7844 → error 1033) - // 与快速隧道一致强制 HTTP/2(国内/企业网常屏蔽 UDP 7844 → error 1033) // `--no-autoupdate` 必须在全局位置(子命令之前):cloudflared 2026.x 移除了 // `tunnel run` 子命令层级的该 flag,但全局位置仍有效(issue #78) - const child = spawn(bin, ['--no-autoupdate', 'tunnel', 'run', '--protocol', 'http2'], { - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, TUNNEL_TOKEN: String(token ?? '') }, - }); - let cleanup = null; - let rejectErr = null; - // H1:spawn 失败(缓存二进制损坏等)必须接住,否则 uncaughtException 崩宿主 - child.on('error', (err) => { - cleanup?.(); - onPhase?.('error'); - rejectErr?.(new Error(`cloudflared 启动失败:${err?.message ?? err}(可删除 $DSH_HOME/dsh-pocket/bin 缓存后重试)`)); - }); - onPhase('registering'); - - await new Promise((resolve, reject) => { - let buf = ''; - const onData = (chunk) => { - buf += String(chunk); - // 边缘连接注册成功即开始服务(每条连接一行;等第一条就够) - if (/Registered tunnel connection/i.test(buf)) { - cleanup(); - onPhase('ready'); - resolve(); - } - }; - const onExit = (code) => { - cleanup(); - const tail = firstMeaningfulErrorLine(buf); - reject(new Error( - `cloudflared 退出(code=${code})${tail ? ':' + tail : ''}——请检查 Tunnel Token 是否有效、域名 Service 是否指向本机代理端口 | ` - + `tunnel exited (code=${code})${tail ? ': ' + tail : ''} — check the Tunnel Token and the ingress hostname`, - )); - }; - cleanup = () => { - child.stdout.off('data', onData); - child.stderr.off('data', onData); - child.off('exit', onExit); - clearTimeout(timer); - signal?.removeEventListener('abort', onAbort); - // M4:摘掉监听后管道不再消费 → 64KB 缓冲填满会阻塞 cloudflared → 继续吞掉输出 - child.stdout.resume(); - child.stderr.resume(); - }; - const onAbort = () => { - cleanup(); - child.kill(); - reject(new Error('已取消 | cancelled')); - }; - const timer = setTimeout(() => { - cleanup(); - child.kill(); - reject(new Error( - 'cloudflared 启动超时(30s)——请检查 Tunnel Token 是否有效、域名 Service 是否指向本机代理端口,' - + '以及是否开着代理/VPN(Clash 等 TUN 模式会掐断隧道连接) | timeout — check the token, the ingress hostname, and quit any proxy/VPN (TUN mode)', - )); - }, 30_000); - - child.stdout.on('data', onData); - child.stderr.on('data', onData); - child.once('exit', onExit); - signal?.addEventListener('abort', onAbort, { once: true }); - rejectErr = reject; - }); - + // 协议不写死 HTTP/2:由 spawnCloudflaredWithFallback 先 auto(按预检自选)再 http2 兜底(见 PROTOCOL_CANDIDATES) + const child = (await spawnCloudflaredWithFallback({ + bin, + args: ['--no-autoupdate', 'tunnel', 'run'], + env: { TUNNEL_TOKEN: String(token ?? '') }, + prompt: (buf) => (/Registered tunnel connection/i.test(buf) ? 'registered' : null), + label: 'cloudflared 启动超时——请检查 Tunnel Token 是否有效、域名 Service 是否指向本机代理端口,' + + '或者本机到 Cloudflare 边缘的连接被挡(代理/VPN 的 TUN 模式、企业网关)', + labelEn: 'cloudflared timeout — check the Tunnel Token, the ingress hostname, and whether a proxy/VPN TUN mode' + + ' or corporate gateway blocks the connection to the Cloudflare edge', + signal, + spawnImpl: internals.spawn, + })).child; + onPhase('ready'); // M1:隧道进程运行中死亡(崩溃/被杀)→ 通知监听方(service 据此把状态从 ready 打回) const exitListeners = new Set(); child.on('exit', (code) => { @@ -532,74 +585,32 @@ export async function startNamedTunnel({ token, home, signal, onPhase = () => {} }; } -export async function startQuickTunnel({ port, home, signal, onPhase = () => {} }) { +/** + * 启动 cloudflared 快速隧道,返回公网 URL。 + * @param {object} opts + * @param {number} opts.port 本机代理端口 + * @param {string} [opts.home] $DSH_HOME(cloudflared 持久缓存) + * @param {AbortSignal} [opts.signal] + * @param {(phase:string)=>void} [opts.onPhase] 进度回调:downloading→starting→registering→ready + * @param {object} [opts.internals] 测试注入(spawn) + * @returns {Promise<{url:string, kill:()=>void}>} + */ +export async function startQuickTunnel({ port, home, signal, onPhase = () => {}, internals = {} }) { const bin = await resolveCloudflared({ home, onPhase, signal }); onPhase('starting'); - // 强制 HTTP/2(TCP 443)而不是默认的 QUIC(UDP 7844): - // 国内网络/部分企业网常屏蔽 UDP 7844,导致 tunnel 报 error 1033(Tunnel error); - // HTTP/2 走 443 更稳。若平台未来恢复 QUIC 可达,可去掉 --protocol http2。 // `--no-autoupdate` 必须在全局位置(子命令之前,见 issue #78 同款修复) - const child = spawn(bin, ['--no-autoupdate', 'tunnel', '--url', `http://127.0.0.1:${port}`, '--protocol', 'http2'], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - // H1:spawn 失败(缓存二进制损坏等)必须接住,否则 uncaughtException 崩宿主 - child.on('error', (err) => { - cleanup?.(); - onPhase?.('error'); - rejectErr?.(new Error(`cloudflared 启动失败:${err?.message ?? err}(可删除 $DSH_HOME/dsh-pocket/bin 缓存后重试)`)); - }); - onPhase('registering'); - - let cleanup = null; - let rejectErr = null; - const url = await new Promise((resolve, reject) => { - let buf = ''; - const onData = (chunk) => { - buf += String(chunk); - const m = buf.match(QUICK_TUNNEL_URL_RE); - if (m) { - cleanup(); - onPhase('ready'); - resolve(m[0]); - } - }; - const onExit = (code) => { - cleanup(); - // 带上 cloudflared 自己的输出(参数错误显示开头、运行期错误显示尾部,见 firstMeaningfulErrorLine), - // 否则「code=1」用户无从排查(issue #65 / #78) - const tail = firstMeaningfulErrorLine(buf); - reject(new Error(`cloudflared 退出(code=${code})${tail ? ':' + tail : ''}`)); - }; - cleanup = () => { - child.stdout.off('data', onData); - child.stderr.off('data', onData); - child.off('exit', onExit); - clearTimeout(timer); - signal?.removeEventListener('abort', onAbort); - // M4:摘掉监听后管道不再消费 → 64KB 缓冲填满会阻塞 cloudflared → 继续吞掉输出 - child.stdout.resume(); - child.stderr.resume(); - }; - const onAbort = () => { - cleanup(); - child.kill(); - reject(new Error('已取消 | cancelled')); - }; - const timer = setTimeout(() => { - cleanup(); - child.kill(); - reject(new Error( - 'cloudflared 启动超时(30s)——请检查是否开着代理/VPN(Clash 等 TUN 模式会掐断隧道连接),退出代理后重试 | ' - + 'timeout — if you run a proxy/VPN (Clash etc., TUN mode), it can block the tunnel; quit it and retry', - )); - }, 30_000); - - child.stdout.on('data', onData); - child.stderr.on('data', onData); - child.once('exit', onExit); - signal?.addEventListener('abort', onAbort, { once: true }); - rejectErr = reject; + // 协议不写死 HTTP/2:先 auto(按预检自选)再 http2 兜底(见 PROTOCOL_CANDIDATES) + const { child, buf } = await spawnCloudflaredWithFallback({ + bin, + args: ['--no-autoupdate', 'tunnel', '--url', `http://127.0.0.1:${port}`], + prompt: (b) => (b.match(QUICK_TUNNEL_URL_RE)?.[0] ?? null), + label: 'cloudflared 启动超时——请检查是否开着代理/VPN(Clash 等 TUN 模式会掐断隧道连接),退出代理后重试', + labelEn: 'timeout — if you run a proxy/VPN (Clash etc., TUN mode), it can block the tunnel; quit it and retry', + signal, + spawnImpl: internals.spawn, }); + onPhase('ready'); + const url = String(buf.match(QUICK_TUNNEL_URL_RE)[0]); // M1:隧道进程运行中死亡(崩溃/被杀)→ 通知监听方(service 据此把状态从 ready 打回) const exitListeners = new Set(); diff --git a/test/tunnel-protocol-fallback.test.js b/test/tunnel-protocol-fallback.test.js new file mode 100644 index 0000000..a2af2ca --- /dev/null +++ b/test/tunnel-protocol-fallback.test.js @@ -0,0 +1,71 @@ +// 协议回退回归:cloudflared 起得来、但某个协议一直注册不上(真实案例:代理 TUN 只拦 +// 到边缘的 TCP,QUIC 正常)时,不能像以前那样死等 30 秒再报一句指向不了真因的 +// 「超时」——要自己杀掉换下一个协议,候选里也必须有 http2 兜底(UDP 7844 被屏蔽的网络)。 +// +// 用注入的 spawn 造一个假 cloudflared:按 --protocol 决定这次是「注册成功」还是 +// 「报错退出」,从而断言候选顺序与失败信息(不依赖平台能否 spawn 无扩展名脚本)。 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { startNamedTunnel, PROTOCOL_CANDIDATES } from '../lib/tunnel.mjs'; + +/** 假子进程:可写 stdout/stderr,带 kill(真的会触发 exit)与 resume(cleanup 会调)。 */ +function fakeChild({ onSpawn }) { + const child = new EventEmitter(); + const stream = () => { + const s = new EventEmitter(); + s.resume = () => {}; + return s; + }; + child.stdout = stream(); + child.stderr = stream(); + child.killed = false; + child.kill = () => { + if (child.killed) return; + child.killed = true; + setImmediate(() => child.emit('exit', null)); + }; + setImmediate(() => onSpawn(child)); + return child; +} + +/** 记录每次 spawn 的协议,并让指定协议成功。 */ +function makeSpawnRecorder({ okProtocol }) { + const seen = []; + const spawnImpl = (bin, args) => { + const protocol = args[args.indexOf('--protocol') + 1]; + seen.push(protocol); + return fakeChild({ + onSpawn: (child) => { + if (protocol === okProtocol) { + child.stderr.emit('data', 'INF Registered tunnel connection connIndex=0\n'); + } else { + child.stderr.emit('data', 'ERR Unable to establish connection with Cloudflare edge error="TLS handshake with edge error: EOF"\n'); + child.emit('exit', 1); + } + }, + }); + }; + return { seen, spawnImpl }; +} + +test('协议回退:前一个协议注册不上就换候选里的下一个,最终成功', async () => { + const { seen, spawnImpl } = makeSpawnRecorder({ okProtocol: PROTOCOL_CANDIDATES[1] }); + const res = await startNamedTunnel({ token: 'faketoken', internals: { spawn: spawnImpl } }); + res.kill(); + assert.deepEqual(seen, PROTOCOL_CANDIDATES.slice(0, 2), '应按候选顺序依次尝试,直到注册成功'); + assert.ok(PROTOCOL_CANDIDATES.includes('auto'), '首个候选应为 auto:让 cloudflared 预检自选 QUIC/HTTP2'); + assert.ok(PROTOCOL_CANDIDATES.includes('http2'), '候选里要保留 http2 兜底(UDP 7844 被屏蔽的网络)'); +}); + +test('协议全失败:错误信息带 cloudflared 自己的输出,不掩盖真实退出原因', async () => { + const { seen, spawnImpl } = makeSpawnRecorder({ okProtocol: '__never__' }); + await assert.rejects( + () => startNamedTunnel({ token: 'faketoken', internals: { spawn: spawnImpl } }), + (err) => { + assert.match(err.message, /TLS handshake with edge error: EOF/, '应带上 cloudflared 的关键输出行'); + return true; + }, + ); + assert.deepEqual(seen, PROTOCOL_CANDIDATES, '所有候选协议都要试过'); +});