@mrrisega/dsh-remote 0.6.9 → 0.6.10-beta.2
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/README.md +2 -1
- package/clients/dsh-remote/dsh-bridge.mjs +148 -9
- package/clients/dsh-remote/dsh-events.mjs +1981 -0
- package/clients/dsh-remote/src/lifecycle.mjs +42 -0
- package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
- package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
- package/clients/dsh-remote/test/machine-fingerprint.test.mjs +148 -0
- package/clients/dsh-remote/test/wechat-channel.test.mjs +2033 -0
- package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +626 -0
- package/clients/dsh-remote/wechat-channel.mjs +2705 -0
- package/clients/dsh-remote/wechat-runtime.mjs +1260 -0
- package/docs/telemetry.md +31 -2
- package/dsh-setup.mjs +134 -40
- package/package.json +6 -5
- package/packages/dsh-remote-web/lib/client.js +799 -65
- package/packages/dsh-remote-web/lib/index.js +460 -10
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
- package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
- package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
- package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
- package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +866 -0
- package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
- 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/
|
|
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,15 +127,25 @@ const TOKEN = process.env.DSH_BRIDGE_TOKEN || "";
|
|
|
117
127
|
|
|
118
128
|
// ---------- 本机稳定指纹(同机重装识别;供服务端自动顶替旧设备) ----------
|
|
119
129
|
|
|
120
|
-
/**
|
|
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") {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
// ⚠️ 必须用**绝对路径**:由 launchd 拉起时 PATH 来自 plist(实测 /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin),
|
|
139
|
+
// **不含 /usr/sbin** —— 而 ioreg 恰好就在那里。用裸名会得到
|
|
140
|
+
// "/bin/sh: ioreg: command not found"(真机日志实测),于是这里静默返回 "",
|
|
141
|
+
// 指纹退化成 hostname 哈希:用户改一次主机名就被服务端当成新设备 —— 与 Windows 那条是同一个 bug。
|
|
142
|
+
for (const ioreg of ["/usr/sbin/ioreg", "ioreg"]) {
|
|
143
|
+
try {
|
|
144
|
+
const out = execSync(`${ioreg} -rd1 -c IOPlatformExpertDevice`, { encoding: "utf8", timeout: 5000 });
|
|
145
|
+
const m = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out);
|
|
146
|
+
if (m && m[1]) return m[1];
|
|
147
|
+
} catch { /* 换下一个候选 */ }
|
|
148
|
+
}
|
|
129
149
|
} else if (process.platform === "linux") {
|
|
130
150
|
for (const f of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
131
151
|
try {
|
|
@@ -133,8 +153,32 @@ function machineUniqueId() {
|
|
|
133
153
|
if (s) return s;
|
|
134
154
|
} catch { /* 继续 */ }
|
|
135
155
|
}
|
|
156
|
+
} else if (process.platform === "win32") {
|
|
157
|
+
// Windows 既没有 IOPlatformUUID 也没有 machine-id,取注册表 MachineGuid
|
|
158
|
+
//(Windows 安装时生成、之后稳定)—— 语义与 Linux 的 machine-id 一致:都是"每次安装一个",
|
|
159
|
+
// 所以"重装 dsh-remote 后仍认成同一台设备"这个唯一用途完全成立。
|
|
160
|
+
//
|
|
161
|
+
// 为什么不用 PowerShell/WMI 的 Win32_ComputerSystemProduct.UUID:
|
|
162
|
+
// · reg.exe 恒定存在于 System32,启动约 20ms;PowerShell 启动要几百毫秒;
|
|
163
|
+
// · PowerShell 在受限语言模式 / AppLocker 的企业机器上可能被策略直接禁掉,
|
|
164
|
+
// 而拿不到指纹时只能退回 hostname(用户一改主机名就被当成新设备)—— 正是要修的问题;
|
|
165
|
+
// · 走 spawnSync 传数组参数,不经过 cmd.exe,因而没有引号/空格/中文路径的转义坑。
|
|
166
|
+
// 同样不赌 PATH:优先 System32 下的绝对路径,取不到再退回裸名(macOS 那条就是这么栽的)。
|
|
167
|
+
const regCandidates = [];
|
|
168
|
+
if (process.env.SystemRoot) regCandidates.push(`${process.env.SystemRoot}\\System32\\reg.exe`);
|
|
169
|
+
regCandidates.push("reg");
|
|
170
|
+
for (const reg of regCandidates) {
|
|
171
|
+
try {
|
|
172
|
+
const r = spawnSync(reg, ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"],
|
|
173
|
+
{ encoding: "utf8", timeout: 5000, windowsHide: true });
|
|
174
|
+
const m = r && r.status === 0
|
|
175
|
+
? /MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{16,})/.exec(String(r.stdout || ""))
|
|
176
|
+
: null;
|
|
177
|
+
if (m && m[1]) return m[1];
|
|
178
|
+
} catch { /* 换下一个候选 */ }
|
|
179
|
+
}
|
|
136
180
|
}
|
|
137
|
-
return ""; // 读不到(
|
|
181
|
+
return ""; // 读不到(如容器/受限环境) → 回退宿主名指纹
|
|
138
182
|
}
|
|
139
183
|
/** 稳定指纹:机器唯一值哈希;同机卸载重装后不变。 */
|
|
140
184
|
function machineFingerprint() {
|
|
@@ -216,7 +260,15 @@ function hardenFile(file) {
|
|
|
216
260
|
try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
|
|
217
261
|
if (process.platform !== "win32") return;
|
|
218
262
|
const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
|
|
219
|
-
|
|
263
|
+
// 两个环境变量都取不到时**绝不能静默返回**:那会让人以为文件已加固,实际仍是继承 ACL。
|
|
264
|
+
// 正常 Windows 会话不会走到这里(USERNAME 必然存在),但受限令牌/服务账户下有可能。
|
|
265
|
+
if (!who) {
|
|
266
|
+
if (!hardenWarned) {
|
|
267
|
+
hardenWarned = true;
|
|
268
|
+
console.warn(`⚠️ 无法收紧文件权限(USERDOMAIN/USERNAME 均未设置):${file}`);
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
220
272
|
let r;
|
|
221
273
|
try {
|
|
222
274
|
r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
|
|
@@ -1158,6 +1210,70 @@ async function initE2ee(token) {
|
|
|
1158
1210
|
}
|
|
1159
1211
|
}
|
|
1160
1212
|
|
|
1213
|
+
/**
|
|
1214
|
+
* 启动微信通道(控制面 + 已绑定时的事件通知/入站长轮询)。
|
|
1215
|
+
*
|
|
1216
|
+
* ★ 为什么**不 await**、且放在 connectTunnel 之后:
|
|
1217
|
+
* 微信侧是"锦上添花"的能力(发通知)。它必须**永远不能**拖慢或拖挂远程访问本身 ——
|
|
1218
|
+
* 所以先让隧道连上,再 fire-and-forget 起微信;任何异常只记日志,不冒泡、不 exit。
|
|
1219
|
+
* 与 e2ee/mobile-adapter 的定位一致:主链路优先。
|
|
1220
|
+
*/
|
|
1221
|
+
/** 给没自带前缀的微信日志补上 `[wechat] `;已带的原样返回(避免双重前缀)。 */
|
|
1222
|
+
function tagWeChat(m) {
|
|
1223
|
+
const s = String(m);
|
|
1224
|
+
return s.startsWith("[") ? s : `[wechat] ${s}`;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
let wechatRuntime = null;
|
|
1228
|
+
function startWeChat() {
|
|
1229
|
+
if (WECHAT_DISABLED) {
|
|
1230
|
+
console.log("[bridge] 微信通道已禁用(DSH_WECHAT=0)");
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
try {
|
|
1234
|
+
const relayDir = path.dirname(CONFIG_PATH);
|
|
1235
|
+
const cfg = loadLocalConfig();
|
|
1236
|
+
wechatRuntime = createWeChatRuntime({
|
|
1237
|
+
relayDir,
|
|
1238
|
+
upstream: UPSTREAM,
|
|
1239
|
+
cookieOf: harnessCookieOf,
|
|
1240
|
+
secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
|
|
1241
|
+
// 模块自己的消息已带 `[wechat]` / `[wechat/events]` 前缀,这里**不能再加一次**
|
|
1242
|
+
// (真机日志里出现过 `[wechat] [wechat] 控制面已就绪`)。没前缀的兜底补一个。
|
|
1243
|
+
logger: {
|
|
1244
|
+
info: (m) => console.log(tagWeChat(m)),
|
|
1245
|
+
warn: (m) => console.warn(tagWeChat(m)),
|
|
1246
|
+
error: (m) => console.warn(tagWeChat(m)),
|
|
1247
|
+
debug: () => {},
|
|
1248
|
+
// WeChatChannel 构造时会给 token 登记脱敏;这里保持同样契约(日志不落 token)
|
|
1249
|
+
addSecret: () => {}
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
wechatRuntime.start().catch((e) => {
|
|
1253
|
+
console.warn(`[bridge] 微信通道启动失败(不影响远程访问): ${redactText(e)}`);
|
|
1254
|
+
});
|
|
1255
|
+
} catch (e) {
|
|
1256
|
+
console.warn(`[bridge] 微信通道初始化失败(不影响远程访问): ${redactText(e)}`);
|
|
1257
|
+
wechatRuntime = null;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/** 优雅退出微信通道(notifystop + 关控制面)。失败只记日志。 */
|
|
1262
|
+
async function stopWeChat() {
|
|
1263
|
+
const rt = wechatRuntime;
|
|
1264
|
+
wechatRuntime = null;
|
|
1265
|
+
if (!rt) return;
|
|
1266
|
+
try {
|
|
1267
|
+
await rt.stop();
|
|
1268
|
+
} catch (e) {
|
|
1269
|
+
console.warn(`[bridge] 微信通道停止失败(忽略): ${redactText(e)}`);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function redactText(e) {
|
|
1274
|
+
return String(e && e.message ? e.message : e).slice(0, 200);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1161
1277
|
async function runTunnel() {
|
|
1162
1278
|
const token = await resolveToken();
|
|
1163
1279
|
if (!token) {
|
|
@@ -1169,6 +1285,8 @@ async function runTunnel() {
|
|
|
1169
1285
|
await initE2ee(token);
|
|
1170
1286
|
await registerDeviceInAccount(token);
|
|
1171
1287
|
connectTunnel(token);
|
|
1288
|
+
// 隧道已发起后再起微信:主链路优先,微信永不阻塞远程访问
|
|
1289
|
+
startWeChat();
|
|
1172
1290
|
setTimeout(() => {
|
|
1173
1291
|
console.log(`[bridge] 隧道模式运行中(上游 ${UPSTREAM},Ctrl-C 退出)`);
|
|
1174
1292
|
}, 1000);
|
|
@@ -1180,6 +1298,27 @@ async function main() {
|
|
|
1180
1298
|
console.error("[bridge] 缺少 DSH_BRIDGE_TUNNEL_URL:隧道模式是唯一模式(请设 relay-router 地址)");
|
|
1181
1299
|
process.exit(1);
|
|
1182
1300
|
}
|
|
1301
|
+
// ⚠️ 本文件此前**一个 process.on 都没有** —— 被 launchd/systemd/schtasks 杀掉时,
|
|
1302
|
+
// 微信长轮询不会收到信号,腾讯侧会一直以为通道在线(notifystop 永远不发)。
|
|
1303
|
+
// 只注册在 main() 里(被测试 import 时不污染测试进程)。
|
|
1304
|
+
let shuttingDown = false;
|
|
1305
|
+
const shutdown = async (sig) => {
|
|
1306
|
+
if (shuttingDown) return;
|
|
1307
|
+
shuttingDown = true;
|
|
1308
|
+
console.log(`[bridge] 收到 ${sig},正在优雅退出...`);
|
|
1309
|
+
await stopWeChat();
|
|
1310
|
+
process.exit(0);
|
|
1311
|
+
};
|
|
1312
|
+
process.on("SIGTERM", () => { shutdown("SIGTERM"); });
|
|
1313
|
+
process.on("SIGINT", () => { shutdown("SIGINT"); });
|
|
1314
|
+
// 【Windows 的关键一条】Windows 上 process.kill() 是**无条件 TerminateProcess**:
|
|
1315
|
+
// 上面的 SIGTERM/SIGINT 处理函数在 Windows 上**永远不会被执行**,
|
|
1316
|
+
// 于是 notifystop(微信通道下线通知)永远发不出去,腾讯侧一直以为通道还在线。
|
|
1317
|
+
// watcher 通过 IPC 发来的 shutdown 消息是 Windows 上唯一能真正跑到的优雅退出路径
|
|
1318
|
+
//(POSIX 上也一并走这条,比信号更可靠)。见 src/lifecycle.mjs 的 stopChildGracefully。
|
|
1319
|
+
process.on("message", (m) => {
|
|
1320
|
+
if (m && m.type === "shutdown") shutdown("IPC shutdown");
|
|
1321
|
+
});
|
|
1183
1322
|
return runTunnel();
|
|
1184
1323
|
}
|
|
1185
1324
|
|