@wenbin_wb/dsh-bridge 2.8.7 → 2.9.0

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,345 +1,361 @@
1
- import { spawn, execSync } from 'node:child_process';
2
- import { createWriteStream, 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 { get as httpsGet } from 'node:https';
8
-
9
- const CLOUDFLARED_VERSION = '2024.10.0';
10
- const DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 分钟
11
- const MIN_BINARY_SIZE = 5 * 1024 * 1024; // 最小 5MB,防止下到 HTML 错误页
12
-
13
- function getCloudflaredInfo() {
14
- const os = platform();
15
- const cpuArch = arch();
16
-
17
- const platformMap = {
18
- 'win32-x64': { file: 'cloudflared-windows-amd64.exe', name: 'cloudflared.exe' },
19
- 'win32-arm64': { file: 'cloudflared-windows-arm64.exe', name: 'cloudflared.exe' },
20
- 'darwin-x64': { file: 'cloudflared-darwin-amd64.tgz', name: 'cloudflared' },
21
- 'darwin-arm64':{ file: 'cloudflared-darwin-arm64.tgz', name: 'cloudflared' },
22
- 'linux-x64': { file: 'cloudflared-linux-amd64', name: 'cloudflared' },
23
- 'linux-arm64': { file: 'cloudflared-linux-arm64', name: 'cloudflared' },
24
- };
25
-
26
- const key = `${os}-${cpuArch}`;
27
- const info = platformMap[key];
28
- if (!info) throw new Error(`不支持的平台: ${os}-${cpuArch}`);
29
-
30
- const url = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${info.file}`;
31
- return { url, name: info.name };
32
- }
33
-
34
- async function downloadFile(url, dest, onProgress) {
35
- return new Promise((resolve, reject) => {
36
- const timer = setTimeout(() => reject(new Error('下载超时(5分钟)')), DOWNLOAD_TIMEOUT);
37
-
38
- function doGet(targetUrl, redirects = 0) {
39
- if (redirects > 5) {
40
- clearTimeout(timer);
41
- return reject(new Error('重定向次数过多'));
42
- }
43
- httpsGet(targetUrl, (res) => {
44
- if (res.statusCode === 301 || res.statusCode === 302) {
45
- res.resume();
46
- return doGet(res.headers.location, redirects + 1);
47
- }
48
- if (res.statusCode !== 200) {
49
- res.resume();
50
- clearTimeout(timer);
51
- return reject(new Error(`下载失败: HTTP ${res.statusCode}`));
52
- }
53
-
54
- const total = parseInt(res.headers['content-length'] ?? '0', 10);
55
- let downloaded = 0;
56
- res.on('data', (chunk) => {
57
- downloaded += chunk.length;
58
- if (onProgress && total > 0) {
59
- onProgress(Math.round(downloaded / total * 100), downloaded, total);
60
- }
61
- });
62
-
63
- const fileStream = createWriteStream(dest);
64
- pipeline(res, fileStream)
65
- .then(() => { clearTimeout(timer); resolve(); })
66
- .catch((err) => { clearTimeout(timer); reject(err); });
67
- }).on('error', (err) => { clearTimeout(timer); reject(err); });
68
- }
69
-
70
- doGet(url);
71
- });
72
- }
73
-
74
- function findSystemCloudflared() {
75
- const isWin = platform() === 'win32';
76
- const candidates = [];
77
- if (isWin) {
78
- candidates.push('cloudflared.exe', 'cloudflared', 'C:\\Program Files (x86)\\cloudflared\\cloudflared.exe', 'C:\\Program Files\\cloudflared\\cloudflared.exe');
79
- } else {
80
- candidates.push('cloudflared', '/opt/homebrew/bin/cloudflared', '/usr/local/bin/cloudflared', '/usr/bin/cloudflared', '/bin/cloudflared');
81
- }
82
-
83
- for (const bin of candidates) {
84
- try {
85
- if (bin.includes('/') || bin.includes('\\')) {
86
- if (!existsSync(bin)) continue;
87
- }
88
- execSync(`"${bin}" --version`, { stdio: 'ignore', timeout: 3000 });
89
- return bin;
90
- } catch {}
91
- }
92
- return null;
93
- }
94
-
95
- export class CloudflaredManager {
96
- constructor({ port, home, token, hostname, onStateChange, logger }) {
97
- this.port = port;
98
- this.home = home || join(homedir(), '.dsh-bridge');
99
- this.token = token ? String(token).trim() : null;
100
- this.hostname = hostname ? String(hostname).trim() : null;
101
- this.onStateChange = onStateChange;
102
- this.logger = logger;
103
-
104
- this.process = null;
105
- this.url = null;
106
- this.binaryPath = null;
107
- this._stopped = false;
108
- }
109
-
110
- // 异步启动,立即返回——调用方不需要 await
111
- start() {
112
- this._stopped = false;
113
- this._run().catch((err) => {
114
- this.logger?.error('cloudflared 启动失败: %s', err.message);
115
- this._setState('error', err.message);
116
- });
117
- }
118
-
119
- async _run() {
120
- await this._ensureBinary();
121
- if (this._stopped) return;
122
- await this._startProcess();
123
- }
124
-
125
- async _ensureBinary() {
126
- // 1. 优先使用系统环境变量或 Homebrew / 包管理器已安装的全局二进制
127
- const systemBin = findSystemCloudflared();
128
- if (systemBin) {
129
- this.binaryPath = systemBin;
130
- this.logger?.info('优先使用系统全局 cloudflared: %s', systemBin);
131
- return;
132
- }
133
-
134
- const { url, name } = getCloudflaredInfo();
135
- const binDir = join(this.home, 'bin');
136
- const binPath = join(binDir, name);
137
- this.binaryPath = binPath;
138
-
139
- // 2. 检查本地 ~/.dsh-bridge/bin/cloudflared 是否已存在且可用
140
- if (existsSync(binPath)) {
141
- try {
142
- const s = await stat(binPath);
143
- if (s.size > MIN_BINARY_SIZE) { // >5MB 才视为有效二进制
144
- // 检查是否为历史残留未解压的 gzip 压缩包 (0x1f 0x8b)
145
- const fd = readFileSync(binPath);
146
- const isGzip = fd.length >= 2 && fd[0] === 0x1f && fd[1] === 0x8b;
147
- if (isGzip) {
148
- this.logger?.warn('检测到历史残留的未解压 cloudflared.tgz 压缩包,正在清理重新准备...');
149
- await unlink(binPath).catch(() => {});
150
- } else {
151
- // macOS / Linux 赋予可执行权限并清除 Gatekeeper 隔离属性
152
- if (platform() !== 'win32') {
153
- await chmod(binPath, 0o755).catch(() => {});
154
- if (platform() === 'darwin') {
155
- try { execSync(`xattr -d com.apple.quarantine "${binPath}"`, { stdio: 'ignore' }); } catch {}
156
- }
157
- }
158
- // 执行一次 --version 验证是否能正常 spawn
159
- execSync(`"${binPath}" --version`, { stdio: 'ignore', timeout: 3000 });
160
- this.logger?.info('cloudflared 已存在且验证通过: %s', binPath);
161
- return;
162
- }
163
- }
164
- } catch (verifyErr) {
165
- this.logger?.warn('现有 cloudflared 二进制验证失败 (%s),准备重新下载', verifyErr.message);
166
- }
167
- // 损坏文件,删掉重下
168
- await unlink(binPath).catch(() => {});
169
- }
170
-
171
- this._setState('downloading', '正在下载 cloudflared (~30MB)...');
172
- this.logger?.info('从 %s 下载 cloudflared', url);
173
-
174
- mkdirSync(binDir, { recursive: true });
175
- const tempPath = `${binPath}.tmp`;
176
-
177
- try {
178
- await downloadFile(url, tempPath, (percent, downloaded, total) => {
179
- if (this._stopped) return;
180
- const mb = (downloaded / 1024 / 1024).toFixed(1);
181
- const totalMb = (total / 1024 / 1024).toFixed(1);
182
- this._setState('downloading', `下载 cloudflared: ${mb}/${totalMb} MB (${percent}%)`);
183
- });
184
-
185
- if (url.endsWith('.tgz') || url.endsWith('.tar.gz')) {
186
- try {
187
- execSync(`tar -xzf "${tempPath}" -C "${binDir}"`);
188
- await unlink(tempPath).catch(() => {});
189
- } catch (tarErr) {
190
- this.logger?.error('解压 cloudflared 压缩包失败: %s', tarErr.message);
191
- throw new Error(`解压 cloudflared 失败: ${tarErr.message}`);
192
- }
193
- } else {
194
- if (existsSync(binPath)) await unlink(binPath).catch(() => {});
195
- await rename(tempPath, binPath);
196
- }
197
-
198
- if (platform() !== 'win32') {
199
- await chmod(binPath, 0o755).catch(() => {});
200
- if (platform() === 'darwin') {
201
- try { execSync(`xattr -d com.apple.quarantine "${binPath}"`, { stdio: 'ignore' }); } catch {}
202
- }
203
- }
204
-
205
- // 执行 --version 最终确认
206
- execSync(`"${binPath}" --version`, { stdio: 'ignore', timeout: 3000 });
207
- this.logger?.info('cloudflared 下载并准备完成');
208
- } catch (err) {
209
- await unlink(tempPath).catch(() => {});
210
- throw new Error(`准备 cloudflared 失败: ${err.message}`);
211
- }
212
- }
213
-
214
- _startProcess() {
215
- return new Promise((resolve, reject) => {
216
- if (this._stopped) return reject(new Error('已取消'));
217
-
218
- this._setState('connecting', '正在连接 Cloudflare...');
219
-
220
- const args = this.token
221
- ? ['tunnel', 'run', '--token', this.token]
222
- : ['tunnel', '--url', `http://127.0.0.1:${this.port}`];
223
-
224
- // 隐藏日志中的 token 敏感字段
225
- const safeArgs = this.token ? ['tunnel', 'run', '--token', '***'] : args;
226
- this.logger?.info('启动 cloudflared: %s %s', this.binaryPath, safeArgs.join(' '));
227
-
228
- this.process = spawn(this.binaryPath, args, {
229
- stdio: ['ignore', 'pipe', 'pipe'],
230
- });
231
-
232
- let resolved = false;
233
-
234
- let timeoutTimer = null;
235
-
236
- const tryResolve = () => {
237
- if (!resolved) {
238
- resolved = true;
239
- if (timeoutTimer) {
240
- clearTimeout(timeoutTimer);
241
- timeoutTimer = null;
242
- }
243
- resolve();
244
- }
245
- };
246
-
247
- // 1. 命名/Token 隧道:通过握手日志判定就绪,使用预设固定域名
248
- const parseNamedTunnel = (text) => {
249
- if (!this.token) return;
250
- if (
251
- (text.includes('Registered tunnel') ||
252
- text.includes('registered connIndex') ||
253
- text.includes('Connection') && text.includes('registered') ||
254
- text.includes('Updated to new configuration') ||
255
- text.includes('Route propagated')) &&
256
- !resolved
257
- ) {
258
- let fixedUrl = this.hostname
259
- ? (this.hostname.startsWith('http') ? this.hostname : `https://${this.hostname}`)
260
- : null;
261
- this.url = fixedUrl;
262
- this._setState('ready', fixedUrl ? `固定隧道已建立 (${fixedUrl})` : '固定隧道已建立');
263
- this.logger?.info('cloudflared 固定隧道就绪: %s', this.url || 'Token 模式');
264
- tryResolve();
265
- }
266
- };
267
-
268
- // 2. 免费临时隧道:从 stdout/stderr 解析随机分配的 trycloudflare.com 域名
269
- const parseUrl = (text) => {
270
- if (this.token) {
271
- parseNamedTunnel(text);
272
- return;
273
- }
274
- const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
275
- if (match && !resolved) {
276
- this.url = match[0];
277
- this._setState('ready', '临时隧道已建立');
278
- this.logger?.info('cloudflared 临时隧道就绪: %s', this.url);
279
- tryResolve();
280
- }
281
- };
282
-
283
- this.process.stdout.on('data', (d) => parseUrl(d.toString()));
284
- this.process.stderr.on('data', (d) => {
285
- const text = d.toString();
286
- this.logger?.debug('cloudflared: %s', text.trim());
287
- parseUrl(text);
288
- if (text.includes('Registered tunnel') && !resolved) {
289
- this._setState('connecting', '隧道已注册,等待就绪...');
290
- }
291
- });
292
-
293
- this.process.on('exit', (code) => {
294
- if (timeoutTimer) {
295
- clearTimeout(timeoutTimer);
296
- timeoutTimer = null;
297
- }
298
- this.process = null;
299
- this.url = null;
300
- if (!resolved) {
301
- reject(new Error(`cloudflared 退出,code=${code}`));
302
- } else {
303
- this._setState('idle', '');
304
- }
305
- });
306
-
307
- this.process.on('error', (err) => {
308
- if (timeoutTimer) {
309
- clearTimeout(timeoutTimer);
310
- timeoutTimer = null;
311
- }
312
- if (!resolved) reject(err);
313
- });
314
-
315
- // 连接超时 90
316
- timeoutTimer = setTimeout(() => {
317
- if (!resolved) {
318
- this.stop();
319
- reject(new Error('等待隧道 URL 超时(90秒)'));
320
- }
321
- }, 90000);
322
- });
323
- }
324
-
325
- _setState(phase, detail) {
326
- this.onStateChange?.({ phase, detail });
327
- }
328
-
329
- stop() {
330
- this._stopped = true;
331
- if (this.process) {
332
- this.logger?.info('停止 cloudflared...');
333
- try {
334
- if (platform() === 'win32') {
335
- // Windows 不支持 SIGTERM,用 taskkill 强制终止
336
- spawn('taskkill', ['/pid', String(this.process.pid), '/f', '/t'], { stdio: 'ignore' });
337
- } else {
338
- this.process.kill('SIGTERM');
339
- }
340
- } catch {}
341
- this.process = null;
342
- }
343
- this.url = null;
344
- }
345
- }
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
+ }