@mrrisega/dsh-remote 0.6.11 → 0.6.13
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 +123 -2
- package/clients/dsh-remote/mobile-adapter.mjs +108 -4
- package/clients/dsh-remote/test/bridge-gzip.test.mjs +68 -1
- package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +388 -0
- package/dsh-setup.mjs +55 -15
- package/package.json +4 -4
- package/packages/dsh-remote-web/lib/client.js +137 -0
- package/packages/dsh-remote-web/lib/fix-prompt.js +160 -0
- package/packages/dsh-remote-web/lib/index.js +748 -18
- package/packages/dsh-remote-web/lib/offline-update.mjs +66 -0
- package/packages/dsh-remote-web/lib/tarball-update.js +442 -0
- package/packages/dsh-remote-web/package.json +5 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +1572 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +1991 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +728 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +767 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim.mjs +163 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +1679 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/package.json +11 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/lifecycle.mjs +45 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +2825 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +1984 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +2526 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +2390 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/LICENSE +20 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/README.md +548 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/browser.js +8 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/index.js +22 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/buffer-util.js +131 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/constants.js +19 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/event-target.js +292 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/extension.js +203 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/limiter.js +55 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/receiver.js +743 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/sender.js +607 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/stream.js +161 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/subprotocol.js +62 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/validation.js +152 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket-server.js +562 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket.js +1407 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/package.json +74 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/wrapper.mjs +21 -0
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +396 -0
- package/packages/dsh-remote-web/test/diag-report.test.mjs +236 -0
- package/packages/dsh-remote-web/test/fix-prompt.test.mjs +170 -0
- package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +198 -0
- package/packages/dsh-remote-web/test/tarball-update.test.mjs +460 -0
|
@@ -0,0 +1,1572 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-bridge — 电脑端守护进程:把手机流量桥接到本地 dsh web(127.0.0.1:3080)
|
|
4
|
+
*
|
|
5
|
+
* 架构(唯一模式,隧道模式):
|
|
6
|
+
* 手机 ──nginx /remote/<deviceId>/──▶ relay-router ──WS 隧道(/ _bridge)──▶ 本进程 ──HTTP/WS──▶ dsh web
|
|
7
|
+
*
|
|
8
|
+
* 流程:
|
|
9
|
+
* 1. 解析稳定 deviceId + ed25519 公钥(缺省生成并持久化到 .dsh-config.json)
|
|
10
|
+
* 2. 账号认证拿 JWT(DSH_BRIDGE_TOKEN 或 手机号/邮箱+密码)
|
|
11
|
+
* 3. 向账号 API 登记设备(POST /api/devices,带 device_id)
|
|
12
|
+
* 4. 连 router / _bridge,注册 {type:"tunnel-register", deviceId, token};
|
|
13
|
+
* 收到 router 转发的 http/ws-* 帧 → 处理 → 回包
|
|
14
|
+
*
|
|
15
|
+
* 帧协议(JSON;超大帧自动分块):
|
|
16
|
+
* ── 通用分块信封(任意方向) ──
|
|
17
|
+
* { "__chunk": { "id": <chunkId>, "n": <总块数>, "i": <第 i 块>, "data": <字符串分片> } }
|
|
18
|
+
* 接收方按 chunkId 攒齐 n 块后 JSON.parse 拼接结果,再按 type 分发。
|
|
19
|
+
*
|
|
20
|
+
* ── HTTP 透明代理 ──
|
|
21
|
+
* → { "id", "type":"http", "method", "path", "headers":{...}, "body":<base64>, "bodyBase64":true }
|
|
22
|
+
* ← { "id", "type":"http", "status", "headers":{...}, "body":<base64>, "bodyBase64":true }
|
|
23
|
+
* bridge 转发时:Host 由 fetch/ws 自动取上游 authority(127.0.0.1:3080,满足 loopback 围栏),
|
|
24
|
+
* 显式剥离 Origin、Sec-Fetch-*、Cookie、Referer 等浏览器标记,保证通过 dsh web 的信任围栏。
|
|
25
|
+
*
|
|
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
|
+
* 请求哪个路径;但错的注释会把后来者带沟里(微信通道订阅事件流时会照着它写)。
|
|
31
|
+
* → { "id", "type":"ws-open", "path", "headers":{...} } ← { "id","type":"ws-open","ok":true|false,"code"?,"reason"? }
|
|
32
|
+
* → { "id", "type":"ws-msg", "data":<文本|base64>, "binary"? }
|
|
33
|
+
* ← { "id", "type":"ws-msg", "data":<文本|base64>, "binary"? }
|
|
34
|
+
* → { "id", "type":"ws-close", "code"?, "reason"? } ← { "id","type":"ws-close","code"?,"reason"? }
|
|
35
|
+
* ws 会话以帧 id 为 key;隧道断开时全部关闭。
|
|
36
|
+
*
|
|
37
|
+
* 环境:
|
|
38
|
+
* DSH_BRIDGE_TUNNEL_URL 必填:relay-router 地址(如 ws://127.0.0.1:13444 或 wss://relay.example.com)
|
|
39
|
+
* DSH_BRIDGE_DEVICE_ID 覆盖稳定 deviceId(缺省读/写 .dsh-config.json 的 device_id)
|
|
40
|
+
* DSH_BRIDGE_UPSTREAM 上游 dsh web(默认 http://127.0.0.1:3080)
|
|
41
|
+
* DSH_BRIDGE_EMAIL 账号邮箱(与 DSH_BRIDGE_PASSWORD 一起自动登录拿 JWT;手机号用 DSH_BRIDGE_PHONE)
|
|
42
|
+
* DSH_BRIDGE_PHONE 账号手机号
|
|
43
|
+
* DSH_BRIDGE_PASSWORD 账号密码
|
|
44
|
+
* DSH_BRIDGE_TOKEN JWT(直接给 token;优先级高于 手机号/邮箱+密码)
|
|
45
|
+
* DSH_BRIDGE_API 账号 API 地址(默认云端服务地址;自建模式无需设置)
|
|
46
|
+
* DSH_BRIDGE_LOCAL_KEY 开源自部署:访问密钥(设后经 router POST /_login 换本地 JWT,免账号体系)
|
|
47
|
+
* DSH_BRIDGE_HEARTBEAT_MS 隧道心跳间隔(默认 15000ms)
|
|
48
|
+
* DSH_MOBILE_ADAPTER 经隧道访问的官方 dsh web 移动端适配注入开关:0 关闭(默认开启,仅 ≤820px 生效)
|
|
49
|
+
*
|
|
50
|
+
* E2EE(Phase-2,见 docs/e2ee-protocol.md):
|
|
51
|
+
* - 账号模式(非 DSH_BRIDGE_LOCAL_KEY)启动时用 device-login 同一账号密码派生 MK(仅内存,
|
|
52
|
+
* 不落盘);服务端 /api/e2ee-params 返回 enabled=false / 401 / 无密码 / 派生失败 → 明文 v1 回退。
|
|
53
|
+
* - 开关与状态写入 <relayDir>/.e2ee-state.json {enabled, reason, profile, epoch, caps};
|
|
54
|
+
* DSH_BRIDGE_E2EE=0 或配置 e2ee:false 可本地关闭;DSH_BRIDGE_E2EE_STATE 可覆盖状态文件路径。
|
|
55
|
+
* - 帧体带信封标记(content-type: application/vnd.dsh.e2ee-v2 或 x-dsh-e2ee)= 已加密:
|
|
56
|
+
* HTTP 解封→转发上游→响应封回;WS 逐消息封/解。任何解封失败显式
|
|
57
|
+
* 502 + x-dsh-e2ee-error / ws-close 1008,绝不静默降级。
|
|
58
|
+
* - 控制通道 /_e2ee/ctrl(device/channel 形态)不连上游,跑 §5.2 握手(hello/ack/探针);
|
|
59
|
+
* 数据流 ws-open 携带 &e2ee=<sessId>&w=<8B hex>,探针通过前不转发任何数据。
|
|
60
|
+
*
|
|
61
|
+
* E2EE Phase-4(镜像页 shim,见 e2ee-shim.mjs / e2ee-shim-script.js):
|
|
62
|
+
* - doHttp 在 text/html 注入 mobile-adapter 之后、压缩/封包之前,对「e2ee.enabled ∧
|
|
63
|
+
* DSH_E2EE_SHIM≠0」的官方 dsh web 响应注入镜像页加密 shim(读 native.html 一次性
|
|
64
|
+
* 交接单重建会话 → 拦截其 /api·/sidebar·/git·/pet 与数据 WS,信封密文化)。
|
|
65
|
+
* - 灰度默认关(e2ee.enabled=false)→ 不注入、镜像页保持既有明文路径(零行为)。
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
import WebSocket from "ws";
|
|
69
|
+
import fs from "node:fs";
|
|
70
|
+
import os from "node:os";
|
|
71
|
+
import path from "node:path";
|
|
72
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
73
|
+
import { randomBytes, generateKeyPairSync, createHash } from "node:crypto";
|
|
74
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
75
|
+
import { promisify } from "node:util";
|
|
76
|
+
import { gzip as gzipCb } from "node:zlib";
|
|
77
|
+
// 移动端适配层(经隧道访问的官方 dsh web 窄屏注入;DSH_MOBILE_ADAPTER=0 可关闭,默认开启)
|
|
78
|
+
import { maybeInjectMobileAdapter } from "./mobile-adapter.mjs";
|
|
79
|
+
// 镜像页 E2EE 加密 shim(Phase-4):text/html 注入;DSH_E2EE_SHIM=0 可关闭,叠加 e2ee.enabled 灰度门
|
|
80
|
+
import { maybeInjectE2eeShim } from "./e2ee-shim.mjs";
|
|
81
|
+
// E2EE(端到端加密)客户端基建(Phase-2):MK 派生/会话密钥/信封/握手/开关
|
|
82
|
+
// 仅在 runTunnel(账号模式)里初始化;被测试 import(未走 main)时保持禁用 → v1 路径不变。
|
|
83
|
+
import {
|
|
84
|
+
E2eeError,
|
|
85
|
+
E2eeService,
|
|
86
|
+
ENVELOPE_CONTENT_TYPE,
|
|
87
|
+
decodeHttpRequestPlain,
|
|
88
|
+
encodeHttpResponsePlain,
|
|
89
|
+
hasEnvelopeMarker,
|
|
90
|
+
parseWsE2eeParams,
|
|
91
|
+
writeE2eeStateFile
|
|
92
|
+
} from "./e2ee-client.mjs";
|
|
93
|
+
// 微信机器人通道(编排层):绑定控制面 + 出站通知路由 + 入站长轮询。
|
|
94
|
+
// ⚠️ 微信流量只走「腾讯 ilink ↔ 本机」,**不经我们的 relay**;DSH 事件走本机回环。
|
|
95
|
+
// 总开关 DSH_WECHAT=0(默认开启);未绑定时只起控制面,不起长轮询。
|
|
96
|
+
import { createWeChatRuntime } from "./wechat-runtime.mjs";
|
|
97
|
+
|
|
98
|
+
// ---------- 强制直连:清除代理环境变量 ----------
|
|
99
|
+
// 家庭网络常配 Clash 等代理(127.0.0.1:7890),node 的 ws/fetch 会继承
|
|
100
|
+
// http_proxy/https_proxy 导致到信令 WSS 的 TLS 握手失败(SSL_ERROR_SYSCALL)。
|
|
101
|
+
// bridge 必须直连 relay,不受本机代理影响。
|
|
102
|
+
for (const k of ["http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "no_proxy", "NO_PROXY"]) {
|
|
103
|
+
delete process.env[k];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
107
|
+
const ROOT = path.join(THIS_DIR, "..", "..");
|
|
108
|
+
// 配置路径:dsh-setup 总会显式传入 DSH_BRIDGE_CONFIG;npm 安装时默认落到 ~/.dsh-remote
|
|
109
|
+
const DEFAULT_CONFIG = path.join(
|
|
110
|
+
ROOT.includes(`${path.sep}node_modules${path.sep}`) ? path.join(os.homedir(), ".dsh-remote") : ROOT,
|
|
111
|
+
".dsh-config.json"
|
|
112
|
+
);
|
|
113
|
+
const CONFIG_PATH = process.env.DSH_BRIDGE_CONFIG || DEFAULT_CONFIG;
|
|
114
|
+
// 隧道模式(唯一):bridge 主动 WS 连 relay-router 的 /_bridge
|
|
115
|
+
const TUNNEL_URL = (process.env.DSH_BRIDGE_TUNNEL_URL || "").replace(/\/+$/, "");
|
|
116
|
+
const TUNNEL_HEARTBEAT_MS = Math.max(100, Number(process.env.DSH_BRIDGE_HEARTBEAT_MS) || 15_000);
|
|
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";
|
|
120
|
+
// 默认云端服务地址(dsh-remote setup 会显式传入;自建模式无需账号 API)
|
|
121
|
+
const API_BASE = (process.env.DSH_BRIDGE_API || "https://n.risegao.cn:13443/relay-api").replace(/\/+$/, "");
|
|
122
|
+
const EMAIL = process.env.DSH_BRIDGE_EMAIL || "";
|
|
123
|
+
// 手机号优先;兼容旧的 DSH_BRIDGE_EMAIL(过渡期)
|
|
124
|
+
const PHONE = process.env.DSH_BRIDGE_PHONE || EMAIL;
|
|
125
|
+
const PASSWORD = process.env.DSH_BRIDGE_PASSWORD || "";
|
|
126
|
+
const TOKEN = process.env.DSH_BRIDGE_TOKEN || "";
|
|
127
|
+
|
|
128
|
+
// ---------- 本机稳定指纹(同机重装识别;供服务端自动顶替旧设备) ----------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 读取与安装无关的机器级唯一值:macOS IOPlatformUUID / Linux machine-id / Windows MachineGuid。
|
|
132
|
+
* 用途只有一个:同机卸载重装后被服务端认成同一台设备(自动顶替旧设备记录)。
|
|
133
|
+
* 拿到的值**只在本机哈希**(见 machineFingerprint),原文绝不上报。
|
|
134
|
+
*/
|
|
135
|
+
function machineUniqueId() {
|
|
136
|
+
if (process.env.DSH_BRIDGE_MACHINE_FP) return String(process.env.DSH_BRIDGE_MACHINE_FP).slice(0, 64);
|
|
137
|
+
if (process.platform === "darwin") {
|
|
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
|
+
}
|
|
149
|
+
} else if (process.platform === "linux") {
|
|
150
|
+
for (const f of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
151
|
+
try {
|
|
152
|
+
const s = fs.readFileSync(f, "utf8").trim();
|
|
153
|
+
if (s) return s;
|
|
154
|
+
} catch { /* 继续 */ }
|
|
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
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return ""; // 读不到(如容器/受限环境) → 回退宿主名指纹
|
|
182
|
+
}
|
|
183
|
+
/** 稳定指纹:机器唯一值哈希;同机卸载重装后不变。 */
|
|
184
|
+
function machineFingerprint() {
|
|
185
|
+
const base = machineUniqueId() || `${os.hostname()}|${os.platform()}`;
|
|
186
|
+
return createHash("sha256").update(`dsh-remote/machine/v1:${base}`).digest("hex").slice(0, 32);
|
|
187
|
+
}
|
|
188
|
+
const MACHINE_FP = machineFingerprint();
|
|
189
|
+
|
|
190
|
+
/** 绑定/登录失败提示文件:供 dsh web 插件面板读取并展示(如“注册失败,已达到上限”)。 */
|
|
191
|
+
const BIND_ERROR_FILE = path.join(path.dirname(CONFIG_PATH), ".bind-error.json");
|
|
192
|
+
function persistBindError(payload) {
|
|
193
|
+
try {
|
|
194
|
+
fs.mkdirSync(path.dirname(BIND_ERROR_FILE), { recursive: true });
|
|
195
|
+
fs.writeFileSync(BIND_ERROR_FILE, JSON.stringify({ ...payload, at: Date.now() }, null, 2), { mode: 0o600 });
|
|
196
|
+
} catch { /* 非关键 */ }
|
|
197
|
+
}
|
|
198
|
+
function clearBindError() {
|
|
199
|
+
try { fs.rmSync(BIND_ERROR_FILE, { force: true }); } catch { /* 非关键 */ }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* bridge 运行状态文件:供 dsh web 插件面板判定「设备是否已在中继注册成功」(连接阶段 online)。
|
|
204
|
+
* 面板用它把「bridge 进程在跑」与「设备已注册、手机端真的能用」区分开——后者才是用户关心的状态,
|
|
205
|
+
* 只按进程存活判断会谎报可用。
|
|
206
|
+
* { device_id, started_at, account_bound_at, tunnel_registered_at, phase, last_error? }
|
|
207
|
+
* 每次进程启动都 reset 一次:上一轮进程的成功记录不能代表当前这一轮。
|
|
208
|
+
*/
|
|
209
|
+
const BRIDGE_STATE_FILE = path.join(path.dirname(CONFIG_PATH), ".dsh-bridge-state.json");
|
|
210
|
+
function persistBridgeState(patch, opts = {}) {
|
|
211
|
+
try {
|
|
212
|
+
fs.mkdirSync(path.dirname(BRIDGE_STATE_FILE), { recursive: true });
|
|
213
|
+
let base = {};
|
|
214
|
+
if (!opts.reset) {
|
|
215
|
+
try { base = JSON.parse(fs.readFileSync(BRIDGE_STATE_FILE, "utf8")) || {}; } catch { base = {}; }
|
|
216
|
+
}
|
|
217
|
+
fs.writeFileSync(BRIDGE_STATE_FILE, JSON.stringify({ ...base, ...patch, at: Date.now() }, null, 2), { mode: 0o600 });
|
|
218
|
+
} catch { /* 非关键:面板侧还有日志/账号兜底判据 */ }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------- 安装来源 / 版本(服务端设备行统计口径) ----------
|
|
222
|
+
// DSH_BRIDGE_INSTALL_SOURCE 由一键安装器(dsh-setup.mjs)注入 = npx;未设置表示这台电脑走的是
|
|
223
|
+
// 「插件市场装面板插件 + 插件自愈补装运行环境」那条路径 = plugin_market;白名单外一律 unknown。
|
|
224
|
+
const INSTALL_SOURCE_VALUES = new Set(["npx", "plugin_market"]);
|
|
225
|
+
function resolveInstallSource() {
|
|
226
|
+
const v = String(process.env.DSH_BRIDGE_INSTALL_SOURCE || "").trim();
|
|
227
|
+
if (INSTALL_SOURCE_VALUES.has(v)) return v;
|
|
228
|
+
return v ? "unknown" : "plugin_market";
|
|
229
|
+
}
|
|
230
|
+
/** 安装版本:安装器注入的包版本优先,其次本包 package.json 版本(读不到留空)。 */
|
|
231
|
+
function resolveInstallVersion() {
|
|
232
|
+
const v = String(process.env.DSH_BRIDGE_INSTALL_VERSION || "").trim();
|
|
233
|
+
if (v) return v;
|
|
234
|
+
try {
|
|
235
|
+
return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version || "";
|
|
236
|
+
} catch { return ""; }
|
|
237
|
+
}
|
|
238
|
+
const INSTALL_SOURCE = resolveInstallSource();
|
|
239
|
+
const INSTALL_VERSION = resolveInstallVersion();
|
|
240
|
+
|
|
241
|
+
// ---------- 稳定设备身份(.dsh-config.json) ----------
|
|
242
|
+
|
|
243
|
+
function loadLocalConfig() {
|
|
244
|
+
try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 把文件权限收紧到「只有本人可读写」。
|
|
249
|
+
*
|
|
250
|
+
* ⚠️ Windows 上 `{ mode: 0o600 }` 是**空操作**(Windows 用 ACL,不是 POSIX mode 位):
|
|
251
|
+
* 实测 .dsh-config.json 拿到的是用户目录的默认**继承 ACL**(AreAccessRulesProtected=False),
|
|
252
|
+
* 也就是说代码里那句 0600 对 Windows 用户完全没效果 —— 而文件里是**明文账号密码**。
|
|
253
|
+
* 风险不在"同机他人可读"(默认 ACL 下其实读不到),而在**任何按文件复制的场景**
|
|
254
|
+
* (备份/同步盘/杀软上报/崩溃转储/用户发给作者的支持包)都会连钥匙一起被带走,
|
|
255
|
+
* 而文档里的"0600 请妥善保护"会让用户误以为 Windows 上已有这层保护。
|
|
256
|
+
* 这里显式断开继承、只授予当前用户;失败只警告一次,绝不影响主流程。
|
|
257
|
+
*/
|
|
258
|
+
let hardenWarned = false;
|
|
259
|
+
function hardenFile(file) {
|
|
260
|
+
try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
|
|
261
|
+
if (process.platform !== "win32") return;
|
|
262
|
+
const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
|
|
263
|
+
// 两个环境变量都取不到时**绝不能静默返回**:那会让人以为文件已加固,实际仍是继承 ACL。
|
|
264
|
+
// 正常 Windows 会话不会走到这里(USERNAME 必然存在),但受限令牌/服务账户下有可能。
|
|
265
|
+
if (!who) {
|
|
266
|
+
if (!hardenWarned) {
|
|
267
|
+
hardenWarned = true;
|
|
268
|
+
console.warn(`⚠️ 无法收紧文件权限(USERDOMAIN/USERNAME 均未设置):${file}`);
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
let r;
|
|
273
|
+
try {
|
|
274
|
+
r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
|
|
275
|
+
{ windowsHide: true, encoding: "utf8", timeout: 8000 });
|
|
276
|
+
} catch (e) { r = { status: -1, stderr: e.message }; }
|
|
277
|
+
if (r.status !== 0 && !hardenWarned) {
|
|
278
|
+
hardenWarned = true;
|
|
279
|
+
console.warn(`⚠️ 收紧文件权限失败(${who}):${String(r.stderr || "").trim() || `icacls 退出码 ${r.status}`}`);
|
|
280
|
+
console.warn(" 配置文件可能仍可被其它账户/备份工具读取,请自行确认其存放位置。");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function saveLocalConfig(cfg) {
|
|
285
|
+
try {
|
|
286
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
287
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
288
|
+
hardenFile(CONFIG_PATH); // Windows 上 mode 是空操作 → 显式收紧 ACL(内含明文账号密码)
|
|
289
|
+
} catch (e) {
|
|
290
|
+
console.warn(`[bridge] 无法写配置 ${CONFIG_PATH}: ${e.message}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Harness 浏览器会话 Cookie(由 dsh-remote-web 插件在进程内换取后写入
|
|
296
|
+
* <relayDir>/.harness-cookie.json)。新版 dsh web(0.1.2+)对每个请求校验该 Cookie,
|
|
297
|
+
* 不带则 401 → 手机端白页;bridge 对所有上游 HTTP/WS 请求自动携带,让手机表现为已授权浏览器。
|
|
298
|
+
*/
|
|
299
|
+
const HARNESS_COOKIE_FILE = ".harness-cookie.json";
|
|
300
|
+
/** dsh web 因会话失效返回的 401 文案(见 @deepseek-ai/dsh-client-connection 的 writeUnauthorized)。 */
|
|
301
|
+
const HARNESS_UNAUTHORIZED_TEXT = "dsh web authentication required";
|
|
302
|
+
/** 撞到 401 时写的「作废」标记:插件在面板轮询时看到它就会立刻重换 Cookie(见 node 半 ensureHarnessCookie)。 */
|
|
303
|
+
const HARNESS_COOKIE_REVOKED_FILE = ".harness-cookie-revoked";
|
|
304
|
+
function markHarnessCookieRevoked() {
|
|
305
|
+
try {
|
|
306
|
+
const p = path.join(path.dirname(CONFIG_PATH), HARNESS_COOKIE_REVOKED_FILE);
|
|
307
|
+
fs.writeFileSync(p, String(Date.now()), { mode: 0o600 });
|
|
308
|
+
} catch { /* 忽略:标记只为加速自愈 */ }
|
|
309
|
+
}
|
|
310
|
+
function harnessCookieOf() {
|
|
311
|
+
try {
|
|
312
|
+
const p = path.join(path.dirname(CONFIG_PATH), HARNESS_COOKIE_FILE);
|
|
313
|
+
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
314
|
+
return j && typeof j.cookie === "string" && j.cookie ? j.cookie : "";
|
|
315
|
+
} catch {
|
|
316
|
+
return "";
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 解析稳定 deviceId:优先级 env DSH_BRIDGE_DEVICE_ID > argv[2] > 配置 device_id > 生成。
|
|
322
|
+
* 生成格式 dev-<12hex> 并持久化到 .dsh-config.json,保证每台 Mac 重启后 id 不变。
|
|
323
|
+
*/
|
|
324
|
+
function resolveDeviceIdentity() {
|
|
325
|
+
const envId = process.env.DSH_BRIDGE_DEVICE_ID || "";
|
|
326
|
+
const argId = process.argv[3] || "";
|
|
327
|
+
const cfg = loadLocalConfig();
|
|
328
|
+
const pick = () => envId || argId || (typeof cfg.device_id === "string" && cfg.device_id ? cfg.device_id : "");
|
|
329
|
+
const existing = pick();
|
|
330
|
+
if (existing) return existing;
|
|
331
|
+
const id = "dev-" + randomBytes(6).toString("hex"); // 12 hex
|
|
332
|
+
cfg.device_id = id;
|
|
333
|
+
saveLocalConfig(cfg);
|
|
334
|
+
return id;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 设备 ed25519 公钥(账号设备登记用;缺省生成并持久化,格式与 dsh-setup 一致)。 */
|
|
338
|
+
function resolveDevicePubKey() {
|
|
339
|
+
const cfg = loadLocalConfig();
|
|
340
|
+
if (typeof cfg.device_public_key === "string" && cfg.device_public_key) return cfg.device_public_key;
|
|
341
|
+
try {
|
|
342
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
343
|
+
cfg.device_public_key = publicKey.export({ type: "spki", format: "der" }).toString("hex");
|
|
344
|
+
cfg.device_private_key = privateKey.export({ type: "pkcs8", format: "der" }).toString("hex");
|
|
345
|
+
saveLocalConfig(cfg);
|
|
346
|
+
return cfg.device_public_key;
|
|
347
|
+
} catch (e) {
|
|
348
|
+
console.warn(`[bridge] ed25519 密钥生成失败: ${e.message}(用临时公钥,设备 id 仍稳定)`);
|
|
349
|
+
return "ed25519:" + randomBytes(16).toString("hex");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const DEVICE_ID = resolveDeviceIdentity();
|
|
354
|
+
// 单条消息分块尺寸(任何一端都不会超限)。
|
|
355
|
+
const CHUNK_SIZE = 200 * 1024;
|
|
356
|
+
// 单个 HTTP 请求的兜底超时(秒)。dsh 的 prompt 等操作可能跑很久,给足余量。
|
|
357
|
+
const HTTP_TIMEOUT_MS = 120_000;
|
|
358
|
+
|
|
359
|
+
// ---------- E2EE 运行时(默认禁用;main() 启动时按配置/服务端开关初始化) ----------
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* E2EE 服务实例。enabled=false 时所有帧处理走原 v1 明文路径;
|
|
363
|
+
* 只有「账号模式 ∧ 配置未关闭 ∧ 服务端 e2ee.enabled ∧ 有密码且 MK 派生成功」才为 enabled。
|
|
364
|
+
*/
|
|
365
|
+
let e2ee = new E2eeService({ enabled: false, reason: "not_initialized" });
|
|
366
|
+
/** 控制通道(/_e2ee/ctrl)连接:frameId → { kind:"ctrl", sessId|null }(不连上游)。 */
|
|
367
|
+
const ctrlConns = new Map();
|
|
368
|
+
|
|
369
|
+
// 转发请求时剥离的浏览器/代理头(围栏只认 Host + Origin + Sec-Fetch-*):
|
|
370
|
+
// - Host:fetch 自动取上游 authority(127.0.0.1:3080)→ loopback 围栏通过;
|
|
371
|
+
// ws 库需要显式设置(见 buildWsHeaders)。
|
|
372
|
+
// - Origin/Sec-Fetch-*/Referer:围栏里 cross-site / 异源 Origin 会被拒,必须剥掉。
|
|
373
|
+
// - Cookie:手机侧的 cookie 属于手机域名,与 dsh web 无关,不应透传。
|
|
374
|
+
// - content-length/transfer-encoding:由 fetch/ws 自己计算。
|
|
375
|
+
const STRIP_REQ_HEADERS = new Set([
|
|
376
|
+
"host", "origin", "referer", "cookie", "connection", "upgrade",
|
|
377
|
+
"keep-alive", "transfer-encoding", "content-length", "accept-encoding",
|
|
378
|
+
"sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest", "sec-fetch-user",
|
|
379
|
+
"te", "trailer", "proxy-connection", "x-forwarded-for", "x-forwarded-proto",
|
|
380
|
+
"x-forwarded-host"
|
|
381
|
+
]);
|
|
382
|
+
// 回包时剥离的实体头:undici 已自动解压 body,content-encoding/length 会误导浏览器。
|
|
383
|
+
const STRIP_RES_HEADERS = new Set([
|
|
384
|
+
"content-encoding", "content-length", "transfer-encoding",
|
|
385
|
+
"connection", "keep-alive", "upgrade"
|
|
386
|
+
]);
|
|
387
|
+
|
|
388
|
+
console.log(`[bridge] 设备 ${DEVICE_ID} → 隧道 ${TUNNEL_URL}/_bridge`);
|
|
389
|
+
console.log(`[bridge] 上游 ${UPSTREAM}`);
|
|
390
|
+
|
|
391
|
+
// ============================================================
|
|
392
|
+
// 通用工具:分块收发(双向)
|
|
393
|
+
// ============================================================
|
|
394
|
+
|
|
395
|
+
let chunkSeq = 0;
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* 构造「超长自动分块」的发送函数(隧道模式用;WebRTC 模式仍走 dcSend)。
|
|
399
|
+
* rawSend 接收完整字符串(如 ws.send)。返回 send(obj) → boolean。
|
|
400
|
+
*/
|
|
401
|
+
export function makeChunkedSender(rawSend) {
|
|
402
|
+
return function send(obj) {
|
|
403
|
+
const s = JSON.stringify(obj);
|
|
404
|
+
if (s.length <= CHUNK_SIZE) {
|
|
405
|
+
try { rawSend(s); } catch { return false; }
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
const cid = `c${++chunkSeq}`;
|
|
409
|
+
let sent = true;
|
|
410
|
+
for (let i = 0; i < s.length; i += CHUNK_SIZE) {
|
|
411
|
+
const part = { __chunk: { id: cid, n: Math.ceil(s.length / CHUNK_SIZE), i: i / CHUNK_SIZE, data: s.slice(i, i + CHUNK_SIZE) } };
|
|
412
|
+
try { rawSend(JSON.stringify(part)); } catch { sent = false; break; }
|
|
413
|
+
}
|
|
414
|
+
return sent;
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** 把对象发上 DataChannel;超长自动分块。返回 true 表示已发送(含分块)。 */
|
|
419
|
+
export function dcSend(dc, obj) {
|
|
420
|
+
if (!dc || dc.readyState !== "open") return false;
|
|
421
|
+
return makeChunkedSender((s) => dc.send(s))(obj);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** 兼容两种调用:handleXxx(dch, frame)(WebRTC) 或 handleXxx(sendFn, frame)(隧道)。 */
|
|
425
|
+
function toSender(dchOrSend) {
|
|
426
|
+
return typeof dchOrSend === "function" ? dchOrSend : (obj) => dcSend(dchOrSend, obj);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* 接收侧:把一条原始 DataChannel 消息规整为完整帧。
|
|
431
|
+
* 普通帧直接回传;分块帧攒齐后回传解析结果。需要挂在每个 channel 上:
|
|
432
|
+
* const recv = makeFrameReceiver();
|
|
433
|
+
* dch.onmessage = (ev) => recv(ev.data, (frame) => handleFrame(dch, frame));
|
|
434
|
+
*/
|
|
435
|
+
export function makeFrameReceiver() {
|
|
436
|
+
const bufs = new Map(); // chunkId → { n, parts: string[] }
|
|
437
|
+
return function receive(raw, onFrame) {
|
|
438
|
+
let text;
|
|
439
|
+
if (raw instanceof ArrayBuffer) text = Buffer.from(raw).toString("utf8");
|
|
440
|
+
else if (Buffer.isBuffer(raw)) text = raw.toString("utf8");
|
|
441
|
+
else text = String(raw);
|
|
442
|
+
let obj;
|
|
443
|
+
try { obj = JSON.parse(text); } catch { return; }
|
|
444
|
+
if (obj && obj.__chunk) {
|
|
445
|
+
const c = obj.__chunk;
|
|
446
|
+
let acc = bufs.get(c.id);
|
|
447
|
+
if (!acc) { acc = { n: c.n, parts: [] }; bufs.set(c.id, acc); }
|
|
448
|
+
acc.parts[c.i] = c.data;
|
|
449
|
+
if (acc.parts.filter(Boolean).length === acc.n) {
|
|
450
|
+
bufs.delete(c.id);
|
|
451
|
+
try { onFrame(JSON.parse(acc.parts.join(""))); } catch { /* 损坏分块,丢弃 */ }
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
onFrame(obj);
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ============================================================
|
|
460
|
+
// 头处理:围栏穿透
|
|
461
|
+
// ============================================================
|
|
462
|
+
|
|
463
|
+
/** 清洗手机发来的请求头:剥离浏览器标记,保留其余(供 fetch 转发)。 */
|
|
464
|
+
export function sanitizeRequestHeaders(headers) {
|
|
465
|
+
const out = {};
|
|
466
|
+
for (const [k, v] of Object.entries(headers || {})) {
|
|
467
|
+
const lk = k.toLowerCase();
|
|
468
|
+
if (STRIP_REQ_HEADERS.has(lk)) continue;
|
|
469
|
+
if (v === undefined || v === null || v === "") continue;
|
|
470
|
+
out[k] = Array.isArray(v) ? v.join(", ") : String(v);
|
|
471
|
+
}
|
|
472
|
+
return out;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** 清洗上游响应头:剥离实体/传输头,避免误导浏览器解码。 */
|
|
476
|
+
export function sanitizeResponseHeaders(headers) {
|
|
477
|
+
const out = {};
|
|
478
|
+
for (const [k, v] of Object.entries(headers || {})) {
|
|
479
|
+
const lk = k.toLowerCase();
|
|
480
|
+
if (STRIP_RES_HEADERS.has(lk)) continue;
|
|
481
|
+
if (v === undefined || v === null || v === "") continue;
|
|
482
|
+
out[k] = Array.isArray(v) ? v.join(", ") : String(v);
|
|
483
|
+
}
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** ws 库用的请求头:Host 显式写成上游 authority(loopback),其余同 sanitize。 */
|
|
488
|
+
export function buildWsHeaders(headers) {
|
|
489
|
+
const up = new URL(UPSTREAM);
|
|
490
|
+
const out = sanitizeRequestHeaders(headers);
|
|
491
|
+
out.Host = up.host; // e.g. "127.0.0.1:3080"
|
|
492
|
+
const ck = harnessCookieOf(); // 新版 dsh web 的浏览器会话 Cookie
|
|
493
|
+
if (ck) out.Cookie = ck;
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ============================================================
|
|
498
|
+
// 帧分发
|
|
499
|
+
// ============================================================
|
|
500
|
+
|
|
501
|
+
/** 处理一帧。type 缺省 → 旧协议(向后兼容)。 */
|
|
502
|
+
export async function handleFrame(dchOrSend, frame) {
|
|
503
|
+
if (!frame || typeof frame !== "object") return;
|
|
504
|
+
const { id, type } = frame;
|
|
505
|
+
if (id === undefined || id === null) return;
|
|
506
|
+
const send = toSender(dchOrSend);
|
|
507
|
+
if (type === "http") return handleHttpFrame(send, frame);
|
|
508
|
+
if (type === "http-abort") {
|
|
509
|
+
// 手机侧断开了这条流(切页/锁屏):掐掉上游,别在电脑上留一条永不结束的 SSE 连接。
|
|
510
|
+
const c = streamAborts.get(id);
|
|
511
|
+
if (c) {
|
|
512
|
+
streamAborts.delete(id);
|
|
513
|
+
try { c.abort(); } catch { /* ignore */ }
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (type === "ws-open") return handleWsOpen(send, frame);
|
|
518
|
+
if (type === "ws-msg") return handleWsMessage(send, frame);
|
|
519
|
+
if (type === "ws-close") return handleWsClose(send, frame);
|
|
520
|
+
if (type === undefined) return handleLegacyFrame(send, frame); // 旧协议
|
|
521
|
+
// 未知 type:忽略
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ---- HTTP 透明代理 ----
|
|
525
|
+
|
|
526
|
+
/** 校验上游路径,防 SSRF(禁止 userinfo/协议相对/绝对 URL/反斜杠/控制字符)。 */
|
|
527
|
+
function safePath(path) {
|
|
528
|
+
if (typeof path !== "string" || !path.startsWith("/")) return null;
|
|
529
|
+
if (/[\x00-\x1f\x7f\\]/.test(path)) return null; // 控制字符/反斜杠(SSRF:无前导 / 已在上面拒绝,@ 在路径段里无害,如 /plugins/@deepseek-ai/)
|
|
530
|
+
if (path.startsWith("//") || /^\/[^/]*:/.test(path)) return null; // 协议相对或 scheme
|
|
531
|
+
return path;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const gzip = promisify(gzipCb);
|
|
535
|
+
// 可压缩的响应类型:JS/JSON/CSS/SVG/XML/纯文本。图片/视频/字体等二进制不压(压了也小不了)。
|
|
536
|
+
const COMPRESSIBLE_CT_RE = /javascript|json|css|svg|xml|text\//i;
|
|
537
|
+
const MIN_COMPRESS_BYTES = 1024;
|
|
538
|
+
|
|
539
|
+
/** 大小写不敏感取帧头(手机经 router 转发的头键大小写不保证)。 */
|
|
540
|
+
function headerValue(headers, name) {
|
|
541
|
+
const lk = name.toLowerCase();
|
|
542
|
+
for (const [k, v] of Object.entries(headers || {})) {
|
|
543
|
+
if (k.toLowerCase() === lk) return Array.isArray(v) ? v.join(", ") : String(v);
|
|
544
|
+
}
|
|
545
|
+
return "";
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* 决定是否把上游响应 gzip 压缩后回传(隧道带宽优化,手机远程控制打开更快)。
|
|
550
|
+
* 保守策略:任一条件不满足都返回 null(原样回传):
|
|
551
|
+
* - 方法非 HEAD、状态非 204/304;
|
|
552
|
+
* - buf ≥ 1KB(太小不值得压);
|
|
553
|
+
* - `contentEncoding` 为空 —— ⚠️ 它指的是**手上这份 buf 的编码**,不是上游响应头的值:
|
|
554
|
+
* 上游头的 "gzip" 在 undici 解压后依然留着(见下),照抄过来只会让我们白丢压缩收益;
|
|
555
|
+
* 调用方必须传"buf 是否已编码",dsh-bridge 的 doHttp 因此**固定传空串**。
|
|
556
|
+
* 若哪天 buf 真是一段未解压的字节流,调用方才应把对应编码传进来(函数会保守放弃压缩)。
|
|
557
|
+
* - 请求 Accept-Encoding 含 gzip(手机浏览器必带;没有就不压,避免手机不会解压);
|
|
558
|
+
* - content-type 可压缩(排除 text/event-stream);
|
|
559
|
+
* - gzip 后确实更小(极小文件/不可压数据 gzip 反而更大,保守判断)。
|
|
560
|
+
* 返回 { buf, headers }(headers 为需附加到响应头的键值)或 null。
|
|
561
|
+
*/
|
|
562
|
+
export async function maybeCompressResponse({ buf, contentType, contentEncoding, acceptEncoding, status, method }) {
|
|
563
|
+
if (method === "HEAD") return null;
|
|
564
|
+
if (status === 204 || status === 304) return null;
|
|
565
|
+
if (!Buffer.isBuffer(buf) || buf.length < MIN_COMPRESS_BYTES) return null;
|
|
566
|
+
if (contentEncoding) return null; // 上游已编码:body 语义不明,不叠压缩
|
|
567
|
+
if (!String(acceptEncoding || "").toLowerCase().includes("gzip")) return null;
|
|
568
|
+
const ct = String(contentType || "");
|
|
569
|
+
if (!ct || ct.includes("text/event-stream") || !COMPRESSIBLE_CT_RE.test(ct)) return null;
|
|
570
|
+
let out;
|
|
571
|
+
try {
|
|
572
|
+
out = await gzip(buf); // 异步压缩,不阻塞事件循环
|
|
573
|
+
} catch {
|
|
574
|
+
return null; // 压缩失败保守回退
|
|
575
|
+
}
|
|
576
|
+
if (out.length >= buf.length) return null; // 压完没变小
|
|
577
|
+
return { buf: out, headers: { "content-encoding": "gzip" } };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* 进行中的 SSE 流:frameId → AbortController。
|
|
582
|
+
* 手机侧断开时,中继会发 `http-abort`,这里据此掐掉上游(否则电脑上会留一条永不结束的连接)。
|
|
583
|
+
*/
|
|
584
|
+
const streamAborts = new Map();
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* 中继在 `tunnel-register-ok` 里声明支持的**追加帧能力**。
|
|
588
|
+
*
|
|
589
|
+
* 为什么必须协商:browser→中继→bridge 是两段**独立升级**的链路。
|
|
590
|
+
* 新 bridge 的流式帧(`http-chunk`/`http-end`)对**旧中继**是不认识的 —— 旧中继会把
|
|
591
|
+
* "仅头部"帧当成一个完整的空响应直接 `end()`,SSE 于是变成"秒断 + 浏览器狂重连",
|
|
592
|
+
* 比原来的 120 秒挂起**更糟**。所以能力由中继声明:拿不到 `http-stream` 就老实退回
|
|
593
|
+
* 旧的整包缓冲行为(慢,但正确)。默认空集合 = 保守(连不上/旧中继都不冒险用新帧)。
|
|
594
|
+
*/
|
|
595
|
+
const routerCaps = new Set();
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* SSE(`text/event-stream`)流式转发。
|
|
599
|
+
*
|
|
600
|
+
* 为什么必须流式:`doHttp` 是「整包 `await res.arrayBuffer()`」的语义,而 SSE **永不结束** ——
|
|
601
|
+
* 实测 DSH 的 `/plugins/events`(client-hmr 的事件通道)每次页面加载都把这条请求挂满
|
|
602
|
+
* bridge 的 120s 上游超时,然后报错;浏览器 EventSource 立刻重连,于是手机端 Network 里
|
|
603
|
+
* **永远有一条转圈的请求**,页面也始终拿不到事件。
|
|
604
|
+
*
|
|
605
|
+
* 帧协议(对中继**追加**、与既有 `http` 帧并存;不认识 streaming 的旧中继只会忽略后续
|
|
606
|
+
* chunk,退化成"空 body 后结束",不会把页面打挂):
|
|
607
|
+
* ← { id, type:"http", status, headers, streaming:true } 仅头部,无 body
|
|
608
|
+
* ← { id, type:"http-chunk", seq, body, bodyBase64:true } 0..n 次
|
|
609
|
+
* ← { id, type:"http-end", seq } 流结束(含出错收尾)
|
|
610
|
+
* → { id, type:"http-abort" } 手机断开 → 掐上游
|
|
611
|
+
*
|
|
612
|
+
* ⚠️ E2EE 路径**不**走这里:信封是"一问一答"的结构,SSE 要逐块封/解需要另立协议;
|
|
613
|
+
* 而现实里 SSE 由 `EventSource` 发出、shim 不接管它(不带头部信封标记),
|
|
614
|
+
* 所以密文流根本不存在 —— 真要做时再单独设计,不要在这里偷偷降级成明文。
|
|
615
|
+
*
|
|
616
|
+
* @returns {Promise<{status:number, chunks:number, bytes:number, aborted?:boolean}>}
|
|
617
|
+
*/
|
|
618
|
+
async function doHttpStream(send, id, method, path, reqHeaders) {
|
|
619
|
+
const safe = safePath(path);
|
|
620
|
+
if (safe === null) throw new Error("非法路径");
|
|
621
|
+
const reqHdrs = sanitizeRequestHeaders(reqHeaders);
|
|
622
|
+
const ck = harnessCookieOf(); // 新版 dsh web 的浏览器会话 Cookie(否则 401 白页)
|
|
623
|
+
if (ck) reqHdrs.Cookie = ck;
|
|
624
|
+
const controller = new AbortController();
|
|
625
|
+
streamAborts.set(id, controller);
|
|
626
|
+
|
|
627
|
+
let res;
|
|
628
|
+
try {
|
|
629
|
+
res = await fetch(`${UPSTREAM}${safe}`, { method, headers: reqHdrs, signal: controller.signal });
|
|
630
|
+
} catch (e) {
|
|
631
|
+
streamAborts.delete(id);
|
|
632
|
+
throw e; // 头部都还没发出去 → 交给调用方回一个普通错误帧
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const status = res.status;
|
|
636
|
+
// 头部帧:告诉中继"这是一条流",随后才逐块推 body
|
|
637
|
+
send({
|
|
638
|
+
id,
|
|
639
|
+
type: "http",
|
|
640
|
+
status,
|
|
641
|
+
headers: sanitizeResponseHeaders(Object.fromEntries(res.headers.entries())),
|
|
642
|
+
streaming: true
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
let seq = 0;
|
|
646
|
+
let bytes = 0;
|
|
647
|
+
try {
|
|
648
|
+
const reader = res.body && typeof res.body.getReader === "function" ? res.body.getReader() : null;
|
|
649
|
+
if (reader) {
|
|
650
|
+
for (;;) {
|
|
651
|
+
const { done, value } = await reader.read();
|
|
652
|
+
if (done) break;
|
|
653
|
+
if (!value || value.length === 0) continue;
|
|
654
|
+
bytes += value.length;
|
|
655
|
+
send({ id, type: "http-chunk", seq: ++seq, body: Buffer.from(value).toString("base64"), bodyBase64: true });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
} catch (e) {
|
|
659
|
+
// 头部已经发出去了:这里**绝不能**抛(调用方会用普通帧回 502,而中继那边响应头已定,
|
|
660
|
+
// 再写一次 head 会炸)。统一以 http-end 收尾,让 EventSource 按自己的节奏重连。
|
|
661
|
+
const aborted = controller.signal.aborted === true;
|
|
662
|
+
streamAborts.delete(id);
|
|
663
|
+
send({ id, type: "http-end", seq, ...(aborted ? { aborted: true } : { error: String(e?.message || e) }) });
|
|
664
|
+
return { status, chunks: seq, bytes, aborted };
|
|
665
|
+
}
|
|
666
|
+
streamAborts.delete(id);
|
|
667
|
+
send({ id, type: "http-end", seq });
|
|
668
|
+
return { status, chunks: seq, bytes };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
672
|
+
const safe = safePath(path);
|
|
673
|
+
if (safe === null) throw new Error("非法路径");
|
|
674
|
+
const url = `${UPSTREAM}${safe}`;
|
|
675
|
+
const reqHdrs = sanitizeRequestHeaders(reqHeaders);
|
|
676
|
+
const ck = harnessCookieOf(); // 新版 dsh web 的浏览器会话 Cookie(否则 401 白页)
|
|
677
|
+
if (ck) reqHdrs.Cookie = ck;
|
|
678
|
+
const init = { method, headers: reqHdrs };
|
|
679
|
+
if (body !== undefined && body !== null && body !== "") {
|
|
680
|
+
// 新协议 http 帧的 body 一律 base64;旧协议 body 是原始文本
|
|
681
|
+
init.body = isB64 ? Buffer.from(String(body), "base64") : String(body);
|
|
682
|
+
}
|
|
683
|
+
let res = await fetch(url, { ...init, signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) });
|
|
684
|
+
let buf = Buffer.from(await res.arrayBuffer());
|
|
685
|
+
// 手机端 401「dsh web authentication required」自愈:
|
|
686
|
+
// dsh web 每次重启都会换签名密钥 → 插件代持的旧 Cookie 立即失效。撞到该 401 时
|
|
687
|
+
// ① 写「作废」标记(插件在面板轮询时秒级重换 Cookie);
|
|
688
|
+
// ② 若此刻 Cookie 已被插件换成新的(文件变了),**立刻用新 Cookie 重试一次** ——
|
|
689
|
+
// 这样用户连一次错误页都看不到,不需要任何手动操作。
|
|
690
|
+
if (res.status === 401) {
|
|
691
|
+
const text = buf.length > 0 && buf.length < 4096 ? buf.toString("utf8") : "";
|
|
692
|
+
if (text.includes(HARNESS_UNAUTHORIZED_TEXT)) {
|
|
693
|
+
markHarnessCookieRevoked();
|
|
694
|
+
const fresh = harnessCookieOf();
|
|
695
|
+
if (fresh && fresh !== ck) {
|
|
696
|
+
console.log("[bridge] 浏览器会话 Cookie 已失效,用新 Cookie 重试一次:", path);
|
|
697
|
+
const retryHdrs = sanitizeRequestHeaders(reqHeaders);
|
|
698
|
+
retryHdrs.Cookie = fresh;
|
|
699
|
+
res = await fetch(url, { ...init, headers: retryHdrs, signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) });
|
|
700
|
+
buf = Buffer.from(await res.arrayBuffer());
|
|
701
|
+
} else {
|
|
702
|
+
console.warn("[bridge] 浏览器会话 Cookie 已失效(已标记,等待插件重换):", path);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
// 移动端适配层:text/html(含 </head> 且匹配官方特征)在 gzip 前注入响应式 <style>/<script>;
|
|
707
|
+
// 非 html / SSE / 二进制 / 上游已压缩等其余响应一律原样(env DSH_MOBILE_ADAPTER=0 关闭)。
|
|
708
|
+
// sanitizeResponseHeaders 会剥 content-encoding(undici 已解压,原头会误导浏览器);
|
|
709
|
+
// 若我们自行 gzip,必须在 sanitize 之后把 content-encoding: gzip 补回,手机才能正确解压。
|
|
710
|
+
const headers = sanitizeResponseHeaders(Object.fromEntries(res.headers.entries()));
|
|
711
|
+
const contentType = res.headers.get("content-type") || "";
|
|
712
|
+
if (!contentType.includes("text/event-stream")) { // SSE 长连不缓冲不注入(见模块注释)
|
|
713
|
+
const m = maybeInjectMobileAdapter({ buf, contentType });
|
|
714
|
+
if (m.injected) {
|
|
715
|
+
console.log(`[bridge] mobile-adapter 注入 ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(m.buf.length / 1024).toFixed(0)}KB`);
|
|
716
|
+
buf = m.buf;
|
|
717
|
+
}
|
|
718
|
+
// E2EE 镜像页加密 shim(Phase-4):仅桥端 e2ee 启用时注入(e2ee.enabled=false → 零注入零行为)
|
|
719
|
+
if (e2ee.enabled) {
|
|
720
|
+
const s = maybeInjectE2eeShim({ buf, contentType });
|
|
721
|
+
if (s.injected) {
|
|
722
|
+
console.log(`[bridge] e2ee-shim 注入 ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(s.buf.length / 1024).toFixed(0)}KB`);
|
|
723
|
+
buf = s.buf;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const compressed = await maybeCompressResponse({
|
|
728
|
+
buf,
|
|
729
|
+
contentType,
|
|
730
|
+
// 🔴 必须传空:这里的 `buf` 是 **undici 已经解压过**的明文,不是上游线速字节。
|
|
731
|
+
// 以前传的是 `res.headers.get("content-encoding")` —— 而 undici 解压之后**仍会把该头留着**
|
|
732
|
+
// (本文件 sanitizeResponseHeaders 的注释正是这么写的:"undici 已自动解压 body,
|
|
733
|
+
// content-encoding/length 会误导浏览器")。于是 maybeCompressResponse 看到 "gzip"
|
|
734
|
+
// 就保守地 `return null`,**永不重压** —— 实测代价:线上 dsh web 的 `/plugins/??`
|
|
735
|
+
// 聚合包本来 gzip 后 5.12MB,被原样发出 13.39MB(2.6×),免费档 1Mbps 下从 40 秒变成 100+ 秒。
|
|
736
|
+
// 传空之后语义才是自洽的:"我手上这份 buf 是未编码的明文,可以压"。
|
|
737
|
+
contentEncoding: "",
|
|
738
|
+
acceptEncoding: headerValue(reqHeaders, "accept-encoding"),
|
|
739
|
+
status: res.status,
|
|
740
|
+
method
|
|
741
|
+
});
|
|
742
|
+
if (compressed) {
|
|
743
|
+
console.log(`[bridge] gzip ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(compressed.buf.length / 1024).toFixed(0)}KB (${(100 * (1 - compressed.buf.length / buf.length)).toFixed(0)}% 减小)`);
|
|
744
|
+
buf = compressed.buf;
|
|
745
|
+
Object.assign(headers, compressed.headers);
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
status: res.status,
|
|
749
|
+
headers,
|
|
750
|
+
body: buf.toString("base64"),
|
|
751
|
+
bodyBase64: true
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export async function handleHttpFrame(dchOrSend, frame) {
|
|
756
|
+
const send = toSender(dchOrSend);
|
|
757
|
+
const { id, method = "GET", path = "/", headers = {}, body, bodyBase64: isB64 } = frame;
|
|
758
|
+
const t0 = Date.now();
|
|
759
|
+
// 桌面授权引导(方案A):POST /_e2ee/intro(device/channel 形态归一)—— 本地应答,不连上游
|
|
760
|
+
if (method === "POST" && path === "/_e2ee/intro") {
|
|
761
|
+
return answerIntro(send, id);
|
|
762
|
+
}
|
|
763
|
+
// E2EE 信封标记即信号(§4.3):有标记=加密,无标记=明文 v1。
|
|
764
|
+
// 解封失败绝不静默降级 → 明文错误 + x-dsh-e2ee-error 头 + 日志。
|
|
765
|
+
if (hasEnvelopeMarker(headers)) {
|
|
766
|
+
if (!e2ee.enabled) {
|
|
767
|
+
console.error(`[bridge] e2ee http ${method} ${path}: bridge 未启用 E2EE,拒绝解封(不静默降级)`);
|
|
768
|
+
return sendE2eeHttpError(send, id, new E2eeError("e2ee_disabled", "bridge 端未启用端到端加密(请检查账号密码/服务端开关)"));
|
|
769
|
+
}
|
|
770
|
+
try {
|
|
771
|
+
const bodyText = Buffer.from(String(body || ""), "base64").toString("utf8");
|
|
772
|
+
let env;
|
|
773
|
+
try {
|
|
774
|
+
env = JSON.parse(bodyText);
|
|
775
|
+
} catch {
|
|
776
|
+
throw new E2eeError("bad_envelope", "帧体不是合法 E2EE 信封 JSON");
|
|
777
|
+
}
|
|
778
|
+
// 防重放 + 会话校验(unknown_session / not_verified / replay 在此抛出)
|
|
779
|
+
const session = e2ee.guardHttpRequest(String(env?.s || ""), env);
|
|
780
|
+
const opened = session.open({ kind: "http", dir: "p2b", env, counter: env?.c ?? 0 });
|
|
781
|
+
const req = decodeHttpRequestPlain(opened.data);
|
|
782
|
+
const reply = await doHttp(req.method, req.path, req.headers, req.bodyB64, true);
|
|
783
|
+
const bodyBuffer = Buffer.from(reply.body, "base64");
|
|
784
|
+
const plain = encodeHttpResponsePlain({ status: reply.status, headers: reply.headers, bodyBuffer });
|
|
785
|
+
// 响应压缩已发生在 doHttp(明文侧,gzip 在加密前 §4.7);信封用 http-resp kind
|
|
786
|
+
const respEnv = session.seal({ kind: "http-resp", dir: "b2p", counter: env?.c ?? 0, data: plain, reqNonceB64: String(env?.n || "") });
|
|
787
|
+
send({
|
|
788
|
+
id,
|
|
789
|
+
type: "http",
|
|
790
|
+
status: 200, // 外层一律 200,真实状态在信封明文 st 里(§4.3)
|
|
791
|
+
headers: {
|
|
792
|
+
"content-type": ENVELOPE_CONTENT_TYPE,
|
|
793
|
+
"x-dsh-e2ee": `v=2;s=${env.s};k=http-resp`
|
|
794
|
+
},
|
|
795
|
+
body: Buffer.from(JSON.stringify(respEnv)).toString("base64"),
|
|
796
|
+
bodyBase64: true
|
|
797
|
+
});
|
|
798
|
+
console.log(`[bridge] e2ee http ${req.method} ${req.path} → ${reply.status} (${Date.now() - t0}ms, 信封 ${(reply.body.length * 3 / 4 / 1024).toFixed(0)}KB)`);
|
|
799
|
+
return;
|
|
800
|
+
} catch (e) {
|
|
801
|
+
console.error(`[bridge] e2ee http ${method} ${path} 解封失败: ${e.message || e}(不回退明文)`);
|
|
802
|
+
return sendE2eeHttpError(send, id, e);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
// SSE 必须流式转发(否则会挂满 120s 超时,见 doHttpStream 注释)。
|
|
807
|
+
// 客户端是 EventSource 时必带 Accept: text/event-stream,以此为准(不看路径白名单,免得漏)。
|
|
808
|
+
if (routerCaps.has("http-stream") && String(headerValue(headers, "accept") || "").includes("text/event-stream")) {
|
|
809
|
+
const s = await doHttpStream(send, id, method, path, headers);
|
|
810
|
+
console.log(`[bridge] ${method} ${path} → SSE ${s.status} (${Date.now() - t0}ms, ${s.chunks} 块 / ${(s.bytes / 1024).toFixed(1)}KB${s.aborted ? ", 手机已断开" : ""})`);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const reply = await doHttp(method, path, headers, body, !!isB64);
|
|
814
|
+
reply.id = id;
|
|
815
|
+
reply.type = "http";
|
|
816
|
+
send(reply);
|
|
817
|
+
console.log(`[bridge] ${method} ${path} → ${reply.status} (${Date.now() - t0}ms, ${(reply.body.length * 3 / 4 / 1024).toFixed(0)}KB)`);
|
|
818
|
+
} catch (e) {
|
|
819
|
+
console.log(`[bridge] ${method} ${path} 上游错误: ${e.message}`);
|
|
820
|
+
send({ id, type: "http", status: 502, headers: { "content-type": "application/json" }, body: Buffer.from(JSON.stringify({ error: String(e.message || e) })).toString("base64"), bodyBase64: true });
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** 桌面授权引导(方案A):POST /_e2ee/intro —— 桥端本地应答,不连上游。
|
|
825
|
+
* 路由层已按 dsh_token cookie 完成同账号授权(device/channel 形态都只到本设备);
|
|
826
|
+
* 这里仅当桥端 E2EE 启用时一次性下发派生 MK(内存转发,不落盘不写日志)。 */
|
|
827
|
+
function answerIntro(send, id) {
|
|
828
|
+
let ok = false;
|
|
829
|
+
let body;
|
|
830
|
+
try {
|
|
831
|
+
const g = e2ee.introGrant();
|
|
832
|
+
body = { ok: true, v: 2, grant: "desktop-intro", mk: g.mk, profile: g.profile, epoch: g.epoch, ts: Date.now() };
|
|
833
|
+
ok = true;
|
|
834
|
+
console.log("[bridge] e2ee intro → 手机(同一账号,已授权;MK 仅内存一次性下发)");
|
|
835
|
+
} catch (e) {
|
|
836
|
+
const code = e instanceof E2eeError ? e.code : "e2ee_disabled";
|
|
837
|
+
body = { ok: false, v: 2, error: { code, message: e?.message || "电脑端未启用端到端加密,无法授权引导" } };
|
|
838
|
+
console.log(`[bridge] e2ee intro 被拒(${code}): ${e?.message || ""}`);
|
|
839
|
+
}
|
|
840
|
+
send({
|
|
841
|
+
id,
|
|
842
|
+
type: "http",
|
|
843
|
+
status: ok ? 200 : 409,
|
|
844
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
|
|
845
|
+
body: Buffer.from(JSON.stringify(body)).toString("base64"),
|
|
846
|
+
bodyBase64: true
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** E2EE 失败回包:明文(无信封标记)+ x-dsh-e2ee-error,绝不把无法解密的密文当正文转发。 */
|
|
851
|
+
function sendE2eeHttpError(send, id, err) {
|
|
852
|
+
const code = err instanceof E2eeError ? err.code : "e2ee_failed";
|
|
853
|
+
const message = `⚠ 无法解密/未加密(${code}): ${err?.message || code}`;
|
|
854
|
+
send({
|
|
855
|
+
id,
|
|
856
|
+
type: "http",
|
|
857
|
+
status: 502,
|
|
858
|
+
headers: {
|
|
859
|
+
"content-type": "application/json; charset=utf-8",
|
|
860
|
+
"cache-control": "no-store",
|
|
861
|
+
"x-dsh-e2ee-error": code
|
|
862
|
+
},
|
|
863
|
+
body: Buffer.from(JSON.stringify({ error: { code, message } })).toString("base64"),
|
|
864
|
+
bodyBase64: true
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** 旧协议(无 type):body 为原始文本,回包 body 为原始文本。 */
|
|
869
|
+
export async function handleLegacyFrame(dchOrSend, frame) {
|
|
870
|
+
const send = toSender(dchOrSend);
|
|
871
|
+
const { id, method = "GET", path = "/", body } = frame;
|
|
872
|
+
const t0 = Date.now();
|
|
873
|
+
try {
|
|
874
|
+
const reply = await doHttp(method, path, { "content-type": "application/json" }, body, false);
|
|
875
|
+
// 旧协议:body 转回文本(兼容 index.html 控制台)
|
|
876
|
+
const text = Buffer.from(reply.body, "base64").toString("utf8");
|
|
877
|
+
send({ id, status: reply.status, headers: reply.headers, body: text });
|
|
878
|
+
console.log(`[bridge] legacy ${method} ${path} → ${reply.status} (${Date.now() - t0}ms)`);
|
|
879
|
+
} catch (e) {
|
|
880
|
+
console.log(`[bridge] legacy ${method} ${path} 上游错误: ${e.message}`);
|
|
881
|
+
send({ id, status: 502, headers: {}, body: JSON.stringify({ error: String(e.message || e) }) });
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ---- WebSocket 透传 ----
|
|
886
|
+
|
|
887
|
+
// ws 会话表:frame id → ws 客户端。DataChannel 断开时统一关闭。
|
|
888
|
+
// - 普通/数据流:{ ws, opened, e2ee? };
|
|
889
|
+
// - e2ee 数据流额外带 e2ee:{ s(E2eeSession), wsLabel, p2bLast, b2pLast };
|
|
890
|
+
// - 控制通道(/_e2ee/ctrl)不连上游,单列 ctrlConns。
|
|
891
|
+
const wsSessions = new Map();
|
|
892
|
+
|
|
893
|
+
/** E2EE 错误码 → 手机可读文案(明文 e2ee-error;绝不静默)。 */
|
|
894
|
+
function e2eeCodeOf(err) {
|
|
895
|
+
return err instanceof E2eeError ? err.code : "e2ee_failed";
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function sendCtrlMsg(send, id, obj) {
|
|
899
|
+
send({ id, type: "ws-msg", data: JSON.stringify(obj), binary: false });
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function sendCtrlError(send, id, code, extra = "") {
|
|
903
|
+
console.error(`[bridge] e2ee ctrl 错误: ${code}${extra ? " — " + extra : ""}`);
|
|
904
|
+
sendCtrlMsg(send, id, { v: 2, type: "e2ee-error", code, ...(extra ? { message: extra } : {}) });
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** 控制通道 ws-open:不连上游,应答 ok 后按 §5.2 跑握手(hello/ack → 探针)。 */
|
|
908
|
+
function openControlWs(send, id) {
|
|
909
|
+
if (!e2ee.enabled) {
|
|
910
|
+
console.log(`[bridge] e2ee ctrl 被拒(桥端未启用): id=${id}`);
|
|
911
|
+
send({ id, type: "ws-open", ok: false, code: 1008, reason: "e2ee_disabled:bridge 未启用端到端加密" });
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
wsSessions.delete(id);
|
|
915
|
+
ctrlConns.set(id, { sessId: null });
|
|
916
|
+
send({ id, type: "ws-open", ok: true });
|
|
917
|
+
console.log(`[bridge] e2ee ctrl 通道已开 (id=${id}),等待 hello`);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** 控制通道消息:明文 hello / AEAD 探针信封(§5.2)。 */
|
|
921
|
+
function handleCtrlMessage(send, id, rawText) {
|
|
922
|
+
const conn = ctrlConns.get(id);
|
|
923
|
+
if (!conn) return;
|
|
924
|
+
let obj;
|
|
925
|
+
try {
|
|
926
|
+
obj = JSON.parse(rawText);
|
|
927
|
+
} catch {
|
|
928
|
+
sendCtrlError(send, id, "bad_msg", "非 JSON 控制消息");
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
if (!obj || typeof obj !== "object") return;
|
|
932
|
+
if (obj.type === "e2ee-hello") {
|
|
933
|
+
try {
|
|
934
|
+
const { session, ack } = e2ee.handleHello(obj);
|
|
935
|
+
conn.sessId = session.sessId;
|
|
936
|
+
sendCtrlMsg(send, id, ack);
|
|
937
|
+
} catch (e) {
|
|
938
|
+
sendCtrlError(send, id, e2eeCodeOf(e), e?.message || "");
|
|
939
|
+
}
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (obj.v === 2 && obj.k === "ctrl") {
|
|
943
|
+
// AEAD 探针(密文信封);失败 → bad_key 提示手机“密码/密钥不一致”,绝不静默
|
|
944
|
+
if (!conn.sessId) {
|
|
945
|
+
sendCtrlError(send, id, "unknown_session", "探针先于 hello");
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
const { reply } = e2ee.handleProbe(conn.sessId, obj);
|
|
950
|
+
sendCtrlMsg(send, id, reply);
|
|
951
|
+
} catch (e) {
|
|
952
|
+
const code = e2eeCodeOf(e);
|
|
953
|
+
// 探针解密失败对手机语义统一为 bad_key(§5.2;密码不一致/改密未更新)
|
|
954
|
+
sendCtrlError(send, id, code === "auth_failed" || code === "bad_key" ? "bad_key" : code, e?.message || "");
|
|
955
|
+
}
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
// 其余控制消息忽略
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
export async function handleWsOpen(dchOrSend, frame) {
|
|
962
|
+
const send = toSender(dchOrSend);
|
|
963
|
+
const { id, path = "/", headers = {} } = frame;
|
|
964
|
+
// 控制通道(device 形态 /remote/<dev>/_e2ee/ctrl 与 channel 形态都归一到 /_e2ee/ctrl)
|
|
965
|
+
if (path.startsWith("/_e2ee/")) {
|
|
966
|
+
return openControlWs(send, id);
|
|
967
|
+
}
|
|
968
|
+
// e2ee 标记数据流:&e2ee=<sessId>&w=<8B hex>(§4.6);未启用/会话未验证 → 显式拒绝
|
|
969
|
+
const tagged = parseWsE2eeParams(path);
|
|
970
|
+
if (tagged) {
|
|
971
|
+
if (!e2ee.enabled) {
|
|
972
|
+
send({ id, type: "ws-open", ok: false, code: 1008, reason: "e2ee_disabled:bridge 未启用端到端加密(请用明文连接)" });
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const session = e2ee.sessionOf(tagged.sessId);
|
|
976
|
+
if (!session || !session.verified) {
|
|
977
|
+
console.error(`[bridge] e2ee ws 拒绝(会话未解锁/未验证): ${path}`);
|
|
978
|
+
send({ id, type: "ws-open", ok: false, code: 1008, reason: "e2ee_session_locked:请先解锁完成握手(检查密码)" });
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
// 探测前不转发任何数据(§4.6/§5.2:探针通过后数据流才可建);此时已验证 → 安全
|
|
982
|
+
}
|
|
983
|
+
if (wsSessions.has(id)) { try { wsSessions.get(id).ws.terminate(); } catch {} wsSessions.delete(id); }
|
|
984
|
+
const safe = safePath(tagged ? tagged.upstreamPath : path);
|
|
985
|
+
if (safe === null) { send({ id, type: "ws-open", ok: false, code: 400, reason: "非法路径" }); return; }
|
|
986
|
+
const url = `${UPSTREAM.replace(/^http/, "ws")}${safe}`;
|
|
987
|
+
const ws = new WebSocket(url, { headers: buildWsHeaders(headers), followRedirects: false });
|
|
988
|
+
const session = { ws, opened: false };
|
|
989
|
+
if (tagged && e2ee.enabled) {
|
|
990
|
+
// 会话必须已 verified(上面已校验),给该流独立的收发密钥与方向计数
|
|
991
|
+
session.e2ee = { s: e2ee.sessionOf(tagged.sessId), wsLabel: tagged.wsLabel, p2bLast: -1, b2pLast: -1 };
|
|
992
|
+
}
|
|
993
|
+
wsSessions.set(id, session);
|
|
994
|
+
ws.on("open", () => {
|
|
995
|
+
session.opened = true;
|
|
996
|
+
console.log(`[bridge] ws-open ${session.e2ee ? "(e2ee) " : ""}${safe} (id=${id})`);
|
|
997
|
+
send({ id, type: "ws-open", ok: true });
|
|
998
|
+
});
|
|
999
|
+
ws.on("message", (data, isBinary) => {
|
|
1000
|
+
// 上游 → 手机:加密流逐条封信封(b2p 独立计数);明文流原样透传
|
|
1001
|
+
if (session.e2ee) {
|
|
1002
|
+
try {
|
|
1003
|
+
const e2 = session.e2ee;
|
|
1004
|
+
e2.b2pLast += 1;
|
|
1005
|
+
const plain = isBinary ? Buffer.from(data) : Buffer.from(String(data), "utf8");
|
|
1006
|
+
const env = e2.s.seal({
|
|
1007
|
+
kind: "w",
|
|
1008
|
+
dir: "b2p",
|
|
1009
|
+
counter: e2.b2pLast,
|
|
1010
|
+
data: plain,
|
|
1011
|
+
t: isBinary ? 1 : 0,
|
|
1012
|
+
wsLabel: e2.wsLabel
|
|
1013
|
+
});
|
|
1014
|
+
send({ id, type: "ws-msg", data: JSON.stringify(env), binary: false });
|
|
1015
|
+
} catch (e) {
|
|
1016
|
+
console.error(`[bridge] e2ee ws 上游→手机 封包失败(id=${id}): ${e.message}`);
|
|
1017
|
+
}
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
const payload = isBinary ? Buffer.from(data).toString("base64") : data.toString();
|
|
1021
|
+
send({ id, type: "ws-msg", data: payload, binary: isBinary });
|
|
1022
|
+
});
|
|
1023
|
+
ws.on("close", (code, reason) => {
|
|
1024
|
+
if (!session.opened) return; // 未建立成功的会话由 error 路径收尾
|
|
1025
|
+
console.log(`[bridge] ws-close ${path} (id=${id}, code=${code})`);
|
|
1026
|
+
wsSessions.delete(id);
|
|
1027
|
+
send({ id, type: "ws-close", code: code ?? 1006, reason: reason?.toString() ?? "" });
|
|
1028
|
+
});
|
|
1029
|
+
ws.on("error", (e) => {
|
|
1030
|
+
console.log(`[bridge] ws-error ${path} (id=${id}): ${e.message || ""}`);
|
|
1031
|
+
if (!session.opened) {
|
|
1032
|
+
wsSessions.delete(id);
|
|
1033
|
+
send({ id, type: "ws-open", ok: false, code: 502, reason: String(e.message || "ws error") });
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
export function handleWsMessage(_dchOrSend, frame) {
|
|
1039
|
+
const { id, data, binary } = frame;
|
|
1040
|
+
const conn = ctrlConns.get(id);
|
|
1041
|
+
if (conn) {
|
|
1042
|
+
// 控制通道消息不走上游
|
|
1043
|
+
if (binary) {
|
|
1044
|
+
sendCtrlError(toSender(_dchOrSend), id, "bad_msg", "控制通道只接受文本消息");
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
handleCtrlMessage(toSender(_dchOrSend), id, String(data));
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const session = wsSessions.get(id);
|
|
1051
|
+
if (!session || !session.opened || session.ws.readyState !== WebSocket.OPEN) return;
|
|
1052
|
+
if (session.e2ee) {
|
|
1053
|
+
// 加密流:逐条解封 → 原文转发上游;AEAD 失败 → 1008 关闭并回告(不静默降级)
|
|
1054
|
+
try {
|
|
1055
|
+
const env = JSON.parse(binary ? Buffer.from(data, "base64").toString("utf8") : String(data));
|
|
1056
|
+
const e2 = session.e2ee;
|
|
1057
|
+
if (!env || env.v !== 2 || env.k !== "w") throw new E2eeError("bad_envelope", "e2ee ws 消息不是 w 信封");
|
|
1058
|
+
if (!Number.isInteger(env.c) || env.c <= e2.p2bLast) throw new E2eeError("replay", `e2ee ws 计数重放/乱序 ${env.c}`);
|
|
1059
|
+
const opened = e2.s.open({ kind: "w", dir: "p2b", env, counter: env.c, wsLabel: e2.wsLabel });
|
|
1060
|
+
e2.p2bLast = env.c;
|
|
1061
|
+
if (opened.t === 1) session.ws.send(opened.data);
|
|
1062
|
+
else session.ws.send(opened.data.toString("utf8"));
|
|
1063
|
+
} catch (e) {
|
|
1064
|
+
console.error(`[bridge] e2ee ws 解封失败(id=${id}): ${e.message || e}(不回退明文,1008 关闭)`);
|
|
1065
|
+
try { session.ws.close(1008, "⚠ e2ee 消息无法解密"); } catch {}
|
|
1066
|
+
wsSessions.delete(id);
|
|
1067
|
+
try { toSender(_dchOrSend)({ id, type: "ws-close", code: 1008, reason: "⚠ e2ee 消息无法解密" }); } catch {}
|
|
1068
|
+
}
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
try {
|
|
1072
|
+
if (binary) session.ws.send(Buffer.from(data, "base64"));
|
|
1073
|
+
else session.ws.send(String(data));
|
|
1074
|
+
} catch (e) { console.log(`[bridge] ws-send err: ${e.message}`); }
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
export function handleWsClose(_dchOrSend, frame) {
|
|
1078
|
+
const { id, code, reason } = frame;
|
|
1079
|
+
const conn = ctrlConns.get(id);
|
|
1080
|
+
if (conn) {
|
|
1081
|
+
ctrlConns.delete(id);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
const session = wsSessions.get(id);
|
|
1085
|
+
if (!session) return;
|
|
1086
|
+
try { session.ws.close(code && typeof code === "number" ? code : 1000, reason || ""); } catch {}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/** DataChannel 断开:关闭所有 ws 会话与控制通道。 */
|
|
1090
|
+
export function closeAllWsSessions() {
|
|
1091
|
+
for (const session of wsSessions.values()) {
|
|
1092
|
+
try { session.ws.terminate(); } catch {}
|
|
1093
|
+
}
|
|
1094
|
+
wsSessions.clear();
|
|
1095
|
+
ctrlConns.clear();
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ============================================================
|
|
1099
|
+
// 认证(SaaS: device-login; 开源自部署: 本地访问密钥 /_login)
|
|
1100
|
+
// ============================================================
|
|
1101
|
+
|
|
1102
|
+
/** 本地认证(自部署):用访问密钥向 router POST /_login 换本地 JWT。 */
|
|
1103
|
+
async function resolveLocalToken() {
|
|
1104
|
+
const key = process.env.DSH_BRIDGE_LOCAL_KEY || "";
|
|
1105
|
+
if (!key || !TUNNEL_URL) return "";
|
|
1106
|
+
try {
|
|
1107
|
+
// 从隧道地址推导同源 HTTP 入口:wss://host:port → https://host:port
|
|
1108
|
+
const u = new URL(TUNNEL_URL);
|
|
1109
|
+
u.protocol = u.protocol === "wss:" ? "https:" : "http:";
|
|
1110
|
+
u.pathname = "/_login";
|
|
1111
|
+
const r = await fetch(u.toString(), {
|
|
1112
|
+
method: "POST",
|
|
1113
|
+
headers: { "content-type": "application/json" },
|
|
1114
|
+
body: JSON.stringify({ key })
|
|
1115
|
+
});
|
|
1116
|
+
const d = await r.json();
|
|
1117
|
+
if (r.status === 200 && d.token) {
|
|
1118
|
+
console.log("[bridge] 本地认证成功(开源自部署),已获取 JWT");
|
|
1119
|
+
return d.token;
|
|
1120
|
+
}
|
|
1121
|
+
console.error(`[bridge] 本地认证失败(${r.status}): ${d.error?.message || "未知错误"}(请检查 DSH_BRIDGE_LOCAL_KEY)`);
|
|
1122
|
+
process.exit(1);
|
|
1123
|
+
} catch (e) {
|
|
1124
|
+
console.error(`[bridge] 无法连接本地认证 ${u?.toString?.() || TUNNEL_URL}: ${e.message}`);
|
|
1125
|
+
process.exit(1);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
async function resolveToken(refresh = false) {
|
|
1130
|
+
if (TOKEN && !refresh) { console.log("[bridge] 使用 DSH_BRIDGE_TOKEN"); return TOKEN; }
|
|
1131
|
+
// 开源自部署:访问密钥优先(不依赖闭源 enterprise 账号体系)
|
|
1132
|
+
if (process.env.DSH_BRIDGE_LOCAL_KEY && !refresh) return resolveLocalToken();
|
|
1133
|
+
if (PHONE && PASSWORD) {
|
|
1134
|
+
console.log(`[bridge] 用账号 ${PHONE} 登录换取 JWT...`);
|
|
1135
|
+
try {
|
|
1136
|
+
// 设备登录:登录接口已加图形验证码(bridge 无法人工输验证码),走免验证码的 device-login
|
|
1137
|
+
const r = await fetch(API_BASE + "/api/device-login", {
|
|
1138
|
+
method: "POST",
|
|
1139
|
+
headers: { "content-type": "application/json", ...(process.env.DSH_BRIDGE_SECRET ? { "x-dsh-bridge-secret": process.env.DSH_BRIDGE_SECRET } : {}) },
|
|
1140
|
+
body: JSON.stringify({ phone: PHONE, email: PHONE, password: PASSWORD })
|
|
1141
|
+
});
|
|
1142
|
+
const d = await r.json();
|
|
1143
|
+
if (r.status === 200 && d.token) {
|
|
1144
|
+
console.log("[bridge] 登录成功,已获取 JWT");
|
|
1145
|
+
// 登录成功 = 限流窗口已结束(服务端成功即清零),把退避提示删掉让 watcher 恢复正常节奏
|
|
1146
|
+
try { fs.rmSync(path.join(path.dirname(CONFIG_PATH), ".dsh-login-ratelimited"), { force: true }); } catch { /* 非关键 */ }
|
|
1147
|
+
return d.token;
|
|
1148
|
+
}
|
|
1149
|
+
if (r.status === 429) {
|
|
1150
|
+
// 服务端登录限流(每 IP 5 次失败 / 15 分钟)。**这不是密码错**:
|
|
1151
|
+
// 旧文案一律提示"请检查 DSH_BRIDGE_EMAIL / DSH_BRIDGE_PASSWORD",把限流误报成凭据问题,
|
|
1152
|
+
// 用户会去反复改密码、反而把窗口拖长(2026-09-19 实测)。
|
|
1153
|
+
const waitMs = Number(d.retry_after_ms) > 0 ? Number(d.retry_after_ms) : 0;
|
|
1154
|
+
console.error(`[bridge] 登录被服务端限流(429)${waitMs ? `,约 ${Math.ceil(waitMs / 1000)} 秒后自动重试` : ",请稍后自动重试"}`);
|
|
1155
|
+
console.error("[bridge] 凭据没错,是这台机器的出口 IP 短时间登录失败过多被临时限制;无需改密码。");
|
|
1156
|
+
// 把"最早可重试时刻"落盘:watcher 据此退避,避免 10 秒一次空撞限流
|
|
1157
|
+
try {
|
|
1158
|
+
const until = Date.now() + (waitMs || 5 * 60 * 1000);
|
|
1159
|
+
fs.writeFileSync(path.join(path.dirname(CONFIG_PATH), ".dsh-login-ratelimited"), String(until));
|
|
1160
|
+
} catch { /* 非关键 */ }
|
|
1161
|
+
process.exit(3);
|
|
1162
|
+
}
|
|
1163
|
+
console.error(`[bridge] 登录失败(${r.status}): ${d.error?.message || "未知错误"}`);
|
|
1164
|
+
console.error("[bridge] 请检查 DSH_BRIDGE_EMAIL / DSH_BRIDGE_PASSWORD,或直接设 DSH_BRIDGE_TOKEN");
|
|
1165
|
+
process.exit(1);
|
|
1166
|
+
} catch (e) {
|
|
1167
|
+
console.error(`[bridge] 无法连接账号 API ${API_BASE}: ${e.message}`);
|
|
1168
|
+
process.exit(1);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
if (TOKEN) {
|
|
1172
|
+
console.error("[bridge] DSH_BRIDGE_TOKEN 已失效且无账号密码可刷新,请更换 token 后重启");
|
|
1173
|
+
process.exit(1);
|
|
1174
|
+
}
|
|
1175
|
+
console.error("[bridge] 无认证配置:请设 DSH_BRIDGE_TOKEN / 手机号+密码,或开源自部署的 DSH_BRIDGE_LOCAL_KEY");
|
|
1176
|
+
process.exit(1);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// ============================================================
|
|
1180
|
+
// 隧道模式:bridge 主动连 relay-router(多设备主用)
|
|
1181
|
+
// ============================================================
|
|
1182
|
+
|
|
1183
|
+
let tunnelRetry = 0;
|
|
1184
|
+
|
|
1185
|
+
/** 把 DSH_BRIDGE_TUNNEL_URL 归一化为 router 注册端点(缺省补 / _bridge)。 */
|
|
1186
|
+
function tunnelEndpoint(raw) {
|
|
1187
|
+
try {
|
|
1188
|
+
const u = new URL(raw);
|
|
1189
|
+
const p = u.pathname.replace(/\/+$/, "");
|
|
1190
|
+
u.pathname = p === "" || p === "/" ? "/_bridge" : p;
|
|
1191
|
+
return u.toString();
|
|
1192
|
+
} catch {
|
|
1193
|
+
return raw + "/_bridge";
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** 账号设备表登记:手机端 /api/devices 才能看到本设备(带稳定 device_id)。自建模式无账号体系,跳过。 */
|
|
1198
|
+
async function registerDeviceInAccount(token) {
|
|
1199
|
+
if (process.env.DSH_BRIDGE_LOCAL_KEY) return; // 自建模式:设备列表来自 router 实时 WS 注册表
|
|
1200
|
+
const pubKey = resolveDevicePubKey();
|
|
1201
|
+
try {
|
|
1202
|
+
const r = await fetch(API_BASE + "/api/devices", {
|
|
1203
|
+
method: "POST",
|
|
1204
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
1205
|
+
body: JSON.stringify({
|
|
1206
|
+
device_id: DEVICE_ID,
|
|
1207
|
+
device_name: os.hostname() || "dsh-bridge",
|
|
1208
|
+
pub_key: pubKey,
|
|
1209
|
+
machine_fp: MACHINE_FP, // v6 同机识别:服务端据此自动顶替旧设备(重装不再被设备数卡死)
|
|
1210
|
+
// 安装口径(服务端存到设备行,用于区分「市场安装/自愈补装」与「安装器 npx」):
|
|
1211
|
+
// install_source: npx(安装器注入) | plugin_market(插件拉起/自愈,默认) | unknown(白名单外)
|
|
1212
|
+
install_source: INSTALL_SOURCE,
|
|
1213
|
+
install_version: INSTALL_VERSION,
|
|
1214
|
+
host_os: process.platform,
|
|
1215
|
+
host_arch: process.arch
|
|
1216
|
+
})
|
|
1217
|
+
});
|
|
1218
|
+
const d = await r.json().catch(() => ({}));
|
|
1219
|
+
if (r.status === 201 || r.status === 200) {
|
|
1220
|
+
clearBindError();
|
|
1221
|
+
persistBridgeState({ device_id: DEVICE_ID, account_bound_at: Date.now(), phase: "bound" });
|
|
1222
|
+
console.log(`[bridge] ✅ 设备已登记到账号: ${DEVICE_ID}`);
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
if (r.status === 409) {
|
|
1226
|
+
const code = d.error?.code || "";
|
|
1227
|
+
if (code === "device_limit_exceeded") {
|
|
1228
|
+
const msg = d.error?.message || "当前套餐最多绑定 1 台设备";
|
|
1229
|
+
persistBindError({ code, message: msg });
|
|
1230
|
+
console.error(`[bridge] 设备数已达上限: ${msg}`);
|
|
1231
|
+
console.error(" 同机重装会自动顶替旧设备;仍失败请到手机端设备管理解绑旧设备后重启(免费每月可解绑 3 次)。");
|
|
1232
|
+
} else {
|
|
1233
|
+
persistBindError({ code, status: 409, message: d.error?.message || "绑定冲突" });
|
|
1234
|
+
console.error(`[bridge] 设备 ${DEVICE_ID} 绑定失败(${code || 409}): ${d.error?.message || "未知错误"}`);
|
|
1235
|
+
}
|
|
1236
|
+
process.exit(1);
|
|
1237
|
+
}
|
|
1238
|
+
persistBindError({ code: d.error?.code || `http_${r.status}`, status: r.status, message: d.error?.message || "设备登记失败" });
|
|
1239
|
+
console.warn(`[bridge] 设备登记失败(${r.status}): ${d.error?.message || "未知错误"}(手机端设备列表可能看不到本设备)`);
|
|
1240
|
+
} catch (e) {
|
|
1241
|
+
persistBindError({ code: "api_unreachable", message: `无法连接账号 API: ${e.message}` });
|
|
1242
|
+
console.warn(`[bridge] 无法连接账号 API ${API_BASE}: ${e.message}(手机端设备列表可能看不到本设备)`);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function connectTunnel(token) {
|
|
1247
|
+
const endpoint = tunnelEndpoint(TUNNEL_URL);
|
|
1248
|
+
const ws = new WebSocket(endpoint, { followRedirects: false });
|
|
1249
|
+
const send = makeChunkedSender((s) => { if (ws.readyState === WebSocket.OPEN) ws.send(s); });
|
|
1250
|
+
let heartbeat;
|
|
1251
|
+
let pongReceived = true;
|
|
1252
|
+
|
|
1253
|
+
ws.on("open", () => {
|
|
1254
|
+
tunnelRetry = 0;
|
|
1255
|
+
console.log(`[bridge] 隧道已连 ${endpoint},注册 ${DEVICE_ID}...`);
|
|
1256
|
+
persistBridgeState({ device_id: DEVICE_ID, phase: "connecting" }); // 新一轮连接:上一轮的成功记录已过期
|
|
1257
|
+
try {
|
|
1258
|
+
// caps:bridge E2EE 能力上报(§6.2/§7.1;router 纯透传)。enabled=false → 空数组(明文回退)
|
|
1259
|
+
ws.send(JSON.stringify({ type: "tunnel-register", deviceId: DEVICE_ID, token, name: os.hostname() || "dsh-bridge", caps: e2ee.caps() }));
|
|
1260
|
+
} catch (e) { console.log(`[bridge] 注册发送失败: ${e.message}`); }
|
|
1261
|
+
heartbeat = setInterval(() => {
|
|
1262
|
+
if (!pongReceived) {
|
|
1263
|
+
console.log("[bridge] 隧道心跳超时,主动重连...");
|
|
1264
|
+
ws.terminate();
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
pongReceived = false;
|
|
1268
|
+
try { ws.ping(); } catch { ws.terminate(); }
|
|
1269
|
+
}, TUNNEL_HEARTBEAT_MS);
|
|
1270
|
+
});
|
|
1271
|
+
ws.on("pong", () => { pongReceived = true; });
|
|
1272
|
+
ws.on("message", (raw) => {
|
|
1273
|
+
const receive = makeFrameReceiver();
|
|
1274
|
+
try {
|
|
1275
|
+
receive(raw, (frame) => {
|
|
1276
|
+
if (frame?.type === "tunnel-register-ok") {
|
|
1277
|
+
// 记下中继声明的追加帧能力(旧中继不带 caps → 空集合 → 退回整包缓冲,见 routerCaps 注释)
|
|
1278
|
+
routerCaps.clear();
|
|
1279
|
+
for (const c of Array.isArray(frame.caps) ? frame.caps : []) routerCaps.add(String(c));
|
|
1280
|
+
// 中继注册成功 = 手机端此刻真的能进这台电脑:面板据此把阶段推进到 online(已连接 ✅)
|
|
1281
|
+
persistBridgeState({ device_id: DEVICE_ID, tunnel_registered_at: Date.now(), phase: "online", last_error: null });
|
|
1282
|
+
console.log(`[bridge] ✅ router 注册成功: ${DEVICE_ID},等待手机访问 /remote/${DEVICE_ID}/`);
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (frame?.type === "tunnel-register-err") {
|
|
1286
|
+
persistBridgeState({ phase: "error", last_error: { code: frame.code || "register_err", message: frame.message || "router 拒绝注册" } });
|
|
1287
|
+
console.error(`[bridge] router 拒绝注册: ${frame.code} ${frame.message || ""}`);
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
handleFrame(send, frame);
|
|
1291
|
+
});
|
|
1292
|
+
} catch (e) { console.log(`[bridge] 隧道帧错误: ${e.message}`); }
|
|
1293
|
+
});
|
|
1294
|
+
ws.on("close", (code, reason) => {
|
|
1295
|
+
routerCaps.clear(); // 换中继/断线:能力重新协商,期间一律退回保守行为
|
|
1296
|
+
clearInterval(heartbeat);
|
|
1297
|
+
console.log(`[bridge] 隧道断开(code=${code}${reason ? ", " + reason : ""})`);
|
|
1298
|
+
closeAllWsSessions();
|
|
1299
|
+
const delay = Math.min(30_000, 2_000 * 2 ** tunnelRetry);
|
|
1300
|
+
tunnelRetry += 1;
|
|
1301
|
+
console.log(`[bridge] ${Math.round(delay / 1000)}s 后重连...`);
|
|
1302
|
+
setTimeout(async () => connectTunnel(code === 4003 ? await resolveToken(true) : token), delay);
|
|
1303
|
+
});
|
|
1304
|
+
ws.on("error", (e) => console.log(`[bridge] 隧道错误: ${e.message || ""}`));
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* E2EE 启动初始化(账号模式):拉取服务端参数 → 用本机账号密码派生 MK(仅内存)。
|
|
1309
|
+
* 任何一步不满足都禁用 E2EE(reason 记录),bridge 走原 v1 明文路径,不影响隧道。
|
|
1310
|
+
* 启用结果写入 relayDir/.e2ee-state.json(供 Phase-4 UI 读取:{enabled, reason, ...})。
|
|
1311
|
+
* 状态文件路径策略:显式 DSH_BRIDGE_CONFIG / DSH_BRIDGE_E2EE_STATE 时落盘(生产必设),
|
|
1312
|
+
* 否则只打日志(避免污染仓库/非托管目录)。
|
|
1313
|
+
*/
|
|
1314
|
+
async function initE2ee(token) {
|
|
1315
|
+
const cfg = loadLocalConfig();
|
|
1316
|
+
const localMode = Boolean(process.env.DSH_BRIDGE_LOCAL_KEY); // 自建模式不启用(§6.6)
|
|
1317
|
+
const userDisabled = process.env.DSH_BRIDGE_E2EE === "0" || cfg.e2ee === false;
|
|
1318
|
+
const allowed = !localMode && !userDisabled;
|
|
1319
|
+
// 密码与 device-login 同源:env 优先,其次 .dsh-config.json(本机 0600 保存的账号密码)
|
|
1320
|
+
const password = PASSWORD || (typeof cfg.password === "string" && cfg.password ? cfg.password : "");
|
|
1321
|
+
e2ee = await E2eeService.init({ apiBase: API_BASE, token, password, allowed });
|
|
1322
|
+
const st = e2ee.state();
|
|
1323
|
+
const stateFileDir = process.env.DSH_BRIDGE_E2EE_STATE || path.dirname(CONFIG_PATH);
|
|
1324
|
+
if (process.env.DSH_BRIDGE_CONFIG || process.env.DSH_BRIDGE_E2EE_STATE) {
|
|
1325
|
+
writeE2eeStateFile(stateFileDir, { enabled: st.enabled, reason: st.reason, profile: st.profile, epoch: st.epoch, caps: e2ee.caps() });
|
|
1326
|
+
}
|
|
1327
|
+
if (st.enabled) {
|
|
1328
|
+
console.log(`[bridge] 🔒 E2EE 已启用(caps=[${e2ee.caps().join(",")}], profile=${st.profile}, epoch=${st.epoch});MK 仅驻内存`);
|
|
1329
|
+
} else {
|
|
1330
|
+
console.log(`[bridge] e2ee 未启用(${st.reason}) → 明文 v1 路径照常(能力不上报)`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* 启动微信通道(控制面 + 已绑定时的事件通知/入站长轮询)。
|
|
1336
|
+
*
|
|
1337
|
+
* ★ 为什么**不 await**、且放在 connectTunnel 之后:
|
|
1338
|
+
* 微信侧是"锦上添花"的能力(发通知)。它必须**永远不能**拖慢或拖挂远程访问本身 ——
|
|
1339
|
+
* 所以先让隧道连上,再 fire-and-forget 起微信;任何异常只记日志,不冒泡、不 exit。
|
|
1340
|
+
* 与 e2ee/mobile-adapter 的定位一致:主链路优先。
|
|
1341
|
+
*/
|
|
1342
|
+
/** 给没自带前缀的微信日志补上 `[wechat] `;已带的原样返回(避免双重前缀)。 */
|
|
1343
|
+
function tagWeChat(m) {
|
|
1344
|
+
const s = String(m);
|
|
1345
|
+
return s.startsWith("[") ? s : `[wechat] ${s}`;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* 微信通道的档位来源(免费/付费分层)。
|
|
1350
|
+
*
|
|
1351
|
+
* 取值 = 账号的**生效套餐**,服务端只有三种:free / pro / pro_max
|
|
1352
|
+
* (见企业端 auth.js 的 PLAN_PRIORITY;试用期服务端已折算成 pro,这里不必自己判断)。
|
|
1353
|
+
*
|
|
1354
|
+
* ★ 返回**两种契约**(见 docs/entitlements-contract.md §5/§6.1):
|
|
1355
|
+
* · 服务端 `/api/me` 带了 `entitlements`(后台可配置的权益包)→ 返回**对象**
|
|
1356
|
+
* `{plan, caps, limits, rev}`,runtime 以它为准判定能力 —— 这样「后台改配置 = 用户侧实时生效」
|
|
1357
|
+
* 才成立(无需发版)。caps/limits **原样透传**:权限判定以服务端为唯一真相源,
|
|
1358
|
+
* 客户端任何"二次加工/本地过滤"都会让展示与判定分叉,也会让后台配置失效(红线 §8.1/§8.2)。
|
|
1359
|
+
* · 服务端**没带** `entitlements`(灰度期的旧服务端)→ 保持今天的行为、返回**字符串**档位:
|
|
1360
|
+
* runtime 有上次的包就继续用它,一份包都没有才按内置表解析。新旧服务端都要能跑,
|
|
1361
|
+
* 所以这里不能只认新契约。
|
|
1362
|
+
*
|
|
1363
|
+
* ⚠️ 取不到时返回**空串**,而不是 "free":空串让 runtime 沿用上次成功取到的档位。
|
|
1364
|
+
* 一次网络抖动就把付过钱的用户降级成免费、还提示他去升级,比多给几分钟权限糟糕得多。
|
|
1365
|
+
* ⚠️ 自建部署直接给最高档:用户跑的是自己的服务器,不存在"会员"这回事,
|
|
1366
|
+
* 不能拿 SaaS 的分层去卡自建用户。
|
|
1367
|
+
*/
|
|
1368
|
+
/**
|
|
1369
|
+
* 档位查询专用的 token 缓存(理由见 resolveWechatTier 里的注释)。
|
|
1370
|
+
* TTL 取 5 分钟:远小于 JWT 有效期,又足以把「每 10 分钟一次定时校准 + 被拒即重查」
|
|
1371
|
+
* 全部挡在网络之外 —— 不缓存的话,免费用户连着发几条消息就会触发一串 device-login。
|
|
1372
|
+
*/
|
|
1373
|
+
const WECHAT_TIER_TOKEN_TTL_MS = 5 * 60_000;
|
|
1374
|
+
let WECHAT_TIER_TOKEN = { value: "", at: 0 };
|
|
1375
|
+
/**
|
|
1376
|
+
* 最近一次 `/api/me` 的账号快照(查档位时顺手记下来)。
|
|
1377
|
+
*
|
|
1378
|
+
* 为什么需要:微信通道要做"会员临近到期"提醒,而它**不该再单独打一次** `/api/me` ——
|
|
1379
|
+
* 档位每 10 分钟本来就会查一次,顺手记住即可。查失败时**保持上次的值**(与档位同一策略:
|
|
1380
|
+
* 一次网络抖动不该让提醒凭空消失或变成乱猜)。
|
|
1381
|
+
*/
|
|
1382
|
+
let WECHAT_ACCOUNT_SNAPSHOT = null;
|
|
1383
|
+
/** 供微信通道取账号快照(到期提醒用)。没查过就返回 null → 上层什么都不发。 */
|
|
1384
|
+
async function wechatAccountInfo() {
|
|
1385
|
+
return WECHAT_ACCOUNT_SNAPSHOT;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
async function resolveWechatTier() {
|
|
1389
|
+
if (process.env.DSH_BRIDGE_LOCAL_KEY) return "pro_max";
|
|
1390
|
+
try {
|
|
1391
|
+
// ⚠️ `resolveToken()` **不带缓存**:账号模式下每调一次就重新 device-login 一次
|
|
1392
|
+
// (见它的第一行,只有 DSH_BRIDGE_TOKEN / 自建模式才短路)。
|
|
1393
|
+
// 而档位会被反复查询:每 10 分钟一次定时校准 + 免费用户**每次被拒**都重查(最快 5 秒一次)。
|
|
1394
|
+
// 拿它直接查 = 用户连着发几条消息就把自己打到登录限流里 —— 而 /api/device-login
|
|
1395
|
+
// 是**按出口 IP 限流**的(15 分钟 5 次),连累的不只是他自己。
|
|
1396
|
+
// 所以这里自己缓存 token。
|
|
1397
|
+
const now = Date.now();
|
|
1398
|
+
if (!WECHAT_TIER_TOKEN.value || now - WECHAT_TIER_TOKEN.at > WECHAT_TIER_TOKEN_TTL_MS) {
|
|
1399
|
+
const t = await resolveToken();
|
|
1400
|
+
if (!t) return "";
|
|
1401
|
+
WECHAT_TIER_TOKEN = { value: t, at: now };
|
|
1402
|
+
}
|
|
1403
|
+
const r = await fetch(`${API_BASE}/api/me`, {
|
|
1404
|
+
headers: { authorization: `Bearer ${WECHAT_TIER_TOKEN.value}` },
|
|
1405
|
+
signal: AbortSignal.timeout(6000)
|
|
1406
|
+
});
|
|
1407
|
+
// token 过期/被吊销 → 丢掉缓存,下一次查询重新登录(而不是一直拿坏 token 打)
|
|
1408
|
+
if (r.status === 401 || r.status === 403) {
|
|
1409
|
+
WECHAT_TIER_TOKEN = { value: "", at: 0 };
|
|
1410
|
+
return "";
|
|
1411
|
+
}
|
|
1412
|
+
if (!r.ok) return "";
|
|
1413
|
+
const j = await r.json().catch(() => null);
|
|
1414
|
+
const u = (j && j.user) || null;
|
|
1415
|
+
// 顺手记账号快照(到期提醒用)。⚠️ 只在**真拿到了 user** 时覆盖 —— 否则保持上次。
|
|
1416
|
+
if (u) {
|
|
1417
|
+
WECHAT_ACCOUNT_SNAPSHOT = {
|
|
1418
|
+
plan: String(u.plan || ""),
|
|
1419
|
+
plan_ends_at: u.plan_ends_at ?? null,
|
|
1420
|
+
trial_expires_at: u.trial_expires_at ?? null
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
// ★ 服务端权益包(契约 §5):有它就返回对象,runtime 以服务端 caps/limits 为准。
|
|
1424
|
+
//
|
|
1425
|
+
// 🔴 判据是「`caps` 是**数组**」,**空数组也算给了**(2026-09-23 修):
|
|
1426
|
+
// 后台「明确清空」某档(契约 §4.1 的 `EMPTY_CAPS_VALUE`,下发即 `caps: []`)表达的是
|
|
1427
|
+
// **这个档位什么能力都没有**。如果这里把空数组当成"没给",runtime 就会沿用上次那份包
|
|
1428
|
+
// (一份都没有时回退**内置表**)—— 于是"我清空了 pro"变成"pro 拿回内置的一整套付费能力",
|
|
1429
|
+
// 比后台意图**更多**权限。那是 fail-open,方向正好反了(契约 §8 红线 2)。
|
|
1430
|
+
// 所以:数组(含空) = 权威;不是数组/字段缺失 = 这次没拿到 → 退回字符串契约沿用上次。
|
|
1431
|
+
const ent = j && j.entitlements;
|
|
1432
|
+
if (ent && typeof ent === "object" && Array.isArray(ent.caps)) {
|
|
1433
|
+
return {
|
|
1434
|
+
// plan 也原样取服务端的值;只有权益包里缺 plan 时才退回 user.plan(同一份真相的旧字段)。
|
|
1435
|
+
plan: String(ent.plan || (u && u.plan) || ""),
|
|
1436
|
+
caps: ent.caps,
|
|
1437
|
+
// ⚠️ limits 缺失时必须给 null,**不能补 `{}`**:runtime 里 `{}` 是"有效的一张限制表",
|
|
1438
|
+
// 于是 messages_per_month 读成 0 = 不限 → 免费用户当场变成无限额度(等于放权)。
|
|
1439
|
+
// null 才会让 runtime 沿用上次的值 / 回退内置表 —— 缺字段绝不等于放开(契约 §8.2 fail-closed)。
|
|
1440
|
+
limits: ent.limits && typeof ent.limits === "object" ? ent.limits : null,
|
|
1441
|
+
rev: String(ent.rev || "")
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
// 没有权益包(旧服务端 / 灰度期)→ 保持旧契约:只给档位字符串。
|
|
1445
|
+
// runtime 收到字符串后:有上次的包就继续用它,一份包都没有才按内置表解析(冷启动兜底)。
|
|
1446
|
+
return String((u && u.plan) || "");
|
|
1447
|
+
} catch {
|
|
1448
|
+
return "";
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* App 入口(免费用户越界时给的转化链接)。
|
|
1454
|
+
* 由账号 API 地址推导:去掉 `/relay-api` 再加 `/app/` —— 与插件半的 DEFAULT_APP_URL 同一口径
|
|
1455
|
+
* (https://host:port/relay-api → https://host:port/app/)。
|
|
1456
|
+
*/
|
|
1457
|
+
function wechatAppUrl() {
|
|
1458
|
+
return `${API_BASE.replace(/\/relay-api\/?$/, "")}/app/`;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
let wechatRuntime = null;
|
|
1462
|
+
function startWeChat() {
|
|
1463
|
+
if (WECHAT_DISABLED) {
|
|
1464
|
+
console.log("[bridge] 微信通道已禁用(DSH_WECHAT=0)");
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
try {
|
|
1468
|
+
const relayDir = path.dirname(CONFIG_PATH);
|
|
1469
|
+
const cfg = loadLocalConfig();
|
|
1470
|
+
wechatRuntime = createWeChatRuntime({
|
|
1471
|
+
relayDir,
|
|
1472
|
+
upstream: UPSTREAM,
|
|
1473
|
+
cookieOf: harnessCookieOf,
|
|
1474
|
+
secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
|
|
1475
|
+
// 免费/付费分层:档位来自账号的生效套餐,App 链接是免费用户越界时的转化入口。
|
|
1476
|
+
tierProvider: resolveWechatTier,
|
|
1477
|
+
// 到期提醒的数据来源(复用上面那次 /api/me,不额外打网络)
|
|
1478
|
+
accountInfo: wechatAccountInfo,
|
|
1479
|
+
appUrl: wechatAppUrl(),
|
|
1480
|
+
// 模块自己的消息已带 `[wechat]` / `[wechat/events]` 前缀,这里**不能再加一次**
|
|
1481
|
+
// (真机日志里出现过 `[wechat] [wechat] 控制面已就绪`)。没前缀的兜底补一个。
|
|
1482
|
+
logger: {
|
|
1483
|
+
info: (m) => console.log(tagWeChat(m)),
|
|
1484
|
+
warn: (m) => console.warn(tagWeChat(m)),
|
|
1485
|
+
error: (m) => console.warn(tagWeChat(m)),
|
|
1486
|
+
debug: () => {},
|
|
1487
|
+
// WeChatChannel 构造时会给 token 登记脱敏;这里保持同样契约(日志不落 token)
|
|
1488
|
+
addSecret: () => {}
|
|
1489
|
+
}
|
|
1490
|
+
});
|
|
1491
|
+
wechatRuntime.start().catch((e) => {
|
|
1492
|
+
console.warn(`[bridge] 微信通道启动失败(不影响远程访问): ${redactText(e)}`);
|
|
1493
|
+
});
|
|
1494
|
+
} catch (e) {
|
|
1495
|
+
console.warn(`[bridge] 微信通道初始化失败(不影响远程访问): ${redactText(e)}`);
|
|
1496
|
+
wechatRuntime = null;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/** 优雅退出微信通道(notifystop + 关控制面)。失败只记日志。 */
|
|
1501
|
+
async function stopWeChat() {
|
|
1502
|
+
const rt = wechatRuntime;
|
|
1503
|
+
wechatRuntime = null;
|
|
1504
|
+
if (!rt) return;
|
|
1505
|
+
try {
|
|
1506
|
+
await rt.stop();
|
|
1507
|
+
} catch (e) {
|
|
1508
|
+
console.warn(`[bridge] 微信通道停止失败(忽略): ${redactText(e)}`);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function redactText(e) {
|
|
1513
|
+
return String(e && e.message ? e.message : e).slice(0, 200);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
async function runTunnel() {
|
|
1517
|
+
const token = await resolveToken();
|
|
1518
|
+
if (!token) {
|
|
1519
|
+
console.error("[bridge] 隧道模式需要账号认证:请设 DSH_BRIDGE_TOKEN,或 DSH_BRIDGE_PHONE+DSH_BRIDGE_PASSWORD");
|
|
1520
|
+
process.exit(1);
|
|
1521
|
+
}
|
|
1522
|
+
// 把刚拿到的 JWT 喂给档位查询的缓存:否则 startWeChat 里的档位校准会**再登录一次**
|
|
1523
|
+
// (真机日志实测:同一个桥接启动里出现两条并发的 device-login)。
|
|
1524
|
+
// 而 /api/device-login 是按出口 IP 限流的 —— 每次启动都多花一份登录额度没有道理。
|
|
1525
|
+
WECHAT_TIER_TOKEN = { value: token, at: Date.now() };
|
|
1526
|
+
// 新进程开始:重置状态文件(上一轮的成功记录不能代表这一轮,面板据此判定 online)
|
|
1527
|
+
persistBridgeState({ device_id: DEVICE_ID, started_at: Date.now(), phase: "connecting", last_error: null }, { reset: true });
|
|
1528
|
+
await initE2ee(token);
|
|
1529
|
+
await registerDeviceInAccount(token);
|
|
1530
|
+
connectTunnel(token);
|
|
1531
|
+
// 隧道已发起后再起微信:主链路优先,微信永不阻塞远程访问
|
|
1532
|
+
startWeChat();
|
|
1533
|
+
setTimeout(() => {
|
|
1534
|
+
console.log(`[bridge] 隧道模式运行中(上游 ${UPSTREAM},Ctrl-C 退出)`);
|
|
1535
|
+
}, 1000);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
async function main() {
|
|
1539
|
+
// 隧道模式是唯一模式(WebRTC/信令已废弃删除)
|
|
1540
|
+
if (!TUNNEL_URL) {
|
|
1541
|
+
console.error("[bridge] 缺少 DSH_BRIDGE_TUNNEL_URL:隧道模式是唯一模式(请设 relay-router 地址)");
|
|
1542
|
+
process.exit(1);
|
|
1543
|
+
}
|
|
1544
|
+
// ⚠️ 本文件此前**一个 process.on 都没有** —— 被 launchd/systemd/schtasks 杀掉时,
|
|
1545
|
+
// 微信长轮询不会收到信号,腾讯侧会一直以为通道在线(notifystop 永远不发)。
|
|
1546
|
+
// 只注册在 main() 里(被测试 import 时不污染测试进程)。
|
|
1547
|
+
let shuttingDown = false;
|
|
1548
|
+
const shutdown = async (sig) => {
|
|
1549
|
+
if (shuttingDown) return;
|
|
1550
|
+
shuttingDown = true;
|
|
1551
|
+
console.log(`[bridge] 收到 ${sig},正在优雅退出...`);
|
|
1552
|
+
await stopWeChat();
|
|
1553
|
+
process.exit(0);
|
|
1554
|
+
};
|
|
1555
|
+
process.on("SIGTERM", () => { shutdown("SIGTERM"); });
|
|
1556
|
+
process.on("SIGINT", () => { shutdown("SIGINT"); });
|
|
1557
|
+
// 【Windows 的关键一条】Windows 上 process.kill() 是**无条件 TerminateProcess**:
|
|
1558
|
+
// 上面的 SIGTERM/SIGINT 处理函数在 Windows 上**永远不会被执行**,
|
|
1559
|
+
// 于是 notifystop(微信通道下线通知)永远发不出去,腾讯侧一直以为通道还在线。
|
|
1560
|
+
// watcher 通过 IPC 发来的 shutdown 消息是 Windows 上唯一能真正跑到的优雅退出路径
|
|
1561
|
+
//(POSIX 上也一并走这条,比信号更可靠)。见 src/lifecycle.mjs 的 stopChildGracefully。
|
|
1562
|
+
process.on("message", (m) => {
|
|
1563
|
+
if (m && m.type === "shutdown") shutdown("IPC shutdown");
|
|
1564
|
+
});
|
|
1565
|
+
return runTunnel();
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// 直接运行(node dsh-bridge.mjs)时启动服务;被测试 import 时只导出协议函数。
|
|
1569
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
1570
|
+
if (isMain) {
|
|
1571
|
+
main();
|
|
1572
|
+
}
|