@mrrisega/dsh-remote 0.6.9-beta.3 → 0.6.10-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.
Files changed (31) hide show
  1. package/README.md +2 -1
  2. package/clients/dsh-remote/dsh-bridge.mjs +131 -4
  3. package/clients/dsh-remote/dsh-events.mjs +1981 -0
  4. package/clients/dsh-remote/e2ee-shim-script.js +21 -8
  5. package/clients/dsh-remote/src/lifecycle.mjs +42 -0
  6. package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
  7. package/clients/dsh-remote/test/e2ee-shim.test.mjs +48 -0
  8. package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
  9. package/clients/dsh-remote/test/machine-fingerprint.test.mjs +120 -0
  10. package/clients/dsh-remote/test/wechat-channel.test.mjs +2033 -0
  11. package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
  12. package/clients/dsh-remote/test/wechat-runtime.test.mjs +626 -0
  13. package/clients/dsh-remote/wechat-channel.mjs +2705 -0
  14. package/clients/dsh-remote/wechat-runtime.mjs +1260 -0
  15. package/docs/telemetry.md +31 -2
  16. package/dsh-setup.mjs +134 -40
  17. package/package.json +6 -5
  18. package/packages/dsh-remote-web/lib/client.js +799 -65
  19. package/packages/dsh-remote-web/lib/index.js +460 -10
  20. package/packages/dsh-remote-web/package.json +1 -1
  21. package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
  22. package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
  23. package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
  24. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
  25. package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
  26. package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
  27. package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
  28. package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
  29. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +866 -0
  30. package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
  31. package/packages/dsh-remote-web/test/windows-compat.test.mjs +117 -0
package/README.md CHANGED
@@ -185,7 +185,8 @@ npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访
185
185
  生产诊断发现「注册了但设备一直没连上」的用户没有任何账号数据、无法归因,因此插件内置了一条
186
186
  **匿名装机统计**通道,只上报**装机与连接是否成功**:
187
187
 
188
- - **采**:事件名(安装开始/失败原因/运行环境就绪/bridge 是否注册成功/首次远程打通/面板打开/重启/更新)、
188
+ - **采**:事件名(安装开始/失败原因/运行环境就绪/bridge 是否注册成功/首次远程打通/面板打开/重启/更新、
189
+ **微信机器人通道的绑定/解绑**)、
189
190
  失败码(白名单,如 `npm_unreachable`、`npm_eacces`、`runtime_install_timeout`)、插件版本号、
190
191
  系统平台(`darwin`/`linux`/`win32`)、架构(`arm64`/`x64`)、node 主版本号,
191
192
  以及一个**本机随机 ID**(`crypto.randomUUID()`,非硬件派生、重装即变、不可跨机器关联);
@@ -23,7 +23,11 @@
23
23
  * bridge 转发时:Host 由 fetch/ws 自动取上游 authority(127.0.0.1:3080,满足 loopback 围栏),
24
24
  * 显式剥离 Origin、Sec-Fetch-*、Cookie、Referer 等浏览器标记,保证通过 dsh web 的信任围栏。
25
25
  *
26
- * ── WebSocket 透传(覆盖 /api/events.mux|host 下行流) ──
26
+ * ── WebSocket 透传(覆盖 /api/remote.mux|host 下行流) ──
27
+ * ⚠️ 修正(2026-09-20):此处原写 /api/events.mux —— 该路径在本版 DSH 里**不存在**
28
+ * (全包 grep 零命中),真路径是 /api/remote.mux(@deepseek-ai/dsh-api-gateway 的
29
+ * REMOTE_STREAM_MUX_PATH)。线上未出问题是因为本文件对路径**透明透传**、由浏览器决定
30
+ * 请求哪个路径;但错的注释会把后来者带沟里(微信通道订阅事件流时会照着它写)。
27
31
  * → { "id", "type":"ws-open", "path", "headers":{...} } ← { "id","type":"ws-open","ok":true|false,"code"?,"reason"? }
28
32
  * → { "id", "type":"ws-msg", "data":<文本|base64>, "binary"? }
