@mrrisega/dsh-remote 0.6.0-beta.12 → 0.6.0-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 +3 -43
- package/clients/dsh-remote/dsh-bridge.mjs +5 -335
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +183 -607
- package/packages/dsh-remote-web/lib/index.js +10 -139
- package/packages/dsh-remote-web/test/access-key-sessions.test.mjs +11 -81
- package/packages/dsh-remote-web/test/account-switch.test.mjs +1 -1
- package/packages/dsh-remote-web/test/feedback-proxy.test.mjs +1 -2
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +3 -175
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +5 -56
- package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +2 -2
- package/clients/dsh-remote/e2ee-client.mjs +0 -728
- package/clients/dsh-remote/e2ee-shim-script.js +0 -737
- package/clients/dsh-remote/e2ee-shim.mjs +0 -163
- package/clients/dsh-remote/mobile-adapter.mjs +0 -595
- package/clients/dsh-remote/test/bridge-html-inject.test.mjs +0 -110
- package/clients/dsh-remote/test/e2ee-bridge.test.mjs +0 -630
- package/clients/dsh-remote/test/e2ee-client.test.mjs +0 -378
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +0 -507
- package/clients/dsh-remote/test/e2ee-wecrypto-parity.test.mjs +0 -200
- package/clients/dsh-remote/test/lib-e2ee-shim.mjs +0 -59
- package/clients/dsh-remote/test/lib-native-e2ee.mjs +0 -92
- package/clients/dsh-remote/test/mobile-adapter.test.mjs +0 -124
- package/packages/dsh-remote-web/test/e2ee-state.test.mjs +0 -141
- package/packages/dsh-remote-web/test/feedback-account-mine.test.mjs +0 -70
- package/packages/dsh-remote-web/test/password-reset.test.mjs +0 -430
package/README.md
CHANGED
|
@@ -28,16 +28,6 @@ dsh-remote 是一个轻量的**隧道模式**远程控制方案:电脑端运
|
|
|
28
28
|
└→ bridge (电脑端) → 127.0.0.1:3080 (dsh web)
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
## 0.6.0-beta.12 速览(用户最关心的新能力)
|
|
32
|
-
|
|
33
|
-
- **扫码即加密,不再要求再输一次密码**:用电脑生成的二维码/一次性链接进入后,由电脑自动授权开启端到端加密;密码登录点设备也自动解锁。全程不输 E2EE 密码、不存服务端。
|
|
34
|
-
- **记住本机:刷新/重开不掉回明文**:解锁一次后,手机 App 刷新、镜像页刷新都自动恢复加密(退出登录即清除;浏览器无痕可不用此功能)。
|
|
35
|
-
- **移动端体验**:选完会话抽屉自动收起;加密状态徽标几秒后自动缩成小 🔒 不挡界面;修复了移动端设置里“模型提供方目录加载失败”。
|
|
36
|
-
- **电脑端侧栏**:官方「设置」旁新增「📱 远程访问」快捷按钮(与官方按钮共存、不遮挡),一键进入远程访问面板。
|
|
37
|
-
- **支付**:关闭收款码时会问“支付完成 / 取消支付”,便于尽快为你开通,不再挽留打扰。
|
|
38
|
-
- **评价只评一次**:按你的账号/设备只收集一次满意度。
|
|
39
|
-
- **更省流量更隐私**:面板不再明文显示手机号、不上报真实主机名;状态轮询间隔降到 15–30s(隐藏页面时暂停)。
|
|
40
|
-
|
|
41
31
|
## 界面预览
|
|
42
32
|
|
|
43
33
|
| 手机端进入 dsh web,100% 还原电脑端体验 | 手机端密钥登录后的在线设备列表 |
|
|
@@ -73,7 +63,7 @@ dsh-remote 是一个轻量的**隧道模式**远程控制方案:电脑端运
|
|
|
73
63
|
npx @mrrisega/dsh-remote
|
|
74
64
|
```
|
|
75
65
|
|
|
76
|
-
安装完成后**无需任何命令**:打开 dsh web → 设置 →
|
|
66
|
+
安装完成后**无需任何命令**:打开 dsh web → 设置 → 「远程控制」,注册/登录手机号即可
|
|
77
67
|
(注册在手机端完成,登录后 bridge 自动启动;自建用户在同一面板切「自建服务」标签)。
|
|
78
68
|
原来的独立设置页(`dsh-remote settings`)已移除,避免与插件面板重复造成困惑。
|
|
79
69
|
|
|
@@ -88,43 +78,13 @@ npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访
|
|
|
88
78
|
|
|
89
79
|
> **版本与更新 / 卸载**:dsh 官方插件市场目前不提供更新按钮,也不会改写用户补丁(因此市场
|
|
90
80
|
> 卸载会提示「仍通过 insert 引用 dsh-remote-web」而拒绝)。插件设置面板里已内置管理入口
|
|
91
|
-
> (dsh web → 设置 →
|
|
81
|
+
> (dsh web → 设置 → 「远程控制」→「🔄 版本与更新」卡片):显示当前版本、自动检测 npm 新版、
|
|
92
82
|
> **一键在线更新**(后台补运行环境并重启 bridge,完成后重启 dsh web 生效)、以及**彻底卸载**
|
|
93
83
|
> (移除补丁 include / 依赖 / bundle 与本地文件,之后市场卸载或直接重启均可完成卸载)。
|
|
94
84
|
|
|
95
85
|
源码安装(开发 / 自建服务器):`git clone https://github.com/mrRisega/dsh-remote.git`
|
|
96
86
|
并 `npm install`,见下文各组件说明。
|
|
97
87
|
|
|
98
|
-
## 安全与隐私
|
|
99
|
-
|
|
100
|
-
**传输与会话**
|
|
101
|
-
|
|
102
|
-
- 全程 HTTPS/WSS(TLS)加密传输;远程访问需先在电脑端登录你的手机号账号,登录会话不
|
|
103
|
-
在中继之外暴露,bridge 按需代持浏览器会话仅供你本人手机使用。
|
|
104
|
-
- 电脑端生成的访问链接为**一次性扫码登录**:30 分钟有效、访问一次即失效、可随时在
|
|
105
|
-
「已授权设备」里取消配对;链接不会二次使用,不用时也可点「刷新」立即作废旧链接。
|
|
106
|
-
|
|
107
|
-
**端到端加密(E2EE,灰度开启中)**
|
|
108
|
-
|
|
109
|
-
- 开启后,手机 ↔ 电脑之间远程操作的**消息内容**(对话、工具执行、审批、凭据与文件等
|
|
110
|
-
正文及 WebSocket 消息)在离开手机前用**你的账号密码派生密钥**加密、进入电脑后才解密;
|
|
111
|
-
中继(router/nginx/enterprise)只可见路由所需信息——目标路径、数据大小与时间——**无法读取内容**。
|
|
112
|
-
- **服务端与中继不保存你的密码明文,也不保存解密密钥**:只保存密码经不可逆 KDF 派生的
|
|
113
|
-
校验值用于登录,与加密密钥域分离(详见 [docs/e2ee-protocol.md](docs/e2ee-protocol.md))。
|
|
114
|
-
- 请**牢记账号密码**:修改/重置密码会使全部旧设备会话失效,且服务端不保存你的内容、
|
|
115
|
-
无法代为解密旧会话;改密后需在电脑端面板重新登录并重启 bridge,手机端重新解锁。
|
|
116
|
-
- 电脑端本机配置(`.dsh-config.json`,0600)会保存账号密码用于自动登录,等于该账号
|
|
117
|
-
内容的“解密权”,请妥善保护电脑;电脑被他人使用期间请退出登录。
|
|
118
|
-
|
|
119
|
-
**边界与建议(如实告知)**
|
|
120
|
-
|
|
121
|
-
- E2EE 保护的是**内容**:HTTPS 下的静态页面壳与路由元数据(页面骨架、路径、大小、时间、
|
|
122
|
-
是否加密)仍对中继可见;既有登录、配额与审计不受影响。
|
|
123
|
-
- E2EE 为**灰度功能**:以服务端开关逐步放量(默认关闭 = 走 HTTPS 明文回退),面板会显示
|
|
124
|
-
当前加密状态与原因(已启用 / 等待服务端开启 / 普通安全连接等),不会静默降级。
|
|
125
|
-
- 建议上线前用**真机回归**一次完整链路:手机登录解锁 → 🔒 加密访问(对话/工具/审批/凭据)→
|
|
126
|
-
明文回退提示 → 修改密码后旧会话全部失效、重新登录恢复。
|
|
127
|
-
|
|
128
88
|
## 自建部署(开源版)
|
|
129
89
|
|
|
130
90
|
1. 在有公网 HTTPS 入口的服务器上部署 `relay-router`(见 [docs/self-hosting.md](docs/self-hosting.md)):
|
|
@@ -149,7 +109,7 @@ npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访
|
|
|
149
109
|
| relay-router | `packages/relay-router/` | 中继服务器:bridge 注册表、实时设备列表、HTTP/WS 透明代理、配额 |
|
|
150
110
|
| bridge | `clients/dsh-remote/` | 电脑端守护进程:连 router 注册,把转发帧代理到本地 `dsh web`;心跳自愈 |
|
|
151
111
|
| PWA | `clients/dsh-web/native.html` | 手机端:登录 / 注册 / 设备选择(单文件,零构建) |
|
|
152
|
-
| dsh web 插件 | `packages/dsh-remote-web/` |
|
|
112
|
+
| dsh web 插件 | `packages/dsh-remote-web/` | 设置页「远程控制」面板:连接模式 / 账号 / bridge 启停 / 反馈 |
|
|
153
113
|
| 部署脚本 | `deploy/` | `install-open.sh` 自建引导、nginx 参考配置、Dockerfile / compose |
|
|
154
114
|
|
|
155
115
|
测试:`npm test`(router 契约 + 插件 + bridge 全部单测与回归)。
|
|
@@ -41,24 +41,6 @@
|
|
|
41
41
|
* DSH_BRIDGE_API 账号 API 地址(默认云端服务地址;自建模式无需设置)
|
|
42
42
|
* DSH_BRIDGE_LOCAL_KEY 开源自部署:访问密钥(设后经 router POST /_login 换本地 JWT,免账号体系)
|
|
43
43
|
* DSH_BRIDGE_HEARTBEAT_MS 隧道心跳间隔(默认 15000ms)
|
|
44
|
-
* DSH_MOBILE_ADAPTER 经隧道访问的官方 dsh web 移动端适配注入开关:0 关闭(默认开启,仅 ≤820px 生效)
|
|
45
|
-
*
|
|
46
|
-
* E2EE(Phase-2,见 docs/e2ee-protocol.md):
|
|
47
|
-
* - 账号模式(非 DSH_BRIDGE_LOCAL_KEY)启动时用 device-login 同一账号密码派生 MK(仅内存,
|
|
48
|
-
* 不落盘);服务端 /api/e2ee-params 返回 enabled=false / 401 / 无密码 / 派生失败 → 明文 v1 回退。
|
|
49
|
-
* - 开关与状态写入 <relayDir>/.e2ee-state.json {enabled, reason, profile, epoch, caps};
|
|
50
|
-
* DSH_BRIDGE_E2EE=0 或配置 e2ee:false 可本地关闭;DSH_BRIDGE_E2EE_STATE 可覆盖状态文件路径。
|
|
51
|
-
* - 帧体带信封标记(content-type: application/vnd.dsh.e2ee-v2 或 x-dsh-e2ee)= 已加密:
|
|
52
|
-
* HTTP 解封→转发上游→响应封回;WS 逐消息封/解。任何解封失败显式
|
|
53
|
-
* 502 + x-dsh-e2ee-error / ws-close 1008,绝不静默降级。
|
|
54
|
-
* - 控制通道 /_e2ee/ctrl(device/channel 形态)不连上游,跑 §5.2 握手(hello/ack/探针);
|
|
55
|
-
* 数据流 ws-open 携带 &e2ee=<sessId>&w=<8B hex>,探针通过前不转发任何数据。
|
|
56
|
-
*
|
|
57
|
-
* E2EE Phase-4(镜像页 shim,见 e2ee-shim.mjs / e2ee-shim-script.js):
|
|
58
|
-
* - doHttp 在 text/html 注入 mobile-adapter 之后、压缩/封包之前,对「e2ee.enabled ∧
|
|
59
|
-
* DSH_E2EE_SHIM≠0」的官方 dsh web 响应注入镜像页加密 shim(读 native.html 一次性
|
|
60
|
-
* 交接单重建会话 → 拦截其 /api·/sidebar·/git·/pet 与数据 WS,信封密文化)。
|
|
61
|
-
* - 灰度默认关(e2ee.enabled=false)→ 不注入、镜像页保持既有明文路径(零行为)。
|
|
62
44
|
*/
|
|
63
45
|
|
|
64
46
|
import WebSocket from "ws";
|
|
@@ -70,22 +52,6 @@ import { randomBytes, generateKeyPairSync, createHash } from "node:crypto";
|
|
|
70
52
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
71
53
|
import { promisify } from "node:util";
|
|
72
54
|
import { gzip as gzipCb } from "node:zlib";
|
|
73
|
-
// 移动端适配层(经隧道访问的官方 dsh web 窄屏注入;DSH_MOBILE_ADAPTER=0 可关闭,默认开启)
|
|
74
|
-
import { maybeInjectMobileAdapter } from "./mobile-adapter.mjs";
|
|
75
|
-
// 镜像页 E2EE 加密 shim(Phase-4):text/html 注入;DSH_E2EE_SHIM=0 可关闭,叠加 e2ee.enabled 灰度门
|
|
76
|
-
import { maybeInjectE2eeShim } from "./e2ee-shim.mjs";
|
|
77
|
-
// E2EE(端到端加密)客户端基建(Phase-2):MK 派生/会话密钥/信封/握手/开关
|
|
78
|
-
// 仅在 runTunnel(账号模式)里初始化;被测试 import(未走 main)时保持禁用 → v1 路径不变。
|
|
79
|
-
import {
|
|
80
|
-
E2eeError,
|
|
81
|
-
E2eeService,
|
|
82
|
-
ENVELOPE_CONTENT_TYPE,
|
|
83
|
-
decodeHttpRequestPlain,
|
|
84
|
-
encodeHttpResponsePlain,
|
|
85
|
-
hasEnvelopeMarker,
|
|
86
|
-
parseWsE2eeParams,
|
|
87
|
-
writeE2eeStateFile
|
|
88
|
-
} from "./e2ee-client.mjs";
|
|
89
55
|
|
|
90
56
|
// ---------- 强制直连:清除代理环境变量 ----------
|
|
91
57
|
// 家庭网络常配 Clash 等代理(127.0.0.1:7890),node 的 ws/fetch 会继承
|
|
@@ -225,16 +191,6 @@ const CHUNK_SIZE = 200 * 1024;
|
|
|
225
191
|
// 单个 HTTP 请求的兜底超时(秒)。dsh 的 prompt 等操作可能跑很久,给足余量。
|
|
226
192
|
const HTTP_TIMEOUT_MS = 120_000;
|
|
227
193
|
|
|
228
|
-
// ---------- E2EE 运行时(默认禁用;main() 启动时按配置/服务端开关初始化) ----------
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* E2EE 服务实例。enabled=false 时所有帧处理走原 v1 明文路径;
|
|
232
|
-
* 只有「账号模式 ∧ 配置未关闭 ∧ 服务端 e2ee.enabled ∧ 有密码且 MK 派生成功」才为 enabled。
|
|
233
|
-
*/
|
|
234
|
-
let e2ee = new E2eeService({ enabled: false, reason: "not_initialized" });
|
|
235
|
-
/** 控制通道(/_e2ee/ctrl)连接:frameId → { kind:"ctrl", sessId|null }(不连上游)。 */
|
|
236
|
-
const ctrlConns = new Map();
|
|
237
|
-
|
|
238
194
|
// 转发请求时剥离的浏览器/代理头(围栏只认 Host + Origin + Sec-Fetch-*):
|
|
239
195
|
// - Host:fetch 自动取上游 authority(127.0.0.1:3080)→ loopback 围栏通过;
|
|
240
196
|
// ws 库需要显式设置(见 buildWsHeaders)。
|
|
@@ -448,30 +404,12 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
448
404
|
}
|
|
449
405
|
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) });
|
|
450
406
|
let buf = Buffer.from(await res.arrayBuffer());
|
|
451
|
-
// 移动端适配层:text/html(含 </head> 且匹配官方特征)在 gzip 前注入响应式 <style>/<script>;
|
|
452
|
-
// 非 html / SSE / 二进制 / 上游已压缩等其余响应一律原样(env DSH_MOBILE_ADAPTER=0 关闭)。
|
|
453
407
|
// sanitizeResponseHeaders 会剥 content-encoding(undici 已解压,原头会误导浏览器);
|
|
454
408
|
// 若我们自行 gzip,必须在 sanitize 之后把 content-encoding: gzip 补回,手机才能正确解压。
|
|
455
409
|
const headers = sanitizeResponseHeaders(Object.fromEntries(res.headers.entries()));
|
|
456
|
-
const contentType = res.headers.get("content-type") || "";
|
|
457
|
-
if (!contentType.includes("text/event-stream")) { // SSE 长连不缓冲不注入(见模块注释)
|
|
458
|
-
const m = maybeInjectMobileAdapter({ buf, contentType });
|
|
459
|
-
if (m.injected) {
|
|
460
|
-
console.log(`[bridge] mobile-adapter 注入 ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(m.buf.length / 1024).toFixed(0)}KB`);
|
|
461
|
-
buf = m.buf;
|
|
462
|
-
}
|
|
463
|
-
// E2EE 镜像页加密 shim(Phase-4):仅桥端 e2ee 启用时注入(e2ee.enabled=false → 零注入零行为)
|
|
464
|
-
if (e2ee.enabled) {
|
|
465
|
-
const s = maybeInjectE2eeShim({ buf, contentType });
|
|
466
|
-
if (s.injected) {
|
|
467
|
-
console.log(`[bridge] e2ee-shim 注入 ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(s.buf.length / 1024).toFixed(0)}KB`);
|
|
468
|
-
buf = s.buf;
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
410
|
const compressed = await maybeCompressResponse({
|
|
473
411
|
buf,
|
|
474
|
-
contentType,
|
|
412
|
+
contentType: res.headers.get("content-type") || "",
|
|
475
413
|
contentEncoding: res.headers.get("content-encoding") || "",
|
|
476
414
|
acceptEncoding: headerValue(reqHeaders, "accept-encoding"),
|
|
477
415
|
status: res.status,
|
|
@@ -494,52 +432,6 @@ export async function handleHttpFrame(dchOrSend, frame) {
|
|
|
494
432
|
const send = toSender(dchOrSend);
|
|
495
433
|
const { id, method = "GET", path = "/", headers = {}, body, bodyBase64: isB64 } = frame;
|
|
496
434
|
const t0 = Date.now();
|
|
497
|
-
// 桌面授权引导(方案A):POST /_e2ee/intro(device/channel 形态归一)—— 本地应答,不连上游
|
|
498
|
-
if (method === "POST" && path === "/_e2ee/intro") {
|
|
499
|
-
return answerIntro(send, id);
|
|
500
|
-
}
|
|
501
|
-
// E2EE 信封标记即信号(§4.3):有标记=加密,无标记=明文 v1。
|
|
502
|
-
// 解封失败绝不静默降级 → 明文错误 + x-dsh-e2ee-error 头 + 日志。
|
|
503
|
-
if (hasEnvelopeMarker(headers)) {
|
|
504
|
-
if (!e2ee.enabled) {
|
|
505
|
-
console.error(`[bridge] e2ee http ${method} ${path}: bridge 未启用 E2EE,拒绝解封(不静默降级)`);
|
|
506
|
-
return sendE2eeHttpError(send, id, new E2eeError("e2ee_disabled", "bridge 端未启用端到端加密(请检查账号密码/服务端开关)"));
|
|
507
|
-
}
|
|
508
|
-
try {
|
|
509
|
-
const bodyText = Buffer.from(String(body || ""), "base64").toString("utf8");
|
|
510
|
-
let env;
|
|
511
|
-
try {
|
|
512
|
-
env = JSON.parse(bodyText);
|
|
513
|
-
} catch {
|
|
514
|
-
throw new E2eeError("bad_envelope", "帧体不是合法 E2EE 信封 JSON");
|
|
515
|
-
}
|
|
516
|
-
// 防重放 + 会话校验(unknown_session / not_verified / replay 在此抛出)
|
|
517
|
-
const session = e2ee.guardHttpRequest(String(env?.s || ""), env);
|
|
518
|
-
const opened = session.open({ kind: "http", dir: "p2b", env, counter: env?.c ?? 0 });
|
|
519
|
-
const req = decodeHttpRequestPlain(opened.data);
|
|
520
|
-
const reply = await doHttp(req.method, req.path, req.headers, req.bodyB64, true);
|
|
521
|
-
const bodyBuffer = Buffer.from(reply.body, "base64");
|
|
522
|
-
const plain = encodeHttpResponsePlain({ status: reply.status, headers: reply.headers, bodyBuffer });
|
|
523
|
-
// 响应压缩已发生在 doHttp(明文侧,gzip 在加密前 §4.7);信封用 http-resp kind
|
|
524
|
-
const respEnv = session.seal({ kind: "http-resp", dir: "b2p", counter: env?.c ?? 0, data: plain, reqNonceB64: String(env?.n || "") });
|
|
525
|
-
send({
|
|
526
|
-
id,
|
|
527
|
-
type: "http",
|
|
528
|
-
status: 200, // 外层一律 200,真实状态在信封明文 st 里(§4.3)
|
|
529
|
-
headers: {
|
|
530
|
-
"content-type": ENVELOPE_CONTENT_TYPE,
|
|
531
|
-
"x-dsh-e2ee": `v=2;s=${env.s};k=http-resp`
|
|
532
|
-
},
|
|
533
|
-
body: Buffer.from(JSON.stringify(respEnv)).toString("base64"),
|
|
534
|
-
bodyBase64: true
|
|
535
|
-
});
|
|
536
|
-
console.log(`[bridge] e2ee http ${req.method} ${req.path} → ${reply.status} (${Date.now() - t0}ms, 信封 ${(reply.body.length * 3 / 4 / 1024).toFixed(0)}KB)`);
|
|
537
|
-
return;
|
|
538
|
-
} catch (e) {
|
|
539
|
-
console.error(`[bridge] e2ee http ${method} ${path} 解封失败: ${e.message || e}(不回退明文)`);
|
|
540
|
-
return sendE2eeHttpError(send, id, e);
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
435
|
try {
|
|
544
436
|
const reply = await doHttp(method, path, headers, body, !!isB64);
|
|
545
437
|
reply.id = id;
|
|
@@ -552,50 +444,6 @@ export async function handleHttpFrame(dchOrSend, frame) {
|
|
|
552
444
|
}
|
|
553
445
|
}
|
|
554
446
|
|
|
555
|
-
/** 桌面授权引导(方案A):POST /_e2ee/intro —— 桥端本地应答,不连上游。
|
|
556
|
-
* 路由层已按 dsh_token cookie 完成同账号授权(device/channel 形态都只到本设备);
|
|
557
|
-
* 这里仅当桥端 E2EE 启用时一次性下发派生 MK(内存转发,不落盘不写日志)。 */
|
|
558
|
-
function answerIntro(send, id) {
|
|
559
|
-
let ok = false;
|
|
560
|
-
let body;
|
|
561
|
-
try {
|
|
562
|
-
const g = e2ee.introGrant();
|
|
563
|
-
body = { ok: true, v: 2, grant: "desktop-intro", mk: g.mk, profile: g.profile, epoch: g.epoch, ts: Date.now() };
|
|
564
|
-
ok = true;
|
|
565
|
-
console.log("[bridge] e2ee intro → 手机(同一账号,已授权;MK 仅内存一次性下发)");
|
|
566
|
-
} catch (e) {
|
|
567
|
-
const code = e instanceof E2eeError ? e.code : "e2ee_disabled";
|
|
568
|
-
body = { ok: false, v: 2, error: { code, message: e?.message || "电脑端未启用端到端加密,无法授权引导" } };
|
|
569
|
-
console.log(`[bridge] e2ee intro 被拒(${code}): ${e?.message || ""}`);
|
|
570
|
-
}
|
|
571
|
-
send({
|
|
572
|
-
id,
|
|
573
|
-
type: "http",
|
|
574
|
-
status: ok ? 200 : 409,
|
|
575
|
-
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
|
|
576
|
-
body: Buffer.from(JSON.stringify(body)).toString("base64"),
|
|
577
|
-
bodyBase64: true
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
/** E2EE 失败回包:明文(无信封标记)+ x-dsh-e2ee-error,绝不把无法解密的密文当正文转发。 */
|
|
582
|
-
function sendE2eeHttpError(send, id, err) {
|
|
583
|
-
const code = err instanceof E2eeError ? err.code : "e2ee_failed";
|
|
584
|
-
const message = `⚠ 无法解密/未加密(${code}): ${err?.message || code}`;
|
|
585
|
-
send({
|
|
586
|
-
id,
|
|
587
|
-
type: "http",
|
|
588
|
-
status: 502,
|
|
589
|
-
headers: {
|
|
590
|
-
"content-type": "application/json; charset=utf-8",
|
|
591
|
-
"cache-control": "no-store",
|
|
592
|
-
"x-dsh-e2ee-error": code
|
|
593
|
-
},
|
|
594
|
-
body: Buffer.from(JSON.stringify({ error: { code, message } })).toString("base64"),
|
|
595
|
-
bodyBase64: true
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
|
|
599
447
|
/** 旧协议(无 type):body 为原始文本,回包 body 为原始文本。 */
|
|
600
448
|
export async function handleLegacyFrame(dchOrSend, frame) {
|
|
601
449
|
const send = toSender(dchOrSend);
|
|
@@ -616,138 +464,24 @@ export async function handleLegacyFrame(dchOrSend, frame) {
|
|
|
616
464
|
// ---- WebSocket 透传 ----
|
|
617
465
|
|
|
618
466
|
// ws 会话表:frame id → ws 客户端。DataChannel 断开时统一关闭。
|
|
619
|
-
// - 普通/数据流:{ ws, opened, e2ee? };
|
|
620
|
-
// - e2ee 数据流额外带 e2ee:{ s(E2eeSession), wsLabel, p2bLast, b2pLast };
|
|
621
|
-
// - 控制通道(/_e2ee/ctrl)不连上游,单列 ctrlConns。
|
|
622
467
|
const wsSessions = new Map();
|
|
623
468
|
|
|
624
|
-
/** E2EE 错误码 → 手机可读文案(明文 e2ee-error;绝不静默)。 */
|
|
625
|
-
function e2eeCodeOf(err) {
|
|
626
|
-
return err instanceof E2eeError ? err.code : "e2ee_failed";
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
function sendCtrlMsg(send, id, obj) {
|
|
630
|
-
send({ id, type: "ws-msg", data: JSON.stringify(obj), binary: false });
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
function sendCtrlError(send, id, code, extra = "") {
|
|
634
|
-
console.error(`[bridge] e2ee ctrl 错误: ${code}${extra ? " — " + extra : ""}`);
|
|
635
|
-
sendCtrlMsg(send, id, { v: 2, type: "e2ee-error", code, ...(extra ? { message: extra } : {}) });
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
/** 控制通道 ws-open:不连上游,应答 ok 后按 §5.2 跑握手(hello/ack → 探针)。 */
|
|
639
|
-
function openControlWs(send, id) {
|
|
640
|
-
if (!e2ee.enabled) {
|
|
641
|
-
console.log(`[bridge] e2ee ctrl 被拒(桥端未启用): id=${id}`);
|
|
642
|
-
send({ id, type: "ws-open", ok: false, code: 1008, reason: "e2ee_disabled:bridge 未启用端到端加密" });
|
|
643
|
-
return;
|
|
644
|
-
}
|
|
645
|
-
wsSessions.delete(id);
|
|
646
|
-
ctrlConns.set(id, { sessId: null });
|
|
647
|
-
send({ id, type: "ws-open", ok: true });
|
|
648
|
-
console.log(`[bridge] e2ee ctrl 通道已开 (id=${id}),等待 hello`);
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
/** 控制通道消息:明文 hello / AEAD 探针信封(§5.2)。 */
|
|
652
|
-
function handleCtrlMessage(send, id, rawText) {
|
|
653
|
-
const conn = ctrlConns.get(id);
|
|
654
|
-
if (!conn) return;
|
|
655
|
-
let obj;
|
|
656
|
-
try {
|
|
657
|
-
obj = JSON.parse(rawText);
|
|
658
|
-
} catch {
|
|
659
|
-
sendCtrlError(send, id, "bad_msg", "非 JSON 控制消息");
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
662
|
-
if (!obj || typeof obj !== "object") return;
|
|
663
|
-
if (obj.type === "e2ee-hello") {
|
|
664
|
-
try {
|
|
665
|
-
const { session, ack } = e2ee.handleHello(obj);
|
|
666
|
-
conn.sessId = session.sessId;
|
|
667
|
-
sendCtrlMsg(send, id, ack);
|
|
668
|
-
} catch (e) {
|
|
669
|
-
sendCtrlError(send, id, e2eeCodeOf(e), e?.message || "");
|
|
670
|
-
}
|
|
671
|
-
return;
|
|
672
|
-
}
|
|
673
|
-
if (obj.v === 2 && obj.k === "ctrl") {
|
|
674
|
-
// AEAD 探针(密文信封);失败 → bad_key 提示手机“密码/密钥不一致”,绝不静默
|
|
675
|
-
if (!conn.sessId) {
|
|
676
|
-
sendCtrlError(send, id, "unknown_session", "探针先于 hello");
|
|
677
|
-
return;
|
|
678
|
-
}
|
|
679
|
-
try {
|
|
680
|
-
const { reply } = e2ee.handleProbe(conn.sessId, obj);
|
|
681
|
-
sendCtrlMsg(send, id, reply);
|
|
682
|
-
} catch (e) {
|
|
683
|
-
const code = e2eeCodeOf(e);
|
|
684
|
-
// 探针解密失败对手机语义统一为 bad_key(§5.2;密码不一致/改密未更新)
|
|
685
|
-
sendCtrlError(send, id, code === "auth_failed" || code === "bad_key" ? "bad_key" : code, e?.message || "");
|
|
686
|
-
}
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
// 其余控制消息忽略
|
|
690
|
-
}
|
|
691
|
-
|
|
692
469
|
export async function handleWsOpen(dchOrSend, frame) {
|
|
693
470
|
const send = toSender(dchOrSend);
|
|
694
471
|
const { id, path = "/", headers = {} } = frame;
|
|
695
|
-
// 控制通道(device 形态 /remote/<dev>/_e2ee/ctrl 与 channel 形态都归一到 /_e2ee/ctrl)
|
|
696
|
-
if (path.startsWith("/_e2ee/")) {
|
|
697
|
-
return openControlWs(send, id);
|
|
698
|
-
}
|
|
699
|
-
// e2ee 标记数据流:&e2ee=<sessId>&w=<8B hex>(§4.6);未启用/会话未验证 → 显式拒绝
|
|
700
|
-
const tagged = parseWsE2eeParams(path);
|
|
701
|
-
if (tagged) {
|
|
702
|
-
if (!e2ee.enabled) {
|
|
703
|
-
send({ id, type: "ws-open", ok: false, code: 1008, reason: "e2ee_disabled:bridge 未启用端到端加密(请用明文连接)" });
|
|
704
|
-
return;
|
|
705
|
-
}
|
|
706
|
-
const session = e2ee.sessionOf(tagged.sessId);
|
|
707
|
-
if (!session || !session.verified) {
|
|
708
|
-
console.error(`[bridge] e2ee ws 拒绝(会话未解锁/未验证): ${path}`);
|
|
709
|
-
send({ id, type: "ws-open", ok: false, code: 1008, reason: "e2ee_session_locked:请先解锁完成握手(检查密码)" });
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
// 探测前不转发任何数据(§4.6/§5.2:探针通过后数据流才可建);此时已验证 → 安全
|
|
713
|
-
}
|
|
714
472
|
if (wsSessions.has(id)) { try { wsSessions.get(id).ws.terminate(); } catch {} wsSessions.delete(id); }
|
|
715
|
-
const safe = safePath(
|
|
473
|
+
const safe = safePath(path);
|
|
716
474
|
if (safe === null) { send({ id, type: "ws-open", ok: false, code: 400, reason: "非法路径" }); return; }
|
|
717
475
|
const url = `${UPSTREAM.replace(/^http/, "ws")}${safe}`;
|
|
718
476
|
const ws = new WebSocket(url, { headers: buildWsHeaders(headers), followRedirects: false });
|
|
719
477
|
const session = { ws, opened: false };
|
|
720
|
-
if (tagged && e2ee.enabled) {
|
|
721
|
-
// 会话必须已 verified(上面已校验),给该流独立的收发密钥与方向计数
|
|
722
|
-
session.e2ee = { s: e2ee.sessionOf(tagged.sessId), wsLabel: tagged.wsLabel, p2bLast: -1, b2pLast: -1 };
|
|
723
|
-
}
|
|
724
478
|
wsSessions.set(id, session);
|
|
725
479
|
ws.on("open", () => {
|
|
726
480
|
session.opened = true;
|
|
727
|
-
console.log(`[bridge] ws-open ${
|
|
481
|
+
console.log(`[bridge] ws-open ${path} (id=${id})`);
|
|
728
482
|
send({ id, type: "ws-open", ok: true });
|
|
729
483
|
});
|
|
730
484
|
ws.on("message", (data, isBinary) => {
|
|
731
|
-
// 上游 → 手机:加密流逐条封信封(b2p 独立计数);明文流原样透传
|
|
732
|
-
if (session.e2ee) {
|
|
733
|
-
try {
|
|
734
|
-
const e2 = session.e2ee;
|
|
735
|
-
e2.b2pLast += 1;
|
|
736
|
-
const plain = isBinary ? Buffer.from(data) : Buffer.from(String(data), "utf8");
|
|
737
|
-
const env = e2.s.seal({
|
|
738
|
-
kind: "w",
|
|
739
|
-
dir: "b2p",
|
|
740
|
-
counter: e2.b2pLast,
|
|
741
|
-
data: plain,
|
|
742
|
-
t: isBinary ? 1 : 0,
|
|
743
|
-
wsLabel: e2.wsLabel
|
|
744
|
-
});
|
|
745
|
-
send({ id, type: "ws-msg", data: JSON.stringify(env), binary: false });
|
|
746
|
-
} catch (e) {
|
|
747
|
-
console.error(`[bridge] e2ee ws 上游→手机 封包失败(id=${id}): ${e.message}`);
|
|
748
|
-
}
|
|
749
|
-
return;
|
|
750
|
-
}
|
|
751
485
|
const payload = isBinary ? Buffer.from(data).toString("base64") : data.toString();
|
|
752
486
|
send({ id, type: "ws-msg", data: payload, binary: isBinary });
|
|
753
487
|
});
|
|
@@ -768,37 +502,8 @@ export async function handleWsOpen(dchOrSend, frame) {
|
|
|
768
502
|
|
|
769
503
|
export function handleWsMessage(_dchOrSend, frame) {
|
|
770
504
|
const { id, data, binary } = frame;
|
|
771
|
-
const conn = ctrlConns.get(id);
|
|
772
|
-
if (conn) {
|
|
773
|
-
// 控制通道消息不走上游
|
|
774
|
-
if (binary) {
|
|
775
|
-
sendCtrlError(toSender(_dchOrSend), id, "bad_msg", "控制通道只接受文本消息");
|
|
776
|
-
return;
|
|
777
|
-
}
|
|
778
|
-
handleCtrlMessage(toSender(_dchOrSend), id, String(data));
|
|
779
|
-
return;
|
|
780
|
-
}
|
|
781
505
|
const session = wsSessions.get(id);
|
|
782
506
|
if (!session || !session.opened || session.ws.readyState !== WebSocket.OPEN) return;
|
|
783
|
-
if (session.e2ee) {
|
|
784
|
-
// 加密流:逐条解封 → 原文转发上游;AEAD 失败 → 1008 关闭并回告(不静默降级)
|
|
785
|
-
try {
|
|
786
|
-
const env = JSON.parse(binary ? Buffer.from(data, "base64").toString("utf8") : String(data));
|
|
787
|
-
const e2 = session.e2ee;
|
|
788
|
-
if (!env || env.v !== 2 || env.k !== "w") throw new E2eeError("bad_envelope", "e2ee ws 消息不是 w 信封");
|
|
789
|
-
if (!Number.isInteger(env.c) || env.c <= e2.p2bLast) throw new E2eeError("replay", `e2ee ws 计数重放/乱序 ${env.c}`);
|
|
790
|
-
const opened = e2.s.open({ kind: "w", dir: "p2b", env, counter: env.c, wsLabel: e2.wsLabel });
|
|
791
|
-
e2.p2bLast = env.c;
|
|
792
|
-
if (opened.t === 1) session.ws.send(opened.data);
|
|
793
|
-
else session.ws.send(opened.data.toString("utf8"));
|
|
794
|
-
} catch (e) {
|
|
795
|
-
console.error(`[bridge] e2ee ws 解封失败(id=${id}): ${e.message || e}(不回退明文,1008 关闭)`);
|
|
796
|
-
try { session.ws.close(1008, "⚠ e2ee 消息无法解密"); } catch {}
|
|
797
|
-
wsSessions.delete(id);
|
|
798
|
-
try { toSender(_dchOrSend)({ id, type: "ws-close", code: 1008, reason: "⚠ e2ee 消息无法解密" }); } catch {}
|
|
799
|
-
}
|
|
800
|
-
return;
|
|
801
|
-
}
|
|
802
507
|
try {
|
|
803
508
|
if (binary) session.ws.send(Buffer.from(data, "base64"));
|
|
804
509
|
else session.ws.send(String(data));
|
|
@@ -807,23 +512,17 @@ export function handleWsMessage(_dchOrSend, frame) {
|
|
|
807
512
|
|
|
808
513
|
export function handleWsClose(_dchOrSend, frame) {
|
|
809
514
|
const { id, code, reason } = frame;
|
|
810
|
-
const conn = ctrlConns.get(id);
|
|
811
|
-
if (conn) {
|
|
812
|
-
ctrlConns.delete(id);
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
515
|
const session = wsSessions.get(id);
|
|
816
516
|
if (!session) return;
|
|
817
517
|
try { session.ws.close(code && typeof code === "number" ? code : 1000, reason || ""); } catch {}
|
|
818
518
|
}
|
|
819
519
|
|
|
820
|
-
/** DataChannel 断开:关闭所有 ws
|
|
520
|
+
/** DataChannel 断开:关闭所有 ws 会话。 */
|
|
821
521
|
export function closeAllWsSessions() {
|
|
822
522
|
for (const session of wsSessions.values()) {
|
|
823
523
|
try { session.ws.terminate(); } catch {}
|
|
824
524
|
}
|
|
825
525
|
wsSessions.clear();
|
|
826
|
-
ctrlConns.clear();
|
|
827
526
|
}
|
|
828
527
|
|
|
829
528
|
// ============================================================
|
|
@@ -962,8 +661,7 @@ function connectTunnel(token) {
|
|
|
962
661
|
tunnelRetry = 0;
|
|
963
662
|
console.log(`[bridge] 隧道已连 ${endpoint},注册 ${DEVICE_ID}...`);
|
|
964
663
|
try {
|
|
965
|
-
|
|
966
|
-
ws.send(JSON.stringify({ type: "tunnel-register", deviceId: DEVICE_ID, token, name: os.hostname() || "dsh-bridge", caps: e2ee.caps() }));
|
|
664
|
+
ws.send(JSON.stringify({ type: "tunnel-register", deviceId: DEVICE_ID, token, name: os.hostname() || "dsh-bridge" }));
|
|
967
665
|
} catch (e) { console.log(`[bridge] 注册发送失败: ${e.message}`); }
|
|
968
666
|
heartbeat = setInterval(() => {
|
|
969
667
|
if (!pongReceived) {
|
|
@@ -1004,40 +702,12 @@ function connectTunnel(token) {
|
|
|
1004
702
|
ws.on("error", (e) => console.log(`[bridge] 隧道错误: ${e.message || ""}`));
|
|
1005
703
|
}
|
|
1006
704
|
|
|
1007
|
-
/**
|
|
1008
|
-
* E2EE 启动初始化(账号模式):拉取服务端参数 → 用本机账号密码派生 MK(仅内存)。
|
|
1009
|
-
* 任何一步不满足都禁用 E2EE(reason 记录),bridge 走原 v1 明文路径,不影响隧道。
|
|
1010
|
-
* 启用结果写入 relayDir/.e2ee-state.json(供 Phase-4 UI 读取:{enabled, reason, ...})。
|
|
1011
|
-
* 状态文件路径策略:显式 DSH_BRIDGE_CONFIG / DSH_BRIDGE_E2EE_STATE 时落盘(生产必设),
|
|
1012
|
-
* 否则只打日志(避免污染仓库/非托管目录)。
|
|
1013
|
-
*/
|
|
1014
|
-
async function initE2ee(token) {
|
|
1015
|
-
const cfg = loadLocalConfig();
|
|
1016
|
-
const localMode = Boolean(process.env.DSH_BRIDGE_LOCAL_KEY); // 自建模式不启用(§6.6)
|
|
1017
|
-
const userDisabled = process.env.DSH_BRIDGE_E2EE === "0" || cfg.e2ee === false;
|
|
1018
|
-
const allowed = !localMode && !userDisabled;
|
|
1019
|
-
// 密码与 device-login 同源:env 优先,其次 .dsh-config.json(本机 0600 保存的账号密码)
|
|
1020
|
-
const password = PASSWORD || (typeof cfg.password === "string" && cfg.password ? cfg.password : "");
|
|
1021
|
-
e2ee = await E2eeService.init({ apiBase: API_BASE, token, password, allowed });
|
|
1022
|
-
const st = e2ee.state();
|
|
1023
|
-
const stateFileDir = process.env.DSH_BRIDGE_E2EE_STATE || path.dirname(CONFIG_PATH);
|
|
1024
|
-
if (process.env.DSH_BRIDGE_CONFIG || process.env.DSH_BRIDGE_E2EE_STATE) {
|
|
1025
|
-
writeE2eeStateFile(stateFileDir, { enabled: st.enabled, reason: st.reason, profile: st.profile, epoch: st.epoch, caps: e2ee.caps() });
|
|
1026
|
-
}
|
|
1027
|
-
if (st.enabled) {
|
|
1028
|
-
console.log(`[bridge] 🔒 E2EE 已启用(caps=[${e2ee.caps().join(",")}], profile=${st.profile}, epoch=${st.epoch});MK 仅驻内存`);
|
|
1029
|
-
} else {
|
|
1030
|
-
console.log(`[bridge] e2ee 未启用(${st.reason}) → 明文 v1 路径照常(能力不上报)`);
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
705
|
async function runTunnel() {
|
|
1035
706
|
const token = await resolveToken();
|
|
1036
707
|
if (!token) {
|
|
1037
708
|
console.error("[bridge] 隧道模式需要账号认证:请设 DSH_BRIDGE_TOKEN,或 DSH_BRIDGE_PHONE+DSH_BRIDGE_PASSWORD");
|
|
1038
709
|
process.exit(1);
|
|
1039
710
|
}
|
|
1040
|
-
await initE2ee(token);
|
|
1041
711
|
await registerDeviceInAccount(token);
|
|
1042
712
|
connectTunnel(token);
|
|
1043
713
|
setTimeout(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.0-beta.
|
|
3
|
+
"version": "0.6.0-beta.2",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"test:router": "node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/quotas.test.mjs",
|
|
53
53
|
"test:plugin": "node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
|
|
54
54
|
"test:bridge": "node --test clients/dsh-remote/test/*.test.mjs",
|
|
55
|
-
"check": "node --check clients/dsh-remote/dsh-bridge.mjs && node --check
|
|
55
|
+
"check": "node --check clients/dsh-remote/dsh-bridge.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && bash -n deploy/install-open.sh"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"ws": "^8.18.0"
|