@mrrisega/dsh-remote 0.3.0

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.
@@ -0,0 +1,658 @@
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/events.mux|host 下行流) ──
27
+ * → { "id", "type":"ws-open", "path", "headers":{...} } ← { "id","type":"ws-open","ok":true|false,"code"?,"reason"? }
28
+ * → { "id", "type":"ws-msg", "data":<文本|base64>, "binary"? }
29
+ * ← { "id", "type":"ws-msg", "data":<文本|base64>, "binary"? }
30
+ * → { "id", "type":"ws-close", "code"?, "reason"? } ← { "id","type":"ws-close","code"?,"reason"? }
31
+ * ws 会话以帧 id 为 key;隧道断开时全部关闭。
32
+ *
33
+ * 环境:
34
+ * DSH_BRIDGE_TUNNEL_URL 必填:relay-router 地址(如 ws://127.0.0.1:13444 或 wss://relay.example.com)
35
+ * DSH_BRIDGE_DEVICE_ID 覆盖稳定 deviceId(缺省读/写 .dsh-config.json 的 device_id)
36
+ * DSH_BRIDGE_UPSTREAM 上游 dsh web(默认 http://127.0.0.1:3080)
37
+ * DSH_BRIDGE_EMAIL 账号邮箱(与 DSH_BRIDGE_PASSWORD 一起自动登录拿 JWT;手机号用 DSH_BRIDGE_PHONE)
38
+ * DSH_BRIDGE_PHONE 账号手机号
39
+ * DSH_BRIDGE_PASSWORD 账号密码
40
+ * DSH_BRIDGE_TOKEN JWT(直接给 token;优先级高于 手机号/邮箱+密码)
41
+ * DSH_BRIDGE_API 账号 API 地址(默认云端服务地址;自建模式无需设置)
42
+ * DSH_BRIDGE_LOCAL_KEY 开源自部署:访问密钥(设后经 router POST /_login 换本地 JWT,免账号体系)
43
+ * DSH_BRIDGE_HEARTBEAT_MS 隧道心跳间隔(默认 15000ms)
44
+ */
45
+
46
+ import WebSocket from "ws";
47
+ import fs from "node:fs";
48
+ import os from "node:os";
49
+ import path from "node:path";
50
+ import { randomBytes, generateKeyPairSync } from "node:crypto";
51
+ import { pathToFileURL, fileURLToPath } from "node:url";
52
+ import { promisify } from "node:util";
53
+ import { gzip as gzipCb } from "node:zlib";
54
+
55
+ // ---------- 强制直连:清除代理环境变量 ----------
56
+ // 家庭网络常配 Clash 等代理(127.0.0.1:7890),node 的 ws/fetch 会继承
57
+ // http_proxy/https_proxy 导致到信令 WSS 的 TLS 握手失败(SSL_ERROR_SYSCALL)。
58
+ // bridge 必须直连 relay,不受本机代理影响。
59
+ for (const k of ["http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "no_proxy", "NO_PROXY"]) {
60
+ delete process.env[k];
61
+ }
62
+
63
+ const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
64
+ const ROOT = path.join(THIS_DIR, "..", "..");
65
+ // 配置路径:dsh-setup 总会显式传入 DSH_BRIDGE_CONFIG;npm 安装时默认落到 ~/.dsh-remote
66
+ const DEFAULT_CONFIG = path.join(
67
+ ROOT.includes(`${path.sep}node_modules${path.sep}`) ? path.join(os.homedir(), ".dsh-remote") : ROOT,
68
+ ".dsh-config.json"
69
+ );
70
+ const CONFIG_PATH = process.env.DSH_BRIDGE_CONFIG || DEFAULT_CONFIG;
71
+ // 隧道模式(唯一):bridge 主动 WS 连 relay-router 的 /_bridge
72
+ const TUNNEL_URL = (process.env.DSH_BRIDGE_TUNNEL_URL || "").replace(/\/+$/, "");
73
+ const TUNNEL_HEARTBEAT_MS = Math.max(100, Number(process.env.DSH_BRIDGE_HEARTBEAT_MS) || 15_000);
74
+ const UPSTREAM = process.env.DSH_BRIDGE_UPSTREAM || "http://127.0.0.1:3080";
75
+ // 默认云端服务地址(dsh-remote setup 会显式传入;自建模式无需账号 API)
76
+ const API_BASE = (process.env.DSH_BRIDGE_API || "https://n.risegao.cn:13443/relay-api").replace(/\/+$/, "");
77
+ const EMAIL = process.env.DSH_BRIDGE_EMAIL || "";
78
+ // 手机号优先;兼容旧的 DSH_BRIDGE_EMAIL(过渡期)
79
+ const PHONE = process.env.DSH_BRIDGE_PHONE || EMAIL;
80
+ const PASSWORD = process.env.DSH_BRIDGE_PASSWORD || "";
81
+ const TOKEN = process.env.DSH_BRIDGE_TOKEN || "";
82
+
83
+ // ---------- 稳定设备身份(.dsh-config.json) ----------
84
+
85
+ function loadLocalConfig() {
86
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
87
+ }
88
+
89
+ function saveLocalConfig(cfg) {
90
+ try {
91
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
92
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
93
+ } catch (e) {
94
+ console.warn(`[bridge] 无法写配置 ${CONFIG_PATH}: ${e.message}`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * 解析稳定 deviceId:优先级 env DSH_BRIDGE_DEVICE_ID > argv[2] > 配置 device_id > 生成。
100
+ * 生成格式 dev-<12hex> 并持久化到 .dsh-config.json,保证每台 Mac 重启后 id 不变。
101
+ */
102
+ function resolveDeviceIdentity() {
103
+ const envId = process.env.DSH_BRIDGE_DEVICE_ID || "";
104
+ const argId = process.argv[3] || "";
105
+ const cfg = loadLocalConfig();
106
+ const pick = () => envId || argId || (typeof cfg.device_id === "string" && cfg.device_id ? cfg.device_id : "");
107
+ const existing = pick();
108
+ if (existing) return existing;
109
+ const id = "dev-" + randomBytes(6).toString("hex"); // 12 hex
110
+ cfg.device_id = id;
111
+ saveLocalConfig(cfg);
112
+ return id;
113
+ }
114
+
115
+ /** 设备 ed25519 公钥(账号设备登记用;缺省生成并持久化,格式与 dsh-setup 一致)。 */
116
+ function resolveDevicePubKey() {
117
+ const cfg = loadLocalConfig();
118
+ if (typeof cfg.device_public_key === "string" && cfg.device_public_key) return cfg.device_public_key;
119
+ try {
120
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
121
+ cfg.device_public_key = publicKey.export({ type: "spki", format: "der" }).toString("hex");
122
+ cfg.device_private_key = privateKey.export({ type: "pkcs8", format: "der" }).toString("hex");
123
+ saveLocalConfig(cfg);
124
+ return cfg.device_public_key;
125
+ } catch (e) {
126
+ console.warn(`[bridge] ed25519 密钥生成失败: ${e.message}(用临时公钥,设备 id 仍稳定)`);
127
+ return "ed25519:" + randomBytes(16).toString("hex");
128
+ }
129
+ }
130
+
131
+ const DEVICE_ID = resolveDeviceIdentity();
132
+ // 单条消息分块尺寸(任何一端都不会超限)。
133
+ const CHUNK_SIZE = 200 * 1024;
134
+ // 单个 HTTP 请求的兜底超时(秒)。dsh 的 prompt 等操作可能跑很久,给足余量。
135
+ const HTTP_TIMEOUT_MS = 120_000;
136
+
137
+ // 转发请求时剥离的浏览器/代理头(围栏只认 Host + Origin + Sec-Fetch-*):
138
+ // - Host:fetch 自动取上游 authority(127.0.0.1:3080)→ loopback 围栏通过;
139
+ // ws 库需要显式设置(见 buildWsHeaders)。
140
+ // - Origin/Sec-Fetch-*/Referer:围栏里 cross-site / 异源 Origin 会被拒,必须剥掉。
141
+ // - Cookie:手机侧的 cookie 属于手机域名,与 dsh web 无关,不应透传。
142
+ // - content-length/transfer-encoding:由 fetch/ws 自己计算。
143
+ const STRIP_REQ_HEADERS = new Set([
144
+ "host", "origin", "referer", "cookie", "connection", "upgrade",
145
+ "keep-alive", "transfer-encoding", "content-length", "accept-encoding",
146
+ "sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest", "sec-fetch-user",
147
+ "te", "trailer", "proxy-connection", "x-forwarded-for", "x-forwarded-proto",
148
+ "x-forwarded-host"
149
+ ]);
150
+ // 回包时剥离的实体头:undici 已自动解压 body,content-encoding/length 会误导浏览器。
151
+ const STRIP_RES_HEADERS = new Set([
152
+ "content-encoding", "content-length", "transfer-encoding",
153
+ "connection", "keep-alive", "upgrade"
154
+ ]);
155
+
156
+ console.log(`[bridge] 设备 ${DEVICE_ID} → 隧道 ${TUNNEL_URL}/_bridge`);
157
+ console.log(`[bridge] 上游 ${UPSTREAM}`);
158
+
159
+ // ============================================================
160
+ // 通用工具:分块收发(双向)
161
+ // ============================================================
162
+
163
+ let chunkSeq = 0;
164
+
165
+ /**
166
+ * 构造「超长自动分块」的发送函数(隧道模式用;WebRTC 模式仍走 dcSend)。
167
+ * rawSend 接收完整字符串(如 ws.send)。返回 send(obj) → boolean。
168
+ */
169
+ export function makeChunkedSender(rawSend) {
170
+ return function send(obj) {
171
+ const s = JSON.stringify(obj);
172
+ if (s.length <= CHUNK_SIZE) {
173
+ try { rawSend(s); } catch { return false; }
174
+ return true;
175
+ }
176
+ const cid = `c${++chunkSeq}`;
177
+ let sent = true;
178
+ for (let i = 0; i < s.length; i += CHUNK_SIZE) {
179
+ const part = { __chunk: { id: cid, n: Math.ceil(s.length / CHUNK_SIZE), i: i / CHUNK_SIZE, data: s.slice(i, i + CHUNK_SIZE) } };
180
+ try { rawSend(JSON.stringify(part)); } catch { sent = false; break; }
181
+ }
182
+ return sent;
183
+ };
184
+ }
185
+
186
+ /** 把对象发上 DataChannel;超长自动分块。返回 true 表示已发送(含分块)。 */
187
+ export function dcSend(dc, obj) {
188
+ if (!dc || dc.readyState !== "open") return false;
189
+ return makeChunkedSender((s) => dc.send(s))(obj);
190
+ }
191
+
192
+ /** 兼容两种调用:handleXxx(dch, frame)(WebRTC) 或 handleXxx(sendFn, frame)(隧道)。 */
193
+ function toSender(dchOrSend) {
194
+ return typeof dchOrSend === "function" ? dchOrSend : (obj) => dcSend(dchOrSend, obj);
195
+ }
196
+
197
+ /**
198
+ * 接收侧:把一条原始 DataChannel 消息规整为完整帧。
199
+ * 普通帧直接回传;分块帧攒齐后回传解析结果。需要挂在每个 channel 上:
200
+ * const recv = makeFrameReceiver();
201
+ * dch.onmessage = (ev) => recv(ev.data, (frame) => handleFrame(dch, frame));
202
+ */
203
+ export function makeFrameReceiver() {
204
+ const bufs = new Map(); // chunkId → { n, parts: string[] }
205
+ return function receive(raw, onFrame) {
206
+ let text;
207
+ if (raw instanceof ArrayBuffer) text = Buffer.from(raw).toString("utf8");
208
+ else if (Buffer.isBuffer(raw)) text = raw.toString("utf8");
209
+ else text = String(raw);
210
+ let obj;
211
+ try { obj = JSON.parse(text); } catch { return; }
212
+ if (obj && obj.__chunk) {
213
+ const c = obj.__chunk;
214
+ let acc = bufs.get(c.id);
215
+ if (!acc) { acc = { n: c.n, parts: [] }; bufs.set(c.id, acc); }
216
+ acc.parts[c.i] = c.data;
217
+ if (acc.parts.filter(Boolean).length === acc.n) {
218
+ bufs.delete(c.id);
219
+ try { onFrame(JSON.parse(acc.parts.join(""))); } catch { /* 损坏分块,丢弃 */ }
220
+ }
221
+ return;
222
+ }
223
+ onFrame(obj);
224
+ };
225
+ }
226
+
227
+ // ============================================================
228
+ // 头处理:围栏穿透
229
+ // ============================================================
230
+
231
+ /** 清洗手机发来的请求头:剥离浏览器标记,保留其余(供 fetch 转发)。 */
232
+ export function sanitizeRequestHeaders(headers) {
233
+ const out = {};
234
+ for (const [k, v] of Object.entries(headers || {})) {
235
+ const lk = k.toLowerCase();
236
+ if (STRIP_REQ_HEADERS.has(lk)) continue;
237
+ if (v === undefined || v === null || v === "") continue;
238
+ out[k] = Array.isArray(v) ? v.join(", ") : String(v);
239
+ }
240
+ return out;
241
+ }
242
+
243
+ /** 清洗上游响应头:剥离实体/传输头,避免误导浏览器解码。 */
244
+ export function sanitizeResponseHeaders(headers) {
245
+ const out = {};
246
+ for (const [k, v] of Object.entries(headers || {})) {
247
+ const lk = k.toLowerCase();
248
+ if (STRIP_RES_HEADERS.has(lk)) continue;
249
+ if (v === undefined || v === null || v === "") continue;
250
+ out[k] = Array.isArray(v) ? v.join(", ") : String(v);
251
+ }
252
+ return out;
253
+ }
254
+
255
+ /** ws 库用的请求头:Host 显式写成上游 authority(loopback),其余同 sanitize。 */
256
+ export function buildWsHeaders(headers) {
257
+ const up = new URL(UPSTREAM);
258
+ const out = sanitizeRequestHeaders(headers);
259
+ out.Host = up.host; // e.g. "127.0.0.1:3080"
260
+ return out;
261
+ }
262
+
263
+ // ============================================================
264
+ // 帧分发
265
+ // ============================================================
266
+
267
+ /** 处理一帧。type 缺省 → 旧协议(向后兼容)。 */
268
+ export async function handleFrame(dchOrSend, frame) {
269
+ if (!frame || typeof frame !== "object") return;
270
+ const { id, type } = frame;
271
+ if (id === undefined || id === null) return;
272
+ const send = toSender(dchOrSend);
273
+ if (type === "http") return handleHttpFrame(send, frame);
274
+ if (type === "ws-open") return handleWsOpen(send, frame);
275
+ if (type === "ws-msg") return handleWsMessage(send, frame);
276
+ if (type === "ws-close") return handleWsClose(send, frame);
277
+ if (type === undefined) return handleLegacyFrame(send, frame); // 旧协议
278
+ // 未知 type:忽略
279
+ }
280
+
281
+ // ---- HTTP 透明代理 ----
282
+
283
+ /** 校验上游路径,防 SSRF(禁止 userinfo/协议相对/绝对 URL/反斜杠/控制字符)。 */
284
+ function safePath(path) {
285
+ if (typeof path !== "string" || !path.startsWith("/")) return null;
286
+ if (/[\x00-\x1f\x7f\\]/.test(path)) return null; // 控制字符/反斜杠(SSRF:无前导 / 已在上面拒绝,@ 在路径段里无害,如 /plugins/@deepseek-ai/)
287
+ if (path.startsWith("//") || /^\/[^/]*:/.test(path)) return null; // 协议相对或 scheme
288
+ return path;
289
+ }
290
+
291
+ const gzip = promisify(gzipCb);
292
+ // 可压缩的响应类型:JS/JSON/CSS/SVG/XML/纯文本。图片/视频/字体等二进制不压(压了也小不了)。
293
+ const COMPRESSIBLE_CT_RE = /javascript|json|css|svg|xml|text\//i;
294
+ const MIN_COMPRESS_BYTES = 1024;
295
+
296
+ /** 大小写不敏感取帧头(手机经 router 转发的头键大小写不保证)。 */
297
+ function headerValue(headers, name) {
298
+ const lk = name.toLowerCase();
299
+ for (const [k, v] of Object.entries(headers || {})) {
300
+ if (k.toLowerCase() === lk) return Array.isArray(v) ? v.join(", ") : String(v);
301
+ }
302
+ return "";
303
+ }
304
+
305
+ /**
306
+ * 决定是否把上游响应 gzip 压缩后回传(隧道带宽优化,手机远程控制打开更快)。
307
+ * 保守策略:任一条件不满足都返回 null(原样回传):
308
+ * - 方法非 HEAD、状态非 204/304;
309
+ * - buf ≥ 1KB(太小不值得压);
310
+ * - 上游未编码(contentEncoding 为空;undici 已自动解压 body,这里只看原 header);
311
+ * - 请求 Accept-Encoding 含 gzip(手机浏览器必带;没有就不压,避免手机不会解压);
312
+ * - content-type 可压缩(排除 text/event-stream);
313
+ * - gzip 后确实更小(极小文件/不可压数据 gzip 反而更大,保守判断)。
314
+ * 返回 { buf, headers }(headers 为需附加到响应头的键值)或 null。
315
+ */
316
+ export async function maybeCompressResponse({ buf, contentType, contentEncoding, acceptEncoding, status, method }) {
317
+ if (method === "HEAD") return null;
318
+ if (status === 204 || status === 304) return null;
319
+ if (!Buffer.isBuffer(buf) || buf.length < MIN_COMPRESS_BYTES) return null;
320
+ if (contentEncoding) return null; // 上游已编码:body 语义不明,不叠压缩
321
+ if (!String(acceptEncoding || "").toLowerCase().includes("gzip")) return null;
322
+ const ct = String(contentType || "");
323
+ if (!ct || ct.includes("text/event-stream") || !COMPRESSIBLE_CT_RE.test(ct)) return null;
324
+ let out;
325
+ try {
326
+ out = await gzip(buf); // 异步压缩,不阻塞事件循环
327
+ } catch {
328
+ return null; // 压缩失败保守回退
329
+ }
330
+ if (out.length >= buf.length) return null; // 压完没变小
331
+ return { buf: out, headers: { "content-encoding": "gzip" } };
332
+ }
333
+
334
+ async function doHttp(method, path, reqHeaders, body, isB64) {
335
+ const safe = safePath(path);
336
+ if (safe === null) throw new Error("非法路径");
337
+ const url = `${UPSTREAM}${safe}`;
338
+ const init = { method, headers: sanitizeRequestHeaders(reqHeaders) };
339
+ if (body !== undefined && body !== null && body !== "") {
340
+ // 新协议 http 帧的 body 一律 base64;旧协议 body 是原始文本
341
+ init.body = isB64 ? Buffer.from(String(body), "base64") : String(body);
342
+ }
343
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) });
344
+ let buf = Buffer.from(await res.arrayBuffer());
345
+ // sanitizeResponseHeaders 会剥 content-encoding(undici 已解压,原头会误导浏览器);
346
+ // 若我们自行 gzip,必须在 sanitize 之后把 content-encoding: gzip 补回,手机才能正确解压。
347
+ const headers = sanitizeResponseHeaders(Object.fromEntries(res.headers.entries()));
348
+ const compressed = await maybeCompressResponse({
349
+ buf,
350
+ contentType: res.headers.get("content-type") || "",
351
+ contentEncoding: res.headers.get("content-encoding") || "",
352
+ acceptEncoding: headerValue(reqHeaders, "accept-encoding"),
353
+ status: res.status,
354
+ method
355
+ });
356
+ if (compressed) {
357
+ 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)}% 减小)`);
358
+ buf = compressed.buf;
359
+ Object.assign(headers, compressed.headers);
360
+ }
361
+ return {
362
+ status: res.status,
363
+ headers,
364
+ body: buf.toString("base64"),
365
+ bodyBase64: true
366
+ };
367
+ }
368
+
369
+ export async function handleHttpFrame(dchOrSend, frame) {
370
+ const send = toSender(dchOrSend);
371
+ const { id, method = "GET", path = "/", headers = {}, body, bodyBase64: isB64 } = frame;
372
+ const t0 = Date.now();
373
+ try {
374
+ const reply = await doHttp(method, path, headers, body, !!isB64);
375
+ reply.id = id;
376
+ reply.type = "http";
377
+ send(reply);
378
+ console.log(`[bridge] ${method} ${path} → ${reply.status} (${Date.now() - t0}ms, ${(reply.body.length * 3 / 4 / 1024).toFixed(0)}KB)`);
379
+ } catch (e) {
380
+ console.log(`[bridge] ${method} ${path} 上游错误: ${e.message}`);
381
+ 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 });
382
+ }
383
+ }
384
+
385
+ /** 旧协议(无 type):body 为原始文本,回包 body 为原始文本。 */
386
+ export async function handleLegacyFrame(dchOrSend, frame) {
387
+ const send = toSender(dchOrSend);
388
+ const { id, method = "GET", path = "/", body } = frame;
389
+ const t0 = Date.now();
390
+ try {
391
+ const reply = await doHttp(method, path, { "content-type": "application/json" }, body, false);
392
+ // 旧协议:body 转回文本(兼容 index.html 控制台)
393
+ const text = Buffer.from(reply.body, "base64").toString("utf8");
394
+ send({ id, status: reply.status, headers: reply.headers, body: text });
395
+ console.log(`[bridge] legacy ${method} ${path} → ${reply.status} (${Date.now() - t0}ms)`);
396
+ } catch (e) {
397
+ console.log(`[bridge] legacy ${method} ${path} 上游错误: ${e.message}`);
398
+ send({ id, status: 502, headers: {}, body: JSON.stringify({ error: String(e.message || e) }) });
399
+ }
400
+ }
401
+
402
+ // ---- WebSocket 透传 ----
403
+
404
+ // ws 会话表:frame id → ws 客户端。DataChannel 断开时统一关闭。
405
+ const wsSessions = new Map();
406
+
407
+ export async function handleWsOpen(dchOrSend, frame) {
408
+ const send = toSender(dchOrSend);
409
+ const { id, path = "/", headers = {} } = frame;
410
+ if (wsSessions.has(id)) { try { wsSessions.get(id).ws.terminate(); } catch {} wsSessions.delete(id); }
411
+ const safe = safePath(path);
412
+ if (safe === null) { send({ id, type: "ws-open", ok: false, code: 400, reason: "非法路径" }); return; }
413
+ const url = `${UPSTREAM.replace(/^http/, "ws")}${safe}`;
414
+ const ws = new WebSocket(url, { headers: buildWsHeaders(headers), followRedirects: false });
415
+ const session = { ws, opened: false };
416
+ wsSessions.set(id, session);
417
+ ws.on("open", () => {
418
+ session.opened = true;
419
+ console.log(`[bridge] ws-open ${path} (id=${id})`);
420
+ send({ id, type: "ws-open", ok: true });
421
+ });
422
+ ws.on("message", (data, isBinary) => {
423
+ const payload = isBinary ? Buffer.from(data).toString("base64") : data.toString();
424
+ send({ id, type: "ws-msg", data: payload, binary: isBinary });
425
+ });
426
+ ws.on("close", (code, reason) => {
427
+ if (!session.opened) return; // 未建立成功的会话由 error 路径收尾
428
+ console.log(`[bridge] ws-close ${path} (id=${id}, code=${code})`);
429
+ wsSessions.delete(id);
430
+ send({ id, type: "ws-close", code: code ?? 1006, reason: reason?.toString() ?? "" });
431
+ });
432
+ ws.on("error", (e) => {
433
+ console.log(`[bridge] ws-error ${path} (id=${id}): ${e.message || ""}`);
434
+ if (!session.opened) {
435
+ wsSessions.delete(id);
436
+ send({ id, type: "ws-open", ok: false, code: 502, reason: String(e.message || "ws error") });
437
+ }
438
+ });
439
+ }
440
+
441
+ export function handleWsMessage(_dchOrSend, frame) {
442
+ const { id, data, binary } = frame;
443
+ const session = wsSessions.get(id);
444
+ if (!session || !session.opened || session.ws.readyState !== WebSocket.OPEN) return;
445
+ try {
446
+ if (binary) session.ws.send(Buffer.from(data, "base64"));
447
+ else session.ws.send(String(data));
448
+ } catch (e) { console.log(`[bridge] ws-send err: ${e.message}`); }
449
+ }
450
+
451
+ export function handleWsClose(_dchOrSend, frame) {
452
+ const { id, code, reason } = frame;
453
+ const session = wsSessions.get(id);
454
+ if (!session) return;
455
+ try { session.ws.close(code && typeof code === "number" ? code : 1000, reason || ""); } catch {}
456
+ }
457
+
458
+ /** DataChannel 断开:关闭所有 ws 会话。 */
459
+ export function closeAllWsSessions() {
460
+ for (const session of wsSessions.values()) {
461
+ try { session.ws.terminate(); } catch {}
462
+ }
463
+ wsSessions.clear();
464
+ }
465
+
466
+ // ============================================================
467
+ // 认证(SaaS: device-login; 开源自部署: 本地访问密钥 /_login)
468
+ // ============================================================
469
+
470
+ /** 本地认证(自部署):用访问密钥向 router POST /_login 换本地 JWT。 */
471
+ async function resolveLocalToken() {
472
+ const key = process.env.DSH_BRIDGE_LOCAL_KEY || "";
473
+ if (!key || !TUNNEL_URL) return "";
474
+ try {
475
+ // 从隧道地址推导同源 HTTP 入口:wss://host:port → https://host:port
476
+ const u = new URL(TUNNEL_URL);
477
+ u.protocol = u.protocol === "wss:" ? "https:" : "http:";
478
+ u.pathname = "/_login";
479
+ const r = await fetch(u.toString(), {
480
+ method: "POST",
481
+ headers: { "content-type": "application/json" },
482
+ body: JSON.stringify({ key })
483
+ });
484
+ const d = await r.json();
485
+ if (r.status === 200 && d.token) {
486
+ console.log("[bridge] 本地认证成功(开源自部署),已获取 JWT");
487
+ return d.token;
488
+ }
489
+ console.error(`[bridge] 本地认证失败(${r.status}): ${d.error?.message || "未知错误"}(请检查 DSH_BRIDGE_LOCAL_KEY)`);
490
+ process.exit(1);
491
+ } catch (e) {
492
+ console.error(`[bridge] 无法连接本地认证 ${u?.toString?.() || TUNNEL_URL}: ${e.message}`);
493
+ process.exit(1);
494
+ }
495
+ }
496
+
497
+ async function resolveToken(refresh = false) {
498
+ if (TOKEN && !refresh) { console.log("[bridge] 使用 DSH_BRIDGE_TOKEN"); return TOKEN; }
499
+ // 开源自部署:访问密钥优先(不依赖闭源 enterprise 账号体系)
500
+ if (process.env.DSH_BRIDGE_LOCAL_KEY && !refresh) return resolveLocalToken();
501
+ if (PHONE && PASSWORD) {
502
+ console.log(`[bridge] 用账号 ${PHONE} 登录换取 JWT...`);
503
+ try {
504
+ // 设备登录:登录接口已加图形验证码(bridge 无法人工输验证码),走免验证码的 device-login
505
+ const r = await fetch(API_BASE + "/api/device-login", {
506
+ method: "POST",
507
+ headers: { "content-type": "application/json", ...(process.env.DSH_BRIDGE_SECRET ? { "x-dsh-bridge-secret": process.env.DSH_BRIDGE_SECRET } : {}) },
508
+ body: JSON.stringify({ phone: PHONE, email: PHONE, password: PASSWORD })
509
+ });
510
+ const d = await r.json();
511
+ if (r.status === 200 && d.token) {
512
+ console.log("[bridge] 登录成功,已获取 JWT");
513
+ return d.token;
514
+ }
515
+ console.error(`[bridge] 登录失败(${r.status}): ${d.error?.message || "未知错误"}`);
516
+ console.error("[bridge] 请检查 DSH_BRIDGE_EMAIL / DSH_BRIDGE_PASSWORD,或直接设 DSH_BRIDGE_TOKEN");
517
+ process.exit(1);
518
+ } catch (e) {
519
+ console.error(`[bridge] 无法连接账号 API ${API_BASE}: ${e.message}`);
520
+ process.exit(1);
521
+ }
522
+ }
523
+ if (TOKEN) {
524
+ console.error("[bridge] DSH_BRIDGE_TOKEN 已失效且无账号密码可刷新,请更换 token 后重启");
525
+ process.exit(1);
526
+ }
527
+ console.error("[bridge] 无认证配置:请设 DSH_BRIDGE_TOKEN / 手机号+密码,或开源自部署的 DSH_BRIDGE_LOCAL_KEY");
528
+ process.exit(1);
529
+ }
530
+
531
+ // ============================================================
532
+ // 隧道模式:bridge 主动连 relay-router(多设备主用)
533
+ // ============================================================
534
+
535
+ let tunnelRetry = 0;
536
+
537
+ /** 把 DSH_BRIDGE_TUNNEL_URL 归一化为 router 注册端点(缺省补 / _bridge)。 */
538
+ function tunnelEndpoint(raw) {
539
+ try {
540
+ const u = new URL(raw);
541
+ const p = u.pathname.replace(/\/+$/, "");
542
+ u.pathname = p === "" || p === "/" ? "/_bridge" : p;
543
+ return u.toString();
544
+ } catch {
545
+ return raw + "/_bridge";
546
+ }
547
+ }
548
+
549
+ /** 账号设备表登记:手机端 /api/devices 才能看到本设备(带稳定 device_id)。自建模式无账号体系,跳过。 */
550
+ async function registerDeviceInAccount(token) {
551
+ if (process.env.DSH_BRIDGE_LOCAL_KEY) return; // 自建模式:设备列表来自 router 实时 WS 注册表
552
+ const pubKey = resolveDevicePubKey();
553
+ try {
554
+ const r = await fetch(API_BASE + "/api/devices", {
555
+ method: "POST",
556
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
557
+ body: JSON.stringify({ device_id: DEVICE_ID, device_name: os.hostname() || "dsh-bridge", pub_key: pubKey })
558
+ });
559
+ const d = await r.json().catch(() => ({}));
560
+ if (r.status === 201 || r.status === 200) {
561
+ console.log(`[bridge] ✅ 设备已登记到账号: ${DEVICE_ID}`);
562
+ return;
563
+ }
564
+ if (r.status === 409) {
565
+ const code = d.error?.code || "";
566
+ if (code === "device_limit_exceeded") {
567
+ console.error(`[bridge] 设备数已达上限: ${d.error?.message || "当前套餐最多绑定 1 台设备"}`);
568
+ console.error(" 请在手机端设备管理或后台移除旧设备后重启。");
569
+ } else {
570
+ console.error(`[bridge] 设备 ${DEVICE_ID} 绑定失败(${code || 409}): ${d.error?.message || "未知错误"}`);
571
+ }
572
+ process.exit(1);
573
+ }
574
+ console.warn(`[bridge] 设备登记失败(${r.status}): ${d.error?.message || "未知错误"}(手机端设备列表可能看不到本设备)`);
575
+ } catch (e) {
576
+ console.warn(`[bridge] 无法连接账号 API ${API_BASE}: ${e.message}(手机端设备列表可能看不到本设备)`);
577
+ }
578
+ }
579
+
580
+ function connectTunnel(token) {
581
+ const endpoint = tunnelEndpoint(TUNNEL_URL);
582
+ const ws = new WebSocket(endpoint, { followRedirects: false });
583
+ const send = makeChunkedSender((s) => { if (ws.readyState === WebSocket.OPEN) ws.send(s); });
584
+ let heartbeat;
585
+ let pongReceived = true;
586
+
587
+ ws.on("open", () => {
588
+ tunnelRetry = 0;
589
+ console.log(`[bridge] 隧道已连 ${endpoint},注册 ${DEVICE_ID}...`);
590
+ try {
591
+ ws.send(JSON.stringify({ type: "tunnel-register", deviceId: DEVICE_ID, token, name: os.hostname() || "dsh-bridge" }));
592
+ } catch (e) { console.log(`[bridge] 注册发送失败: ${e.message}`); }
593
+ heartbeat = setInterval(() => {
594
+ if (!pongReceived) {
595
+ console.log("[bridge] 隧道心跳超时,主动重连...");
596
+ ws.terminate();
597
+ return;
598
+ }
599
+ pongReceived = false;
600
+ try { ws.ping(); } catch { ws.terminate(); }
601
+ }, TUNNEL_HEARTBEAT_MS);
602
+ });
603
+ ws.on("pong", () => { pongReceived = true; });
604
+ ws.on("message", (raw) => {
605
+ const receive = makeFrameReceiver();
606
+ try {
607
+ receive(raw, (frame) => {
608
+ if (frame?.type === "tunnel-register-ok") {
609
+ console.log(`[bridge] ✅ router 注册成功: ${DEVICE_ID},等待手机访问 /remote/${DEVICE_ID}/`);
610
+ return;
611
+ }
612
+ if (frame?.type === "tunnel-register-err") {
613
+ console.error(`[bridge] router 拒绝注册: ${frame.code} ${frame.message || ""}`);
614
+ return;
615
+ }
616
+ handleFrame(send, frame);
617
+ });
618
+ } catch (e) { console.log(`[bridge] 隧道帧错误: ${e.message}`); }
619
+ });
620
+ ws.on("close", (code, reason) => {
621
+ clearInterval(heartbeat);
622
+ console.log(`[bridge] 隧道断开(code=${code}${reason ? ", " + reason : ""})`);
623
+ closeAllWsSessions();
624
+ const delay = Math.min(30_000, 2_000 * 2 ** tunnelRetry);
625
+ tunnelRetry += 1;
626
+ console.log(`[bridge] ${Math.round(delay / 1000)}s 后重连...`);
627
+ setTimeout(async () => connectTunnel(code === 4003 ? await resolveToken(true) : token), delay);
628
+ });
629
+ ws.on("error", (e) => console.log(`[bridge] 隧道错误: ${e.message || ""}`));
630
+ }
631
+
632
+ async function runTunnel() {
633
+ const token = await resolveToken();
634
+ if (!token) {
635
+ console.error("[bridge] 隧道模式需要账号认证:请设 DSH_BRIDGE_TOKEN,或 DSH_BRIDGE_PHONE+DSH_BRIDGE_PASSWORD");
636
+ process.exit(1);
637
+ }
638
+ await registerDeviceInAccount(token);
639
+ connectTunnel(token);
640
+ setTimeout(() => {
641
+ console.log(`[bridge] 隧道模式运行中(上游 ${UPSTREAM},Ctrl-C 退出)`);
642
+ }, 1000);
643
+ }
644
+
645
+ async function main() {
646
+ // 隧道模式是唯一模式(WebRTC/信令已废弃删除)
647
+ if (!TUNNEL_URL) {
648
+ console.error("[bridge] 缺少 DSH_BRIDGE_TUNNEL_URL:隧道模式是唯一模式(请设 relay-router 地址)");
649
+ process.exit(1);
650
+ }
651
+ return runTunnel();
652
+ }
653
+
654
+ // 直接运行(node dsh-bridge.mjs)时启动服务;被测试 import 时只导出协议函数。
655
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
656
+ if (isMain) {
657
+ main();
658
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@dsh-remote/client",
3
+ "version": "0.2.0",
4
+ "description": "dsh-remote bridge: tunnel-mode WebSocket client to relay-router (WebRTC/P2P removed)",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "dependencies": {
8
+ "ws": "^8.18.0"
9
+ },
10
+ "license": "PolyForm-Noncommercial-1.0.0"
11
+ }
@@ -0,0 +1,3 @@
1
+ export function childStopped(child) {
2
+ return !child || child.exitCode !== null || child.signalCode !== null;
3
+ }