29
33
  * ← { "id", "type":"ws-msg", "data":<文本|base64>, "binary"? }
@@ -86,6 +90,10 @@ import {
86
90
  parseWsE2eeParams,
87
91
  writeE2eeStateFile
88
92
  } from "./e2ee-client.mjs";
93
+ // 微信机器人通道(编排层):绑定控制面 + 出站通知路由 + 入站长轮询。
94
+ // ⚠️ 微信流量只走「腾讯 ilink ↔ 本机」,**不经我们的 relay**;DSH 事件走本机回环。
95
+ // 总开关 DSH_WECHAT=0(默认开启);未绑定时只起控制面,不起长轮询。
96
+ import { createWeChatRuntime } from "./wechat-runtime.mjs";
89
97
 
90
98
  // ---------- 强制直连:清除代理环境变量 ----------
91
99
  // 家庭网络常配 Clash 等代理(127.0.0.1:7890),node 的 ws/fetch 会继承
@@ -107,6 +115,8 @@ const CONFIG_PATH = process.env.DSH_BRIDGE_CONFIG || DEFAULT_CONFIG;
107
115
  const TUNNEL_URL = (process.env.DSH_BRIDGE_TUNNEL_URL || "").replace(/\/+$/, "");
108
116
  const TUNNEL_HEARTBEAT_MS = Math.max(100, Number(process.env.DSH_BRIDGE_HEARTBEAT_MS) || 15_000);
109
117
  const UPSTREAM = process.env.DSH_BRIDGE_UPSTREAM || "http://127.0.0.1:3080";
118
+ // 微信机器人通道总开关:DSH_WECHAT=0 关闭(默认开启)。控制面只 bind 回环,且必须带 bridge_secret。
119
+ const WECHAT_DISABLED = String(process.env.DSH_WECHAT || "") === "0";
110
120
  // 默认云端服务地址(dsh-remote setup 会显式传入;自建模式无需账号 API)
111
121
  const API_BASE = (process.env.DSH_BRIDGE_API || "https://n.risegao.cn:13443/relay-api").replace(/\/+$/, "");
112
122
  const EMAIL = process.env.DSH_BRIDGE_EMAIL || "";
@@ -117,7 +127,11 @@ const TOKEN = process.env.DSH_BRIDGE_TOKEN || "";
117
127
 
118
128
  // ---------- 本机稳定指纹(同机重装识别;供服务端自动顶替旧设备) ----------
119
129
 
120
- /** 读取与安装无关的机器级唯一值:macOS IOPlatformUUID / Linux machine-id。 */
130
+ /**
131
+ * 读取与安装无关的机器级唯一值:macOS IOPlatformUUID / Linux machine-id / Windows MachineGuid。
132
+ * 用途只有一个:同机卸载重装后被服务端认成同一台设备(自动顶替旧设备记录)。
133
+ * 拿到的值**只在本机哈希**(见 machineFingerprint),原文绝不上报。
134
+ */
121
135
  function machineUniqueId() {
122
136
  if (process.env.DSH_BRIDGE_MACHINE_FP) return String(process.env.DSH_BRIDGE_MACHINE_FP).slice(0, 64);
123
137
  if (process.platform === "darwin") {
@@ -133,8 +147,26 @@ function machineUniqueId() {
133
147
  if (s) return s;
134
148
  } catch { /* 继续 */ }
135
149
  }
150
+ } else if (process.platform === "win32") {
151
+ // Windows 既没有 IOPlatformUUID 也没有 machine-id,取注册表 MachineGuid
152
+ //(Windows 安装时生成、之后稳定)—— 语义与 Linux 的 machine-id 一致:都是"每次安装一个",
153
+ // 所以"重装 dsh-remote 后仍认成同一台设备"这个唯一用途完全成立。
154
+ //
155
+ // 为什么不用 PowerShell/WMI 的 Win32_ComputerSystemProduct.UUID:
156
+ // · reg.exe 恒定存在于 System32,启动约 20ms;PowerShell 启动要几百毫秒;
157
+ // · PowerShell 在受限语言模式 / AppLocker 的企业机器上可能被策略直接禁掉,
158
+ // 而拿不到指纹时只能退回 hostname(用户一改主机名就被当成新设备)—— 正是要修的问题;
159
+ // · 走 spawnSync 传数组参数,不经过 cmd.exe,因而没有引号/空格/中文路径的转义坑。
160
+ try {
161
+ const r = spawnSync("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"],
162
+ { encoding: "utf8", timeout: 5000, windowsHide: true });
163
+ const m = r && r.status === 0
164
+ ? /MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{16,})/.exec(String(r.stdout || ""))
165
+ : null;
166
+ if (m && m[1]) return m[1];
167
+ } catch { /* 兜底 */ }
136
168
  }
