@wenbin_wb/dsh-bridge 2.10.4 → 2.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,361 +1,513 @@
1
- import { spawn, execSync } from 'node:child_process';
2
- import { createWriteStream, createReadStream, existsSync, mkdirSync, readFileSync } from 'node:fs';
3
- import { chmod, stat, unlink, rename } from 'node:fs/promises';
4
- import { homedir, platform, arch } from 'node:os';
5
- import { join } from 'node:path';
6
- import { pipeline } from 'node:stream/promises';
7
- import { createHash } from 'node:crypto';
8
- import { get as httpsGet } from 'node:https';
9
-
10
- const CLOUDFLARED_VERSION = '2024.10.0';
11
- const DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 分钟
12
- const MIN_BINARY_SIZE = 5 * 1024 * 1024; // 最小 5MB,防止下到 HTML 错误页
13
-
14
- // 上游 release 不提供任何官方校验和文件(已核实 2024.10.0 资产清单),
15
- // 因此无法做下载校验和比对;退而求其次:记录产物 SHA-256 指纹供事后审计比对。
16
- async function sha256File(filePath) {
17
- return new Promise((resolve, reject) => {
18
- const hash = createHash('sha256');
19
- createReadStream(filePath)
20
- .on('data', (c) => hash.update(c))
21
- .on('end', () => resolve(hash.digest('hex')))
22
- .on('error', reject);
23
- });
24
- }
25
-
26
- function getCloudflaredInfo() {
27
- const os = platform();
28
- const cpuArch = arch();
29
-
30
- const platformMap = {
31
- 'win32-x64': { file: 'cloudflared-windows-amd64.exe', name: 'cloudflared.exe' },
32
- 'win32-arm64': { file: 'cloudflared-windows-arm64.exe', name: 'cloudflared.exe' },
33
- 'darwin-x64': { file: 'cloudflared-darwin-amd64.tgz', name: 'cloudflared' },
34
- 'darwin-arm64':{ file: 'cloudflared-darwin-arm64.tgz', name: 'cloudflared' },
35
- 'linux-x64': { file: 'cloudflared-linux-amd64', name: 'cloudflared' },
36
- 'linux-arm64': { file: 'cloudflared-linux-arm64', name: 'cloudflared' },
37
- };
38
-
39
- const key = `${os}-${cpuArch}`;
40
- const info = platformMap[key];
41
- if (!info) throw new Error(`不支持的平台: ${os}-${cpuArch}`);
42
-
43
- const url = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${info.file}`;
44
- return { url, name: info.name };
45
- }
46
-
47
- async function downloadFile(url, dest, onProgress) {
48
- return new Promise((resolve, reject) => {
49
- const timer = setTimeout(() => reject(new Error('下载超时(5分钟)')), DOWNLOAD_TIMEOUT);
50
-
51
- function doGet(targetUrl, redirects = 0) {
52
- if (redirects > 5) {
53
- clearTimeout(timer);
54
- return reject(new Error('重定向次数过多'));
55
- }
56
- httpsGet(targetUrl, (res) => {
57
- if (res.statusCode === 301 || res.statusCode === 302) {
58
- res.resume();
59
- return doGet(res.headers.location, redirects + 1);
60
- }
61
- if (res.statusCode !== 200) {
62
- res.resume();
63
- clearTimeout(timer);
64
- return reject(new Error(`下载失败: HTTP ${res.statusCode}`));
65
- }
66
-
67
- const total = parseInt(res.headers['content-length'] ?? '0', 10);
68
- let downloaded = 0;
69
- res.on('data', (chunk) => {
70
- downloaded += chunk.length;
71
- if (onProgress && total > 0) {
72
- onProgress(Math.round(downloaded / total * 100), downloaded, total);
73
- }
74
- });
75
-
76
- const fileStream = createWriteStream(dest);
77
- pipeline(res, fileStream)
78
- .then(() => { clearTimeout(timer); resolve(); })
79
- .catch((err) => { clearTimeout(timer); reject(err); });
80
- }).on('error', (err) => { clearTimeout(timer); reject(err); });
81
- }
82
-
83
- doGet(url);
84
- });
85
- }
86
-
87
- function findSystemCloudflared() {
88
- const isWin = platform() === 'win32';
89
- const candidates = [];
90
- if (isWin) {
91
- candidates.push('cloudflared.exe', 'cloudflared', 'C:\\Program Files (x86)\\cloudflared\\cloudflared.exe', 'C:\\Program Files\\cloudflared\\cloudflared.exe');
92
- } else {
93
- candidates.push('cloudflared', '/opt/homebrew/bin/cloudflared', '/usr/local/bin/cloudflared', '/usr/bin/cloudflared', '/bin/cloudflared');
94
- }
95
-
96
- for (const bin of candidates) {
97
- try {
98
- if (bin.includes('/') || bin.includes('\\')) {
99
- if (!existsSync(bin)) continue;
100
- }
101
- execSync(`"${bin}" --version`, { stdio: 'ignore', timeout: 3000 });
102
- return bin;
103
- } catch {}
104
- }
105
- return null;
106
- }
107
-
108
- export class CloudflaredManager {
109
- constructor({ port, home, token, hostname, onStateChange, logger }) {
110
- this.port = port;
111
- this.home = home || join(homedir(), '.dsh-bridge');
112
- this.token = token ? String(token).trim() : null;
113
- this.hostname = hostname ? String(hostname).trim() : null;
114
- this.onStateChange = onStateChange;
115
- this.logger = logger;
116
-
117
- this.process = null;
118
- this.url = null;
119
- this.binaryPath = null;
120
- this._stopped = false;
121
- }
122
-
123
- // 异步启动,立即返回——调用方不需要 await
124
- start() {
125
- this._stopped = false;
126
- this._run().catch((err) => {
127
- this.logger?.error('cloudflared 启动失败: %s', err.message);
128
- this._setState('error', err.message);
129
- });
130
- }
131
-
132
- async _run() {
133
- await this._ensureBinary();
134
- if (this._stopped) return;
135
- await this._startProcess();
136
- }
137
-
138
- async _ensureBinary() {
139
- // 1. 优先使用系统环境变量或 Homebrew / 包管理器已安装的全局二进制
140
- const systemBin = findSystemCloudflared();
141
- if (systemBin) {
142
- this.binaryPath = systemBin;
143
- this.logger?.info('优先使用系统全局 cloudflared: %s', systemBin);
144
- return;
145
- }
146
-
147
- const { url, name } = getCloudflaredInfo();
148
- const binDir = join(this.home, 'bin');
149
- const binPath = join(binDir, name);
150
- this.binaryPath = binPath;
151
-
152
- // 2. 检查本地 ~/.dsh-bridge/bin/cloudflared 是否已存在且可用
153
- if (existsSync(binPath)) {
154
- try {
155
- const s = await stat(binPath);
156
- if (s.size > MIN_BINARY_SIZE) { // >5MB 才视为有效二进制
157
- // 检查是否为历史残留未解压的 gzip 压缩包 (0x1f 0x8b)
158
- const fd = readFileSync(binPath);
159
- const isGzip = fd.length >= 2 && fd[0] === 0x1f && fd[1] === 0x8b;
160
- if (isGzip) {
161
- this.logger?.warn('检测到历史残留的未解压 cloudflared.tgz 压缩包,正在清理重新准备...');
162
- await unlink(binPath).catch(() => {});
163
- } else {
164
- // macOS / Linux 赋予可执行权限并清除 Gatekeeper 隔离属性
165
- if (platform() !== 'win32') {
166
- await chmod(binPath, 0o755).catch(() => {});
167
- if (platform() === 'darwin') {
168
- try { execSync(`xattr -d com.apple.quarantine "${binPath}"`, { stdio: 'ignore' }); } catch {}
169
- }
170
- }
171
- // 执行一次 --version 验证是否能正常 spawn
172
- execSync(`"${binPath}" --version`, { stdio: 'ignore', timeout: 3000 });
173
- this.logger?.info('cloudflared 已存在且验证通过: %s', binPath);
174
- return;
175
- }
176
- }
177
- } catch (verifyErr) {
178
- this.logger?.warn('现有 cloudflared 二进制验证失败 (%s),准备重新下载', verifyErr.message);
179
- }
180
- // 损坏文件,删掉重下
181
- await unlink(binPath).catch(() => {});
182
- }
183
-
184
- this._setState('downloading', '正在下载 cloudflared (~30MB)...');
185
- this.logger?.info('从 %s 下载 cloudflared', url);
186
-
187
- mkdirSync(binDir, { recursive: true });
188
- const tempPath = `${binPath}.tmp`;
189
-
190
- try {
191
- await downloadFile(url, tempPath, (percent, downloaded, total) => {
192
- if (this._stopped) return;
193
- const mb = (downloaded / 1024 / 1024).toFixed(1);
194
- const totalMb = (total / 1024 / 1024).toFixed(1);
195
- this._setState('downloading', `下载 cloudflared: ${mb}/${totalMb} MB (${percent}%)`);
196
- });
197
-
198
- if (url.endsWith('.tgz') || url.endsWith('.tar.gz')) {
199
- try {
200
- execSync(`tar -xzf "${tempPath}" -C "${binDir}"`);
201
- await unlink(tempPath).catch(() => {});
202
- } catch (tarErr) {
203
- this.logger?.error('解压 cloudflared 压缩包失败: %s', tarErr.message);
204
- throw new Error(`解压 cloudflared 失败: ${tarErr.message}`, { cause: tarErr });
205
- }
206
- } else {
207
- if (existsSync(binPath)) await unlink(binPath).catch(() => {});
208
- await rename(tempPath, binPath);
209
- }
210
-
211
- if (platform() !== 'win32') {
212
- await chmod(binPath, 0o755).catch(() => {});
213
- if (platform() === 'darwin') {
214
- try { execSync(`xattr -d com.apple.quarantine "${binPath}"`, { stdio: 'ignore' }); } catch {}
215
- }
216
- }
217
-
218
- // 执行 --version 最终确认
219
- execSync(`"${binPath}" --version`, { stdio: 'ignore', timeout: 3000 });
220
- this.logger?.info('cloudflared 下载并准备完成 (sha256=%s)', await sha256File(binPath));
221
- } catch (err) {
222
- await unlink(tempPath).catch(() => {});
223
- throw new Error(`准备 cloudflared 失败: ${err.message}`, { cause: err });
224
- }
225
- }
226
-
227
- _startProcess() {
228
- return new Promise((resolve, reject) => {
229
- if (this._stopped) return reject(new Error('已取消'));
230
-
231
- this._setState('connecting', '正在连接 Cloudflare...');
232
-
233
- const args = this.token
234
- ? ['tunnel', 'run', '--token', this.token]
235
- : ['tunnel', '--url', `http://127.0.0.1:${this.port}`];
236
-
237
- // 隐藏日志中的 token 敏感字段
238
- const safeArgs = this.token ? ['tunnel', 'run', '--token', '***'] : args;
239
- this.logger?.info('启动 cloudflared: %s %s', this.binaryPath, safeArgs.join(' '));
240
-
241
- this.process = spawn(this.binaryPath, args, {
242
- stdio: ['ignore', 'pipe', 'pipe'],
243
- });
244
-
245
- let resolved = false;
246
-
247
- let timeoutTimer = null;
248
-
249
- const tryResolve = () => {
250
- if (!resolved) {
251
- resolved = true;
252
- if (timeoutTimer) {
253
- clearTimeout(timeoutTimer);
254
- timeoutTimer = null;
255
- }
256
- resolve();
257
- }
258
- };
259
-
260
- // 1. 命名/Token 隧道:通过握手日志判定就绪,使用预设固定域名
261
- const parseNamedTunnel = (text) => {
262
- if (!this.token) return;
263
- if (
264
- (text.includes('Registered tunnel') ||
265
- text.includes('registered connIndex') ||
266
- text.includes('Connection') && text.includes('registered') ||
267
- text.includes('Updated to new configuration') ||
268
- text.includes('Route propagated')) &&
269
- !resolved
270
- ) {
271
- let fixedUrl = this.hostname
272
- ? (this.hostname.startsWith('http') ? this.hostname : `https://${this.hostname}`)
273
- : null;
274
- this.url = fixedUrl;
275
- this._setState('ready', fixedUrl ? `固定隧道已建立 (${fixedUrl})` : '固定隧道已建立');
276
- this.logger?.info('cloudflared 固定隧道就绪: %s', this.url || 'Token 模式');
277
- tryResolve();
278
- }
279
- };
280
-
281
- // 2. 免费临时隧道:从 stdout/stderr 解析随机分配的 trycloudflare.com 域名
282
- const parseUrl = (text) => {
283
- if (this.token) {
284
- parseNamedTunnel(text);
285
- return;
286
- }
287
- const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
288
- if (match && !resolved) {
289
- this.url = match[0];
290
- this._setState('ready', '临时隧道已建立');
291
- this.logger?.info('cloudflared 临时隧道就绪: %s', this.url);
292
- tryResolve();
293
- }
294
- };
295
-
296
- this.process.stdout.on('data', (d) => parseUrl(d.toString()));
297
- this.process.stderr.on('data', (d) => {
298
- const text = d.toString();
299
- this.logger?.debug('cloudflared: %s', text.trim());
300
- parseUrl(text);
301
- if (text.includes('Registered tunnel') && !resolved) {
302
- this._setState('connecting', '隧道已注册,等待就绪...');
303
- }
304
- });
305
-
306
- this.process.on('exit', (code) => {
307
- if (timeoutTimer) {
308
- clearTimeout(timeoutTimer);
309
- timeoutTimer = null;
310
- }
311
- this.process = null;
312
- this.url = null;
313
- if (!resolved) {
314
- reject(new Error(`cloudflared 退出,code=${code}`));
315
- } else if (!this._stopped) {
316
- // 意外退出上报 error,让 BridgeService 清空引用、UI 显示可重启状态
317
- this._setState('error', `cloudflared 进程意外退出 (code=${code})`);
318
- } else {
319
- this._setState('idle', '');
320
- }
321
- });
322
-
323
- this.process.on('error', (err) => {
324
- if (timeoutTimer) {
325
- clearTimeout(timeoutTimer);
326
- timeoutTimer = null;
327
- }
328
- if (!resolved) reject(err);
329
- });
330
-
331
- // 连接超时 90
332
- timeoutTimer = setTimeout(() => {
333
- if (!resolved) {
334
- this.stop();
335
- reject(new Error('等待隧道 URL 超时(90秒)'));
336
- }
337
- }, 90000);
338
- });
339
- }
340
-
341
- _setState(phase, detail) {
342
- this.onStateChange?.({ phase, detail });
343
- }
344
-
345
- stop() {
346
- this._stopped = true;
347
- if (this.process) {
348
- this.logger?.info('停止 cloudflared...');
349
- try {
350
- if (platform() === 'win32') {
351
- // Windows 不支持 SIGTERM,用 taskkill 强制终止
352
- spawn('taskkill', ['/pid', String(this.process.pid), '/f', '/t'], { stdio: 'ignore' });
353
- } else {
354
- this.process.kill('SIGTERM');
355
- }
356
- } catch {}
357
- this.process = null;
358
- }
359
- this.url = null;
360
- }
361
- }
1
+ import { spawn, execSync } from 'node:child_process';
2
+ import { createWriteStream, createReadStream, existsSync, mkdirSync, readFileSync } from 'node:fs';
3
+ import { chmod, stat, unlink, rename } from 'node:fs/promises';
4
+ import { homedir, platform, arch } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { createHash } from 'node:crypto';
8
+ import { get as httpsGet } from 'node:https';
9
+
10
+ const CLOUDFLARED_VERSION = '2024.10.0';
11
+ const DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 分钟
12
+ const MIN_BINARY_SIZE = 5 * 1024 * 1024; // 最小 5MB,防止下到 HTML 错误页
13
+ const HANDSHAKE_TIMEOUT_MS = 90 * 1000; // 等待隧道就绪的握手超时(默认 90s,可注入)
14
+ const RETRY_BASE_MS = 5 * 1000; // 自愈退避起点 5s
15
+ const RETRY_MAX_MS = 5 * 60 * 1000; // 自愈退避封顶 5min
16
+ const DEFAULT_MAX_RETRIES = 12; // 连续失败超过该次数转为 error,不再无限重试
17
+
18
+ // 上游 release 不提供任何官方校验和文件(已核实 2024.10.0 资产清单),
19
+ // 因此无法做下载校验和比对;退而求其次:记录产物 SHA-256 指纹供事后审计比对。
20
+ async function sha256File(filePath) {
21
+ return new Promise((resolve, reject) => {
22
+ const hash = createHash('sha256');
23
+ createReadStream(filePath)
24
+ .on('data', (c) => hash.update(c))
25
+ .on('end', () => resolve(hash.digest('hex')))
26
+ .on('error', reject);
27
+ });
28
+ }
29
+
30
+ // 解析 cloudflared --version 输出("cloudflared version 2024.10.0 (built ...)")为版本号
31
+ export function parseCloudflaredVersion(output) {
32
+ const m = /version\s+(\d{4}\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(output || '');
33
+ return m ? m[1] : null;
34
+ }
35
+
36
+ function getCloudflaredInfo() {
37
+ const os = platform();
38
+ const cpuArch = arch();
39
+
40
+ const platformMap = {
41
+ 'win32-x64': { file: 'cloudflared-windows-amd64.exe', name: 'cloudflared.exe' },
42
+ 'win32-arm64': { file: 'cloudflared-windows-arm64.exe', name: 'cloudflared.exe' },
43
+ 'darwin-x64': { file: 'cloudflared-darwin-amd64.tgz', name: 'cloudflared' },
44
+ 'darwin-arm64':{ file: 'cloudflared-darwin-arm64.tgz', name: 'cloudflared' },
45
+ 'linux-x64': { file: 'cloudflared-linux-amd64', name: 'cloudflared' },
46
+ 'linux-arm64': { file: 'cloudflared-linux-arm64', name: 'cloudflared' },
47
+ };
48
+
49
+ const key = `${os}-${cpuArch}`;
50
+ const info = platformMap[key];
51
+ if (!info) throw new Error(`不支持的平台: ${os}-${cpuArch}`);
52
+
53
+ const url = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${info.file}`;
54
+ return { url, name: info.name };
55
+ }
56
+
57
+ async function downloadFile(url, dest, onProgress) {
58
+ return new Promise((resolve, reject) => {
59
+ const timer = setTimeout(() => reject(new Error('下载超时(5分钟)')), DOWNLOAD_TIMEOUT);
60
+
61
+ function doGet(targetUrl, redirects = 0) {
62
+ if (redirects > 5) {
63
+ clearTimeout(timer);
64
+ return reject(new Error('重定向次数过多'));
65
+ }
66
+ httpsGet(targetUrl, (res) => {
67
+ if (res.statusCode === 301 || res.statusCode === 302) {
68
+ res.resume();
69
+ return doGet(res.headers.location, redirects + 1);
70
+ }
71
+ if (res.statusCode !== 200) {
72
+ res.resume();
73
+ clearTimeout(timer);
74
+ return reject(new Error(`下载失败: HTTP ${res.statusCode}`));
75
+ }
76
+
77
+ const total = parseInt(res.headers['content-length'] ?? '0', 10);
78
+ let downloaded = 0;
79
+ res.on('data', (chunk) => {
80
+ downloaded += chunk.length;
81
+ if (onProgress && total > 0) {
82
+ onProgress(Math.round(downloaded / total * 100), downloaded, total);
83
+ }
84
+ });
85
+
86
+ const fileStream = createWriteStream(dest);
87
+ pipeline(res, fileStream)
88
+ .then(() => { clearTimeout(timer); resolve(); })
89
+ .catch((err) => { clearTimeout(timer); reject(err); });
90
+ }).on('error', (err) => { clearTimeout(timer); reject(err); });
91
+ }
92
+
93
+ doGet(url);
94
+ });
95
+ }
96
+
97
+ function findSystemCloudflared() {
98
+ const isWin = platform() === 'win32';
99
+ const candidates = [];
100
+ if (isWin) {
101
+ candidates.push('cloudflared.exe', 'cloudflared', 'C:\\Program Files (x86)\\cloudflared\\cloudflared.exe', 'C:\\Program Files\\cloudflared\\cloudflared.exe');
102
+ } else {
103
+ candidates.push('cloudflared', '/opt/homebrew/bin/cloudflared', '/usr/local/bin/cloudflared', '/usr/bin/cloudflared', '/bin/cloudflared');
104
+ }
105
+
106
+ for (const bin of candidates) {
107
+ try {
108
+ if (bin.includes('/') || bin.includes('\\')) {
109
+ if (!existsSync(bin)) continue;
110
+ }
111
+ execSync(`"${bin}" --version`, { stdio: 'ignore', timeout: 3000 });
112
+ return bin;
113
+ } catch {}
114
+ }
115
+ return null;
116
+ }
117
+
118
+ export class CloudflaredManager {
119
+ /**
120
+ * @param {object} opts
121
+ * @param {string} [opts.binaryPath] 直接指定可执行文件路径(测试注入假 cloudflared 用;
122
+ * 生产不传,走 findSystemCloudflared → ~/.dsh-bridge/bin 的自动准备逻辑)。
123
+ * @param {number} [opts.retryPolicy] 自愈重试策略:
124
+ * - false / null:禁用自动重启(保持旧版"意外退出置 error"行为)
125
+ * - 对象 { baseDelayMs, maxDelayMs, maxRetries }:覆盖默认退避参数(默认 5s→5min,12 次封顶)
126
+ * @param {boolean} [opts.noAutoupdate=true] 是否禁用 cloudflared 自身的 autoupdate 自替换。
127
+ * autoupdate spawn 出的新进程不受本 manager 监督,会破坏"钉死版本 + 可自愈"的语义,默认必须关闭。
128
+ * @param {string} [opts.binaryVersion=CLOUDFLARED_VERSION] 本 manager 期望管理的 cloudflared 版本。
129
+ * 仅对自管理二进制(~/.dsh-bridge/bin)强制校验;系统级全局二进制尊重用户选择、不强制。
130
+ * @param {number} [opts.handshakeTimeoutMs=90000] 等待隧道就绪的握手超时(测试可注入小值)。
131
+ * @param {object} [opts.spawnOptions] 透传给 child_process.spawn 的额外选项(测试注入用,
132
+ * 如 Windows 下需 shell:true 才能运行 .cmd mock;生产不传)。
133
+ */
134
+ constructor({ port, home, token, hostname, onStateChange, logger,
135
+ binaryPath, retryPolicy, noAutoupdate = true, binaryVersion = CLOUDFLARED_VERSION,
136
+ handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS, spawnOptions = null }) {
137
+ this.port = port;
138
+ this.home = home || join(homedir(), '.dsh-bridge');
139
+ this.token = token ? String(token).trim() : null;
140
+ this.hostname = hostname ? String(hostname).trim() : null;
141
+ this.onStateChange = onStateChange;
142
+ this.logger = logger;
143
+
144
+ // 显式注入的可执行文件(测试用):跳过自动查找/下载,直接按此路径运行
145
+ this._injectedBinaryPath = binaryPath || null;
146
+
147
+ // 自愈重试策略:默认开启;false/null 显式禁用(旧版行为)
148
+ if (retryPolicy === false || retryPolicy === null) {
149
+ this.retry = null;
150
+ } else {
151
+ const p = retryPolicy && typeof retryPolicy === 'object' ? retryPolicy : {};
152
+ this.retry = {
153
+ baseDelayMs: p.baseDelayMs ?? RETRY_BASE_MS,
154
+ maxDelayMs: p.maxDelayMs ?? RETRY_MAX_MS,
155
+ maxRetries: p.maxRetries ?? DEFAULT_MAX_RETRIES,
156
+ };
157
+ }
158
+
159
+ this.noAutoupdate = noAutoupdate !== false;
160
+ this.binaryVersion = binaryVersion || CLOUDFLARED_VERSION;
161
+ this.handshakeTimeoutMs = handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
162
+ // 测试注入的 spawn 选项(如 Windows 的 shell);生产为 null 不影响默认行为
163
+ this._spawnOptions = spawnOptions || null;
164
+
165
+ this.process = null;
166
+ this.url = null;
167
+ this.binaryPath = null;
168
+ this._stopped = false;
169
+ this._retryTimer = null;
170
+ this._restartCount = 0; // 连续启动失败/意外退出次数,就绪后清零
171
+ }
172
+
173
+ // 异步启动,立即返回——调用方不需要 await
174
+ start() {
175
+ this._stopped = false;
176
+ this._restartCount = 0;
177
+ if (this._retryTimer) {
178
+ clearTimeout(this._retryTimer);
179
+ this._retryTimer = null;
180
+ }
181
+ this._setState('connecting', '正在初始化...');
182
+ this._run().catch((err) => {
183
+ this.logger?.error('cloudflared 启动失败: %s', err.message);
184
+ // 启动失败同样进入退避自愈(下载失败 / 握手失败 / 秒退)
185
+ this._scheduleRestart(`cloudflared 启动失败: ${err.message}`);
186
+ });
187
+ }
188
+
189
+ async _run() {
190
+ await this._ensureBinary();
191
+ if (this._stopped) return;
192
+ await this._startProcess();
193
+ }
194
+
195
+ // 退避自愈调度:唯一入口在"启动失败"与"就绪后意外退出"。stop()/超限 终止。
196
+ _scheduleRestart(reason) {
197
+ if (this._stopped) return; // 用户已停止,绝不复活
198
+ if (this._retryTimer) return; // 已在倒计时中,避免叠加调度
199
+ if (!this.retry) {
200
+ this._setState('error', reason);
201
+ return;
202
+ }
203
+ this._restartCount++;
204
+ if (this._restartCount > this.retry.maxRetries) {
205
+ this._setState('error', `${reason}(已自动重试 ${this.retry.maxRetries} 次仍失败,请检查网络/Token,或点击「关闭」停止)`);
206
+ return;
207
+ }
208
+ const delay = Math.min(
209
+ this.retry.baseDelayMs * 2 ** (this._restartCount - 1),
210
+ this.retry.maxDelayMs
211
+ );
212
+ this._setState('reconnecting',
213
+ `${reason},${Math.max(1, Math.round(delay / 1000))}s 后自动重连(第 ${this._restartCount}/${this.retry.maxRetries} 次)`);
214
+ this._retryTimer = setTimeout(() => {
215
+ this._retryTimer = null;
216
+ this._restartAttempt();
217
+ }, delay);
218
+ }
219
+
220
+ // 一次实际的自愈尝试:成功(ready)后 _restartCount 清零,失败/退出由 exit/超时/异常再次调度
221
+ _restartAttempt() {
222
+ if (this._stopped) return;
223
+ this._setState('connecting', '正在自动重连...');
224
+ this._run().catch((err) => {
225
+ this.logger?.error('cloudflared 自动重连失败: %s', err.message);
226
+ this._scheduleRestart(`cloudflared 启动失败: ${err.message}`);
227
+ });
228
+ }
229
+
230
+ _terminateProcess() {
231
+ const p = this.process;
232
+ if (!p) return;
233
+ try {
234
+ if (platform() === 'win32') {
235
+ // Windows 不支持 SIGTERM,用 taskkill 强制终止
236
+ spawn('taskkill', ['/pid', String(p.pid), '/f', '/t'], { stdio: 'ignore' });
237
+ } else {
238
+ p.kill('SIGTERM');
239
+ }
240
+ } catch {}
241
+ }
242
+
243
+ // 校验自管理二进制是否匹配期望版本;系统级二进制不校验(尊重用户安装)
244
+ _checkManagedBinaryVersion(binPath) {
245
+ try {
246
+ const out = execSync(`"${binPath}" --version`, { encoding: 'utf8', timeout: 3000 });
247
+ const ver = parseCloudflaredVersion(out);
248
+ if (!ver) return { ok: false, reason: `无法解析版本输出: ${(out || '').trim().slice(0, 80)}` };
249
+ if (ver !== this.binaryVersion) {
250
+ return { ok: false, reason: `版本不匹配: 期望 ${this.binaryVersion},实际 ${ver}` };
251
+ }
252
+ return { ok: true, version: ver };
253
+ } catch (err) {
254
+ return { ok: false, reason: err.message };
255
+ }
256
+ }
257
+
258
+ async _ensureBinary() {
259
+ // 0. 测试注入的可执行文件:直接使用,跳过查找/下载/版本校验
260
+ if (this._injectedBinaryPath) {
261
+ this.binaryPath = this._injectedBinaryPath;
262
+ return;
263
+ }
264
+
265
+ // 1. 优先使用系统环境变量或 Homebrew / 包管理器已安装的全局二进制(尊重用户版本,不做强制校验)
266
+ const systemBin = findSystemCloudflared();
267
+ if (systemBin) {
268
+ this.binaryPath = systemBin;
269
+ this.logger?.info('优先使用系统全局 cloudflared: %s', systemBin);
270
+ return;
271
+ }
272
+
273
+ const { url, name } = getCloudflaredInfo();
274
+ const binDir = join(this.home, 'bin');
275
+ const binPath = join(binDir, name);
276
+ this.binaryPath = binPath;
277
+
278
+ // 2. 检查本地 ~/.dsh-bridge/bin/cloudflared 是否已存在且可用
279
+ if (existsSync(binPath)) {
280
+ try {
281
+ const s = await stat(binPath);
282
+ if (s.size > MIN_BINARY_SIZE) { // >5MB 才视为有效二进制
283
+ // 检查是否为历史残留未解压的 gzip 压缩包 (0x1f 0x8b)
284
+ const fd = readFileSync(binPath);
285
+ const isGzip = fd.length >= 2 && fd[0] === 0x1f && fd[1] === 0x8b;
286
+ if (isGzip) {
287
+ this.logger?.warn('检测到历史残留的未解压 cloudflared.tgz 压缩包,正在清理重新准备...');
288
+ await unlink(binPath).catch(() => {});
289
+ } else {
290
+ // macOS / Linux 赋予可执行权限并清除 Gatekeeper 隔离属性
291
+ if (platform() !== 'win32') {
292
+ await chmod(binPath, 0o755).catch(() => {});
293
+ if (platform() === 'darwin') {
294
+ try { execSync(`xattr -d com.apple.quarantine "${binPath}"`, { stdio: 'ignore' }); } catch {}
295
+ }
296
+ }
297
+ // 版本钉死校验:cloudflared autoupdate 可能已把钉死的版本自替换成新版,
298
+ // 自管理二进制必须与期望版本一致,否则回滚重下——版本控制权留在插件手里。
299
+ const check = this._checkManagedBinaryVersion(binPath);
300
+ if (check.ok) {
301
+ this.logger?.info('cloudflared 已存在且验证通过: %s (version=%s)', binPath, check.version);
302
+ return;
303
+ }
304
+ this.logger?.warn('现有 cloudflared 二进制验证失败(%s),准备重新下载', check.reason);
305
+ }
306
+ }
307
+ } catch (verifyErr) {
308
+ this.logger?.warn('现有 cloudflared 二进制验证失败 (%s),准备重新下载', verifyErr.message);
309
+ }
310
+ // 损坏 / 版本不符 / 无法执行:删掉重下
311
+ await unlink(binPath).catch(() => {});
312
+ }
313
+
314
+ this._setState('downloading', '正在下载 cloudflared (~30MB)...');
315
+ this.logger?.info('从 %s 下载 cloudflared', url);
316
+
317
+ mkdirSync(binDir, { recursive: true });
318
+ const tempPath = `${binPath}.tmp`;
319
+
320
+ try {
321
+ await downloadFile(url, tempPath, (percent, downloaded, total) => {
322
+ if (this._stopped) return;
323
+ const mb = (downloaded / 1024 / 1024).toFixed(1);
324
+ const totalMb = (total / 1024 / 1024).toFixed(1);
325
+ this._setState('downloading', `下载 cloudflared: ${mb}/${totalMb} MB (${percent}%)`);
326
+ });
327
+
328
+ if (url.endsWith('.tgz') || url.endsWith('.tar.gz')) {
329
+ try {
330
+ execSync(`tar -xzf "${tempPath}" -C "${binDir}"`);
331
+ await unlink(tempPath).catch(() => {});
332
+ } catch (tarErr) {
333
+ this.logger?.error('解压 cloudflared 压缩包失败: %s', tarErr.message);
334
+ throw new Error(`解压 cloudflared 失败: ${tarErr.message}`, { cause: tarErr });
335
+ }
336
+ } else {
337
+ if (existsSync(binPath)) await unlink(binPath).catch(() => {});
338
+ await rename(tempPath, binPath);
339
+ }
340
+
341
+ if (platform() !== 'win32') {
342
+ await chmod(binPath, 0o755).catch(() => {});
343
+ if (platform() === 'darwin') {
344
+ try { execSync(`xattr -d com.apple.quarantine "${binPath}"`, { stdio: 'ignore' }); } catch {}
345
+ }
346
+ }
347
+
348
+ // 下载产物也必须通过版本校验(钉死版本)
349
+ const check = this._checkManagedBinaryVersion(binPath);
350
+ if (!check.ok) {
351
+ throw new Error(`下载的 cloudflared 版本校验失败: ${check.reason}`);
352
+ }
353
+ this.logger?.info('cloudflared 下载并准备完成 (version=%s, sha256=%s)', check.version, await sha256File(binPath));
354
+ } catch (err) {
355
+ await unlink(tempPath).catch(() => {});
356
+ throw new Error(`准备 cloudflared 失败: ${err.message}`, { cause: err });
357
+ }
358
+ }
359
+
360
+ _startProcess() {
361
+ return new Promise((resolve, reject) => {
362
+ if (this._stopped) return reject(new Error('已取消'));
363
+
364
+ this._setState('connecting', this._restartCount > 0 ? '正在自动重连...' : '正在连接 Cloudflare...');
365
+
366
+ const args = this.token
367
+ ? ['tunnel', 'run', ...(this.noAutoupdate ? ['--no-autoupdate'] : []), '--token', this.token]
368
+ : ['tunnel', ...(this.noAutoupdate ? ['--no-autoupdate'] : []), '--url', `http://127.0.0.1:${this.port}`];
369
+
370
+ // 隐藏日志中的 token 敏感字段
371
+ const safeArgs = this.token ? args.map((a) => (a === this.token ? '***' : a)) : args;
372
+ this.logger?.info('启动 cloudflared: %s %s', this.binaryPath, safeArgs.join(' '));
373
+
374
+ // 环境变量双保险禁用 autoupdate(部分打包/脚本以 env 方式读取)
375
+ const spawnEnv = this.noAutoupdate
376
+ ? { ...process.env, NO_AUTOUPDATE: 'true' }
377
+ : process.env;
378
+
379
+ const proc = spawn(this.binaryPath, args, {
380
+ stdio: ['ignore', 'pipe', 'pipe'],
381
+ env: spawnEnv,
382
+ ...(this._spawnOptions || {}),
383
+ });
384
+ this.process = proc;
385
+
386
+ let resolved = false;
387
+ let timeoutTimer = null;
388
+
389
+ // 仅当当前引用的仍是本次 spawn 的进程时才清空——自愈重启后旧进程的
390
+ // exit 事件可能晚于新进程 spawn 触发;exit handler 内用 stillCurrent
391
+ // (this.process === proc)判定,避免误清新进程引用(stop() 将无法终止它)。
392
+
393
+ const tryResolve = () => {
394
+ if (!resolved) {
395
+ resolved = true;
396
+ if (timeoutTimer) {
397
+ clearTimeout(timeoutTimer);
398
+ timeoutTimer = null;
399
+ }
400
+ // 就绪即证明链路可用,连续失败计数清零
401
+ this._restartCount = 0;
402
+ resolve();
403
+ }
404
+ };
405
+
406
+ // 1. 命名/Token 隧道:通过握手日志判定就绪,使用预设固定域名
407
+ const parseNamedTunnel = (text) => {
408
+ if (!this.token) return;
409
+ if (
410
+ (text.includes('Registered tunnel') ||
411
+ text.includes('registered connIndex') ||
412
+ text.includes('Connection') && text.includes('registered') ||
413
+ text.includes('Updated to new configuration') ||
414
+ text.includes('Route propagated')) &&
415
+ !resolved
416
+ ) {
417
+ let fixedUrl = this.hostname
418
+ ? (this.hostname.startsWith('http') ? this.hostname : `https://${this.hostname}`)
419
+ : null;
420
+ this.url = fixedUrl;
421
+ this._setState('ready', fixedUrl ? `固定隧道已建立 (${fixedUrl})` : '固定隧道已建立');
422
+ this.logger?.info('cloudflared 固定隧道就绪: %s', this.url || 'Token 模式');
423
+ tryResolve();
424
+ }
425
+ };
426
+
427
+ // 2. 免费临时隧道:从 stdout/stderr 解析随机分配的 trycloudflare.com 域名
428
+ const parseUrl = (text) => {
429
+ if (this.token) {
430
+ parseNamedTunnel(text);
431
+ return;
432
+ }
433
+ const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
434
+ if (match && !resolved) {
435
+ this.url = match[0];
436
+ this._setState('ready', '临时隧道已建立');
437
+ this.logger?.info('cloudflared 临时隧道就绪: %s', this.url);
438
+ tryResolve();
439
+ }
440
+ };
441
+
442
+ proc.stdout.on('data', (d) => parseUrl(d.toString()));
443
+ proc.stderr.on('data', (d) => {
444
+ const text = d.toString();
445
+ this.logger?.debug('cloudflared: %s', text.trim());
446
+ parseUrl(text);
447
+ if (text.includes('Registered tunnel') && !resolved) {
448
+ this._setState('connecting', '隧道已注册,等待就绪...');
449
+ }
450
+ });
451
+
452
+ proc.on('exit', (code, signal) => {
453
+ if (timeoutTimer) {
454
+ clearTimeout(timeoutTimer);
455
+ timeoutTimer = null;
456
+ }
457
+ // exit 时"仍是当前进程"才允许清理引用 + 调度自愈。
458
+ // 自愈已 spawn 新进程后,旧进程迟到的 exit 不满足 stillCurrent → 静默,
459
+ // 避免"新进程连接中、旧 exit 又触发一次重启"的竞态(否则会叠出第三进程)。
460
+ const stillCurrent = this.process === proc;
461
+ if (stillCurrent) this.process = null;
462
+ this.url = null;
463
+ if (!resolved) {
464
+ // 就绪前退出 = 启动失败,交给 reject → start()/restart 的 catch 进入退避自愈
465
+ reject(new Error(`cloudflared 退出,code=${code ?? ''} signal=${signal ?? ''}`));
466
+ } else if (!this._stopped && stillCurrent) {
467
+ // 就绪后的意外退出(崩溃 / OOM / 误杀 / autoupdate 残留自替换)→ 退避自愈
468
+ this._scheduleRestart(`cloudflared 进程意外退出 (code=${code ?? ''}${signal ? `, ${signal}` : ''})`);
469
+ } else {
470
+ this._setState('idle', '');
471
+ }
472
+ });
473
+
474
+ proc.on('error', (err) => {
475
+ if (timeoutTimer) {
476
+ clearTimeout(timeoutTimer);
477
+ timeoutTimer = null;
478
+ }
479
+ if (!resolved) reject(err);
480
+ });
481
+
482
+ // 握手超时:只终止进程、不置 _stopped——让退出/重试路径接管(kill 后进程
483
+ // exit 会触发 reject;此处兜底 reject 保证进程僵死时也能推进)
484
+ timeoutTimer = setTimeout(() => {
485
+ if (!resolved) {
486
+ this.logger?.warn('等待隧道 URL 超时(%ss),终止进程并进入自动重试', Math.round(this.handshakeTimeoutMs / 1000));
487
+ this._terminateProcess();
488
+ reject(new Error(`等待隧道 URL 超时(${Math.round(this.handshakeTimeoutMs / 1000)}秒)`));
489
+ }
490
+ }, this.handshakeTimeoutMs);
491
+ });
492
+ }
493
+
494
+ _setState(phase, detail) {
495
+ this.onStateChange?.({ phase, detail });
496
+ }
497
+
498
+ stop() {
499
+ this._stopped = true;
500
+ if (this._retryTimer) {
501
+ clearTimeout(this._retryTimer);
502
+ this._retryTimer = null;
503
+ }
504
+ this._restartCount = 0;
505
+ if (this.process) {
506
+ this.logger?.info('停止 cloudflared...');
507
+ this._terminateProcess();
508
+ this.process = null;
509
+ }
510
+ this.url = null;
511
+ this._setState('idle', '');
512
+ }
513
+ }