@mrrisega/dsh-remote 0.6.13 → 0.6.15
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 +124 -14
- package/clients/dsh-remote/dsh-events.mjs +22 -2
- package/clients/dsh-remote/e2ee-client.mjs +97 -13
- package/clients/dsh-remote/e2ee-shim-script.js +31 -3
- package/clients/dsh-remote/mobile-adapter.mjs +283 -1
- package/clients/dsh-remote/test/boot-tip.test.mjs +158 -0
- package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
- package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
- package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +18 -1
- package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
- package/clients/dsh-remote/test/upstream-discovery.test.mjs +196 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
- package/clients/dsh-remote/upstream-discovery.mjs +448 -0
- package/clients/dsh-remote/wechat-channel.mjs +86 -3
- package/clients/dsh-remote/wechat-runtime.mjs +253 -10
- package/dsh-setup.mjs +225 -23
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +31 -1
- package/packages/dsh-remote-web/lib/index.js +464 -15
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +124 -14
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +283 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +448 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +225 -23
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +16 -1
- package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
- package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +201 -0
- package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
- package/packages/dsh-remote-web/test/watcher-upstream-port.test.mjs +170 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上游(dsh web)地址发现 —— **单一实现**,watcher(dsh-setup.mjs)与 bridge(dsh-bridge.mjs)共用。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要它
|
|
5
|
+
*
|
|
6
|
+
* 桥接链路的两端(watcher 的存活探测、bridge 的转发目标)原本把上游写死
|
|
7
|
+
* `http://127.0.0.1:3080`(独立 `dsh web` 的默认端口)。但真实世界里端口是会变的:
|
|
8
|
+
*
|
|
9
|
+
* · `dsh web --port 8090` / profile 里配了 port / `--port 0` 让系统分配空闲端口;
|
|
10
|
+
* · **DSH Desktop**(Electron 壳 `dsh-plugin-desktop`)默认监听 **43120**,且被占用时按 +1 递增;
|
|
11
|
+
* · `dsh web` 重启后端口可能不同。
|
|
12
|
+
*
|
|
13
|
+
* 2026-09-23 用户实测(DSH Desktop 2.0.4 + 插件 0.6.10):上游在 43120,而 watcher 探 3080 →
|
|
14
|
+
* **永远探不到 → bridge 永远不被派生 → 面板永久停在「正在启动 Bridge…」**,设备从未登记。
|
|
15
|
+
* 这不是"某个用户环境特殊",而是**所有改了端口的用户**都会踩的同一类问题,所以要有通用发现。
|
|
16
|
+
*
|
|
17
|
+
* ## 发现顺序(先便宜后昂贵,每一步都做**身份校验**)
|
|
18
|
+
*
|
|
19
|
+
* 1. `DSH_BRIDGE_UPSTREAM`(显式覆盖,部署/排障用)—— 唯一不做探测就采信的来源;
|
|
20
|
+
* 2. `<relayDir>/.dsh-upstream`(**插件半**在宿主进程里用 `ctx.webServer.port` 落盘,最权威);
|
|
21
|
+
* 3. `DSH_WEB_URL` 的端口;
|
|
22
|
+
* 4. **实测端口**:本进程的祖先进程 / 本机 dsh 进程正在 LISTEN 的端口(lsof / netstat);
|
|
23
|
+
* 5. 静态候选:3080 + DSH Desktop 的 43120 及其递增区间。
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ 第 4、5 步必须**校验身份**:43120 之类的端口可能被别的本地程序占用,
|
|
26
|
+
* 把 bridge 接到一个陌生人身上既不可用、也可能泄露数据。判据见 `looksLikeDshWeb`。
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ 探测全部**并行**且有总预算:本地端口连接被拒是瞬时的,只有"端口在听但不回包"才会等到超时,
|
|
29
|
+
* 串行探 12 个候选最坏要十几秒,会让 watcher 的轮询卡住。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { execFileSync } from "node:child_process";
|
|
35
|
+
|
|
36
|
+
/** 插件半落盘的上游地址文件名(与 packages/dsh-remote-web/lib/index.js 的常量同名同义)。 */
|
|
37
|
+
export const UPSTREAM_FILE = ".dsh-upstream";
|
|
38
|
+
/** 独立 `dsh web` 的默认端口(历史行为,最后的兜底)。 */
|
|
39
|
+
export const FALLBACK_UPSTREAM = "http://127.0.0.1:3080";
|
|
40
|
+
/** DSH Desktop(Electron 壳)的默认 Web 端口;被占用时它按 +1 递增,所以要扫一段区间。 */
|
|
41
|
+
export const DESKTOP_DEFAULT_PORT = 43120;
|
|
42
|
+
/** Desktop 端口区间扫多少个(43120..43120+11)。 */
|
|
43
|
+
export const DESKTOP_PORT_SCAN = 12;
|
|
44
|
+
/** 单个候选的探测超时(本地回环,超过它基本就是"在听但不回包")。 */
|
|
45
|
+
const PROBE_TIMEOUT_MS = 900;
|
|
46
|
+
|
|
47
|
+
/** 规范化:去尾斜杠;非 http(s) 一律丢弃(返回空串)。 */
|
|
48
|
+
export function normalizeUpstream(value) {
|
|
49
|
+
const raw = String(value ?? "").trim();
|
|
50
|
+
if (!raw) return "";
|
|
51
|
+
if (!/^https?:\/\/[^\s]+$/i.test(raw)) return "";
|
|
52
|
+
return raw.replace(/\/+$/, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 从字符串里取端口(拿不到返回 0)。 */
|
|
56
|
+
function portOf(value) {
|
|
57
|
+
try {
|
|
58
|
+
const n = Number(new URL(String(value)).port);
|
|
59
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
60
|
+
} catch {
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 1) 显式环境变量。 */
|
|
66
|
+
export function upstreamFromEnv(env = process.env) {
|
|
67
|
+
return normalizeUpstream(env.DSH_BRIDGE_UPSTREAM);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 2) 插件半落盘的端口文件(插件不在场/首次安装时可能没有)。 */
|
|
71
|
+
export function upstreamFromFile(relayDir) {
|
|
72
|
+
if (!relayDir) return "";
|
|
73
|
+
try {
|
|
74
|
+
return normalizeUpstream(fs.readFileSync(path.join(relayDir, UPSTREAM_FILE), "utf8"));
|
|
75
|
+
} catch {
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 3) DSH_WEB_URL 的端口(bridge 的 loopback 围栏只认 127.0.0.1,这里也统一回环地址)。 */
|
|
81
|
+
export function upstreamFromDshWebUrl(env = process.env) {
|
|
82
|
+
const raw = String(env.DSH_WEB_URL || "").trim();
|
|
83
|
+
if (!raw) return "";
|
|
84
|
+
const port = portOf(raw);
|
|
85
|
+
return port ? `http://127.0.0.1:${port}` : "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 按优先级取出"提示地址"(不做任何网络探测)。
|
|
90
|
+
* @returns {{url: string, source: "env"|"file"|"dsh_web_url"|""}}
|
|
91
|
+
*/
|
|
92
|
+
export function resolveUpstreamHint({ relayDir = "", env = process.env } = {}) {
|
|
93
|
+
const explicit = upstreamFromEnv(env);
|
|
94
|
+
if (explicit) return { url: explicit, source: "env" };
|
|
95
|
+
const fromFile = upstreamFromFile(relayDir);
|
|
96
|
+
if (fromFile) return { url: fromFile, source: "file" };
|
|
97
|
+
const fromWebUrl = upstreamFromDshWebUrl(env);
|
|
98
|
+
if (fromWebUrl) return { url: fromWebUrl, source: "dsh_web_url" };
|
|
99
|
+
return { url: "", source: "" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------- 实测端口(best effort,任何失败都只是少一个候选,绝不影响主流程) ----------
|
|
103
|
+
|
|
104
|
+
function tryExec(cmd, args, timeoutMs = 4000) {
|
|
105
|
+
try {
|
|
106
|
+
return String(execFileSync(cmd, args, { encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"] }) || "");
|
|
107
|
+
} catch {
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 某进程正在 LISTEN 的 TCP 端口。macOS/Linux 用 lsof,Windows 用 netstat -ano。 */
|
|
113
|
+
export function listeningPortsOfPid(pid, platform = process.platform) {
|
|
114
|
+
const n = Number(pid);
|
|
115
|
+
if (!Number.isInteger(n) || n <= 0) return [];
|
|
116
|
+
const ports = new Set();
|
|
117
|
+
if (platform === "win32") {
|
|
118
|
+
for (const line of tryExec("netstat", ["-ano"], 6000).split(/\r?\n/)) {
|
|
119
|
+
if (!/LISTENING/i.test(line)) continue;
|
|
120
|
+
const cols = line.trim().split(/\s+/);
|
|
121
|
+
if (cols.length < 5) continue;
|
|
122
|
+
if (Number(cols[cols.length - 1]) !== n) continue;
|
|
123
|
+
const m = /:(\d+)$/.exec(cols[1] || "");
|
|
124
|
+
if (m) ports.add(Number(m[1]));
|
|
125
|
+
}
|
|
126
|
+
return [...ports];
|
|
127
|
+
}
|
|
128
|
+
const out = tryExec("lsof", ["-nP", "-a", "-p", String(n), "-iTCP", "-sTCP:LISTEN"], 5000);
|
|
129
|
+
for (const line of out.split("\n")) {
|
|
130
|
+
const m = /:(\d+)\s*\(LISTEN\)/.exec(line);
|
|
131
|
+
if (m) ports.add(Number(m[1]));
|
|
132
|
+
}
|
|
133
|
+
return [...ports];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 祖先进程 pid(最多 6 层):被插件/宿主拉起的 watcher、bridge 走这条路能找到宿主。 */
|
|
137
|
+
export function ancestorPids(startPid = process.pid, maxDepth = 6, platform = process.platform) {
|
|
138
|
+
if (platform === "win32") return []; // Windows 无 ps;靠 netstat+pgrep 不可行,交给端口文件与静态候选
|
|
139
|
+
const out = [];
|
|
140
|
+
let pid = Number(startPid);
|
|
141
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
142
|
+
const txt = tryExec("ps", ["-o", "ppid=", "-p", String(pid)], 2000).trim();
|
|
143
|
+
const ppid = Number(txt);
|
|
144
|
+
if (!Number.isInteger(ppid) || ppid <= 1) break;
|
|
145
|
+
out.push(ppid);
|
|
146
|
+
pid = ppid;
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 本机 dsh 相关进程(pgrep)—— Desktop 壳/独立 dsh web 都可能命中。 */
|
|
152
|
+
export function dshProcessPids(platform = process.platform) {
|
|
153
|
+
if (platform === "win32") return [];
|
|
154
|
+
const out = tryExec("pgrep", ["-f", "dsh"], 4000);
|
|
155
|
+
return out
|
|
156
|
+
.split(/\s+/)
|
|
157
|
+
.map((s) => Number(s))
|
|
158
|
+
.filter((n) => Number.isInteger(n) && n > 1 && n !== process.pid);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------- 「本机所有回环监听端口」:NAS 上的救命稻草(0.6.15) ----------
|
|
162
|
+
//
|
|
163
|
+
// 【为什么必须加这一层】飞牛 fnOS 用户诊断(2026-09-25,插件 0.6.14):
|
|
164
|
+
// 上游 dsh web: http://127.0.0.1:3080(默认值 3080(**未取到 dsh web 实际监听端口**),⚠️ 不可达)
|
|
165
|
+
// 他的 dsh web 实际在 **2298**(fnOS 应用里配的内部监听端口),而上面五条发现路径**全都没命中**:
|
|
166
|
+
// · 插件半的 ctx.webServer.port 没取到(该环境下拿不到);
|
|
167
|
+
// · `lsof` 在 NAS 上**根本没装**;`pgrep -f dsh` 也可能没有;
|
|
168
|
+
// · 静态候选只有 3080 与 Desktop 的 43120 区间 —— 2298 永远不在里面。
|
|
169
|
+
// 于是用户去改端口号(改成 20xx、又改回 3080)当然都没用:**没有任何一条路径会去试 2298**。
|
|
170
|
+
//
|
|
171
|
+
// 做法:直接把"这台机器上正在 LISTEN 的回环端口"全列出来当候选,再逐个做**身份校验**。
|
|
172
|
+
// 为什么敢全列:身份校验(`looksLikeDshWeb` / `looksLikeDshRemoteSelf`)会拒掉所有陌生服务,
|
|
173
|
+
// 多探几个端口只是几十毫秒;而漏掉一个端口就是"用户永远连不上"。
|
|
174
|
+
|
|
175
|
+
/** 解析 /proc/net/tcp 一行里的本地地址与状态(Linux 无依赖:不需要 ss/netstat/lsof)。 */
|
|
176
|
+
function parseProcNetLine(line) {
|
|
177
|
+
const cols = String(line || "").trim().split(/\s+/);
|
|
178
|
+
if (cols.length < 4) return null;
|
|
179
|
+
const local = cols[1];
|
|
180
|
+
const state = cols[3];
|
|
181
|
+
const m = /^([0-9A-Fa-f]{1,32}):([0-9A-Fa-f]{1,4})$/.exec(local || "");
|
|
182
|
+
if (!m) return null;
|
|
183
|
+
return { hex: m[1], port: parseInt(m[2], 16), state: String(state).toUpperCase() };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 32 位小端 hex(/proc/net/tcp)→ IPv4 点分串。 */
|
|
187
|
+
function ipv4OfHex(hex) {
|
|
188
|
+
const h = String(hex).padStart(8, "0").slice(-8);
|
|
189
|
+
const bytes = [h.slice(6, 8), h.slice(4, 6), h.slice(2, 4), h.slice(0, 2)].map((x) => parseInt(x, 16));
|
|
190
|
+
return bytes.join(".");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 128 位 hex(/proc/net/tcp6)→ 是否回环(::1)。 */
|
|
194
|
+
function isIpv6LoopbackHex(hex) {
|
|
195
|
+
const h = String(hex).toLowerCase().padStart(32, "0");
|
|
196
|
+
// ::1 在 /proc 里按 4 组小端 32 位存放 → 末组为 01000000,其余全 0
|
|
197
|
+
return /^0{24}01000000$/.test(h);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Linux:直接读 /proc/net/tcp{,6}(内核真值,NAS 上不需要任何外部命令)。 */
|
|
201
|
+
export function procNetLoopbackPorts() {
|
|
202
|
+
const ports = new Set();
|
|
203
|
+
for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
|
204
|
+
let text = "";
|
|
205
|
+
try { text = fs.readFileSync(file, "utf8"); } catch { continue; }
|
|
206
|
+
for (const line of text.split("\n").slice(1)) {
|
|
207
|
+
const row = parseProcNetLine(line);
|
|
208
|
+
if (!row || row.state !== "0A") continue; // 0A = LISTEN
|
|
209
|
+
const loop = file.endsWith("tcp6")
|
|
210
|
+
? isIpv6LoopbackHex(row.hex)
|
|
211
|
+
: ipv4OfHex(row.hex).startsWith("127.");
|
|
212
|
+
// 通配绑定(0.0.0.0 / ::)也收:dsh web 绑 0.0.0.0 时回环照样能连
|
|
213
|
+
const wildcard = file.endsWith("tcp6") ? /^0+$/.test(String(row.hex)) : ipv4OfHex(row.hex) === "0.0.0.0";
|
|
214
|
+
if (loop || wildcard) ports.add(row.port);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return [...ports];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** `ss -ltnH` 输出里的本地端口(Linux;部分精简系统没有 /proc,或反之)。 */
|
|
221
|
+
function ssLoopbackPorts() {
|
|
222
|
+
const ports = new Set();
|
|
223
|
+
const out = tryExec("ss", ["-ltnH"], 5000) || tryExec("ss", ["-ltn"], 5000);
|
|
224
|
+
for (const line of out.split("\n")) {
|
|
225
|
+
const cols = line.trim().split(/\s+/);
|
|
226
|
+
if (cols.length < 4) continue;
|
|
227
|
+
// ss 的本地地址是第 4 列(State Recv-Q Send-Q Local Address:Port Peer Address:Port)
|
|
228
|
+
const m = /[:.](\d+)$/.exec(cols[3] || "");
|
|
229
|
+
if (m) ports.add(Number(m[1]));
|
|
230
|
+
}
|
|
231
|
+
return [...ports];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** `netstat -ltn` 输出里的本地端口(BSD/GNU 两种格式都认)。 */
|
|
235
|
+
function netstatLoopbackPorts() {
|
|
236
|
+
const ports = new Set();
|
|
237
|
+
const out = (tryExec("netstat", ["-ltn"], 5000) || tryExec("netstat", ["-an"], 6000));
|
|
238
|
+
for (const line of out.split("\n")) {
|
|
239
|
+
if (!/listen/i.test(line) && !/^tcp/i.test(line)) continue;
|
|
240
|
+
const m = /(?:^|\s)(?:\d{1,3}\.){3}\d{1,3}[:.](\d+)|\[?::1?\]?[:.](\d+)/.exec(line);
|
|
241
|
+
const p = m ? Number(m[1] || m[2]) : 0;
|
|
242
|
+
if (p > 0 && p < 65536) ports.add(p);
|
|
243
|
+
}
|
|
244
|
+
return [...ports];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** `lsof -nP -iTCP -sTCP:LISTEN` 输出里的本地端口(macOS/装了 lsof 的 Linux)。 */
|
|
248
|
+
function lsofLoopbackPorts() {
|
|
249
|
+
const ports = new Set();
|
|
250
|
+
const out = tryExec("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], 6000);
|
|
251
|
+
for (const line of out.split("\n")) {
|
|
252
|
+
const m = /(?:TCP\s+)?(?:\d{1,3}\.){3}\d{1,3}:(\d+)\s*\(LISTEN\)/.exec(line) || /TCP\s+\*:(\d+)/.exec(line);
|
|
253
|
+
if (m) ports.add(Number(m[1]));
|
|
254
|
+
}
|
|
255
|
+
return [...ports];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 本机所有回环(含通配绑定)LISTEN 端口,去重。
|
|
260
|
+
* 多来源合并而不是"取第一个成功的":NAS 上 lsof/ss/netstat/pgrep 各有缺失,
|
|
261
|
+
* 只有合起来才够全;重复端口由 Set 消化,代价只是多读几个文件。
|
|
262
|
+
*/
|
|
263
|
+
export function allLoopbackListeningPorts(platform = process.platform) {
|
|
264
|
+
const ports = new Set();
|
|
265
|
+
const push = (list) => { for (const p of list) { const n = Number(p); if (Number.isInteger(n) && n > 0 && n < 65536) ports.add(n); } };
|
|
266
|
+
if (platform === "win32") {
|
|
267
|
+
for (const line of tryExec("netstat", ["-ano"], 6000).split(/\r?\n/)) {
|
|
268
|
+
if (!/LISTENING/i.test(line)) continue;
|
|
269
|
+
const cols = line.trim().split(/\s+/);
|
|
270
|
+
const m = /:(\d+)$/.exec(cols[1] || "");
|
|
271
|
+
if (m) push([Number(m[1])]);
|
|
272
|
+
}
|
|
273
|
+
return [...ports];
|
|
274
|
+
}
|
|
275
|
+
if (platform === "linux") {
|
|
276
|
+
push(procNetLoopbackPorts()); // 无依赖,优先
|
|
277
|
+
push(ssLoopbackPorts());
|
|
278
|
+
}
|
|
279
|
+
push(lsofLoopbackPorts());
|
|
280
|
+
push(netstatLoopbackPorts());
|
|
281
|
+
return [...ports];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 候选列表(已去重,按"可能性"排序),每项带**来源标注**(用于日志与测试断言)。
|
|
286
|
+
* 顺序 = 提示里的端口 → 显式额外端口 → DSH_WEB_URL 的端口 → 祖先/dsh 进程实测监听端口
|
|
287
|
+
* → **本机所有回环监听端口** → 3080 → Desktop 默认端口区间。
|
|
288
|
+
*
|
|
289
|
+
* 「所有回环监听端口」排在静态候选之前:NAS 上 dsh web 的端口(实测 2298)**只可能**从这一层找到,
|
|
290
|
+
* 排在 3080 之后就等于白加(3080 探测失败之前不会去试它,而静态候选有 12 个 Desktop 端口)。
|
|
291
|
+
* 最后仍留静态候选兜底(宿主把端口藏得很深、/proc 也没有的极端情况)。
|
|
292
|
+
* @returns {Array<{port:number, source:string}>}
|
|
293
|
+
*/
|
|
294
|
+
export function candidatePorts({ relayDir = "", env = process.env, extraPorts = [], platform = process.platform, listenPorts = null } = {}) {
|
|
295
|
+
const out = [];
|
|
296
|
+
const seen = new Set();
|
|
297
|
+
const push = (p, source) => {
|
|
298
|
+
const n = Number(p);
|
|
299
|
+
if (!Number.isInteger(n) || n <= 0 || n >= 65536 || seen.has(n)) return;
|
|
300
|
+
seen.add(n);
|
|
301
|
+
out.push({ port: n, source });
|
|
302
|
+
};
|
|
303
|
+
const hint = resolveUpstreamHint({ relayDir, env });
|
|
304
|
+
push(portOf(hint.url), hint.source || "hint");
|
|
305
|
+
for (const p of extraPorts) push(p, "extra");
|
|
306
|
+
push(portOf(upstreamFromDshWebUrl(env)), "dsh_web_url");
|
|
307
|
+
const pids = [...new Set([process.pid, ...ancestorPids(process.pid, 6, platform), ...dshProcessPids(platform)])];
|
|
308
|
+
for (const pid of pids) for (const p of listeningPortsOfPid(pid, platform)) push(p, "process");
|
|
309
|
+
// ★ 本机所有回环 LISTEN 端口(0.6.15):NAS 上 dsh web 在 2298 这类非默认端口时唯一的发现路径。
|
|
310
|
+
// 调用方可以注入 listenPorts(测试用),避免用例依赖开发机真实监听表。
|
|
311
|
+
const listen = Array.isArray(listenPorts) ? listenPorts : allLoopbackListeningPorts(platform);
|
|
312
|
+
for (const p of listen) push(p, "listen");
|
|
313
|
+
push(3080, "static");
|
|
314
|
+
for (let i = 0; i < DESKTOP_PORT_SCAN; i++) push(DESKTOP_DEFAULT_PORT + i, "static");
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------- 身份校验 ----------
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 这个地址**真的**是 dsh web 吗?
|
|
322
|
+
*
|
|
323
|
+
* 为什么必须有:候选探测会去敲 43120 这类可能被别的程序占用的端口。只看"端口有响应"就把
|
|
324
|
+
* bridge 接上去,用户会得到一个"能连上但不工作"的怪状态,而且等于把这台机器的流量交给陌生服务。
|
|
325
|
+
* 判据取 dsh web 自身的稳定特征(HTML/标题/鉴权文案),任一命中即通过。
|
|
326
|
+
*/
|
|
327
|
+
export const DSH_WEB_MARKERS = [
|
|
328
|
+
"__ModuleLoader__",
|
|
329
|
+
"dsh-client-modules",
|
|
330
|
+
"deepseek harness",
|
|
331
|
+
"dsh web authentication required",
|
|
332
|
+
"dsh-remote"
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
export function looksLikeDshWeb(status, text) {
|
|
336
|
+
const body = String(text || "").toLowerCase();
|
|
337
|
+
if (!body) return false;
|
|
338
|
+
if (status >= 500) return false;
|
|
339
|
+
return DSH_WEB_MARKERS.some((m) => body.includes(m));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** 本插件自己的路由(`GET /dsh-remote/self`)—— 只有"宿主这台 dsh web"才会应答。 */
|
|
343
|
+
export const DSH_REMOTE_SELF_PATH = "/dsh-remote/self";
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 身份校验的**第二判据**:本插件自己的 `/dsh-remote/self`。
|
|
347
|
+
*
|
|
348
|
+
* 为什么需要它:`looksLikeDshWeb` 依赖 dsh web 首页的 HTML 特征(`__ModuleLoader__` 等),
|
|
349
|
+
* 而首页在有些部署下是**跳转/登录页/自定义门户**,特征一个字都不出现 —— 于是明明端口就是对的,
|
|
350
|
+
* 却被判成"陌生服务"。`/dsh-remote/self` 由**本插件**注册在同一个 web server 上,
|
|
351
|
+
* 返回 `{"ok":true,"version":…,"channel":…,"relayDir":…}`,是本机最硬的同一性证据
|
|
352
|
+
* (它跑在同一条隧道上,能应答就说明这个端口就是宿主的 dsh web)。
|
|
353
|
+
*/
|
|
354
|
+
export function looksLikeDshRemoteSelf(status, text) {
|
|
355
|
+
if (Number(status) !== 200) return false;
|
|
356
|
+
const body = String(text || "");
|
|
357
|
+
if (!body.includes("relayDir")) return false;
|
|
358
|
+
return body.includes('"channel"') || body.includes('"runtimeReady"') || body.includes("dsh-remote");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* 探测一个候选:先 GET `/`(dsh web 首页特征),不中再 GET `/dsh-remote/self`(本插件特征)。
|
|
363
|
+
* 两条判据任一命中即通过。
|
|
364
|
+
* @returns {Promise<boolean>}
|
|
365
|
+
*/
|
|
366
|
+
export async function probeDshWeb(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl } = {}) {
|
|
367
|
+
const target = normalizeUpstream(url);
|
|
368
|
+
if (!target) return false;
|
|
369
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
370
|
+
if (typeof doFetch !== "function") return false;
|
|
371
|
+
const once = async (path, check) => {
|
|
372
|
+
try {
|
|
373
|
+
const ac = new AbortController();
|
|
374
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
375
|
+
let res;
|
|
376
|
+
try {
|
|
377
|
+
// redirect: manual —— 不跟随跳转(跳走的多半不是我们要的宿主)
|
|
378
|
+
res = await doFetch(`${target}${path}`, { signal: ac.signal, redirect: "manual" });
|
|
379
|
+
} finally {
|
|
380
|
+
clearTimeout(timer);
|
|
381
|
+
}
|
|
382
|
+
const status = Number(res && res.status) || 0;
|
|
383
|
+
if (status >= 500) return false;
|
|
384
|
+
// 只读前 8KB 足够命中特征;读流失败按"不是"处理(宁可少一个候选,不可误接)
|
|
385
|
+
let text = "";
|
|
386
|
+
try {
|
|
387
|
+
if (res.body && typeof res.body.getReader === "function") {
|
|
388
|
+
const reader = res.body.getReader();
|
|
389
|
+
const { value } = await reader.read();
|
|
390
|
+
text = value ? Buffer.from(value).toString("utf8") : "";
|
|
391
|
+
try { await reader.cancel(); } catch { /* ignore */ }
|
|
392
|
+
} else if (typeof res.text === "function") {
|
|
393
|
+
text = await res.text();
|
|
394
|
+
}
|
|
395
|
+
} catch { /* 读失败 → text 为空 → 判否 */ }
|
|
396
|
+
return check(status, text.slice(0, 8192));
|
|
397
|
+
} catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
if (await once("/", looksLikeDshWeb)) return true;
|
|
402
|
+
return once(DSH_REMOTE_SELF_PATH, looksLikeDshRemoteSelf);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 发现上游地址(带身份校验)。
|
|
407
|
+
*
|
|
408
|
+
* @param {number[]} [extraPorts] 调用方已知的额外候选(排在静态候选之前;测试与特化部署用)。
|
|
409
|
+
* @returns {Promise<{url: string, source: string, probed: number[], rejected: string[]}>}
|
|
410
|
+
* source: env | file | dsh_web_url | process | static | extra | fallback
|
|
411
|
+
*/
|
|
412
|
+
export async function discoverUpstream({
|
|
413
|
+
relayDir = "",
|
|
414
|
+
env = process.env,
|
|
415
|
+
timeoutMs = PROBE_TIMEOUT_MS,
|
|
416
|
+
fetchImpl,
|
|
417
|
+
platform = process.platform,
|
|
418
|
+
extraPorts = [],
|
|
419
|
+
listenPorts = null,
|
|
420
|
+
log
|
|
421
|
+
} = {}) {
|
|
422
|
+
const hint = resolveUpstreamHint({ relayDir, env });
|
|
423
|
+
// ① 显式环境变量:部署方说是什么就是什么(不探测,保持既有语义)
|
|
424
|
+
if (hint.source === "env") return { url: hint.url, source: "env", probed: [], rejected: [] };
|
|
425
|
+
// ② 提示地址先验证:文件/DSh_WEB_URL 都可能过期(宿主换端口/换了程序占用)
|
|
426
|
+
if (hint.url && (await probeDshWeb(hint.url, { timeoutMs, fetchImpl }))) {
|
|
427
|
+
return { url: hint.url, source: hint.source, probed: [portOf(hint.url)], rejected: [] };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ③ 候选并行探测(本地端口,拒绝是瞬时的;并行是为了不让 watcher 卡住)
|
|
431
|
+
const hintPort = portOf(hint.url);
|
|
432
|
+
const entries = candidatePorts({ relayDir, env, platform, extraPorts, listenPorts }).filter((e) => e.port !== hintPort);
|
|
433
|
+
const results = await Promise.all(entries.map(async (e) => ({ ...e, ok: await probeDshWeb(`http://127.0.0.1:${e.port}`, { timeoutMs, fetchImpl }) })));
|
|
434
|
+
const hit = results.find((r) => r.ok);
|
|
435
|
+
if (hit) {
|
|
436
|
+
const url = `http://127.0.0.1:${hit.port}`;
|
|
437
|
+
if (typeof log === "function") log(`[upstream] 动态发现上游 ${url}(来源 ${hit.source};提示 ${hint.url || "无"} 不可用)`);
|
|
438
|
+
return { url, source: hit.source, probed: entries.map((e) => e.port), rejected: hint.url ? [hint.url] : [] };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ④ 都没找到:保持历史行为(3080),让上层按"上游没起来"正常重试
|
|
442
|
+
return {
|
|
443
|
+
url: hint.url || FALLBACK_UPSTREAM,
|
|
444
|
+
source: hint.url ? hint.source : "fallback",
|
|
445
|
+
probed: entries.map((e) => e.port),
|
|
446
|
+
rejected: hint.url ? [hint.url] : []
|
|
447
|
+
};
|
|
448
|
+
}
|
|
@@ -429,9 +429,32 @@ export class IlinkClient {
|
|
|
429
429
|
this.clientVersion = resolveClientVersion(opts.clientVersion);
|
|
430
430
|
this.logger = opts.logger || createLogger();
|
|
431
431
|
this.fetchImpl = opts.fetch || ((...a) => globalThis.fetch(...a));
|
|
432
|
+
/**
|
|
433
|
+
* 出站要回带的会话上下文令牌(来自**最近一条入站消息**的 `context_token`)。
|
|
434
|
+
* 与 `contextTokenPeer` 成对使用:只回带给**同一个对端**,避免把 A 的上下文发给 B。
|
|
435
|
+
* 由 WeChatChannel 在收到入站消息时写入并在启动时从状态文件恢复。
|
|
436
|
+
*/
|
|
437
|
+
this.contextToken = String(opts.contextToken || "");
|
|
438
|
+
this.contextTokenPeer = String(opts.contextTokenPeer || "");
|
|
432
439
|
if (this.token) this.logger.addSecret(this.token);
|
|
433
440
|
}
|
|
434
441
|
|
|
442
|
+
/** 清掉会话上下文令牌(解绑/换绑时必须调用:旧令牌属于上一个人/上一个会话)。 */
|
|
443
|
+
clearContextToken() {
|
|
444
|
+
this.contextToken = "";
|
|
445
|
+
this.contextTokenPeer = "";
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** 记下/更新会话上下文令牌(入站消息带来;sendMessage 会回带)。 */
|
|
449
|
+
setContextToken(token, peerUserId) {
|
|
450
|
+
const t = String(token || "").trim();
|
|
451
|
+
if (!t) return false;
|
|
452
|
+
this.contextToken = t;
|
|
453
|
+
this.contextTokenPeer = String(peerUserId || "");
|
|
454
|
+
this.logger.addSecret(t); // 日志同样按密文处理(它等价于会话凭据)
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
|
|
435
458
|
/** 换基址(scaned_but_redirect 用)。 */
|
|
436
459
|
setBaseUrl(url) {
|
|
437
460
|
this.baseUrl = String(url).replace(/\/+$/, "");
|
|
@@ -675,13 +698,25 @@ export class IlinkClient {
|
|
|
675
698
|
*/
|
|
676
699
|
async sendMessage({ to, text, clientId, timeoutMs = DEFAULT_API_TIMEOUT_MS, signal } = {}) {
|
|
677
700
|
if (!to) throw new WeChatError("bad_options", "sendMessage: 缺少 to(to_user_id)");
|
|
701
|
+
// ★ 回带 `context_token`:平台要求出站消息带上"这条会话的上下文令牌",而它只出现在
|
|
702
|
+
// **入站消息**里(官方实现见 messaging/send.js 的 buildTextMessageReq:`msg.context_token`;
|
|
703
|
+
// 腾讯自家插件同样是「从入站取出 → 按 (账号, 用户) 缓存 → 发送时回带」)。
|
|
704
|
+
// 🔴 我们以前**从不发它** → 真机症状就是日志里的 `ret=-2 errmsg=prepare failed`:
|
|
705
|
+
// 消息发不出去(业主 2026-09-23:「没收到微信通道」)。
|
|
706
|
+
// ⚠️ 只在"有令牌且属于同一个对端"时带上,绝不拿 A 的上下文发给 B。
|
|
707
|
+
const peer = String(to);
|
|
708
|
+
// 本产品一个 bot 只绑定一个微信用户,所以只要有令牌就回带;`contextTokenPeer` 仅作诊断。
|
|
709
|
+
// ⚠️ 换绑会换人 → unbind 时必须**清掉**旧令牌(见 clearContextToken),否则会拿旧上下文发。
|
|
710
|
+
const ctxToken = this.contextToken || "";
|
|
678
711
|
const msg = {
|
|
679
712
|
from_user_id: "",
|
|
680
|
-
to_user_id:
|
|
713
|
+
to_user_id: peer,
|
|
681
714
|
client_id: clientId || newClientId(),
|
|
682
715
|
message_type: MessageType.BOT,
|
|
683
716
|
message_state: MessageState.FINISH,
|
|
684
|
-
item_list: text ? [{ type: MessageItemType.TEXT, text_item: { text: String(text) } }] : []
|
|
717
|
+
item_list: text ? [{ type: MessageItemType.TEXT, text_item: { text: String(text) } }] : [],
|
|
718
|
+
// 没有就**不出现这个键**(不是空串)—— 与官方 `contextToken ?? undefined` 同口径
|
|
719
|
+
...(ctxToken ? { context_token: ctxToken } : {})
|
|
685
720
|
};
|
|
686
721
|
const { json } = await this.request({
|
|
687
722
|
method: "POST",
|
|
@@ -2396,6 +2431,23 @@ export function extractFromUserId(msg) {
|
|
|
2396
2431
|
return "";
|
|
2397
2432
|
}
|
|
2398
2433
|
|
|
2434
|
+
/**
|
|
2435
|
+
* 取出入站消息里的会话上下文令牌 `context_token`。
|
|
2436
|
+
*
|
|
2437
|
+
* 为什么必须取:平台要求**出站**消息回带它(官方实现 `msg.context_token`),
|
|
2438
|
+
* 而它只出现在入站消息里 —— 不回带的后果真机实测是
|
|
2439
|
+
* `sendMessage: ret=-2 errmsg=prepare failed`(消息根本发不出去)。
|
|
2440
|
+
* 形状未验证(§11),所以防御式:非字符串/空串一律当"没有",绝不猜。
|
|
2441
|
+
*/
|
|
2442
|
+
export function extractContextToken(msg) {
|
|
2443
|
+
if (!msg || typeof msg !== "object") return "";
|
|
2444
|
+
for (const k of ["context_token", "contextToken"]) {
|
|
2445
|
+
const v = msg[k];
|
|
2446
|
+
if (typeof v === "string" && v.trim()) return v.trim();
|
|
2447
|
+
}
|
|
2448
|
+
return "";
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2399
2451
|
/**
|
|
2400
2452
|
* 别名 → 规范指令名。
|
|
2401
2453
|
* ⚠️ 编排层是按**规范名**分派的(`cmd === "/ls"`),所以别名必须在 `classifyInbound()`
|
|
@@ -2705,6 +2757,32 @@ export class WeChatChannel {
|
|
|
2705
2757
|
this.cooldown = new SessionCooldown({ cooldownMs: opts.cooldownMs, clock: this.clock });
|
|
2706
2758
|
this.registry = new EventRegistry({ clock: this.clock });
|
|
2707
2759
|
this.updatesBuf = "";
|
|
2760
|
+
// ★ 恢复上次记住的 context_token:不恢复的话,bridge 一重启就要等用户先发一条消息
|
|
2761
|
+
// 才重新具备"能发出去"的能力 —— 而重启后的第一条通知恰恰是最需要送达的那条。
|
|
2762
|
+
this.#restoreContextToken();
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
/** 从状态文件恢复 context_token(启动时 / 换绑后各调一次)。 */
|
|
2766
|
+
#restoreContextToken() {
|
|
2767
|
+
try {
|
|
2768
|
+
const st = loadState(this.relayDir);
|
|
2769
|
+
if (st.context_token) this.client.setContextToken(st.context_token, st.context_token_peer || "");
|
|
2770
|
+
} catch { /* 状态文件读不了就当没有:不影响主流程 */ }
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
/**
|
|
2774
|
+
* 记下入站消息带来的 `context_token`(发送时回带它才发得出去,见 sendMessage 注释)。
|
|
2775
|
+
* 落盘持久化:重启/换实例后仍然能发;同时**按对端成对保存**,绝不跨对端复用。
|
|
2776
|
+
* @returns {boolean} 是否有变化(无变化不重复写盘)
|
|
2777
|
+
*/
|
|
2778
|
+
rememberContextToken(token, peerUserId) {
|
|
2779
|
+
const t = String(token || "").trim();
|
|
2780
|
+
if (!t) return false;
|
|
2781
|
+
const peer = String(peerUserId || "");
|
|
2782
|
+
if (this.client.contextToken === t && this.client.contextTokenPeer === peer) return false;
|
|
2783
|
+
this.client.setContextToken(t, peer);
|
|
2784
|
+
this.writeState({ context_token: t, context_token_peer: peer });
|
|
2785
|
+
return true;
|
|
2708
2786
|
}
|
|
2709
2787
|
|
|
2710
2788
|
/** 面板接口用:永不回显 token(§8)。 */
|
|
@@ -2743,6 +2821,8 @@ export class WeChatChannel {
|
|
|
2743
2821
|
logger: this.logger,
|
|
2744
2822
|
fetch: this.client.fetchImpl
|
|
2745
2823
|
});
|
|
2824
|
+
// 换绑会**新建 client** → 必须把会话上下文令牌重新挂上(否则换绑后第一条推送就发不出去)
|
|
2825
|
+
this.#restoreContextToken();
|
|
2746
2826
|
this.writeState({
|
|
2747
2827
|
bound: true,
|
|
2748
2828
|
bot_id: account.accountId,
|
|
@@ -2777,7 +2857,10 @@ export class WeChatChannel {
|
|
|
2777
2857
|
const cleared = clearAccount(this.relayDir);
|
|
2778
2858
|
this.account = null;
|
|
2779
2859
|
this.client.setToken("");
|
|
2780
|
-
|
|
2860
|
+
// ★ 会话上下文令牌属于**上一个人/上一个会话**:解绑必须一并清掉,
|
|
2861
|
+
// 否则换绑后第一条推送会拿旧上下文去发 —— 表现还是"发不出去"。
|
|
2862
|
+
this.client.clearContextToken();
|
|
2863
|
+
this.writeState({ bound: false, bot_id: "", bound_at: 0, connected_at: 0, last_error: notifyError, context_token: "", context_token_peer: "" });
|
|
2781
2864
|
// ★ 解绑同样清冷却:凭据都删了,再"退避"没有任何意义 ——
|
|
2782
2865
|
// 留着只会让用户重新绑定时继续被挡(见 adoptConfirmed 的注释)。
|
|
2783
2866
|
this.cooldown.clear();
|