@mrrisega/dsh-remote 0.7.11-beta.3 → 0.7.12-beta.1
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/clients/dsh-remote/dsh-bridge.mjs +7 -1
- package/clients/dsh-remote/im-format.mjs +372 -0
- package/clients/dsh-remote/test/im-format.test.mjs +229 -0
- package/clients/dsh-remote/test/upstream-discovery.test.mjs +203 -2
- package/clients/dsh-remote/test/wecom-bot.test.mjs +281 -1
- package/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/clients/dsh-remote/wecom-bot.mjs +238 -12
- package/dsh-setup.mjs +77 -6
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +371 -111
- package/packages/dsh-remote-web/lib/index.js +120 -10
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +17 -2
- package/packages/dsh-remote-web/test/im-bots.test.mjs +41 -0
- package/packages/dsh-remote-web/test/login-fast.test.mjs +104 -0
- package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +4 -1
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +200 -2
- package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +4 -4
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -113
- package/packages/dsh-remote-web/test/self-manage.test.mjs +5 -1
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +13 -5
- package/packages/dsh-remote-web/test/telemetry.test.mjs +2 -2
|
@@ -27,6 +27,24 @@
|
|
|
27
27
|
*
|
|
28
28
|
* ⚠️ 探测全部**并行**且有总预算:本地端口连接被拒是瞬时的,只有"端口在听但不回包"才会等到超时,
|
|
29
29
|
* 串行探 12 个候选最坏要十几秒,会让 watcher 的轮询卡住。
|
|
30
|
+
*
|
|
31
|
+
* ## 2026-11-07 修复:别再乱敲本机所有端口(微信 CPU 高 + 登录慢)
|
|
32
|
+
*
|
|
33
|
+
* 旧实现把「本机所有回环 LISTEN 端口」(用户机实测 209 个)全部当候选逐个探测,
|
|
34
|
+
* 其中包含**微信 PC 客户端**监听的端口 —— 发现一次就把微信敲一遍,用户侧表现为 CPU 升高;
|
|
35
|
+
* 冷启动时几百个端口 × 900ms 超时也让登录卡 7–8 秒。
|
|
36
|
+
*
|
|
37
|
+
* 现在的纪律("能发现非默认端口"的能力保留,只是不再乱敲):
|
|
38
|
+
* 1. **只探可能是 dsh 的端口**:hint → dsh/node 进程自己的监听端口 → **按归属进程名过滤**后的
|
|
39
|
+
* 本机监听端口(白名单 node/dsh/deepseek/electron,且 IM 客户端进程名黑名单双保险)
|
|
40
|
+
* → 静态候选(3080、43120..)。拿不到进程名时**不再退化成全量探测**(宁可漏,不乱敲)。
|
|
41
|
+
* 2. **更便宜**:探测超时 300ms、并发上限 16、**负缓存** 10 分钟(被判定"不是 dsh"的端口
|
|
42
|
+
* 期间不再探测)、监听表枚举缓存 10s。
|
|
43
|
+
* 3. **不阻塞请求路径**:`discoverUpstreamFast()` 只走 hint/缓存/进程端口(无全量探测);
|
|
44
|
+
* `discoverUpstream()` 默认先返回 fast 结果,没命中时最多再等 `maxWaitMs`(默认 600ms)
|
|
45
|
+
* 完成一次完整发现,否则把完整发现放后台、结果写缓存供下次用。
|
|
46
|
+
* 4. **可观测**:日志只打 `source / probed / rejected / skipped` 计数,**不打端口清单**
|
|
47
|
+
* (skipped = 因"不是 node/dsh 进程"被跳过的端口数,含微信等 IM 客户端)。
|
|
30
48
|
*/
|
|
31
49
|
|
|
32
50
|
import fs from "node:fs";
|
|
@@ -41,8 +59,45 @@ export const FALLBACK_UPSTREAM = "http://127.0.0.1:3080";
|
|
|
41
59
|
export const DESKTOP_DEFAULT_PORT = 43120;
|
|
42
60
|
/** Desktop 端口区间扫多少个(43120..43120+11)。 */
|
|
43
61
|
export const DESKTOP_PORT_SCAN = 12;
|
|
44
|
-
/**
|
|
45
|
-
const PROBE_TIMEOUT_MS =
|
|
62
|
+
/** 单个候选的探测超时(本地回环;旧值 900ms 会让冷启动明显变慢,2026-11-07 收到 300ms)。 */
|
|
63
|
+
export const PROBE_TIMEOUT_MS = 300;
|
|
64
|
+
/** 「不是 dsh」的负缓存时长(期间不再探测同一个端口)。 */
|
|
65
|
+
export const NEGATIVE_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
66
|
+
/** 已确认上游的缓存时长(端口漂移时最多这么久后重新发现)。 */
|
|
67
|
+
export const VERIFIED_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
68
|
+
/** 候选探测并发上限(本地回环,16 足够;防止一次打出上百个连接)。 */
|
|
69
|
+
export const MAX_PROBE_CONCURRENCY = 16;
|
|
70
|
+
/** 「按进程名过滤后的本机监听端口」最多取多少个进候选(开发机 node 进程可能很多)。 */
|
|
71
|
+
export const LISTEN_PROBE_LIMIT = 24;
|
|
72
|
+
/** 候选总数超过这个数时**整层放弃 listen 扫描**(只探 hint/进程/静态)——防止一次发现打上百个请求。 */
|
|
73
|
+
export const MAX_PROBE_CANDIDATES = 20;
|
|
74
|
+
/** 监听表枚举缓存(lsof/ss 调用不算贵但也没必要每次发现都跑)。 */
|
|
75
|
+
const LISTEN_ENUM_TTL_MS = 10 * 1000;
|
|
76
|
+
/** 监听表"拿不到"的缓存(lsof 不存在时不必每次重试;稍后仍会再试)。 */
|
|
77
|
+
const LISTEN_ENUM_MISS_TTL_MS = 30 * 1000;
|
|
78
|
+
|
|
79
|
+
/** 已知 IM / 会议客户端的进程名黑名单:**即使名字像 node 也绝不探**(双保险)。 */
|
|
80
|
+
const IM_PROCESS_BLACKLIST = /wechat|weixin|wecom|wxwork|企业微信|微信|\bqq\b|dingtalk|dingding|feishu|lark|telegram|slack|discord|skype|whatsapp|\bteams\b|wemeet|tencent/i;
|
|
81
|
+
/** 可能是 dsh web 宿主的进程名(macOS lsof 的 COMMAND 会被截断到 9 字符;Windows 去掉 .exe)。 */
|
|
82
|
+
const DSH_LIKE_PROCESS = /^(?:node|nodejs|dsh|deepseek|electron)/i;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 这个进程名是不是「可能是 dsh 的宿主」?
|
|
86
|
+
* IM 客户端黑名单优先(`wechat-node` 这类名字也一律拒绝)。
|
|
87
|
+
*/
|
|
88
|
+
export function isDshLikeProcessName(name) {
|
|
89
|
+
const n = String(name || "").trim();
|
|
90
|
+
if (!n) return false;
|
|
91
|
+
if (IM_PROCESS_BLACKLIST.test(n)) return false;
|
|
92
|
+
return DSH_LIKE_PROCESS.test(n);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 是否是已知 IM/会议客户端(用于统计 skipped 与双保险排除)。 */
|
|
96
|
+
export function isIgnoredListenProcess(name) {
|
|
97
|
+
const n = String(name || "").trim();
|
|
98
|
+
if (!n) return false;
|
|
99
|
+
return IM_PROCESS_BLACKLIST.test(n);
|
|
100
|
+
}
|
|
46
101
|
|
|
47
102
|
/** 规范化:去尾斜杠;非 http(s) 一律丢弃(返回空串)。 */
|
|
48
103
|
export function normalizeUpstream(value) {
|
|
@@ -286,19 +341,134 @@ export function allLoopbackListeningPorts(platform = process.platform) {
|
|
|
286
341
|
return [...ports];
|
|
287
342
|
}
|
|
288
343
|
|
|
344
|
+
/** 本地地址是否可能通过 127.0.0.1 访问(回环或通配绑定)。 */
|
|
345
|
+
function isLoopbackListenName(name) {
|
|
346
|
+
const v = String(name || "");
|
|
347
|
+
if (v.includes("->")) return false; // 已建立连接的那一端,不是监听项
|
|
348
|
+
return /^(?:127\.0\.0\.1|\[::1\]|\*|0\.0\.0\.0|\[::\]):\d+$/.test(v);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 解析 `lsof -nP -F pcn -iTCP -sTCP:LISTEN` 的字段化输出。 */
|
|
352
|
+
function parseLsofFieldListeners(out) {
|
|
353
|
+
const rows = [];
|
|
354
|
+
let pid = 0;
|
|
355
|
+
let name = "";
|
|
356
|
+
for (const line of String(out || "").split("\n")) {
|
|
357
|
+
if (!line) continue;
|
|
358
|
+
const tag = line[0];
|
|
359
|
+
const val = line.slice(1);
|
|
360
|
+
if (tag === "p") { pid = Number(val) || 0; name = ""; continue; }
|
|
361
|
+
if (tag === "c") { name = val; continue; }
|
|
362
|
+
if (tag !== "n") continue;
|
|
363
|
+
if (!isLoopbackListenName(val)) continue;
|
|
364
|
+
const m = /:(\d+)$/.exec(val);
|
|
365
|
+
if (!m) continue;
|
|
366
|
+
const port = Number(m[1]);
|
|
367
|
+
if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue;
|
|
368
|
+
rows.push({ port, name: String(name).replace(/\.exe$/i, ""), pid });
|
|
369
|
+
}
|
|
370
|
+
return rows;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 解析 `lsof -nP -iTCP -sTCP:LISTEN` 的整行输出(老 lsof 兜底)。 */
|
|
374
|
+
function parseLsofPlainListeners(out) {
|
|
375
|
+
const rows = [];
|
|
376
|
+
for (const line of String(out || "").split("\n")) {
|
|
377
|
+
if (!/LISTEN/.test(line)) continue;
|
|
378
|
+
const head = /^(\S+)\s+(\d+)\s+/.exec(line);
|
|
379
|
+
const addr = /(?:TCP\s+)?(127\.0\.0\.1|\[::1\]|\*|0\.0\.0\.0):(\d+)\s*\(LISTEN\)/.exec(line);
|
|
380
|
+
if (!head || !addr) continue;
|
|
381
|
+
const port = Number(addr[2]);
|
|
382
|
+
if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue;
|
|
383
|
+
rows.push({ port, name: head[1].replace(/\.exe$/i, ""), pid: Number(head[2]) });
|
|
384
|
+
}
|
|
385
|
+
return rows;
|
|
386
|
+
}
|
|
387
|
+
|
|
289
388
|
/**
|
|
290
|
-
*
|
|
291
|
-
* 顺序 = 提示里的端口 → 显式额外端口 → DSH_WEB_URL 的端口 → 祖先/dsh 进程实测监听端口
|
|
292
|
-
* → **本机所有回环监听端口** → 3080 → Desktop 默认端口区间。
|
|
389
|
+
* 本机 LISTEN 端口 **连同归属进程名**(macOS/Linux 用 lsof;Linux 再兜底 `ss -ltnp`)。
|
|
293
390
|
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
391
|
+
* 为什么需要进程名(2026-11-07 微信 CPU 事故):旧实现把本机所有 LISTEN 端口都当候选去敲,
|
|
392
|
+
* 里面就有微信 PC 客户端的监听端口 —— 每发现一次就把微信敲一遍。现在只保留
|
|
393
|
+
* 「进程名像 node/dsh/deepseek/electron」的端口;IM 客户端黑名单双保险。
|
|
394
|
+
*
|
|
395
|
+
* ⚠️ 拿不到进程名时返回 **null**(调用方绝不退化成"全量探测"—— 宁可漏一个端口,也不再乱敲)。
|
|
396
|
+
* @returns {Array<{port:number,name:string,pid:number}>|null}
|
|
298
397
|
*/
|
|
299
|
-
export function
|
|
398
|
+
export function listeningPortsWithProcess(platform = process.platform) {
|
|
399
|
+
if (platform === "win32") {
|
|
400
|
+
// Windows 拿"端口 + 进程名"要 netstat + tasklist 两次调用(几百 ms),且有 Desktop 静态候选兜底:
|
|
401
|
+
// 本层只在 mac/Linux 提供;Windows 不猜、也不退化成全量探测。
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
// ① lsof:macOS 自带,Linux 常见。
|
|
405
|
+
// 优先 `-F pcn`(字段化输出):本机实测比整行输出快 ~15ms,且解析不会被"COMMAND 带空格/截断"坑到。
|
|
406
|
+
const rows = parseLsofFieldListeners(tryExec("lsof", ["-nP", "-F", "pcn", "-iTCP", "-sTCP:LISTEN"], 6000));
|
|
407
|
+
if (rows.length) return rows;
|
|
408
|
+
// 兼容极老 lsof(不认 -F 组合)→ 退回整行解析
|
|
409
|
+
const plain = parseLsofPlainListeners(tryExec("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], 6000));
|
|
410
|
+
if (plain.length) return plain;
|
|
411
|
+
// ② Linux:ss -ltnp(有 -p 时能拿到 users:(("node",pid=...)))
|
|
412
|
+
const ss = tryExec("ss", ["-ltnpH"], 5000) || tryExec("ss", ["-ltnp"], 5000);
|
|
413
|
+
const ssRows = [];
|
|
414
|
+
for (const line of ss.split("\n")) {
|
|
415
|
+
if (!/LISTEN/.test(line)) continue;
|
|
416
|
+
const cols = line.trim().split(/\s+/);
|
|
417
|
+
const local = cols[3] || "";
|
|
418
|
+
const m = /(?:127\.0\.0\.1|\[::1\]|\*|0\.0\.0\.0|\[::\]):(\d+)$/.exec(local);
|
|
419
|
+
const user = /users:\(\(\("([^"]+)",pid=(\d+)/.exec(line);
|
|
420
|
+
if (!m) continue;
|
|
421
|
+
const port = Number(m[1]);
|
|
422
|
+
if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue;
|
|
423
|
+
ssRows.push({ port, name: user ? user[1] : "", pid: user ? Number(user[2]) : 0 });
|
|
424
|
+
}
|
|
425
|
+
if (ssRows.length) return ssRows;
|
|
426
|
+
return null; // 拿不到进程名:调用方不得退化成全量探测
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** 监听表枚举缓存(含"拿不到"的负结果;避免插件每次轮询都跑 lsof)。 */
|
|
430
|
+
let listenEnumCache = { at: 0, rows: null };
|
|
431
|
+
function cachedListenersWithProcess(platform) {
|
|
432
|
+
const nowAt = Date.now();
|
|
433
|
+
const ttl = listenEnumCache.rows ? LISTEN_ENUM_TTL_MS : LISTEN_ENUM_MISS_TTL_MS;
|
|
434
|
+
if (nowAt - listenEnumCache.at < ttl) return listenEnumCache.rows;
|
|
435
|
+
let rows = null;
|
|
436
|
+
try { rows = listeningPortsWithProcess(platform); } catch { rows = null; }
|
|
437
|
+
listenEnumCache = { at: nowAt, rows };
|
|
438
|
+
return rows;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 候选计划:返回候选列表 + 被跳过的端口数(skipped = 因"不是 node/dsh 进程"或 IM 黑名单跳过)。
|
|
443
|
+
*
|
|
444
|
+
* 顺序 = 提示里的端口 → 显式额外端口 → DSH_WEB_URL → 祖先/dsh 进程监听端口
|
|
445
|
+
* → **按进程名过滤后的本机监听端口** → 3080 → Desktop 默认端口区间。
|
|
446
|
+
*
|
|
447
|
+
* 「本机监听端口」这一层**必须**有进程名才收(见 listeningPortsWithProcess);
|
|
448
|
+
* 拿不到进程名就整层跳过(skipped 只统计被过滤掉的,不统计"拿不到进程名")。
|
|
449
|
+
*
|
|
450
|
+
* @returns {{entries:Array<{port:number, source:string}>, skipped:number, truncated:number}}
|
|
451
|
+
*/
|
|
452
|
+
export function candidatePlan({
|
|
453
|
+
relayDir = "",
|
|
454
|
+
env = process.env,
|
|
455
|
+
extraPorts = [],
|
|
456
|
+
platform = process.platform,
|
|
457
|
+
listenPorts = null, // 仅测试/特殊部署:直接注入端口列表(绕过进程名过滤)
|
|
458
|
+
listeners = null, // 仅测试:注入 [{port,name,pid}] 以验证过滤/黑名单;false = 模拟"拿不到进程名"
|
|
459
|
+
includeStatic = true,
|
|
460
|
+
includeListen = true,
|
|
461
|
+
/**
|
|
462
|
+
* 是否枚举「祖先进程 / dsh 进程 / 逐 pid 的监听端口」。
|
|
463
|
+
* fast 路径传 false:那一段每次要跑 ps 链 + pgrep + 每个 pid 一次 lsof(本机实测 ~180ms),
|
|
464
|
+
* 而"进程端口"信息已经包含在 `includeListen` 的监听表里(带 pid/进程名),不必重复枚举。
|
|
465
|
+
*/
|
|
466
|
+
includeProcess = true
|
|
467
|
+
} = {}) {
|
|
300
468
|
const out = [];
|
|
301
469
|
const seen = new Set();
|
|
470
|
+
let skipped = 0;
|
|
471
|
+
let truncated = 0;
|
|
302
472
|
const push = (p, source) => {
|
|
303
473
|
const n = Number(p);
|
|
304
474
|
if (!Number.isInteger(n) || n <= 0 || n >= 65536 || seen.has(n)) return;
|
|
@@ -309,15 +479,63 @@ export function candidatePorts({ relayDir = "", env = process.env, extraPorts =
|
|
|
309
479
|
push(portOf(hint.url), hint.source || "hint");
|
|
310
480
|
for (const p of extraPorts) push(p, "extra");
|
|
311
481
|
push(portOf(upstreamFromDshWebUrl(env)), "dsh_web_url");
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
482
|
+
if (includeProcess) {
|
|
483
|
+
const pids = [...new Set([process.pid, ...ancestorPids(process.pid, 6, platform), ...dshProcessPids(platform)])];
|
|
484
|
+
for (const pid of pids) for (const p of listeningPortsOfPid(pid, platform)) push(p, "process");
|
|
485
|
+
}
|
|
486
|
+
if (includeListen) {
|
|
487
|
+
if (Array.isArray(listenPorts)) {
|
|
488
|
+
// 显式注入(测试/特殊部署):不做进程名过滤,保持既有语义
|
|
489
|
+
for (const p of listenPorts) push(p, "listen");
|
|
490
|
+
} else {
|
|
491
|
+
const rows = listeners === false ? null : (Array.isArray(listeners) ? listeners : cachedListenersWithProcess(platform));
|
|
492
|
+
if (Array.isArray(rows)) {
|
|
493
|
+
let accepted = 0;
|
|
494
|
+
for (const row of rows) {
|
|
495
|
+
if (!row || typeof row !== "object") continue;
|
|
496
|
+
const name = String(row.name || "");
|
|
497
|
+
if (isIgnoredListenProcess(name) || !isDshLikeProcessName(name)) { skipped += 1; continue; }
|
|
498
|
+
if (accepted >= LISTEN_PROBE_LIMIT) { truncated += 1; continue; }
|
|
499
|
+
const before = out.length;
|
|
500
|
+
push(row.port, "listen");
|
|
501
|
+
if (out.length > before) accepted += 1;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// rows === null(拿不到进程名)→ 整层跳过:**绝不退化成全量探测**
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (includeStatic) {
|
|
508
|
+
push(3080, "static");
|
|
509
|
+
for (let i = 0; i < DESKTOP_PORT_SCAN; i++) push(DESKTOP_DEFAULT_PORT + i, "static");
|
|
510
|
+
}
|
|
511
|
+
return { entries: out, skipped, truncated };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* 候选列表(兼容旧签名;只返回 entries)。需要 skipped/truncated 用 `candidatePlan()`。
|
|
516
|
+
* @returns {Array<{port:number, source:string}>}
|
|
517
|
+
*/
|
|
518
|
+
export function candidatePorts(opts = {}) {
|
|
519
|
+
return candidatePlan(opts).entries;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* 给"要真的去探测"的候选计划加**总量上限**:超过 `MAX_PROBE_CANDIDATES` 时整层放弃 listen 扫描,
|
|
524
|
+
* 只保留 hint/进程/静态(这些是强信号,量级可控)。
|
|
525
|
+
*
|
|
526
|
+
* 为什么需要:开发机可能同时跑十几个 node 进程(各自 1–3 个监听端口),全探一遍就是几十个请求;
|
|
527
|
+
* 但"漏掉一个端口"的代价远小于"每次发现都打几十个请求"(正是本次微信 CPU 事故的教训)。
|
|
528
|
+
*/
|
|
529
|
+
function planCandidatesForProbe(opts = {}) {
|
|
530
|
+
const full = candidatePlan(opts);
|
|
531
|
+
if (full.entries.length <= MAX_PROBE_CANDIDATES) return full;
|
|
532
|
+
const minimal = candidatePlan({ ...opts, includeListen: false });
|
|
533
|
+
return {
|
|
534
|
+
entries: minimal.entries,
|
|
535
|
+
skipped: minimal.skipped,
|
|
536
|
+
truncated: minimal.truncated + Math.max(0, full.entries.length - minimal.entries.length),
|
|
537
|
+
capApplied: true
|
|
538
|
+
};
|
|
321
539
|
}
|
|
322
540
|
|
|
323
541
|
// ---------- 身份校验 ----------
|
|
@@ -373,6 +591,15 @@ export async function probeDshWeb(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl
|
|
|
373
591
|
if (!target) return false;
|
|
374
592
|
const doFetch = fetchImpl || globalThis.fetch;
|
|
375
593
|
if (typeof doFetch !== "function") return false;
|
|
594
|
+
/**
|
|
595
|
+
* 探测一次。返回 `{http, matched}`:
|
|
596
|
+
* · `http=false` = 连 HTTP 响应都没拿到(拒绝/超时/DNS/读失败)—— 这个端口基本不是活的
|
|
597
|
+
* · `http=true` = 拿到了 HTTP 响应(哪怕是 4xx/5xx/陌生页面)
|
|
598
|
+
*
|
|
599
|
+
* 为什么要区分(2026-11-07):旧实现首探失败后还会再打 `/dsh-remote/self`,
|
|
600
|
+
* 于是**每个死端口都要发 2 个请求**(大多数候选都是死的)→ 扫描请求量翻倍。
|
|
601
|
+
* 现在:首探连 HTTP 都不是就直接判否,不再打第二个(省一半连接)。
|
|
602
|
+
*/
|
|
376
603
|
const once = async (path, check) => {
|
|
377
604
|
try {
|
|
378
605
|
const ac = new AbortController();
|
|
@@ -385,7 +612,8 @@ export async function probeDshWeb(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl
|
|
|
385
612
|
clearTimeout(timer);
|
|
386
613
|
}
|
|
387
614
|
const status = Number(res && res.status) || 0;
|
|
388
|
-
if (status
|
|
615
|
+
if (!status) return { http: false, matched: false };
|
|
616
|
+
if (status >= 500) return { http: true, matched: false };
|
|
389
617
|
// 只读前 8KB 足够命中特征;读流失败按"不是"处理(宁可少一个候选,不可误接)
|
|
390
618
|
let text = "";
|
|
391
619
|
try {
|
|
@@ -398,23 +626,181 @@ export async function probeDshWeb(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl
|
|
|
398
626
|
text = await res.text();
|
|
399
627
|
}
|
|
400
628
|
} catch { /* 读失败 → text 为空 → 判否 */ }
|
|
401
|
-
return check(status, text.slice(0, 8192));
|
|
629
|
+
return { http: true, matched: check(status, text.slice(0, 8192)) };
|
|
402
630
|
} catch {
|
|
403
|
-
return false;
|
|
631
|
+
return { http: false, matched: false };
|
|
404
632
|
}
|
|
405
633
|
};
|
|
406
|
-
|
|
407
|
-
|
|
634
|
+
const first = await once("/", looksLikeDshWeb);
|
|
635
|
+
if (first.matched) return true;
|
|
636
|
+
// 只有"确实拿到了 HTTP 响应但首页特征不中"才值得再问一次插件自己的 /dsh-remote/self
|
|
637
|
+
// (首页可能是登录页/自定义门户;连 HTTP 都不通的端口没有任何理由再打一次)
|
|
638
|
+
if (!first.http) return false;
|
|
639
|
+
return (await once(DSH_REMOTE_SELF_PATH, looksLikeDshRemoteSelf)).matched;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ---------- 探测缓存(负缓存防反复敲、已确认缓存给 fast 路径) ----------
|
|
643
|
+
|
|
644
|
+
/** port -> 判定"不是 dsh"的时间戳(10 分钟内不再探测)。 */
|
|
645
|
+
const negativeCache = new Map();
|
|
646
|
+
/** 最近一次**已验证**的上游(fast 路径直接采信/秒回)。 */
|
|
647
|
+
const verifiedCache = { url: "", source: "", at: 0 };
|
|
648
|
+
/** 正在后台跑的完整发现(超时返回时继续跑完,结果写缓存供下次用)。 */
|
|
649
|
+
let bgDiscoveryInflight = null;
|
|
650
|
+
|
|
651
|
+
function negativeKey(port) { return String(Number(port) || 0); }
|
|
652
|
+
function isNegativelyCached(port) {
|
|
653
|
+
const key = negativeKey(port);
|
|
654
|
+
const at = negativeCache.get(key);
|
|
655
|
+
if (!at) return false;
|
|
656
|
+
if (Date.now() - at > NEGATIVE_CACHE_TTL_MS) { negativeCache.delete(key); return false; }
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
function rememberNegative(port) {
|
|
660
|
+
const key = negativeKey(port);
|
|
661
|
+
if (!key || key === "0") return;
|
|
662
|
+
negativeCache.set(key, Date.now());
|
|
663
|
+
if (negativeCache.size <= 512) return;
|
|
664
|
+
// 有界:Map 保插入序,超限删最旧
|
|
665
|
+
const first = negativeCache.keys().next();
|
|
666
|
+
if (!first.done) negativeCache.delete(first.value);
|
|
667
|
+
}
|
|
668
|
+
function rememberVerified(url, source) {
|
|
669
|
+
verifiedCache.url = String(url || "");
|
|
670
|
+
verifiedCache.source = String(source || "");
|
|
671
|
+
verifiedCache.at = Date.now();
|
|
672
|
+
}
|
|
673
|
+
function clearVerified() { verifiedCache.url = ""; verifiedCache.source = ""; verifiedCache.at = 0; }
|
|
674
|
+
|
|
675
|
+
/** 仅测试:清空所有进程内缓存(负缓存/已验证/监听表),避免用例互相污染。 */
|
|
676
|
+
export function __resetUpstreamCachesForTest() {
|
|
677
|
+
negativeCache.clear();
|
|
678
|
+
clearVerified();
|
|
679
|
+
listenEnumCache = { at: 0, rows: null };
|
|
680
|
+
bgDiscoveryInflight = null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** 仅测试:等后台完整发现跑完(没有在跑的则立即 resolve)。 */
|
|
684
|
+
export async function __waitUpstreamDiscoveryForTest() {
|
|
685
|
+
if (bgDiscoveryInflight) { try { await bgDiscoveryInflight; } catch { /* 忽略 */ } }
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* 已验证缓存是否仍然可用:**每次都用一次本地回环探测确认**(1–2ms,端口死了最多 300ms)。
|
|
690
|
+
*
|
|
691
|
+
* 为什么不"缓存内直接采信"(试过 30s 盲信):dsh web 换端口后,盲信会把旧地址继续喂给
|
|
692
|
+
* bridge 最多 30–45s,端口漂移自愈被拖慢。本地探测成本极低,换来的是"缓存绝不会把请求
|
|
693
|
+
* 引到死端口",更符合这个模块存在的意义。
|
|
694
|
+
*/
|
|
695
|
+
async function useVerifiedCache({ timeoutMs = PROBE_TIMEOUT_MS, fetchImpl } = {}) {
|
|
696
|
+
if (!verifiedCache.url) return null;
|
|
697
|
+
if (Date.now() - verifiedCache.at > VERIFIED_CACHE_TTL_MS) { clearVerified(); return null; }
|
|
698
|
+
if (await probeDshWeb(verifiedCache.url, { timeoutMs, fetchImpl })) {
|
|
699
|
+
verifiedCache.at = Date.now();
|
|
700
|
+
return { url: verifiedCache.url, source: verifiedCache.source };
|
|
701
|
+
}
|
|
702
|
+
clearVerified();
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** 限量并发 map(保持结果顺序;异常折成 undefined)。 */
|
|
707
|
+
async function mapLimit(items, limit, fn) {
|
|
708
|
+
const list = Array.isArray(items) ? items : [];
|
|
709
|
+
const max = Math.max(1, Math.min(Number(limit) || 1, list.length || 1));
|
|
710
|
+
const out = new Array(list.length);
|
|
711
|
+
let cursor = 0;
|
|
712
|
+
const workers = Array.from({ length: max }, async () => {
|
|
713
|
+
for (;;) {
|
|
714
|
+
const i = cursor;
|
|
715
|
+
cursor += 1;
|
|
716
|
+
if (i >= list.length) return;
|
|
717
|
+
try { out[i] = await fn(list[i], i); } catch { out[i] = undefined; }
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
await Promise.all(workers);
|
|
721
|
+
return out;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** 日志:只打计数,**不打端口清单**(隐私/噪音;排障要清单时用 `dsh-remote doctor` 的探测结果)。 */
|
|
725
|
+
function logDiscovery(log, info) {
|
|
726
|
+
if (typeof log !== "function") return;
|
|
727
|
+
try {
|
|
728
|
+
const { found, source, probed, rejected, skipped, note } = info || {};
|
|
729
|
+
log(`[upstream] ${found ? "命中" : "未命中"} source=${source || "-"} probed=${probed || 0} rejected=${rejected || 0} skipped=${skipped || 0}${note ? `(${note})` : ""}`);
|
|
730
|
+
} catch { /* 日志失败不影响发现 */ }
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* **快速发现**(供请求路径/热路径使用):只用 hint / 已验证缓存 / 进程端口,
|
|
735
|
+
* **不做全量端口枚举**,正常情况 <50ms。
|
|
736
|
+
*
|
|
737
|
+
* 顺序:`DSH_BRIDGE_UPSTREAM`(直接采信)→ 已验证缓存 → hint 单次校验 → 显式 extraPorts
|
|
738
|
+
* → 「祖先/dsh 进程 + 按进程名过滤后的本机监听端口」逐个校验(并发上限 16、300ms 超时、负缓存)。
|
|
739
|
+
*
|
|
740
|
+
* @returns {Promise<{url:string, source:string, probed:number[], rejected:string[], skipped:number, fast?:boolean}>}
|
|
741
|
+
* 没找到时 `url=""`(调用方决定用 hint/回退值,或再调完整发现)。
|
|
742
|
+
*/
|
|
743
|
+
export async function discoverUpstreamFast({
|
|
744
|
+
relayDir = "",
|
|
745
|
+
env = process.env,
|
|
746
|
+
timeoutMs = PROBE_TIMEOUT_MS,
|
|
747
|
+
fetchImpl,
|
|
748
|
+
platform = process.platform,
|
|
749
|
+
extraPorts = [],
|
|
750
|
+
listeners = null,
|
|
751
|
+
log
|
|
752
|
+
} = {}) {
|
|
753
|
+
const hint = resolveUpstreamHint({ relayDir, env });
|
|
754
|
+
// ① 显式 env:部署方说是什么就是什么(保持既有语义,不探测)
|
|
755
|
+
if (hint.source === "env") {
|
|
756
|
+
return { url: hint.url, source: "env", probed: [], rejected: [], skipped: 0, fast: true };
|
|
757
|
+
}
|
|
758
|
+
// ② 已验证缓存(秒回;过期会重新校验一次)
|
|
759
|
+
const cached = await useVerifiedCache({ timeoutMs, fetchImpl });
|
|
760
|
+
if (cached) {
|
|
761
|
+
return { url: cached.url, source: cached.source, probed: [], rejected: [], skipped: 0, fast: true, cached: true };
|
|
762
|
+
}
|
|
763
|
+
const rejected = [];
|
|
764
|
+
// ③ hint 单次校验(端口文件可能过期,所以不盲信)
|
|
765
|
+
if (hint.url) {
|
|
766
|
+
if (await probeDshWeb(hint.url, { timeoutMs, fetchImpl })) {
|
|
767
|
+
rememberVerified(hint.url, hint.source);
|
|
768
|
+
return { url: hint.url, source: hint.source, probed: [portOf(hint.url)], rejected, skipped: 0, fast: true };
|
|
769
|
+
}
|
|
770
|
+
rejected.push(hint.url);
|
|
771
|
+
}
|
|
772
|
+
// ④ 进程端口 + 显式额外端口(**不含** static 兜底:13 个请求不该出现在请求路径上)
|
|
773
|
+
const plan = planCandidatesForProbe({
|
|
774
|
+
relayDir, env, platform, extraPorts, listeners,
|
|
775
|
+
includeStatic: false, includeListen: true, includeProcess: false
|
|
776
|
+
});
|
|
777
|
+
const hintPort = portOf(hint.url);
|
|
778
|
+
const entries = plan.entries.filter((e) => e.port !== hintPort && !isNegativelyCached(e.port));
|
|
779
|
+
const outcomes = await mapLimit(entries, MAX_PROBE_CONCURRENCY, async (e) => {
|
|
780
|
+
const ok = await probeDshWeb(`http://127.0.0.1:${e.port}`, { timeoutMs, fetchImpl });
|
|
781
|
+
if (!ok) rememberNegative(e.port);
|
|
782
|
+
return { ...e, ok };
|
|
783
|
+
});
|
|
784
|
+
const hit = outcomes.find((r) => r && r.ok);
|
|
785
|
+
if (hit) {
|
|
786
|
+
const url = `http://127.0.0.1:${hit.port}`;
|
|
787
|
+
rememberVerified(url, hit.source);
|
|
788
|
+
logDiscovery(log, { found: true, source: hit.source, probed: entries.length, rejected: rejected.length, skipped: plan.skipped, note: "fast" });
|
|
789
|
+
return { url, source: hit.source, probed: entries.map((e) => e.port), rejected, skipped: plan.skipped, fast: true };
|
|
790
|
+
}
|
|
791
|
+
return { url: "", source: "", probed: entries.map((e) => e.port), rejected, skipped: plan.skipped, fast: true };
|
|
408
792
|
}
|
|
409
793
|
|
|
410
794
|
/**
|
|
411
|
-
*
|
|
795
|
+
* **完整发现**(后台/诊断用):fast 的全部来源 + 静态候选(3080、Desktop 区间)。
|
|
412
796
|
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
797
|
+
* 所有候选探测并发上限 16、单端口 300ms;被判定"不是 dsh"的端口进 10 分钟负缓存。
|
|
798
|
+
* 命中后写已验证缓存(`discoverUpstreamFast` 下一次直接秒回)。
|
|
799
|
+
*
|
|
800
|
+
* @returns {Promise<{url:string, source:string, probed:number[], rejected:string[], skipped:number, truncated?:number}>}
|
|
801
|
+
* source: env | file | dsh_web_url | process | listen | extra | static | fallback
|
|
416
802
|
*/
|
|
417
|
-
export async function
|
|
803
|
+
export async function discoverUpstreamFull({
|
|
418
804
|
relayDir = "",
|
|
419
805
|
env = process.env,
|
|
420
806
|
timeoutMs = PROBE_TIMEOUT_MS,
|
|
@@ -422,32 +808,98 @@ export async function discoverUpstream({
|
|
|
422
808
|
platform = process.platform,
|
|
423
809
|
extraPorts = [],
|
|
424
810
|
listenPorts = null,
|
|
811
|
+
listeners = null,
|
|
425
812
|
log
|
|
426
813
|
} = {}) {
|
|
427
814
|
const hint = resolveUpstreamHint({ relayDir, env });
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
815
|
+
if (hint.source === "env") {
|
|
816
|
+
rememberVerified(hint.url, "env");
|
|
817
|
+
return { url: hint.url, source: "env", probed: [], rejected: [], skipped: 0 };
|
|
818
|
+
}
|
|
819
|
+
const rejected = [];
|
|
431
820
|
if (hint.url && (await probeDshWeb(hint.url, { timeoutMs, fetchImpl }))) {
|
|
432
|
-
|
|
821
|
+
rememberVerified(hint.url, hint.source);
|
|
822
|
+
return { url: hint.url, source: hint.source, probed: [portOf(hint.url)], rejected, skipped: 0 };
|
|
433
823
|
}
|
|
824
|
+
if (hint.url) rejected.push(hint.url);
|
|
434
825
|
|
|
435
|
-
|
|
826
|
+
const plan = planCandidatesForProbe({ relayDir, env, platform, extraPorts, listenPorts, listeners });
|
|
436
827
|
const hintPort = portOf(hint.url);
|
|
437
|
-
const entries =
|
|
438
|
-
const
|
|
439
|
-
const
|
|
828
|
+
const entries = plan.entries.filter((e) => e.port !== hintPort && !isNegativelyCached(e.port));
|
|
829
|
+
const probedPorts = [];
|
|
830
|
+
const outcomes = await mapLimit(entries, MAX_PROBE_CONCURRENCY, async (e) => {
|
|
831
|
+
probedPorts.push(e.port);
|
|
832
|
+
const ok = await probeDshWeb(`http://127.0.0.1:${e.port}`, { timeoutMs, fetchImpl });
|
|
833
|
+
if (!ok) rememberNegative(e.port);
|
|
834
|
+
return { ...e, ok };
|
|
835
|
+
});
|
|
836
|
+
const hit = outcomes.find((r) => r && r.ok);
|
|
440
837
|
if (hit) {
|
|
441
838
|
const url = `http://127.0.0.1:${hit.port}`;
|
|
442
|
-
|
|
443
|
-
|
|
839
|
+
rememberVerified(url, hit.source);
|
|
840
|
+
logDiscovery(log, { found: true, source: hit.source, probed: entries.length, rejected: rejected.length, skipped: plan.skipped });
|
|
841
|
+
return { url, source: hit.source, probed: probedPorts, rejected, skipped: plan.skipped, truncated: plan.truncated };
|
|
444
842
|
}
|
|
445
|
-
|
|
446
|
-
// ④ 都没找到:保持历史行为(3080),让上层按"上游没起来"正常重试
|
|
843
|
+
logDiscovery(log, { found: false, source: hint.url ? hint.source : "fallback", probed: entries.length, rejected: rejected.length, skipped: plan.skipped });
|
|
447
844
|
return {
|
|
448
845
|
url: hint.url || FALLBACK_UPSTREAM,
|
|
449
846
|
source: hint.url ? hint.source : "fallback",
|
|
450
|
-
probed:
|
|
451
|
-
rejected
|
|
847
|
+
probed: probedPorts,
|
|
848
|
+
rejected,
|
|
849
|
+
skipped: plan.skipped,
|
|
850
|
+
truncated: plan.truncated
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* 发现上游地址(**顶层 API,默认不阻塞**)。
|
|
856
|
+
*
|
|
857
|
+
* 语义(2026-11-07 提速):
|
|
858
|
+
* 1. 先跑 `discoverUpstreamFast()` —— 正常情况 <50ms 返回;
|
|
859
|
+
* 2. fast 没命中时才做一次完整发现,但最多等 `maxWaitMs`(默认 600ms):
|
|
860
|
+
* 完成就返回完整结果;超时则返回 fast/hint 的结果(`pending:true`),完整发现转到后台,
|
|
861
|
+
* 结果写缓存,供**下一次**调用秒回(并避免每个请求都重新全量探测)。
|
|
862
|
+
*
|
|
863
|
+
* 需要"一次调用就等到完整结果"(诊断/测试)时传 `{ wait: true }` 或直接用 `discoverUpstreamFull()`。
|
|
864
|
+
*
|
|
865
|
+
* @param {{wait?:boolean, maxWaitMs?:number}} [options]
|
|
866
|
+
* @returns {Promise<{url: string, source: string, probed: number[], rejected: string[], skipped: number, pending?:boolean}>}
|
|
867
|
+
* source: env | file | dsh_web_url | process | listen | static | extra | fallback
|
|
868
|
+
*/
|
|
869
|
+
export async function discoverUpstream(options = {}) {
|
|
870
|
+
if (options.wait === true || options.full === true) return discoverUpstreamFull(options);
|
|
871
|
+
const maxWaitMs = Number.isFinite(Number(options.maxWaitMs)) ? Math.max(0, Number(options.maxWaitMs)) : 600;
|
|
872
|
+
let fast = null;
|
|
873
|
+
try {
|
|
874
|
+
fast = await discoverUpstreamFast(options);
|
|
875
|
+
} catch { fast = null; }
|
|
876
|
+
if (fast && fast.url) return { ...fast, pending: false };
|
|
877
|
+
|
|
878
|
+
// fast 没命中:给完整发现一个**有界**窗口(静态候选也就 13 个,正常远小于预算)
|
|
879
|
+
let timer = null;
|
|
880
|
+
let timedOut = false;
|
|
881
|
+
const full = discoverUpstreamFull(options).catch(() => null);
|
|
882
|
+
bgDiscoveryInflight = full.finally(() => { bgDiscoveryInflight = null; });
|
|
883
|
+
const bounded = await Promise.race([
|
|
884
|
+
full,
|
|
885
|
+
new Promise((resolve) => {
|
|
886
|
+
timer = setTimeout(() => { timedOut = true; resolve(null); }, maxWaitMs);
|
|
887
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
888
|
+
})
|
|
889
|
+
]);
|
|
890
|
+
if (timer) { try { clearTimeout(timer); } catch { /* 忽略 */ } }
|
|
891
|
+
if (bounded && bounded.url) return { ...bounded, pending: false };
|
|
892
|
+
if (timedOut) {
|
|
893
|
+
// 超时:完整发现继续在后台跑(结果进缓存);本次先返回 fast/hint 结果
|
|
894
|
+
void full;
|
|
895
|
+
}
|
|
896
|
+
const hint = resolveUpstreamHint({ relayDir: options.relayDir, env: options.env });
|
|
897
|
+
return {
|
|
898
|
+
url: (fast && fast.url) || hint.url || FALLBACK_UPSTREAM,
|
|
899
|
+
source: (fast && fast.source) || hint.source || "fallback",
|
|
900
|
+
probed: (fast && fast.probed) || [],
|
|
901
|
+
rejected: (fast && fast.rejected) || [],
|
|
902
|
+
skipped: (fast && fast.skipped) || 0,
|
|
903
|
+
pending: true
|
|
452
904
|
};
|
|
453
905
|
}
|