Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
283 changes: 147 additions & 136 deletions lib/tunnel.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
*
Expand Down Expand Up @@ -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<string,string>} [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:固定公网域名)。
*
Expand All @@ -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) => {
Expand All @@ -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();
Expand Down
71 changes: 71 additions & 0 deletions test/tunnel-protocol-fallback.test.js
Original file line number Diff line number Diff line change
@@ -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, '所有候选协议都要试过');
});