137
- return ""; // 读不到(如容器) → 回退宿主名指纹
169
+ return ""; // 读不到(如容器/受限环境) → 回退宿主名指纹
138
170
  }
139
171
  /** 稳定指纹:机器唯一值哈希;同机卸载重装后不变。 */
140
172
  function machineFingerprint() {
@@ -216,7 +248,15 @@ function hardenFile(file) {
216
248
  try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
217
249
  if (process.platform !== "win32") return;
218
250
  const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
219
- if (!who) return;
251
+ // 两个环境变量都取不到时**绝不能静默返回**:那会让人以为文件已加固,实际仍是继承 ACL。
252
+ // 正常 Windows 会话不会走到这里(USERNAME 必然存在),但受限令牌/服务账户下有可能。
253
+ if (!who) {
254
+ if (!hardenWarned) {
255
+ hardenWarned = true;
256
+ console.warn(`⚠️ 无法收紧文件权限(USERDOMAIN/USERNAME 均未设置):${file}`);
257
+ }
258
+ return;
259
+ }
220
260
  let r;
221
261
  try {
222
262
  r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
@@ -1158,6 +1198,70 @@ async function initE2ee(token) {
1158
1198
  }
1159
1199
  }
1160
1200
 
1201
+ /**
1202
+ * 启动微信通道(控制面 + 已绑定时的事件通知/入站长轮询)。
1203
+ *
1204
+ * ★ 为什么**不 await**、且放在 connectTunnel 之后:
1205
+ * 微信侧是"锦上添花"的能力(发通知)。它必须**永远不能**拖慢或拖挂远程访问本身 ——
1206
+ * 所以先让隧道连上,再 fire-and-forget 起微信;任何异常只记日志,不冒泡、不 exit。
1207
+ * 与 e2ee/mobile-adapter 的定位一致:主链路优先。
1208
+ */
1209
+ /** 给没自带前缀的微信日志补上 `[wechat] `;已带的原样返回(避免双重前缀)。 */
1210
+ function tagWeChat(m) {
1211
+ const s = String(m);
1212
+ return s.startsWith("[") ? s : `[wechat] ${s}`;
1213
+ }
1214
+
1215
+ let wechatRuntime = null;
1216
+ function startWeChat() {
1217
+ if (WECHAT_DISABLED) {
1218
+ console.log("[bridge] 微信通道已禁用(DSH_WECHAT=0)");
1219
+ return;
1220
+ }
1221
+ try {
1222
+ const relayDir = path.dirname(CONFIG_PATH);
1223
+ const cfg = loadLocalConfig();
1224
+ wechatRuntime = createWeChatRuntime({
1225
+ relayDir,
1226
+ upstream: UPSTREAM,
1227
+ cookieOf: harnessCookieOf,
1228
+ secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
1229
+ // 模块自己的消息已带 `[wechat]` / `[wechat/events]` 前缀,这里**不能再加一次**
1230
+ // (真机日志里出现过 `[wechat] [wechat] 控制面已就绪`)。没前缀的兜底补一个。
1231
+ logger: {
1232
+ info: (m) => console.log(tagWeChat(m)),
1233
+ warn: (m) => console.warn(tagWeChat(m)),
1234
+ error: (m) => console.warn(tagWeChat(m)),
1235
+ debug: () => {},
1236
+ // WeChatChannel 构造时会给 token 登记脱敏;这里保持同样契约(日志不落 token)
1237
+ addSecret: () => {}
1238
+ }
1239
+ });
1240
+ wechatRuntime.start().catch((e) => {
1241
+ console.warn(`[bridge] 微信通道启动失败(不影响远程访问): ${redactText(e)}`);
1242
+ });
1243
+ } catch (e) {
1244
+ console.warn(`[bridge] 微信通道初始化失败(不影响远程访问): ${redactText(e)}`);
1245
+ wechatRuntime = null;
1246
+ }
1247
+ }
1248
+
1249
+ /** 优雅退出微信通道(notifystop + 关控制面)。失败只记日志。 */
1250
+ async function stopWeChat() {
1251
+ const rt = wechatRuntime;
1252
+ wechatRuntime = null;
1253
+ if (!rt) return;
1254
+ try {
1255
+ await rt.stop();
1256
+ } catch (e) {
1257
+ console.warn(`[bridge] 微信通道停止失败(忽略): ${redactText(e)}`);
1258
+ }
1259
+ }
1260
+
1261
+ function redactText(e) {
1262
+ return String(e && e.message ? e.message : e).slice(0, 200);
1263
+ }
1264
+
1161
1265
  async function runTunnel() {
1162
1266
  const token = await resolveToken();
1163
1267
  if (!token) {
@@ -1169,6 +1273,8 @@ async function runTunnel() {
1169
1273
  await initE2ee(token);
1170
1274
  await registerDeviceInAccount(token);
1171
1275
  connectTunnel(token);
1276
+ // 隧道已发起后再起微信:主链路优先,微信永不阻塞远程访问
1277
+ startWeChat();
1172
1278
  setTimeout(() => {
1173
1279
  console.log(`[bridge] 隧道模式运行中(上游 ${UPSTREAM},Ctrl-C 退出)`);
1174
1280
  }, 1000);
@@ -1180,6 +1286,27 @@ async function main() {
1180
1286
  console.error("[bridge] 缺少 DSH_BRIDGE_TUNNEL_URL:隧道模式是唯一模式(请设 relay-router 地址)");
1181
1287
  process.exit(1);
1182
1288
  }
1289
+ // ⚠️ 本文件此前**一个 process.on 都没有** —— 被 launchd/systemd/schtasks 杀掉时,
1290
+ // 微信长轮询不会收到信号,腾讯侧会一直以为通道在线(notifystop 永远不发)。
1291
+ // 只注册在 main() 里(被测试 import 时不污染测试进程)。
1292
+ let shuttingDown = false;
1293
+ const shutdown = async (sig) => {
1294
+ if (shuttingDown) return;
1295
+ shuttingDown = true;
1296
+ console.log(`[bridge] 收到 ${sig},正在优雅退出...`);
1297
+ await stopWeChat();
1298
+ process.exit(0);
1299
+ };
1300
+ process.on("SIGTERM", () => { shutdown("SIGTERM"); });
1301
+ process.on("SIGINT", () => { shutdown("SIGINT"); });
1302
+ // 【Windows 的关键一条】Windows 上 process.kill() 是**无条件 TerminateProcess**:
1303
+ // 上面的 SIGTERM/SIGINT 处理函数在 Windows 上**永远不会被执行**,
1304
+ // 于是 notifystop(微信通道下线通知)永远发不出去,腾讯侧一直以为通道还在线。
1305
+ // watcher 通过 IPC 发来的 shutdown 消息是 Windows 上唯一能真正跑到的优雅退出路径
1306
+ //(POSIX 上也一并走这条,比信号更可靠)。见 src/lifecycle.mjs 的 stopChildGracefully。
1307
+ process.on("message", (m) => {
1308
+ if (m && m.type === "shutdown") shutdown("IPC shutdown");
1309
+ });
1183
1310
  return runTunnel();
1184
1311
  }
1185
1312