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.
@@ -0,0 +1,578 @@
1
+ // dsh-pocket 服务:在 dsh web 进程内跑改头代理 + 公网隧道
2
+ //
3
+ // - 代理:监听 0.0.0.0:<port>(默认 3081),把入站 Host/Origin 改写成
4
+ // 127.0.0.1:<dshPort>(dsh web 实际端口),HTTP + WebSocket 全透传。
5
+ // 这样 DSH 的 /api 浏览器信任栅栏永远看到 loopback,局域网/公网都能进,
6
+ // 且不需要改 dsh 的任何配置(0.0.0.0 绑定被 dsh 官方禁用)。
7
+ // - 隧道:cloudflared 快速隧道(可选),公网 https URL,供人在外面访问。
8
+
9
+ import { networkInterfaces } from 'node:os';
10
+ import { createRequire } from 'node:module';
11
+ import { readFileSync } from 'node:fs';
12
+ import { execFile } from 'node:child_process';
13
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
14
+ import { join, dirname } from 'node:path';
15
+ import { createPocketProxy, DEFAULT_INJECT } from './proxy.mjs';
16
+ import { startQuickTunnel, startNamedTunnel } from './tunnel.mjs';
17
+ import { createRelayAgent, localAgentId } from './relay.mjs';
18
+ import { createDeviceAuth } from './device-auth.mjs';
19
+ import { createDeviceAuthGateway } from './device-auth-http.mjs';
20
+ import { isValidIpv4 } from './ip.mjs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+
24
+ /** URL → 二维码 data URL(浏览器 <img> 直接显示,全本地不依赖第三方)。 */
25
+ export async function qrDataUrl(text, { width = 220, margin = 1 } = {}) {
26
+ const QRCode = require('qrcode');
27
+ return QRCode.toDataURL(text, { errorCorrectionLevel: 'M', margin, width, type: 'image/png' });
28
+ }
29
+
30
+ // RFC1918 私网地址:手机与电脑连同一局域网时通常可直连。
31
+ // 另含 CGNAT 100.64/10(RFC 6598,Tailscale/ZeroTier 默认网段,公网不可路由),保持一致(issue #79)。
32
+ const PRIVATE_IPV4_RE = /^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|100\.(?:6[4-9]|[7-9]\d|1(?:0\d|1\d|2[0-7]))\.)/;
33
+
34
+ /** 名称像真实物理网卡的接口(WLAN / Wi-Fi / Ethernet / 以太网 / 有线 / 无线 / en / eth)。 */
35
+ const PHYSICAL_IFACE_RE = /^(?:wlan|wi-?fi|wireless|ethernet|eth\d|en\d|wlp\d|以太网|有线|无线|本地连接)/i;
36
+
37
+ /** 常见的 VPN / 虚拟网卡名称:手机通常无法通过它们直连电脑。 */
38
+ const VPN_IFACE_RE = /(?:radmin|tailscale|zerotier|easytier|et_|tun|tap|vpn|vethernet|virtual|vmware|virtualbox|wsl|docker|teredo|hamachi|bluetooth|bridge)/i;
39
+
40
+ /**
41
+ * 从 networkInterfaces() 返回的接口表里选出手机最可能可达的 IPv4。
42
+ *
43
+ * `os.networkInterfaces()` 的枚举顺序不可靠:Windows 上 Radmin VPN / Tailscale /
44
+ * vEthernet 等虚拟网卡常排在 WLAN 前面,旧实现直接取第一张非回环网卡,会生成
45
+ * 手机打不开的二维码。这里按以下规则打分排序:
46
+ * - RFC1918 私网地址优先(10/8、172.16/12、192.168/16);
47
+ * - 名称像物理网卡再加分;
48
+ * - 名称像 VPN/虚拟网卡减分;
49
+ * - 同分保持原枚举顺序。
50
+ * 没有任何私网地址时回退到最高分地址(例如纯 VPN 环境仍可用)。
51
+ *
52
+ * @param {ReturnType<typeof networkInterfaces>} interfaces
53
+ * @returns {string|null}
54
+ */
55
+ export function selectLanIPv4(interfaces) {
56
+ const candidates = [];
57
+ for (const [name, addrs] of Object.entries(interfaces ?? {})) {
58
+ for (const addr of addrs ?? []) {
59
+ if (addr.family !== 'IPv4' || addr.internal) continue;
60
+ const ip = addr.address;
61
+ // 排除 loopback 与 link-local;其余地址即使不是私网(如 Radmin 的 26.x)也保留兜底
62
+ if (!ip || ip.startsWith('127.') || ip.startsWith('169.254.')) continue;
63
+
64
+ let score = 0;
65
+ if (PRIVATE_IPV4_RE.test(ip)) score += 100;
66
+ if (PHYSICAL_IFACE_RE.test(name)) score += 20;
67
+ else if (VPN_IFACE_RE.test(name)) score -= 50;
68
+
69
+ candidates.push({ ip, score, order: candidates.length });
70
+ }
71
+ }
72
+
73
+ candidates.sort((a, b) => b.score - a.score || a.order - b.order);
74
+ return candidates[0]?.ip ?? null;
75
+ }
76
+
77
+ // ---------- WSL 局域网 IP(issue #39) ----------
78
+ // WSL2 是 NAT 模式:WSL 内部 os.networkInterfaces() 只能看到自己的虚拟网卡
79
+ // (172.x.x.x),看不到 Windows 宿主机的物理网卡 IP(192.168.x.x)——手机在
80
+ // 同一 WiFi 下访问的是 Windows 宿主机,拿 WSL 的 IP 生成的二维码必然打不开。
81
+ // 解法:检测到 WSL 时,通过 WSL interop 直接执行 Windows 的 ipconfig.exe,
82
+ // 解析出 Windows 侧非虚拟网卡的 IPv4 作为局域网地址;失败回退本机探测。
83
+
84
+ /** WSL 检测:/proc/version 含 microsoft/wsl,或 WSL 专属环境变量存在。 */
85
+ export function detectWsl() {
86
+ try {
87
+ const v = readFileSync('/proc/version', 'utf8').toLowerCase();
88
+ if (v.includes('microsoft') || v.includes('wsl')) return true;
89
+ } catch { /* 非 Linux:无 /proc/version */ }
90
+ // 注意:**不能**用 WSLENV 判据——Windows Terminal 在原生 Windows 上也会设置
91
+ // WSLENV(如 WT_SESSION:WT_PROFILE_ID:),会误判成 WSL。只认 WSL 内部才有的变量。
92
+ return Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
93
+ }
94
+
95
+ /**
96
+ * 解析 ipconfig.exe 输出,默认取非虚拟网卡块的 IPv4 地址(保持输出顺序)。
97
+ * 支持中文(`IPv4 地址 . . . :`)与英文(`IPv4 Address. . . :`)两种格式。
98
+ * @param {string} text ipconfig.exe 的完整输出
99
+ * @param {{ includeVpn?: boolean }} [opts] 传 includeVpn 时保留 Tailscale/VPN 等候选
100
+ * @returns {string[]} 候选 IPv4 列表
101
+ */
102
+ export function parseIpconfig(text, { includeVpn = false } = {}) {
103
+ const out = [];
104
+ // 网卡块:块标题行顶格(行首无缩进),其后内容行带缩进
105
+ const blocks = String(text).split(/\r?\n(?=\S)/);
106
+ const ipRe = /IPv4[^0-9]{0,40}((?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)/;
107
+ for (const block of blocks) {
108
+ const title = String(block.split(/\r?\n/)[0] ?? '');
109
+ // 跳过虚拟网卡块(vEthernet (WSL)、Docker、VirtualBox、VPN 等)
110
+ if (!includeVpn && VPN_IFACE_RE.test(title)) continue;
111
+ const m = block.match(ipRe);
112
+ if (m) out.push(m[0].replace(/^IPv4[^0-9]*/i, ''));
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function runIpconfig() {
118
+ // WSL 内 PATH 可能不含 Windows System32;用绝对路径兜底
119
+ const candidates = ['ipconfig.exe', '/mnt/c/Windows/System32/ipconfig.exe'];
120
+ return new Promise((resolve) => {
121
+ const tryNext = (i) => {
122
+ if (i >= candidates.length) return resolve(null);
123
+ execFile(candidates[i], [], { timeout: 5000, windowsHide: true }, (err, stdout) => {
124
+ if (err || !stdout) return tryNext(i + 1);
125
+ resolve(String(stdout));
126
+ });
127
+ };
128
+ tryNext(0);
129
+ });
130
+ }
131
+
132
+ async function lanIPv4() {
133
+ // WSL:优先 Windows 物理网卡 IP(手机可达);超时/失败回退本机探测
134
+ if (detectWsl()) {
135
+ try {
136
+ const out = await runIpconfig();
137
+ const candidates = parseIpconfig(out ?? '');
138
+ const ip = candidates.find((c) => PRIVATE_IPV4_RE.test(c)) ?? candidates[0];
139
+ if (ip) return ip;
140
+ } catch { /* 回退 */ }
141
+ }
142
+ return selectLanIPv4(networkInterfaces());
143
+ }
144
+
145
+ /** 收集所有可手动选择的局域网/Tailnet 候选 IP(WSL 下以 Windows ipconfig 为准)。 */
146
+ async function listLanCandidates() {
147
+ if (detectWsl()) {
148
+ try {
149
+ const out = await runIpconfig();
150
+ const ips = parseIpconfig(out ?? '', { includeVpn: true });
151
+ if (ips.length) return [...new Set(ips)];
152
+ } catch { /* 回退 */ }
153
+ }
154
+ const ips = [];
155
+ for (const [name, addrs] of Object.entries(networkInterfaces())) {
156
+ for (const addr of addrs ?? []) {
157
+ if (addr.family !== 'IPv4' || addr.internal) continue;
158
+ const ip = addr.address;
159
+ if (!ip || ip.startsWith('127.') || ip.startsWith('169.254.')) continue;
160
+ if (!ips.includes(ip)) ips.push(ip);
161
+ }
162
+ }
163
+ return ips;
164
+ }
165
+
166
+ /** 去掉 Host 的端口([::1]:3081 → ::1;host:3081 → host),与 classifyHost 同款处理。 */
167
+ function hostNameOnly(host) {
168
+ let name = String(host ?? '').trim().toLowerCase();
169
+ if (name.startsWith('[')) {
170
+ const end = name.indexOf(']');
171
+ if (end >= 0) name = name.slice(1, end);
172
+ } else {
173
+ name = name.replace(/:\d+$/, '');
174
+ }
175
+ return name;
176
+ }
177
+
178
+ /** 从对外访问地址里取出主机名(解析失败返回空串 = 不匹配任何 Host)。 */
179
+ function publicHostOf(url) {
180
+ try {
181
+ return new URL(String(url ?? '')).hostname.toLowerCase();
182
+ } catch {
183
+ return '';
184
+ }
185
+ }
186
+
187
+ /** 对外访问地址本身(去掉尾部斜杠),用于二维码与设备配对链接。 */
188
+ function publicHostUrl(url) {
189
+ const v = String(url ?? '').trim();
190
+ if (!v) return '';
191
+ try {
192
+ return new URL(v).origin;
193
+ } catch {
194
+ return '';
195
+ }
196
+ }
197
+
198
+ /**
199
+ * 创建 Pocket 服务。
200
+ * @param {object} opts
201
+ * @param {number} opts.dshPort dsh web 实际端口(从 ctx.webServer.port 取)
202
+ * @param {number} [opts.port] 代理端口(默认 3081)
203
+ * @param {object} [opts.internals] 测试注入:createProxy / startTunnel / lanIPv4
204
+ * @returns {PocketService}
205
+ */
206
+ export function createPocketService({
207
+ dshPort,
208
+ port = 3081,
209
+ home,
210
+ internals = {},
211
+ /** 局域网地址手动覆盖:返回 IPv4 字符串;空值表示自动选择 */
212
+ getLanIpOverride,
213
+ /** 局域网访问总开关:返回 boolean;默认开启。关闭后代理拒绝局域网 Host 请求 */
214
+ getLanEnabled = () => true,
215
+ /** 代理注入 HTML 的内容(桌面端补丁等由 lib/index.js 传入;默认 randomUUID polyfill) */
216
+ injectHtml,
217
+ /** 访问令牌认证配置(issue #13):{ getToken, isProtected },传给代理 */
218
+ auth,
219
+ /** @type {() => string} dsh web 浏览器会话启动 token(issue #77;老版本返回空字符串) */
220
+ launchToken = () => '',
221
+ /** 隧道配置(issue #66):() => ({ mode:'quick'|'named', token, hostname });named 用固定域名 */
222
+ getTunnelConfig,
223
+ /** 隧道就绪回调(lib/index.js 用它轮换公网密码;参数为 'quick'|'named') */
224
+ onTunnelReady,
225
+ /** 中继配置:() => ({ enabled, consent, host, port, tls, allowInsecure, token, publicUrl }) */
226
+ getRelayConfig,
227
+ /** 日志(DSH 宿主 ctx.logger 有 info/warn/error;默认 console 无 info,自动退回 log)。
228
+ * 隧道自动恢复的成败必须走这里:console 输出在 DSH Desktop 里不进日志文件,
229
+ * 出问题时无法排查(2026-09-07 实测教训)。 */
230
+ log = console,
231
+ } = {}) {
232
+ // ctx.logger 形状是 info/warn/error;console 只有 log/warn/error——info 做兜底兼容
233
+ const logInfo = (...args) => (log.info ?? log.log).call(log, ...args);
234
+ const logWarn = (...args) => log.warn(...args);
235
+ const createProxy = internals.createProxy ?? createPocketProxy;
236
+ const startTunnel = internals.startTunnel ?? startQuickTunnel;
237
+ const startNamed = internals.startNamedTunnel ?? startNamedTunnel;
238
+ const getLanOverride = () => {
239
+ const value = String(getLanIpOverride?.() ?? '').trim();
240
+ return isValidIpv4(value) ? value : '';
241
+ };
242
+ const getLan = async () => getLanOverride() || (internals.lanIPv4 ? internals.lanIPv4() : lanIPv4());
243
+ let lanCandidateCache = null;
244
+ const getLanCandidates = async () => {
245
+ if (internals.lanCandidates) return internals.lanCandidates();
246
+ const now = Date.now();
247
+ if (!lanCandidateCache || now - lanCandidateCache.at > 15000) {
248
+ lanCandidateCache = { at: now, ips: await listLanCandidates() };
249
+ }
250
+ return lanCandidateCache.ips;
251
+ };
252
+
253
+ let proxy = null;
254
+ let tunnel = null;
255
+ let tunnelAbort = null;
256
+ /** 中继 agent(自建 relay 通道)。配置好即常驻,与隧道生命周期完全独立。 */
257
+ let relay = null;
258
+ const createRelay = internals.createRelayAgent ?? createRelayAgent;
259
+
260
+ // ---------- 设备认证(relay 通道专用) ----------
261
+ // 网关常驻,但**是否生效按每个请求当时的 relay 配置判定**:用户改了对外地址
262
+ // 或关掉中继,下一次请求就换行为,不需要重启代理。
263
+ const readRelayCfg = () => (typeof getRelayConfig === 'function' ? (getRelayConfig() ?? {}) : {});
264
+ const deviceAuthStore = internals.deviceAuth ?? createDeviceAuth();
265
+ const deviceGateway = internals.deviceGateway ?? createDeviceAuthGateway({
266
+ auth: deviceAuthStore,
267
+ isDeviceHost: (host) => {
268
+ const cfg = readRelayCfg();
269
+ if (cfg.enabled !== true) return false;
270
+ const want = publicHostOf(cfg.publicUrl);
271
+ return want !== '' && hostNameOnly(host) === want;
272
+ },
273
+ publicUrl: () => readRelayCfg().publicUrl ?? '',
274
+ log,
275
+ });
276
+ /** in-flight 隧道启动(单飞):并发调用复用同一次,避免 spawn 多个 cloudflared 孤儿进程 */
277
+ let tunnelPromise = null;
278
+ /** 隧道进度:{ phase: idle|downloading|starting|registering|ready|error, detail, startedAt } */
279
+ const tunnelState = { phase: 'idle', detail: '', startedAt: null };
280
+ /** 二维码缓存:URL → data URL promise。status() 每 3 秒轮询一次,不能每次都重新生成(CPU 密集)。 */
281
+ const qrCache = new Map();
282
+ const encodeQr = internals.encodeQr ?? qrDataUrl;
283
+ async function qrCached(text) {
284
+ if (!text) return null;
285
+ if (!qrCache.has(text)) {
286
+ if (qrCache.size >= 8) {
287
+ // 只淘汰最旧一条(隧道 URL 每次重启换新),别殃及稳定的 LAN 二维码
288
+ const oldest = qrCache.keys().next().value;
289
+ qrCache.delete(oldest);
290
+ }
291
+ qrCache.set(text, encodeQr(text).catch(() => null));
292
+ }
293
+ return qrCache.get(text);
294
+ }
295
+
296
+ // 公网隧道自动恢复(issue #11):DSH 重启后 cloudflared 子进程被杀、隧道消失,
297
+ // 插件无从知晓。启动时检查持久化的「隧道开启中」标记,自动重新拉起。
298
+ const autoStatePath = home ? join(home, 'dsh-pocket', 'tunnel-auto.json') : null;
299
+ // 写入与删除必须串行:隧道刚就绪就立即关闭时,异步写不能晚于删除重新落盘,
300
+ // 否则标记被晚到的写操作复活,下次启动误恢复(writeFile 与 rm 的完成顺序不保证)。
301
+ let autoStateQueue = Promise.resolve();
302
+ function persistAutoTunnel() {
303
+ if (!autoStatePath) return;
304
+ autoStateQueue = autoStateQueue.then(async () => {
305
+ try {
306
+ await mkdir(dirname(autoStatePath), { recursive: true });
307
+ await writeFile(autoStatePath, JSON.stringify({ at: Date.now() }), 'utf8');
308
+ } catch { /* 忽略 */ }
309
+ });
310
+ return autoStateQueue;
311
+ }
312
+ function clearAutoTunnel() {
313
+ if (!autoStatePath) return;
314
+ autoStateQueue = autoStateQueue.then(async () => {
315
+ try { await rm(autoStatePath, { force: true }); } catch { /* 忽略 */ }
316
+ });
317
+ return autoStateQueue;
318
+ }
319
+
320
+ return {
321
+ dshPort,
322
+ /** 启动局域网代理(幂等)。端口被占(EADDRINUSE,如桌面版与普通环境同时运行)时自动尝试下一个端口。 */
323
+ async startProxy() {
324
+ if (proxy) return proxy;
325
+ let lastErr = null;
326
+ for (let p = port; p < port + 10; p++) {
327
+ try {
328
+ proxy = await createProxy({
329
+ port: p,
330
+ host: '0.0.0.0',
331
+ upstream: { host: '127.0.0.1', port: dshPort },
332
+ // 注入内容按 host 定制:设备通道要额外注入「真实用户活动」上报脚本
333
+ // (会话续期只认真实操作,见 device-auth-http.mjs)。
334
+ injectHtml: (host) => {
335
+ const base = injectHtml === undefined ? DEFAULT_INJECT : injectHtml;
336
+ return deviceGateway.handles(host) ? base + deviceGateway.activityScript() : base;
337
+ },
338
+ ...(auth ? { auth } : {}),
339
+ // 设备认证通道(relay):与共享 PIN 互斥,命中该 host 就整段跳过 PIN
340
+ deviceAuth: deviceGateway,
341
+ // 每次请求实时读开关:设置页切换后立即生效,无需重启代理
342
+ lanAccessEnabled: () => getLanEnabled(),
343
+ // dsh web 浏览器会话启动 token(issue #77):实时取,新版 dsh 才有
344
+ ...(launchToken ? { launchToken } : {}),
345
+ });
346
+ if (p !== port) {
347
+ logInfo(`dsh-pocket-relay: port ${port} busy, proxy on ${p} | 端口 ${port} 被占用,代理改用 ${p}`);
348
+ }
349
+ break;
350
+ } catch (err) {
351
+ if (err?.code !== 'EADDRINUSE') throw err; // 非端口冲突直接失败
352
+ lastErr = err;
353
+ }
354
+ }
355
+ if (!proxy) throw lastErr ?? new Error('proxy start failed | 代理启动失败');
356
+ return proxy;
357
+ },
358
+
359
+ /** 启动公网隧道(幂等;返回公网 URL)。进度写进 tunnelState。并发调用单飞。 */
360
+ async startTunnel() {
361
+ await this.startProxy();
362
+ if (tunnel) return tunnel.url;
363
+ if (tunnelPromise) return tunnelPromise; // 复用 in-flight,防孤儿 cloudflared
364
+ const controller = new AbortController();
365
+ tunnelAbort = controller;
366
+ tunnelState.startedAt = Date.now();
367
+ const onPhase = (phase) => {
368
+ tunnelState.phase = phase;
369
+ if (phase === 'downloading') tunnelState.detail = '首次下载 cloudflared(约 20MB)| first run downloads cloudflared (~20MB)';
370
+ else if (phase === 'starting') tunnelState.detail = '启动隧道进程… | starting tunnel…';
371
+ else if (phase === 'registering') tunnelState.detail = '连接 Cloudflare 边缘(通常 5-30 秒)| connecting to Cloudflare edge (usually 5-30s)';
372
+ else if (phase === 'ready') tunnelState.detail = '隧道就绪 | ready';
373
+ };
374
+ // 先 `await null` 让函数体从微任务开始执行:否则在 `p` 初始化之前(getTunnelConfig
375
+ // 之类的同步抛错)就会走到 finally,引用未初始化的 const 触发 TDZ 报错,
376
+ // tunnelPromise 会永久停在 rejected —— 此后 startTunnel 一直复用这个失败的
377
+ // promise,隧道再也起不来。
378
+ const p = (async () => {
379
+ await null;
380
+ try {
381
+ // 命名隧道(issue #66):固定域名模式——URL 由设置里的域名拼出(cloudflared 不打印)
382
+ const cfg = typeof getTunnelConfig === 'function' ? (getTunnelConfig() ?? {}) : {};
383
+ if (cfg?.mode === 'named') {
384
+ if (!cfg.token || !cfg.hostname) {
385
+ throw new Error(
386
+ '命名隧道未配置完整:需要 Tunnel Token 和固定域名(设置页「固定域名」里填写) | '
387
+ + 'named tunnel is incomplete — set the Tunnel Token and the fixed hostname in Settings',
388
+ );
389
+ }
390
+ const result = await startNamed({ token: cfg.token, home, signal: controller.signal, onPhase });
391
+ tunnel = { url: `https://${cfg.hostname}`, kill: result.kill, onExit: result.onExit };
392
+ } else {
393
+ const result = await startTunnel({ port: proxy.port, home, signal: controller.signal, onPhase });
394
+ // 归一化:startTunnel 契约返回 {url, kill}(字符串也兼容)
395
+ tunnel = typeof result === 'string' ? { url: result, kill: () => {} } : result;
396
+ }
397
+ tunnelState.phase = 'ready';
398
+ // M1:隧道进程运行中死亡(崩溃/被杀)→ 状态打回,别让 UI 永远显示"可用"
399
+ tunnel.onExit?.((code) => {
400
+ if (controller.signal.aborted) return; // 主动停止(stopTunnel)不算故障
401
+ tunnelState.phase = 'error';
402
+ tunnelState.detail = `隧道进程退出(code=${code}) | tunnel process exited (code=${code})`;
403
+ });
404
+ // 记录「隧道开启中」,供重启后自动恢复(issue #11)
405
+ void persistAutoTunnel();
406
+ // 公网隧道就绪 → 回调(快速模式轮换访问密码;命名模式不轮换,见 lib/index.js)
407
+ try { onTunnelReady?.(cfg?.mode === 'named' ? 'named' : 'quick'); } catch { /* 忽略 */ }
408
+ return tunnel.url;
409
+ } catch (err) {
410
+ // stopTunnel 触发的 abort 不算错误:保持 idle,别把状态刷成 error
411
+ if (!controller.signal.aborted) {
412
+ tunnelState.phase = 'error';
413
+ tunnelState.detail = err?.message ?? String(err);
414
+ }
415
+ tunnelState.startedAt = null; // 失败后清掉计时,避免 UI 误显"启动中"
416
+ throw err;
417
+ } finally {
418
+ // 只清自己的引用:stopTunnel 后立即 startTunnel 可能已建了新的 in-flight
419
+ // (tunnelPromise=B),A 的 finally 不能把 B 清掉,否则第三次调用会并发 spawn
420
+ if (tunnelPromise === p) tunnelPromise = null;
421
+ }
422
+ })();
423
+ tunnelPromise = p;
424
+ return p;
425
+ },
426
+
427
+ /**
428
+ * 启动中继通道(幂等)。
429
+ *
430
+ * 「无人监听」的核心:配置好了就直接连,不等用户点任何按钮。
431
+ * 与隧道不同,中继**没有** start/stop 的用户语义——它就是常驻的,
432
+ * 用户唯一的操作是「暂停」(stopRelay)或改配置。
433
+ */
434
+ async startRelay() {
435
+ const cfg = typeof getRelayConfig === 'function' ? (getRelayConfig() ?? {}) : {};
436
+ if (!cfg.host || !cfg.port || !cfg.token) {
437
+ throw new Error('中继未配置完整(地址 / 端口 / Token) | relay is not configured');
438
+ }
439
+ await this.startProxy(); // 代理是上游,必须先在
440
+ if (relay) return relay.status();
441
+ relay = createRelay({
442
+ host: cfg.host,
443
+ port: cfg.port,
444
+ tls: cfg.tls === true,
445
+ // 自签证书场景允许放行;代价是放弃中间人防护,界面上要讲清楚
446
+ rejectUnauthorized: cfg.allowInsecure !== true,
447
+ token: cfg.token,
448
+ // 本机在 relay 上的名字:留空时按主机名派生。
449
+ // 不能默认成 "default":relay 按名字登记机器,同名会被后来者踢掉,
450
+ // 多台电脑会互相踢到没法用(见 relay.mjs 的 localAgentId 注释)。
451
+ agentId: cfg.agentId || localAgentId(),
452
+ // 代理端口可能顺延(3081 被占 → 3082),所以实时取,不写死
453
+ getTargetPort: () => proxy?.port ?? null,
454
+ log,
455
+ });
456
+ relay.start();
457
+ logInfo('dsh-pocket-relay: relay started → %s:%d | 中继已启动', cfg.host, cfg.port);
458
+ return relay.status();
459
+ },
460
+
461
+ /** 停止中继(用户点「暂停」,或换配置时先停再起)。 */
462
+ stopRelay() {
463
+ const r = relay;
464
+ relay = null;
465
+ if (r) {
466
+ try { r.stop(); } catch { /* 忽略 */ }
467
+ logInfo('dsh-pocket-relay: relay stopped | 中继已停止');
468
+ }
469
+ },
470
+
471
+ /** 中继状态快照(未启动时返回 idle)。 */
472
+ relayStatus() {
473
+ return relay ? relay.status() : { phase: 'idle', detail: '', streams: 0, reconnects: 0 };
474
+ },
475
+
476
+ /**
477
+ * 设备认证存储(设置页的设备管理用:列出 / 生成配对码 / 批准 / 撤销)。
478
+ *
479
+ * 注意:它暴露的是**全部设备记录**(含哈希),只应被本机 loopback 的管理面使用。
480
+ * 代理层已经保证非本机来源调不到 /dsh-pocket 通道(见 proxy.mjs 的
481
+ * REMOTE_ALLOWED_POCKET_ENDPOINTS)。
482
+ */
483
+ deviceAuth: deviceAuthStore,
484
+
485
+ /** 恢复出厂设置时一并撤销全部已配对设备(凭据属于本机状态,该一起清掉)。 */
486
+ resetDevices() {
487
+ deviceAuthStore.reset();
488
+ },
489
+
490
+ /**
491
+ * 停止公网隧道(代理保持)。
492
+ * @param {{ keepAutoMarker?: boolean }} [opts] - keepAutoMarker=true 用于进程退出/
493
+ * 自重启(dispose / pocketRestart):隧道是随进程被动消失的,不是用户主动关闭,
494
+ * 必须保留 tunnel-auto.json,下次启动才能自动恢复(issue #11 的本意)。
495
+ * 默认(设置页手动关闭、恢复出厂)删除标记。
496
+ */
497
+ stopTunnel({ keepAutoMarker = false } = {}) {
498
+ tunnelAbort?.abort();
499
+ tunnelAbort = null;
500
+ tunnelPromise = null; // 丢弃已 abort 的 in-flight(其 finally 会再清一次,无害)
501
+ if (tunnel) tunnel.kill();
502
+ tunnel = null;
503
+ tunnelState.phase = 'idle';
504
+ tunnelState.detail = '';
505
+ tunnelState.startedAt = null;
506
+ if (!keepAutoMarker) void clearAutoTunnel(); // 手动关闭后不再自动恢复
507
+ },
508
+
509
+ /** 启动时自动恢复上次开启的公网隧道(DSH 重启后 cloudflared 子进程被杀,issue #11)。 */
510
+ async restoreTunnelIfNeeded() {
511
+ logInfo('dsh-pocket-relay: auto-restore check | 自动恢复检查:读取上次隧道状态');
512
+ if (!autoStatePath || tunnel || tunnelPromise) return;
513
+ let has = false;
514
+ try {
515
+ const raw = await readFile(autoStatePath, 'utf8');
516
+ has = /"at"\s*:/.test(raw);
517
+ } catch { return; } // 无标记 → 不恢复
518
+ if (!has) return;
519
+ logInfo('dsh-pocket-relay: auto-restore: marker found, starting tunnel | 发现上次开启标记,尝试自动恢复');
520
+ try {
521
+ await this.startTunnel();
522
+ logInfo('dsh-pocket-relay: public tunnel auto-restored | 已自动恢复公网隧道');
523
+ } catch (err) {
524
+ // 恢复失败保留标记(下次启动再试);网络问题见 README 排障
525
+ logWarn('dsh-pocket-relay: tunnel auto-restore failed | 自动恢复隧道失败: %s', err?.message ?? err);
526
+ }
527
+ },
528
+
529
+ /** 状态快照(RPC 返回,不含敏感信息;二维码 data URL 本地生成 + 缓存)。 */
530
+ async status() {
531
+ const lan = await getLan();
532
+ const proxyPort = proxy?.port ?? null;
533
+ const lanUrl = lan && proxyPort ? `http://${lan}:${proxyPort}` : null;
534
+ const lanIpOverride = getLanOverride();
535
+ const lanCandidates = [...new Set(await getLanCandidates())];
536
+ if (lanIpOverride && !lanCandidates.includes(lanIpOverride)) lanCandidates.push(lanIpOverride);
537
+ return {
538
+ proxyRunning: proxy !== null,
539
+ proxyPort,
540
+ lanUrl,
541
+ lanQr: await qrCached(lanUrl),
542
+ lanCandidates,
543
+ lanIpOverride,
544
+ tunnelRunning: tunnel !== null,
545
+ tunnelUrl: tunnel?.url ?? null,
546
+ tunnelQr: await qrCached(tunnel?.url ?? null),
547
+ tunnelState: { ...tunnelState },
548
+ // 隧道配置脱敏视图(issue #66):token 永不回显,只有 tokenSet 布尔值
549
+ tunnelConfig: (() => {
550
+ const cfg = typeof getTunnelConfig === 'function' ? (getTunnelConfig() ?? {}) : {};
551
+ return {
552
+ mode: cfg?.mode === 'named' ? 'named' : 'quick',
553
+ hostname: typeof cfg?.hostname === 'string' ? cfg.hostname : '',
554
+ tokenSet: Boolean(cfg?.token),
555
+ };
556
+ })(),
557
+ dshPort,
558
+ // 中继通道状态(常开:配置好人就在线,界面上直接显示连没连上)
559
+ relayRunning: relay !== null,
560
+ relayState: this.relayStatus(),
561
+ // 对外访问地址的二维码(地址固定,缓存一次长期复用)
562
+ relayQr: await qrCached(publicHostUrl(readRelayCfg().publicUrl) || null),
563
+ };
564
+ },
565
+
566
+ /** 停止一切(插件卸载时)。 */
567
+ /** 停止一切(插件卸载时)。进程退出不是用户手动关闭:保留自动恢复标记。 */
568
+ async dispose() {
569
+ this.stopRelay();
570
+ this.stopTunnel({ keepAutoMarker: true });
571
+ if (proxy) {
572
+ const p = proxy;
573
+ proxy = null;
574
+ try { await p.close(); } catch { /* server 已关闭等边缘情况 */ }
575
+ }
576
+ },
577
+ };
578
+ }