dsh-pocket-relay 1.0.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.
package/lib/tunnel.mjs ADDED
@@ -0,0 +1,621 @@
1
+ // cloudflared 隧道:把本机代理暴露成公网 https URL
2
+ //
3
+ // 两条路径(issue #66):
4
+ // - 快速隧道 startQuickTunnel:URL 由 cloudflared 随机分配(每次重启会变),零配置;
5
+ // - 命名隧道 startNamedTunnel:用户自带 Cloudflare Tunnel Token + 固定域名,重启地址不变。
6
+ //
7
+ // 手机在任何网络都能访问。公网一律要求访问密码(dsh web 能执行代码,请勿泄露二维码/URL)。
8
+
9
+ import { spawn, execSync } from 'node:child_process';
10
+ import { mkdir, access, chmod, rm, stat, rename, cp, open } from 'node:fs/promises';
11
+ import { homedir } from 'node:os';
12
+ import { join, dirname } from 'node:path';
13
+ import { pipeline } from 'node:stream/promises';
14
+ import { Readable } from 'node:stream';
15
+ import { createWriteStream } from 'node:fs';
16
+
17
+ // 快速隧道 URL:https://<随机子域>.trycloudflare.com
18
+ // (?!api\.) 负向前瞻排除保留子域 api(issue #32):某些 cloudflared 版本/网络环境下
19
+ // 进程输出会先出现 https://api.trycloudflare.com(Cloudflare API 注册地址),原正则
20
+ // [a-z0-9-]+ 会把它误当隧道 URL → 设置页/二维码给出 api 地址 → 扫码打开返回
21
+ // {"code":10005,"message":"Method Not Allowed"}。api.trycloudflare.com 访问 GET 实测
22
+ // 正是该错误体,与 issue 完全一致。
23
+ export const QUICK_TUNNEL_URL_RE = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com/i;
24
+
25
+ /**
26
+ * 从 cloudflared 输出里提取最有诊断价值的一段(issue #78)。
27
+ *
28
+ * cloudflared 参数错误(如 "Incorrect Usage: flag provided but not defined")的
29
+ * 关键信息在输出**开头**,尾部整段都是 usage 帮助文本(对用户没用);而运行期错误
30
+ * (403 / 协议 / 网络)的关键信息在**尾部**。所以:参数错误取该行,其它仍取尾部。
31
+ * 最多 500 字符,与历史上限一致。
32
+ */
33
+ export function firstMeaningfulErrorLine(buf) {
34
+ const lines = String(buf ?? '').trim().split(/\r?\n/);
35
+ // 命中哪行就返回哪行:cloudflared 先打版本横幅时,参数错误未必是首行
36
+ const usageIdx = lines.findIndex((l) => /^(?:Incorrect Usage|flag provided but not defined|unknown flag|unknown command)/i.test(l.trim()));
37
+ if (usageIdx >= 0) return lines[usageIdx].trim().slice(0, 500);
38
+ return lines.slice(-4).join('\n').trim().slice(0, 500);
39
+ }
40
+
41
+ function platformBinary() {
42
+ const archMap = { x64: 'amd64', arm64: 'arm64', ia32: '386', arm: 'arm' };
43
+ const a = archMap[process.arch] ?? process.arch;
44
+ const os = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux';
45
+ return { os, a, ext: os === 'windows' ? '.exe' : '' };
46
+ }
47
+
48
+ /**
49
+ * 候选发布资产名,按优先级排列(issue #45)。
50
+ *
51
+ * cloudflared 现在的发布布局是**按平台分**:
52
+ * - linux:只有裸二进制 `cloudflared-linux-amd64`(没有 .tgz)
53
+ * - darwin:只有 `cloudflared-darwin-<arch>.tgz`
54
+ * - windows:只有 `cloudflared-windows-<arch>.exe`
55
+ *
56
+ * 我们以前给 linux 拼的是 `cloudflared-linux-amd64.tgz`,而它**根本不存在**
57
+ * (GitHub 返回 404),五个镜像全指向同一个 404 地址,于是必然"所有源都不通"
58
+ * —— 表现就是 Linux 服务器上开了公网访问一直报"无法安装",而 macOS/Windows
59
+ * 一切正常。Linux 下裸二进制还有个额外好处:不用解压,也就不再依赖系统装了
60
+ * `tar`(Alpine / slim 镜像 / 容器里常常没有)。
61
+ *
62
+ * linux 仍把 .tgz 留在候选里作为回退,万一上游改回打包方式也不会直接躺平。
63
+ *
64
+ * @returns {string[]} 资产名列表,越靠前越优先。
65
+ */
66
+ export function platformAssets() {
67
+ const { os, a } = platformBinary();
68
+ if (os === 'windows') return [`cloudflared-windows-${a}.exe`];
69
+ if (os === 'darwin') return [`cloudflared-darwin-${a}.tgz`];
70
+ return [`cloudflared-linux-${a}`, `cloudflared-linux-${a}.tgz`];
71
+ }
72
+
73
+ /**
74
+ * cloudflared 下载源。
75
+ * 平台策略:macOS 优先清华 TUNA 镜像的 Homebrew bottle(国内 CDN,实测 ~3MB/s),
76
+ * 拿不到再回退;Linux / Windows 直接走官方源优先(见 downloadCloudflared 里的说明)。
77
+ * 兜底:官方 GitHub + 国内加速源(ghproxy.net / gh.ddlc.top / gh-proxy.com,2026-08
78
+ * 实测可达)。npmmirror(淘宝)没有 cloudflared 镜像(已实测 404)。
79
+ */
80
+ const CLOUDFLARED_MIRRORS = [
81
+ (asset) => `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`,
82
+ (asset) => `https://ghproxy.net/https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`,
83
+ (asset) => `https://gh.ddlc.top/https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`,
84
+ (asset) => `https://gh-proxy.com/https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`,
85
+ ];
86
+
87
+ const TUNA_BOTTLES = 'https://mirrors.tuna.tsinghua.edu.cn/homebrew-bottles/';
88
+
89
+ /** 多线程分块下载的并发段数(Windows 官方源单线程 ~200KB/s,8 并发 ≈ 1.6MB/s)。 */
90
+ const PARALLEL_SEGMENTS = 8;
91
+ /** 小于该字节数的文件不值得分块(直接用单线程)。 */
92
+ const MIN_PARALLEL_SIZE = 8 * 1024 * 1024;
93
+ /** 探针大小:单线程先下这么多测速。 */
94
+ const PROBE_SIZE = 2 * 1024 * 1024;
95
+ /** 探针测速阈值(bytes/ms):低于它认为慢网络,切多线程。300KB/s = 0.3。 */
96
+ const SLOW_SPEED_THRESHOLD = 0.3;
97
+
98
+ function hostOf(url) {
99
+ try { return new URL(url).host; } catch { return url; }
100
+ }
101
+
102
+ /** 合并多个分段文件为一个目标文件(顺序拼接后统一结束)。 */
103
+ async function mergeParts(partFiles, dest) {
104
+ const { createReadStream } = await import('node:fs');
105
+ const out = createWriteStream(dest);
106
+ try {
107
+ for (const f of partFiles) {
108
+ await new Promise((resolve, reject) => {
109
+ const rs = createReadStream(f);
110
+ rs.on('error', reject);
111
+ rs.pipe(out, { end: false });
112
+ rs.on('end', resolve);
113
+ });
114
+ }
115
+ } finally {
116
+ await new Promise((r) => out.end(r));
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 下载文件到 dest(自适应):
122
+ * 1. 服务器不支持 Range 或文件小 → 单线程;
123
+ * 2. 单线程下载探针(PROBE_SIZE)测速——速度够快 → 继续单线程(多线程在部分网络/
124
+ * 服务器上反而更慢,如 GitHub CDN 并发限速);
125
+ * 3. 探针速度低于阈值(典型慢网络,如 Windows 用户官方源 ~200KB/s)→ 丢弃探针,
126
+ * 改 8 段并发分块(可把 200KB/s 拉到 1.6MB/s)。
127
+ * 返回实际下载字节数。
128
+ */
129
+ export async function downloadFile(url, dest, { signal, segments = PARALLEL_SEGMENTS } = {}) {
130
+ // HEAD 探测:Content-Length + Accept-Ranges
131
+ let head = null;
132
+ try { head = await fetch(url, { method: 'HEAD', signal }); } catch { head = null; }
133
+ const len = head ? Number(head.headers.get('content-length') || 0) : 0;
134
+ const acceptsRanges = head ? String(head.headers.get('accept-ranges') || '').toLowerCase() === 'bytes' : false;
135
+
136
+ if (!head || !acceptsRanges || len < MIN_PARALLEL_SIZE) {
137
+ // 单线程
138
+ const res = await fetch(url, { signal });
139
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
140
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
141
+ return len || 0;
142
+ }
143
+
144
+ // 探针测速:单线程下载前 PROBE_SIZE,计时
145
+ const probeBytes = Math.min(PROBE_SIZE, len);
146
+ const probeStart = Date.now();
147
+ try {
148
+ const probeRes = await fetch(url, { signal, headers: { Range: `bytes=0-${probeBytes - 1}` } });
149
+ if (!probeRes.ok) throw new Error(`HTTP ${probeRes.status} (probe)`);
150
+ const probeBody = await probeRes.arrayBuffer();
151
+ const probeMs = Date.now() - probeStart;
152
+ const probeSpeed = probeMs > 0 ? probeBytes / probeMs : Infinity; // bytes/ms
153
+ if (probeMs < 500 || probeSpeed >= SLOW_SPEED_THRESHOLD) {
154
+ // 够快 → 单线程下完剩余部分(探针字节已拿到,写入 dest)
155
+ const { createWriteStream, createReadStream } = await import('node:fs');
156
+ const w = createWriteStream(dest);
157
+ await new Promise((resolve, reject) => {
158
+ w.on('error', reject);
159
+ w.write(Buffer.from(probeBody));
160
+ w.end(resolve);
161
+ });
162
+ const restRes = await fetch(url, { signal, headers: { Range: `bytes=${probeBytes}-${len - 1}` } });
163
+ if (!restRes.ok) throw new Error(`HTTP ${restRes.status} (rest)`);
164
+ await pipeline(Readable.fromWeb(restRes.body), createWriteStream(dest, { flags: 'a' }));
165
+ return len;
166
+ }
167
+ // 慢 → 丢弃探针,转分块并发(从 0 开始全量分块)
168
+ await rm(dest, { force: true }).catch(() => {});
169
+ } catch (err) {
170
+ await rm(dest, { force: true }).catch(() => {});
171
+ if (!/HTTP|fetch/i.test(String(err?.message ?? ''))) throw err; // 探针网络异常 → 抛给上层换源
172
+ // 探针 HTTP 错误(部分服务器 HEAD 与 GET 行为不一致)→ 直接分块
173
+ }
174
+
175
+ // 分块并发
176
+ const parts = [];
177
+ const chunk = Math.ceil(len / segments);
178
+ for (let i = 0; i < segments; i++) {
179
+ const start = i * chunk;
180
+ const end = i === segments - 1 ? len - 1 : Math.min(start + chunk - 1, len - 1);
181
+ if (start > end) break;
182
+ parts.push({ start, end, file: `${dest}.part${i}` });
183
+ }
184
+ try {
185
+ await Promise.all(parts.map(async (p) => {
186
+ const res = await fetch(url, { signal, headers: { Range: `bytes=${p.start}-${p.end}` } });
187
+ if (!res.ok) throw new Error(`HTTP ${res.status} (range ${p.start}-${p.end})`);
188
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(p.file));
189
+ }));
190
+ await mergeParts(parts.map((p) => p.file), dest);
191
+ } finally {
192
+ await Promise.all(parts.map((p) => rm(p.file, { force: true }).catch(() => {})));
193
+ }
194
+ return len;
195
+ }
196
+
197
+ /**
198
+ * 清华 TUNA 镜像的 cloudflared Homebrew bottle URL(国内 CDN,实测 ~3MB/s)。
199
+ * **仅 macOS**——Linux 的 Homebrew bottle 其 ELF 解释器是 `@@HOMEBREW_PREFIX@@`
200
+ * 占位符(需 brew install 时 patchelf 替换),没装 Homebrew 的机器直接 spawn 会
201
+ * ENOENT(issue #22);Linux 走官方 GitHub 裸二进制(无需解压)+ 加速源。
202
+ * 匹配按 CPU 架构取清华目录里版本号最新的 bottle——Homebrew 构建时部署目标
203
+ * 设得较老、向后兼容,所以旧系统(如 Ventura)也能用新一点的 bottle。
204
+ * 抓目录失败/无匹配 → null(调用方回退 GitHub/加速源,不影响可用性)。
205
+ */
206
+ async function tsinghuaBottleUrl({ os, a }) {
207
+ if (os !== 'darwin') return null;
208
+ let res;
209
+ try {
210
+ res = await fetch(TUNA_BOTTLES, { signal: AbortSignal.timeout(20_000) });
211
+ } catch { return null; }
212
+ if (!res.ok) return null;
213
+ let html;
214
+ try { html = await res.text(); } catch { return null; }
215
+ // macOS: arm64_<代号> 或 <代号>(Intel 无前缀),代号白名单排除 linux;Linux: arm64_linux / x86_64_linux
216
+ const MACOS_CODES = 'monterey|ventura|sonoma|sequoia|tahoe';
217
+ const pattern = os === 'darwin'
218
+ ? new RegExp(`cloudflared-([0-9.]+)\\.${a === 'arm64' ? 'arm64_' : ''}(${MACOS_CODES})\\.bottle\\.tar\\.gz`, 'g')
219
+ : new RegExp(`cloudflared-([0-9.]+)\\.${a === 'arm64' ? 'arm64' : 'x86_64'}_linux\\.bottle\\.tar\\.gz`, 'g');
220
+ let best = null;
221
+ let bestV = '';
222
+ for (const m of html.matchAll(pattern)) {
223
+ if (m[1] > bestV) { bestV = m[1]; best = m[0]; }
224
+ }
225
+ return best ? `${TUNA_BOTTLES}${best}` : null;
226
+ }
227
+
228
+ async function downloadCloudflared(binPath, signal) {
229
+ const { os, a, ext } = platformBinary();
230
+ const dir = dirname(binPath);
231
+ const tmpFile = join(dir, `cloudflared.download`);
232
+ const isWindows = os === 'windows';
233
+ const fetchSignal = signal
234
+ ? AbortSignal.any([signal, AbortSignal.timeout(120_000)])
235
+ : AbortSignal.timeout(120_000);
236
+
237
+ // 源的顺序按平台定(issue #45):
238
+ // - macOS:清华 Homebrew bottle 排第一(国内 CDN 实测 ~3MB/s,给家用网络省几分钟),
239
+ // 拿不到再退回官方源 + 国内加速源。
240
+ // - Linux / Windows:不走 bottle,直接官方源优先。这两类环境网络差异太大
241
+ // (服务器、容器、 corporate 网络),多一层镜像就多一层失败模式;官方源
242
+ // 拿不到时,再用国内加速源兜底。
243
+ // bottle 与资产名无关,只算一次。
244
+ const bottle = os === 'darwin' ? await tsinghuaBottleUrl({ os, a }).catch(() => null) : null;
245
+
246
+ // 逐个资产试(linux 首选裸二进制,.tgz 只是回退),每个资产再逐个源试
247
+ const assets = platformAssets();
248
+ let lastErr = null;
249
+ let usedAsset = null;
250
+
251
+ for (let ai = 0; ai < assets.length && usedAsset === null; ai++) {
252
+ const asset = assets[ai];
253
+ const sources = [];
254
+ if (bottle && asset.endsWith('.tgz')) {
255
+ sources.push({ url: bottle, host: 'mirrors.tuna.tsinghua.edu.cn' });
256
+ }
257
+ for (const m of CLOUDFLARED_MIRRORS) sources.push({ url: m(asset), host: hostOf(m(asset)) });
258
+
259
+ for (let i = 0; i < sources.length; i++) {
260
+ const { url, host } = sources[i];
261
+ console.log(`⬇️ 下载 cloudflared(${asset},源 ${i + 1}/${sources.length}:${host})…`);
262
+ try {
263
+ // 多线程分块(官方 GitHub 支持 Range,Windows 50MB 从几分钟降到几十秒);
264
+ // 不支持 Range 的源自动回退单线程
265
+ await downloadFile(url, tmpFile, { signal: fetchSignal });
266
+ // 简单校验:空文件/极小文件视为下载失败(可能是镜像返回了错误页)
267
+ const st = await stat(tmpFile);
268
+ if (st.size < 1024 * 1024) throw new Error(`文件异常小(${st.size} 字节),疑似镜像错误页`);
269
+ usedAsset = asset;
270
+ lastErr = null;
271
+ break; // 下载成功
272
+ } catch (err) {
273
+ lastErr = err;
274
+ await rm(tmpFile, { force: true }).catch(() => {}); // 清掉半截文件
275
+ console.warn(` ⚠️ 源 ${i + 1} 失败:${err?.message ?? err},尝试下一个…`);
276
+ }
277
+ }
278
+ }
279
+ if (usedAsset === null) {
280
+ throw new Error(
281
+ `cloudflared 下载失败:所有源都不通(最后错误:${lastErr?.message ?? lastErr})。`
282
+ + (isWindows
283
+ ? `Windows 可手动安装后重试:winget install cloudflared;或下载 ${assets[0]} 放到 ${dir} 目录 | download failed — try: winget install cloudflared, or put the exe into ${dir}`
284
+ : `也可以自己装好后在 settings.json 写 "cloudflaredPath": "/path/to/cloudflared" 跳过下载(或用环境变量 DSH_POCKET_CLOUDFLARED);或用包管理器安装:apt/dnf install cloudflared | all mirrors failed — install cloudflared manually and set "cloudflaredPath" in settings.json, or: apt/dnf install cloudflared`),
285
+ );
286
+ }
287
+
288
+ let extracted = join(dir, `cloudflared${ext}`);
289
+ // 只有 .tgz 才需要解压:Windows 的 .exe 和 linux 的裸二进制本身就可直接执行
290
+ // (linux 用裸二进制还有个好处——不依赖系统装了 tar,见 platformAssets 的说明)
291
+ if (!usedAsset.endsWith('.tgz')) {
292
+ await rename(tmpFile, extracted).catch(async () => {
293
+ await cp(tmpFile, extracted).catch(() => {});
294
+ });
295
+ } else {
296
+ // 解压到独立临时子目录(bottle 解压产物会占用 cacheDir/cloudflared 这个名字,
297
+ // 直接解压到 dir 会让目标路径变成目录,rename 失败)
298
+ const extractDir = join(dir, `.extract-${process.pid}-${Date.now()}`);
299
+ await mkdir(extractDir, { recursive: true });
300
+ try {
301
+ await new Promise((resolve, reject) => {
302
+ const child = spawn('tar', ['-xzf', tmpFile, '-C', extractDir], { stdio: 'ignore' });
303
+ child.once('exit', (code) => code === 0 ? resolve() : reject(new Error(`cloudflared 解压失败(code=${code})`)));
304
+ // spawn 失败(Alpine / slim 镜像 / 容器里常见:根本没有 tar)时 err.code 是
305
+ // ENOENT,直接抛出来只有一句 "spawn tar ENOENT",看不出该怎么办
306
+ child.once('error', (err) => reject(
307
+ err?.code === 'ENOENT'
308
+ ? new Error(`系统里没有 tar 命令,无法解压 ${usedAsset} —— 可改用 linux 裸二进制(默认已如此)或手动安装后设置 cloudflaredPath | no tar on this system`)
309
+ : err,
310
+ ));
311
+ });
312
+ // 找真实的二进制**文件**(排除目录):
313
+ // - GitHub tgz:extractDir/cloudflared
314
+ // - Homebrew bottle(清华):extractDir/cloudflared/<版本>/bin/cloudflared
315
+ const { readdir } = await import('node:fs/promises');
316
+ let found = null;
317
+ const direct = join(extractDir, `cloudflared${ext}`);
318
+ try { if ((await stat(direct)).isFile()) found = direct; } catch { /* 不存在 */ }
319
+ if (!found) {
320
+ const verDir = join(extractDir, 'cloudflared');
321
+ try {
322
+ const vers = await readdir(verDir);
323
+ for (const v of vers) {
324
+ const bin = join(verDir, v, 'bin', `cloudflared${ext}`);
325
+ try { if ((await stat(bin)).isFile()) { found = bin; break; } } catch { /* 继续 */ }
326
+ }
327
+ } catch { /* 无此目录 */ }
328
+ }
329
+ if (!found) throw new Error('cloudflared 解压成功但未找到二进制 | binary not found after extract');
330
+ if (found !== extracted) {
331
+ await rename(found, extracted).catch(async () => { await cp(found, extracted).catch(() => {}); });
332
+ }
333
+ } finally {
334
+ await rm(extractDir, { recursive: true, force: true }).catch(() => {});
335
+ }
336
+ }
337
+ if (!isWindows) await chmod(extracted, 0o755);
338
+ // 解压/搬移完成就删掉临时下载文件,避免长期占用缓存目录
339
+ await rm(tmpFile, { force: true }).catch(() => {});
340
+ return extracted;
341
+ }
342
+
343
+ /** PATH 里是否已有 cloudflared。 */
344
+ function cloudflaredOnPath() {
345
+ try {
346
+ execSync(process.platform === 'win32' ? 'where cloudflared' : 'command -v cloudflared', { stdio: 'ignore' });
347
+ return true;
348
+ } catch {
349
+ return false;
350
+ }
351
+ }
352
+
353
+ /** in-flight 下载(单飞):并发调用复用同一次,防止交错写入损坏 tgz。 */
354
+ let downloading = null;
355
+
356
+ /**
357
+ * 拿一个可用的 cloudflared 路径。
358
+ * 优先:PATH 已有 → 直接用;否则用持久缓存($DSH_HOME/dsh-pocket/cloudflared),
359
+ * 只有缓存缺失才下载——避免每次开启公网都重新下 20MB。
360
+ */
361
+ export { tsinghuaBottleUrl };
362
+
363
+ export async function resolveCloudflared({ home, onPhase = () => {}, signal } = {}) {
364
+ // 自定义路径(issue #45):用户可能在 settings.json 写了 cloudflaredPath,
365
+ // 或者在外部注入 process.env.DSH_POCKET_CLOUDFLARED;命中就直接用,绕过
366
+ // PATH 探测与下载(远程 Linux 服务器上下载源不可达时的兜底)。
367
+ const explicit = process.env.DSH_POCKET_CLOUDFLARED;
368
+ if (explicit) {
369
+ try {
370
+ await access(explicit);
371
+ return explicit;
372
+ } catch {
373
+ throw new Error(
374
+ `DSH_POCKET_CLOUDFLARED 指向的路径不可执行:${explicit} | cloudflaredPath is set but not accessible: ${explicit}`,
375
+ );
376
+ }
377
+ }
378
+ if (cloudflaredOnPath()) return 'cloudflared';
379
+ const dshHome = home ?? process.env.DSH_HOME ?? join(homedir(), '.dsh');
380
+ const cacheDir = join(dshHome, 'dsh-pocket', 'bin');
381
+ const { os, a, ext } = platformBinary();
382
+ // 缓存命中,兼容两种文件名(issue #15):
383
+ // 1) 本插件下载时写入的 bin 名:cloudflared.exe
384
+ // 2) 手动放置的**发布资产名**:cloudflared-windows-amd64.exe(与下载失败的错误提示一致)
385
+ const candidates = [
386
+ join(cacheDir, `cloudflared${ext}`),
387
+ join(cacheDir, `cloudflared-${os}-${a}${ext}`),
388
+ ];
389
+ for (const bin of candidates) {
390
+ try {
391
+ await access(bin);
392
+ // Linux:识别并丢弃 Homebrew bottle 坏缓存(issue #22)——其 ELF 解释器是
393
+ // @@HOMEBREW_PREFIX@@ 占位符,直接 spawn 报 ENOENT;读文件头(解释器路径在
394
+ // ELF 头部附近)即可识别,命中则删掉走重新下载
395
+ if (os === 'linux') {
396
+ try {
397
+ const fd = await open(bin, 'r');
398
+ const head = Buffer.alloc(8192);
399
+ await fd.read(head, 0, 8192, 0);
400
+ await fd.close();
401
+ if (head.includes('@@HOMEBREW_PREFIX@@')) {
402
+ await rm(bin, { force: true }).catch(() => {});
403
+ console.warn('dsh-pocket-relay: discarding unusable Homebrew-bottle cloudflared cache | 丢弃不可用的 Homebrew bottle 缓存,重新下载');
404
+ continue;
405
+ }
406
+ } catch { /* 读失败按正常缓存处理 */ }
407
+ }
408
+ return bin; // 缓存命中,秒开
409
+ } catch { /* 继续找下一个 */ }
410
+ }
411
+ onPhase('downloading');
412
+ await mkdir(cacheDir, { recursive: true });
413
+ if (!downloading) {
414
+ downloading = downloadCloudflared(join(cacheDir, `cloudflared${ext}`), signal).finally(() => { downloading = null; });
415
+ }
416
+ return downloading;
417
+ }
418
+
419
+ /**
420
+ * 启动 cloudflared 快速隧道,返回公网 URL。
421
+ * @param {object} opts
422
+ * @param {number} opts.port 本机代理端口
423
+ * @param {string} [opts.home] $DSH_HOME(cloudflared 持久缓存)
424
+ * @param {AbortSignal} [opts.signal]
425
+ * @param {(phase:string)=>void} [opts.onPhase] 进度回调:downloading→starting→registering→ready
426
+ * @returns {Promise<{url:string, kill:()=>void}>}
427
+ */
428
+ /**
429
+ * 启动 cloudflared 命名隧道(issue #66:固定公网域名)。
430
+ *
431
+ * 用户在 Cloudflare Zero Trust 后台创建 Tunnel、把域名 ingress 的 Service 指向
432
+ * `http://127.0.0.1:<port>`,复制 Tunnel Token 填进设置页。与快速隧道的区别:
433
+ * - Token 走 `TUNNEL_TOKEN` **环境变量**(不进 argv——长期凭据不该出现在
434
+ * 进程列表/崩溃日志里,issue #66 讨论中的一致意见);
435
+ * - URL 固定为用户绑定的域名(cloudflared 输出不打印它),由调用方拼 `https://<域名>`,
436
+ * 所以这里返回 `url: null`;
437
+ * - 就绪判据是 stderr 出现 `Registered tunnel connection`(边缘连接注册成功即开始服务)。
438
+ * @param {object} opts
439
+ * @param {string} opts.token Cloudflare Tunnel Token
440
+ * @param {string} [opts.home] $DSH_HOME(cloudflared 持久缓存)
441
+ * @param {AbortSignal} [opts.signal]
442
+ * @param {(phase:string)=>void} [opts.onPhase] 进度回调:downloading→starting→registering→ready
443
+ * @returns {Promise<{url:null, kill:()=>void, onExit:(cb)=>()=>void}>}
444
+ */
445
+ export async function startNamedTunnel({ token, home, signal, onPhase = () => {} }) {
446
+ const bin = await resolveCloudflared({ home, onPhase, signal });
447
+ onPhase('starting');
448
+ // 与快速隧道一致强制 HTTP/2(国内/企业网常屏蔽 UDP 7844 → error 1033)
449
+ // 与快速隧道一致强制 HTTP/2(国内/企业网常屏蔽 UDP 7844 → error 1033)
450
+ // `--no-autoupdate` 必须在全局位置(子命令之前):cloudflared 2026.x 移除了
451
+ // `tunnel run` 子命令层级的该 flag,但全局位置仍有效(issue #78)
452
+ const child = spawn(bin, ['--no-autoupdate', 'tunnel', 'run', '--protocol', 'http2'], {
453
+ stdio: ['ignore', 'pipe', 'pipe'],
454
+ env: { ...process.env, TUNNEL_TOKEN: String(token ?? '') },
455
+ });
456
+ let cleanup = null;
457
+ let rejectErr = null;
458
+ // H1:spawn 失败(缓存二进制损坏等)必须接住,否则 uncaughtException 崩宿主
459
+ child.on('error', (err) => {
460
+ cleanup?.();
461
+ onPhase?.('error');
462
+ rejectErr?.(new Error(`cloudflared 启动失败:${err?.message ?? err}(可删除 $DSH_HOME/dsh-pocket/bin 缓存后重试)`));
463
+ });
464
+ onPhase('registering');
465
+
466
+ await new Promise((resolve, reject) => {
467
+ let buf = '';
468
+ const onData = (chunk) => {
469
+ buf += String(chunk);
470
+ // 边缘连接注册成功即开始服务(每条连接一行;等第一条就够)
471
+ if (/Registered tunnel connection/i.test(buf)) {
472
+ cleanup();
473
+ onPhase('ready');
474
+ resolve();
475
+ }
476
+ };
477
+ const onExit = (code) => {
478
+ cleanup();
479
+ const tail = firstMeaningfulErrorLine(buf);
480
+ reject(new Error(
481
+ `cloudflared 退出(code=${code})${tail ? ':' + tail : ''}——请检查 Tunnel Token 是否有效、域名 Service 是否指向本机代理端口 | `
482
+ + `tunnel exited (code=${code})${tail ? ': ' + tail : ''} — check the Tunnel Token and the ingress hostname`,
483
+ ));
484
+ };
485
+ cleanup = () => {
486
+ child.stdout.off('data', onData);
487
+ child.stderr.off('data', onData);
488
+ child.off('exit', onExit);
489
+ clearTimeout(timer);
490
+ signal?.removeEventListener('abort', onAbort);
491
+ // M4:摘掉监听后管道不再消费 → 64KB 缓冲填满会阻塞 cloudflared → 继续吞掉输出
492
+ child.stdout.resume();
493
+ child.stderr.resume();
494
+ };
495
+ const onAbort = () => {
496
+ cleanup();
497
+ child.kill();
498
+ reject(new Error('已取消 | cancelled'));
499
+ };
500
+ const timer = setTimeout(() => {
501
+ cleanup();
502
+ child.kill();
503
+ reject(new Error(
504
+ 'cloudflared 启动超时(30s)——请检查 Tunnel Token 是否有效、域名 Service 是否指向本机代理端口,'
505
+ + '以及是否开着代理/VPN(Clash 等 TUN 模式会掐断隧道连接) | timeout — check the token, the ingress hostname, and quit any proxy/VPN (TUN mode)',
506
+ ));
507
+ }, 30_000);
508
+
509
+ child.stdout.on('data', onData);
510
+ child.stderr.on('data', onData);
511
+ child.once('exit', onExit);
512
+ signal?.addEventListener('abort', onAbort, { once: true });
513
+ rejectErr = reject;
514
+ });
515
+
516
+ // M1:隧道进程运行中死亡(崩溃/被杀)→ 通知监听方(service 据此把状态从 ready 打回)
517
+ const exitListeners = new Set();
518
+ child.on('exit', (code) => {
519
+ for (const cb of exitListeners) cb(code);
520
+ });
521
+
522
+ return {
523
+ url: null, // 固定域名由调用方(service)按设置拼 https://<hostname>
524
+ kill: () => {
525
+ try { child.kill(); } catch { /* 忽略 */ }
526
+ },
527
+ /** 注册「进程已退出」回调,返回取消函数。 */
528
+ onExit: (cb) => {
529
+ exitListeners.add(cb);
530
+ return () => exitListeners.delete(cb);
531
+ },
532
+ };
533
+ }
534
+
535
+ export async function startQuickTunnel({ port, home, signal, onPhase = () => {} }) {
536
+ const bin = await resolveCloudflared({ home, onPhase, signal });
537
+ onPhase('starting');
538
+ // 强制 HTTP/2(TCP 443)而不是默认的 QUIC(UDP 7844):
539
+ // 国内网络/部分企业网常屏蔽 UDP 7844,导致 tunnel 报 error 1033(Tunnel error);
540
+ // HTTP/2 走 443 更稳。若平台未来恢复 QUIC 可达,可去掉 --protocol http2。
541
+ // `--no-autoupdate` 必须在全局位置(子命令之前,见 issue #78 同款修复)
542
+ const child = spawn(bin, ['--no-autoupdate', 'tunnel', '--url', `http://127.0.0.1:${port}`, '--protocol', 'http2'], {
543
+ stdio: ['ignore', 'pipe', 'pipe'],
544
+ });
545
+ // H1:spawn 失败(缓存二进制损坏等)必须接住,否则 uncaughtException 崩宿主
546
+ child.on('error', (err) => {
547
+ cleanup?.();
548
+ onPhase?.('error');
549
+ rejectErr?.(new Error(`cloudflared 启动失败:${err?.message ?? err}(可删除 $DSH_HOME/dsh-pocket/bin 缓存后重试)`));
550
+ });
551
+ onPhase('registering');
552
+
553
+ let cleanup = null;
554
+ let rejectErr = null;
555
+ const url = await new Promise((resolve, reject) => {
556
+ let buf = '';
557
+ const onData = (chunk) => {
558
+ buf += String(chunk);
559
+ const m = buf.match(QUICK_TUNNEL_URL_RE);
560
+ if (m) {
561
+ cleanup();
562
+ onPhase('ready');
563
+ resolve(m[0]);
564
+ }
565
+ };
566
+ const onExit = (code) => {
567
+ cleanup();
568
+ // 带上 cloudflared 自己的输出(参数错误显示开头、运行期错误显示尾部,见 firstMeaningfulErrorLine),
569
+ // 否则「code=1」用户无从排查(issue #65 / #78)
570
+ const tail = firstMeaningfulErrorLine(buf);
571
+ reject(new Error(`cloudflared 退出(code=${code})${tail ? ':' + tail : ''}`));
572
+ };
573
+ cleanup = () => {
574
+ child.stdout.off('data', onData);
575
+ child.stderr.off('data', onData);
576
+ child.off('exit', onExit);
577
+ clearTimeout(timer);
578
+ signal?.removeEventListener('abort', onAbort);
579
+ // M4:摘掉监听后管道不再消费 → 64KB 缓冲填满会阻塞 cloudflared → 继续吞掉输出
580
+ child.stdout.resume();
581
+ child.stderr.resume();
582
+ };
583
+ const onAbort = () => {
584
+ cleanup();
585
+ child.kill();
586
+ reject(new Error('已取消 | cancelled'));
587
+ };
588
+ const timer = setTimeout(() => {
589
+ cleanup();
590
+ child.kill();
591
+ reject(new Error(
592
+ 'cloudflared 启动超时(30s)——请检查是否开着代理/VPN(Clash 等 TUN 模式会掐断隧道连接),退出代理后重试 | '
593
+ + 'timeout — if you run a proxy/VPN (Clash etc., TUN mode), it can block the tunnel; quit it and retry',
594
+ ));
595
+ }, 30_000);
596
+
597
+ child.stdout.on('data', onData);
598
+ child.stderr.on('data', onData);
599
+ child.once('exit', onExit);
600
+ signal?.addEventListener('abort', onAbort, { once: true });
601
+ rejectErr = reject;
602
+ });
603
+
604
+ // M1:隧道进程运行中死亡(崩溃/被杀)→ 通知监听方(service 据此把状态从 ready 打回)
605
+ const exitListeners = new Set();
606
+ child.on('exit', (code) => {
607
+ for (const cb of exitListeners) cb(code);
608
+ });
609
+
610
+ return {
611
+ url,
612
+ kill: () => {
613
+ try { child.kill(); } catch { /* 忽略 */ }
614
+ },
615
+ /** 注册「进程已退出」回调,返回取消函数。 */
616
+ onExit: (cb) => {
617
+ exitListeners.add(cb);
618
+ return () => exitListeners.delete(cb);
619
+ },
620
+ };
621
+ }